diff --git a/.clang-tidy b/.clang-tidy index a444957a0e..e12c73cc56 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -1,160 +1,146 @@ --- Checks: "-*, - bugprone-argument-comment, - bugprone-assert-side-effect, - bugprone-bad-signal-to-kill-thread, - bugprone-bool-pointer-implicit-conversion, - bugprone-capturing-this-in-member-variable, - bugprone-casting-through-void, - bugprone-chained-comparison, - bugprone-compare-pointer-to-member-virtual-function, - bugprone-copy-constructor-init, - bugprone-crtp-constructor-accessibility, - bugprone-dangling-handle, - bugprone-dynamic-static-initializers, - bugprone-empty-catch, - bugprone-fold-init-type, - bugprone-forward-declaration-namespace, - bugprone-inaccurate-erase, - bugprone-inc-dec-in-conditions, - bugprone-incorrect-enable-if, - bugprone-incorrect-roundings, - bugprone-infinite-loop, - bugprone-integer-division, - bugprone-lambda-function-name, - bugprone-macro-parentheses, - bugprone-macro-repeated-side-effects, - bugprone-misleading-setter-of-reference, - bugprone-misplaced-operator-in-strlen-in-alloc, - bugprone-misplaced-pointer-arithmetic-in-alloc, - bugprone-misplaced-widening-cast, - bugprone-move-forwarding-reference, - bugprone-multi-level-implicit-pointer-conversion, - bugprone-multiple-new-in-one-expression, - bugprone-multiple-statement-macro, - bugprone-no-escape, - bugprone-non-zero-enum-to-bool-conversion, - bugprone-optional-value-conversion, - bugprone-parent-virtual-call, - bugprone-pointer-arithmetic-on-polymorphic-object, - bugprone-posix-return, - bugprone-redundant-branch-condition, - bugprone-reserved-identifier, - bugprone-return-const-ref-from-parameter, - bugprone-shared-ptr-array-mismatch, - bugprone-signal-handler, - bugprone-signed-char-misuse, - bugprone-sizeof-container, - bugprone-sizeof-expression, - bugprone-spuriously-wake-up-functions, - bugprone-standalone-empty, - bugprone-string-constructor, - bugprone-string-integer-assignment, - bugprone-string-literal-with-embedded-nul, - bugprone-stringview-nullptr, - bugprone-suspicious-enum-usage, - bugprone-suspicious-include, - bugprone-suspicious-memory-comparison, - bugprone-suspicious-memset-usage, - bugprone-suspicious-missing-comma, - bugprone-suspicious-realloc-usage, - bugprone-suspicious-semicolon, - bugprone-suspicious-string-compare, - bugprone-suspicious-stringview-data-usage, - bugprone-swapped-arguments, - bugprone-switch-missing-default-case, - bugprone-terminating-continue, - bugprone-throw-keyword-missing, - bugprone-too-small-loop-variable, - bugprone-unchecked-optional-access, - bugprone-undefined-memory-manipulation, - bugprone-undelegated-constructor, - bugprone-unhandled-exception-at-new, - bugprone-unhandled-self-assignment, - bugprone-unique-ptr-array-mismatch, - bugprone-unsafe-functions, - bugprone-unused-local-non-trivial-variable, - bugprone-unused-raii, - bugprone-unused-return-value, - bugprone-use-after-move, - bugprone-virtual-near-miss, - cppcoreguidelines-init-variables, - cppcoreguidelines-misleading-capture-default-by-value, - cppcoreguidelines-no-suspend-with-lock, - cppcoreguidelines-pro-type-member-init, - cppcoreguidelines-pro-type-static-cast-downcast, - cppcoreguidelines-rvalue-reference-param-not-moved, - cppcoreguidelines-use-default-member-init, - cppcoreguidelines-use-enum-class, - cppcoreguidelines-virtual-class-destructor, - hicpp-ignored-remove-result, + bugprone-*, + -bugprone-easily-swappable-parameters, + -bugprone-exception-escape, + -bugprone-implicit-widening-of-multiplication-result, + -bugprone-narrowing-conversions, + -bugprone-throwing-static-initialization, + + cppcoreguidelines-*, + -cppcoreguidelines-avoid-c-arrays, + -cppcoreguidelines-avoid-capturing-lambda-coroutines, + -cppcoreguidelines-avoid-const-or-ref-data-members, + -cppcoreguidelines-avoid-do-while, + -cppcoreguidelines-avoid-goto, + -cppcoreguidelines-avoid-magic-numbers, + -cppcoreguidelines-avoid-non-const-global-variables, + -cppcoreguidelines-avoid-reference-coroutine-parameters, + -cppcoreguidelines-c-copy-assignment-signature, + -cppcoreguidelines-explicit-virtual-functions, + -cppcoreguidelines-interfaces-global-init, + -cppcoreguidelines-macro-to-enum, + -cppcoreguidelines-macro-usage, + -cppcoreguidelines-missing-std-forward, + -cppcoreguidelines-narrowing-conversions, + -cppcoreguidelines-no-malloc, + -cppcoreguidelines-noexcept-destructor, + -cppcoreguidelines-noexcept-move-operations, + -cppcoreguidelines-noexcept-swap, + -cppcoreguidelines-non-private-member-variables-in-classes, + -cppcoreguidelines-owning-memory, + -cppcoreguidelines-prefer-member-initializer, + -cppcoreguidelines-pro-bounds-array-to-pointer-decay, + -cppcoreguidelines-pro-bounds-avoid-unchecked-container-access, + -cppcoreguidelines-pro-bounds-constant-array-index, + -cppcoreguidelines-pro-bounds-pointer-arithmetic, + -cppcoreguidelines-pro-type-const-cast, + -cppcoreguidelines-pro-type-cstyle-cast, + -cppcoreguidelines-pro-type-reinterpret-cast, + -cppcoreguidelines-pro-type-union-access, + -cppcoreguidelines-pro-type-vararg, + -cppcoreguidelines-slicing, + -cppcoreguidelines-special-member-functions, + llvm-namespace-comment, - misc-const-correctness, - misc-definitions-in-headers, - misc-header-include-cycle, - misc-include-cleaner, - misc-misplaced-const, - misc-redundant-expression, - misc-static-assert, - misc-throw-by-value-catch-by-reference, - misc-unused-alias-decls, - misc-unused-using-decls, - modernize-concat-nested-namespaces, - modernize-deprecated-headers, - modernize-make-shared, - modernize-make-unique, - modernize-pass-by-value, - modernize-type-traits, - modernize-use-designated-initializers, - modernize-use-emplace, - modernize-use-equals-default, - modernize-use-equals-delete, - modernize-use-nodiscard, - modernize-use-override, - modernize-use-ranges, - modernize-use-scoped-lock, - modernize-use-starts-ends-with, - modernize-use-std-numbers, - modernize-use-using, - performance-faster-string-find, - performance-for-range-copy, - performance-implicit-conversion-in-loop, - performance-inefficient-vector-operation, - performance-move-const-arg, - performance-move-constructor-init, - performance-no-automatic-move, - performance-trivially-destructible, - readability-ambiguous-smartptr-reset-call, - readability-avoid-nested-conditional-operator, - readability-avoid-return-with-void-value, - readability-braces-around-statements, - readability-const-return-type, - readability-container-contains, - readability-container-size-empty, - readability-convert-member-functions-to-static, - readability-duplicate-include, - readability-else-after-return, - readability-enum-initial-value, - readability-identifier-naming, - readability-implicit-bool-conversion, - readability-make-member-function-const, - readability-math-missing-parentheses, - readability-misleading-indentation, - readability-non-const-parameter, - readability-redundant-casting, - readability-redundant-declaration, - readability-redundant-inline-specifier, - readability-redundant-member-init, - readability-redundant-string-init, - readability-reference-to-constructed-temporary, - readability-simplify-boolean-expr, - readability-static-definition-in-anonymous-namespace, - readability-suspicious-call-argument, - readability-use-std-min-max + + misc-*, + -misc-anonymous-namespace-in-header, + -misc-confusable-identifiers, + -misc-coroutine-hostile-raii, + -misc-misleading-bidirectional, + -misc-misleading-identifier, + -misc-multiple-inheritance, + -misc-new-delete-overloads, + -misc-no-recursion, + -misc-non-copyable-objects, + -misc-non-private-member-variables-in-classes, + -misc-override-with-different-visibility, + -misc-predictable-rand, + -misc-unconventional-assign-operator, + -misc-uniqueptr-reset-release, + -misc-unused-parameters, + -misc-use-anonymous-namespace, + -misc-use-internal-linkage, + + modernize-*, + -modernize-avoid-bind, + -modernize-avoid-c-arrays, + -modernize-avoid-c-style-cast, + -modernize-avoid-setjmp-longjmp, + -modernize-avoid-variadic-functions, + -modernize-deprecated-ios-base-aliases, + -modernize-loop-convert, + -modernize-macro-to-enum, + -modernize-min-max-use-initializer-list, + -modernize-raw-string-literal, + -modernize-redundant-void-arg, + -modernize-replace-auto-ptr, + -modernize-replace-disallow-copy-and-assign-macro, + -modernize-replace-random-shuffle, + -modernize-return-braced-init-list, + -modernize-shrink-to-fit, + -modernize-unary-static-assert, + -modernize-use-auto, + -modernize-use-bool-literals, + -modernize-use-constraints, + -modernize-use-default-member-init, + -modernize-use-integer-sign-comparison, + -modernize-use-noexcept, + -modernize-use-nullptr, + -modernize-use-std-format, + -modernize-use-std-print, + -modernize-use-trailing-return-type, + -modernize-use-transparent-functors, + -modernize-use-uncaught-exceptions, + + performance-*, + -performance-avoid-endl, + -performance-enum-size, + -performance-inefficient-algorithm, + -performance-inefficient-string-concatenation, + -performance-no-int-to-ptr, + -performance-noexcept-destructor, + -performance-noexcept-move-constructor, + -performance-noexcept-swap, + -performance-type-promotion-in-math-fn, + -performance-unnecessary-copy-initialization, + -performance-unnecessary-value-param, + + readability-*, + -readability-avoid-const-params-in-decls, + -readability-avoid-unconditional-preprocessor-if, + -readability-container-data-pointer, + -readability-delete-null-pointer, + -readability-function-cognitive-complexity, + -readability-function-size, + -readability-identifier-length, + -readability-inconsistent-declaration-parameter-name, + -readability-isolate-declaration, + -readability-magic-numbers, + -readability-misplaced-array-index, + -readability-named-parameter, + -readability-operators-representation, + -readability-qualified-auto, + -readability-redundant-access-specifiers, + -readability-redundant-control-flow, + -readability-redundant-function-ptr-dereference, + -readability-redundant-preprocessor, + -readability-redundant-smartptr-get, + -readability-redundant-string-cstr, + -readability-simplify-subscript-expr, + -readability-static-accessed-through-instance, + -readability-string-compare, + -readability-uniqueptr-delete-release, + -readability-uppercase-literal-suffix, + -readability-use-anyofallof, + -readability-use-concise-preprocessor-directives " # --- -# readability-inconsistent-declaration-parameter-name, # in this codebase this check will break a lot of arg names -# readability-static-accessed-through-instance, # this check is probably unnecessary. it makes the code less readable +# bugprone-narrowing-conversions, # This will break a lot of code but we should enable it in the future because it can eliminate a lot of bugs +# misc-override-with-different-visibility, # Will be addressed in a future PR, but for now it generates too many warnings +# readability-inconsistent-declaration-parameter-name, # In this codebase this check will break a lot of arg names +# readability-static-accessed-through-instance, # this check is probably unnecessary. It makes the code less readable # --- CheckOptions: @@ -191,11 +177,14 @@ CheckOptions: readability-identifier-naming.ParameterCase: camelBack readability-identifier-naming.FunctionCase: camelBack readability-identifier-naming.MemberCase: camelBack + readability-identifier-naming.PrivateMemberCase: camelBack readability-identifier-naming.PrivateMemberSuffix: _ + readability-identifier-naming.ProtectedMemberCase: camelBack readability-identifier-naming.ProtectedMemberSuffix: _ + readability-identifier-naming.PublicMemberCase: camelBack readability-identifier-naming.PublicMemberSuffix: "" readability-identifier-naming.GlobalFunctionIgnoredRegexp: "^(to_string|hash_append|tuple_hash)$" -HeaderFilterRegex: '^.*/(test|xrpl|xrpld)/.*\.(h|hpp|ipp)$' +HeaderFilterRegex: '^.*/(tests?|xrpl|xrpld)/.*\.(h|hpp|ipp)$' ExcludeHeaderFilterRegex: '^.*/protocol_autogen/.*\.(h|hpp)$' WarningsAsErrors: "*" diff --git a/.gersemi/definitions.cmake b/.gersemi/definitions.cmake index a16e330ffa..58bc74c70a 100644 --- a/.gersemi/definitions.cmake +++ b/.gersemi/definitions.cmake @@ -11,9 +11,6 @@ endfunction() function(create_symbolic_link target link) endfunction() -function(xrpl_add_test name) -endfunction() - macro(exclude_from_default target_) endmacro() @@ -99,3 +96,6 @@ function(verbose_find_path variable name) ${ARGN} ) endfunction() + +function(patch_nix_binary target) +endfunction() diff --git a/.github/actions/build-deps/action.yml b/.github/actions/build-deps/action.yml index 9d52be1998..044c264ef0 100644 --- a/.github/actions/build-deps/action.yml +++ b/.github/actions/build-deps/action.yml @@ -35,14 +35,13 @@ runs: LOG_VERBOSITY: ${{ inputs.log_verbosity }} SANITIZERS: ${{ inputs.sanitizers }} run: | - echo 'Installing dependencies.' conan install \ - --profile ci \ - --build="${BUILD_OPTION}" \ - --options:host='&:tests=True' \ - --options:host='&:xrpld=True' \ - --settings:all build_type="${BUILD_TYPE}" \ - --conf:all tools.build:jobs=${BUILD_NPROC} \ - --conf:all tools.build:verbosity="${LOG_VERBOSITY}" \ - --conf:all tools.compilation:verbosity="${LOG_VERBOSITY}" \ - . + --profile:all ci \ + --build="${BUILD_OPTION}" \ + --options:host='&:tests=True' \ + --options:host='&:xrpld=True' \ + --settings:all build_type="${BUILD_TYPE}" \ + --conf:all tools.build:jobs=${BUILD_NPROC} \ + --conf:all tools.build:verbosity="${LOG_VERBOSITY}" \ + --conf:all tools.compilation:verbosity="${LOG_VERBOSITY}" \ + . diff --git a/.github/actions/generate-version/action.yml b/.github/actions/generate-version/action.yml index 8edb7920c6..50b3166596 100644 --- a/.github/actions/generate-version/action.yml +++ b/.github/actions/generate-version/action.yml @@ -15,7 +15,7 @@ runs: shell: bash env: VERSION: ${{ github.ref_name }} - run: echo "VERSION=${VERSION}" >> "${GITHUB_ENV}" + run: echo "VERSION=${VERSION}" >>"${GITHUB_ENV}" # When a tag is not pushed, then the version (e.g. 1.2.3-b0) is extracted # from the BuildInfo.cpp file and the shortened commit hash appended to it. @@ -28,17 +28,17 @@ runs: echo 'Extracting version from BuildInfo.cpp.' VERSION="$(cat src/libxrpl/protocol/BuildInfo.cpp | grep "versionString =" | awk -F '"' '{print $2}')" if [[ -z "${VERSION}" ]]; then - echo 'Unable to extract version from BuildInfo.cpp.' - exit 1 + echo 'Unable to extract version from BuildInfo.cpp.' + exit 1 fi echo 'Appending shortened commit hash to version.' SHA='${{ github.sha }}' VERSION="${VERSION}+${SHA:0:7}" - echo "VERSION=${VERSION}" >> "${GITHUB_ENV}" + echo "VERSION=${VERSION}" >>"${GITHUB_ENV}" - name: Output version id: version shell: bash - run: echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + run: echo "version=${VERSION}" >>"${GITHUB_OUTPUT}" diff --git a/.github/actions/set-compiler-env/action.yml b/.github/actions/set-compiler-env/action.yml new file mode 100644 index 0000000000..a16dde2b30 --- /dev/null +++ b/.github/actions/set-compiler-env/action.yml @@ -0,0 +1,34 @@ +name: Set compiler environment +description: "Set CC and CXX environment variables for the given compiler." + +inputs: + compiler: + description: 'The compiler to use ("gcc" or "clang").' + required: true + +runs: + using: composite + + steps: + - name: Set CC and CXX for gcc + if: ${{ inputs.compiler == 'gcc' }} + shell: bash + run: | + echo "CC=gcc" >>"${GITHUB_ENV}" + echo "CXX=g++" >>"${GITHUB_ENV}" + + - name: Set CC and CXX for clang + if: ${{ inputs.compiler == 'clang' }} + shell: bash + run: | + echo "CC=clang" >>"${GITHUB_ENV}" + echo "CXX=clang++" >>"${GITHUB_ENV}" + + - name: Fail on unknown compiler + if: ${{ inputs.compiler != 'gcc' && inputs.compiler != 'clang' }} + shell: bash + env: + COMPILER: ${{ inputs.compiler }} + run: | + echo "Unknown compiler: $COMPILER" >&2 + exit 1 diff --git a/.github/actions/setup-conan/action.yml b/.github/actions/setup-conan/action.yml index 9d834884d2..e8a548cfce 100644 --- a/.github/actions/setup-conan/action.yml +++ b/.github/actions/setup-conan/action.yml @@ -9,38 +9,41 @@ inputs: remote_url: description: "The URL of the Conan endpoint to use." required: false - default: https://conan.ripplex.io + default: https://conan.xrplf.org/repository/conan/ runs: using: composite steps: - - name: Set up Conan configuration + - name: Apply custom configuration to global.conf shell: bash run: | - echo 'Installing configuration.' cat conan/global.conf ${{ runner.os == 'Linux' && '>>' || '>' }} $(conan config home)/global.conf - echo 'Conan configuration:' - conan config show '*' - - - name: Set up Conan profile + - name: Show global configuration + shell: bash + run: | + conan config show '*' + + - name: Install profiles shell: bash run: | - echo 'Installing profile.' conan config install conan/profiles/ -tf $(conan config home)/profiles/ - echo 'Conan profile:' + - name: Show CI profile + shell: bash + run: | conan profile show --profile ci - - name: Set up Conan remote + - name: Add a remote shell: bash env: REMOTE_NAME: ${{ inputs.remote_name }} REMOTE_URL: ${{ inputs.remote_url }} run: | - echo "Adding Conan remote '${REMOTE_NAME}' at '${REMOTE_URL}'." conan remote add --index 0 --force "${REMOTE_NAME}" "${REMOTE_URL}" - echo 'Listing Conan remotes.' + - name: List remotes + shell: bash + run: | conan remote list diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0e6b840fe7..da7a30dc77 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,40 +1,12 @@ version: 2 updates: - package-ecosystem: github-actions - directory: / - schedule: - interval: weekly - day: monday - time: "04:00" - timezone: Etc/GMT - commit-message: - prefix: "ci: [DEPENDABOT] " - target-branch: develop - - - package-ecosystem: github-actions - directory: .github/actions/build-deps/ - schedule: - interval: weekly - day: monday - time: "04:00" - timezone: Etc/GMT - commit-message: - prefix: "ci: [DEPENDABOT] " - target-branch: develop - - - package-ecosystem: github-actions - directory: .github/actions/generate-version/ - schedule: - interval: weekly - day: monday - time: "04:00" - timezone: Etc/GMT - commit-message: - prefix: "ci: [DEPENDABOT] " - target-branch: develop - - - package-ecosystem: github-actions - directory: .github/actions/setup-conan/ + directories: + - / + - .github/actions/build-deps/ + - .github/actions/generate-version/ + - .github/actions/set-compiler-env/ + - .github/actions/setup-conan/ schedule: interval: weekly day: monday diff --git a/.github/scripts/format-inline-bash.py b/.github/scripts/format-inline-bash.py new file mode 100755 index 0000000000..423c78109c --- /dev/null +++ b/.github/scripts/format-inline-bash.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 + +""" +Format embedded shell snippets using the shfmt hook configured in +.pre-commit-config.yaml. + +Two shapes are recognised: + +* YAML workflow/action files: literal block-scalar runs (`run: |`) and + single-line runs (`run: some command`). A single-line run is upgraded to + a `run: |` block scalar if shfmt's output spans multiple lines. + +* Markdown files: ``` ```bash ``` fenced code blocks. + +Any block that shfmt cannot parse is skipped with a warning on stderr, so +the file is left untouched and surrounding blocks still get formatted. + +For each occurrence the body is dedented, written to a temp .sh file, +formatted via `pre-commit run shfmt --files ` (falling back to +`prek`), then re-indented and written back in place. + +When invoked without arguments, every .yml/.yaml under .github/ plus every +.md file in the repo is scanned. When invoked with file arguments (the +pre-commit case), only those files are processed. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Union + +REPO = Path(__file__).resolve().parents[2] + +_HOOK_RUNNER = next((cmd for cmd in ("pre-commit", "prek") if shutil.which(cmd)), None) +if _HOOK_RUNNER is None: + sys.exit("error: neither `pre-commit` nor `prek` found on PATH") + +RUN_BLOCK_RE = re.compile(r"^(?P[ \t]*(?:- )?)run:[ \t]*\|[+-]?[ \t]*$") +RUN_INLINE_RE = re.compile( + r"^(?P[ \t]*(?:- )?)run:[ \t]+" r"(?P(?!\|[+-]?[ \t]*$)\S.*?)[ \t]*$" +) +MD_BASH_OPEN_RE = re.compile(r"^(?P[ ]{0,3})`{3}bash[ \t]*$") +MD_FENCE_CLOSE_RE = re.compile(r"^[ ]{0,3}`{3,}[ \t]*$") + + +@dataclass(frozen=True) +class BlockRun: + """A `run: |` block scalar; `body_start:body_end` slices into `lines`.""" + + body_start: int + body_end: int + body_indent: int + + +@dataclass(frozen=True) +class InlineRun: + """A single-line `run: value` at `line_idx`.""" + + line_idx: int + prefix: str + value: str + + +@dataclass(frozen=True) +class MdBashBlock: + """A markdown ``` ```bash ``` fenced code block. + + `body_start:body_end` slices into the file's lines; `open_line_idx` + points at the opening fence line. + """ + + open_line_idx: int + body_start: int + body_end: int + body_indent: int + + +RunItem = Union[BlockRun, InlineRun] + + +def _scan_block_body( + lines: list[str], body_start: int, run_col: int +) -> tuple[int | None, int]: + """Locate the body of a `run: |` block scalar starting at `body_start`. + + Returns `(body_indent, scan_end)`. `scan_end` is the line index where the + outer scanner should resume. `body_indent` is `None` when no body is + present (the scalar is empty, or the next non-blank line has indent + `<= run_col`). + """ + body_indent: int | None = None + scan_end = len(lines) + for idx in range(body_start, len(lines)): + line = lines[idx] + if line.strip() == "": + continue + indent = len(line) - len(line.lstrip(" ")) + if body_indent is None: + if indent > run_col: + body_indent = indent + else: + scan_end = idx + break + elif indent < body_indent: + scan_end = idx + break + if body_indent is not None: + while scan_end > body_start and lines[scan_end - 1].strip() == "": + scan_end -= 1 + if scan_end <= body_start: + body_indent = None + return body_indent, scan_end + + +def find_run_blocks(lines: list[str]) -> list[RunItem]: + """Return run items in document order.""" + items: list[RunItem] = [] + line_idx = 0 + while line_idx < len(lines): + line = lines[line_idx] + if block_match := RUN_BLOCK_RE.match(line): + run_col = len(block_match.group("prefix")) + body_start = line_idx + 1 + body_indent, scan_end = _scan_block_body(lines, body_start, run_col) + if body_indent is not None: + items.append( + BlockRun( + body_start=body_start, + body_end=scan_end, + body_indent=body_indent, + ) + ) + line_idx = scan_end + continue + if inline_match := RUN_INLINE_RE.match(line): + items.append( + InlineRun( + line_idx=line_idx, + prefix=inline_match.group("prefix"), + value=inline_match.group("value"), + ) + ) + line_idx += 1 + return items + + +def find_md_bash_blocks(lines: list[str]) -> list[MdBashBlock]: + """Return ``` ```bash ``` fenced code blocks in document order.""" + blocks: list[MdBashBlock] = [] + line_idx = 0 + while line_idx < len(lines): + open_match = MD_BASH_OPEN_RE.match(lines[line_idx]) + if not open_match: + line_idx += 1 + continue + body_start = line_idx + 1 + close_idx = next( + ( + j + for j in range(body_start, len(lines)) + if MD_FENCE_CLOSE_RE.match(lines[j]) + ), + None, + ) + if close_idx is None: + line_idx = body_start + continue + body = lines[body_start:close_idx] + non_blank = [b for b in body if b.strip()] + body_indent = ( + min(len(b) - len(b.lstrip(" ")) for b in non_blank) + if non_blank + else len(open_match.group("indent")) + ) + blocks.append( + MdBashBlock( + open_line_idx=line_idx, + body_start=body_start, + body_end=close_idx, + body_indent=body_indent, + ) + ) + line_idx = close_idx + 1 + return blocks + + +def dedent(lines: list[str], n: int) -> list[str]: + pad = " " * n + return [ + ( + "" + if line.strip() == "" + else (line[n:] if line.startswith(pad) else line.lstrip(" ")) + ) + for line in lines + ] + + +def reindent(lines: list[str], n: int) -> list[str]: + pad = " " * n + return [pad + line if line else "" for line in lines] + + +_SHFMT_ERR_RE = re.compile(r"\.sh:\d+:\d+:\s") +_GHA_EXPR_RE = re.compile(r"\$\{\{.*?\}\}", re.DOTALL) +_GHA_PLACEHOLDER_RE = re.compile(r"__GHA_EXPR_(\d+)__") + + +def _encode_gha_exprs(text: str) -> tuple[str, list[str]]: + """Replace `${{ ... }}` expressions with bash-safe placeholder identifiers.""" + exprs: list[str] = [] + + def repl(match: re.Match[str]) -> str: + exprs.append(match.group(0)) + return f"__GHA_EXPR_{len(exprs) - 1}__" + + return _GHA_EXPR_RE.sub(repl, text), exprs + + +def _decode_gha_exprs(text: str, exprs: list[str]) -> str: + """Restore `${{ ... }}` expressions from placeholder identifiers.""" + return _GHA_PLACEHOLDER_RE.sub(lambda m: exprs[int(m.group(1))], text) + + +def shfmt_via_hook(tmp_path: Path) -> tuple[bool, str]: + # `${{ ... }}` is not valid shell, so swap it for a placeholder identifier + # that shfmt can parse, then restore it after formatting. + encoded, exprs = _encode_gha_exprs(tmp_path.read_text()) + if exprs: + tmp_path.write_text(encoded) + res = subprocess.run( + [_HOOK_RUNNER, "run", "shfmt", "--files", str(tmp_path)], + cwd=REPO, + capture_output=True, + text=True, + ) + output = res.stdout + res.stderr + # shfmt emits parse errors as "::: ". + parse_err = bool(_SHFMT_ERR_RE.search(output)) + # A non-zero exit that is neither a parse error nor pre-commit's "I had + # to modify files" signal means the hook itself failed to run (missing + # binary, install failure, bad config, ...). Surface that loudly rather + # than silently treating it as a no-op. + if ( + res.returncode != 0 + and not parse_err + and "files were modified by this hook" not in output + ): + sys.exit( + f"error: `{_HOOK_RUNNER} run shfmt` failed with exit {res.returncode}:\n{output}" + ) + if exprs and not parse_err: + tmp_path.write_text(_decode_gha_exprs(tmp_path.read_text(), exprs)) + return not parse_err, output + + +def _skip(path: Path, where: int, kind: str, output: str) -> None: + print( + f" shfmt could not parse {kind} at {path}:{where + 1} — skipped", + file=sys.stderr, + ) + print(f" {output.strip()}", file=sys.stderr) + + +def process_yaml_file(path: Path, tmp_path: Path) -> int: + text = path.read_text() + had_nl = text.endswith("\n") + lines = text.split("\n") + if had_nl: + lines = lines[:-1] + items = find_run_blocks(lines) + if not items: + return 0 + changed = 0 + # Process in reverse so earlier indices remain valid as we splice. + for item in reversed(items): + if isinstance(item, BlockRun): + body = lines[item.body_start : item.body_end] + tmp_path.write_text("\n".join(dedent(body, item.body_indent)) + "\n") + ok, output = shfmt_via_hook(tmp_path) + if not ok: + _skip(path, item.body_start, "block", output) + continue + formatted = tmp_path.read_text().rstrip("\n") + new_body = reindent(formatted.split("\n"), item.body_indent) + if new_body != body: + lines[item.body_start : item.body_end] = new_body + changed += 1 + else: + tmp_path.write_text(item.value + "\n") + ok, output = shfmt_via_hook(tmp_path) + if not ok: + _skip(path, item.line_idx, "inline run", output) + continue + formatted = tmp_path.read_text().rstrip("\n") + if formatted == item.value: + continue + formatted_lines = formatted.split("\n") + if len(formatted_lines) == 1: + lines[item.line_idx] = f"{item.prefix}run: {formatted}" + else: + body_indent = len(item.prefix) + 2 + lines[item.line_idx : item.line_idx + 1] = [ + f"{item.prefix}run: |", + *reindent(formatted_lines, body_indent), + ] + changed += 1 + new_text = "\n".join(lines) + ("\n" if had_nl else "") + if new_text != text: + path.write_text(new_text) + return changed + + +def process_md_file(path: Path, tmp_path: Path) -> int: + text = path.read_text() + had_nl = text.endswith("\n") + lines = text.split("\n") + if had_nl: + lines = lines[:-1] + blocks = find_md_bash_blocks(lines) + if not blocks: + return 0 + changed = 0 + for block in reversed(blocks): + body = lines[block.body_start : block.body_end] + tmp_path.write_text("\n".join(dedent(body, block.body_indent)) + "\n") + ok, output = shfmt_via_hook(tmp_path) + if not ok: + _skip(path, block.open_line_idx, "```bash block", output) + continue + formatted = tmp_path.read_text().rstrip("\n") + formatted_lines = formatted.split("\n") if formatted else [] + new_body = reindent(formatted_lines, block.body_indent) + if new_body != body: + lines[block.body_start : block.body_end] = new_body + changed += 1 + new_text = "\n".join(lines) + ("\n" if had_nl else "") + if new_text != text: + path.write_text(new_text) + return changed + + +def process_file(path: Path, tmp_path: Path) -> int: + if path.suffix in (".yml", ".yaml"): + return process_yaml_file(path, tmp_path) + if path.suffix == ".md": + return process_md_file(path, tmp_path) + return 0 + + +def gather_files(argv: list[str]) -> list[Path]: + """Return YAML workflow/action files and markdown files that we should + process — either the paths in `argv` or, when `argv` is empty, every + such file in the repo (skipping `external/`).""" + if argv: + candidates: list[Path] = [ + (REPO / a).resolve() if not Path(a).is_absolute() else Path(a) for a in argv + ] + else: + gh = REPO / ".github" + candidates = [ + *gh.rglob("*.yml"), + *gh.rglob("*.yaml"), + *( + p + for p in REPO.rglob("*.md") + if "external" not in p.relative_to(REPO).parts + ), + ] + return sorted( + p + for p in candidates + if p.exists() + and ( + (p.suffix in (".yml", ".yaml") and ".github" in p.parts) + or p.suffix == ".md" + ) + ) + + +def main(argv: list[str]) -> int: + files = gather_files(argv) + if not files: + return 0 + with tempfile.TemporaryDirectory(prefix="format-inline-bash-") as tmpdir: + tmp_path = Path(tmpdir) / "shfmt.sh" + total = 0 + for f in files: + n = process_file(f, tmp_path) + if n: + print(f"{f.relative_to(REPO)}: reformatted {n} block(s)") + total += n + return 1 if total else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/scripts/levelization/generate.py b/.github/scripts/levelization/generate.py old mode 100644 new mode 100755 diff --git a/.github/scripts/levelization/results/ordering.txt b/.github/scripts/levelization/results/ordering.txt index c2000d1768..7b31042158 100644 --- a/.github/scripts/levelization/results/ordering.txt +++ b/.github/scripts/levelization/results/ordering.txt @@ -1,6 +1,8 @@ libxrpl.basics > xrpl.basics libxrpl.conditions > xrpl.basics libxrpl.conditions > xrpl.conditions +libxrpl.config > xrpl.basics +libxrpl.config > xrpl.config libxrpl.core > xrpl.basics libxrpl.core > xrpl.core libxrpl.core > xrpl.json @@ -12,11 +14,11 @@ libxrpl.ledger > xrpl.json libxrpl.ledger > xrpl.ledger libxrpl.ledger > xrpl.nodestore libxrpl.ledger > xrpl.protocol -libxrpl.ledger > xrpl.server libxrpl.ledger > xrpl.shamap libxrpl.net > xrpl.basics libxrpl.net > xrpl.net libxrpl.nodestore > xrpl.basics +libxrpl.nodestore > xrpl.config libxrpl.nodestore > xrpl.json libxrpl.nodestore > xrpl.nodestore libxrpl.nodestore > xrpl.protocol @@ -24,6 +26,7 @@ libxrpl.protocol > xrpl.basics libxrpl.protocol > xrpl.json libxrpl.protocol > xrpl.protocol libxrpl.rdb > xrpl.basics +libxrpl.rdb > xrpl.config libxrpl.rdb > xrpl.core libxrpl.rdb > xrpl.rdb libxrpl.resource > xrpl.basics @@ -31,6 +34,7 @@ libxrpl.resource > xrpl.json libxrpl.resource > xrpl.protocol libxrpl.resource > xrpl.resource libxrpl.server > xrpl.basics +libxrpl.server > xrpl.config libxrpl.server > xrpl.core libxrpl.server > xrpl.json libxrpl.server > xrpl.protocol @@ -52,6 +56,7 @@ libxrpl.tx > xrpl.tx test.app > test.jtx test.app > test.unit_test test.app > xrpl.basics +test.app > xrpl.config test.app > xrpl.core test.app > xrpld.app test.app > xrpld.consensus @@ -78,7 +83,6 @@ test.conditions > xrpl.basics test.conditions > xrpl.conditions test.consensus > test.csf test.consensus > test.jtx -test.consensus > test.toplevel test.consensus > test.unit_test test.consensus > xrpl.basics test.consensus > xrpld.app @@ -90,6 +94,7 @@ test.consensus > xrpl.tx test.core > test.jtx test.core > test.unit_test test.core > xrpl.basics +test.core > xrpl.config test.core > xrpl.core test.core > xrpld.core test.core > xrpl.json @@ -104,6 +109,7 @@ test.csf > xrpl.protocol test.json > test.jtx test.json > xrpl.json test.jtx > xrpl.basics +test.jtx > xrpl.config test.jtx > xrpl.core test.jtx > xrpld.app test.jtx > xrpld.core @@ -126,6 +132,7 @@ test.ledger > xrpl.protocol test.nodestore > test.jtx test.nodestore > test.unit_test test.nodestore > xrpl.basics +test.nodestore > xrpl.config test.nodestore > xrpld.core test.nodestore > xrpl.nodestore test.nodestore > xrpl.protocol @@ -133,6 +140,7 @@ test.nodestore > xrpl.rdb test.overlay > test.jtx test.overlay > test.unit_test test.overlay > xrpl.basics +test.overlay > xrpl.config test.overlay > xrpld.app test.overlay > xrpld.core test.overlay > xrpld.overlay @@ -159,6 +167,7 @@ test.resource > xrpl.basics test.resource > xrpl.resource test.rpc > test.jtx test.rpc > xrpl.basics +test.rpc > xrpl.config test.rpc > xrpl.core test.rpc > xrpld.app test.rpc > xrpld.core @@ -173,6 +182,7 @@ test.rpc > xrpl.tx test.server > test.jtx test.server > test.unit_test test.server > xrpl.basics +test.server > xrpl.config test.server > xrpld.app test.server > xrpld.core test.server > xrpl.json @@ -180,6 +190,7 @@ test.server > xrpl.protocol test.server > xrpl.server test.shamap > test.unit_test test.shamap > xrpl.basics +test.shamap > xrpl.config test.shamap > xrpl.nodestore test.shamap > xrpl.protocol test.shamap > xrpl.shamap @@ -188,6 +199,7 @@ test.toplevel > xrpl.json test.unit_test > xrpl.basics test.unit_test > xrpl.protocol tests.libxrpl > xrpl.basics +tests.libxrpl > xrpl.config tests.libxrpl > xrpl.core tests.libxrpl > xrpl.json tests.libxrpl > xrpl.ledger @@ -200,16 +212,17 @@ tests.libxrpl > xrpl.shamap tests.libxrpl > xrpl.tx xrpl.conditions > xrpl.basics xrpl.conditions > xrpl.protocol +xrpl.config > xrpl.basics xrpl.core > xrpl.basics xrpl.core > xrpl.json xrpl.core > xrpl.protocol xrpl.json > xrpl.basics xrpl.ledger > xrpl.basics xrpl.ledger > xrpl.protocol -xrpl.ledger > xrpl.server xrpl.ledger > xrpl.shamap xrpl.net > xrpl.basics xrpl.nodestore > xrpl.basics +xrpl.nodestore > xrpl.config xrpl.nodestore > xrpl.protocol xrpl.protocol > xrpl.basics xrpl.protocol > xrpl.json @@ -237,6 +250,7 @@ xrpl.tx > xrpl.ledger xrpl.tx > xrpl.protocol xrpld.app > test.unit_test xrpld.app > xrpl.basics +xrpld.app > xrpl.config xrpld.app > xrpl.core xrpld.app > xrpld.consensus xrpld.app > xrpld.core @@ -255,11 +269,13 @@ xrpld.consensus > xrpl.json xrpld.consensus > xrpl.ledger xrpld.consensus > xrpl.protocol xrpld.core > xrpl.basics +xrpld.core > xrpl.config xrpld.core > xrpl.core xrpld.core > xrpl.net xrpld.core > xrpl.protocol xrpld.core > xrpl.rdb xrpld.overlay > xrpl.basics +xrpld.overlay > xrpl.config xrpld.overlay > xrpl.core xrpld.overlay > xrpld.consensus xrpld.overlay > xrpld.core @@ -272,15 +288,18 @@ xrpld.overlay > xrpl.server xrpld.overlay > xrpl.shamap xrpld.overlay > xrpl.tx xrpld.peerfinder > xrpl.basics +xrpld.peerfinder > xrpl.config xrpld.peerfinder > xrpld.core xrpld.peerfinder > xrpl.protocol xrpld.peerfinder > xrpl.rdb xrpld.perflog > xrpl.basics +xrpld.perflog > xrpl.config xrpld.perflog > xrpl.core xrpld.perflog > xrpld.rpc xrpld.perflog > xrpl.json xrpld.perflog > xrpl.protocol xrpld.rpc > xrpl.basics +xrpld.rpc > xrpl.config xrpld.rpc > xrpl.core xrpld.rpc > xrpld.core xrpld.rpc > xrpl.json diff --git a/.github/scripts/rename/binary.sh b/.github/scripts/rename/binary.sh index cdce6db4ba..89d884538c 100755 --- a/.github/scripts/rename/binary.sh +++ b/.github/scripts/rename/binary.sh @@ -6,7 +6,7 @@ set -e # On MacOS, ensure that GNU sed is installed and available as `gsed`. SED_COMMAND=sed if [[ "${OSTYPE}" == 'darwin'* ]]; then - if ! command -v gsed &> /dev/null; then + if ! command -v gsed &>/dev/null; then echo "Error: gsed is not installed. Please install it using 'brew install gnu-sed'." exit 1 fi diff --git a/.github/scripts/rename/cmake.sh b/.github/scripts/rename/cmake.sh index 9c91e8f277..3539f563e0 100755 --- a/.github/scripts/rename/cmake.sh +++ b/.github/scripts/rename/cmake.sh @@ -8,12 +8,12 @@ set -e SED_COMMAND=sed HEAD_COMMAND=head if [[ "${OSTYPE}" == 'darwin'* ]]; then - if ! command -v gsed &> /dev/null; then + if ! command -v gsed &>/dev/null; then echo "Error: gsed is not installed. Please install it using 'brew install gnu-sed'." exit 1 fi SED_COMMAND=gsed - if ! command -v ghead &> /dev/null; then + if ! command -v ghead &>/dev/null; then echo "Error: ghead is not installed. Please install it using 'brew install coreutils'." exit 1 fi @@ -43,9 +43,6 @@ pushd "${DIRECTORY}" # Rename the files. find cmake -type f -name 'Rippled*.cmake' -exec bash -c 'mv "${1}" "${1/Rippled/Xrpl}"' - {} \; find cmake -type f -name 'Ripple*.cmake' -exec bash -c 'mv "${1}" "${1/Ripple/Xrpl}"' - {} \; -if [ -e cmake/xrpl_add_test.cmake ]; then - mv cmake/xrpl_add_test.cmake cmake/XrplAddTest.cmake -fi if [ -e include/xrpl/proto/ripple.proto ]; then mv include/xrpl/proto/ripple.proto include/xrpl/proto/xrpl.proto fi @@ -60,7 +57,6 @@ find cmake -type f -name '*.cmake' | while read -r FILE; do done ${SED_COMMAND} -i -E 's/Rippled?/Xrpl/g' CMakeLists.txt ${SED_COMMAND} -i 's/ripple/xrpl/g' CMakeLists.txt -${SED_COMMAND} -i 's/include(xrpl_add_test)/include(XrplAddTest)/' src/tests/libxrpl/CMakeLists.txt ${SED_COMMAND} -i 's/ripple.pb.h/xrpl.pb.h/' include/xrpl/protocol/messages.h ${SED_COMMAND} -i 's/ripple.pb.h/xrpl.pb.h/' BUILD.md ${SED_COMMAND} -i 's/ripple.pb.h/xrpl.pb.h/' BUILD.md @@ -74,10 +70,10 @@ if grep -q '"xrpld"' cmake/XrplCore.cmake; then # The script has been rerun, so just restore the name of the binary. ${SED_COMMAND} -i 's/"xrpld"/"rippled"/' cmake/XrplCore.cmake elif ! grep -q '"rippled"' cmake/XrplCore.cmake; then - ${HEAD_COMMAND} -n -1 cmake/XrplCore.cmake > cmake.tmp - echo ' # For the time being, we will keep the name of the binary as it was.' >> cmake.tmp - echo ' set_target_properties(xrpld PROPERTIES OUTPUT_NAME "rippled")' >> cmake.tmp - tail -1 cmake/XrplCore.cmake >> cmake.tmp + ${HEAD_COMMAND} -n -1 cmake/XrplCore.cmake >cmake.tmp + echo ' # For the time being, we will keep the name of the binary as it was.' >>cmake.tmp + echo ' set_target_properties(xrpld PROPERTIES OUTPUT_NAME "rippled")' >>cmake.tmp + tail -1 cmake/XrplCore.cmake >>cmake.tmp mv cmake.tmp cmake/XrplCore.cmake fi diff --git a/.github/scripts/rename/config.sh b/.github/scripts/rename/config.sh index 81edcc73d6..ac9debb154 100755 --- a/.github/scripts/rename/config.sh +++ b/.github/scripts/rename/config.sh @@ -6,7 +6,7 @@ set -e # On MacOS, ensure that GNU sed is installed and available as `gsed`. SED_COMMAND=sed if [[ "${OSTYPE}" == 'darwin'* ]]; then - if ! command -v gsed &> /dev/null; then + if ! command -v gsed &>/dev/null; then echo "Error: gsed is not installed. Please install it using 'brew install gnu-sed'." exit 1 fi diff --git a/.github/scripts/rename/copyright.sh b/.github/scripts/rename/copyright.sh index 9ebdad1e89..09bc5a8926 100755 --- a/.github/scripts/rename/copyright.sh +++ b/.github/scripts/rename/copyright.sh @@ -6,7 +6,7 @@ set -e # On MacOS, ensure that GNU sed is installed and available as `gsed`. SED_COMMAND=sed if [[ "${OSTYPE}" == 'darwin'* ]]; then - if ! command -v gsed &> /dev/null; then + if ! command -v gsed &>/dev/null; then echo "Error: gsed is not installed. Please install it using 'brew install gnu-sed'." exit 1 fi @@ -62,37 +62,37 @@ done # restoring the verbiage that is already present in LICENSE.md. Ensure that if # the script is run multiple times, duplicate notices are not added. if ! grep -q 'Raw Material Software' include/xrpl/beast/core/CurrentThreadName.h; then - echo -e "// Portions of this file are from JUCE (http://www.juce.com).\n// Copyright (c) 2013 - Raw Material Software Ltd.\n// Please visit http://www.juce.com\n\n$(cat include/xrpl/beast/core/CurrentThreadName.h)" > include/xrpl/beast/core/CurrentThreadName.h + echo -e "// Portions of this file are from JUCE (http://www.juce.com).\n// Copyright (c) 2013 - Raw Material Software Ltd.\n// Please visit http://www.juce.com\n\n$(cat include/xrpl/beast/core/CurrentThreadName.h)" >include/xrpl/beast/core/CurrentThreadName.h fi if ! grep -q 'Dev Null' src/test/app/NetworkID_test.cpp; then - echo -e "// Copyright (c) 2020 Dev Null Productions\n\n$(cat src/test/app/NetworkID_test.cpp)" > src/test/app/NetworkID_test.cpp + echo -e "// Copyright (c) 2020 Dev Null Productions\n\n$(cat src/test/app/NetworkID_test.cpp)" >src/test/app/NetworkID_test.cpp fi if ! grep -q 'Dev Null' src/test/app/tx/apply_test.cpp; then - echo -e "// Copyright (c) 2020 Dev Null Productions\n\n$(cat src/test/app/tx/apply_test.cpp)" > src/test/app/tx/apply_test.cpp + echo -e "// Copyright (c) 2020 Dev Null Productions\n\n$(cat src/test/app/tx/apply_test.cpp)" >src/test/app/tx/apply_test.cpp fi if ! grep -q 'Dev Null' src/test/rpc/ManifestRPC_test.cpp; then - echo -e "// Copyright (c) 2020 Dev Null Productions\n\n$(cat src/test/rpc/ManifestRPC_test.cpp)" > src/test/rpc/ManifestRPC_test.cpp + echo -e "// Copyright (c) 2020 Dev Null Productions\n\n$(cat src/test/rpc/ManifestRPC_test.cpp)" >src/test/rpc/ManifestRPC_test.cpp fi if ! grep -q 'Dev Null' src/test/rpc/ValidatorInfo_test.cpp; then - echo -e "// Copyright (c) 2020 Dev Null Productions\n\n$(cat src/test/rpc/ValidatorInfo_test.cpp)" > src/test/rpc/ValidatorInfo_test.cpp + echo -e "// Copyright (c) 2020 Dev Null Productions\n\n$(cat src/test/rpc/ValidatorInfo_test.cpp)" >src/test/rpc/ValidatorInfo_test.cpp fi if ! grep -q 'Dev Null' src/xrpld/rpc/handlers/server_info/Manifest.cpp; then - echo -e "// Copyright (c) 2019 Dev Null Productions\n\n$(cat src/xrpld/rpc/handlers/server_info/Manifest.cpp)" > src/xrpld/rpc/handlers/server_info/Manifest.cpp + echo -e "// Copyright (c) 2019 Dev Null Productions\n\n$(cat src/xrpld/rpc/handlers/server_info/Manifest.cpp)" >src/xrpld/rpc/handlers/server_info/Manifest.cpp fi if ! grep -q 'Dev Null' src/xrpld/rpc/handlers/admin/status/ValidatorInfo.cpp; then - echo -e "// Copyright (c) 2019 Dev Null Productions\n\n$(cat src/xrpld/rpc/handlers/admin/status/ValidatorInfo.cpp)" > src/xrpld/rpc/handlers/admin/status/ValidatorInfo.cpp + echo -e "// Copyright (c) 2019 Dev Null Productions\n\n$(cat src/xrpld/rpc/handlers/admin/status/ValidatorInfo.cpp)" >src/xrpld/rpc/handlers/admin/status/ValidatorInfo.cpp fi if ! grep -q 'Bougalis' include/xrpl/basics/SlabAllocator.h; then - echo -e "// Copyright (c) 2022, Nikolaos D. Bougalis \n\n$(cat include/xrpl/basics/SlabAllocator.h)" > include/xrpl/basics/SlabAllocator.h # cspell: ignore Nikolaos Bougalis nikb + echo -e "// Copyright (c) 2022, Nikolaos D. Bougalis \n\n$(cat include/xrpl/basics/SlabAllocator.h)" >include/xrpl/basics/SlabAllocator.h # cspell: ignore Nikolaos Bougalis nikb fi if ! grep -q 'Bougalis' include/xrpl/basics/spinlock.h; then - echo -e "// Copyright (c) 2022, Nikolaos D. Bougalis \n\n$(cat include/xrpl/basics/spinlock.h)" > include/xrpl/basics/spinlock.h # cspell: ignore Nikolaos Bougalis nikb + echo -e "// Copyright (c) 2022, Nikolaos D. Bougalis \n\n$(cat include/xrpl/basics/spinlock.h)" >include/xrpl/basics/spinlock.h # cspell: ignore Nikolaos Bougalis nikb fi if ! grep -q 'Bougalis' include/xrpl/basics/tagged_integer.h; then - echo -e "// Copyright (c) 2014, Nikolaos D. Bougalis \n\n$(cat include/xrpl/basics/tagged_integer.h)" > include/xrpl/basics/tagged_integer.h # cspell: ignore Nikolaos Bougalis nikb + echo -e "// Copyright (c) 2014, Nikolaos D. Bougalis \n\n$(cat include/xrpl/basics/tagged_integer.h)" >include/xrpl/basics/tagged_integer.h # cspell: ignore Nikolaos Bougalis nikb fi if ! grep -q 'Ritchford' include/xrpl/beast/utility/Zero.h; then - echo -e "// Copyright (c) 2014, Tom Ritchford \n\n$(cat include/xrpl/beast/utility/Zero.h)" > include/xrpl/beast/utility/Zero.h # cspell: ignore Ritchford + echo -e "// Copyright (c) 2014, Tom Ritchford \n\n$(cat include/xrpl/beast/utility/Zero.h)" >include/xrpl/beast/utility/Zero.h # cspell: ignore Ritchford fi # Restore newlines and tabs in string literals in the affected file. diff --git a/.github/scripts/rename/definitions.sh b/.github/scripts/rename/definitions.sh index 5e004afe39..daa5d01e80 100755 --- a/.github/scripts/rename/definitions.sh +++ b/.github/scripts/rename/definitions.sh @@ -6,7 +6,7 @@ set -e # On MacOS, ensure that GNU sed is installed and available as `gsed`. SED_COMMAND=sed if [[ "${OSTYPE}" == 'darwin'* ]]; then - if ! command -v gsed &> /dev/null; then + if ! command -v gsed &>/dev/null; then echo "Error: gsed is not installed. Please install it using 'brew install gnu-sed'." exit 1 fi diff --git a/.github/scripts/rename/docs.sh b/.github/scripts/rename/docs.sh index 8b7a362405..9f080b06e5 100755 --- a/.github/scripts/rename/docs.sh +++ b/.github/scripts/rename/docs.sh @@ -6,7 +6,7 @@ set -e # On MacOS, ensure that GNU sed is installed and available as `gsed`. SED_COMMAND=sed if [[ "${OSTYPE}" == 'darwin'* ]]; then - if ! command -v gsed &> /dev/null; then + if ! command -v gsed &>/dev/null; then echo "Error: gsed is not installed. Please install it using 'brew install gnu-sed'." exit 1 fi diff --git a/.github/scripts/rename/namespace.sh b/.github/scripts/rename/namespace.sh index aba193b0cf..bb186bc8bc 100755 --- a/.github/scripts/rename/namespace.sh +++ b/.github/scripts/rename/namespace.sh @@ -6,7 +6,7 @@ set -e # On MacOS, ensure that GNU sed is installed and available as `gsed`. SED_COMMAND=sed if [[ "${OSTYPE}" == 'darwin'* ]]; then - if ! command -v gsed &> /dev/null; then + if ! command -v gsed &>/dev/null; then echo "Error: gsed is not installed. Please install it using 'brew install gnu-sed'." exit 1 fi diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index 6eccfcc6be..a269cb25d4 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -1,384 +1,322 @@ #!/usr/bin/env python3 import argparse +import dataclasses import itertools import json -from dataclasses import dataclass from pathlib import Path THIS_DIR = Path(__file__).parent.resolve() +_BASE_CMAKE_ARGS = ["-Dtests=ON", "-Dwerr=ON", "-Dxrpld=ON", "-Dwextra=ON"] -@dataclass -class Config: - architecture: list[dict] - os: list[dict] - build_type: list[str] - cmake_args: list[str] +# Maps sanitizer names (as used in cmake) to short config-name suffixes. +_SANITIZER_SUFFIX: dict[str, str] = { + "address": "asan", + "undefinedbehavior": "ubsan", + "thread": "tsan", +} -""" -Generate a strategy matrix for GitHub Actions CI. - -On each PR commit we will build a selection of Debian, RHEL, Ubuntu, MacOS, and -Windows configurations, while upon merge into the develop or release branches, -we will build all configurations, and test most of them. - -We will further set additional CMake arguments as follows: -- All builds will have the `tests`, `werr`, and `xrpld` options. -- All builds will have the `wextra` option except for GCC 12 and Clang 16. -- All release builds will have the `assert` option. -- Certain Debian Bookworm configurations will change the reference fee, enable - codecov, and enable voidstar in PRs. -""" +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() + if extra_args: + args.extend(extra_args.split()) + return " ".join(args) -def build_config_name(os_entry: dict[str, str], platform: str, build_type: str) -> str: - parts = [os_entry["distro_name"]] - for key in ("distro_version", "compiler_name", "compiler_version"): - if value := os_entry[key]: - parts.append(value) - parts.append("arm64" if "arm64" in platform else "amd64") - parts.append(build_type.lower()) - return "-".join(parts) +def runs_on_event(exclude_event_types: list[str], event: str | None) -> bool: + """Whether a config should run for the current event. - -def generate_packaging_matrix(config: Config) -> list[dict]: - """Emit one entry per os entry with `package: true`. Architecture is - hardcoded to linux/amd64 here (and the runner is hardcoded at the - workflow level) until arm64 packaging is ready. + 'exclude_event_types' is a list of GitHub event names (e.g. + ["pull_request"]) on which the config should NOT run; an empty list means + the config runs on every event. When no event is given (event is None), no + filtering is applied. """ - return [ - { - "artifact_name": f"xrpld-{build_config_name(os, 'linux/amd64', 'Release')}", - "os": os, - } - for os in config.os - if os.get("package", False) - ] + if event is None: + return True + return event not in exclude_event_types -def generate_strategy_matrix(all: bool, config: Config) -> list[dict]: - configurations = [] - for architecture, os, build_type, cmake_args in itertools.product( - config.architecture, config.os, config.build_type, config.cmake_args - ): - # The default CMake target is 'all' for Linux and MacOS and 'install' - # for Windows, but it can get overridden for certain configurations. - cmake_target = "install" if os["distro_name"] == "windows" else "all" +# --------------------------------------------------------------------------- +# Input types — shapes of the JSON config files +# --------------------------------------------------------------------------- - # We build and test all configurations by default, except for Windows in - # Debug, because it is too slow, as well as when code coverage is - # enabled as that mode already runs the tests. - build_only = False - if os["distro_name"] == "windows" and build_type == "Debug": - build_only = True - # Only generate a subset of configurations in PRs. - if not all: - # Debian: - # - Bookworm using GCC 13: Debug on linux/amd64, set the reference - # fee to 500 and enable code coverage (which will be done below). - # - Bookworm using GCC 15: Debug on linux/amd64, enable Address and - # UB sanitizers (which will be done below). - # - Bookworm using Clang 16: Debug on linux/amd64, enable voidstar. - # - Bookworm using Clang 17: Release on linux/amd64, set the - # reference fee to 1000. - # - Bookworm using Clang 20: Debug on linux/amd64, enable Address - # and UB sanitizers (which will be done below). - if os["distro_name"] == "debian": - skip = True - if os["distro_version"] == "bookworm": - if ( - f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-13" - and build_type == "Debug" - and architecture["platform"] == "linux/amd64" - ): - cmake_args = f"-DUNIT_TEST_REFERENCE_FEE=500 {cmake_args}" - skip = False - if ( - f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-15" - and build_type == "Release" - and architecture["platform"] == "linux/amd64" - ): - skip = False - if ( - f"{os['compiler_name']}-{os['compiler_version']}" == "clang-16" - and build_type == "Debug" - and architecture["platform"] == "linux/amd64" - ): - cmake_args = f"-Dvoidstar=ON {cmake_args}" - skip = False - if ( - f"{os['compiler_name']}-{os['compiler_version']}" == "clang-17" - and build_type == "Release" - and architecture["platform"] == "linux/amd64" - ): - cmake_args = f"-DUNIT_TEST_REFERENCE_FEE=1000 {cmake_args}" - skip = False - elif os["distro_version"] == "trixie": - if ( - f"{os['compiler_name']}-{os['compiler_version']}" == "clang-22" - and build_type == "Debug" - and architecture["platform"] == "linux/amd64" - ): - skip = False - if skip: - continue +@dataclasses.dataclass +class LinuxConfig: + """One entry in linux.json's 'configs' or 'package_configs' arrays.""" - # RHEL: - # - 9 using GCC 12: Debug and Release on linux/amd64 - # (Release is required for RPM packaging). - # - 10 using Clang: Release on linux/amd64. - if os["distro_name"] == "rhel": - skip = True - if os["distro_version"] == "9": - if ( - f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-12" - and build_type in ["Debug", "Release"] - and architecture["platform"] == "linux/amd64" - ): - skip = False - elif os["distro_version"] == "10": - if ( - f"{os['compiler_name']}-{os['compiler_version']}" == "clang-any" - and build_type == "Release" - and architecture["platform"] == "linux/amd64" - ): - skip = False - if skip: - continue + compiler: list[str] + build_type: list[str] + arch: list[str] + sanitizers: list[str] = dataclasses.field(default_factory=list) + suffix: str = "" + extra_cmake_args: str = "" + image: str = "" # only used by package_configs entries + # List of GitHub event names (e.g. "pull_request") on which this config + # should NOT run. Empty means it runs on every event. + exclude_event_types: list[str] = dataclasses.field(default_factory=list) - # Ubuntu: - # - Jammy using GCC 12: Debug on linux/arm64, Release on - # linux/amd64 (Release is required for DEB packaging). - # - Noble using GCC 14: Release on linux/amd64. - # - Noble using Clang 18: Debug on linux/amd64. - # - Noble using Clang 19: Release on linux/arm64. - if os["distro_name"] == "ubuntu": - skip = True - if os["distro_version"] == "jammy": - if ( - f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-12" - and build_type == "Debug" - and architecture["platform"] == "linux/arm64" - ): - skip = False - if ( - f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-12" - and build_type == "Release" - and architecture["platform"] == "linux/amd64" - ): - skip = False - elif os["distro_version"] == "noble": - if ( - f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-14" - and build_type == "Release" - and architecture["platform"] == "linux/amd64" - ): - skip = False - if ( - f"{os['compiler_name']}-{os['compiler_version']}" == "clang-18" - and build_type == "Debug" - and architecture["platform"] == "linux/amd64" - ): - skip = False - if ( - f"{os['compiler_name']}-{os['compiler_version']}" == "clang-19" - and build_type == "Release" - and architecture["platform"] == "linux/arm64" - ): - skip = False - if skip: - continue - # MacOS: - # - Debug on macos/arm64. - if os["distro_name"] == "macos" and not ( - build_type == "Debug" and architecture["platform"] == "macos/arm64" - ): +@dataclasses.dataclass +class LinuxFile: + """Shape of linux.json.""" + + 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", {})), + ) + + +@dataclasses.dataclass +class PlatformConfig: + """One entry in macos.json's or windows.json's 'configs' array.""" + + build_type: list[str] + build_only: bool = False # if true, skip tests (e.g. macos/Windows Debug) + extra_cmake_args: str = "" + # List of GitHub event names (e.g. "pull_request") on which this config + # should NOT run. Empty means it runs on every event. + exclude_event_types: list[str] = dataclasses.field(default_factory=list) + + def __post_init__(self) -> None: + if isinstance(self.build_type, str): + self.build_type = [self.build_type] + + +@dataclasses.dataclass +class PlatformFile: + """Shape of macos.json and windows.json.""" + + platform: str # e.g. "macos/arm64" or "windows/amd64" + runner: list[str] # GitHub Actions runner labels + configs: list[PlatformConfig] + + @classmethod + def load(cls, path: Path) -> "PlatformFile": + data = json.loads(path.read_text()) + return cls( + platform=data["platform"], + runner=data["runner"], + configs=[PlatformConfig(**c) for c in data["configs"]], + ) + + +# --------------------------------------------------------------------------- +# Output types — shapes of the generated GitHub Actions matrix entries +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class Architecture: + platform: str + runner: list[str] + + +@dataclasses.dataclass +class MatrixEntry: + """One entry in the generated build/test strategy matrix.""" + + config_name: str + cmake_args: str + cmake_target: str + build_only: bool + build_type: str + architecture: Architecture + sanitizers: str + image: str = "" # container image; empty for macOS/Windows (runs natively) + compiler: str = "" # compiler name ("gcc" or "clang"); empty for macOS/Windows + + +@dataclasses.dataclass +class PackagingEntry: + """One entry in the generated packaging strategy matrix.""" + + artifact_name: str + image: str + distro: str # e.g. "debian" or "rhel"; drives package-format-specific steps + + +# --------------------------------------------------------------------------- +# Matrix expansion +# --------------------------------------------------------------------------- + +_ARCHS: dict[str, Architecture] = { + "amd64": Architecture( + platform="linux/amd64", runner=["self-hosted", "Linux", "X64", "heavy"] + ), + "arm64": Architecture( + platform="linux/arm64", + runner=["self-hosted", "Linux", "ARM64", "heavy-arm64"], + ), +} + + +def expand_linux_matrix( + linux: LinuxFile, event: str | None = None +) -> list[MatrixEntry]: + """Expand a LinuxFile into a flat list of matrix entries. + + Each config entry is expanded over the cross-product of its + compiler, build_type, sanitizers, and architecture lists. Configs that + exclude the current event are skipped. + """ + entries: list[MatrixEntry] = [] + + for distro, configs in linux.configs.items(): + for cfg in configs: + if not runs_on_event(cfg.exclude_event_types, event): continue + # An empty sanitizers list means "one entry with no sanitizer". + effective_sanitizers = cfg.sanitizers or [""] + effective_archs = {arch: _ARCHS[arch] for arch in cfg.arch} - # Windows: - # - Release on windows/amd64. - if os["distro_name"] == "windows" and not ( - build_type == "Release" and architecture["platform"] == "windows/amd64" + for compiler, build_type, sanitizer, (arch, arch_info) in itertools.product( + cfg.compiler, + cfg.build_type, + effective_sanitizers, + effective_archs.items(), ): - continue + 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) - # Additional CMake arguments. - cmake_args = f"{cmake_args} -Dtests=ON -Dwerr=ON -Dxrpld=ON" - if not f"{os['compiler_name']}-{os['compiler_version']}" in [ - "gcc-12", - "clang-16", - ]: - cmake_args = f"{cmake_args} -Dwextra=ON" - if build_type == "Release": - cmake_args = f"{cmake_args} -Dassert=ON" - - # We skip all RHEL on arm64 due to a build failure that needs further - # investigation. - if os["distro_name"] == "rhel" and architecture["platform"] == "linux/arm64": - continue - - # We skip all clang 20+ on arm64 due to Boost build error. - if ( - os["compiler_name"] == "clang" - and os["compiler_version"].isdigit() - and int(os["compiler_version"]) >= 20 - and architecture["platform"] == "linux/arm64" - ): - continue - - # Enable code coverage for Debian Bookworm using GCC 13 in Debug on - # linux/amd64. - if ( - f"{os['distro_name']}-{os['distro_version']}" == "debian-bookworm" - and f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-13" - and build_type == "Debug" - and architecture["platform"] == "linux/amd64" - ): - cmake_args = f"{cmake_args} -Dcoverage=ON -Dcoverage_format=xml -DCODE_COVERAGE_VERBOSE=ON -DCMAKE_C_FLAGS=-O0 -DCMAKE_CXX_FLAGS=-O0" - - # Enable unity build for Ubuntu Jammy using GCC 12 in Debug on - # linux/amd64. - if ( - f"{os['distro_name']}-{os['distro_version']}" == "ubuntu-jammy" - and f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-12" - and build_type == "Debug" - and architecture["platform"] == "linux/amd64" - ): - cmake_args = f"{cmake_args} -Dunity=ON" - - # Generate a unique name for the configuration, e.g. macos-arm64-debug - # or debian-bookworm-gcc-12-amd64-release. - config_name = build_config_name(os, architecture["platform"], build_type) - if "-Dcoverage=ON" in cmake_args: - config_name += "-coverage" - if "-Dunity=ON" in cmake_args: - config_name += "-unity" - - # Add the configuration to the list, with the most unique fields first, - # so that they are easier to identify in the GitHub Actions UI, as long - # names get truncated. - # Add Address and UB sanitizers as separate configurations for specific - # bookworm distros. Thread sanitizer is currently disabled (see below). - # GCC-Asan xrpld-embedded tests are failing because of https://github.com/google/sanitizers/issues/856 - if ( - os["distro_version"] == "bookworm" - and f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-15" - ) or ( - os["distro_version"] == "trixie" - and f"{os['compiler_name']}-{os['compiler_version']}" == "clang-22" - ): - # Add ASAN and UBSAN configurations for both gcc-15 and clang-22 - configurations.append( - { - "config_name": config_name + "-asan", - "cmake_args": cmake_args, - "cmake_target": cmake_target, - "build_only": build_only, - "build_type": build_type, - "os": os, - "architecture": architecture, - "sanitizers": "address", - } - ) - configurations.append( - { - "config_name": config_name + "-ubsan", - "cmake_args": cmake_args, - "cmake_target": cmake_target, - "build_only": build_only, - "build_type": build_type, - "os": os, - "architecture": architecture, - "sanitizers": "undefinedbehavior", - } - ) - # TSAN is deactivated due to seg faults with latest compilers. - activate_tsan = False - if activate_tsan: - configurations.append( - { - "config_name": config_name + "-tsan-ubsan", - "cmake_args": cmake_args, - "cmake_target": cmake_target, - "build_only": build_only, - "build_type": build_type, - "os": os, - "architecture": architecture, - "sanitizers": "thread,undefinedbehavior", - } + entries.append( + MatrixEntry( + config_name=name, + image=f"ghcr.io/xrplf/xrpld/nix-{distro}:{linux.image_tag}", + cmake_args=get_cmake_args(build_type, cfg.extra_cmake_args), + cmake_target="all", + build_only=False, + build_type=build_type, + architecture=arch_info, + sanitizers=sanitizer, + compiler=compiler, + ) + ) + + return entries + + +def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]: + """Generate the packaging matrix from a LinuxFile's package_configs section. + + Packaging uses vanilla distro images (debian:bookworm, ubi9, …) instead of + 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'. + """ + entries = [] + for distro, configs in linux.package_configs.items(): + for cfg in configs: + for compiler, build_type in itertools.product(cfg.compiler, cfg.build_type): + entries.append( + PackagingEntry( + artifact_name=f"xrpld-{distro}-{compiler}-{build_type.lower()}-amd64", + image=cfg.image, + distro=distro, + ) + ) + + return entries + + +def expand_platform_matrix( + pf: PlatformFile, event: str | None = None +) -> list[MatrixEntry]: + """Expand a PlatformFile (macOS or Windows) into matrix entries. + + Configs that exclude the current event are skipped. + """ + platform_name, arch = pf.platform.split("/") + is_windows = platform_name == "windows" + + entries: list[MatrixEntry] = [] + for cfg in pf.configs: + if not runs_on_event(cfg.exclude_event_types, event): + continue + for build_type in cfg.build_type: + entries.append( + MatrixEntry( + config_name=f"{platform_name}-{arch}-{build_type.lower()}", + cmake_args=get_cmake_args(build_type, cfg.extra_cmake_args), + cmake_target="install" if is_windows else "all", + build_only=cfg.build_only, + build_type=build_type, + architecture=Architecture(platform=pf.platform, runner=pf.runner), + sanitizers="", ) - else: - configurations.append( - { - "config_name": config_name, - "cmake_args": cmake_args, - "cmake_target": cmake_target, - "build_only": build_only, - "build_type": build_type, - "os": os, - "architecture": architecture, - "sanitizers": "", - } ) - - return configurations + return entries -def read_config(file: Path) -> Config: - config = json.loads(file.read_text()) - if ( - config["architecture"] is None - or config["os"] is None - or config["build_type"] is None - or config["cmake_args"] is None - ): - raise Exception("Invalid configuration file.") - - return Config(**config) +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument( - "-a", - "--all", - help="Set to generate all configurations (generally used when merging a PR) or leave unset to generate a subset of configurations (generally used when committing to a PR).", - action="store_true", + parser = argparse.ArgumentParser( + description="Generate a CI strategy matrix for all platforms or a specific one." ) parser.add_argument( "-c", "--config", - help="Path to the JSON file containing the strategy matrix configurations.", - required=False, - type=Path, + help="Platform to generate for ('linux', 'macos', or 'windows'). Defaults to all platforms.", + choices=["linux", "macos", "windows"], + default=None, ) parser.add_argument( "-p", "--packaging", - help="Emit the packaging matrix (derived from the 'package' field on os entries) instead of the build/test matrix.", + help="Emit the Linux packaging matrix instead of the build/test matrix.", action="store_true", ) + parser.add_argument( + "-e", + "--event", + help="The GitHub event name that triggered the workflow (e.g. 'push', " + "'pull_request'). Configs are filtered by their 'event_type'. If " + "omitted, no filtering is applied.", + default=None, + ) args = parser.parse_args() - matrix = [] - if args.packaging: - config_path = args.config if args.config else THIS_DIR / "linux.json" - matrix += generate_packaging_matrix(read_config(config_path)) - elif args.config is None or args.config == "": - matrix += generate_strategy_matrix( - args.all, read_config(THIS_DIR / "linux.json") - ) - matrix += generate_strategy_matrix( - args.all, read_config(THIS_DIR / "macos.json") - ) - matrix += generate_strategy_matrix( - args.all, read_config(THIS_DIR / "windows.json") - ) - else: - matrix += generate_strategy_matrix(args.all, read_config(args.config)) + matrix: list[MatrixEntry] | list[PackagingEntry] = [] - # Generate the strategy matrix. - print(f"matrix={json.dumps({'include': matrix})}") + if args.packaging: + matrix = expand_linux_packaging(LinuxFile.load(THIS_DIR / "linux.json")) + else: + if args.config in ("linux", None): + matrix += expand_linux_matrix( + LinuxFile.load(THIS_DIR / "linux.json"), args.event + ) + if args.config in ("macos", None): + matrix += expand_platform_matrix( + PlatformFile.load(THIS_DIR / "macos.json"), args.event + ) + if args.config in ("windows", None): + matrix += expand_platform_matrix( + PlatformFile.load(THIS_DIR / "windows.json"), args.event + ) + + print(f"matrix={json.dumps({'include': [dataclasses.asdict(e) for e in matrix]})}") diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 4f090a81a3..863b910dda 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -1,221 +1,84 @@ { - "architecture": [ - { - "platform": "linux/amd64", - "runner": ["self-hosted", "Linux", "X64", "heavy"] - }, - { - "platform": "linux/arm64", - "runner": ["self-hosted", "Linux", "ARM64", "heavy-arm64"] - } - ], - "os": [ - { - "distro_name": "debian", - "distro_version": "bookworm", - "compiler_name": "gcc", - "compiler_version": "12", - "image_sha": "4c086b9" - }, - { - "distro_name": "debian", - "distro_version": "bookworm", - "compiler_name": "gcc", - "compiler_version": "13", - "image_sha": "4c086b9" - }, - { - "distro_name": "debian", - "distro_version": "bookworm", - "compiler_name": "gcc", - "compiler_version": "14", - "image_sha": "4c086b9" - }, - { - "distro_name": "debian", - "distro_version": "bookworm", - "compiler_name": "gcc", - "compiler_version": "15", - "image_sha": "4c086b9" - }, - { - "distro_name": "debian", - "distro_version": "bookworm", - "compiler_name": "clang", - "compiler_version": "16", - "image_sha": "4c086b9" - }, - { - "distro_name": "debian", - "distro_version": "bookworm", - "compiler_name": "clang", - "compiler_version": "17", - "image_sha": "4c086b9" - }, - { - "distro_name": "debian", - "distro_version": "bookworm", - "compiler_name": "clang", - "compiler_version": "18", - "image_sha": "4c086b9" - }, - { - "distro_name": "debian", - "distro_version": "bookworm", - "compiler_name": "clang", - "compiler_version": "19", - "image_sha": "4c086b9" - }, - { - "distro_name": "debian", - "distro_version": "bookworm", - "compiler_name": "clang", - "compiler_version": "20", - "image_sha": "4c086b9" - }, - { - "distro_name": "debian", - "distro_version": "trixie", - "compiler_name": "gcc", - "compiler_version": "14", - "image_sha": "4c086b9" - }, - { - "distro_name": "debian", - "distro_version": "trixie", - "compiler_name": "gcc", - "compiler_version": "15", - "image_sha": "4c086b9" - }, - { - "distro_name": "debian", - "distro_version": "trixie", - "compiler_name": "clang", - "compiler_version": "20", - "image_sha": "4c086b9" - }, - { - "distro_name": "debian", - "distro_version": "trixie", - "compiler_name": "clang", - "compiler_version": "21", - "image_sha": "4c086b9" - }, - { - "distro_name": "debian", - "distro_version": "trixie", - "compiler_name": "clang", - "compiler_version": "22", - "image_sha": "4c086b9" - }, - { - "distro_name": "rhel", - "distro_version": "8", - "compiler_name": "gcc", - "compiler_version": "14", - "image_sha": "4c086b9" - }, - { - "distro_name": "rhel", - "distro_version": "8", - "compiler_name": "clang", - "compiler_version": "any", - "image_sha": "4c086b9" - }, - { - "distro_name": "rhel", - "distro_version": "9", - "compiler_name": "gcc", - "compiler_version": "12", - "image_sha": "4c086b9", - "package": true - }, - { - "distro_name": "rhel", - "distro_version": "9", - "compiler_name": "gcc", - "compiler_version": "13", - "image_sha": "4c086b9" - }, - { - "distro_name": "rhel", - "distro_version": "9", - "compiler_name": "gcc", - "compiler_version": "14", - "image_sha": "4c086b9" - }, - { - "distro_name": "rhel", - "distro_version": "9", - "compiler_name": "clang", - "compiler_version": "any", - "image_sha": "4c086b9" - }, - { - "distro_name": "rhel", - "distro_version": "10", - "compiler_name": "gcc", - "compiler_version": "14", - "image_sha": "4c086b9" - }, - { - "distro_name": "rhel", - "distro_version": "10", - "compiler_name": "clang", - "compiler_version": "any", - "image_sha": "4c086b9" - }, - { - "distro_name": "ubuntu", - "distro_version": "jammy", - "compiler_name": "gcc", - "compiler_version": "12", - "image_sha": "4c086b9", - "package": true - }, - { - "distro_name": "ubuntu", - "distro_version": "noble", - "compiler_name": "gcc", - "compiler_version": "13", - "image_sha": "4c086b9" - }, - { - "distro_name": "ubuntu", - "distro_version": "noble", - "compiler_name": "gcc", - "compiler_version": "14", - "image_sha": "4c086b9" - }, - { - "distro_name": "ubuntu", - "distro_version": "noble", - "compiler_name": "clang", - "compiler_version": "16", - "image_sha": "4c086b9" - }, - { - "distro_name": "ubuntu", - "distro_version": "noble", - "compiler_name": "clang", - "compiler_version": "17", - "image_sha": "4c086b9" - }, - { - "distro_name": "ubuntu", - "distro_version": "noble", - "compiler_name": "clang", - "compiler_version": "18", - "image_sha": "4c086b9" - }, - { - "distro_name": "ubuntu", - "distro_version": "noble", - "compiler_name": "clang", - "compiler_version": "19", - "image_sha": "4c086b9" - } - ], - "build_type": ["Debug", "Release"], - "cmake_args": [""] + "image_tag": "sha-e29b523", + "configs": { + "ubuntu": [ + { + "compiler": ["gcc", "clang"], + "build_type": ["Debug", "Release"], + "arch": ["amd64", "arm64"] + }, + + { + "compiler": ["gcc", "clang"], + "build_type": ["Debug", "Release"], + "arch": ["amd64"], + "sanitizers": ["address", "undefinedbehavior"] + }, + + { + "compiler": ["gcc"], + "build_type": ["Debug"], + "arch": ["amd64"], + "suffix": "coverage", + "extra_cmake_args": "-DUNIT_TEST_REFERENCE_FEE=500 -Dcoverage=ON -Dcoverage_format=xml -DCODE_COVERAGE_VERBOSE=ON -DCMAKE_C_FLAGS=-O0 -DCMAKE_CXX_FLAGS=-O0" + }, + { + "compiler": ["clang"], + "build_type": ["Debug"], + "arch": ["amd64"], + "suffix": "voidstar", + "extra_cmake_args": "-Dvoidstar=ON" + }, + { + "compiler": ["clang"], + "build_type": ["Release"], + "arch": ["amd64"], + "suffix": "reffee", + "extra_cmake_args": "-DUNIT_TEST_REFERENCE_FEE=1000" + }, + { + "compiler": ["gcc"], + "build_type": ["Debug"], + "arch": ["amd64"], + "suffix": "unity", + "extra_cmake_args": "-Dunity=ON", + "exclude_event_types": ["pull_request"] + } + ], + + "debian": [ + { + "compiler": ["gcc"], + "build_type": ["Release"], + "arch": ["amd64"] + } + ], + + "rhel": [ + { + "compiler": ["gcc"], + "build_type": ["Release"], + "arch": ["amd64"] + } + ] + }, + "package_configs": { + "debian": [ + { + "compiler": ["gcc"], + "build_type": ["Release"], + "arch": ["amd64"], + "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-577d745" + } + ], + + "rhel": [ + { + "compiler": ["gcc"], + "build_type": ["Release"], + "arch": ["amd64"], + "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-577d745" + } + ] + } } diff --git a/.github/scripts/strategy-matrix/macos.json b/.github/scripts/strategy-matrix/macos.json index 6fc44d0f80..2d3cc75c7b 100644 --- a/.github/scripts/strategy-matrix/macos.json +++ b/.github/scripts/strategy-matrix/macos.json @@ -1,19 +1,16 @@ { - "architecture": [ + "platform": "macos/arm64", + "runner": ["self-hosted", "macOS", "ARM64", "macos-26-apple-clang-21"], + "configs": [ { - "platform": "macos/arm64", - "runner": ["self-hosted", "macOS", "ARM64", "mac-runner-m1"] - } - ], - "os": [ + "build_type": "Release", + "extra_cmake_args": "-DCMAKE_POLICY_VERSION_MINIMUM=3.5" + }, { - "distro_name": "macos", - "distro_version": "", - "compiler_name": "", - "compiler_version": "", - "image_sha": "" + "build_type": "Debug", + "extra_cmake_args": "-DCMAKE_POLICY_VERSION_MINIMUM=3.5", + "build_only": true, + "exclude_event_types": ["pull_request"] } - ], - "build_type": ["Debug", "Release"], - "cmake_args": ["-DCMAKE_POLICY_VERSION_MINIMUM=3.5"] + ] } diff --git a/.github/scripts/strategy-matrix/windows.json b/.github/scripts/strategy-matrix/windows.json index 8c536c70f2..370e9f5bc7 100644 --- a/.github/scripts/strategy-matrix/windows.json +++ b/.github/scripts/strategy-matrix/windows.json @@ -1,19 +1,12 @@ { - "architecture": [ + "platform": "windows/amd64", + "runner": ["self-hosted", "Windows", "dev-box-windows-2026"], + "configs": [ + { "build_type": "Release" }, { - "platform": "windows/amd64", - "runner": ["self-hosted", "Windows", "devbox"] + "build_type": "Debug", + "build_only": true, + "exclude_event_types": ["pull_request"] } - ], - "os": [ - { - "distro_name": "windows", - "distro_version": "", - "compiler_name": "", - "compiler_version": "", - "image_sha": "" - } - ], - "build_type": ["Debug", "Release"], - "cmake_args": [""] + ] } diff --git a/.github/workflows/build-nix-image.yml b/.github/workflows/build-nix-image.yml deleted file mode 100644 index 6554cf6c08..0000000000 --- a/.github/workflows/build-nix-image.yml +++ /dev/null @@ -1,101 +0,0 @@ -name: Build Nix Docker image - -on: - push: - branches: - - develop - paths: - - ".github/workflows/build-nix-image.yml" - - "docker/nix.Dockerfile" - - "flake.nix" - - "flake.lock" - - "nix/**" - pull_request: - paths: - - ".github/workflows/build-nix-image.yml" - - "docker/nix.Dockerfile" - - "flake.nix" - - "flake.lock" - - "nix/**" - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -defaults: - run: - shell: bash - -env: - UBUNTU_VERSION: "20.04" - RHEL_VERSION: "9" - DEBIAN_VERSION: "bookworm" - -jobs: - build: - name: Build and push Nix image (${{ matrix.distro }}) - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - strategy: - matrix: - include: - - distro: nixos - - distro: ubuntu - - distro: rhel - - distro: debian - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Determine base image - id: vars - run: | - case "${{ matrix.distro }}" in - nixos) - echo "base_image=nixos/nix:latest" >> $GITHUB_OUTPUT - ;; - ubuntu) - echo "base_image=ubuntu:${UBUNTU_VERSION}" >> $GITHUB_OUTPUT - ;; - rhel) - echo "base_image=registry.access.redhat.com/ubi${RHEL_VERSION}/ubi:latest" >> $GITHUB_OUTPUT - ;; - debian) - echo "base_image=debian:${DEBIAN_VERSION}" >> $GITHUB_OUTPUT - ;; - esac - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - - name: Login to GitHub Container Registry - if: github.event_name == 'push' - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Docker metadata - id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 - with: - images: ghcr.io/xrplf/ci/nix-${{ matrix.distro }} - tags: | - type=sha,prefix=sha-,format=short - type=raw,value=latest - - - name: Build and push - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 - with: - context: . - file: docker/nix.Dockerfile - platforms: linux/amd64 - push: ${{ github.event_name == 'push' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: BASE_IMAGE=${{ steps.vars.outputs.base_image }} diff --git a/.github/workflows/build-nix-images.yml b/.github/workflows/build-nix-images.yml new file mode 100644 index 0000000000..54911ef6e0 --- /dev/null +++ b/.github/workflows/build-nix-images.yml @@ -0,0 +1,62 @@ +name: Build Nix Docker images + +on: + push: + branches: + - develop + paths: + - ".github/workflows/build-nix-images.yml" + - "flake.nix" + - "flake.lock" + - "nix/**" + - "!nix/docker/README.md" + - "!nix/devshell.nix" + - "bin/check-tools.sh" + - "bin/install-sanitizer-libs.sh" + pull_request: + paths: + - ".github/workflows/build-nix-images.yml" + - "flake.nix" + - "flake.lock" + - "nix/**" + - "!nix/docker/README.md" + - "!nix/devshell.nix" + - "bin/check-tools.sh" + - "bin/install-sanitizer-libs.sh" + workflow_dispatch: + +concurrency: + # Read `on-trigger.yml` for the rationale behind this concurrency group name. + group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' && github.sha || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + build-merge: + name: Build and push nix-${{ matrix.distro.name }} + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + # The base images are the oldest supported version of each distro + # that we want to build images for. + distro: + - name: nixos + base_image: nixos/nix:latest + - name: ubuntu + base_image: ubuntu:20.04 + - name: debian + base_image: debian:bookworm + - name: rhel + base_image: registry.access.redhat.com/ubi9/ubi:latest + uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@ee03d31bcc4501d7599dc1b1ecd7a34af582ad1c + with: + image_name: xrpld/nix-${{ matrix.distro.name }} + dockerfile: nix/docker/Dockerfile + base_image: ${{ matrix.distro.base_image }} + push: ${{ github.event_name == 'push' }} diff --git a/.github/workflows/build-packaging-images.yml b/.github/workflows/build-packaging-images.yml new file mode 100644 index 0000000000..3633847ef3 --- /dev/null +++ b/.github/workflows/build-packaging-images.yml @@ -0,0 +1,46 @@ +name: Build packaging Docker images + +on: + push: + branches: + - develop + paths: + - ".github/workflows/build-packaging-images.yml" + - "package/Dockerfile" + - "package/install-packaging-tools.sh" + pull_request: + paths: + - ".github/workflows/build-packaging-images.yml" + - "package/Dockerfile" + - "package/install-packaging-tools.sh" + workflow_dispatch: + +concurrency: + # Read `on-trigger.yml` for the rationale behind this concurrency group name. + group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' && github.sha || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + build-merge: + name: Build and push packaging-${{ matrix.distro.name }} + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + distro: + - name: debian + base_image: debian:bookworm + - name: rhel + base_image: registry.access.redhat.com/ubi9/ubi:latest + uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@ee03d31bcc4501d7599dc1b1ecd7a34af582ad1c + with: + image_name: xrpld/packaging-${{ matrix.distro.name }} + dockerfile: package/Dockerfile + base_image: ${{ matrix.distro.base_image }} + push: ${{ github.event_name == 'push' }} diff --git a/.github/workflows/check-pr-description.yml b/.github/workflows/check-pr-description.yml index f6eee50291..744449f216 100644 --- a/.github/workflows/check-pr-description.yml +++ b/.github/workflows/check-pr-description.yml @@ -5,8 +5,17 @@ on: types: - checks_requested pull_request: - types: [opened, edited, reopened, synchronize, ready_for_review] - branches: [develop] + types: + - opened + - edited + - reopened + - synchronize + - ready_for_review + branches: + - develop + - "release-*" + - "release/*" + - "staging/*" jobs: check_description: @@ -14,17 +23,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Write PR body to file env: PR_BODY: ${{ github.event.pull_request.body }} if: ${{ github.event_name == 'pull_request' }} - run: printenv PR_BODY > pr_body.md + run: printenv PR_BODY >pr_body.md - name: Check PR description differs from template if: ${{ github.event_name == 'pull_request' }} - run: > - python .github/scripts/check-pr-description.py - --template-file .github/pull_request_template.md - --pr-body-file pr_body.md + run: | + python .github/scripts/check-pr-description.py \ + --template-file .github/pull_request_template.md \ + --pr-body-file pr_body.md diff --git a/.github/workflows/check-pr-title.yml b/.github/workflows/check-pr-title.yml index 5631950df6..4b5f679df1 100644 --- a/.github/workflows/check-pr-title.yml +++ b/.github/workflows/check-pr-title.yml @@ -5,10 +5,19 @@ on: types: - checks_requested pull_request: - types: [opened, edited, reopened, synchronize, ready_for_review] - branches: [develop] + types: + - opened + - edited + - reopened + - synchronize + - ready_for_review + branches: + - develop + - "release-*" + - "release/*" + - "staging/*" jobs: check_title: if: ${{ github.event.pull_request.draft != true }} - uses: XRPLF/actions/.github/workflows/check-pr-title.yml@291206777251b4d493641b5afbdf7c23009d2988 + uses: XRPLF/actions/.github/workflows/check-pr-title.yml@cba1f0891650baf1a9c88624dc2d72573be2eb81 diff --git a/.github/workflows/conflicting-pr.yml b/.github/workflows/conflicting-pr.yml index 6e667632a0..772d46fd7d 100644 --- a/.github/workflows/conflicting-pr.yml +++ b/.github/workflows/conflicting-pr.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check if PRs are dirty - uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3 + uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0 with: dirtyLabel: "PR: has conflicts" repoToken: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index ca715e0376..19fb170b92 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -33,7 +33,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Determine changed files # This step checks whether any files have changed that should # cause the next jobs to run. We do it this way rather than @@ -70,6 +70,7 @@ jobs: .github/workflows/reusable-upload-recipe.yml .clang-tidy .codecov.yml + bin/check-tools.sh cfg/** cmake/** conan/** @@ -98,7 +99,7 @@ jobs: READY: ${{ contains(github.event.pull_request.labels.*.name, 'Ready to merge') }} MERGE: ${{ github.event_name == 'merge_group' }} run: | - echo "go=${{ (env.DRAFT != 'true' && env.READY == 'true') || env.FILES == 'true' || env.MERGE == 'true' }}" >> "${GITHUB_OUTPUT}" + echo "go=${{ (env.DRAFT != 'true' && env.READY == 'true') || env.FILES == 'true' || env.MERGE == 'true' }}" >>"${GITHUB_OUTPUT}" cat "${GITHUB_OUTPUT}" outputs: go: ${{ steps.go.outputs.go == 'true' }} @@ -121,7 +122,6 @@ jobs: issues: write contents: read with: - check_only_changed: true create_issue_on_failure: false build-test: @@ -153,8 +153,8 @@ jobs: if: ${{ github.repository == 'XRPLF/rippled' && needs.should-run.outputs.go == 'true' && github.event_name == 'pull_request' && startsWith(github.event.pull_request.base.ref, 'release') }} uses: ./.github/workflows/reusable-upload-recipe.yml secrets: - remote_username: ${{ secrets.CONAN_REMOTE_USERNAME }} - remote_password: ${{ secrets.CONAN_REMOTE_PASSWORD }} + remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }} + remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} notify-clio: needs: upload-recipe @@ -168,9 +168,9 @@ jobs: PR_URL: ${{ github.event.pull_request.html_url }} run: | gh api --method POST -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" \ - /repos/xrplf/clio/dispatches -f "event_type=check_libxrpl" \ - -F "client_payload[ref]=${{ needs.upload-recipe.outputs.recipe_ref }}" \ - -F "client_payload[pr_url]=${PR_URL}" + /repos/xrplf/clio/dispatches -f "event_type=check_libxrpl" \ + -F "client_payload[ref]=${{ needs.upload-recipe.outputs.recipe_ref }}" \ + -F "client_payload[pr_url]=${PR_URL}" passed: if: failure() || cancelled() diff --git a/.github/workflows/on-tag.yml b/.github/workflows/on-tag.yml index b7517ccf11..abedc13d69 100644 --- a/.github/workflows/on-tag.yml +++ b/.github/workflows/on-tag.yml @@ -20,8 +20,8 @@ jobs: if: ${{ github.repository == 'XRPLF/rippled' }} uses: ./.github/workflows/reusable-upload-recipe.yml secrets: - remote_username: ${{ secrets.CONAN_REMOTE_USERNAME }} - remote_password: ${{ secrets.CONAN_REMOTE_PASSWORD }} + remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }} + remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} build-test: if: ${{ github.repository == 'XRPLF/rippled' }} @@ -33,7 +33,6 @@ jobs: with: ccache_enabled: false os: ${{ matrix.os }} - strategy_matrix: minimal secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/on-trigger.yml b/.github/workflows/on-trigger.yml index 803ba3c87b..49a93d2746 100644 --- a/.github/workflows/on-trigger.yml +++ b/.github/workflows/on-trigger.yml @@ -27,6 +27,7 @@ on: - ".github/workflows/reusable-upload-recipe.yml" - ".clang-tidy" - ".codecov.yml" + - "bin/check-tools.sh" - "cfg/**" - "cmake/**" - "conan/**" @@ -71,7 +72,6 @@ jobs: issues: write contents: read with: - check_only_changed: false create_issue_on_failure: ${{ github.event_name == 'schedule' }} build-test: @@ -88,7 +88,6 @@ jobs: # not identical to a regular compilation. ccache_enabled: ${{ github.repository_owner == 'XRPLF' && !startsWith(github.ref, 'refs/heads/release') }} os: ${{ matrix.os }} - strategy_matrix: ${{ github.event_name == 'schedule' && 'all' || 'minimal' }} secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -98,8 +97,8 @@ jobs: if: ${{ github.repository == 'XRPLF/rippled' && github.event_name == 'push' && github.ref == 'refs/heads/develop' }} uses: ./.github/workflows/reusable-upload-recipe.yml secrets: - remote_username: ${{ secrets.CONAN_REMOTE_USERNAME }} - remote_password: ${{ secrets.CONAN_REMOTE_PASSWORD }} + remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }} + remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} package: needs: build-test diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 2f15b82266..0363534af5 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@5e942d61bf32f7557a7c159cfac4712a687b3e3a + uses: XRPLF/actions/.github/workflows/pre-commit.yml@e06d4138c9ec8dceeb7c818645faa38087ea9e3d with: runs_on: ubuntu-latest container: '{ "image": "ghcr.io/xrplf/ci/tools-rippled-pre-commit:sha-41ec7c1" }' diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index d619be5543..bfa8d2e79c 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/ci/tools-rippled-documentation:sha-a8c7be1 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-e29b523 steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@90f11ee655d1687824fb8793db770477d52afbab + uses: XRPLF/actions/prepare-runner@9355d190fd7d4de80fadfd161e6edddc9702cd9f with: enable_ccache: false @@ -57,19 +57,11 @@ jobs: with: subtract: ${{ env.NPROC_SUBTRACT }} - - name: Check configuration - run: | - echo 'Checking path.' - echo ${PATH} | tr ':' '\n' + - name: Print build environment + uses: XRPLF/actions/print-build-env@59dec886e4afb05a1724443af08baccbc045b574 - echo 'Checking environment variables.' - env | sort - - echo 'Checking CMake version.' - cmake --version - - echo 'Checking Doxygen version.' - doxygen --version + - name: Check Doxygen version + run: doxygen --version - name: Build documentation env: diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index 4c7c41fd0c..0cb0219d72 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -57,6 +57,12 @@ on: type: string default: "" + compiler: + description: 'The compiler to use ("gcc" or "clang"). Leave empty for macOS/Windows (uses system default).' + required: false + type: string + default: "" + secrets: CODECOV_TOKEN: description: "The Codecov token to use for uploading coverage reports." @@ -76,7 +82,7 @@ jobs: name: ${{ inputs.config_name }} runs-on: ${{ fromJSON(inputs.runs_on) }} container: ${{ inputs.image != '' && inputs.image || null }} - timeout-minutes: ${{ inputs.sanitizers != '' && 360 || 60 }} + timeout-minutes: ${{ inputs.sanitizers != '' && 360 || 180 }} env: # Use a namespace to keep the objects separate for each configuration. CCACHE_NAMESPACE: ${{ inputs.config_name }} @@ -104,16 +110,21 @@ jobs: uses: XRPLF/actions/cleanup-workspace@c7d9ce5ebb03c752a354889ecd870cadfc2b1cd4 - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@90f11ee655d1687824fb8793db770477d52afbab + uses: XRPLF/actions/prepare-runner@9355d190fd7d4de80fadfd161e6edddc9702cd9f with: enable_ccache: ${{ inputs.ccache_enabled }} - name: Set ccache log file if: ${{ inputs.ccache_enabled && runner.debug == '1' }} - run: echo "CCACHE_LOGFILE=${{ runner.temp }}/ccache.log" >> "${GITHUB_ENV}" + run: echo "CCACHE_LOGFILE=${{ runner.temp }}/ccache.log" >>"${GITHUB_ENV}" + + - name: Check tools + env: + CHECK_TOOLS_SKIP_CLONE: "1" + run: ./bin/check-tools.sh - name: Print build environment uses: XRPLF/actions/print-build-env@59dec886e4afb05a1724443af08baccbc045b574 @@ -124,6 +135,12 @@ jobs: with: subtract: ${{ inputs.nproc_subtract }} + - name: Set compiler environment (Linux) + if: ${{ runner.os == 'Linux' }} + uses: ./.github/actions/set-compiler-env + with: + compiler: ${{ inputs.compiler }} + - name: Setup Conan env: SANITIZERS: ${{ inputs.sanitizers }} @@ -146,11 +163,32 @@ jobs: CMAKE_ARGS: ${{ inputs.cmake_args }} run: | cmake \ - -G '${{ runner.os == 'Windows' && 'Visual Studio 17 2022' || 'Ninja' }}' \ - -DCMAKE_TOOLCHAIN_FILE:FILEPATH=build/generators/conan_toolchain.cmake \ - -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ - ${CMAKE_ARGS} \ - .. + -G '${{ runner.os == 'Windows' && 'Visual Studio 18 2026' || 'Ninja' }}' \ + -DCMAKE_TOOLCHAIN_FILE:FILEPATH=build/generators/conan_toolchain.cmake \ + -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ + ${CMAKE_ARGS} \ + .. + + # Export the sanitizer options before any instrumented binary runs. The + # protocol code-gen and build steps below invoke instrumented dependency + # tools (protoc, grpc), so setting UBSAN_OPTIONS here lets the UBSan + # suppression list silence their diagnostics too, not just at test time. + # GITHUB_WORKSPACE (not the github.workspace context) is used so the path + # resolves correctly inside the container job. + - name: Set sanitizer options + if: ${{ !inputs.build_only && env.SANITIZERS_ENABLED == 'true' }} + env: + CONFIG_NAME: ${{ inputs.config_name }} + run: | + SUPP="${GITHUB_WORKSPACE}/sanitizers/suppressions" + ASAN_OPTS="include=${SUPP}/runtime-asan-options.txt:suppressions=${SUPP}/asan.supp" + if [[ "${CONFIG_NAME}" == *gcc* ]]; then + ASAN_OPTS="${ASAN_OPTS}:alloc_dealloc_mismatch=0" + fi + echo "ASAN_OPTIONS=${ASAN_OPTS}" >>${GITHUB_ENV} + echo "TSAN_OPTIONS=include=${SUPP}/runtime-tsan-options.txt:suppressions=${SUPP}/tsan.supp" >>${GITHUB_ENV} + echo "UBSAN_OPTIONS=include=${SUPP}/runtime-ubsan-options.txt:suppressions=${SUPP}/ubsan.supp" >>${GITHUB_ENV} + echo "LSAN_OPTIONS=include=${SUPP}/runtime-lsan-options.txt:suppressions=${SUPP}/lsan.supp" >>${GITHUB_ENV} - name: Check protocol autogen files are up-to-date working-directory: ${{ env.BUILD_DIR }} @@ -172,32 +210,32 @@ jobs: cmake --build . --target code_gen DIFF=$(git -C .. status --porcelain -- include/xrpl/protocol_autogen src/tests/libxrpl/protocol_autogen) if [ -n "${DIFF}" ]; then - echo "::error::Generated protocol files are out of date" - git -C .. diff -- include/xrpl/protocol_autogen src/tests/libxrpl/protocol_autogen - echo "${MESSAGE}" - exit 1 + echo "::error::Generated protocol files are out of date" + git -C .. diff -- include/xrpl/protocol_autogen src/tests/libxrpl/protocol_autogen + echo "${MESSAGE}" + exit 1 fi - name: Build the binary working-directory: ${{ env.BUILD_DIR }} env: - BUILD_NPROC: ${{ runner.os == 'Linux' && '16' || steps.nproc.outputs.nproc }} + BUILD_NPROC: ${{ steps.nproc.outputs.nproc }} BUILD_TYPE: ${{ inputs.build_type }} CMAKE_TARGET: ${{ inputs.cmake_target }} run: | cmake \ - --build . \ - --config "${BUILD_TYPE}" \ - --parallel "${BUILD_NPROC}" \ - --target "${CMAKE_TARGET}" + --build . \ + --config "${BUILD_TYPE}" \ + --parallel "${BUILD_NPROC}" \ + --target "${CMAKE_TARGET}" - name: Show ccache statistics if: ${{ inputs.ccache_enabled }} run: | ccache --show-stats -vv if [ '${{ runner.debug }}' = '1' ]; then - cat "${CCACHE_LOGFILE}" - curl ${CCACHE_REMOTE_STORAGE%|*}/status || true + cat "${CCACHE_LOGFILE}" + curl ${CCACHE_REMOTE_STORAGE%|*}/status || true fi - name: Upload the binary (Linux) @@ -209,15 +247,24 @@ jobs: retention-days: 3 if-no-files-found: error + - name: Upload the test binary (Linux) + if: ${{ github.event.repository.visibility == 'public' && runner.os == 'Linux' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: xrpl_tests-${{ inputs.config_name }} + path: ${{ env.BUILD_DIR }}/xrpl_tests + retention-days: 3 + if-no-files-found: error + - name: Export server definitions if: ${{ runner.os != 'Windows' && !inputs.build_only && env.VOIDSTAR_ENABLED != 'true' }} working-directory: ${{ env.BUILD_DIR }} run: | set -o pipefail - ./xrpld --definitions | python3 -m json.tool > server_definitions.json + ./xrpld --definitions | python3 -m json.tool >server_definitions.json - name: Upload server definitions - if: ${{ github.event.repository.visibility == 'public' && inputs.config_name == 'debian-bookworm-gcc-13-amd64-release' }} + if: ${{ github.event.repository.visibility == 'public' && inputs.config_name == 'debian-gcc-release-amd64' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: server-definitions @@ -231,10 +278,10 @@ jobs: run: | ldd ./xrpld if [ "$(ldd ./xrpld | grep -E '(libstdc\+\+|libgcc)' | wc -l)" -eq 0 ]; then - echo 'The binary is statically linked.' + echo 'The binary is statically linked.' else - echo 'The binary is dynamically linked.' - exit 1 + echo 'The binary is dynamically linked.' + exit 1 fi - name: Verify presence of instrumentation (Linux) @@ -243,32 +290,10 @@ jobs: run: | ./xrpld --version | grep libvoidstar - - name: Set sanitizer options - if: ${{ !inputs.build_only && env.SANITIZERS_ENABLED == 'true' }} - env: - CONFIG_NAME: ${{ inputs.config_name }} - run: | - ASAN_OPTS="include=${GITHUB_WORKSPACE}/sanitizers/suppressions/runtime-asan-options.txt:suppressions=${GITHUB_WORKSPACE}/sanitizers/suppressions/asan.supp" - if [[ "${CONFIG_NAME}" == *gcc* ]]; then - ASAN_OPTS="${ASAN_OPTS}:alloc_dealloc_mismatch=0" - fi - echo "ASAN_OPTIONS=${ASAN_OPTS}" >> ${GITHUB_ENV} - echo "TSAN_OPTIONS=include=${GITHUB_WORKSPACE}/sanitizers/suppressions/runtime-tsan-options.txt:suppressions=${GITHUB_WORKSPACE}/sanitizers/suppressions/tsan.supp" >> ${GITHUB_ENV} - echo "UBSAN_OPTIONS=include=${GITHUB_WORKSPACE}/sanitizers/suppressions/runtime-ubsan-options.txt:suppressions=${GITHUB_WORKSPACE}/sanitizers/suppressions/ubsan.supp" >> ${GITHUB_ENV} - echo "LSAN_OPTIONS=include=${GITHUB_WORKSPACE}/sanitizers/suppressions/runtime-lsan-options.txt:suppressions=${GITHUB_WORKSPACE}/sanitizers/suppressions/lsan.supp" >> ${GITHUB_ENV} - - name: Run the separate tests if: ${{ !inputs.build_only }} - working-directory: ${{ env.BUILD_DIR }} - # Windows locks some of the build files while running tests, and parallel jobs can collide - env: - BUILD_TYPE: ${{ inputs.build_type }} - PARALLELISM: ${{ runner.os == 'Windows' && '1' || steps.nproc.outputs.nproc }} - run: | - ctest \ - --output-on-failure \ - -C "${BUILD_TYPE}" \ - -j "${PARALLELISM}" + working-directory: ${{ runner.os == 'Windows' && format('{0}/{1}', env.BUILD_DIR, inputs.build_type) || env.BUILD_DIR }} + run: ./xrpl_tests - name: Run the embedded tests if: ${{ !inputs.build_only }} @@ -278,8 +303,26 @@ jobs: run: | set -o pipefail # Coverage builds are slower due to instrumentation; use fewer parallel jobs to avoid flakiness - [ "$COVERAGE_ENABLED" = "true" ] && BUILD_NPROC=$(( BUILD_NPROC - 2 )) - ./xrpld --unittest --unittest-jobs "${BUILD_NPROC}" 2>&1 | tee unittest.log + [ "$COVERAGE_ENABLED" = "true" ] && BUILD_NPROC=$((BUILD_NPROC - 2)) + + # The resolver/preload workaround is only correct for the ASan build: + # a regular build doesn't hit the __dn_expand interceptor bug, and must + # NOT have libasan injected. So only preload when xrpld is ASan-built. + # + # libresolv hosts getaddrinfo's resolver helpers (dn_expand, res_*). Under ASan + # these are intercepted via dlsym(RTLD_NEXT, ...), which yields a NULL pointer + # and crashes DNS resolution if libresolv isn't loaded. Linking it guarantees + # the symbols are present; it's a harmless no-op on glibc >= 2.34 (merged into + # libc) and is what the compiler driver already does for sanitizer builds. + # https://github.com/llvm/llvm-project/issues/59007 + # https://github.com/google/sanitizers/issues/1592 + if ldd ./xrpld | grep -q libasan; then + PRELOAD="$(gcc -print-file-name=libasan.so):/usr/lib/x86_64-linux-gnu/libresolv.so.2" + else + PRELOAD="" + fi + + LD_PRELOAD="$PRELOAD" ./xrpld --unittest --unittest-jobs "${BUILD_NPROC}" 2>&1 | tee unittest.log - name: Show test failure summary if: ${{ failure() && !inputs.build_only }} @@ -287,19 +330,19 @@ jobs: WORKING_DIR: ${{ runner.os == 'Windows' && format('{0}\{1}', env.BUILD_DIR, inputs.build_type) || env.BUILD_DIR }} run: | if [ ! -d "${WORKING_DIR}" ]; then - echo "Working directory '${WORKING_DIR}' does not exist." - exit 0 + echo "Working directory '${WORKING_DIR}' does not exist." + exit 0 fi cd "${WORKING_DIR}" if [ ! -f unittest.log ]; then - echo "unittest.log not found; embedded tests may not have run." - exit 0 + echo "unittest.log not found; embedded tests may not have run." + exit 0 fi if ! grep -E "failed" unittest.log; then - echo "Log present but no failure lines found in unittest.log." + echo "Log present but no failure lines found in unittest.log." fi - name: Debug failure (Linux) if: ${{ failure() && runner.os == 'Linux' && !inputs.build_only }} @@ -317,14 +360,14 @@ jobs: BUILD_TYPE: ${{ inputs.build_type }} run: | cmake \ - --build . \ - --config "${BUILD_TYPE}" \ - --parallel "${BUILD_NPROC}" \ - --target coverage + --build . \ + --config "${BUILD_TYPE}" \ + --parallel "${BUILD_NPROC}" \ + --target coverage - name: Upload coverage report if: ${{ github.repository == 'XRPLF/rippled' && !inputs.build_only && env.COVERAGE_ENABLED == 'true' }} - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: disable_search: true disable_telem: true diff --git a/.github/workflows/reusable-build-test.yml b/.github/workflows/reusable-build-test.yml index 0086cbbfb5..4b64c53521 100644 --- a/.github/workflows/reusable-build-test.yml +++ b/.github/workflows/reusable-build-test.yml @@ -19,13 +19,6 @@ on: required: true type: string - strategy_matrix: - # TODO: Support additional strategies, e.g. "ubuntu" for generating all Ubuntu configurations. - description: 'The strategy matrix to use for generating the configurations ("minimal", "all").' - required: false - type: string - default: "minimal" - secrets: CODECOV_TOKEN: description: "The Codecov token to use for uploading coverage reports." @@ -37,7 +30,6 @@ jobs: uses: ./.github/workflows/reusable-strategy-matrix.yml with: os: ${{ inputs.os }} - strategy_matrix: ${{ inputs.strategy_matrix }} # Build and test the binary for each configuration. build-test-config: @@ -47,7 +39,6 @@ jobs: strategy: fail-fast: ${{ github.event_name == 'merge_group' }} matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} - max-parallel: 10 with: build_only: ${{ matrix.build_only }} build_type: ${{ matrix.build_type }} @@ -55,8 +46,9 @@ jobs: cmake_args: ${{ matrix.cmake_args }} cmake_target: ${{ matrix.cmake_target }} runs_on: ${{ toJSON(matrix.architecture.runner) }} - image: ${{ contains(matrix.architecture.platform, 'linux') && format('ghcr.io/xrplf/ci/{0}-{1}:{2}-{3}-sha-{4}', matrix.os.distro_name, matrix.os.distro_version, matrix.os.compiler_name, matrix.os.compiler_version, matrix.os.image_sha) || '' }} + image: ${{ matrix.image || '' }} config_name: ${{ matrix.config_name }} sanitizers: ${{ matrix.sanitizers }} + compiler: ${{ matrix.compiler || '' }} secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/reusable-check-levelization.yml b/.github/workflows/reusable-check-levelization.yml index 4efe3e1138..88c95ac3ba 100644 --- a/.github/workflows/reusable-check-levelization.yml +++ b/.github/workflows/reusable-check-levelization.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check levelization run: python .github/scripts/levelization/generate.py - name: Check for differences @@ -38,9 +38,9 @@ jobs: run: | DIFF=$(git status --porcelain) if [ -n "${DIFF}" ]; then - # Print the differences to give the contributor a hint about what to - # expect when running levelization on their own machine. - git diff - echo "${MESSAGE}" - exit 1 + # Print the differences to give the contributor a hint about what to + # expect when running levelization on their own machine. + git diff + echo "${MESSAGE}" + exit 1 fi diff --git a/.github/workflows/reusable-check-rename.yml b/.github/workflows/reusable-check-rename.yml index 56a1a3e637..9a91e98ee3 100644 --- a/.github/workflows/reusable-check-rename.yml +++ b/.github/workflows/reusable-check-rename.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check definitions run: .github/scripts/rename/definitions.sh . - name: Check copyright notices @@ -48,9 +48,9 @@ jobs: run: | DIFF=$(git status --porcelain) if [ -n "${DIFF}" ]; then - # Print the differences to give the contributor a hint about what to - # expect when running the renaming scripts on their own machine. - git diff - echo "${MESSAGE}" - exit 1 + # Print the differences to give the contributor a hint about what to + # expect when running the renaming scripts on their own machine. + git diff + echo "${MESSAGE}" + exit 1 fi diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index e01a50cf6d..b04847e137 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -3,10 +3,6 @@ name: Run clang-tidy on files on: workflow_call: inputs: - check_only_changed: - description: "Check only changed files in PR. If false, checks all files in the repository." - type: boolean - default: false create_issue_on_failure: description: "Whether to create an issue if the check failed" type: boolean @@ -20,32 +16,34 @@ env: BUILD_DIR: build BUILD_TYPE: Debug # Debug so that ASSERTS and such participate in clang-tidy check - OUTPUT_FILE: clang-tidy-output.txt - DIFF_FILE: clang-tidy-git-diff.txt - ISSUE_FILE: clang-tidy-issue.md + OUTPUT_FILE: /tmp/clang-tidy-output.txt + FILTERED_OUTPUT_FILE: /tmp/clang-tidy-filtered-output.txt + DIFF_FILE: /tmp/clang-tidy-git-diff.txt + ISSUE_FILE: /tmp/clang-tidy-issue.md + + COMPILER: clang jobs: determine-files: - if: ${{ inputs.check_only_changed }} permissions: contents: read - uses: XRPLF/actions/.github/workflows/determine-tidy-files.yml@224f3c48d3014d082a1129237b8291ff0b0a331f + uses: XRPLF/actions/.github/workflows/determine-tidy-files.yml@d041ac9f1fa9f07a4ba335eb4c1c82233fb3fef6 run-clang-tidy: name: Run clang tidy needs: [determine-files] - if: ${{ always() && !cancelled() && (!inputs.check_only_changed || needs.determine-files.outputs.cpp_changed_files != '' || needs.determine-files.outputs.clang_tidy_config_changed == 'true') }} + 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/ci/debian-trixie:clang-21-sha-53033a2" + container: "ghcr.io/xrplf/xrpld/nix-debian:sha-e29b523" permissions: contents: read issues: write steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@90f11ee655d1687824fb8793db770477d52afbab + uses: XRPLF/actions/prepare-runner@9355d190fd7d4de80fadfd161e6edddc9702cd9f with: enable_ccache: false @@ -56,6 +54,11 @@ jobs: uses: XRPLF/actions/get-nproc@cf0433aa74563aead044a1e395610c96d65a37cf id: nproc + - name: Set compiler environment + uses: ./.github/actions/set-compiler-env + with: + compiler: ${{ env.COMPILER }} + - name: Setup Conan uses: ./.github/actions/setup-conan @@ -70,13 +73,13 @@ jobs: working-directory: ${{ env.BUILD_DIR }} run: | cmake \ - -G 'Ninja' \ - -DCMAKE_TOOLCHAIN_FILE:FILEPATH=build/generators/conan_toolchain.cmake \ - -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ - -Dtests=ON \ - -Dwerr=ON \ - -Dxrpld=ON \ - .. + -G 'Ninja' \ + -DCMAKE_TOOLCHAIN_FILE:FILEPATH=build/generators/conan_toolchain.cmake \ + -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ + -Dtests=ON \ + -Dwerr=ON \ + -Dxrpld=ON \ + .. # clang-tidy needs headers generated from proto files - name: Build libxrpl.libpb @@ -88,15 +91,15 @@ jobs: id: run_clang_tidy continue-on-error: true env: - TARGETS: ${{ (needs.determine-files.outputs.clang_tidy_config_changed != 'true' && inputs.check_only_changed) && needs.determine-files.outputs.cpp_changed_files || 'src tests' }} + TARGETS: ${{ needs.determine-files.outputs.need_full_run != 'true' && needs.determine-files.outputs.cpp_changed_files || 'src tests' }} run: | set -o pipefail run-clang-tidy -j ${{ steps.nproc.outputs.nproc }} -p "${BUILD_DIR}" -quiet -fix -allow-no-checks ${TARGETS} 2>&1 | tee "${OUTPUT_FILE}" - - name: Print errors + - name: Print filtered clang-tidy errors if: ${{ steps.run_clang_tidy.outcome != 'success' }} run: | - sed '/error\||/!d' "${OUTPUT_FILE}" + bin/filter-clang-tidy.py "${OUTPUT_FILE}" - name: Upload clang-tidy output if: ${{ github.event.repository.visibility == 'public' && steps.run_clang_tidy.outcome != 'success' }} @@ -133,41 +136,41 @@ jobs: - name: Write issue header if: ${{ steps.run_clang_tidy.outcome != 'success' }} run: | - cat > "${ISSUE_FILE}" <"${ISSUE_FILE}" < filtered-output.txt || true + # Filter to the unique errors with their source context. + bin/filter-clang-tidy.py "${OUTPUT_FILE}" >"${FILTERED_OUTPUT_FILE}" || true - # If filtered output is empty, use original (might be a different error format) - if [ ! -s filtered-output.txt ]; then - cp "${OUTPUT_FILE}" filtered-output.txt - fi + # If filtered output is empty, use original (might be a different error format) + if [ ! -s "${FILTERED_OUTPUT_FILE}" ]; then + cp "${OUTPUT_FILE}" "${FILTERED_OUTPUT_FILE}" + fi - # Truncate if too large - head -c 60000 filtered-output.txt >> "${ISSUE_FILE}" - if [ "$(wc -c < filtered-output.txt)" -gt 60000 ]; then - echo "" >> "${ISSUE_FILE}" - echo "... (output truncated, see artifacts for full output)" >> "${ISSUE_FILE}" - fi + # Truncate if too large + head -c 60000 "${FILTERED_OUTPUT_FILE}" >>"${ISSUE_FILE}" + if [ "$(wc -c <"${FILTERED_OUTPUT_FILE}")" -gt 60000 ]; then + echo "" >>"${ISSUE_FILE}" + echo "... (output truncated, see artifacts for full output)" >>"${ISSUE_FILE}" + fi - rm filtered-output.txt + rm "${FILTERED_OUTPUT_FILE}" else - echo "No output file found" >> "${ISSUE_FILE}" + echo "No output file found" >>"${ISSUE_FILE}" fi - name: Append issue footer if: ${{ steps.run_clang_tidy.outcome != 'success' }} run: | - cat >> "${ISSUE_FILE}" <>"${ISSUE_FILE}" <> "${GITHUB_OUTPUT}" - - generate-version: - runs-on: ubuntu-latest - outputs: - version: ${{ steps.version.outputs.version }} - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: | - .github/actions/generate-version - src/libxrpl/protocol/BuildInfo.cpp - - name: Generate version - id: version - uses: ./.github/actions/generate-version + run: ./generate.py --packaging >>"${GITHUB_OUTPUT}" package: - needs: [generate-matrix, generate-version] + needs: [generate-matrix] if: ${{ github.event.repository.visibility == 'public' }} strategy: fail-fast: false @@ -66,12 +49,12 @@ jobs: permissions: contents: read runs-on: ["self-hosted", "Linux", "X64", "heavy"] - container: ${{ format('ghcr.io/xrplf/ci/{0}-{1}:{2}-{3}-sha-{4}', matrix.os.distro_name, matrix.os.distro_version, matrix.os.compiler_name, matrix.os.compiler_version, matrix.os.image_sha) }} + container: ${{ matrix.image }} timeout-minutes: 30 steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Download pre-built binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -84,14 +67,13 @@ jobs: - name: Build package env: - PKG_VERSION: ${{ needs.generate-version.outputs.version }} PKG_RELEASE: ${{ inputs.pkg_release }} run: ./package/build_pkg.sh - name: Upload package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: ${{ matrix.artifact_name }}-pkg-${{ needs.generate-version.outputs.version }} + name: ${{ matrix.artifact_name }}-pkg path: | ${{ env.BUILD_DIR }}/debbuild/*.deb ${{ env.BUILD_DIR }}/debbuild/*.ddeb diff --git a/.github/workflows/reusable-strategy-matrix.yml b/.github/workflows/reusable-strategy-matrix.yml index b1232a138f..c1a1c1a78b 100644 --- a/.github/workflows/reusable-strategy-matrix.yml +++ b/.github/workflows/reusable-strategy-matrix.yml @@ -4,15 +4,9 @@ on: workflow_call: inputs: os: - description: 'The operating system to use for the build ("linux", "macos", "windows").' + description: 'The operating system to use for the build ("linux", "macos", "windows", or empty for all).' required: false type: string - strategy_matrix: - # TODO: Support additional strategies, e.g. "ubuntu" for generating all Ubuntu configurations. - description: 'The strategy matrix to use for generating the configurations ("minimal", "all").' - required: false - type: string - default: "minimal" outputs: matrix: description: "The generated strategy matrix." @@ -29,17 +23,17 @@ jobs: matrix: ${{ steps.generate.outputs.matrix }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: 3.13 + python-version: "3.13" - name: Generate strategy matrix working-directory: .github/scripts/strategy-matrix id: generate env: - GENERATE_CONFIG: ${{ inputs.os != '' && format('--config={0}.json', inputs.os) || '' }} - GENERATE_OPTION: ${{ inputs.strategy_matrix == 'all' && '--all' || '' }} - run: ./generate.py ${GENERATE_OPTION} ${GENERATE_CONFIG} >> "${GITHUB_OUTPUT}" + GENERATE_CONFIG: ${{ inputs.os != '' && format('--config={0}', inputs.os) || '' }} + GENERATE_EVENT: ${{ github.event_name }} + run: ./generate.py ${GENERATE_CONFIG} --event="${GENERATE_EVENT}" >>"${GITHUB_OUTPUT}" diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml index d3fe0f356b..feeee0a621 100644 --- a/.github/workflows/reusable-upload-recipe.yml +++ b/.github/workflows/reusable-upload-recipe.yml @@ -14,7 +14,7 @@ on: description: "The URL of the Conan endpoint to use." required: false type: string - default: https://conan.ripplex.io + default: https://conan.xrplf.org/repository/conan/ secrets: remote_username: @@ -40,10 +40,14 @@ defaults: jobs: upload: runs-on: ubuntu-latest - container: ghcr.io/xrplf/ci/ubuntu-noble:gcc-13-sha-5dd7158 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-e29b523 + env: + REMOTE_NAME: ${{ inputs.remote_name }} + CONAN_LOGIN_USERNAME_XRPLF: ${{ secrets.remote_username }} + CONAN_PASSWORD_XRPLF: ${{ secrets.remote_password }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Generate build version number id: version @@ -56,15 +60,9 @@ jobs: remote_url: ${{ inputs.remote_url }} - name: Log into Conan remote - env: - REMOTE_NAME: ${{ inputs.remote_name }} - REMOTE_USERNAME: ${{ secrets.remote_username }} - REMOTE_PASSWORD: ${{ secrets.remote_password }} - run: conan remote login "${REMOTE_NAME}" "${REMOTE_USERNAME}" --password "${REMOTE_PASSWORD}" + run: conan remote login "${REMOTE_NAME}" "${CONAN_LOGIN_USERNAME_XRPLF}" --password "${CONAN_PASSWORD_XRPLF}" - name: Upload Conan recipe (version) - env: - REMOTE_NAME: ${{ inputs.remote_name }} run: | conan export . --version=${{ steps.version.outputs.version }} conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/${{ steps.version.outputs.version }} @@ -73,8 +71,6 @@ jobs: # 'develop' branch, see on-trigger.yml. - name: Upload Conan recipe (develop) if: ${{ github.event_name == 'push' }} - env: - REMOTE_NAME: ${{ inputs.remote_name }} run: | conan export . --version=develop conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/develop @@ -83,8 +79,6 @@ jobs: # one of the 'release' branches, see on-pr.yml. - name: Upload Conan recipe (rc) if: ${{ github.event_name == 'pull_request' }} - env: - REMOTE_NAME: ${{ inputs.remote_name }} run: | conan export . --version=rc conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/rc @@ -93,8 +87,6 @@ jobs: # release, see on-tag.yml. - name: Upload Conan recipe (release) if: ${{ startsWith(github.ref, 'refs/tags/') }} - env: - REMOTE_NAME: ${{ inputs.remote_name }} run: | conan export . --version=release conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/release diff --git a/.github/workflows/upload-conan-deps.yml b/.github/workflows/upload-conan-deps.yml index 34dce28334..88b364c2b1 100644 --- a/.github/workflows/upload-conan-deps.yml +++ b/.github/workflows/upload-conan-deps.yml @@ -30,10 +30,11 @@ on: - ".github/scripts/strategy-matrix/**" - conanfile.py - conan.lock + - conan/profiles/** env: CONAN_REMOTE_NAME: xrplf - CONAN_REMOTE_URL: https://conan.ripplex.io + CONAN_REMOTE_URL: https://conan.xrplf.org/repository/conan/ NPROC_SUBTRACT: 2 concurrency: @@ -48,8 +49,6 @@ jobs: # Generate the strategy matrix to be used by the following job. generate-matrix: uses: ./.github/workflows/reusable-strategy-matrix.yml - with: - strategy_matrix: ${{ github.event_name == 'pull_request' && 'minimal' || 'all' }} # Build and upload the dependencies for each configuration. run-upload-conan-deps: @@ -58,19 +57,18 @@ jobs: strategy: fail-fast: false matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} - max-parallel: 10 runs-on: ${{ matrix.architecture.runner }} - container: ${{ contains(matrix.architecture.platform, 'linux') && format('ghcr.io/xrplf/ci/{0}-{1}:{2}-{3}-sha-{4}', matrix.os.distro_name, matrix.os.distro_version, matrix.os.compiler_name, matrix.os.compiler_version, matrix.os.image_sha) || null }} + container: ${{ matrix.image || null }} steps: - name: Cleanup workspace (macOS and Windows) if: ${{ runner.os == 'macOS' || runner.os == 'Windows' }} uses: XRPLF/actions/cleanup-workspace@c7d9ce5ebb03c752a354889ecd870cadfc2b1cd4 - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@90f11ee655d1687824fb8793db770477d52afbab + uses: XRPLF/actions/prepare-runner@9355d190fd7d4de80fadfd161e6edddc9702cd9f with: enable_ccache: false @@ -83,6 +81,12 @@ jobs: with: subtract: ${{ env.NPROC_SUBTRACT }} + - name: Set compiler environment (Linux) + if: ${{ runner.os == 'Linux' }} + uses: ./.github/actions/set-compiler-env + with: + compiler: ${{ matrix.compiler }} + - name: Setup Conan env: SANITIZERS: ${{ matrix.sanitizers }} @@ -104,10 +108,12 @@ jobs: - name: Log into Conan remote if: ${{ github.repository == 'XRPLF/rippled' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} - run: conan remote login "${CONAN_REMOTE_NAME}" "${{ secrets.CONAN_REMOTE_USERNAME }}" --password "${{ secrets.CONAN_REMOTE_PASSWORD }}" + run: conan remote login "${CONAN_REMOTE_NAME}" "${{ secrets.NEXUS_REMOTE_USERNAME }}" --password "${{ secrets.NEXUS_REMOTE_PASSWORD }}" - name: Upload Conan packages if: ${{ github.repository == 'XRPLF/rippled' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} env: FORCE_OPTION: ${{ github.event.inputs.force_upload == 'true' && '--force' || '' }} + CONAN_LOGIN_USERNAME_XRPLF: ${{ secrets.NEXUS_REMOTE_USERNAME }} + CONAN_PASSWORD_XRPLF: ${{ secrets.NEXUS_REMOTE_PASSWORD }} run: conan upload "*" --remote="${CONAN_REMOTE_NAME}" --confirm ${FORCE_OPTION} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1313ad567c..4cbf4c1dd0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,6 +15,7 @@ repos: hooks: - id: check-added-large-files args: [--maxkb=400, --enforce-all] + - id: check-executables-have-shebangs - id: trailing-whitespace - id: end-of-file-fixer - id: check-merge-conflict @@ -35,39 +36,57 @@ repos: language: python types_or: [c++, c] exclude: ^include/xrpl/protocol_autogen/(transactions|ledger_entries)/ + - id: fix-pragma-once + name: fix missing '#pragma once' declarations in header files + language: python + entry: ./bin/pre-commit/fix_pragma_once.py + files: \.(h|hpp)$ - repo: https://github.com/pre-commit/mirrors-clang-format - rev: cd481d7b0bfb5c7b3090c21846317f9a8262e891 # frozen: v22.1.0 + rev: dd18dad857d6133e90bbe478f4f2f22ec0030269 # frozen: v22.1.5 hooks: - id: clang-format args: [--style=file] - "types_or": [c++, c, proto] + types_or: [c++, c, proto] exclude: ^include/xrpl/protocol_autogen/(transactions|ledger_entries)/ - - repo: https://github.com/BlankSpruce/gersemi - rev: 0.26.0 + - repo: https://github.com/BlankSpruce/gersemi-pre-commit + rev: faadd6a9d852369ca94f4d15b2404c967ba8cb01 # frozen: 0.27.6 hooks: - id: gersemi - repo: https://github.com/rbubley/mirrors-prettier - rev: c2bc67fe8f8f549cc489e00ba8b45aa18ee713b1 # frozen: v3.8.1 + rev: 515f543f5718ebfd6ce22e16708bb32c68ff96e1 # frozen: v3.8.3 hooks: - id: prettier args: [--end-of-line=auto] - repo: https://github.com/psf/black-pre-commit-mirror - rev: ea488cebbfd88a5f50b8bd95d5c829d0bb76feb8 # frozen: 26.1.0 + rev: 4160603246a6b365d4a2af661c6d71b0a0f50478 # frozen: 26.5.1 hooks: - id: black - - repo: https://github.com/openstack/bashate - rev: 5798d24d571676fc407e81df574c1ef57b520f23 # frozen: 2.1.1 + - repo: https://github.com/scop/pre-commit-shfmt + rev: 05c1426671b9237fb5e1444dd63aa5731bec0dfb # frozen: v3.13.1-1 hooks: - - id: bashate - args: ["--ignore=E006"] + - id: shfmt + args: [--write, --indent=4, --case-indent=true] + + - repo: local + hooks: + - id: format-inline-bash-workflows + name: "format `run:` blocks in workflows/actions" + entry: ./.github/scripts/format-inline-bash.py + language: python + files: ^\.github/(workflows|actions)/.*\.ya?ml$ + - id: format-inline-bash-markdown + name: "format ```bash blocks in markdown" + entry: ./.github/scripts/format-inline-bash.py + language: python + files: \.md$ - repo: https://github.com/streetsidesoftware/cspell-cli - rev: a42085ade523f591dca134379a595e7859986445 # frozen: v9.7.0 + rev: 4643f154907327ee0a2c7038f0296e0dd77d9776 # frozen: v10.0.0 hooks: - id: cspell # Spell check changed files exclude: | diff --git a/BUILD.md b/BUILD.md index 632832c002..847cd7bc1a 100644 --- a/BUILD.md +++ b/BUILD.md @@ -1,26 +1,57 @@ -| :warning: **WARNING** :warning: | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| These instructions assume you have a C++ development environment ready with Git, Python, Conan, CMake, and a C++ compiler. For help setting one up on Linux, macOS, or Windows, [see this guide](./docs/build/environment.md). | +| :warning: **WARNING** :warning: | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| These instructions assume you have a C++ development environment ready with Git, Python, Conan, CMake, and a C++ compiler. For help setting one up on Linux, macOS, or Windows, [see this guide](./docs/build/environment.md).

These instructions also assume a basic familiarity with Conan and CMake. If you are unfamiliar with Conan, you can read our [crash course](./docs/build/conan.md) or the official [Getting Started][conan-getting-started] walkthrough. | -> These instructions also assume a basic familiarity with Conan and CMake. -> If you are unfamiliar with Conan, you can read our -> [crash course](./docs/build/conan.md) or the official [Getting Started][3] -> walkthrough. +## Minimum Requirements -## Branches +See [System Requirements](https://xrpl.org/system-requirements.html). -For a stable release, choose the `master` branch or one of the [tagged -releases](https://github.com/XRPLF/rippled/releases). +Building xrpld generally requires Git, Python, Conan, CMake, and a C++ +compiler. + +- [Python](https://www.python.org/downloads/) +- [Conan](https://conan.io/downloads.html) +- [CMake](https://cmake.org/download/) + +You can verify that the required tools are installed and runnable with: ```bash -git checkout master +./bin/check-tools.sh ``` -For the latest release candidate, choose the `release` branch. +`xrpld` is written in the C++23 dialect. The [tested compiler versions][cpp23-support] are: -```bash -git checkout release -``` +| Compiler | Version | +| ----------- | --------------- | +| GCC | 15.2 | +| Clang | 22 | +| Apple Clang | 17 | +| MSVC | 19.44[^windows] | + +## Operating Systems + +Please see the [environment setup guide](./docs/build/environment.md) for detailed instructions for all platforms. + +### Linux + +The Ubuntu Linux distribution has received the highest level of quality +assurance, testing, and support. We also support Red Hat and use Debian +internally. +Our Linux CI tooling is distro-independent and uses a Nix-based environment, so it should be possible to build on other Linux distributions as well, although we have not tested them. + +### macOS + +Many `xrpld` engineers use macOS for development. + +### Windows + +Windows is used by some engineers for development only. + +[^windows]: Windows is not recommended for production use. + +## Steps + +### Branches For the latest set of untested features, or to contribute, choose the `develop` branch. @@ -29,55 +60,15 @@ branch. git checkout develop ``` -## Minimum Requirements +For a release candidate, choose the relevant release branch, e.g. +`release/3.2.x`. -See [System Requirements](https://xrpl.org/system-requirements.html). +```bash +git checkout release/3.2.x +``` -Building xrpld generally requires git, Python, Conan, CMake, and a C++ -compiler. Some guidance on setting up such a [C++ development environment can be -found here](./docs/build/environment.md). - -- [Python 3.11](https://www.python.org/downloads/), or higher -- [Conan 2.17](https://conan.io/downloads.html)[^1], or higher -- [CMake 3.22](https://cmake.org/download/), or higher - -[^1]: - It is possible to build with Conan 1.60+, but the instructions are - significantly different, which is why we are not recommending it. - -`xrpld` is written in the C++20 dialect and includes the `` header. -The [minimum compiler versions][2] required are: - -| Compiler | Version | -| ----------- | --------- | -| GCC | 12 | -| Clang | 16 | -| Apple Clang | 16 | -| MSVC | 19.44[^3] | - -### Linux - -The Ubuntu Linux distribution has received the highest level of quality -assurance, testing, and support. We also support Red Hat and use Debian -internally. - -Here are [sample instructions for setting up a C++ development environment on -Linux](./docs/build/environment.md#linux). - -### Mac - -Many xrpld engineers use macOS for development. - -Here are [sample instructions for setting up a C++ development environment on -macOS](./docs/build/environment.md#macos). - -### Windows - -Windows is used by some engineers for development only. - -[^3]: Windows is not recommended for production use. - -## Steps +For a stable release, choose one of the [tagged +releases](https://github.com/XRPLF/rippled/releases). ### Set Up Conan @@ -86,18 +77,11 @@ Conan, CMake, and a C++ compiler, you may need to set up your Conan profile. These instructions assume a basic familiarity with Conan and CMake. If you are unfamiliar with Conan, then please read [this crash course](./docs/build/conan.md) or the official -[Getting Started][3] walkthrough. +[Getting Started][conan-getting-started] walkthrough. -#### Conan lockfile +#### Profiles -To achieve reproducible dependencies, we use a [Conan lockfile](https://docs.conan.io/2/tutorial/versioning/lockfiles.html), -which has to be updated every time dependencies change. - -Please see the [instructions on how to regenerate the lockfile](conan/lockfile/README.md). - -#### Default profile - -We recommend that you import the provided `conan/profiles/default` profile: +We recommend that you install our Conan profiles: ```bash conan config install conan/profiles/ -tf $(conan config home)/profiles/ @@ -109,222 +93,15 @@ You can check your Conan profile by running: conan profile show ``` -#### Custom profile +If the default profile is not suitable for your environment, you can create a custom profile and pass it to Conan. +More information on customizing Conan can be found in the [Advanced Conan configuration](./docs/build/advanced_conan.md). -If the default profile does not work for you and you do not yet have a Conan -profile, you can create one by running: +#### Add xrplf remote + +Run the following command to add the `xrplf` remote, which hosts some of our dependencies: ```bash -conan profile detect -``` - -You may need to make changes to the profile to suit your environment. You can -refer to the provided `conan/profiles/default` profile for inspiration, and you -may also need to apply the required [tweaks](#conan-profile-tweaks) to this -default profile. - -### Patched recipes - -Occasionally, we need patched recipes or recipes not present in Conan Center. -We maintain a fork of the Conan Center Index -[here](https://github.com/XRPLF/conan-center-index/) containing the modified and newly added recipes. - -To ensure our patched recipes are used, you must add our Conan remote at a -higher index than the default Conan Center remote, so it is consulted first. You -can do this by running: - -```bash -conan remote add --index 0 xrplf https://conan.ripplex.io -``` - -Alternatively, you can pull our recipes from the repository and export them locally: - -```bash -# Define which recipes to export. -recipes=('abseil' 'ed25519' 'mpt-crypto' 'openssl' 'secp256k1' 'snappy' 'soci' 'wasm-xrplf' 'wasmi') - -# Selectively check out the recipes from our CCI fork. -cd external -mkdir -p conan-center-index -cd conan-center-index -git init -git remote add origin git@github.com:XRPLF/conan-center-index.git -git sparse-checkout init -for recipe in "${recipes[@]}"; do - echo "Checking out recipe '${recipe}'..." - git sparse-checkout add recipes/${recipe} -done -git fetch origin master -git checkout master - -./export_all.sh -cd ../../ -``` - -In the case we switch to a newer version of a dependency that still requires a -patch or add a new dependency, it will be necessary for you to pull in the changes and re-export the -updated dependencies with the newer version. However, if we switch to a newer -version that no longer requires a patch, no action is required on your part, as -the new recipe will be automatically pulled from the official Conan Center. - -> [!NOTE] -> You might need to add `--lockfile=""` to your `conan install` command -> to avoid automatic use of the existing `conan.lock` file when you run -> `conan export` manually on your machine -> -> This is not recommended though, as you might end up using different revisions of recipes. - -### Conan profile tweaks - -#### Missing compiler version - -If you see an error similar to the following after running `conan profile show`: - -```bash -ERROR: Invalid setting '17' is not a valid 'settings.compiler.version' value. -Possible values are ['5.0', '5.1', '6.0', '6.1', '7.0', '7.3', '8.0', '8.1', -'9.0', '9.1', '10.0', '11.0', '12.0', '13', '13.0', '13.1', '14', '14.0', '15', -'15.0', '16', '16.0'] -Read "http://docs.conan.io/2/knowledge/faq.html#error-invalid-setting" -``` - -you need to add your compiler to the list of compiler versions in -`$(conan config home)/settings_user.yml`, by adding the required version number(s) -to the `version` array specific for your compiler. For example: - -```yaml -compiler: - apple-clang: - version: ["17.0"] -``` - -#### Multiple compilers - -If you have multiple compilers installed, make sure to select the one to use in -your default Conan configuration **before** running `conan profile detect`, by -setting the `CC` and `CXX` environment variables. - -For example, if you are running MacOS and have [homebrew -LLVM@18](https://formulae.brew.sh/formula/llvm@18), and want to use it as a -compiler in the new Conan profile: - -```bash -export CC=$(brew --prefix llvm@18)/bin/clang -export CXX=$(brew --prefix llvm@18)/bin/clang++ -conan profile detect -``` - -You should also explicitly set the path to the compiler in the profile file, -which helps to avoid errors when `CC` and/or `CXX` are set and disagree with the -selected Conan profile. For example: - -```text -[conf] -tools.build:compiler_executables={'c':'/usr/bin/gcc','cpp':'/usr/bin/g++'} -``` - -#### Multiple profiles - -You can manage multiple Conan profiles in the directory -`$(conan config home)/profiles`, for example renaming `default` to a different -name and then creating a new `default` profile for a different compiler. - -#### Select language - -The default profile created by Conan will typically select different C++ dialect -than C++20 used by this project. You should set `20` in the profile line -starting with `compiler.cppstd=`. For example: - -```bash -sed -i.bak -e 's|^compiler\.cppstd=.*$|compiler.cppstd=20|' $(conan config home)/profiles/default -``` - -#### Select standard library in Linux - -**Linux** developers will commonly have a default Conan [profile][] that -compiles with GCC and links with libstdc++. If you are linking with libstdc++ -(see profile setting `compiler.libcxx`), then you will need to choose the -`libstdc++11` ABI: - -```bash -sed -i.bak -e 's|^compiler\.libcxx=.*$|compiler.libcxx=libstdc++11|' $(conan config home)/profiles/default -``` - -#### Select architecture and runtime in Windows - -**Windows** developers may need to use the x64 native build tools. An easy way -to do that is to run the shortcut "x64 Native Tools Command Prompt" for the -version of Visual Studio that you have installed. - -Windows developers must also build `xrpld` and its dependencies for the x64 -architecture: - -```bash -sed -i.bak -e 's|^arch=.*$|arch=x86_64|' $(conan config home)/profiles/default -``` - -**Windows** developers also must select static runtime: - -```bash -sed -i.bak -e 's|^compiler\.runtime=.*$|compiler.runtime=static|' $(conan config home)/profiles/default -``` - -#### Clang workaround for grpc - -If your compiler is clang, version 19 or later, or apple-clang, version 17 or -later, you may encounter a compilation error while building the `grpc` -dependency: - -```text -In file included from .../lib/promise/try_seq.h:26: -.../lib/promise/detail/basic_seq.h:499:38: error: a template argument list is expected after a name prefixed by the template keyword [-Wmissing-template-arg-list-after-template-kw] - 499 | Traits::template CallSeqFactory(f_, *cur_, std::move(arg))); - | ^ -``` - -The workaround for this error is to add two lines to profile: - -```text -[conf] -tools.build:cxxflags=['-Wno-missing-template-arg-list-after-template-kw'] -``` - -#### Workaround for gcc 12 - -If your compiler is gcc, version 12, and you have enabled `werr` option, you may -encounter a compilation error such as: - -```text -/usr/include/c++/12/bits/char_traits.h:435:56: error: 'void* __builtin_memcpy(void*, const void*, long unsigned int)' accessing 9223372036854775810 or more bytes at offsets [2, 9223372036854775807] and 1 may overlap up to 9223372036854775813 bytes at offset -3 [-Werror=restrict] - 435 | return static_cast(__builtin_memcpy(__s1, __s2, __n)); - | ~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~ -cc1plus: all warnings being treated as errors -``` - -The workaround for this error is to add two lines to your profile: - -```text -[conf] -tools.build:cxxflags=['-Wno-restrict'] -``` - -#### Workaround for clang 16 - -If your compiler is clang, version 16, you may encounter compilation error such -as: - -```text -In file included from .../boost/beast/websocket/stream.hpp:2857: -.../boost/beast/websocket/impl/read.hpp:695:17: error: call to 'async_teardown' is ambiguous - async_teardown(impl.role, impl.stream(), - ^~~~~~~~~~~~~~ -``` - -The workaround for this error is to add two lines to your profile: - -```text -[conf] -tools.build:cxxflags=['-DBOOST_ASIO_DISABLE_CONCEPTS'] +conan remote add --index 0 --force xrplf https://conan.xrplf.org/repository/conan/ ``` ### Set Up Ccache @@ -333,14 +110,7 @@ To speed up repeated compilations, we recommend that you install [ccache](https://ccache.dev), a tool that wraps your compiler so that it can cache build objects locally. -#### Linux - -You can install it using the package manager, e.g. `sudo apt install ccache` -(Ubuntu) or `sudo dnf install ccache` (RHEL). - -#### macOS - -You can install it using Homebrew, i.e. `brew install ccache`. +On Linux and macOS, `ccache` is included in the [Nix development shell](./docs/build/nix.md). #### Windows @@ -427,16 +197,19 @@ install ccache --version 4.11.3 --allow-downgrade`. Single-config generators: ``` - cmake --build . + cmake --build . --parallel N ``` Multi-config generators: ``` - cmake --build . --config Release - cmake --build . --config Debug + cmake --build . --config Release --parallel N + cmake --build . --config Debug --parallel N ``` + Replace the `--parallel` parameter N with the desired number of parallel jobs. A common starting point is half of the number of available CPU + cores. + 5. Test xrpld. Single-config generators: @@ -546,7 +319,7 @@ See [Sanitizers docs](./docs/build/sanitizers.md) for more details. | Option | Default Value | Description | | ---------- | ------------- | -------------------------------------------------------------- | -| `assert` | OFF | Enable assertions. | +| `assert` | OFF | Force enabling assertions. | | `coverage` | OFF | Prepare the coverage report. | | `tests` | OFF | Build tests. | | `unity` | OFF | Configure a unity build. | @@ -554,7 +327,7 @@ See [Sanitizers docs](./docs/build/sanitizers.md) for more details. | `werr` | OFF | Treat compilation warnings as errors | | `wextra` | OFF | Enable additional compilation warnings | -[Unity builds][5] may be faster for the first build (at the cost of much more +[Unity builds][unity-build] may be faster for the first build (at the cost of much more memory) since they concatenate sources into fewer translation units. Non-unity builds may be faster for incremental builds, and can be helpful for detecting `#include` omissions. @@ -580,14 +353,14 @@ After any updates or changes to dependencies, you may need to do the following: conan remove '*' ``` -3. Re-run [conan export](#patched-recipes) if needed. -4. [Regenerate lockfile](#conan-lockfile). +3. Re-run [conan export](./docs/build/advanced_conan.md#patched-recipes) if needed. +4. [Regenerate lockfile](./docs/build/advanced_conan.md#conan-lockfile). 5. Re-run [conan install](#build-and-test). #### ERROR: Package not resolved If you're seeing an error like `ERROR: Package 'snappy/1.1.10' not resolved: Unable to find 'snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1756234314.246' in remotes.`, -please add `xrplf` remote or re-run `conan export` for [patched recipes](#patched-recipes). +please [add `xrplf` remote](#add-xrplf-remote) or re-run `conan export` for [patched recipes](./docs/build/advanced_conan.md#patched-recipes). ### `protobuf/port_def.inc` file not found @@ -607,28 +380,9 @@ For example, if you want to build Debug: 1. For conan install, pass `--settings build_type=Debug` 2. For cmake, pass `-DCMAKE_BUILD_TYPE=Debug` -## Add a Dependency - -If you want to experiment with a new package, follow these steps: - -1. Search for the package on [Conan Center](https://conan.io/center/). -2. Modify [`conanfile.py`](./conanfile.py): - - Add a version of the package to the `requires` property. - - Change any default options for the package by adding them to the - `default_options` property (with syntax `'$package:$option': $value`). -3. Modify [`CMakeLists.txt`](./CMakeLists.txt): - - Add a call to `find_package($package REQUIRED)`. - - Link a library from the package to the target `xrpl_libs` - (search for the existing call to `target_link_libraries(xrpl_libs INTERFACE ...)`). -4. Start coding! Don't forget to include whatever headers you need from the package. - -[1]: https://github.com/conan-io/conan-center-index/issues/13168 -[2]: https://en.cppreference.com/w/cpp/compiler_support/20 -[3]: https://docs.conan.io/en/latest/getting_started.html -[5]: https://en.wikipedia.org/wiki/Unity_build -[6]: https://github.com/boostorg/beast/issues/2648 -[7]: https://github.com/boostorg/beast/issues/2661 +[cpp23-support]: https://en.cppreference.com/w/cpp/compiler_support/23 +[conan-getting-started]: https://docs.conan.io/en/latest/getting_started.html +[unity-build]: https://en.wikipedia.org/wiki/Unity_build [gcovr]: https://gcovr.com/en/stable/getting-started.html [python-pip]: https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/ [build_type]: https://cmake.org/cmake/help/latest/variable/CMAKE_BUILD_TYPE.html -[profile]: https://docs.conan.io/en/latest/reference/profiles.html diff --git a/CMakeLists.txt b/CMakeLists.txt index d315a5dcec..1e8befcc8f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,7 +15,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") project(xrpl) set(CMAKE_CXX_EXTENSIONS OFF) -set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) @@ -57,6 +57,8 @@ if(target) ) endif() +include(PatchNixBinary) + include(XrplSanity) include(XrplVersion) include(XrplSettings) @@ -88,6 +90,7 @@ find_package(ed25519 REQUIRED) find_package(gRPC REQUIRED) find_package(LibArchive REQUIRED) find_package(lz4 REQUIRED) +find_package(mpt-crypto REQUIRED) find_package(nudb REQUIRED) find_package(OpenSSL REQUIRED) find_package(secp256k1 REQUIRED) @@ -100,6 +103,7 @@ target_link_libraries( INTERFACE ed25519::ed25519 lz4::lz4 + mpt-crypto::mpt-crypto OpenSSL::Crypto OpenSSL::SSL secp256k1::secp256k1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25dd7ac059..fc93223925 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,9 +14,9 @@ The following branches exist in the main project repository: - `develop`: The latest set of unreleased features, and the most common starting point for contributions. -- `release`: The latest beta release or release candidate. -- `master`: The latest stable release. -- `gh-pages`: The documentation for this project, built by Doxygen. +- `release/*` (e.g. `release/3.2.x`): Release branches, one per release line, + holding the latest release candidate, or stable release for that line. + Stable releases are published as [tagged releases](https://github.com/XRPLF/rippled/releases). The tip of each branch must be signed. In order for GitHub to sign a squashed commit that it builds from your pull request, GitHub must know @@ -130,11 +130,9 @@ tl;dr ## Pull requests In general, pull requests use `develop` as the base branch. -The exceptions are -- Fixes and improvements to a release candidate use `release` as the - base. -- Hotfixes use `master` as the base. +The exceptions are fixes, improvements, and hotfixes for an existing release, +which use that release's branch (e.g. `release/3.2.x`) as the base. If your changes are not quite ready, but you want to make it easily available for preliminary examination or review, you can create a "Draft" pull request. @@ -216,7 +214,7 @@ coherent rather than a set of _thou shalt not_ commandments. ## Formatting -All code must conform to `clang-format` version 21, +All code must conform to `clang-format` version 22, according to the settings in [`.clang-format`](./.clang-format), unless the result would be unreasonably difficult to read or maintain. To demarcate lines that should be left as-is, surround them with comments like @@ -261,7 +259,7 @@ This ensures that configuration changes don't introduce new warnings across the ### Installing clang-tidy -See the [environment setup guide](./docs/build/environment.md#clang-tidy) for platform-specific installation instructions. +See the [environment setup guide](./docs/build/environment.md#clang-tidy) for how to get clang-tidy. ### Running clang-tidy locally diff --git a/bin/check-tools.sh b/bin/check-tools.sh new file mode 100755 index 0000000000..808f384d5b --- /dev/null +++ b/bin/check-tools.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# +# check-tools.sh — verify the xrpld development tooling is present and runnable. +# +# Works on Linux, macOS, and Windows (Git Bash / MSYS). For every expected tool +# it runs a version probe, collecting anything that is missing or fails to run, +# and prints a summary at the end (exiting non-zero if anything is missing). +# +# The tool set is platform-aware: +# - Linux: the full Nix CI environment (see nix/packages.nix, nix/ci-env.nix), +# with GCC, Clang and the sanitizer/coverage tooling. This script is +# run during the Nix Docker image build (nix/docker/Dockerfile), so +# the Linux list is kept in sync with that environment. +# - macOS: the same tooling, minus GCC/g++/gcov/mold +# - Windows: the core build tools only (CMake, Conan, Git, Python). +# MSVC is expected to be provided separately and is not checked here. +# +# Some tools (clang-format, doxygen, gcovr, gh, git-cliff, gpg, pre-commit, +# run-clang-tidy) are present in our Linux CI images and in local development +# setups, but not in the macOS CI environment. They are checked everywhere +# except when running in CI on macOS. +# +# Environment variables: +# CI if set, skip the tools above when on macOS. +# CHECK_TOOLS_SKIP_CLONE if set, skip the git-over-HTTPS connectivity check. + +set -uo pipefail + +missing=() +checked=0 + +# check [probe-command...] +# Runs the probe (default: " --version") quietly. Records as +# missing if the command is not found or exits non-zero. +check() { + local name="$1" + shift + local -a probe=("$@") + if [ "${#probe[@]}" -eq 0 ]; then + probe=("${name}" --version) + fi + + echo "Checking ${name}..." + checked=$((checked + 1)) + if "${probe[@]}" | head -n 1; then + printf ' [ ok ] %s\n' "${name}" + else + printf ' [MISS] %s\n' "${name}" + missing+=("${name}") + fi +} + +case "$(uname -s)" in + Linux*) os=linux ;; + Darwin*) os=macos ;; + MINGW* | MSYS* | CYGWIN*) os=windows ;; + *) + echo "Unknown OS: $(uname -s)" >&2 + exit 1 + ;; +esac + +echo "Detected OS: ${os} ($(uname -s) $(uname -m))" +echo +echo "Core build tools:" +check cmake +check conan +check git +if [ "${os}" = "windows" ]; then + check python python --version +else + check python3 +fi + +# The full development toolchain. Available from Nix on Linux and macOS; on +# Windows these are typically not installed, so they are skipped. +if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then + echo + echo "Development tooling:" + check ccache + check clang + check clang++ + check ClangBuildAnalyzer + check curl + check file + check less + check make + check netstat which netstat + check ninja + check perl + check pkg-config + check vim + check zip + + # These tools are present in our Linux CI images and in local development + # setups, but not in the macOS CI environment. So check them everywhere + # except when running in CI on macOS. + if [ "${os}" = "linux" ] || [ -z "${CI:-}" ]; then + check clang-format + check dot + check doxygen + check gcovr + check gh + check git-cliff + check git-lfs + check gpg + # pre-commit, or its alternative implementation prek + check pre-commit sh -c 'pre-commit --version || prek --version' + check run-clang-tidy run-clang-tidy --help + fi +fi + +# GCC is the default compiler on Linux. macOS uses the system Apple Clang +# instead, so GCC/g++/gcov are not expected there. +if [ "${os}" = "linux" ]; then + echo + echo "GCC toolchain:" + check gcc + check g++ + check gcov + + echo + echo "Mold:" + check mold +fi + +if [ "${os}" = "windows" ]; then + echo + echo "Note: on Windows the C++ compiler is MSVC, which is provided" + echo " separately (e.g. via Visual Studio) and is not checked here." +fi + +# A simple test to verify that git can clone a repository over HTTPS +# (i.e. the CA bundle is wired up). Clone to a temp dir and clean up. +if [ -n "${CHECK_TOOLS_SKIP_CLONE:-}" ]; then + echo + echo "Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set)." +else + echo + echo "Connectivity check:" + checked=$((checked + 1)) + tmp_clone="$(mktemp -d)" + if git clone --depth 1 https://github.com/XRPLF/actions.git "${tmp_clone}/actions" >/dev/null 2>&1; then + printf ' [ ok ] git clone over HTTPS\n' + else + printf ' [MISS] git clone over HTTPS\n' + missing+=("git-https-clone") + fi + rm -rf "${tmp_clone}" +fi + +echo +if [ "${#missing[@]}" -eq 0 ]; then + echo "All ${checked} checked tools are present and runnable." +else + echo "Missing or non-functional tools (${#missing[@]} of ${checked}):" >&2 + for tool in "${missing[@]}"; do + echo " - ${tool}" >&2 + done + exit 1 +fi diff --git a/bin/filter-clang-tidy.py b/bin/filter-clang-tidy.py new file mode 100755 index 0000000000..204a4e36f3 --- /dev/null +++ b/bin/filter-clang-tidy.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 + +""" +Reduce run-clang-tidy output to its unique errors. + +It does two things: + + 1. Filters the raw output down to diagnostics and their source-context lines + (the indented " 103 | ..." / " | ^" lines clang-tidy prints), + matching the "path:line:col: error:" diagnostic shape. + + 2. Deduplicates. The same diagnostic in a header is reported once per + translation unit that includes it, so identical error blocks are collapsed + to their first occurrence. + +An "error block" is an "error:" line together with the indented context lines +and any "note:" lines that follow it (up to the next "error:" line). Blocks are +compared as a whole, so an error stays attached to its own context, and +first-occurrence order is preserved. + +The deduplicated output goes to stdout; a summary of unique error counts per +check is printed to stderr. + +Usage: + bin/filter-clang-tidy.py [INPUT_FILE] # read from file, or + run-clang-tidy ... | bin/filter-clang-tidy.py # read from stdin +""" + +import re +import sys +from collections import Counter + +# A clang-tidy diagnostic line looks like "path:line:col: error: msg [check]". +# Matching on that shape (rather than a loose "error" substring) avoids treating +# progress lines whose paths contain "error" as diagnostics, e.g. +# [284/850][0.7s] /nix/.../clang-tidy ... src/.../error.cpp +DIAG_RE = re.compile(r":\d+:\d+: (?:error|warning|note):") +ERROR_RE = re.compile(r":\d+:\d+: error:") +CHECK_RE = re.compile(r" error: .*\[([^\],]+)") + + +def filter_and_dedup(lines: list[str]) -> list[str]: + """Keep diagnostics with their context, then drop duplicate error blocks.""" + blocks: list[str] = [] + seen: set[str] = set() + current: list[str] = [] + + def flush() -> None: + if not current: + return + block = "".join(current) + if block not in seen: + seen.add(block) + blocks.append(block) + + for line in lines: + # Keep only diagnostics and their indented source-context lines; drop + # progress/status output and blank lines. + if not (DIAG_RE.search(line) or line[:1] in (" ", "\t")): + continue + # An "error:" line starts a new block; its context and any following + # "note:" lines (and their context) belong to it. + if ERROR_RE.search(line): + flush() + current = [] + current.append(line) + flush() + + return blocks + + +def summarize(blocks: list[str]) -> Counter[str]: + """Count unique errors per check name (e.g. "bugprone-branch-clone").""" + counts: Counter[str] = Counter() + for block in blocks: + # The error line is the first line of the block. + match = CHECK_RE.search(block.splitlines()[0]) + if match: + counts[match.group(1)] += 1 + return counts + + +def main() -> int: + if len(sys.argv) > 1 and sys.argv[1] != "-": + with open(sys.argv[1], encoding="utf-8") as f: + lines = f.readlines() + else: + lines = sys.stdin.readlines() + + blocks = filter_and_dedup(lines) + # Blank line between blocks so distinct errors are easy to tell apart. + sys.stdout.write("\n".join(blocks)) + + print("\nUnique errors per check:", file=sys.stderr) + for check, count in summarize(blocks).most_common(): + print(f"{count:>4} {check}", file=sys.stderr) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/git/setup-upstreams.sh b/bin/git/setup-upstreams.sh index 57c3f935f9..97c84f5507 100755 --- a/bin/git/setup-upstreams.sh +++ b/bin/git/setup-upstreams.sh @@ -1,8 +1,8 @@ #!/bin/bash if [[ $# -ne 1 || "$1" == "--help" || "$1" == "-h" ]]; then - name=$( basename $0 ) - cat <<- USAGE + name=$(basename $0) + cat <<-USAGE Usage: $name Where is the Github username of the upstream repo. e.g. XRPLF @@ -14,7 +14,7 @@ fi shift user="$1" # Get the origin URL. Expect it be an SSH-style URL -origin=$( git remote get-url origin ) +origin=$(git remote get-url origin) if [[ "${origin}" == "" ]]; then echo Invalid origin remote >&2 exit 1 @@ -22,11 +22,11 @@ fi # echo "Origin: ${origin}" # Parse the origin ifs_orig="${IFS}" -IFS=':' read remote originpath <<< "${origin}" +IFS=':' read remote originpath <<<"${origin}" # echo "Remote: ${remote}, Originpath: ${originpath}" -IFS='@' read sshuser server <<< "${remote}" +IFS='@' read sshuser server <<<"${remote}" # echo "SSHUser: ${sshuser}, Server: ${server}" -IFS='/' read originuser repo <<< "${originpath}" +IFS='/' read originuser repo <<<"${originpath}" # echo "Originuser: ${originuser}, Repo: ${repo}" if [[ "${sshuser}" == "" || "${server}" == "" || "${originuser}" == "" || "${repo}" == "" ]]; then echo "Can't parse origin URL: ${origin}" >&2 @@ -35,9 +35,9 @@ fi upstream="https://${server}/${user}/${repo}" upstreampush="${remote}:${user}/${repo}" upstreamgroup="upstream upstream-push" -current=$( git remote get-url upstream 2>/dev/null ) -currentpush=$( git remote get-url upstream-push 2>/dev/null ) -currentgroup=$( git config remotes.upstreams ) +current=$(git remote get-url upstream 2>/dev/null) +currentpush=$(git remote get-url upstream-push 2>/dev/null) +currentgroup=$(git config remotes.upstreams) if [[ "${current}" == "${upstream}" ]]; then echo "Upstream already set up correctly. Skip" elif [[ -n "${current}" && "${current}" != "${upstream}" && "${current}" != "${upstreampush}" ]]; then @@ -45,9 +45,9 @@ elif [[ -n "${current}" && "${current}" != "${upstream}" && "${current}" != "${u else if [[ "${current}" == "${upstreampush}" ]]; then echo "Upstream set to dangerous push URL. Update." - _run git remote rename upstream upstream-push || \ - _run git remote remove upstream - currentpush=$( git remote get-url upstream-push 2>/dev/null ) + _run git remote rename upstream upstream-push || + _run git remote remove upstream + currentpush=$(git remote get-url upstream-push 2>/dev/null) fi _run git remote add upstream "${upstream}" fi diff --git a/bin/git/squash-branches.sh b/bin/git/squash-branches.sh index eb4aefe23c..ba63f9c148 100755 --- a/bin/git/squash-branches.sh +++ b/bin/git/squash-branches.sh @@ -1,8 +1,8 @@ #!/bin/bash if [[ $# -lt 3 || "$1" == "--help" || "$1" = "-h" ]]; then - name=$( basename $0 ) - cat <<- USAGE + name=$(basename $0) + cat <<-USAGE Usage: $name workbranch base/branch user/branch [user/branch [...]] * workbranch will be created locally from base/branch @@ -16,7 +16,7 @@ fi work="$1" shift -branches=( $( echo "${@}" | sed "s/:/\//" ) ) +branches=($(echo "${@}" | sed "s/:/\//")) base="${branches[0]}" unset branches[0] @@ -24,10 +24,10 @@ set -e users=() for b in "${branches[@]}"; do - users+=( $( echo $b | cut -d/ -f1 ) ) + users+=($(echo $b | cut -d/ -f1)) done -users=( $( printf '%s\n' "${users[@]}" | sort -u ) ) +users=($(printf '%s\n' "${users[@]}" | sort -u)) git fetch --multiple upstreams "${users[@]}" git checkout -B "$work" --no-track "$base" @@ -40,7 +40,7 @@ done # Make sure the commits look right git log --show-signature "$base..HEAD" -parts=( $( echo $base | sed "s/\// /" ) ) +parts=($(echo $base | sed "s/\// /")) repo="${parts[0]}" b="${parts[1]}" push=$repo @@ -50,7 +50,7 @@ fi if [[ "$repo" == "upstream" ]]; then repo="upstreams" fi -cat << PUSH +cat </dev/null ) || true +push=$(git rev-parse --abbrev-ref --symbolic-full-name '@{push}' \ + 2>/dev/null) || true if [[ "${push}" != "" ]]; then echo "Warning: ${push} may already exist." fi -build=$( find -name BuildInfo.cpp ) -sed 's/\(^.*versionString =\).*$/\1 "'${version}'"/' ${build} > version.cpp && \ -diff "${build}" version.cpp && exit 1 || \ -mv -vi version.cpp ${build} +build=$(find -name BuildInfo.cpp) +sed 's/\(^.*versionString =\).*$/\1 "'${version}'"/' ${build} >version.cpp && + diff "${build}" version.cpp && exit 1 || + mv -vi version.cpp ${build} git diff @@ -47,7 +47,7 @@ git commit -S -m "Set version to ${version}" git log --oneline --first-parent ${base}^.. -cat << PUSH +cat <&2 + exit 1 +fi + +# shellcheck source=/dev/null +. /etc/os-release + +echo "Detected OS: ${ID} ${VERSION_ID:-}" + +case "${ID}" in + ubuntu | debian | rhel | centos | rocky | almalinux) + echo "Supported OS detected: ${ID}" + ;; + *) + echo "ERROR: unsupported OS '${ID}'. Supported: debian, ubuntu, rhel-family" >&2 + exit 1 + ;; +esac + +function preinstall() { + case "${ID}" in + ubuntu) + apt-get update -y + apt-get install -y --no-install-recommends \ + gnupg \ + software-properties-common + add-apt-repository -y ppa:ubuntu-toolchain-r/test + ;; + esac +} + +function install() { + case "${ID}" in + debian | ubuntu) + apt-get update -y + apt-get install -y --no-install-recommends \ + libasan8 \ + libtsan2 \ + libubsan1 + ;; + + rhel | centos | rocky | almalinux) + dnf install -y \ + libasan8 \ + libtsan2 \ + libubsan + ;; + esac +} + +function postinstall() { + # Don't clear cache in non-CI environments + if [ -z "${CI:-}" ]; then + echo "Not running in CI environment; skipping cache cleanup" + return + fi + + case "${ID}" in + debian | ubuntu) + apt-get clean + rm -rf /var/lib/apt/lists/* + ;; + + rhel | centos | rocky | almalinux) + dnf clean -y all + rm -rf /var/cache/dnf/* + ;; + esac +} + +function verify() { + # Verify that every expected library is now resolvable by the dynamic linker. + missing=0 + for lib in libasan.so.8 libtsan.so.2 libubsan.so.1; do + if ldconfig -p | grep -q "${lib}"; then + echo "OK: ${lib} found" + else + echo "ERROR: ${lib} not found after installation" >&2 + missing=$((missing + 1)) + fi + done + + if [ "${missing}" -ne 0 ]; then + echo "ERROR: ${missing} library/libraries missing" >&2 + exit 1 + fi +} + +preinstall +install +postinstall +verify + +echo "All sanitizer runtime libraries installed successfully." diff --git a/bin/pre-commit/clang_tidy_check.py b/bin/pre-commit/clang_tidy_check.py index 7fb51d1c46..f134660671 100755 --- a/bin/pre-commit/clang_tidy_check.py +++ b/bin/pre-commit/clang_tidy_check.py @@ -168,7 +168,13 @@ def main(): if not os.environ.get("TIDY"): return 0 - repo_root = Path(__file__).parent.parent + repo_root = Path( + subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], + cwd=Path(__file__).parent, + text=True, + ).strip() + ) files = staged_files(repo_root) if not files: return 0 diff --git a/bin/pre-commit/fix_pragma_once.py b/bin/pre-commit/fix_pragma_once.py new file mode 100755 index 0000000000..08a505b6d0 --- /dev/null +++ b/bin/pre-commit/fix_pragma_once.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 + +""" +Adds "#pragma once" to the top of header files that don't already have it. + +Usage: ./bin/pre-commit/fix_pragma_once.py ... +""" + +import sys +from pathlib import Path + +PRAGMA_ONCE = "#pragma once\n\n" + + +def fix_pragma_once(path: Path) -> bool: + original = path.read_text(encoding="utf-8") + if PRAGMA_ONCE not in original: + path.write_text(PRAGMA_ONCE + original, encoding="utf-8") + return False + return True + + +def main() -> int: + files = [Path(f) for f in sys.argv[1:]] + success = True + + for path in files: + success &= fix_pragma_once(path) + + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cfg/xrpld-example.cfg b/cfg/xrpld-example.cfg index effc62c274..9e334e6f4f 100644 --- a/cfg/xrpld-example.cfg +++ b/cfg/xrpld-example.cfg @@ -953,6 +953,21 @@ # # Optional keys for NuDB and RocksDB: # +# cache_size Size of cache for database records. Default is 16384. +# Setting this value to 0 will use the default value. +# +# cache_age Length of time in minutes to keep database records +# cached. Default is 5 minutes. Setting this value to +# 0 will use the default value. +# +# Note: if cache_size or cache_age is not specified, +# default values will be used for the unspecified +# parameter. +# +# Note: the cache will not be created if online_delete +# is specified, because the rotating NodeStore does +# not use this cache). +# # fast_load Boolean. If set, load the last persisted ledger # from disk upon process start before syncing to # the network. This is likely to improve performance diff --git a/cmake/CompilationEnv.cmake b/cmake/CompilationEnv.cmake index 0d44f90974..8e69a4dfdd 100644 --- a/cmake/CompilationEnv.cmake +++ b/cmake/CompilationEnv.cmake @@ -56,3 +56,16 @@ elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64|ARM64") else() message(FATAL_ERROR "Unknown architecture: ${CMAKE_SYSTEM_PROCESSOR}") endif() + +# -------------------------------------------------------------------- +# Sanitizers +# -------------------------------------------------------------------- +# SANITIZERS is injected by the Conan toolchain when a sanitizer build is +# requested (see conan/profiles/sanitizers). The flags are applied to the +# 'common' target in XrplSanitizers; this flag lets other modules know a +# sanitizer build is active without depending on that module. +if(DEFINED SANITIZERS) + set(SANITIZERS_ENABLED TRUE) +else() + set(SANITIZERS_ENABLED FALSE) +endif() diff --git a/cmake/PatchNixBinary.cmake b/cmake/PatchNixBinary.cmake new file mode 100644 index 0000000000..79ca0b150c --- /dev/null +++ b/cmake/PatchNixBinary.cmake @@ -0,0 +1,53 @@ +#[===================================================================[ + Patch executables to run in non-Nix environments. + + The Nix-based CI image links binaries against an ELF interpreter (loader) + that lives in the Nix store, so the resulting binaries don't run elsewhere + (including once installed from the .deb package). `patch_nix_binary` adds a + POST_BUILD step that resets the interpreter to the system default loader and + drops the rpath. + + This is only active inside the Nix-based image, detected by the presence of + /tmp/loader-path.sh (shipped by that image, resolves the default loader). It + is skipped for sanitizer builds, whose runtime libraries are resolved through + the rpath. Everywhere else `patch_nix_binary` is a no-op. +#]===================================================================] + +include_guard(GLOBAL) + +include(CompilationEnv) + +# Provided by the Nix-based CI image; prints the system default ELF loader path. +set(_loader_path_script "/tmp/loader-path.sh") + +if(is_linux AND NOT SANITIZERS_ENABLED AND EXISTS "${_loader_path_script}") + execute_process( + COMMAND "${_loader_path_script}" + OUTPUT_VARIABLE DEFAULT_LOADER_PATH + OUTPUT_STRIP_TRAILING_WHITESPACE + COMMAND_ERROR_IS_FATAL ANY + ) + find_program(PATCHELF_COMMAND patchelf REQUIRED) + set(PATCH_NIX_BINARIES TRUE) + message( + STATUS + "Binaries will be patched to use loader '${DEFAULT_LOADER_PATH}'" + ) +else() + set(PATCH_NIX_BINARIES FALSE) +endif() + +function(patch_nix_binary target) + if(NOT PATCH_NIX_BINARIES) + return() + endif() + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND + "${PATCHELF_COMMAND}" --set-interpreter "${DEFAULT_LOADER_PATH}" + --remove-rpath "$" + COMMENT "Patching ${target}: set default loader, remove rpath" + VERBATIM + ) +endfunction() diff --git a/cmake/XrplAddTest.cmake b/cmake/XrplAddTest.cmake deleted file mode 100644 index 2f1209e03c..0000000000 --- a/cmake/XrplAddTest.cmake +++ /dev/null @@ -1,22 +0,0 @@ -include(isolate_headers) - -function(xrpl_add_test name) - set(target ${PROJECT_NAME}.test.${name}) - - file( - GLOB_RECURSE sources - CONFIGURE_DEPENDS - "${CMAKE_CURRENT_SOURCE_DIR}/${name}/*.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/${name}.cpp" - ) - add_executable(${target} ${ARGN} ${sources}) - - isolate_headers( - ${target} - "${CMAKE_SOURCE_DIR}" - "${CMAKE_SOURCE_DIR}/tests/${name}" - PRIVATE - ) - - add_test(NAME ${target} COMMAND ${target}) -endfunction() diff --git a/cmake/XrplCompiler.cmake b/cmake/XrplCompiler.cmake index 0b77ff3525..cb4e797137 100644 --- a/cmake/XrplCompiler.cmake +++ b/cmake/XrplCompiler.cmake @@ -145,13 +145,48 @@ else() INTERFACE -rdynamic $<$:-Wl,-z,relro,-z,now,--build-id> - # link to static libc/c++ iff: * static option set and * NOT APPLE (AppleClang does not support static - # libc/c++) and * NOT SANITIZERS (sanitizers typically don't work with static libc/c++) - $<$,$>,$>>: + # link to static libc/c++ if: + # * static option set and + # * NOT APPLE (AppleClang does not support static libc/c++) + $<$,$>>: -static-libstdc++ -static-libgcc > ) + + # On aarch64, libatomic is required for atomic operations. It is not needed on x86_64. + # Linking it statically on Linux + if(is_arm64 AND is_linux) + target_link_options( + common + INTERFACE -Wl,--push-state -Wl,-Bstatic -latomic -Wl,--pop-state + ) + endif() + + # Keep -stdlib=libstdc++ off the compile commands, but preserve it for linking. + # + # Conan turns `compiler.libcxx=libstdc++` into `-stdlib=libstdc++` and puts it in + # CMAKE_CXX_FLAGS, which CMake passes to BOTH compile and link steps. On a normal Clang + # the compile step consumes it while choosing the C++ stdlib include paths. The Nixpkgs + # Clang wrapper supplies those paths itself (via -nostdinc++), so at compile time the + # flag is unused -> Clang errors under our -Werror. At link time the flag IS consumed + # (it selects the C++ runtime), so we move it there instead of dropping it entirely. + get_filename_component(_cxx_real "${CMAKE_CXX_COMPILER}" REALPATH) + if( + _cxx_real MATCHES "^/nix/store/" + AND is_linux + AND is_clang + AND CMAKE_CXX_FLAGS MATCHES "stdlib=libstdc" + ) + string( + REPLACE "-stdlib=libstdc++" + "" + CMAKE_CXX_FLAGS + "${CMAKE_CXX_FLAGS}" + ) + string(STRIP "${CMAKE_CXX_FLAGS}" CMAKE_CXX_FLAGS) + add_link_options($<$:-stdlib=libstdc++>) + endif() endif() # Antithesis instrumentation will only be built and deployed using machines running Linux. diff --git a/cmake/XrplCore.cmake b/cmake/XrplCore.cmake index 9b1dc74049..4d4a800d9a 100644 --- a/cmake/XrplCore.cmake +++ b/cmake/XrplCore.cmake @@ -94,6 +94,9 @@ add_module(xrpl basics) target_link_libraries(xrpl.libxrpl.basics PUBLIC xrpl.libxrpl.beast) # Level 03 +add_module(xrpl config) +target_link_libraries(xrpl.libxrpl.config PUBLIC xrpl.libxrpl.basics) + add_module(xrpl json) target_link_libraries(xrpl.libxrpl.json PUBLIC xrpl.libxrpl.basics) @@ -120,6 +123,7 @@ target_link_libraries( xrpl.libxrpl.core PUBLIC xrpl.libxrpl.basics + xrpl.libxrpl.config xrpl.libxrpl.json xrpl.libxrpl.protocol xrpl.libxrpl.protocol_autogen @@ -143,7 +147,11 @@ target_link_libraries( add_module(xrpl nodestore) target_link_libraries( xrpl.libxrpl.nodestore - PUBLIC xrpl.libxrpl.basics xrpl.libxrpl.json xrpl.libxrpl.protocol + PUBLIC + xrpl.libxrpl.basics + xrpl.libxrpl.config + xrpl.libxrpl.json + xrpl.libxrpl.protocol ) add_module(xrpl shamap) @@ -159,13 +167,14 @@ target_link_libraries( add_module(xrpl rdb) target_link_libraries( xrpl.libxrpl.rdb - PUBLIC xrpl.libxrpl.basics xrpl.libxrpl.core + PUBLIC xrpl.libxrpl.basics xrpl.libxrpl.config xrpl.libxrpl.core ) add_module(xrpl server) target_link_libraries( xrpl.libxrpl.server PUBLIC + xrpl.libxrpl.config xrpl.libxrpl.protocol xrpl.libxrpl.core xrpl.libxrpl.rdb @@ -210,6 +219,7 @@ target_link_modules( basics beast conditions + config core crypto git @@ -237,6 +247,7 @@ target_link_modules( if(xrpld) add_executable(xrpld) + patch_nix_binary(xrpld) if(tests) target_compile_definitions(xrpld PUBLIC ENABLE_TESTS) target_compile_definitions( diff --git a/cmake/XrplCov.cmake b/cmake/XrplCov.cmake index d81d7e689f..86ba534a88 100644 --- a/cmake/XrplCov.cmake +++ b/cmake/XrplCov.cmake @@ -47,7 +47,7 @@ setup_target_for_coverage_gcovr( "include/xrpl/beast/test" "include/xrpl/beast/unit_test" "${CMAKE_BINARY_DIR}/pb-xrpl.libpb" - DEPENDENCIES xrpld xrpl.tests + DEPENDENCIES xrpld xrpl_tests ) add_code_coverage_to_target(opts INTERFACE) diff --git a/cmake/XrplPackaging.cmake b/cmake/XrplPackaging.cmake index fe885c200c..8e3861925d 100644 --- a/cmake/XrplPackaging.cmake +++ b/cmake/XrplPackaging.cmake @@ -28,7 +28,6 @@ endif() set(package_env SRC_DIR=${CMAKE_SOURCE_DIR} BUILD_DIR=${CMAKE_BINARY_DIR} - PKG_VERSION=${xrpld_version} PKG_RELEASE=${pkg_release} ) diff --git a/cmake/XrplSanitizers.cmake b/cmake/XrplSanitizers.cmake index 64f1841bfb..893c880374 100644 --- a/cmake/XrplSanitizers.cmake +++ b/cmake/XrplSanitizers.cmake @@ -14,11 +14,9 @@ include_guard(GLOBAL) include(CompilationEnv) -if(NOT DEFINED SANITIZERS) - set(SANITIZERS_ENABLED FALSE) +if(NOT SANITIZERS_ENABLED) return() endif() -set(SANITIZERS_ENABLED TRUE) message(STATUS "=== Configuring Sanitizers ===") message(STATUS " SANITIZERS: ${SANITIZERS}") diff --git a/cmake/scripts/codegen/requirements.in b/cmake/scripts/codegen/requirements.in new file mode 100644 index 0000000000..d799fd60fd --- /dev/null +++ b/cmake/scripts/codegen/requirements.in @@ -0,0 +1,13 @@ +# Python dependencies for XRP Ledger code generation scripts +# +# These packages are required to run the code generation scripts that +# parse macro files and generate C++ wrapper classes. + +# C preprocessor for Python - used to preprocess macro files +pcpp>=1.30 + +# Parser combinator library - used to parse the macro DSL +pyparsing>=3.0.0 + +# Template engine - used to generate C++ code from templates +Mako>=1.2.2 diff --git a/cmake/scripts/codegen/requirements.txt b/cmake/scripts/codegen/requirements.txt index d799fd60fd..ff37548c7b 100644 --- a/cmake/scripts/codegen/requirements.txt +++ b/cmake/scripts/codegen/requirements.txt @@ -1,13 +1,105 @@ -# Python dependencies for XRP Ledger code generation scripts -# -# These packages are required to run the code generation scripts that -# parse macro files and generate C++ wrapper classes. - -# C preprocessor for Python - used to preprocess macro files -pcpp>=1.30 - -# Parser combinator library - used to parse the macro DSL -pyparsing>=3.0.0 - -# Template engine - used to generate C++ code from templates -Mako>=1.2.2 +# This file was autogenerated by uv via the following command: +# uv pip compile requirements.in --generate-hashes --output-file requirements.txt +mako==1.3.12 \ + --hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \ + --hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a + # via -r requirements.in +markupsafe==3.0.3 \ + --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \ + --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \ + --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \ + --hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \ + --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ + --hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \ + --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \ + --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \ + --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \ + --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \ + --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \ + --hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \ + --hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \ + --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \ + --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \ + --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \ + --hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \ + --hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \ + --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \ + --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \ + --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \ + --hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \ + --hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \ + --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \ + --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \ + --hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \ + --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \ + --hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \ + --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \ + --hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \ + --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \ + --hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \ + --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \ + --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \ + --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \ + --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \ + --hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \ + --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \ + --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \ + --hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \ + --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ + --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \ + --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \ + --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \ + --hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \ + --hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \ + --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \ + --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \ + --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \ + --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \ + --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \ + --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \ + --hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \ + --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \ + --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \ + --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \ + --hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \ + --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \ + --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \ + --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \ + --hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \ + --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \ + --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \ + --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \ + --hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \ + --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \ + --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \ + --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \ + --hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \ + --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \ + --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \ + --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \ + --hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \ + --hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \ + --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \ + --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \ + --hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \ + --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \ + --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \ + --hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \ + --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \ + --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \ + --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \ + --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \ + --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ + --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \ + --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \ + --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ + --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 + # via mako +pcpp==1.30 \ + --hash=sha256:05fe08292b6da57f385001c891a87f40d6aa7f46787b03e8ba326d20a3297c6e \ + --hash=sha256:5af9fbce55f136d7931ae915fae03c34030a3b36c496e72d9636cedc8e2543a1 + # via -r requirements.in +pyparsing==3.3.2 \ + --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \ + --hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc + # via -r requirements.in diff --git a/cmake/scripts/codegen/templates/LedgerEntry.h.mako b/cmake/scripts/codegen/templates/LedgerEntry.h.mako index 31029cd311..63f5f39ef9 100644 --- a/cmake/scripts/codegen/templates/LedgerEntry.h.mako +++ b/cmake/scripts/codegen/templates/LedgerEntry.h.mako @@ -33,7 +33,7 @@ public: * @brief Construct a ${name} ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit ${name}(std::shared_ptr sle) + explicit ${name}(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -168,7 +168,7 @@ ${field['typeData']['setter_type']} ${field['paramName']}${',' if i < len(requir * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - ${name}Builder(std::shared_ptr sle) + ${name}Builder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ${tag}) { diff --git a/conan.lock b/conan.lock index e2eb8d871a..b6ddfa4e58 100644 --- a/conan.lock +++ b/conan.lock @@ -1,43 +1,44 @@ { "version": "0.5", "requires": [ - "zlib/1.3.2#1cb806da49011867778ffb6ac7190fcb%1777558780.503", - "xxhash/0.8.3#681d36a0a6111fc56e5e45ea182c19cc%1765850149.987", - "sqlite3/3.53.0#324ada52333108388a9a6108bfa96734%1776096494.149", - "soci/4.0.3#fe32b9ad5eb47e79ab9e45a68f363945%1774450067.231", - "snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1765850147.878", - "secp256k1/0.7.1#481881709eb0bdd0185a12b912bbe8ad%1770910500.329", - "rocksdb/10.5.1#4a197eca381a3e5ae8adf8cffa5aacd0%1765850186.86", - "re2/20251105#8579cfd0bda4daf0683f9e3898f964b4%1774398111.888", - "protobuf/6.33.5#d96d52ba5baaaa532f47bda866ad87a5%1774467363.12", - "openssl/3.6.2#4789bbf131b77d0515d15e094c8f697f%1778071755.506", - "nudb/2.0.9#11149c73f8f2baff9a0198fe25971fc7%1775040983.408", - "lz4/1.10.0#59fc63cac7f10fbe8e05c7e62c2f3504%1765850143.914", - "libiconv/1.17#1e65319e945f2d31941a9d28cc13c058%1765842973.492", - "libbacktrace/cci.20210118#a7691bfccd8caaf66309df196790a5a1%1765842973.03", - "libarchive/3.8.7#c446109bd1f1d8ba7936c94189bc50e6%1776147552.838", + "zlib/1.3.2#1cb806da49011867778ffb6ac7190fcb%1782392402.122708", + "xxhash/0.8.3#681d36a0a6111fc56e5e45ea182c19cc%1782392402.420688", + "sqlite3/3.53.0#324ada52333108388a9a6108bfa96734%1782392403.185447", + "soci/4.0.3#e726491a03468795453f7c83fc924a96%1782392402.679521", + "snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1782307151.633168", + "secp256k1/0.7.1#b1f450b7f78a36fff75bb6934a356f3a%1782338841.3729", + "rocksdb/10.5.1#4a197eca381a3e5ae8adf8cffa5aacd0%1782392413.075713", + "re2/20251105#8579cfd0bda4daf0683f9e3898f964b4%1782392402.431897", + "protobuf/6.33.5#ff253ead763bd8d9904a52979cd21e81%1782392410.233933", + "openssl/3.6.3#1163d4ddc603907084d08a6a0c6e580f%1782307150.583886", + "nudb/2.0.9#11149c73f8f2baff9a0198fe25971fc7%1782392402.297166", + "mpt-crypto/0.4.0-rc2#a580f2f9ad0e795de696aa62d54fb9af%1782425834.488828", + "lz4/1.10.0#982d9b673900f665a1da109e09c17cab%1782392402.164188", + "libiconv/1.17#9923bc6dc6f106646d6967e0039a5ada%1782392792.775744", + "libbacktrace/cci.20210118#a7691bfccd8caaf66309df196790a5a1%1782392402.420732", + "libarchive/3.8.7#c446109bd1f1d8ba7936c94189bc50e6%1782392403.066892", "jemalloc/5.3.1#1fc58d55316041f10fbc1e8a2eae632a%1776700028.228", - "gtest/1.17.0#5224b3b3ff3b4ce1133cbdd27d53ee7d%1768312129.152", - "grpc/1.78.1#b1a9e74b145cc471bed4dc64dc6eb2c1%1774467387.342", - "ed25519/2015.03#ae761bdc52730a843f0809bdf6c1b1f6%1765850143.772", - "date/3.0.4#862e11e80030356b53c2c38599ceb32b%1765850143.772", - "c-ares/1.34.6#545240bb1c40e2cacd4362d6b8967650%1774439234.681", - "bzip2/1.0.8#c470882369c2d95c5c77e970c0c7e321%1765850143.837", - "boost/1.91.0#ea540ca2133d831b560036aa24dece3c%1778050991.9", - "abseil/20250127.0#bb0baf1f362bc4a725a24eddd419b8f7%1774365460.196" + "gtest/1.17.0#5224b3b3ff3b4ce1133cbdd27d53ee7d%1782392402.791979", + "grpc/1.81.1#5217e6ef0544c42b46f4af35d5e7f649%1782307148.845616", + "ed25519/2015.03#ae761bdc52730a843f0809bdf6c1b1f6%1782307148.15562", + "date/3.0.4#862e11e80030356b53c2c38599ceb32b%1782392402.538492", + "c-ares/1.34.6#545240bb1c40e2cacd4362d6b8967650%1782392402.681654", + "bzip2/1.0.8#c470882369c2d95c5c77e970c0c7e321%1782392402.296732", + "boost/1.91.0#ea540ca2133d831b560036aa24dece3c%1782392419.475605", + "abseil/20250127.0#bb0baf1f362bc4a725a24eddd419b8f7%1782307147.395833" ], "build_requires": [ - "zlib/1.3.2#1cb806da49011867778ffb6ac7190fcb%1777558780.503", - "strawberryperl/5.32.1.1#8d114504d172cfea8ea1662d09b6333e%1774447376.964", - "protobuf/6.33.5#d96d52ba5baaaa532f47bda866ad87a5%1774467363.12", - "nasm/2.16.01#31e26f2ee3c4346ecd347911bd126904%1765850144.707", + "zlib/1.3.2#1cb806da49011867778ffb6ac7190fcb%1782392402.122708", + "strawberryperl/5.32.1.1#8d114504d172cfea8ea1662d09b6333e%1782395692.540639", + "protobuf/6.33.5#ff253ead763bd8d9904a52979cd21e81%1782392410.233933", + "nasm/2.16.01#31e26f2ee3c4346ecd347911bd126904%1782395690.33162", "msys2/cci.latest#d22fe7b2808f5fd34d0a7923ace9c54f%1770657326.649", - "m4/1.4.19#4523e4347b55cd26ae918bd5770cab9a%1778062762.471", - "cmake/4.3.0#b939a42e98f593fb34d3a8c5cc860359%1774439249.183", - "b2/5.4.2#ffd6084a119587e70f11cd45d1a386e2%1774439233.447", + "m4/1.4.19#34c4bbc3eeebe98ca6edf2f52d602e7d%1777282960.259", + "cmake/4.3.3#840cf00ea09777e05c2050a50a82c722%1782392418.696091", + "b2/5.4.2#ffd6084a119587e70f11cd45d1a386e2%1782392402.624226", "automake/1.16.5#b91b7c384c3deaa9d535be02da14d04f%1755524470.56", "autoconf/2.71#51077f068e61700d65bb05541ea1e4b0%1731054366.86", - "abseil/20250127.0#bb0baf1f362bc4a725a24eddd419b8f7%1774365460.196" + "abseil/20250127.0#bb0baf1f362bc4a725a24eddd419b8f7%1782307147.395833" ], "python_requires": [], "overrides": { @@ -57,7 +58,7 @@ "boost/1.91.0" ], "lz4/[>=1.9.4 <2]": [ - "lz4/1.10.0#59fc63cac7f10fbe8e05c7e62c2f3504" + "lz4/1.10.0#982d9b673900f665a1da109e09c17cab" ] }, "config_requires": [] diff --git a/conan/lockfile/linux.profile b/conan/lockfile/linux.profile index 25ad5988c5..ea9f66de69 100644 --- a/conan/lockfile/linux.profile +++ b/conan/lockfile/linux.profile @@ -2,7 +2,7 @@ arch=x86_64 build_type=Release compiler=gcc -compiler.cppstd=20 +compiler.cppstd=23 compiler.libcxx=libstdc++11 compiler.version=13 os=Linux diff --git a/conan/lockfile/macos.profile b/conan/lockfile/macos.profile index 332a0c143d..f223627c26 100644 --- a/conan/lockfile/macos.profile +++ b/conan/lockfile/macos.profile @@ -2,7 +2,7 @@ arch=armv8 build_type=Release compiler=apple-clang -compiler.cppstd=20 +compiler.cppstd=23 compiler.libcxx=libc++ compiler.version=17.0 os=Macos diff --git a/conan/lockfile/regenerate.sh b/conan/lockfile/regenerate.sh index 1aa47628f0..98ee6f7c99 100755 --- a/conan/lockfile/regenerate.sh +++ b/conan/lockfile/regenerate.sh @@ -14,7 +14,7 @@ export CONAN_HOME="$TEMP_DIR" # Ensure that the xrplf remote is the first to be consulted, so any recipes we # patched are used. We also add it there to not created huge diff when the # official Conan Center Index is updated. -conan remote add --force --index 0 xrplf https://conan.ripplex.io +conan remote add --force --index 0 xrplf https://conan.xrplf.org/repository/conan/ # Delete any existing lockfile. rm -f conan.lock diff --git a/conan/lockfile/windows.profile b/conan/lockfile/windows.profile index 4bb266a62e..b3a8fed4f3 100644 --- a/conan/lockfile/windows.profile +++ b/conan/lockfile/windows.profile @@ -2,7 +2,7 @@ arch=x86_64 build_type=Release compiler=msvc -compiler.cppstd=20 +compiler.cppstd=23 compiler.runtime=dynamic compiler.runtime_type=Release compiler.version=194 diff --git a/conan/profiles/ci b/conan/profiles/ci index ae93187026..9422addfe3 100644 --- a/conan/profiles/ci +++ b/conan/profiles/ci @@ -1 +1,8 @@ +{% set os = detect_api.detect_os() %} include(sanitizers) + +[conf] +{% if os == "Linux" %} +user.package:libc_version=2.31 +tools.info.package_id:confs+=["user.package:libc_version"] +{% endif %} diff --git a/conan/profiles/default b/conan/profiles/default index cde59f7f3b..e0a88ebca1 100644 --- a/conan/profiles/default +++ b/conan/profiles/default @@ -12,7 +12,7 @@ arch={{ arch }} build_type=Debug compiler={{compiler}} compiler.version={{ compiler_version }} -compiler.cppstd=20 +compiler.cppstd=23 {% if os == "Windows" %} compiler.runtime=static {% else %} diff --git a/conan/profiles/sanitizers b/conan/profiles/sanitizers index 4a05fda734..083807ea9e 100644 --- a/conan/profiles/sanitizers +++ b/conan/profiles/sanitizers @@ -52,52 +52,50 @@ include(default) {% endif %} {# Frame pointer required for meaningful stack traces; -O1 for reasonable performance #} -{% set compile_flags = ["-fno-omit-frame-pointer", "-O1"] %} +{% set sanitizer_compiler_flags = ["-fno-omit-frame-pointer", "-O1"] %} {% if compiler == "gcc" %} {# Suppress false positive warnings with GCC #} - {% set _ = compile_flags.append("-Wno-stringop-overflow") %} + {% set _ = sanitizer_compiler_flags.append("-Wno-stringop-overflow") %} {% set relocation_flags = [] %} {% if arch == "x86_64" and enable_asan %} {# Large code model prevents relocation errors in instrumented ASAN binaries #} - {% set _ = compile_flags.append("-mcmodel=large") %} + {% set _ = sanitizer_compiler_flags.append("-mcmodel=large") %} {% set _ = relocation_flags.append("-mcmodel=large") %} {% elif enable_tsan %} {# GCC doesn't support atomic_thread_fence with TSAN; suppress warnings #} - {% set _ = compile_flags.append("-Wno-tsan") %} + {% set _ = sanitizer_compiler_flags.append("-Wno-tsan") %} {% if arch == "x86_64" %} {# Medium code model for TSAN; large is incompatible #} - {% set _ = compile_flags.append("-mcmodel=medium") %} + {% set _ = sanitizer_compiler_flags.append("-mcmodel=medium") %} {% set _ = relocation_flags.append("-mcmodel=medium") %} {% endif %} {% endif %} {% set fsanitize = "-fsanitize=" ~ ",".join(sanitizer_types) %} - {% set _ = compile_flags.append(fsanitize) %} + {% set _ = sanitizer_compiler_flags.append(fsanitize) %} {% set _ = relocation_flags.append(fsanitize) %} - {% set sanitizer_compiler_flags = " ".join(compile_flags) %} - {% set sanitizer_linker_flags = " ".join(relocation_flags) %} + {% set sanitizer_linker_flags = relocation_flags %} {% elif compiler == "clang" or compiler == "apple-clang" %} {% set fsanitize = "-fsanitize=" ~ ",".join(sanitizer_types) %} - {% set _ = compile_flags.append(fsanitize) %} + {% set _ = sanitizer_compiler_flags.append(fsanitize) %} - {% set sanitizer_compiler_flags = " ".join(compile_flags) %} - {% set sanitizer_linker_flags = fsanitize %} + {% set sanitizer_linker_flags = [fsanitize] %} {% endif %} [conf] tools.build:defines+={{defines}} -tools.build:cxxflags+=['{{sanitizer_compiler_flags}}'] -tools.build:sharedlinkflags+=['{{sanitizer_linker_flags}}'] -tools.build:exelinkflags+=['{{sanitizer_linker_flags}}'] +tools.build:cxxflags+={{sanitizer_compiler_flags}} +tools.build:sharedlinkflags+={{sanitizer_linker_flags}} +tools.build:exelinkflags+={{sanitizer_linker_flags}} tools.info.package_id:confs+=["tools.build:cxxflags", "tools.build:exelinkflags", "tools.build:sharedlinkflags", "tools.build:defines"] # &: means "apply only to the consumer/root package" -&:tools.cmake.cmaketoolchain:extra_variables={"SANITIZERS": "{{sanitizers}}", "SANITIZERS_COMPILER_FLAGS": "{{sanitizer_compiler_flags}}", "SANITIZERS_LINKER_FLAGS": "{{sanitizer_linker_flags}}"} +&:tools.cmake.cmaketoolchain:extra_variables={"SANITIZERS": "{{sanitizers}}", "SANITIZERS_COMPILER_FLAGS": "{{sanitizer_compiler_flags | join(' ')}}", "SANITIZERS_LINKER_FLAGS": "{{sanitizer_linker_flags | join(' ')}}"} [options] {% if enable_asan %} diff --git a/conanfile.py b/conanfile.py index 2cf5aefbc2..db12dcb585 100644 --- a/conanfile.py +++ b/conanfile.py @@ -28,11 +28,10 @@ class Xrpl(ConanFile): requires = [ "ed25519/2015.03", - "grpc/1.78.1", + "grpc/1.81.1", "libarchive/3.8.7", "nudb/2.0.9", - "openssl/3.6.2", - "secp256k1/0.7.1", + "openssl/3.6.3", "soci/4.0.3", "zlib/1.3.2", ] @@ -132,13 +131,15 @@ class Xrpl(ConanFile): def requirements(self): self.requires("boost/1.91.0", force=True, transitive_headers=True) self.requires("date/3.0.4", transitive_headers=True) - self.requires("lz4/1.10.0", force=True) - self.requires("protobuf/6.33.5", force=True) - self.requires("sqlite3/3.53.0", force=True) if self.options.jemalloc: self.requires("jemalloc/5.3.1") + self.requires("lz4/1.10.0", force=True) + self.requires("mpt-crypto/0.4.0-rc2", transitive_headers=True) + self.requires("protobuf/6.33.5", force=True) if self.options.rocksdb: self.requires("rocksdb/10.5.1") + self.requires("secp256k1/0.7.1", transitive_headers=True) + self.requires("sqlite3/3.53.0", force=True) self.requires("xxhash/0.8.3", transitive_headers=True) exports_sources = ( @@ -208,6 +209,7 @@ class Xrpl(ConanFile): "grpc::grpc++", "libarchive::libarchive", "lz4::lz4", + "mpt-crypto::mpt-crypto", "nudb::nudb", "openssl::crypto", "protobuf::libprotobuf", diff --git a/cspell.config.yaml b/cspell.config.yaml index 275df41f58..c120c31855 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -50,6 +50,7 @@ words: - AMMXRP - amt - amts + - archs - asnode - asynchrony - attestation @@ -59,6 +60,7 @@ words: - autobridging - bimap - bindir + - blindings - bookdir - Bougalis - Britto @@ -83,6 +85,7 @@ words: - coro - coros - cowid + - cpack - cryptocondition - cryptoconditional - cryptoconditions @@ -93,6 +96,8 @@ words: - daria - dcmake - dearmor + - decryptor + - dedented - deleteme - demultiplexer - deserializaton @@ -103,9 +108,11 @@ words: - distro - doxyfile - dxrpl + - elgamal - enabled - enablerepo - endmacro + - envrc - exceptioned - EXPECT_STREQ - Falco @@ -115,6 +122,7 @@ words: - fmtdur - fsanitize - funclets + - Gamal - gcov - gcovr - ghead @@ -133,6 +141,7 @@ words: - iou - ious - isrdc + - isystem - itype - jemalloc - jlog @@ -199,16 +208,19 @@ words: - nonxrp - noreplace - noripple + - nostdinc - notifempty - nudb - nullptr - nunl - Nyffenegger + - onlatest - ostr - pargs - partitioner - paychan - paychans + - Pedersen - permdex - perminute - permissioned @@ -227,9 +239,15 @@ words: - pyenv - pyparsing - qalloc + - qbsprofile - queuable - Raphson + - rcflags - replayer + - rerandomize + - rerandomization + - rerandomized + - rerandomizes - rerere - retriable - RIPD @@ -246,6 +264,7 @@ words: - sahyadri - Satoshi - scons + - Schnorr - secp - sendq - seqit @@ -254,6 +273,7 @@ words: - sfields - shamap - shamapitem + - shfmt - shlibs - sidechain - SIGGOOD @@ -275,6 +295,7 @@ words: - stvar - stvector - stxchainattestations + - summands - superpeer - superpeers - takergets @@ -291,6 +312,7 @@ words: - txs - ubsan - UBSAN + - ufdio - umant - unacquired - unambiguity @@ -298,6 +320,7 @@ words: - unauthorizing - unergonomic - unfetched + - unfindable - unflatten - unfund - unimpair diff --git a/docker/nix.Dockerfile b/docker/nix.Dockerfile deleted file mode 100644 index 52faa8b8dc..0000000000 --- a/docker/nix.Dockerfile +++ /dev/null @@ -1,66 +0,0 @@ -ARG BASE_IMAGE=nixos/nix:latest - -# Nix builder -FROM nixos/nix:latest AS builder-source - -RUN mkdir -p ~/.config/nix && \ - echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf - -# Copy our source and setup our working dir. -COPY nix/ci-env.nix /tmp/build/nix/ci-env.nix -COPY nix/packages.nix /tmp/build/nix/packages.nix -COPY nix/utils.nix /tmp/build/nix/utils.nix -COPY flake.nix /tmp/build/ -COPY flake.lock /tmp/build/ -WORKDIR /tmp/build - -FROM builder-source AS builder - -# Build our Nix CI environment (all build tools in a single store path) -RUN nix \ - --option filter-syscalls false \ - build - -# Copy the Nix store closure into a directory. The Nix store closure is the -# entire set of Nix store values that we need for our build. -RUN mkdir /tmp/nix-store-closure && \ - cp -R $(nix-store -qR result/) /tmp/nix-store-closure - -# Final image -FROM ${BASE_IMAGE} - -# bash is not located at /bin/bash in nixos/nix, so we need to create a symlink to it. -RUN if [ -d /nix ]; then \ - ln -s /root/.nix-profile/bin/bash /bin/bash; \ - fi - -# Use Bash as the default shell for RUN commands, using the options -# `set -o errexit -o pipefail`, and as the entrypoint. -SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] -ENTRYPOINT ["/bin/bash"] - -# Copy /nix/store and the env symlink tree -COPY --from=builder /tmp/nix-store-closure /nix/store -COPY --from=builder /tmp/build/result /nix/ci-env - -ENV PATH="/nix/ci-env/bin:$PATH" - -RUN < [!NOTE] +> You might need to add `--lockfile=""` to your `conan install` command +> to avoid automatic use of the existing `conan.lock` file when you run +> `conan export` manually on your machine +> +> This is not recommended though, as you might end up using different revisions of recipes. + +## Conan profile tweaks + +### Missing compiler version + +If you see an error similar to the following after running `conan profile show`: + +```text +ERROR: Invalid setting '17' is not a valid 'settings.compiler.version' value. +Possible values are ['5.0', '5.1', '6.0', '6.1', '7.0', '7.3', '8.0', '8.1', +'9.0', '9.1', '10.0', '11.0', '12.0', '13', '13.0', '13.1', '14', '14.0', '15', +'15.0', '16', '16.0'] +Read "http://docs.conan.io/2/knowledge/faq.html#error-invalid-setting" +``` + +you need to create `$(conan config home)/settings_user.yml` file if it doesn't exist and add the required version number(s) +to the `version` array specific for your compiler. For example: + +```yaml +compiler: + apple-clang: + version: ["17.0"] +``` + +### Multiple compilers + +If you have multiple compilers installed, make sure to select the one to use in +your default Conan configuration **before** running `conan profile detect`, by +setting the `CC` and `CXX` environment variables. + +For example, if you are running MacOS and have [homebrew +LLVM@18](https://formulae.brew.sh/formula/llvm@18), and want to use it as a +compiler in the new Conan profile: + +```bash +export CC=$(brew --prefix llvm@18)/bin/clang +export CXX=$(brew --prefix llvm@18)/bin/clang++ +conan profile detect +``` + +You should also explicitly set the path to the compiler in the profile file, +which helps to avoid errors when `CC` and/or `CXX` are set and disagree with the +selected Conan profile. For example: + +```text +[conf] +tools.build:compiler_executables={'c':'/usr/bin/gcc','cpp':'/usr/bin/g++'} +``` + +### Multiple profiles + +You can manage multiple Conan profiles in the directory +`$(conan config home)/profiles`, for example renaming `default` to a different +name and then creating a new `default` profile for a different compiler. + +### Select language + +The default profile created by Conan will typically select different C++ dialect +than C++23 used by this project. You should set `23` in the profile line +starting with `compiler.cppstd=`. For example: + +```bash +sed -i.bak -e 's|^compiler\.cppstd=.*$|compiler.cppstd=23|' $(conan config home)/profiles/default +``` + +### Select standard library in Linux + +**Linux** developers will commonly have a default Conan [profile][] that +compiles with GCC and links with libstdc++. If you are linking with libstdc++ +(see profile setting `compiler.libcxx`), then you will need to choose the +`libstdc++11` ABI: + +```bash +sed -i.bak -e 's|^compiler\.libcxx=.*$|compiler.libcxx=libstdc++11|' $(conan config home)/profiles/default +``` + +### Select architecture and runtime in Windows + +**Windows** developers may need to use the x64 native build tools. An easy way +to do that is to run the shortcut "x64 Native Tools Command Prompt" for the +version of Visual Studio that you have installed. + +Windows developers must also build `xrpld` and its dependencies for the x64 +architecture: + +```bash +sed -i.bak -e 's|^arch=.*$|arch=x86_64|' $(conan config home)/profiles/default +``` + +**Windows** developers also must select static runtime: + +```bash +sed -i.bak -e 's|^compiler\.runtime=.*$|compiler.runtime=static|' $(conan config home)/profiles/default +``` + +## Add a Dependency + +If you want to experiment with a new package, follow these steps: + +1. Search for the package on [Conan Center](https://conan.io/center/). +2. Modify [`conanfile.py`](../../conanfile.py): + - Add a version of the package to the `requires` property. + - Change any default options for the package by adding them to the + `default_options` property (with syntax `'$package:$option': $value`). +3. Regenerate the [Conan lockfile](../../conan/lockfile/README.md) so the new + dependency is captured: + + ```bash + ./conan/lockfile/regenerate.sh + ``` + +4. Modify [`CMakeLists.txt`](../../CMakeLists.txt): + - Add a call to `find_package($package REQUIRED)`. + - Link a library from the package to the target `xrpl_libs` + (search for the existing call to `target_link_libraries(xrpl_libs INTERFACE ...)`). +5. Start coding! Don't forget to include whatever headers you need from the package. + +[profile]: https://docs.conan.io/2/reference/config_files/profiles.html diff --git a/docs/build/conan.md b/docs/build/conan.md index 9dcd2c8f1c..22c25a0bf9 100644 --- a/docs/build/conan.md +++ b/docs/build/conan.md @@ -115,7 +115,7 @@ By default, Conan will use the profile named "default". [find_package]: https://cmake.org/cmake/help/latest/command/find_package.html [pcf]: https://cmake.org/cmake/help/latest/manual/cmake-packages.7.html#package-configuration-file [prefix_path]: https://cmake.org/cmake/help/latest/variable/CMAKE_PREFIX_PATH.html -[profile]: https://docs.conan.io/en/latest/reference/profiles.html +[profile]: https://docs.conan.io/2/reference/config_files/profiles.html [pvf]: https://cmake.org/cmake/help/latest/manual/cmake-packages.7.html#package-version-file [runtime]: https://cmake.org/cmake/help/latest/variable/CMAKE_MSVC_RUNTIME_LIBRARY.html [search]: https://cmake.org/cmake/help/latest/command/find_package.html#search-procedure diff --git a/docs/build/environment.md b/docs/build/environment.md index fb1ebde8bc..2cca608567 100644 --- a/docs/build/environment.md +++ b/docs/build/environment.md @@ -1,69 +1,73 @@ Our [build instructions][BUILD.md] assume you have a C++ development environment complete with Git, Python, Conan, CMake, and a C++ compiler. -This document exists to help readers set one up on any of the Big Three -platforms: Linux, macOS, or Windows. - -As an alternative to system packages, the Nix development shell can be used to provide a development environment. See [using nix development shell](./nix.md) for more details. +This document explains how to set one up. [BUILD.md]: ../../BUILD.md -## Linux +## Tested compiler versions -Package ecosystems vary across Linux distributions, -so there is no one set of instructions that will work for every Linux user. -The instructions below are written for Debian 12 (Bookworm). +`xrpld` is built in the **C++23** dialect by default. +Make sure your toolchain is recent enough — the compiler versions currently tested in CI are: -``` -export GCC_RELEASE=12 -sudo apt update -sudo apt install --yes gcc-${GCC_RELEASE} g++-${GCC_RELEASE} python3-pip \ - python-is-python3 python3-venv python3-dev curl wget ca-certificates \ - git build-essential cmake ninja-build libc6-dev -sudo pip install --break-system-packages conan +| Compiler | Version | +| ----------- | ------- | +| GCC | 15.2 | +| Clang | 22 | +| Apple Clang | 17 | +| MSVC | 19.44 | -sudo update-alternatives --install /usr/bin/cc cc /usr/bin/gcc-${GCC_RELEASE} 999 -sudo update-alternatives --install \ - /usr/bin/gcc gcc /usr/bin/gcc-${GCC_RELEASE} 100 \ - --slave /usr/bin/g++ g++ /usr/bin/g++-${GCC_RELEASE} \ - --slave /usr/bin/gcc-ar gcc-ar /usr/bin/gcc-ar-${GCC_RELEASE} \ - --slave /usr/bin/gcc-nm gcc-nm /usr/bin/gcc-nm-${GCC_RELEASE} \ - --slave /usr/bin/gcc-ranlib gcc-ranlib /usr/bin/gcc-ranlib-${GCC_RELEASE} \ - --slave /usr/bin/gcov gcov /usr/bin/gcov-${GCC_RELEASE} \ - --slave /usr/bin/gcov-tool gcov-tool /usr/bin/gcov-tool-${GCC_RELEASE} \ - --slave /usr/bin/gcov-dump gcov-dump /usr/bin/gcov-dump-${GCC_RELEASE} \ - --slave /usr/bin/lto-dump lto-dump /usr/bin/lto-dump-${GCC_RELEASE} -sudo update-alternatives --auto cc -sudo update-alternatives --auto gcc +LLVM tools (`clang-tidy` and `clang-format`) are also pinned to version 22. + +Older compilers may fail to build the latest `develop` code: the codebase now +relies on C++23 features and has been adjusted for `clang-tidy`. +If the latest code doesn't build for you, update your build toolchain first. + +## Linux and macOS + +The **recommended way** to get a development environment on Linux and macOS is +the Nix development shell. It provides the exact tooling used in CI — `git`, +`python`, `conan`, `cmake`, `clang-tidy`, `clang-format`, and everything else — +with a single command and without installing anything system-wide: + +```bash +nix --experimental-features 'nix-command flakes' develop ``` -If you use different Linux distribution, hope the instruction above can guide -you in the right direction. We try to maintain compatibility with all recent -compiler releases, so if you use a rolling distribution like e.g. Arch or CentOS -then there is a chance that everything will "just work". +On **Linux**, Nix also provides the compiler (GCC). On **macOS**, the shell uses +your **system-wide Apple Clang** as the compiler, so you still need to manage +its version (see below). -## macOS +See [Using the Nix development shell](./nix.md) for installation and usage +details, including how to select a different compiler. -Open a Terminal and enter the below command to bring up a dialog to install -the command line developer tools. -Once it is finished, this command should return a version greater than the -minimum required (see [BUILD.md][]). +> [!NOTE] +> Using Nix is not mandatory. Any custom environment (Homebrew packages or +> anything else) will continue to work, but then it is up to you to keep it in +> sync with the environment used in CI. Nix unifies the development environment +> for everyone and synchronizes updates, which is why we recommend it. -``` +### macOS: managing the Apple Clang version + +Because the Nix shell uses the system-wide Apple Clang on macOS, the compiler +version is whatever your installed Xcode (or Command Line Tools) provides. The +following command should return a version greater than or equal to the +[minimum required](#tested-compiler-versions): + +```bash clang --version ``` -### Install Xcode Specific Version (Optional) - -If you develop other applications using XCode you might be consistently updating to the newest version of Apple Clang. -This will likely cause issues building xrpld. You may want to install a specific version of Xcode: +If you develop other applications using Xcode, you might be consistently +updating to the newest version of Apple Clang, which will likely cause issues +building xrpld. You may want to install and pin a specific version of Xcode: 1. **Download Xcode** - Visit [Apple Developer Downloads](https://developer.apple.com/download/more/) - Sign in with your Apple Developer account - - Search for an Xcode version that includes **Apple Clang (Expected Version)** + - Search for an Xcode version that includes the expected Apple Clang version - Download the `.xip` file -2. **Install and Configure Xcode** +2. **Install and configure Xcode** ```bash # Extract the .xip file and rename for version management @@ -79,62 +83,28 @@ This will likely cause issues building xrpld. You may want to install a specific export DEVELOPER_DIR=/Applications/Xcode_16.2.app/Contents/Developer ``` -The command line developer tools should include Git too: +## Windows -``` -git --version -``` +Nix is not available on Windows, so the required tools have to be installed +manually: -Install [Homebrew][], -use it to install [pyenv][], -use it to install Python, -and use it to install Conan: +- [Visual Studio 2022](https://visualstudio.microsoft.com/) with the + **"Desktop development with C++"** workload — this provides MSVC and the + "x64 Native Tools Command Prompt". +- [Git for Windows](https://git-scm.com/download/win) +- [Python 3.11](https://www.python.org/downloads/), or higher +- [Conan 2.17](https://conan.io/downloads.html), or higher +- [CMake 3.22](https://cmake.org/download/), or higher -[Homebrew]: https://brew.sh/ -[pyenv]: https://github.com/pyenv/pyenv - -``` -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -brew update -brew install xz -brew install pyenv -pyenv install 3.11 -pyenv global 3.11 -eval "$(pyenv init -)" -pip install 'conan' -``` - -Install CMake with Homebrew too: - -``` -brew install cmake -``` +> [!NOTE] +> Windows is used for development only and is not recommended for production. ## Clang-tidy -Clang-tidy is required to run static analysis checks locally (see [CONTRIBUTING.md](../../CONTRIBUTING.md)). -It is not required to build the project. Currently this project uses clang-tidy version 21. +`clang-tidy` is required to run static analysis checks locally (see +[CONTRIBUTING.md](../../CONTRIBUTING.md)). It is not required to build the +project. This project currently uses `clang-tidy` version 22. -### Linux - -LLVM 21 is not available in the default Debian 12 (Bookworm) repositories. -Install it using the official LLVM apt installer: - -``` -wget https://apt.llvm.org/llvm.sh -chmod +x llvm.sh -sudo ./llvm.sh 21 -sudo apt install --yes clang-tidy-21 -``` - -Then use `run-clang-tidy-21` when running clang-tidy locally. - -### macOS - -Install LLVM 21 via Homebrew: - -``` -brew install llvm@21 -``` - -Then use `run-clang-tidy` from the LLVM 21 Homebrew prefix when running clang-tidy locally. +On Linux and macOS, the [Nix development shell](./nix.md) provides `clang-tidy` +22 out of the box — run it via `run-clang-tidy`. No separate installation is +needed. diff --git a/docs/build/nix.md b/docs/build/nix.md index 33bb3711d0..2ae483aefe 100644 --- a/docs/build/nix.md +++ b/docs/build/nix.md @@ -2,9 +2,12 @@ This guide explains how to use Nix to set up a reproducible development environment for xrpld. Using Nix eliminates the need to manually install utilities and ensures consistent tooling across different machines. +**The Nix development shell is the recommended way to develop xrpld.** It unifies the development environment for everyone and synchronizes updates: the same tooling and compiler versions are used both here and in CI. Any custom environment (Homebrew packages or anything else) will continue to work, but then it is up to you to keep it in sync with the environment used in CI. + ## Benefits of Using Nix - **Reproducible environment**: Everyone gets the same versions of tools and compilers +- **Matches CI**: The Linux CI runs in Docker images built from this exact Nix environment - **No system pollution**: Dependencies are isolated and don't affect your system packages - **Multiple compiler versions**: Easily switch between different GCC and Clang versions - **Quick setup**: Get started with a single command @@ -28,11 +31,22 @@ This will: - Download and set up all required development tools (CMake, Ninja, Conan, etc.) - Configure the appropriate compiler for your platform: - - **macOS**: Apple Clang (default system compiler) - - **Linux**: GCC 15 + - **Linux**: GCC 15.2 (provided by Nix) + - **macOS**: Apple Clang (your system compiler) The first time you run this command, it will take a few minutes to download and build the environment. Subsequent runs will be much faster. +### Platform notes + +- **Linux**: `nix develop` gives you a shell with all the tooling necessary to + develop xrpld and with GCC 15.2 (also provided by Nix). There are no caveats. +- **macOS**: `nix develop` gives you a full environment too. The compiler is + your system-wide Apple Clang, while every other tool — including Conan — is + provided by Nix. Conan has no binary in the Nix cache for macOS, so it is + built from source the first time you enter the shell, which makes the initial + setup slower (this is handled automatically; see + [`nix/devshell.nix`](../../nix/devshell.nix)). + > [!TIP] > To avoid typing `--experimental-features 'nix-command flakes'` every time, you can permanently enable flakes by creating `~/.config/nix/nix.conf`: > @@ -51,7 +65,7 @@ The first time you run this command, it will take a few minutes to download and A compiler can be chosen by providing its name with the `.#` prefix, e.g. `nix develop .#gcc15`. Use `nix flake show` to see all the available development shells. -Use `nix develop .#no_compiler` to use the compiler from your system. +Use `nix develop .#no-compiler` to use the compiler from your system. ### Example Usage @@ -68,12 +82,28 @@ nix develop ### Using a different shell -`nix develop` opens bash by default. If you want to use another shell this could be done by adding `-c` flag. For example: +`nix develop` opens bash by default. To use another shell, pass it with the `-c` flag — this works with any shell, e.g. `zsh` or `fish`: ```bash +# Use zsh nix develop -c zsh + +# Use fish +nix develop -c fish + +# Use your login shell +nix develop -c "$SHELL" ``` +> [!WARNING] +> Your shell's interactive startup files (e.g. `config.fish`, `.zshrc`) may prepend other directories — most commonly Homebrew — to `$PATH`, which can shadow the tools provided by the Nix shell. After entering, verify that tools resolve into the Nix store: +> +> ```bash +> command -v cmake # should print a /nix/store/... path +> ``` +> +> If it doesn't, either adjust your shell configuration so it doesn't override `$PATH`, or use [direnv](#automatic-activation-with-direnv) (below), which loads the environment _after_ your shell config and so takes precedence regardless of the shell you use. + ## Building xrpld with Nix Once inside the Nix development shell, follow the standard [build instructions](../../BUILD.md#steps). The Nix shell provides all necessary tools (CMake, Ninja, Conan, etc.). @@ -82,6 +112,8 @@ Once inside the Nix development shell, follow the standard [build instructions]( [direnv](https://direnv.net/) or [nix-direnv](https://github.com/nix-community/nix-direnv) can automatically activate the Nix development shell when you enter the repository directory. +This is also the most robust way to use the environment from **any shell** (bash, zsh, fish, …): direnv stays in your current shell and loads the environment _after_ your shell's startup files have run, so the Nix-provided tools take precedence over anything your shell configuration adds to `$PATH`. To use it, install direnv for your shell, then add an `.envrc` containing `use flake` at the repository root and run `direnv allow`. + ## Conan and Prebuilt Packages Please note that there is no guarantee that binaries from conan cache will work when using nix. If you encounter any errors, please use `--build '*'` to force conan to compile everything from source: @@ -93,3 +125,8 @@ conan install .. --output-folder . --build '*' --settings build_type=Release ## Updating `flake.lock` file To update `flake.lock` to the latest revision use `nix flake update` command. + +## Troubleshooting + +See [Troubleshooting Nix problems](./nix_troubleshooting.md) for common issues, +such as `nix develop` failing inside Git worktrees. diff --git a/docs/build/nix_troubleshooting.md b/docs/build/nix_troubleshooting.md new file mode 100644 index 0000000000..ae5cb8059a --- /dev/null +++ b/docs/build/nix_troubleshooting.md @@ -0,0 +1,61 @@ +# Troubleshooting Nix problems + +Common issues encountered when using the [Nix development shell](./nix.md), and +how to resolve them. + +## Git worktrees + +If `nix develop` fails with an error like: + +``` +error: + … while fetching the input 'git+file:///path/to/rippled' + + error: opening Git repository "/path/to/rippled": unsupported extension name extensions.relativeworktrees (libgit2 error code = 6) +``` + +then your Nix is linked against a libgit2 older than **1.9.4**. Git 2.48+ writes +the `extensions.relativeWorktrees` config entry when a worktree is created with +relative paths (`git worktree add --relative-paths`, or with +`worktree.useRelativePaths=true`), and older libgit2 versions refuse to open a +repository that uses it. Nix uses libgit2 to read the flake, so evaluation +fails. + +> [!IMPORTANT] +> This entry is written to the **shared** repository config, so once any +> relative worktree exists, `nix develop` fails in the main checkout too — not +> just inside the worktree. + +### Workarounds + +These work today, with any Nix version: + +- bypass libgit2 with a `path:` flakeref: `nix develop "path:$PWD"` + (note: this copies the working tree to the store and ignores `.gitignore`); or +- create worktrees with absolute paths (omit `--relative-paths`); or +- clear the extension if you don't need relative worktrees: + `git config --unset extensions.relativeWorktrees`. + +### Permanent fix + +The fix is in [libgit2 1.9.4](https://github.com/libgit2/libgit2/releases/tag/v1.9.4), +so the real solution is a Nix that links against libgit2 `1.9.4` or newer. Check +which version yours links against: + +```bash +nix-store -qR "$(readlink -f "$(command -v nix)")" | grep libgit2 +``` + +> [!WARNING] +> `nix upgrade-nix` does **not** help yet. It installs the build from the +> official [`nix-fallback-paths`](https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/installer/tools/nix-fallback-paths.nix), +> which is still linked against libgit2 `1.9.2` — there is no new upstream Nix +> release with the fix. (On some systems that build is even the exact store path +> you already have, making the upgrade a no-op.) + +nixpkgs has already rebuilt Nix against the fixed libgit2 (e.g. `nix-2.34.7+1`), +so the cleanest path is to reinstall Nix using your usual installation method +once it picks up that rebuild, then re-run the `grep libgit2` check above to +confirm it reports `1.9.4` or newer. + +Until then, prefer the workarounds above. diff --git a/flake.lock b/flake.lock index 5ec053975d..80243ccf15 100644 --- a/flake.lock +++ b/flake.lock @@ -2,20 +2,21 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1777954456, - "narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=", + "lastModified": 1781173989, + "narHash": "sha256-fnzKKPvS+oieI/pTzotA5tkoM47EB1NpaBcgk4R97hE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1", + "rev": "8c91a71d13451abc40eb9dae8910f972f979852f", "type": "github" }, "original": { - "id": "nixpkgs", - "ref": "nixos-unstable", - "type": "indirect" + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" } }, - "nixpkgs-glibc231": { + "nixpkgs-custom-glibc": { "flake": false, "locked": { "lastModified": 1593520194, @@ -35,7 +36,7 @@ "root": { "inputs": { "nixpkgs": "nixpkgs", - "nixpkgs-glibc231": "nixpkgs-glibc231" + "nixpkgs-custom-glibc": "nixpkgs-custom-glibc" } } }, diff --git a/flake.nix b/flake.nix index 18671bdf31..c52f4d050e 100644 --- a/flake.nix +++ b/flake.nix @@ -1,21 +1,21 @@ { description = "Nix related things for xrpld"; inputs = { - nixpkgs.url = "nixpkgs/nixos-unstable"; + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; # nixpkgs snapshot (2020-06-30) that shipped glibc 2.31 as the primary # version — matches the system libc on Ubuntu 20.04 LTS. Imported # manually (flake = false) because this revision predates nixpkgs' # own flake.nix. - nixpkgs-glibc231 = { + nixpkgs-custom-glibc = { url = "github:NixOS/nixpkgs/9cd98386a38891d1074fc18036b842dc4416f562"; flake = false; }; }; outputs = - { nixpkgs, nixpkgs-glibc231, ... }: + { nixpkgs, nixpkgs-custom-glibc, ... }: let - forEachSystem = import ./nix/utils.nix { inherit nixpkgs nixpkgs-glibc231; }; + forEachSystem = import ./nix/utils.nix { inherit nixpkgs nixpkgs-custom-glibc; }; in { devShells = forEachSystem (import ./nix/devshell.nix); diff --git a/include/xrpl/basics/DecayingSample.h b/include/xrpl/basics/DecayingSample.h index d1861ebc4a..910c8f9e14 100644 --- a/include/xrpl/basics/DecayingSample.h +++ b/include/xrpl/basics/DecayingSample.h @@ -12,8 +12,8 @@ template class DecayingSample { public: - using value_type = typename Clock::duration::rep; - using time_point = typename Clock::time_point; + using value_type = Clock::duration::rep; + using time_point = Clock::time_point; DecayingSample() = delete; @@ -93,7 +93,7 @@ template class DecayWindow { public: - using time_point = typename Clock::time_point; + using time_point = Clock::time_point; explicit DecayWindow(time_point now) : when_(now) { diff --git a/include/xrpl/basics/Expected.h b/include/xrpl/basics/Expected.h deleted file mode 100644 index 3796151777..0000000000 --- a/include/xrpl/basics/Expected.h +++ /dev/null @@ -1,248 +0,0 @@ -#pragma once - -#include - -#include - -#include - -namespace xrpl { - -/** Expected is an approximation of std::expected (hoped for in C++23) - - See: http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p0323r10.html - - The implementation is entirely based on boost::outcome_v2::result. -*/ - -// Exception thrown by an invalid access to Expected. -struct BadExpectedAccess : public std::runtime_error -{ - BadExpectedAccess() : runtime_error("bad expected access") - { - } -}; - -namespace detail { - -// Custom policy for Expected. Always throw on an invalid access. -struct ThrowPolicy : public boost::outcome_v2::policy::base -{ - template - static constexpr void - // NOLINTNEXTLINE(readability-identifier-naming) - wide_value_check(Impl&& self) - { - if (!base::_has_value(std::forward(self))) - Throw(); - } - - template - static constexpr void - // NOLINTNEXTLINE(readability-identifier-naming) - wide_error_check(Impl&& self) - { - if (!base::_has_error(std::forward(self))) - Throw(); - } - - template - static constexpr void - // NOLINTNEXTLINE(readability-identifier-naming) - wide_exception_check(Impl&& self) - { - if (!base::_has_exception(std::forward(self))) - Throw(); - } -}; - -} // namespace detail - -// Definition of Unexpected, which is used to construct the unexpected -// return type of an Expected. -template -class Unexpected -{ -public: - static_assert(!std::is_same_v, "E must not be void"); - - Unexpected() = delete; - - constexpr explicit Unexpected(E const& e) : val_(e) - { - } - - constexpr explicit Unexpected(E&& e) : val_(std::move(e)) - { - } - - [[nodiscard]] constexpr E const& - value() const& - { - return val_; - } - - constexpr E& - value() & - { - return val_; - } - - constexpr E&& - value() && - { - return std::move(val_); - } - - [[nodiscard]] constexpr E const&& - value() const&& - { - return std::move(val_); - } - -private: - E val_; -}; - -// Unexpected deduction guide that converts array to const*. -template -Unexpected(E (&)[N]) -> Unexpected; - -// Definition of Expected. All of the machinery comes from boost::result. -template -class [[nodiscard]] Expected : private boost::outcome_v2::result -{ - using Base = boost::outcome_v2::result; - -public: - template - requires std::convertible_to - constexpr Expected(U&& r) : Base(boost::outcome_v2::in_place_type_t{}, std::forward(r)) - { - } - - template - requires std::convertible_to && (!std::is_reference_v) - constexpr Expected(Unexpected e) - : Base(boost::outcome_v2::in_place_type_t{}, std::move(e.value())) - { - } - - [[nodiscard]] constexpr bool - // NOLINTNEXTLINE(readability-identifier-naming) - has_value() const - { - return Base::has_value(); - } - - [[nodiscard]] constexpr T const& - value() const - { - return Base::value(); - } - - constexpr T& - value() - { - return Base::value(); - } - - [[nodiscard]] constexpr E const& - error() const& - { - return Base::error(); - } - - [[nodiscard]] constexpr E& - error() & - { - return Base::error(); - } - - [[nodiscard]] constexpr E&& - error() && - { - return std::move(Base::error()); - } - - constexpr explicit - operator bool() const - { - return has_value(); - } - - // Add operator* and operator-> so the Expected API looks a bit more like - // what std::expected is likely to look like. See: - // http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p0323r10.html - [[nodiscard]] constexpr T& - operator*() - { - return this->value(); - } - - [[nodiscard]] constexpr T const& - operator*() const - { - return this->value(); - } - - [[nodiscard]] constexpr T* - operator->() - { - return &this->value(); - } - - [[nodiscard]] constexpr T const* - operator->() const - { - return &this->value(); - } -}; - -// Specialization of Expected. Allows returning either success -// (without a value) or the reason for the failure. -template -class [[nodiscard]] -Expected : private boost::outcome_v2::result -{ - using Base = boost::outcome_v2::result; - -public: - // The default constructor makes a successful Expected. - // This aligns with std::expected behavior proposed in P0323R10. - constexpr Expected() : Base(boost::outcome_v2::success()) - { - } - - template - requires std::convertible_to && (!std::is_reference_v) - constexpr Expected(Unexpected e) : Base(E(std::move(e.value()))) - { - } - - [[nodiscard]] constexpr E const& - error() const& - { - return Base::error(); - } - - [[nodiscard]] constexpr E& - error() & - { - return Base::error(); - } - - [[nodiscard]] constexpr E&& - error() && - { - return std::move(Base::error()); - } - - constexpr explicit - operator bool() const - { - return Base::has_value(); - } -}; - -} // namespace xrpl diff --git a/include/xrpl/basics/Log.h b/include/xrpl/basics/Log.h index 6bafbc7c54..0699cdd3d9 100644 --- a/include/xrpl/basics/Log.h +++ b/include/xrpl/basics/Log.h @@ -206,8 +206,7 @@ private: #ifndef JLOG #define JLOG(x) \ if (!(x)) \ - { \ - } \ + ; \ else \ x #endif diff --git a/include/xrpl/basics/Number.h b/include/xrpl/basics/Number.h index e67f1f534d..cee0c45355 100644 --- a/include/xrpl/basics/Number.h +++ b/include/xrpl/basics/Number.h @@ -2,12 +2,16 @@ #include +#include #include #include #include #include #include +#include +#include #include +#include namespace xrpl { @@ -38,17 +42,58 @@ isPowerOfTen(T value) return logTen(value).has_value(); } +namespace detail { + +/** Builds a table of the powers of 10 + * + * This function is marked consteval, so it can only be run in + * a constexpr context. This assures that it is and can only be run at + * compile time. Doing it at runtime would be pretty wasteful and + * inefficient. + */ +constexpr std::size_t kInt64Digits = 20; +consteval std::array +buildPowersOfTen() +{ + std::array result{}; + + std::uint64_t power = 1; + std::size_t exponent = 0; + // end the loop early so it doesn't overflow; + for (; exponent < result.size() - 1; ++exponent, power *= 10) + { + result[exponent] = power; + if (power > std::numeric_limits::max() / 10) + throw std::logic_error("Power of 10 table is too big"); + } + result[exponent] = power; + if (power < std::numeric_limits::max() / 10) + throw std::logic_error("Power of 10 table is not big enough for the uint64_t type"); + + return result; +} + +} // namespace detail + +constexpr std::array kPowerOfTen = detail::buildPowersOfTen(); + +static_assert(kPowerOfTen[0] == 1); +static_assert(kPowerOfTen[1] == 10); +static_assert(kPowerOfTen[10] == 10'000'000'000); +static_assert( + isPowerOfTen(kPowerOfTen.back()) && *logTen(kPowerOfTen.back()) == detail::kInt64Digits - 1); + /** MantissaRange defines a range for the mantissa of a normalized Number. * * The mantissa is in the range [min, max], where * * min is a power of 10, and * * max = min * 10 - 1. * - * The mantissa_scale enum indicates whether the range is "small" or "large". - * This intentionally restricts the number of MantissaRanges that can be - * instantiated to two: one for each scale. + * The MantissaScale enum indicates properties of the range: size, and some behavioral + * options. This intentionally restricts the number of unique MantissaRanges that can + * be instantiated: one for each scale. * - * The "small" scale is based on the behavior of STAmount for IOUs. It has a min + * The "Small" scale is based on the behavior of STAmount for IOUs. It has a min * value of 10^15, and a max value of 10^16-1. This was sufficient for * uses before Lending Protocol was implemented, mostly related to AMM. * @@ -59,46 +104,100 @@ isPowerOfTen(T value) * STNumber field type, and for internal calculations. That necessitated the * "large" scale. * - * The "large" scale is intended to represent all values that can be represented + * The "Large" scales are intended to represent all values that can be represented * by an STAmount - IOUs, XRP, and MPTs. It has a min value of 10^18, and a max - * value of 10^19-1. + * value of 10^19-1. "LargeLegacy" is like "Large", but preserves + * a rounding error when a computation results in a mantissa of + * Number::kMaxRep that needs to be rounded up, but rounds down + * instead. It will maintain consistent behavior until the fixCleanup3_2_0 + * amendment is enabled. * * Note that if the mentioned amendments are eventually retired, this class - * should be left in place, but the "small" scale option should be removed. This + * should be left in place, but the "Small" scale option should be removed. This * will allow for future expansion beyond 64-bits if it is ever needed. */ -struct MantissaRange +struct MantissaRange final { using rep = std::uint64_t; - enum class MantissaScale { Small, Large }; - explicit constexpr MantissaRange(MantissaScale scale) - : min(getMin(scale)), log(logTen(min).value_or(-1)), scale(scale) + enum class MantissaScale { + Small, + // LargeLegacy can be removed when fixCleanup3_2_0 is retired + LargeLegacy, + Large, + }; + + // This entire enum can be removed when fixCleanup3_2_0 is retired + enum class CuspRoundingFix : bool { + Disabled = false, + Enabled = true, + }; + + explicit constexpr MantissaRange(MantissaScale sc) : scale(sc) { } - rep min; - rep max{(min * 10) - 1}; - int log; - MantissaScale scale; + MantissaScale const scale; + int const log{getExponent(scale)}; + rep const min{getMin(scale, log)}; + rep const max{(min * 10) - 1}; + CuspRoundingFix const cuspRoundingFixEnabled{isCuspFixEnabled(scale)}; + + static MantissaRange const& + getMantissaRange(MantissaScale scale); + + static std::set const& + getAllScales(); private: - static constexpr rep - getMin(MantissaScale scale) + static constexpr int + getExponent(MantissaScale scale) { switch (scale) { case MantissaScale::Small: - return 1'000'000'000'000'000ULL; + return 15; + case MantissaScale::LargeLegacy: case MantissaScale::Large: - return 1'000'000'000'000'000'000ULL; + return 18; + // LCOV_EXCL_START default: - // Since this can never be called outside a non-constexpr - // context, this throw assures that the build fails if an + // If called in a constexpr context, this throw assures that the build fails if an // invalid scale is used. throw std::runtime_error("Unknown mantissa scale"); + // LCOV_EXCL_STOP } } + + // Keep this function for future use with different ways to compute + // the ranges. + static constexpr rep + getMin(MantissaScale scale, int exponent) + { + if (exponent < 0 || exponent >= kPowerOfTen.size()) + throw std::runtime_error("Invalid exponent"); // LCOV_EXCL_LINE + return kPowerOfTen[exponent]; + } + + static constexpr CuspRoundingFix + isCuspFixEnabled(MantissaScale scale) + { + switch (scale) + { + case MantissaScale::Small: + case MantissaScale::LargeLegacy: + return CuspRoundingFix::Disabled; + case MantissaScale::Large: + return CuspRoundingFix::Enabled; + default: + // If called in a constexpr context, this throw assures that the build fails if an + // invalid scale is used. + throw std::runtime_error("Unknown mantissa scale"); // LCOV_EXCL_LINE + } + } + + static std::unordered_map const& + getRanges(); }; // Like std::integral, but only 64-bit integral types. @@ -203,7 +302,7 @@ concept Integral64 = std::is_same_v || std::is_same_v 0; + // Both have same sign and the left is zero: both must be non-negative. + // If the right is greater than 0, then it is larger, so the comparison is true. + if (l.mantissa_ == 0) + return r.mantissa_ > 0; - // Both have same sign, the right is zero and the left is non-zero. - if (y.mantissa_ == 0) + // Both have same sign, the right is zero and the left is non-zero, so the left must be + // positive, and thus is larger, so the comparison is false. + if (r.mantissa_ == 0) return false; // Both have the same sign, compare by exponents: - if (x.exponent_ > y.exponent_) + if (l.exponent_ > r.exponent_) return lneg; - if (x.exponent_ < y.exponent_) + if (l.exponent_ < r.exponent_) return !lneg; - // If equal exponents, compare mantissas - return x.mantissa_ < y.mantissa_; + // If equal signs and exponents, compare mantissas. + if (lneg) + { + // If negative, the operator is reversed. + return l.mantissa_ > r.mantissa_; + } + + return l.mantissa_ < r.mantissa_; } /** Return the sign of the amount */ @@ -424,49 +530,28 @@ public: return kRange.get().log; } - /// oneSmall is needed because the ranges are private - static constexpr Number - oneSmall(); - /// oneLarge is needed because the ranges are private - static constexpr Number - oneLarge(); - - // And one is needed because it needs to choose between oneSmall and - // oneLarge based on the current range static Number one(); - template + template < + auto MinMantissa, + auto MaxMantissa, + Integral64 T = std::decay_t> [[nodiscard]] std::pair - normalizeToRange(T minMantissa, T maxMantissa) const; + normalizeToRange() const; private: static thread_local RoundingMode mode; // The available ranges for mantissa - static constexpr MantissaRange kSmallRange{MantissaRange::MantissaScale::Small}; - static_assert(isPowerOfTen(kSmallRange.min)); - static_assert(kSmallRange.min == 1'000'000'000'000'000LL); - static_assert(kSmallRange.max == 9'999'999'999'999'999LL); - static_assert(kSmallRange.log == 15); - static_assert(kSmallRange.min < kMaxRep); - static_assert(kSmallRange.max < kMaxRep); - static constexpr MantissaRange kLargeRange{MantissaRange::MantissaScale::Large}; - static_assert(isPowerOfTen(kLargeRange.min)); - static_assert(kLargeRange.min == 1'000'000'000'000'000'000ULL); - static_assert(kLargeRange.max == internalrep(9'999'999'999'999'999'999ULL)); - static_assert(kLargeRange.log == 18); - static_assert(kLargeRange.min < kMaxRep); - static_assert(kLargeRange.max > kMaxRep); - // The range for the mantissa when normalized. // Use reference_wrapper to avoid making copies, and prevent accidentally // changing the values inside the range. static thread_local std::reference_wrapper kRange; void - normalize(); + normalize(MantissaRange const& range); /** Normalize Number components to an arbitrary range. * @@ -481,7 +566,8 @@ private: T& mantissa, int& exponent, internalrep const& minMantissa, - internalrep const& maxMantissa); + internalrep const& maxMantissa, + MantissaRange::CuspRoundingFix cuspRoundingFixEnabled); template friend void @@ -490,7 +576,9 @@ private: T& mantissa, int& exponent, MantissaRange::rep const& minMantissa, - MantissaRange::rep const& maxMantissa); + MantissaRange::rep const& maxMantissa, + MantissaRange::CuspRoundingFix cuspRoundingFixEnabled, + bool dropped); [[nodiscard]] bool isnormal() const noexcept; @@ -526,7 +614,7 @@ static constexpr Number kNumZero{}; inline Number::Number(bool negative, internalrep mantissa, int exponent, Normalized) : Number(negative, mantissa, exponent, Unchecked{}) { - normalize(); + normalize(kRange); } inline Number::Number(internalrep mantissa, int exponent, Normalized) @@ -696,10 +784,21 @@ Number::isnormal() const noexcept kMinExponent <= exponent_ && exponent_ <= kMaxExponent); } -template +template std::pair -Number::normalizeToRange(T minMantissa, T maxMantissa) const +Number::normalizeToRange() const { + static_assert(std::is_same_v || std::is_same_v); + static_assert(std::is_same_v>); + static_assert(std::is_same_v>); + auto constexpr kMIN = static_cast(MinMantissa); + auto constexpr kMAX = static_cast(MaxMantissa); + static_assert(kMIN > 0); + static_assert(kMIN % 10 == 0); + static_assert(isPowerOfTen(kMIN)); + static_assert(kMAX % 10 == 9); + static_assert((kMAX + 1) / 10 == kMIN); + bool negative = negative_; internalrep mantissa = mantissa_; int exponent = exponent_; @@ -711,7 +810,10 @@ Number::normalizeToRange(T minMantissa, T maxMantissa) const "xrpl::Number::normalizeToRange", "Number is non-negative for unsigned range."); } - Number::normalize(negative, mantissa, exponent, minMantissa, maxMantissa); + // Don't need to worry about the cuspRounding fix because rounding up will never take the + // mantissa over maxMantissa with a ones digit value other than 0. 0 can safely be truncated. + Number::normalize( + negative, mantissa, exponent, kMIN, kMAX, MantissaRange::CuspRoundingFix::Disabled); auto const sign = negative ? -1 : 1; return std::make_pair(static_cast(sign * mantissa), exponent); @@ -763,6 +865,8 @@ to_string(MantissaRange::MantissaScale const& scale) { case MantissaRange::MantissaScale::Small: return "small"; + case MantissaRange::MantissaScale::LargeLegacy: + return "largeLegacy"; case MantissaRange::MantissaScale::Large: return "large"; default: diff --git a/include/xrpl/basics/StringUtilities.h b/include/xrpl/basics/StringUtilities.h index 28421626aa..1d3434b7ed 100644 --- a/include/xrpl/basics/StringUtilities.h +++ b/include/xrpl/basics/StringUtilities.h @@ -11,6 +11,7 @@ #include #include #include +#include #include namespace xrpl { @@ -95,13 +96,7 @@ strUnHex(std::size_t strSize, Iterator begin, Iterator end) } inline std::optional -strUnHex(std::string const& strSrc) -{ - return strUnHex(strSrc.size(), strSrc.cbegin(), strSrc.cend()); -} - -inline std::optional -strViewUnHex(std::string_view strSrc) +strUnHex(std::string_view strSrc) { return strUnHex(strSrc.size(), strSrc.cbegin(), strSrc.cend()); } diff --git a/include/xrpl/basics/TaggedCache.h b/include/xrpl/basics/TaggedCache.h index 52b866cdad..973fcd828a 100644 --- a/include/xrpl/basics/TaggedCache.h +++ b/include/xrpl/basics/TaggedCache.h @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,22 @@ namespace xrpl { +namespace detail { + +// Replace-policy tags selecting how TaggedCache::canonicalizeImpl resolves a +// collision when the key already exists (defined in TaggedCache.ipp): +// - ReplaceCached: always replace the cached value with `data`. `data` is +// never written back and may be const. +// - ReplaceClient: keep the cached value and write it back into `data` (the +// client's pointer), which must therefore be writable. +// - ReplaceDynamically: call the supplied callback to decide per call; `data` +// is written back when the cached value is kept, so it must be writable. +struct ReplaceCached; +struct ReplaceClient; +struct ReplaceDynamically; + +} // namespace detail + /** Map/cache combination. This class implements a cache and a map. The cache keeps objects alive in the map. The map allows multiple code paths that reference objects @@ -96,6 +113,32 @@ public: bool del(key_type const& key, bool valid); +private: + // Selects the `data` parameter type of canonicalizeImpl from the replace + // policy: const for detail::ReplaceCached (never written back), otherwise + // writable. + template + using CanonicalizeClientPointerType = std::conditional_t< + std::is_same_v, + SharedPointerType const&, + SharedPointerType&>; + + /** Shared implementation of the canonicalize family. + + `policy` selects how a collision is resolved when `key` already exists: + detail::ReplaceCached, detail::ReplaceClient or + detail::ReplaceDynamically. For ReplaceDynamically `replaceCallback` is + invoked with the existing strong pointer and returns whether to replace + the cached value with `data`; for the tag policies it is unused. + */ + template + bool + canonicalizeImpl( + key_type const& key, + CanonicalizeClientPointerType data, + Policy policy, + Callback&& replaceCallback = nullptr); + public: /** Replace aliased objects with originals. @@ -104,19 +147,52 @@ public: This routine eliminates the duplicate and performs a replacement on the callers shared pointer if needed. + `replaceCallback` is a callable taking the existing strong pointer and + returning whether to replace the cached value with `data` (true) or to + keep the cached value and write it back into `data` (false). Because the + write-back case mutates `data`, `data` must be writable. + @param key The key corresponding to the object @param data A shared pointer to the data corresponding to the object. - @param replace Function that decides if cache should be replaced + @param replaceCallback A callable (existing strong pointer -> bool). - @return `true` If the key already existed. - */ - template + @return `true` if an existing live entry was found and used; `false` if a new entry was + inserted or an expired tracked entry was re-cached. + **/ + template bool - canonicalize(key_type const& key, SharedPointerType& data, R&& replaceCallback); + canonicalize(key_type const& key, SharedPointerType& data, Callback&& replaceCallback); + /** Insert/update the canonical entry for `key`, always replacing the + cached value with `data`. + + If an entry already exists for `key`, the cached value is unconditionally + replaced with `data`; otherwise `data` is inserted. `data` is never + written back, so it may be const. + + @param key The key corresponding to the object. + @param data A shared pointer to the data corresponding to the object. + + @return `true` if an existing live entry was found and used; `false` if a new entry was + inserted or an expired tracked entry was re-cached. + **/ bool canonicalizeReplaceCache(key_type const& key, SharedPointerType const& data); + /** Insert the canonical entry for `key`, keeping any existing cached value. + + If an entry already exists for `key`, the cached value is kept and + written back into `data` so the caller ends up with the canonical + object; otherwise `data` is inserted. Because `data` may be overwritten + it must be writable. + + @param key The key corresponding to the object. + @param data A shared pointer to the data corresponding to the object; + updated to the canonical value when one already exists. + + @return `true` if an existing live entry was found and used; `false` if a new entry was + inserted or an expired tracked entry was re-cached. + **/ bool canonicalizeReplaceClient(key_type const& key, SharedPointerType& data); @@ -157,7 +233,7 @@ public: /** Fetch an item from the cache. If the digest was not found, Handler will be called with this signature: - std::shared_ptr(void) + SLE::const_pointer(void) */ template SharedPointerType @@ -181,14 +257,14 @@ private: beast::insight::Collector::ptr const& collector) : hook(collector->makeHook(handler)) , size(collector->makeGauge(prefix, "size")) - , hit_rate(collector->makeGauge(prefix, "hit_rate")) + , hitRate(collector->makeGauge(prefix, "hit_rate")) { } beast::insight::Hook hook; beast::insight::Gauge size; - beast::insight::Gauge hit_rate; + beast::insight::Gauge hitRate; std::size_t hits{0}; std::size_t misses{0}; @@ -197,16 +273,16 @@ private: class KeyOnlyEntry { public: - clock_type::time_point last_access; + clock_type::time_point lastAccess; - explicit KeyOnlyEntry(clock_type::time_point const& lastAccess) : last_access(lastAccess) + explicit KeyOnlyEntry(clock_type::time_point const& lastAccess) : lastAccess(lastAccess) { } void touch(clock_type::time_point const& now) { - last_access = now; + lastAccess = now; } }; @@ -214,10 +290,10 @@ private: { public: shared_weak_combo_pointer_type ptr; - clock_type::time_point last_access; + clock_type::time_point lastAccess; ValueEntry(clock_type::time_point const& lastAccess, shared_pointer_type const& ptr) - : ptr(ptr), last_access(lastAccess) + : ptr(ptr), lastAccess(lastAccess) { } @@ -246,7 +322,7 @@ private: void touch(clock_type::time_point const& now) { - last_access = now; + lastAccess = now; } }; @@ -262,7 +338,7 @@ private: sweepHelper( clock_type::time_point const& whenExpire, [[maybe_unused]] clock_type::time_point const& now, - typename KeyValueCacheType::map_type& partition, + KeyValueCacheType::map_type& partition, SweptPointersVector& stuffToSweep, std::atomic& allRemovals, std::scoped_lock const&); @@ -271,7 +347,7 @@ private: sweepHelper( clock_type::time_point const& whenExpire, clock_type::time_point const& now, - typename KeyOnlyCacheType::map_type& partition, + KeyOnlyCacheType::map_type& partition, SweptPointersVector&, std::atomic& allRemovals, std::scoped_lock const&); @@ -286,13 +362,13 @@ private: std::string name_; // Desired number of cache entries (0 = ignore) - int const target_size_; + int const targetSize_; // Desired maximum cache age - clock_type::duration const target_age_; + clock_type::duration const targetAge_; // Number of items cached - int cache_count_{0}; + int cacheCount_{0}; cache_type cache_; // Hold strong reference to recent objects std::uint64_t hits_{0}; std::uint64_t misses_{0}; diff --git a/include/xrpl/basics/TaggedCache.ipp b/include/xrpl/basics/TaggedCache.ipp index 8ef9e16570..7e812ce4c7 100644 --- a/include/xrpl/basics/TaggedCache.ipp +++ b/include/xrpl/basics/TaggedCache.ipp @@ -5,6 +5,30 @@ namespace xrpl { +namespace detail { + +// Replace-policy tags selecting how TaggedCache::canonicalizeImpl resolves a +// collision when the key already exists: +// - ReplaceCached: always replace the cached value with `data`. `data` is +// never written back and may be const. +// - ReplaceClient: keep the cached value and write it back into `data` (the +// client's pointer), which must therefore be writable. +// - ReplaceDynamically: call the supplied callback to decide per call; `data` +// is written back when the cached value is kept, so it must be writable. +struct ReplaceCached +{ +}; + +struct ReplaceClient +{ +}; + +struct ReplaceDynamically +{ +}; + +} // namespace detail + template < class Key, class T, @@ -34,8 +58,8 @@ inline TaggedCache< , clock_(clock) , stats_(name, std::bind(&TaggedCache::collectMetrics, this), collector) , name_(name) - , target_size_(size) - , target_age_(expiration) + , targetSize_(size) + , targetAge_(expiration) { } @@ -86,7 +110,7 @@ TaggedCache(cache_.size()) <= target_size_)) + if (targetSize_ == 0 || (static_cast(cache_.size()) <= targetSize_)) { - whenExpire = now - target_age_; + whenExpire = now - targetAge_; } else { - whenExpire = now - (target_age_ * target_size_ / cache_.size()); + whenExpire = now - (targetAge_ * targetSize_ / cache_.size()); clock_type::duration const minimumAge(std::chrono::seconds(1)); if (whenExpire > (now - minimumAge)) whenExpire = now - minimumAge; JLOG(journal_.trace()) - << name_ << " is growing fast " << cache_.size() << " of " << target_size_ - << " aging at " << (now - whenExpire).count() << " of " << target_age_.count(); + << name_ << " is growing fast " << cache_.size() << " of " << targetSize_ + << " aging at " << (now - whenExpire).count() << " of " << targetAge_.count(); } std::vector workers; @@ -242,7 +266,7 @@ TaggedCache -template +template inline bool TaggedCache:: - canonicalize(key_type const& key, SharedPointerType& data, R&& replaceCallback) + canonicalizeImpl( + key_type const& key, + CanonicalizeClientPointerType data, + [[maybe_unused]] Policy policy, + [[maybe_unused]] Callback&& replaceCallback) { // Return canonical value, store if needed, refresh in cache // Return values: true=we had the data already + + // `Policy` is one of: + // - detail::ReplaceCached: always replace the cached value with `data`; + // `data` is never written back and may be const. + // - detail::ReplaceClient: keep the cached value and write it back into + // `data` (the client's pointer), which must therefore be writable. + // - detail::ReplaceDynamically: call `replaceCallback` to decide at run + // time; `data` must be writable. + // For the latter two the write-back below requires a mutable `data`, so + // passing a const argument is a compile error. + constexpr bool replaceCached = std::is_same_v; + std::scoped_lock const lock(mutex_); auto cit = cache_.find(key); @@ -317,20 +357,21 @@ TaggedCachesecond; entry.touch(clock_.now()); - auto shouldReplace = [&] { - if constexpr (std::is_invocable_r_v) + auto shouldReplaceCached = [&] { + if constexpr (replaceCached) { - // The reason for this extra complexity is for intrusive - // strong/weak combo getting a strong is relatively expensive - // and not needed for many cases. - return replaceCallback(); + return true; + } + else if constexpr (std::is_same_v) + { + return false; } else { @@ -340,11 +381,11 @@ TaggedCache +template +inline bool +TaggedCache:: + canonicalize(key_type const& key, SharedPointerType& data, Callback&& replaceCallback) +{ + return canonicalizeImpl( + key, data, detail::ReplaceDynamically{}, std::forward(replaceCallback)); +} + template < class Key, class T, @@ -389,7 +448,7 @@ inline bool TaggedCache:: canonicalizeReplaceCache(key_type const& key, SharedPointerType const& data) { - return canonicalize(key, const_cast(data), []() { return true; }); + return canonicalizeImpl(key, data, detail::ReplaceCached{}); } template < @@ -405,7 +464,7 @@ inline bool TaggedCache:: canonicalizeReplaceClient(key_type const& key, SharedPointerType& data) { - return canonicalize(key, data, []() { return false; }); + return canonicalizeImpl(key, data, detail::ReplaceClient{}); } template < @@ -477,7 +536,7 @@ TaggedCachesecond.last_access = now; + it->second.lastAccess = now; return inserted; } @@ -626,7 +685,7 @@ TaggedCache& allRemovals, std::scoped_lock const&) @@ -706,7 +765,7 @@ TaggedCachesecond.last_access <= whenExpire) + else if (cit->second.lastAccess <= whenExpire) { // strong, expired ++cacheRemovals; @@ -756,7 +815,7 @@ TaggedCache& allRemovals, std::scoped_lock const&) @@ -773,12 +832,12 @@ TaggedCachesecond.last_access > now) + if (cit->second.lastAccess > now) { - cit->second.last_access = now; + cit->second.lastAccess = now; ++cit; } - else if (cit->second.last_access <= whenExpire) + else if (cit->second.lastAccess <= whenExpire) { cit = partition.erase(cit); } diff --git a/include/xrpl/basics/base64.h b/include/xrpl/basics/base64.h index ed30e40a36..660958ce14 100644 --- a/include/xrpl/basics/base64.h +++ b/include/xrpl/basics/base64.h @@ -36,6 +36,7 @@ #include #include +#include namespace xrpl { @@ -43,7 +44,7 @@ std::string base64Encode(std::uint8_t const* data, std::size_t len); inline std::string -base64Encode(std::string const& s) +base64Encode(std::string_view s) { return base64Encode(reinterpret_cast(s.data()), s.size()); } diff --git a/include/xrpl/basics/base_uint.h b/include/xrpl/basics/base_uint.h index 93a9ced15e..93520ff699 100644 --- a/include/xrpl/basics/base_uint.h +++ b/include/xrpl/basics/base_uint.h @@ -5,7 +5,6 @@ #pragma once -#include #include #include #include @@ -20,6 +19,7 @@ #include #include #include +#include #include namespace xrpl { @@ -177,7 +177,7 @@ private: BadChar, }; - constexpr Expected + constexpr std::expected parseFromStringView(std::string_view sv) noexcept { // Local lambda that converts a single hex char to four bits and @@ -216,7 +216,7 @@ private: } if (sv.size() != size() * 2) - return Unexpected(ParseResult::BadLength); + return std::unexpected(ParseResult::BadLength); std::size_t i = 0u; auto in = sv.begin(); @@ -227,7 +227,7 @@ private: { if (auto const result = hexCharToUInt(*in++, shift, accum); result != ParseResult::Okay) - return Unexpected(result); + return std::unexpected(result); } ret[i++] = accum; } diff --git a/include/xrpl/basics/hardened_hash.h b/include/xrpl/basics/hardened_hash.h index efc77e058b..b8ea1e0f3f 100644 --- a/include/xrpl/basics/hardened_hash.h +++ b/include/xrpl/basics/hardened_hash.h @@ -75,7 +75,7 @@ private: detail::seed_pair seeds_{detail::makeSeedPair<>()}; public: - using result_type = typename HashAlgorithm::result_type; + using result_type = HashAlgorithm::result_type; HardenedHash() = default; diff --git a/include/xrpl/basics/join.h b/include/xrpl/basics/join.h index 0fb00aaf82..c214212473 100644 --- a/include/xrpl/basics/join.h +++ b/include/xrpl/basics/join.h @@ -1,12 +1,13 @@ #pragma once #include +#include namespace xrpl { template Stream& -join(Stream& s, Iter iter, Iter end, std::string const& delimiter) +join(Stream& s, Iter iter, Iter end, std::string_view delimiter) { if (iter == end) return s; diff --git a/include/xrpl/basics/partitioned_unordered_map.h b/include/xrpl/basics/partitioned_unordered_map.h index 3bf64985e5..c51cedf2dd 100644 --- a/include/xrpl/basics/partitioned_unordered_map.h +++ b/include/xrpl/basics/partitioned_unordered_map.h @@ -57,8 +57,8 @@ public: { using iterator_category = std::forward_iterator_tag; partition_map_type* map{nullptr}; - typename partition_map_type::iterator ait{}; - typename map_type::iterator mit; + partition_map_type::iterator ait{}; + map_type::iterator mit; Iterator() = default; @@ -126,8 +126,8 @@ public: using iterator_category = std::forward_iterator_tag; partition_map_type* map{nullptr}; - typename partition_map_type::iterator ait{}; - typename map_type::iterator mit; + partition_map_type::iterator ait{}; + map_type::iterator mit; ConstIterator() = default; diff --git a/include/xrpl/basics/random.h b/include/xrpl/basics/random.h index 17f4a1c213..0b298e12d9 100644 --- a/include/xrpl/basics/random.h +++ b/include/xrpl/basics/random.h @@ -29,6 +29,7 @@ static_assert( namespace detail { // Determines if a type can be called like an Engine +// NOLINTNEXTLINE(readability-redundant-typename): typename required by MSVC template using is_engine = std::is_invocable_r; } // namespace detail diff --git a/include/xrpl/basics/rocksdb.h b/include/xrpl/basics/rocksdb.h deleted file mode 100644 index 3d468b0f1b..0000000000 --- a/include/xrpl/basics/rocksdb.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#if XRPL_ROCKSDB_AVAILABLE -// #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#endif diff --git a/include/xrpl/basics/sanitizers.h b/include/xrpl/basics/sanitizers.h index b954952848..7937344b53 100644 --- a/include/xrpl/basics/sanitizers.h +++ b/include/xrpl/basics/sanitizers.h @@ -4,7 +4,7 @@ /* ASAN flags some false positives with sudden jumps in control flow, like exceptions, or when encountering coroutine stack switches. This macro can be used to disable ASAN - intrumentation for specific functions. + instrumentation for specific functions. */ #if defined(__GNUC__) || defined(__clang__) #define XRPL_NO_SANITIZE_ADDRESS __attribute__((no_sanitize("address", "hwaddress"))) diff --git a/include/xrpl/basics/scope.h b/include/xrpl/basics/scope.h index cd2a5299b2..cfd21e6e30 100644 --- a/include/xrpl/basics/scope.h +++ b/include/xrpl/basics/scope.h @@ -24,20 +24,20 @@ namespace xrpl { template class ScopeExit { - EF exit_function_; - bool execute_on_destruction_{true}; + EF exitFunction_; + bool executeOnDestruction_{true}; public: ~ScopeExit() { - if (execute_on_destruction_) - exit_function_(); + if (executeOnDestruction_) + exitFunction_(); } ScopeExit(ScopeExit&& rhs) noexcept( std::is_nothrow_move_constructible_v || std::is_nothrow_copy_constructible_v) - : exit_function_{std::forward(rhs.exit_function_)} - , execute_on_destruction_{rhs.execute_on_destruction_} + : exitFunction_{std::forward(rhs.exitFunction_)} + , executeOnDestruction_{rhs.executeOnDestruction_} { rhs.release(); } @@ -51,7 +51,7 @@ public: std::enable_if_t< !std::is_same_v, ScopeExit> && std::is_constructible_v>* = 0) noexcept - : exit_function_{std::forward(f)} + : exitFunction_{std::forward(f)} { static_assert(std::is_nothrow_constructible_v(f))>); } @@ -59,7 +59,7 @@ public: void release() noexcept { - execute_on_destruction_ = false; + executeOnDestruction_ = false; } }; @@ -69,22 +69,22 @@ ScopeExit(EF) -> ScopeExit; template class ScopeFail { - EF exit_function_; - bool execute_on_destruction_{true}; - int uncaught_on_creation_{std::uncaught_exceptions()}; + EF exitFunction_; + bool executeOnDestruction_{true}; + int uncaughtOnCreation_{std::uncaught_exceptions()}; public: ~ScopeFail() { - if (execute_on_destruction_ && std::uncaught_exceptions() > uncaught_on_creation_) - exit_function_(); + if (executeOnDestruction_ && std::uncaught_exceptions() > uncaughtOnCreation_) + exitFunction_(); } ScopeFail(ScopeFail&& rhs) noexcept( std::is_nothrow_move_constructible_v || std::is_nothrow_copy_constructible_v) - : exit_function_{std::forward(rhs.exit_function_)} - , execute_on_destruction_{rhs.execute_on_destruction_} - , uncaught_on_creation_{rhs.uncaught_on_creation_} + : exitFunction_{std::forward(rhs.exitFunction_)} + , executeOnDestruction_{rhs.executeOnDestruction_} + , uncaughtOnCreation_{rhs.uncaughtOnCreation_} { rhs.release(); } @@ -98,7 +98,7 @@ public: std::enable_if_t< !std::is_same_v, ScopeFail> && std::is_constructible_v>* = 0) noexcept - : exit_function_{std::forward(f)} + : exitFunction_{std::forward(f)} { static_assert(std::is_nothrow_constructible_v(f))>); } @@ -106,7 +106,7 @@ public: void release() noexcept { - execute_on_destruction_ = false; + executeOnDestruction_ = false; } }; @@ -116,22 +116,22 @@ ScopeFail(EF) -> ScopeFail; template class ScopeSuccess { - EF exit_function_; - bool execute_on_destruction_{true}; - int uncaught_on_creation_{std::uncaught_exceptions()}; + EF exitFunction_; + bool executeOnDestruction_{true}; + int uncaughtOnCreation_{std::uncaught_exceptions()}; public: - ~ScopeSuccess() noexcept(noexcept(exit_function_())) + ~ScopeSuccess() noexcept(noexcept(exitFunction_())) { - if (execute_on_destruction_ && std::uncaught_exceptions() <= uncaught_on_creation_) - exit_function_(); + if (executeOnDestruction_ && std::uncaught_exceptions() <= uncaughtOnCreation_) + exitFunction_(); } ScopeSuccess(ScopeSuccess&& rhs) noexcept( std::is_nothrow_move_constructible_v || std::is_nothrow_copy_constructible_v) - : exit_function_{std::forward(rhs.exit_function_)} - , execute_on_destruction_{rhs.execute_on_destruction_} - , uncaught_on_creation_{rhs.uncaught_on_creation_} + : exitFunction_{std::forward(rhs.exitFunction_)} + , executeOnDestruction_{rhs.executeOnDestruction_} + , uncaughtOnCreation_{rhs.uncaughtOnCreation_} { rhs.release(); } @@ -146,14 +146,14 @@ public: !std::is_same_v, ScopeSuccess> && std::is_constructible_v>* = 0) noexcept(std::is_nothrow_constructible_v || std::is_nothrow_constructible_v) - : exit_function_{std::forward(f)} + : exitFunction_{std::forward(f)} { } void release() noexcept { - execute_on_destruction_ = false; + executeOnDestruction_ = false; } }; diff --git a/include/xrpl/beast/asio/io_latency_probe.h b/include/xrpl/beast/asio/io_latency_probe.h index ce3929a394..5e1b098dcb 100644 --- a/include/xrpl/beast/asio/io_latency_probe.h +++ b/include/xrpl/beast/asio/io_latency_probe.h @@ -18,8 +18,8 @@ template class IOLatencyProbe { private: - using duration = typename Clock::duration; - using time_point = typename Clock::time_point; + using duration = Clock::duration; + using time_point = Clock::time_point; std::recursive_mutex mutex_; std::condition_variable_any cond_; diff --git a/include/xrpl/beast/clock/abstract_clock.h b/include/xrpl/beast/clock/abstract_clock.h index 33d4096d0e..15d785d138 100644 --- a/include/xrpl/beast/clock/abstract_clock.h +++ b/include/xrpl/beast/clock/abstract_clock.h @@ -34,10 +34,10 @@ template class AbstractClock { public: - using rep = typename Clock::rep; - using period = typename Clock::period; - using duration = typename Clock::duration; - using time_point = typename Clock::time_point; + using rep = Clock::rep; + using period = Clock::period; + using duration = Clock::duration; + using time_point = Clock::time_point; using clock_type = Clock; static bool const is_steady = Clock::is_steady; // NOLINT(readability-identifier-naming) diff --git a/include/xrpl/beast/clock/basic_seconds_clock.h b/include/xrpl/beast/clock/basic_seconds_clock.h index 8460dbe26b..5a267e9458 100644 --- a/include/xrpl/beast/clock/basic_seconds_clock.h +++ b/include/xrpl/beast/clock/basic_seconds_clock.h @@ -20,10 +20,10 @@ public: explicit BasicSecondsClock() = default; - using rep = typename Clock::rep; - using period = typename Clock::period; - using duration = typename Clock::duration; - using time_point = typename Clock::time_point; + using rep = Clock::rep; + using period = Clock::period; + using duration = Clock::duration; + using time_point = Clock::time_point; static bool const is_steady = // NOLINT(readability-identifier-naming) Clock::is_steady; diff --git a/include/xrpl/beast/container/detail/aged_container_iterator.h b/include/xrpl/beast/container/detail/aged_container_iterator.h index 8d44646cd4..02fb3927dd 100644 --- a/include/xrpl/beast/container/detail/aged_container_iterator.h +++ b/include/xrpl/beast/container/detail/aged_container_iterator.h @@ -16,15 +16,15 @@ template class AgedContainerIterator { public: - using iterator_category = typename std::iterator_traits::iterator_category; + using iterator_category = std::iterator_traits::iterator_category; using value_type = std::conditional_t< IsConst, typename Iterator::value_type::Stashed::value_type const, typename Iterator::value_type::Stashed::value_type>; - using difference_type = typename std::iterator_traits::difference_type; + using difference_type = std::iterator_traits::difference_type; using pointer = value_type*; using reference = value_type&; - using time_point = typename Iterator::value_type::Stashed::time_point; + using time_point = Iterator::value_type::Stashed::time_point; AgedContainerIterator() = default; diff --git a/include/xrpl/beast/container/detail/aged_ordered_container.h b/include/xrpl/beast/container/detail/aged_ordered_container.h index ad368f2754..4cb2246a22 100644 --- a/include/xrpl/beast/container/detail/aged_ordered_container.h +++ b/include/xrpl/beast/container/detail/aged_ordered_container.h @@ -62,8 +62,8 @@ class AgedOrderedContainer { public: using clock_type = AbstractClock; - using time_point = typename clock_type::time_point; - using duration = typename clock_type::duration; + using time_point = clock_type::time_point; + using duration = clock_type::duration; using key_type = Key; using mapped_type = T; using value_type = std::conditional_t, Key>; @@ -94,8 +94,8 @@ private: { explicit Stashed() = default; - using value_type = typename AgedOrderedContainer::value_type; - using time_point = typename AgedOrderedContainer::time_point; + using value_type = AgedOrderedContainer::value_type; + using time_point = AgedOrderedContainer::time_point; }; Element(time_point const& when, value_type const& value) : value(value), when(when) @@ -192,8 +192,8 @@ private: } }; - using list_type = typename boost::intrusive:: - make_list>::type; + using list_type = + boost::intrusive::make_list>::type; using cont_type = std::conditional_t< IsMulti, @@ -206,8 +206,7 @@ private: boost::intrusive::constant_time_size, boost::intrusive::compare>::type>; - using ElementAllocator = - typename std::allocator_traits::template rebind_alloc; + using ElementAllocator = std::allocator_traits::template rebind_alloc; using ElementAllocatorTraits = std::allocator_traits; @@ -373,8 +372,8 @@ public: using allocator_type = Allocator; using reference = value_type&; using const_reference = value_type const&; - using pointer = typename std::allocator_traits::pointer; - using const_pointer = typename std::allocator_traits::const_pointer; + using pointer = std::allocator_traits::pointer; + using const_pointer = std::allocator_traits::const_pointer; // A set iterator (IsMap==false) is always const // because the elements of a set are immutable. @@ -617,7 +616,7 @@ public: bool MaybeMulti = IsMulti, bool MaybeMap = IsMap, class = std::enable_if_t> - typename std::conditional::type const& + std::conditional::type const& at(K const& k) const; template < @@ -1146,7 +1145,7 @@ private: void touch( beast::detail::AgedContainerIterator pos, - typename clock_type::time_point const& now); + clock_type::time_point const& now); template < bool MaybePropagate = std::allocator_traits::propagate_on_container_swap::value> @@ -1393,7 +1392,7 @@ AgedOrderedContainer::at(K co template template -typename std::conditional::type const& +std::conditional::type const& AgedOrderedContainer::at(K const& k) const { auto const iter(cont_.find(k, std::cref(config_.keyCompare()))); @@ -1732,7 +1731,7 @@ AgedOrderedContainer::operato cend(), other.cbegin(), other.cend(), - [&eq, &other](value_type const& lhs, typename Other::value_type const& rhs) { + [&eq, &other](value_type const& lhs, Other::value_type const& rhs) { return eq(extract(lhs), other.extract(rhs)); }); } @@ -1744,7 +1743,7 @@ template void AgedOrderedContainer::touch( beast::detail::AgedContainerIterator pos, - typename clock_type::time_point const& now) + clock_type::time_point const& now) { auto& e(*pos.iterator()); e.when = now; diff --git a/include/xrpl/beast/container/detail/aged_unordered_container.h b/include/xrpl/beast/container/detail/aged_unordered_container.h index 7162c237d6..3bad12d9e5 100644 --- a/include/xrpl/beast/container/detail/aged_unordered_container.h +++ b/include/xrpl/beast/container/detail/aged_unordered_container.h @@ -67,8 +67,8 @@ class AgedUnorderedContainer { public: using clock_type = AbstractClock; - using time_point = typename clock_type::time_point; - using duration = typename clock_type::duration; + using time_point = clock_type::time_point; + using duration = clock_type::duration; using key_type = Key; using mapped_type = T; using value_type = std::conditional_t, Key>; @@ -99,8 +99,8 @@ private: { explicit Stashed() = default; - using value_type = typename AgedUnorderedContainer::value_type; - using time_point = typename AgedUnorderedContainer::time_point; + using value_type = AgedUnorderedContainer::value_type; + using time_point = AgedUnorderedContainer::time_point; }; Element(time_point const& when, value_type const& value) : value(value), when(when) @@ -201,8 +201,8 @@ private: } }; - using list_type = typename boost::intrusive:: - make_list>::type; + using list_type = + boost::intrusive::make_list>::type; using cont_type = std::conditional_t< IsMulti, @@ -219,16 +219,14 @@ private: boost::intrusive::equal, boost::intrusive::cache_begin>::type>; - using bucket_type = typename cont_type::bucket_type; - using bucket_traits = typename cont_type::bucket_traits; + using bucket_type = cont_type::bucket_type; + using bucket_traits = cont_type::bucket_traits; - using ElementAllocator = - typename std::allocator_traits::template rebind_alloc; + using ElementAllocator = std::allocator_traits::template rebind_alloc; using ElementAllocatorTraits = std::allocator_traits; - using BucketAllocator = - typename std::allocator_traits::template rebind_alloc; + using BucketAllocator = std::allocator_traits::template rebind_alloc; using BucketAllocatorTraits = std::allocator_traits; @@ -542,8 +540,8 @@ public: using allocator_type = Allocator; using reference = value_type&; using const_reference = value_type const&; - using pointer = typename std::allocator_traits::pointer; - using const_pointer = typename std::allocator_traits::const_pointer; + using pointer = std::allocator_traits::pointer; + using const_pointer = std::allocator_traits::const_pointer; // A set iterator (IsMap==false) is always const // because the elements of a set are immutable. @@ -850,7 +848,7 @@ public: bool MaybeMulti = IsMulti, bool MaybeMap = IsMap, class = std::enable_if_t> - typename std::conditional::type const& + std::conditional::type const& at(K const& k) const; template < @@ -1414,7 +1412,7 @@ private: void touch( beast::detail::AgedContainerIterator pos, - typename clock_type::time_point const& now) + clock_type::time_point const& now) { auto& e(*pos.iterator()); e.when = now; @@ -2111,7 +2109,7 @@ template < class KeyEqual, class Allocator> template -typename std::conditional::type const& +std::conditional::type const& AgedUnorderedContainer::at( K const& k) const { diff --git a/include/xrpl/beast/core/List.h b/include/xrpl/beast/core/List.h index 946126a004..1c3827ae1c 100644 --- a/include/xrpl/beast/core/List.h +++ b/include/xrpl/beast/core/List.h @@ -24,7 +24,7 @@ struct CopyConst { explicit CopyConst() = default; - using type = typename std::remove_const::type const; + using type = std::remove_const::type const; }; /** @} */ @@ -56,7 +56,7 @@ class ListIterator { public: using iterator_category = std::bidirectional_iterator_tag; - using value_type = typename beast::detail::CopyConst::type; + using value_type = beast::detail::CopyConst::type; using difference_type = std::ptrdiff_t; using pointer = value_type*; using reference = value_type&; @@ -259,7 +259,7 @@ template class List { public: - using Node = typename detail::ListNode; + using Node = detail::ListNode; using value_type = T; using pointer = value_type*; diff --git a/include/xrpl/beast/core/LockFreeStack.h b/include/xrpl/beast/core/LockFreeStack.h index 6b8f686246..d4ad45cf5c 100644 --- a/include/xrpl/beast/core/LockFreeStack.h +++ b/include/xrpl/beast/core/LockFreeStack.h @@ -12,13 +12,13 @@ template class LockFreeStackIterator { protected: - using Node = typename Container::Node; + using Node = Container::Node; using NodePtr = std::conditional_t; public: using iterator_category = std::forward_iterator_tag; - using value_type = typename Container::value_type; - using difference_type = typename Container::difference_type; + using value_type = Container::value_type; + using difference_type = Container::difference_type; using pointer = std::conditional_t; using reference = std:: diff --git a/include/xrpl/beast/hash/uhash.h b/include/xrpl/beast/hash/uhash.h index 97461f67c7..9ffd5924f7 100644 --- a/include/xrpl/beast/hash/uhash.h +++ b/include/xrpl/beast/hash/uhash.h @@ -11,7 +11,7 @@ struct Uhash { Uhash() = default; - using result_type = typename Hasher::result_type; + using result_type = Hasher::result_type; template result_type diff --git a/include/xrpl/beast/hash/xxhasher.h b/include/xrpl/beast/hash/xxhasher.h index 95a67dede0..978bbc6917 100644 --- a/include/xrpl/beast/hash/xxhasher.h +++ b/include/xrpl/beast/hash/xxhasher.h @@ -7,8 +7,11 @@ #include #include #include +#include +#include #include #include +#include namespace beast { diff --git a/include/xrpl/beast/insight/Counter.h b/include/xrpl/beast/insight/Counter.h index 71ace4bb4e..482808b2c7 100644 --- a/include/xrpl/beast/insight/Counter.h +++ b/include/xrpl/beast/insight/Counter.h @@ -3,6 +3,7 @@ #include #include +#include namespace beast::insight { @@ -29,7 +30,7 @@ public: factory function in the Collector interface. @see Collector. */ - explicit Counter(std::shared_ptr const& impl) : impl_(impl) + explicit Counter(std::shared_ptr impl) : impl_(std::move(impl)) { } diff --git a/include/xrpl/beast/insight/Event.h b/include/xrpl/beast/insight/Event.h index 5e424a0f9b..afccf9baba 100644 --- a/include/xrpl/beast/insight/Event.h +++ b/include/xrpl/beast/insight/Event.h @@ -4,6 +4,7 @@ #include #include +#include namespace beast::insight { @@ -31,7 +32,7 @@ public: factory function in the Collector interface. @see Collector. */ - explicit Event(std::shared_ptr const& impl) : impl_(impl) + explicit Event(std::shared_ptr impl) : impl_(std::move(impl)) { } diff --git a/include/xrpl/beast/insight/Gauge.h b/include/xrpl/beast/insight/Gauge.h index dd2c4bc6b6..b24c4366c3 100644 --- a/include/xrpl/beast/insight/Gauge.h +++ b/include/xrpl/beast/insight/Gauge.h @@ -3,6 +3,7 @@ #include #include +#include namespace beast::insight { @@ -31,7 +32,7 @@ public: factory function in the Collector interface. @see Collector. */ - explicit Gauge(std::shared_ptr const& impl) : impl_(impl) + explicit Gauge(std::shared_ptr impl) : impl_(std::move(impl)) { } diff --git a/include/xrpl/beast/insight/Hook.h b/include/xrpl/beast/insight/Hook.h index 1cb6cae5d9..8dbe5a4be0 100644 --- a/include/xrpl/beast/insight/Hook.h +++ b/include/xrpl/beast/insight/Hook.h @@ -3,6 +3,7 @@ #include #include +#include namespace beast::insight { @@ -20,7 +21,7 @@ public: factory function in the Collector interface. @see Collector. */ - explicit Hook(std::shared_ptr const& impl) : impl_(impl) + explicit Hook(std::shared_ptr impl) : impl_(std::move(impl)) { } diff --git a/include/xrpl/beast/insight/Meter.h b/include/xrpl/beast/insight/Meter.h index 03aa17c313..25ffabd928 100644 --- a/include/xrpl/beast/insight/Meter.h +++ b/include/xrpl/beast/insight/Meter.h @@ -3,6 +3,7 @@ #include #include +#include namespace beast::insight { @@ -28,7 +29,7 @@ public: factory function in the Collector interface. @see Collector. */ - explicit Meter(std::shared_ptr const& impl) : impl_(impl) + explicit Meter(std::shared_ptr impl) : impl_(std::move(impl)) { } diff --git a/include/xrpl/beast/rfc2616.h b/include/xrpl/beast/rfc2616.h index cf80bd26c0..e810733210 100644 --- a/include/xrpl/beast/rfc2616.h +++ b/include/xrpl/beast/rfc2616.h @@ -102,7 +102,7 @@ Result split(FwdIt first, FwdIt last, Char delim) { using namespace detail; - using string = typename Result::value_type; + using string = Result::value_type; Result result; diff --git a/include/xrpl/beast/unit_test/detail/const_container.h b/include/xrpl/beast/unit_test/detail/const_container.h index c171771e45..6826bf4258 100644 --- a/include/xrpl/beast/unit_test/detail/const_container.h +++ b/include/xrpl/beast/unit_test/detail/const_container.h @@ -32,11 +32,11 @@ protected: } public: - using value_type = typename cont_type::value_type; - using size_type = typename cont_type::size_type; - using difference_type = typename cont_type::difference_type; - using iterator = typename cont_type::const_iterator; - using const_iterator = typename cont_type::const_iterator; + using value_type = cont_type::value_type; + using size_type = cont_type::size_type; + using difference_type = cont_type::difference_type; + using iterator = cont_type::const_iterator; + using const_iterator = cont_type::const_iterator; /** Returns `true` if the container is empty. */ [[nodiscard]] bool diff --git a/include/xrpl/beast/unit_test/match.h b/include/xrpl/beast/unit_test/match.h index 222c4ea656..da466ab228 100644 --- a/include/xrpl/beast/unit_test/match.h +++ b/include/xrpl/beast/unit_test/match.h @@ -41,7 +41,7 @@ private: public: template - explicit Selector(ModeT mode, std::string const& pattern = ""); + explicit Selector(ModeT mode, std::string pattern = ""); template bool @@ -51,9 +51,9 @@ public: //------------------------------------------------------------------------------ template -Selector::Selector(ModeT mode, std::string const& pattern) : mode_(mode), pat_(pattern) +Selector::Selector(ModeT mode, std::string pattern) : mode_(mode), pat_(std::move(pattern)) { - if (mode_ == ModeT::Automatch && pattern.empty()) + if (mode_ == ModeT::Automatch && pat_.empty()) mode_ = ModeT::All; } diff --git a/include/xrpl/beast/unit_test/reporter.h b/include/xrpl/beast/unit_test/reporter.h index b4b6c69fa4..ff990dece5 100644 --- a/include/xrpl/beast/unit_test/reporter.h +++ b/include/xrpl/beast/unit_test/reporter.h @@ -48,7 +48,7 @@ private: std::size_t cases = 0; std::size_t total = 0; std::size_t failed = 0; - typename clock_type::time_point start = clock_type::now(); + clock_type::time_point start = clock_type::now(); explicit SuiteResults(std::string name = "") : name(std::move(name)) { @@ -60,7 +60,7 @@ private: struct Results { - using run_time = std::pair; + using run_time = std::pair; static constexpr auto kMaxTop = 10; @@ -69,7 +69,7 @@ private: std::size_t total = 0; std::size_t failed = 0; std::vector top; - typename clock_type::time_point start = clock_type::now(); + clock_type::time_point start = clock_type::now(); void add(SuiteResults const& r); @@ -77,8 +77,8 @@ private: std::ostream& os_; Results results_; - SuiteResults suite_results_; - CaseResults case_results_; + SuiteResults suiteResults_; + CaseResults caseResults_; public: Reporter(Reporter const&) = delete; @@ -91,7 +91,7 @@ public: private: static std::string - fmtdur(typename clock_type::duration const& d); + fmtdur(clock_type::duration const& d); void onSuiteBegin(SuiteInfo const& info) override; @@ -141,9 +141,7 @@ Reporter::Results::add(SuiteResults const& r) top.begin(), top.end(), elapsed, - [](run_time const& t1, typename clock_type::duration const& t2) { - return t1.second > t2; - }); + [](run_time const& t1, clock_type::duration const& t2) { return t1.second > t2; }); if (iter != top.end()) { if (top.size() == kMaxTop) @@ -181,7 +179,7 @@ Reporter::~Reporter() template std::string -Reporter::fmtdur(typename clock_type::duration const& d) +Reporter::fmtdur(clock_type::duration const& d) { using namespace std::chrono; auto const ms = duration_cast(d); @@ -196,22 +194,22 @@ template void Reporter::onSuiteBegin(SuiteInfo const& info) { - suite_results_ = SuiteResults{info.fullName()}; + suiteResults_ = SuiteResults{info.fullName()}; } template void Reporter::onSuiteEnd() { - results_.add(suite_results_); + results_.add(suiteResults_); } template void Reporter::onCaseBegin(std::string const& name) { - case_results_ = CaseResults(name); - os_ << suite_results_.name << (case_results_.name.empty() ? "" : (" " + case_results_.name)) + caseResults_ = CaseResults(name); + os_ << suiteResults_.name << (caseResults_.name.empty() ? "" : (" " + caseResults_.name)) << std::endl; } @@ -219,23 +217,23 @@ template void Reporter::onCaseEnd() { - suite_results_.add(case_results_); + suiteResults_.add(caseResults_); } template void Reporter::onPass() { - ++case_results_.total; + ++caseResults_.total; } template void Reporter::onFail(std::string const& reason) { - ++case_results_.failed; - ++case_results_.total; - os_ << "#" << case_results_.total << " failed" << (reason.empty() ? "" : ": ") << reason + ++caseResults_.failed; + ++caseResults_.total; + os_ << "#" << caseResults_.total << " failed" << (reason.empty() ? "" : ": ") << reason << std::endl; } diff --git a/include/xrpl/beast/utility/Journal.h b/include/xrpl/beast/utility/Journal.h index 1a0c148d1f..1262a64179 100644 --- a/include/xrpl/beast/utility/Journal.h +++ b/include/xrpl/beast/utility/Journal.h @@ -411,9 +411,9 @@ class BasicLogstream : public std::basic_ostream { using char_type = CharT; using traits_type = Traits; - using int_type = typename traits_type::int_type; - using pos_type = typename traits_type::pos_type; - using off_type = typename traits_type::off_type; + using int_type = traits_type::int_type; + using pos_type = traits_type::pos_type; + using off_type = traits_type::off_type; detail::LogStreamBuf buf_; diff --git a/include/xrpl/beast/utility/instrumentation.h b/include/xrpl/beast/utility/instrumentation.h index 39b80bc438..c20c42156a 100644 --- a/include/xrpl/beast/utility/instrumentation.h +++ b/include/xrpl/beast/utility/instrumentation.h @@ -11,6 +11,8 @@ // Macros below are copied from antithesis_sdk.h and slightly simplified // The duplication is because Visual Studio 2019 cannot compile that header // even with the option -Zc:__cplusplus added. +// NOTE: cond must not contain bare commas outside () or []. Commas inside {} +// are not protected by the preprocessor and would be parsed as extra arguments. #define ALWAYS(cond, message, ...) assert((message) && (cond)) #define ALWAYS_OR_UNREACHABLE(cond, message) assert((message) && (cond)) #define SOMETIMES(cond, message, ...) @@ -22,6 +24,8 @@ #define XRPL_ASSERT_PARTS(cond, function, description, ...) \ XRPL_ASSERT(cond, function " : " description) +#define XRPL_ASSERT_IF(guard, cond, message) XRPL_ASSERT(!(guard) || (cond), message) + // How to use the instrumentation macros: // // * XRPL_ASSERT if cond must be true but the line might not be reached during @@ -29,6 +33,14 @@ // * XRPL_ASSERT_PARTS is for convenience, and works like XRPL_ASSERT, but // splits the message param into "function" and "description", then joins // them with " : " before passing to XRPL_ASSERT. +// * XRPL_ASSERT_IF(guard, cond, message) asserts the implication +// `guard => cond`: it can only fail when guard is true (e.g. an amendment +// is enabled) and cond is false. Unlike `if (guard) XRPL_ASSERT(...)`, the +// assertion site is always evaluated, so the fuzzer registers it +// unconditionally; cond itself is short-circuited and only evaluated when +// guard is true. NOTE: do not rely on side effects in guard — in release +// builds the assertion body is stripped, and the compiler may optimize away +// a side-effect-free guard entirely. // * ALWAYS if cond must be true _and_ the line must be reached during fuzzing. // Same like `assert` in normal use. // * REACHABLE if the line must be reached during fuzzing diff --git a/include/xrpl/beast/utility/maybe_const.h b/include/xrpl/beast/utility/maybe_const.h index 40904471be..10b2eaf7f6 100644 --- a/include/xrpl/beast/utility/maybe_const.h +++ b/include/xrpl/beast/utility/maybe_const.h @@ -15,6 +15,6 @@ struct MaybeConst /** Alias for omitting `typename`. */ template -using maybe_const_t = typename MaybeConst::type; +using maybe_const_t = MaybeConst::type; } // namespace beast diff --git a/include/xrpl/beast/utility/rngfill.h b/include/xrpl/beast/utility/rngfill.h index 1614a594c5..2ea84a7a3d 100644 --- a/include/xrpl/beast/utility/rngfill.h +++ b/include/xrpl/beast/utility/rngfill.h @@ -13,7 +13,7 @@ template void rngfill(void* const buffer, std::size_t const bytes, Generator& g) { - using result_type = typename Generator::result_type; + using result_type = Generator::result_type; constexpr std::size_t kResultSize = sizeof(result_type); std::uint8_t* const bufferStart = static_cast(buffer); @@ -42,7 +42,7 @@ template < void rngfill(std::array& a, Generator& g) { - using result_type = typename Generator::result_type; + using result_type = Generator::result_type; auto i = N / sizeof(result_type); result_type* p = reinterpret_cast(a.data()); while (i--) diff --git a/include/xrpl/basics/BasicConfig.h b/include/xrpl/config/BasicConfig.h similarity index 99% rename from include/xrpl/basics/BasicConfig.h rename to include/xrpl/config/BasicConfig.h index 5a82f7d081..858bf8bf2e 100644 --- a/include/xrpl/basics/BasicConfig.h +++ b/include/xrpl/config/BasicConfig.h @@ -298,7 +298,8 @@ set(T& target, std::string const& name, Section const& section) try { auto const val = section.get(name); - if ((foundAndValid = val.has_value())) + foundAndValid = val.has_value(); + if (foundAndValid) target = *val; } catch (boost::bad_lexical_cast const&) // NOLINT(bugprone-empty-catch) diff --git a/include/xrpl/config/Constants.h b/include/xrpl/config/Constants.h new file mode 100644 index 0000000000..5514e0e77b --- /dev/null +++ b/include/xrpl/config/Constants.h @@ -0,0 +1,180 @@ +#pragma once + +namespace xrpl { + +struct Sections +{ + static constexpr auto kAmendments = "amendments"; + static constexpr auto kAmendmentMajorityTime = "amendment_majority_time"; + static constexpr auto kBetaRpcApi = "beta_rpc_api"; + static constexpr auto kClusterNodes = "cluster_nodes"; + static constexpr auto kCompression = "compression"; + static constexpr auto kCrawl = "crawl"; + static constexpr auto kDatabasePath = "database_path"; + static constexpr auto kDebugLogfile = "debug_logfile"; + static constexpr auto kElbSupport = "elb_support"; + static constexpr auto kFeatures = "features"; + static constexpr auto kFeeDefault = "fee_default"; + static constexpr auto kFetchDepth = "fetch_depth"; + static constexpr auto kHashrouter = "hashrouter"; + static constexpr auto kImportNodeDatabase = "import_db"; + static constexpr auto kInsight = "insight"; + static constexpr auto kIoWorkers = "io_workers"; + static constexpr auto kIps = "ips"; + static constexpr auto kIpsFixed = "ips_fixed"; + static constexpr auto kLedgerHistory = "ledger_history"; + static constexpr auto kLedgerReplay = "ledger_replay"; + static constexpr auto kLedgerTxTables = "ledger_tx_tables"; + static constexpr auto kMaxTransactions = "max_transactions"; + static constexpr auto kNetworkId = "network_id"; + static constexpr auto kNetworkQuorum = "network_quorum"; + static constexpr auto kNodeDatabase = "node_db"; + static constexpr auto kNodeSeed = "node_seed"; + static constexpr auto kNodeSize = "node_size"; + static constexpr auto kOverlay = "overlay"; + static constexpr auto kPathSearch = "path_search"; + static constexpr auto kPathSearchFast = "path_search_fast"; + static constexpr auto kPathSearchMax = "path_search_max"; + static constexpr auto kPathSearchOld = "path_search_old"; + static constexpr auto kPeerPrivate = "peer_private"; + static constexpr auto kPeersInMax = "peers_in_max"; + static constexpr auto kPeersMax = "peers_max"; + static constexpr auto kPeersOutMax = "peers_out_max"; + static constexpr auto kPerf = "perf"; + static constexpr auto kPortGrpc = "port_grpc"; + static constexpr auto kPortPeer = "port_peer"; + static constexpr auto kPortRpc = "port_rpc"; + static constexpr auto kPortWs = "port_ws"; + static constexpr auto kPortWssAdmin = "port_wss_admin"; + static constexpr auto kPrefetchWorkers = "prefetch_workers"; + static constexpr auto kReduceRelay = "reduce_relay"; + static constexpr auto kRelationalDb = "relational_db"; + static constexpr auto kRelayProposals = "relay_proposals"; + static constexpr auto kRelayValidations = "relay_validations"; + static constexpr auto kRpcStartup = "rpc_startup"; + static constexpr auto kServer = "server"; + static constexpr auto kServerDomain = "server_domain"; + static constexpr auto kSigningSupport = "signing_support"; + static constexpr auto kSntp = "sntp_servers"; + static constexpr auto kSqdb = "sqdb"; + static constexpr auto kSqlite = "sqlite"; + static constexpr auto kSslVerify = "ssl_verify"; + static constexpr auto kSslVerifyDir = "ssl_verify_dir"; + static constexpr auto kSslVerifyFile = "ssl_verify_file"; + static constexpr auto kSweepInterval = "sweep_interval"; + static constexpr auto kTransactionQueue = "transaction_queue"; + static constexpr auto kValidationSeed = "validation_seed"; + static constexpr auto kValidatorKeys = "validator_keys"; + static constexpr auto kValidatorKeyRevocation = "validator_key_revocation"; + static constexpr auto kValidatorListKeys = "validator_list_keys"; + static constexpr auto kValidatorListSites = "validator_list_sites"; + static constexpr auto kValidatorListThreshold = "validator_list_threshold"; + static constexpr auto kValidatorToken = "validator_token"; + static constexpr auto kValidators = "validators"; + static constexpr auto kValidatorsFile = "validators_file"; + static constexpr auto kVetoAmendments = "veto_amendments"; + static constexpr auto kVl = "vl"; + static constexpr auto kVoting = "voting"; + static constexpr auto kWorkers = "workers"; +}; + +struct Keys +{ + static constexpr auto kAccountReserve = "account_reserve"; + static constexpr auto kAddress = "address"; + static constexpr auto kAdmin = "admin"; + static constexpr auto kAdminPassword = "admin_password"; + static constexpr auto kAdminUser = "admin_user"; + static constexpr auto kAdvisoryDelete = "advisory_delete"; + static constexpr auto kAgeThresholdSeconds = "age_threshold_seconds"; + static constexpr auto kBackOff = "backOff"; + static constexpr auto kBackOffMilliseconds = "back_off_milliseconds"; + static constexpr auto kBackend = "backend"; + static constexpr auto kBbtOptions = "bbt_options"; + static constexpr auto kBgThreads = "bg_threads"; + static constexpr auto kBlockSize = "block_size"; + static constexpr auto kCacheAge = "cache_age"; + static constexpr auto kCacheMb = "cache_mb"; + static constexpr auto kCacheSize = "cache_size"; + static constexpr auto kClientMaxWindowBits = "client_max_window_bits"; + static constexpr auto kClientNoContextTakeover = "client_no_context_takeover"; + static constexpr auto kCompressLevel = "compress_level"; + static constexpr auto kCounts = "counts"; + static constexpr auto kDeleteBatch = "delete_batch"; + static constexpr auto kEarliestSeq = "earliest_seq"; + static constexpr auto kFastLoad = "fast_load"; + static constexpr auto kFileSizeMb = "file_size_mb"; + static constexpr auto kFileSizeMult = "file_size_mult"; + static constexpr auto kFilterBits = "filter_bits"; + static constexpr auto kFilterFull = "filter_full"; + static constexpr auto kHardSet = "hard_set"; + static constexpr auto kHighThreads = "high_threads"; + static constexpr auto kHoldTime = "hold_time"; + static constexpr auto kIp = "ip"; + static constexpr auto kJournalMode = "journal_mode"; + static constexpr auto kJournalSizeLimit = "journal_size_limit"; + static constexpr auto kLedgersInQueue = "ledgers_in_queue"; + static constexpr auto kLimit = "limit"; + static constexpr auto kLogInterval = "log_interval"; + static constexpr auto kMaxDivergedTime = "max_diverged_time"; + static constexpr auto kMaxLedgerCountsToStore = "max_ledger_counts_to_store"; + static constexpr auto kMaxUnknownTime = "max_unknown_time"; + 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 kMinLedgersToComputeSizeLimit = "min_ledgers_to_compute_size_limit"; + static constexpr auto kMinimumEscalationMultiplier = "minimum_escalation_multiplier"; + static constexpr auto kMinimumLastLedgerBuffer = "minimum_last_ledger_buffer"; + static constexpr auto kMinimumQueueSize = "minimum_queue_size"; + static constexpr auto kMinimumTxnInLedger = "minimum_txn_in_ledger"; + static constexpr auto kMinimumTxnInLedgerStandalone = "minimum_txn_in_ledger_standalone"; + static constexpr auto kNormalConsensusIncreasePercent = "normal_consensus_increase_percent"; + static constexpr auto kNudbBlockSize = "nudb_block_size"; + static constexpr auto kOnlineDelete = "online_delete"; + static constexpr auto kOpenFiles = "open_files"; + static constexpr auto kOptions = "options"; + static constexpr auto kOverlay = "overlay"; + static constexpr auto kOwnerReserve = "owner_reserve"; + static constexpr auto kPageSize = "page_size"; + static constexpr auto kPassword = "password"; + static constexpr auto kPath = "path"; + static constexpr auto kPermessageDeflate = "permessage_deflate"; + static constexpr auto kPort = "port"; + static constexpr auto kPrefix = "prefix"; + static constexpr auto kProtocol = "protocol"; + static constexpr auto kRecoveryWaitSeconds = "recovery_wait_seconds"; + static constexpr auto kReferenceFee = "reference_fee"; + static constexpr auto kRelayTime = "relay_time"; + static constexpr auto kRetrySequencePercent = "retry_sequence_percent"; + static constexpr auto kRqBundle = "rq_bundle"; + static constexpr auto kSafetyLevel = "safety_level"; + static constexpr auto kSecureGateway = "secure_gateway"; + static constexpr auto kSendQueueLimit = "send_queue_limit"; + static constexpr auto kServer = "server"; + static constexpr auto kServerMaxWindowBits = "server_max_window_bits"; + static constexpr auto kServerNoContextTakeover = "server_no_context_takeover"; + static constexpr auto kSlowConsensusDecreasePercent = "slow_consensus_decrease_percent"; + static constexpr auto kSslCert = "ssl_cert"; + static constexpr auto kSslCertChain = "ssl_cert_chain"; + static constexpr auto kSslChain = "ssl_chain"; + static constexpr auto kSslCiphers = "ssl_ciphers"; + static constexpr auto kSslClientCa = "ssl_client_ca"; + static constexpr auto kSslKey = "ssl_key"; + static constexpr auto kSynchronous = "synchronous"; + static constexpr auto kTargetTxnInLedger = "target_txn_in_ledger"; + static constexpr auto kTempStore = "temp_store"; + static constexpr auto kTxEnable = "tx_enable"; + static constexpr auto kTxMetrics = "tx_metrics"; + static constexpr auto kTxMinPeers = "tx_min_peers"; + static constexpr auto kTxRelayPercentage = "tx_relay_percentage"; + static constexpr auto kType = "type"; + static constexpr auto kUniversalCompaction = "universal_compaction"; + static constexpr auto kUnl = "unl"; + static constexpr auto kUseTxTables = "use_tx_tables"; + static constexpr auto kUser = "user"; + static constexpr auto kVpBaseSquelchEnable = "vp_base_squelch_enable"; + static constexpr auto kVpBaseSquelchMaxSelectedPeers = "vp_base_squelch_max_selected_peers"; + static constexpr auto kVpEnable = "vp_enable"; +}; + +} // namespace xrpl diff --git a/include/xrpl/core/Coro.ipp b/include/xrpl/core/Coro.ipp index 90fa965038..133caf37a9 100644 --- a/include/xrpl/core/Coro.ipp +++ b/include/xrpl/core/Coro.ipp @@ -47,7 +47,7 @@ inline bool JobQueue::Coro::post() { { - std::scoped_lock const lk(mutex_run_); + std::scoped_lock const lk(mutexRun_); running_ = true; } @@ -58,7 +58,7 @@ JobQueue::Coro::post() } // The coroutine will not run. Clean up running_. - std::scoped_lock const lk(mutex_run_); + std::scoped_lock const lk(mutexRun_); running_ = false; cv_.notify_all(); return false; @@ -68,7 +68,7 @@ inline void JobQueue::Coro::resume() { { - std::scoped_lock const lk(mutex_run_); + std::scoped_lock const lk(mutexRun_); running_ = true; } { @@ -92,7 +92,7 @@ JobQueue::Coro::resume() } detail::getLocalValues().release(); detail::getLocalValues().reset(saved); - std::scoped_lock const lk(mutex_run_); + std::scoped_lock const lk(mutexRun_); running_ = false; cv_.notify_all(); } @@ -127,7 +127,7 @@ JobQueue::Coro::expectEarlyExit() inline void JobQueue::Coro::join() { - std::unique_lock lk(mutex_run_); + std::unique_lock lk(mutexRun_); cv_.wait(lk, [this]() { return !running_; }); } diff --git a/include/xrpl/core/Job.h b/include/xrpl/core/Job.h index b62488dec6..6af32eb2d8 100644 --- a/include/xrpl/core/Job.h +++ b/include/xrpl/core/Job.h @@ -127,7 +127,7 @@ private: std::function job_; std::shared_ptr loadEvent_; std::string name_; - clock_type::time_point queue_time_; + clock_type::time_point queueTime_; }; using JobCounter = ClosureCounter; diff --git a/include/xrpl/core/JobQueue.h b/include/xrpl/core/JobQueue.h index 2a96ea45fa..fc15e9a064 100644 --- a/include/xrpl/core/JobQueue.h +++ b/include/xrpl/core/JobQueue.h @@ -52,7 +52,7 @@ public: std::string name_; bool running_{false}; std::mutex mutex_; - std::mutex mutex_run_; + std::mutex mutexRun_; std::condition_variable cv_; boost::coroutines2::coroutine::push_type* yield_{}; boost::coroutines2::coroutine::pull_type coro_; @@ -246,7 +246,7 @@ private: // Statistics tracking perf::PerfLog& perfLog_; beast::insight::Collector::ptr collector_; - beast::insight::Gauge job_count_; + beast::insight::Gauge jobCount_; beast::insight::Hook hook_; std::condition_variable cv_; diff --git a/include/xrpl/core/PerfLog.h b/include/xrpl/core/PerfLog.h index 38318c745d..ca0d9333a4 100644 --- a/include/xrpl/core/PerfLog.h +++ b/include/xrpl/core/PerfLog.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include @@ -18,6 +17,7 @@ class Journal; namespace xrpl { class Application; +class Section; namespace perf { /** diff --git a/include/xrpl/crypto/RFC1751.h b/include/xrpl/crypto/RFC1751.h index c99c691ba0..19b636b9dc 100644 --- a/include/xrpl/crypto/RFC1751.h +++ b/include/xrpl/crypto/RFC1751.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include namespace xrpl { @@ -34,7 +35,7 @@ private: static void standard(std::string& strWord); static int - wsrch(std::string const& strWord, int iMin, int iMax); + wsrch(std::string_view strWord, int iMin, int iMax); static int etob(std::string& strData, std::vector vsHuman); diff --git a/include/xrpl/json/Writer.h b/include/xrpl/json/Writer.h index 664a2a75c1..87e3e99c7e 100644 --- a/include/xrpl/json/Writer.h +++ b/include/xrpl/json/Writer.h @@ -161,7 +161,7 @@ public: * While the JSON spec doesn't explicitly disallow this, you should avoid * calling this method twice with the same tag for the same object. * - * If CHECK_JSON_WRITER is defined, this function throws an exception if if + * If CHECK_JSON_WRITER is defined, this function throws an exception if * the tag you use has already been used in this object. */ template diff --git a/include/xrpl/json/json_value.h b/include/xrpl/json/json_value.h index e9dcb8bcbe..f786c6a9dc 100644 --- a/include/xrpl/json/json_value.h +++ b/include/xrpl/json/json_value.h @@ -566,6 +566,7 @@ public: using SelfType = ValueConstIterator; ValueConstIterator() = default; + ValueConstIterator(ValueConstIterator const& other) = default; private: /*! \internal Use by Value to create an iterator. @@ -574,12 +575,12 @@ private: public: SelfType& - operator=(ValueIteratorBase const& other); + operator=(SelfType const& other); SelfType operator++(int) { - SelfType temp(*this); + SelfType const temp(*this); ++*this; return temp; } @@ -587,7 +588,7 @@ public: SelfType operator--(int) { - SelfType temp(*this); + SelfType const temp(*this); --*this; return temp; } diff --git a/include/xrpl/ledger/ApplyView.h b/include/xrpl/ledger/ApplyView.h index f825311e1d..362eae0f79 100644 --- a/include/xrpl/ledger/ApplyView.h +++ b/include/xrpl/ledger/ApplyView.h @@ -123,7 +123,7 @@ private: bool preserveOrder, Keylet const& directory, uint256 const& key, - std::function const&)> const& describe); + std::function const& describe); public: ApplyView() = default; @@ -153,7 +153,7 @@ public: @return `nullptr` if the key is not present */ - virtual std::shared_ptr + virtual SLE::pointer peek(Keylet const& k) = 0; /** Remove a peeked SLE. @@ -168,7 +168,7 @@ public: The key is no longer associated with the SLE. */ virtual void - erase(std::shared_ptr const& sle) = 0; + erase(SLE::ref sle) = 0; /** Insert a new state SLE @@ -189,7 +189,7 @@ public: @note The key is taken from the SLE */ virtual void - insert(std::shared_ptr const& sle) = 0; + insert(SLE::ref sle) = 0; /** Indicate changes to a peeked SLE @@ -208,7 +208,7 @@ public: */ /** @{ */ virtual void - update(std::shared_ptr const& sle) = 0; + update(SLE::ref sle) = 0; //-------------------------------------------------------------------------- @@ -301,7 +301,7 @@ public: dirAppend( Keylet const& directory, Keylet const& key, - std::function const&)> const& describe) + std::function const& describe) { if (key.type != ltOFFER) { @@ -340,7 +340,7 @@ public: dirInsert( Keylet const& directory, uint256 const& key, - std::function const&)> const& describe) + std::function const& describe) { return dirAdd(false, directory, key, describe); } @@ -349,7 +349,7 @@ public: dirInsert( Keylet const& directory, Keylet const& key, - std::function const&)> const& describe) + std::function const& describe) { return dirAdd(false, directory, key.key, describe); } @@ -411,7 +411,7 @@ createRoot( ApplyView& view, Keylet const& directory, uint256 const& key, - std::function const&)> const& describe); + std::function const& describe); auto findPreviousPage(ApplyView& view, Keylet const& directory, SLE::ref start); @@ -434,7 +434,7 @@ insertPage( SLE::ref next, uint256 const& key, Keylet const& directory, - std::function const&)> const& describe); + std::function const& describe); } // namespace directory } // namespace xrpl diff --git a/include/xrpl/ledger/ApplyViewImpl.h b/include/xrpl/ledger/ApplyViewImpl.h index 7f790f2be5..1245568630 100644 --- a/include/xrpl/ledger/ApplyViewImpl.h +++ b/include/xrpl/ledger/ApplyViewImpl.h @@ -67,8 +67,8 @@ public: std::function const& before, - std::shared_ptr const& after)> const& func); + SLE::const_ref before, + SLE::const_ref after)> const& func); private: std::optional deliver_; diff --git a/include/xrpl/ledger/BookDirs.h b/include/xrpl/ledger/BookDirs.h index afde6ee7b3..36798934da 100644 --- a/include/xrpl/ledger/BookDirs.h +++ b/include/xrpl/ledger/BookDirs.h @@ -9,15 +9,15 @@ class BookDirs private: ReadView const* view_ = nullptr; uint256 const root_; - uint256 const next_quality_; + uint256 const nextQuality_; uint256 const key_; - std::shared_ptr sle_ = nullptr; + SLE::const_pointer sle_ = nullptr; unsigned int entry_ = 0; uint256 index_; public: class const_iterator; // NOLINT(readability-identifier-naming) - using value_type = std::shared_ptr; + using value_type = SLE::const_pointer; BookDirs(ReadView const&, Book const&); @@ -67,16 +67,16 @@ private: friend class BookDirs; const_iterator(ReadView const& view, uint256 const& root, uint256 const& dirKey) - : view_(&view), root_(root), key_(dirKey), cur_key_(dirKey) + : view_(&view), root_(root), key_(dirKey), curKey_(dirKey) { } ReadView const* view_ = nullptr; uint256 root_; - uint256 next_quality_; + uint256 nextQuality_; uint256 key_; - uint256 cur_key_; - std::shared_ptr sle_; + uint256 curKey_; + SLE::const_pointer sle_; unsigned int entry_ = 0; uint256 index_; std::optional mutable cache_; diff --git a/include/xrpl/ledger/BookListeners.h b/include/xrpl/ledger/BookListeners.h deleted file mode 100644 index 3b96aca680..0000000000 --- a/include/xrpl/ledger/BookListeners.h +++ /dev/null @@ -1,49 +0,0 @@ -#pragma once - -#include -#include - -#include -#include - -namespace xrpl { - -/** Listen to public/subscribe messages from a book. */ -class BookListeners -{ -public: - using pointer = std::shared_ptr; - - BookListeners() = default; - - /** Add a new subscription for this book - */ - void - addSubscriber(InfoSub::ref sub); - - /** Stop publishing to a subscriber - */ - void - removeSubscriber(std::uint64_t sub); - - /** Publish a transaction to subscribers - - Publish a transaction to clients subscribed to changes on this book. - Uses havePublished to prevent sending duplicate transactions to clients - that have subscribed to multiple books. - - @param jvObj JSON transaction data to publish - @param havePublished InfoSub sequence numbers that have already - published this transaction. - - */ - void - publish(MultiApiJson const& jvObj, hash_set& havePublished); - -private: - std::recursive_mutex lock_; - - hash_map listeners_; -}; - -} // namespace xrpl diff --git a/include/xrpl/ledger/CachedView.h b/include/xrpl/ledger/CachedView.h index 34a75e4c07..462db48ee3 100644 --- a/include/xrpl/ledger/CachedView.h +++ b/include/xrpl/ledger/CachedView.h @@ -36,7 +36,7 @@ public: bool exists(Keylet const& k) const override; - std::shared_ptr + SLE::const_pointer read(Keylet const& k) const override; bool diff --git a/include/xrpl/ledger/CanonicalTXSet.h b/include/xrpl/ledger/CanonicalTXSet.h index 8653816eee..4dffadd52f 100644 --- a/include/xrpl/ledger/CanonicalTXSet.h +++ b/include/xrpl/ledger/CanonicalTXSet.h @@ -93,7 +93,7 @@ public: } void - insert(std::shared_ptr const& txn); + insert(std::shared_ptr txn); // Pops the next transaction on account that follows seqProx in the // sort order. Normally called when a transaction is successfully diff --git a/include/xrpl/ledger/Dir.h b/include/xrpl/ledger/Dir.h index cfbef357b1..d305e21938 100644 --- a/include/xrpl/ledger/Dir.h +++ b/include/xrpl/ledger/Dir.h @@ -22,12 +22,12 @@ class Dir private: ReadView const* view_ = nullptr; Keylet root_; - std::shared_ptr sle_; + SLE::const_pointer sle_; STVector256 const* indexes_ = nullptr; public: class ConstIterator; - using value_type = std::shared_ptr; + using value_type = SLE::const_pointer; Dir(ReadView const&, Keylet const&); @@ -102,7 +102,7 @@ private: Keylet page_; uint256 index_; std::optional mutable cache_; - std::shared_ptr sle_; + SLE::const_pointer sle_; STVector256 const* indexes_ = nullptr; std::vector::const_iterator it_; }; diff --git a/include/xrpl/ledger/Ledger.h b/include/xrpl/ledger/Ledger.h index 351f7d80e5..5f7d79c61d 100644 --- a/include/xrpl/ledger/Ledger.h +++ b/include/xrpl/ledger/Ledger.h @@ -166,7 +166,7 @@ public: std::optional succ(uint256 const& key, std::optional const& last = std::nullopt) const override; - std::shared_ptr + SLE::const_pointer read(Keylet const& k) const override; std::unique_ptr @@ -202,16 +202,16 @@ public: // void - rawErase(std::shared_ptr const& sle) override; + rawErase(SLE::ref sle) override; void - rawInsert(std::shared_ptr const& sle) override; + rawInsert(SLE::ref sle) override; void rawErase(uint256 const& key); void - rawReplace(std::shared_ptr const& sle) override; + rawReplace(SLE::ref sle) override; void rawDestroyXRP(XRPAmount const& fee) override @@ -361,7 +361,7 @@ public: bool isVotingLedger() const; - std::shared_ptr + SLE::pointer peek(Keylet const& k) const; private: diff --git a/include/xrpl/ledger/OpenView.h b/include/xrpl/ledger/OpenView.h index c7f80ee321..4ba2a7759b 100644 --- a/include/xrpl/ledger/OpenView.h +++ b/include/xrpl/ledger/OpenView.h @@ -76,7 +76,7 @@ private: // monotonic_resource_ must outlive `items_`. Make a pointer so it may be // easily moved. - std::unique_ptr monotonic_resource_; + std::unique_ptr monotonicResource_; txs_map txs_; Rules rules_; LedgerHeader header_; @@ -197,7 +197,7 @@ public: std::optional succ(key_type const& key, std::optional const& last = std::nullopt) const override; - std::shared_ptr + SLE::const_pointer read(Keylet const& k) const override; std::unique_ptr @@ -224,13 +224,13 @@ public: // RawView void - rawErase(std::shared_ptr const& sle) override; + rawErase(SLE::ref sle) override; void - rawInsert(std::shared_ptr const& sle) override; + rawInsert(SLE::ref sle) override; void - rawReplace(std::shared_ptr const& sle) override; + rawReplace(SLE::ref sle) override; void rawDestroyXRP(XRPAmount const& fee) override; diff --git a/include/xrpl/ledger/OrderBookDB.h b/include/xrpl/ledger/OrderBookDB.h index a0aee58e2a..a44183900c 100644 --- a/include/xrpl/ledger/OrderBookDB.h +++ b/include/xrpl/ledger/OrderBookDB.h @@ -1,11 +1,11 @@ #pragma once +#include +#include #include -#include #include #include #include -#include #include #include @@ -77,34 +77,24 @@ public: */ virtual bool isBookToXRP(Asset const& asset, std::optional const& domain = std::nullopt) = 0; - - /** - * Process a transaction for order book tracking. - * @param ledger The ledger the transaction was applied to - * @param alTx The transaction to process - * @param jvObj The JSON object of the transaction - */ - virtual void - processTxn( - std::shared_ptr const& ledger, - AcceptedLedgerTx const& alTx, - MultiApiJson const& jvObj) = 0; - - /** - * Get the book listeners for a book. - * @param book The book to get the listeners for - * @return The book listeners for the book - */ - virtual BookListeners::pointer - getBookListeners(Book const&) = 0; - - /** - * Create a new book listeners for a book. - * @param book The book to create the listeners for - * @return The new book listeners for the book - */ - virtual BookListeners::pointer - makeBookListeners(Book const&) = 0; }; +/** Extract the set of books affected by a transaction. + * + * Walks the transaction's metadata nodes and collects every order book + * whose offers were created, modified, or deleted. Used by NetworkOPs to + * fan transaction notifications out to book subscribers. + * + * @param alTx The accepted ledger transaction to inspect. + * @param j Journal used to log per-node parsing failures. Inspecting an + * offer node can throw if a required field is missing; in that + * case the bad node is skipped and a warn-level message is + * emitted via @p j. Other affected books in the same transaction + * are still returned. + * @return The set of books whose offers were created, modified, or + * deleted. May be empty for non-offer transactions. + */ +hash_set +affectedBooks(AcceptedLedgerTx const& alTx, beast::Journal const& j); + } // namespace xrpl diff --git a/include/xrpl/ledger/RawView.h b/include/xrpl/ledger/RawView.h index cfcf807e13..cf61c3e814 100644 --- a/include/xrpl/ledger/RawView.h +++ b/include/xrpl/ledger/RawView.h @@ -25,7 +25,7 @@ public: can calculate metadata. */ virtual void - rawErase(std::shared_ptr const& sle) = 0; + rawErase(SLE::ref sle) = 0; /** Unconditionally insert a state item. @@ -39,7 +39,7 @@ public: @note The key is taken from the SLE */ virtual void - rawInsert(std::shared_ptr const& sle) = 0; + rawInsert(SLE::ref sle) = 0; /** Unconditionally replace a state item. @@ -54,7 +54,7 @@ public: @note The key is taken from the SLE */ virtual void - rawReplace(std::shared_ptr const& sle) = 0; + rawReplace(SLE::ref sle) = 0; /** Destroy XRP. diff --git a/include/xrpl/ledger/ReadView.h b/include/xrpl/ledger/ReadView.h index 4f9bf9c31d..f4ee7e6fd2 100644 --- a/include/xrpl/ledger/ReadView.h +++ b/include/xrpl/ledger/ReadView.h @@ -34,9 +34,9 @@ public: using key_type = uint256; - using mapped_type = std::shared_ptr; + using mapped_type = SLE::const_pointer; - struct SlesType : detail::ReadViewFwdRange> + struct SlesType : detail::ReadViewFwdRange { explicit SlesType(ReadView const& view); [[nodiscard]] Iterator @@ -143,7 +143,7 @@ public: @return `nullptr` if the key is not present or if the type does not match. */ - [[nodiscard]] virtual std::shared_ptr + [[nodiscard]] virtual SLE::const_pointer read(Keylet const& k) const = 0; // Accounts in a payment are not allowed to use assets acquired during that diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 4958a89d8c..255413e459 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -12,7 +12,6 @@ #include #include #include -#include #include #include #include @@ -57,7 +56,7 @@ isVaultPseudoAccountFrozen( ReadView const& view, AccountID const& account, MPTIssue const& mptShare, - int depth); + std::uint8_t depth); [[nodiscard]] bool isLPTokenFrozen( @@ -135,7 +134,7 @@ areCompatible( dirLink( ApplyView& view, AccountID const& owner, - std::shared_ptr& object, + SLE::pointer& object, SF_UINT64 const& node = sfOwnerNode); /** Checks that can withdraw funds from an object to itself or a destination. @@ -215,8 +214,8 @@ doWithdraw( * (if should not be skipped) and if the entry should be skipped. The status * is always tesSUCCESS if the entry should be skipped. */ -using EntryDeleter = std::function< - std::pair(LedgerEntryType, uint256 const&, std::shared_ptr&)>; +using EntryDeleter = + std::function(LedgerEntryType, uint256 const&, SLE::pointer&)>; /** Cleanup owner directory entries on account delete. * Used for a regular and AMM accounts deletion. The caller * has to provide the deleter function, which handles details of diff --git a/include/xrpl/ledger/detail/ApplyStateTable.h b/include/xrpl/ledger/detail/ApplyStateTable.h index 7b18f742b4..f40e3d0d1c 100644 --- a/include/xrpl/ledger/detail/ApplyStateTable.h +++ b/include/xrpl/ledger/detail/ApplyStateTable.h @@ -8,8 +8,6 @@ #include #include -#include - namespace xrpl::detail { // Helper class that buffers modifications @@ -26,7 +24,7 @@ private: Modify, }; - using items_t = std::map>>; + using items_t = std::map>; items_t items_; XRPAmount dropsDestroyed_{0}; @@ -60,10 +58,10 @@ public: [[nodiscard]] std::optional succ(ReadView const& base, key_type const& key, std::optional const& last) const; - [[nodiscard]] std::shared_ptr + [[nodiscard]] SLE::const_pointer read(ReadView const& base, Keylet const& k) const; - std::shared_ptr + SLE::pointer peek(ReadView const& base, Keylet const& k); [[nodiscard]] std::size_t @@ -75,23 +73,23 @@ public: std::function const& before, - std::shared_ptr const& after)> const& func) const; + SLE::const_ref before, + SLE::const_ref after)> const& func) const; void - erase(ReadView const& base, std::shared_ptr const& sle); + erase(ReadView const& base, SLE::ref sle); void - rawErase(ReadView const& base, std::shared_ptr const& sle); + rawErase(ReadView const& base, SLE::ref sle); void - insert(ReadView const& base, std::shared_ptr const& sle); + insert(ReadView const& base, SLE::ref sle); void - update(ReadView const& base, std::shared_ptr const& sle); + update(ReadView const& base, SLE::ref sle); void - replace(ReadView const& base, std::shared_ptr const& sle); + replace(ReadView const& base, SLE::ref sle); void destroyXRP(XRPAmount const& fee); @@ -104,12 +102,12 @@ public: } private: - using Mods = hash_map>; + using Mods = hash_map; static void - threadItem(TxMeta& meta, std::shared_ptr const& to); + threadItem(TxMeta& meta, SLE::ref to); - std::shared_ptr + SLE::pointer getForMod(ReadView const& base, key_type const& key, Mods& mods, beast::Journal j); void @@ -119,7 +117,7 @@ private: threadOwners( ReadView const& base, TxMeta& meta, - std::shared_ptr const& sle, + SLE::const_ref sle, Mods& mods, beast::Journal j); }; diff --git a/include/xrpl/ledger/detail/ApplyViewBase.h b/include/xrpl/ledger/detail/ApplyViewBase.h index 558c9e5d4d..d6493c46a8 100644 --- a/include/xrpl/ledger/detail/ApplyViewBase.h +++ b/include/xrpl/ledger/detail/ApplyViewBase.h @@ -40,7 +40,7 @@ public: [[nodiscard]] std::optional succ(key_type const& key, std::optional const& last = std::nullopt) const override; - [[nodiscard]] std::shared_ptr + [[nodiscard]] SLE::const_pointer read(Keylet const& k) const override; [[nodiscard]] std::unique_ptr @@ -69,28 +69,28 @@ public: [[nodiscard]] ApplyFlags flags() const override; - std::shared_ptr + SLE::pointer peek(Keylet const& k) override; void - erase(std::shared_ptr const& sle) override; + erase(SLE::ref sle) override; void - insert(std::shared_ptr const& sle) override; + insert(SLE::ref sle) override; void - update(std::shared_ptr const& sle) override; + update(SLE::ref sle) override; // RawView void - rawErase(std::shared_ptr const& sle) override; + rawErase(SLE::ref sle) override; void - rawInsert(std::shared_ptr const& sle) override; + rawInsert(SLE::ref sle) override; void - rawReplace(std::shared_ptr const& sle) override; + rawReplace(SLE::ref sle) override; void rawDestroyXRP(XRPAmount const& feeDrops) override; diff --git a/include/xrpl/ledger/detail/RawStateTable.h b/include/xrpl/ledger/detail/RawStateTable.h index 53532ee5ec..d2567e34f1 100644 --- a/include/xrpl/ledger/detail/RawStateTable.h +++ b/include/xrpl/ledger/detail/RawStateTable.h @@ -22,14 +22,14 @@ public: static constexpr size_t kInitialBufferSize = kilobytes(256); RawStateTable() - : monotonic_resource_{std::make_unique( + : monotonicResource_{std::make_unique( kInitialBufferSize)} - , items_{monotonic_resource_.get()} {}; + , items_{monotonicResource_.get()} {}; RawStateTable(RawStateTable const& rhs) - : monotonic_resource_{std::make_unique( + : monotonicResource_{std::make_unique( kInitialBufferSize)} - , items_{rhs.items_, monotonic_resource_.get()} + , items_{rhs.items_, monotonicResource_.get()} , dropsDestroyed_{rhs.dropsDestroyed_} {}; RawStateTable(RawStateTable&&) = default; @@ -49,15 +49,15 @@ public: succ(ReadView const& base, key_type const& key, std::optional const& last) const; void - erase(std::shared_ptr const& sle); + erase(SLE::ref sle); void - insert(std::shared_ptr const& sle); + insert(SLE::ref sle); void - replace(std::shared_ptr const& sle); + replace(SLE::ref sle); - [[nodiscard]] std::shared_ptr + [[nodiscard]] SLE::const_pointer read(ReadView const& base, Keylet const& k) const; void @@ -84,10 +84,10 @@ private: struct SleAction { Action action; - std::shared_ptr sle; + SLE::pointer sle; // Constructor needed for emplacement in std::map - SleAction(Action action, std::shared_ptr const& sle) : action(action), sle(sle) + SleAction(Action action, SLE::pointer sle) : action(action), sle(std::move(sle)) { } }; @@ -101,7 +101,7 @@ private: boost::container::pmr::polymorphic_allocator>>; // monotonic_resource_ must outlive `items_`. Make a pointer so it may be // easily moved. - std::unique_ptr monotonic_resource_; + std::unique_ptr monotonicResource_; items_t items_; XRPAmount dropsDestroyed_{0}; diff --git a/include/xrpl/ledger/helpers/AMMHelpers.h b/include/xrpl/ledger/helpers/AMMHelpers.h index a146ef753b..de8bb9d3f7 100644 --- a/include/xrpl/ledger/helpers/AMMHelpers.h +++ b/include/xrpl/ledger/helpers/AMMHelpers.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include @@ -18,6 +17,8 @@ #include #include +#include + namespace xrpl { namespace detail { @@ -36,6 +37,8 @@ reduceOffer(auto const& amount) enum class IsDeposit : bool { No = false, Yes = true }; +inline Number const kAMMInvariantRelativeTolerance{1, -11}; + /** Calculate LP Tokens given AMM pool reserves. * @param asset1 AMM one side of the pool reserve * @param asset2 AMM another side of the pool reserve @@ -737,11 +740,35 @@ ammPoolHolds( AuthHandling authHandling, beast::Journal const j); +/** Check AMM pool product invariant after an AMM operation that changes LP tokens + * (deposit/withdraw/clawback) from an already calculated pool product mean. + * Returns tecPRECISION_LOSS if poolProductMean < newLPTokenBalance beyond the + * invariant tolerance, + * tesSUCCESS otherwise. Skips check when newLPTokenBalance is zero (last withdrawal). + */ +TER +checkAMMPrecisionLoss(Number const& poolProductMean, STAmount const& newLPTokenBalance); + +/** Check AMM pool product invariant after an AMM operation that changes LP tokens + * (deposit/withdraw/clawback). + * Returns tecPRECISION_LOSS if sqrt(asset1 * asset2) < newLPTokenBalance beyond + * the invariant tolerance, + * tesSUCCESS otherwise. Skips check when newLPTokenBalance is zero (last withdrawal). + */ +TER +checkAMMPrecisionLoss( + ReadView const& view, + AccountID const& ammAccountID, + Asset const& asset1, + Asset const& asset2, + STAmount const& newLPTokenBalance, + beast::Journal const j); + /** Get AMM pool and LP token balances. If both optIssue are * provided then they are used as the AMM token pair issues. * Otherwise the missing issues are fetched from ammSle. */ -Expected, TER> +std::expected, TER> ammHolds( ReadView const& view, SLE const& ammSle, @@ -792,7 +819,7 @@ deleteAMMAccount(Sandbox& view, Asset const& asset, Asset const& asset2, beast:: void initializeFeeAuctionVote( ApplyView& view, - std::shared_ptr& ammSle, + SLE::pointer& ammSle, AccountID const& account, Asset const& lptAsset, std::uint16_t tfee); @@ -801,18 +828,18 @@ initializeFeeAuctionVote( * otherwise. Return tecINTERNAL if encountered an unexpected condition, * for instance Liquidity Provider has more than one LPToken trustline. */ -Expected +std::expected isOnlyLiquidityProvider(ReadView const& view, Issue const& ammIssue, AccountID const& lpAccount); /** Due to rounding, the LPTokenBalance of the last LP might * not match the LP's trustline balance. If it's within the tolerance, * update LPTokenBalance to match the LP's trustline balance. */ -Expected +std::expected verifyAndAdjustLPTokenBalance( Sandbox& sb, STAmount const& lpTokens, - std::shared_ptr& ammSle, + SLE::pointer& ammSle, AccountID const& account); } // namespace xrpl diff --git a/include/xrpl/ledger/helpers/AccountRootHelpers.h b/include/xrpl/ledger/helpers/AccountRootHelpers.h index 353c27fe41..cf6082d533 100644 --- a/include/xrpl/ledger/helpers/AccountRootHelpers.h +++ b/include/xrpl/ledger/helpers/AccountRootHelpers.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include @@ -9,7 +8,7 @@ #include #include -#include +#include #include #include @@ -36,11 +35,7 @@ xrpLiquid(ReadView const& view, AccountID const& id, std::int32_t ownerCountAdj, /** Adjust the owner count up or down. */ void -adjustOwnerCount( - ApplyView& view, - std::shared_ptr const& sle, - std::int32_t amount, - beast::Journal j); +adjustOwnerCount(ApplyView& view, SLE::ref sle, std::int32_t amount, beast::Journal j); /** Returns IOU issuer transfer fee as Rate. Rate specifies * the fee as fractions of 1 billion. For example, 1% transfer rate @@ -76,9 +71,7 @@ getPseudoAccountFields(); - null pointer */ [[nodiscard]] bool -isPseudoAccount( - std::shared_ptr sleAcct, - std::set const& pseudoFieldFilter = {}); +isPseudoAccount(SLE::const_pointer sleAcct, std::set const& pseudoFieldFilter = {}); /** Convenience overload that reads the account from the view. */ [[nodiscard]] inline bool @@ -98,7 +91,7 @@ isPseudoAccount( * before using a field. The amendment check is **not** performed in * createPseudoAccount. */ -[[nodiscard]] Expected, TER> +[[nodiscard]] std::expected createPseudoAccount(ApplyView& view, uint256 const& pseudoOwnerKey, SField const& ownerField); /** Checks the destination and tag. diff --git a/include/xrpl/ledger/helpers/CredentialHelpers.h b/include/xrpl/ledger/helpers/CredentialHelpers.h index e06d225934..d6b797ce34 100644 --- a/include/xrpl/ledger/helpers/CredentialHelpers.h +++ b/include/xrpl/ledger/helpers/CredentialHelpers.h @@ -23,7 +23,7 @@ checkExpired(SLE const& sleCredential, NetClock::time_point const& closed); // Actually remove a credentials object from the ledger [[nodiscard]] TER -deleteSLE(ApplyView& view, std::shared_ptr const& sleCredential, beast::Journal j); +deleteSLE(ApplyView& view, SLE::ref sleCredential, beast::Journal j); // Amendment and parameters checks for sfCredentialIDs field NotTEC @@ -36,13 +36,13 @@ checkFields(STTx const& tx, beast::Journal j); TER valid(STTx const& tx, ReadView const& view, AccountID const& src, beast::Journal j); -// Check if subject has any credential maching the given domain. If you call it +// Check if subject has any credential matching the given domain. If you call it // in preclaim and it returns tecEXPIRED, you should call verifyValidDomain in // doApply. This will ensure that expired credentials are deleted. TER validDomain(ReadView const& view, uint256 domainID, AccountID const& subject); -// This function is only called when we about to return tecNO_PERMISSION +// This function is only called when we are about to return tecNO_PERMISSION // because all the checks for the DepositPreauth authorization failed. TER authorizedDepositPreauth(ReadView const& view, STVector256 const& ctx, AccountID const& dst); @@ -58,11 +58,44 @@ checkArray(STArray const& credentials, unsigned maxSize, beast::Journal j); } // namespace credentials -// Check expired credentials and for credentials maching DomainID of the ledger +// Check expired credentials and for credentials matching DomainID of the ledger // object TER verifyValidDomain(ApplyView& view, AccountID const& account, uint256 domainID, beast::Journal j); +/** + * @brief Check whether src is authorized to deposit to dst. + * + * @param tx Transaction containing optional credential IDs. + * @param view Read-only ledger view. + * @param src Source account. + * @param dst Destination account. + * @param sleDst Destination AccountRoot, if it exists. + * @param j Journal for diagnostics. + * @return tesSUCCESS if the deposit is allowed, otherwise an authorization + * error. + */ +TER +checkDepositPreauth( + STTx const& tx, + ReadView const& view, + AccountID const& src, + AccountID const& dst, + std::shared_ptr const& sleDst, + beast::Journal j); + +/** + * @brief Remove expired credentials referenced by the transaction. + * + * @param tx Transaction containing optional sfCredentialIDs. + * @param view Mutable ledger view. + * @param j Journal for diagnostics. + * @return tesSUCCESS if no referenced credentials expired, tecEXPIRED if any + * were removed, or an error from credential deletion. + */ +TER +cleanupExpiredCredentials(STTx const& tx, ApplyView& view, beast::Journal j); + // Check expired credentials and for existing DepositPreauth ledger object TER verifyDepositPreauth( @@ -70,7 +103,7 @@ verifyDepositPreauth( ApplyView& view, AccountID const& src, AccountID const& dst, - std::shared_ptr const& sleDst, + SLE::const_ref sleDst, beast::Journal j); } // namespace xrpl diff --git a/include/xrpl/ledger/helpers/DelegateHelpers.h b/include/xrpl/ledger/helpers/DelegateHelpers.h index 78ccc46d0b..a517eefdaa 100644 --- a/include/xrpl/ledger/helpers/DelegateHelpers.h +++ b/include/xrpl/ledger/helpers/DelegateHelpers.h @@ -15,7 +15,7 @@ namespace xrpl { * if not. */ NotTEC -checkTxPermission(std::shared_ptr const& delegate, STTx const& tx); +checkTxPermission(SLE::const_ref delegate, STTx const& tx); /** * Load the granular permissions granted to the delegate account for the @@ -23,13 +23,9 @@ checkTxPermission(std::shared_ptr const& delegate, STTx const& tx); * @param delegate The delegate account. * @param type Used to determine which granted granular permissions to load, * based on the transaction type. - * @param granularPermissions Granted granular permissions tied to the - * transaction type. + * @return the granted granular permissions tied to the transaction type. */ -void -loadGranularPermission( - std::shared_ptr const& delegate, - TxType const& type, - std::unordered_set& granularPermissions); +std::unordered_set +getGranularPermission(SLE::const_ref delegate, TxType const& type); } // namespace xrpl diff --git a/include/xrpl/ledger/helpers/DirectoryHelpers.h b/include/xrpl/ledger/helpers/DirectoryHelpers.h index 2ae188182d..a0be52df99 100644 --- a/include/xrpl/ledger/helpers/DirectoryHelpers.h +++ b/include/xrpl/ledger/helpers/DirectoryHelpers.h @@ -115,7 +115,7 @@ bool cdirFirst( ReadView const& view, uint256 const& root, - std::shared_ptr& page, + SLE::const_pointer& page, unsigned int& index, uint256& entry); @@ -123,7 +123,7 @@ bool dirFirst( ApplyView& view, uint256 const& root, - std::shared_ptr& page, + SLE::pointer& page, unsigned int& index, uint256& entry); /** @} */ @@ -147,7 +147,7 @@ bool cdirNext( ReadView const& view, uint256 const& root, - std::shared_ptr& page, + SLE::const_pointer& page, unsigned int& index, uint256& entry); @@ -155,17 +155,14 @@ bool dirNext( ApplyView& view, uint256 const& root, - std::shared_ptr& page, + SLE::pointer& page, unsigned int& index, uint256& entry); /** @} */ /** Iterate all items in the given directory. */ void -forEachItem( - ReadView const& view, - Keylet const& root, - std::function const&)> const& f); +forEachItem(ReadView const& view, Keylet const& root, std::function const& f); /** Iterate all items after an item in the given directory. @param after The key of the item to start after @@ -180,14 +177,11 @@ forEachItemAfter( uint256 const& after, std::uint64_t const hint, unsigned int limit, - std::function const&)> const& f); + std::function const& f); /** Iterate all items in an account's owner directory. */ inline void -forEachItem( - ReadView const& view, - AccountID const& id, - std::function const&)> const& f) +forEachItem(ReadView const& view, AccountID const& id, std::function const& f) { forEachItem(view, keylet::ownerDir(id), f); } @@ -205,7 +199,7 @@ forEachItemAfter( uint256 const& after, std::uint64_t const hint, unsigned int limit, - std::function const&)> const& f) + std::function const& f) { return forEachItemAfter(view, keylet::ownerDir(id), after, hint, limit, f); } diff --git a/include/xrpl/ledger/helpers/EscrowHelpers.h b/include/xrpl/ledger/helpers/EscrowHelpers.h index 859981cf05..dc7c479c42 100644 --- a/include/xrpl/ledger/helpers/EscrowHelpers.h +++ b/include/xrpl/ledger/helpers/EscrowHelpers.h @@ -18,7 +18,7 @@ TER escrowUnlockApplyHelper( ApplyView& view, Rate lockedRate, - std::shared_ptr const& sleDest, + SLE::ref sleDest, STAmount const& xrpBalance, STAmount const& amount, AccountID const& issuer, @@ -32,7 +32,7 @@ inline TER escrowUnlockApplyHelper( ApplyView& view, Rate lockedRate, - std::shared_ptr const& sleDest, + SLE::ref sleDest, STAmount const& xrpBalance, STAmount const& amount, AccountID const& issuer, @@ -42,7 +42,7 @@ escrowUnlockApplyHelper( beast::Journal journal) { Issue const& issue = amount.get(); - Keylet const trustLineKey = keylet::line(receiver, issue); + Keylet const trustLineKey = keylet::trustLine(receiver, issue); bool const recvLow = issuer > receiver; bool const senderIssuer = issuer == sender; bool const receiverIssuer = issuer == receiver; @@ -162,7 +162,7 @@ inline TER escrowUnlockApplyHelper( ApplyView& view, Rate lockedRate, - std::shared_ptr const& sleDest, + SLE::ref sleDest, STAmount const& xrpBalance, STAmount const& amount, AccountID const& issuer, @@ -175,7 +175,7 @@ escrowUnlockApplyHelper( bool const receiverIssuer = issuer == receiver; auto const mptID = amount.get().getMptID(); - auto const issuanceKey = keylet::mptIssuance(mptID); + auto const issuanceKey = keylet::mptokenIssuance(mptID); if (!view.exists(keylet::mptoken(issuanceKey.key, receiver)) && createAsset && !receiverIssuer) { if (std::uint32_t const ownerCount = {sleDest->at(sfOwnerCount)}; diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index a6ab42254b..8de945233b 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -4,8 +4,39 @@ #include #include +#include +#include + namespace xrpl { +/** + * Broker cover preclaim precision guard (fixCleanup3_2_0). + * + * Prevents a "silent sub-ULP no-op" where a deposit, withdrawal, or clawback + * amount is so small that it rounds to zero at `sfCoverAvailable`'s scale. + * Without this guard, both the pseudo trust-line and `sfCoverAvailable` would + * identically absorb the rounded zero, resulting in a successful transaction + * (tesSUCCESS) where no funds actually moved. + * + * @param view Read view (rules used for amendment gating). + * @param sleBroker The loan broker SLE (read-only). + * @param vaultAsset The underlying vault asset (the broker's cover asset). + * @param amount The effective subtraction/addition amount. + * @param j Journal for logging. + * @param logPrefix Transactor name for log diagnostics. + * + * @return `tecPRECISION_LOSS` if the request rounds to zero at cover scale. + * `tesSUCCESS` if the amendment is disabled or the request is safely supra-ULP. + */ +[[nodiscard]] TER +canApplyToBrokerCover( + ReadView const& view, + SLE::const_ref sleBroker, + Asset const& vaultAsset, + STAmount const& amount, + beast::Journal j, + std::string_view logPrefix); + // Lending protocol has dependencies, so capture them here. bool checkLendingProtocolDependencies(Rules const& rules, STTx const& tx); @@ -173,6 +204,21 @@ getAssetsTotalScale(SLE::const_ref vaultSle) return scale(vaultSle->at(sfAssetsTotal), vaultSle->at(sfAsset)); } +// Compute the minimum required broker cover, rounded consistently. +// DebtTotal is a broker-level aggregate maintained at vault scale, so the +// rounding must also use vault scale — never an individual loan's scale. +inline Number +minimumBrokerCover(Number const& debtTotal, TenthBips32 coverRateMinimum, SLE::const_ref vaultSle) +{ + XRPL_ASSERT( + vaultSle && vaultSle->getType() == ltVAULT, "xrpl::minimumBrokerCover : valid Vault sle"); + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + return roundToAsset( + vaultSle->at(sfAsset), + tenthBipsOfValue(debtTotal, coverRateMinimum), + getAssetsTotalScale(vaultSle)); +} + TER checkLoanGuards( Asset const& vaultAsset, @@ -352,7 +398,7 @@ struct LoanStateDeltas nonNegative(); }; -Expected, TER> +std::expected, TER> tryOverpayment( Rules const& rules, Asset const& asset, @@ -416,6 +462,7 @@ loanAccruedInterest( ExtendedPaymentComponents computeOverpaymentComponents( + Rules const& rules, Asset const& asset, int32_t const loanScale, Number const& overpayment, @@ -477,7 +524,7 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale); // potential extra work at the end. enum class LoanPaymentType { Regular = 0, Late, Full, Overpayment }; -Expected +std::expected loanMakePayment( Asset const& asset, ApplyView& view, diff --git a/include/xrpl/ledger/helpers/MPTokenHelpers.h b/include/xrpl/ledger/helpers/MPTokenHelpers.h index 6544b18dd1..c709badab8 100644 --- a/include/xrpl/ledger/helpers/MPTokenHelpers.h +++ b/include/xrpl/ledger/helpers/MPTokenHelpers.h @@ -27,14 +27,18 @@ isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue); isIndividualFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue); [[nodiscard]] bool -isFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue, int depth = 0); +isFrozen( + ReadView const& view, + AccountID const& account, + MPTIssue const& mptIssue, + std::uint8_t depth = 0); [[nodiscard]] bool isAnyFrozen( ReadView const& view, std::initializer_list const& accounts, MPTIssue const& mptIssue, - int depth = 0); + std::uint8_t depth = 0); //------------------------------------------------------------------------------ // @@ -88,7 +92,7 @@ requireAuth( MPTIssue const& mptIssue, AccountID const& account, AuthType authType = AuthType::Legacy, - int depth = 0); + std::uint8_t depth = 0); /** Enforce account has MPToken to match its authorization. * @@ -104,22 +108,77 @@ enforceMPTokenAuthorization( XRPAmount const& priorBalance, beast::Journal j); -/** Check if the destination account is allowed - * to receive MPT. Return tecNO_AUTH if it doesn't - * and tesSUCCESS otherwise. +/** Resolve the underlying asset of a vault share. + * + * Reads sfReferenceHolding from @p sleShareIssuance to determine which + * asset the vault wraps. @p sleHolding must be the SLE that + * sfReferenceHolding points to — either an ltMPTOKEN (returns its + * MPTIssue) or an ltRIPPLE_STATE (returns its low/high Issue). + * + * @pre Both SLEs must exist and @p sleHolding must be of type ltMPTOKEN + * or ltRIPPLE_STATE. Passing any other type is undefined behaviour. + * @param sleShareIssuance MPTokenIssuance SLE for the vault share token. + * @param sleHolding SLE referenced by sfReferenceHolding. + * @return The underlying Asset (MPTIssue or Issue). + */ +[[nodiscard]] Asset +assetOfHolding(SLE const& sleShareIssuance, SLE const& sleHolding); + +/** Check whether @p to may receive the given MPT from @p from. + * + * The check passes when any of the following is true: + * - @p waive is WaiveMPTCanTransfer::Yes (recovery-path exemption), or + * - @p from or @p to is the issuer, or + * - lsfMPTCanTransfer is set on the MPTokenIssuance. + * + * For vault shares (MPTokenIssuances that carry sfReferenceHolding) the + * check recurses into the underlying asset's transferability. This + * recursion is defensive; vault-of-vault-shares is rejected at vault + * creation, so in practice depth never exceeds 1. + * + * @param view Ledger state to read from. + * @param mptIssue The MPT issuance being transferred. + * @param from Sending account. + * @param to Receiving account. + * @param waive WaiveMPTCanTransfer::Yes skips the lsfMPTCanTransfer + * check. Use for recovery paths (e.g. unwinding SAV or + * Lending Protocol positions after an issuer revokes + * transferability). + * @param depth Recursion depth; bounded at kMaxAssetCheckDepth. + * @return tesSUCCESS if the transfer is allowed, tecNO_AUTH otherwise. */ [[nodiscard]] TER canTransfer( ReadView const& view, MPTIssue const& mptIssue, AccountID const& from, - AccountID const& to); + AccountID const& to, + WaiveMPTCanTransfer waive = WaiveMPTCanTransfer::No, + std::uint8_t depth = 0); -/** Check if Asset can be traded on DEX. return tecNO_PERMISSION - * if it doesn't and tesSUCCESS otherwise. +/** Check whether @p asset may be traded on the DEX. + * + * For IOU assets the check delegates to the existing offer/AMM freeze + * logic. For MPT assets it checks lsfMPTCanTrade on the MPTokenIssuance. + * Vault shares recurse into the underlying asset's tradability via + * sfReferenceHolding; depth is bounded at kMaxAssetCheckDepth. + * + * @param view Ledger state to read from. + * @param asset The asset to check. + * @param depth Recursion depth; bounded at kMaxAssetCheckDepth. + * @return tesSUCCESS if trading is allowed, tecNO_PERMISSION otherwise. */ [[nodiscard]] TER -canTrade(ReadView const& view, Asset const& asset); +canTrade(ReadView const& view, Asset const& asset, std::uint8_t depth = 0); + +/** Convenience to combine canTrade/Transfer. Returns tesSUCCESS if Asset is Issue. + */ +[[nodiscard]] TER +canMPTTradeAndTransfer( + ReadView const& v, + Asset const& asset, + AccountID const& from, + AccountID const& to); //------------------------------------------------------------------------------ // @@ -227,17 +286,4 @@ issuerFundsToSelfIssue(ReadView const& view, MPTIssue const& issue); void issuerSelfDebitHookMPT(ApplyView& view, MPTIssue const& issue, std::uint64_t amount); -//------------------------------------------------------------------------------ -// -// MPT DEX -// -//------------------------------------------------------------------------------ - -/* Return true if a transaction is allowed for the specified MPT/account. The - * function checks MPTokenIssuance and MPToken objects flags to determine if the - * transaction is allowed. - */ -TER -checkMPTTxAllowed(ReadView const& v, TxType tx, Asset const& asset, AccountID const& accountID); - } // namespace xrpl diff --git a/include/xrpl/ledger/helpers/NFTokenHelpers.h b/include/xrpl/ledger/helpers/NFTokenHelpers.h index 9c68ec3bcc..92902c16c7 100644 --- a/include/xrpl/ledger/helpers/NFTokenHelpers.h +++ b/include/xrpl/ledger/helpers/NFTokenHelpers.h @@ -32,10 +32,9 @@ findToken(ReadView const& view, AccountID const& owner, uint256 const& nftokenID struct TokenAndPage { STObject token; - std::shared_ptr page; + SLE::pointer page; - TokenAndPage(STObject token, std::shared_ptr page) - : token(std::move(token)), page(std::move(page)) + TokenAndPage(STObject token, SLE::pointer page) : token(std::move(token)), page(std::move(page)) { } }; @@ -51,11 +50,7 @@ TER removeToken(ApplyView& view, AccountID const& owner, uint256 const& nftokenID); TER -removeToken( - ApplyView& view, - AccountID const& owner, - uint256 const& nftokenID, - std::shared_ptr const& page); +removeToken(ApplyView& view, AccountID const& owner, uint256 const& nftokenID, SLE::ref page); /** Deletes the given token offer. @@ -67,7 +62,7 @@ removeToken( The offer also consumes one incremental reserve. */ bool -deleteTokenOffer(ApplyView& view, std::shared_ptr const& offer); +deleteTokenOffer(ApplyView& view, SLE::ref offer); /** Repairs the links in an NFTokenPage directory. diff --git a/include/xrpl/ledger/helpers/OfferHelpers.h b/include/xrpl/ledger/helpers/OfferHelpers.h index 9096071811..fc863dff0a 100644 --- a/include/xrpl/ledger/helpers/OfferHelpers.h +++ b/include/xrpl/ledger/helpers/OfferHelpers.h @@ -5,8 +5,6 @@ #include #include -#include - namespace xrpl { /** Delete an offer. @@ -23,6 +21,6 @@ namespace xrpl { */ // [[nodiscard]] // nodiscard commented out so Flow, BookTip and others compile. TER -offerDelete(ApplyView& view, std::shared_ptr const& sle, beast::Journal j); +offerDelete(ApplyView& view, SLE::ref sle, beast::Journal j); } // namespace xrpl diff --git a/include/xrpl/ledger/helpers/PaymentChannelHelpers.h b/include/xrpl/ledger/helpers/PaymentChannelHelpers.h index 24838f1331..3c08ee9f32 100644 --- a/include/xrpl/ledger/helpers/PaymentChannelHelpers.h +++ b/include/xrpl/ledger/helpers/PaymentChannelHelpers.h @@ -5,13 +5,45 @@ #include #include +#include +#include +#include + namespace xrpl { +/** Close a payment channel and return its remaining funds to the channel owner. + * + * @param slep The SLE for the PayChannel object to close. + * @param view The apply view in which ledger state modifications are made. + * @param key The ledger key identifying the PayChannel entry. + * @param j Journal used for fatal-level diagnostic messages. + * @return tesSUCCESS on success; tefBAD_LEDGER if a directory removal + * fails; tefINTERNAL if the source account SLE cannot be found. + */ TER -closeChannel( - std::shared_ptr const& slep, - ApplyView& view, - uint256 const& key, - beast::Journal j); +closeChannel(SLE::ref slep, ApplyView& view, uint256 const& key, beast::Journal j); + +/** Add two uint32_t values with saturation at UINT32_MAX. + * + * @param rules The current ledger rules used to check amendment status. + * @param lhs Left-hand operand. + * @param rhs Right-hand operand. + * @return @p lhs + @p rhs, saturated at UINT32_MAX when the amendment + * is active. + */ +uint32_t +saturatingAdd(Rules const& rules, uint32_t const lhs, uint32_t const rhs); + +/** Determine whether a payment channel time field represents an expired time. + * + * @param view The apply view providing the parent close time and rules. + * @param timeField The optional expiry timestamp (seconds since the XRP + * Ledger epoch). If empty, the function returns false. + * @return @c true if @p timeField is set and the indicated time is + * in the past relative to the view's parent close time; + * @c false otherwise. + */ +bool +isChannelExpired(ApplyView const& view, std::optional timeField); } // namespace xrpl diff --git a/include/xrpl/ledger/helpers/PermissionedDEXHelpers.h b/include/xrpl/ledger/helpers/PermissionedDEXHelpers.h index 04b12f2fc5..695a4950f0 100644 --- a/include/xrpl/ledger/helpers/PermissionedDEXHelpers.h +++ b/include/xrpl/ledger/helpers/PermissionedDEXHelpers.h @@ -1,4 +1,5 @@ #pragma once + #include namespace xrpl::permissioned_dex { diff --git a/include/xrpl/ledger/helpers/RippleStateHelpers.h b/include/xrpl/ledger/helpers/RippleStateHelpers.h index 17b0f7673e..3aaaa541fd 100644 --- a/include/xrpl/ledger/helpers/RippleStateHelpers.h +++ b/include/xrpl/ledger/helpers/RippleStateHelpers.h @@ -93,7 +93,7 @@ isFrozen(ReadView const& view, AccountID const& account, Issue const& issue) // Overload with depth parameter for uniformity with MPTIssue version. // The depth parameter is ignored for IOUs since they don't have vault recursion. [[nodiscard]] inline bool -isFrozen(ReadView const& view, AccountID const& account, Issue const& issue, int /*depth*/) +isFrozen(ReadView const& view, AccountID const& account, Issue const& issue, std::uint8_t /*depth*/) { return isFrozen(view, account, issue); } @@ -110,7 +110,7 @@ isDeepFrozen( ReadView const& view, AccountID const& account, Issue const& issue, - int = 0 /*ignored*/) + std::uint8_t = 0 /*ignored*/) { return isDeepFrozen(view, account, issue.currency, issue.account); } @@ -154,7 +154,7 @@ trustCreate( [[nodiscard]] TER trustDelete( ApplyView& view, - std::shared_ptr const& sleRippleState, + SLE::ref sleRippleState, AccountID const& uLowAccountID, AccountID const& uHighAccountID, beast::Journal j); @@ -248,7 +248,7 @@ removeEmptyHolding( [[nodiscard]] TER deleteAMMTrustLine( ApplyView& view, - std::shared_ptr sleState, + SLE::pointer sleState, std::optional const& ammAccountID, beast::Journal j); @@ -258,7 +258,7 @@ deleteAMMTrustLine( [[nodiscard]] TER deleteAMMMPToken( ApplyView& view, - std::shared_ptr sleMPT, + SLE::pointer sleMPT, AccountID const& ammAccountID, beast::Journal j); diff --git a/include/xrpl/ledger/helpers/TokenHelpers.h b/include/xrpl/ledger/helpers/TokenHelpers.h index 3d41ac47cd..0c9871cd76 100644 --- a/include/xrpl/ledger/helpers/TokenHelpers.h +++ b/include/xrpl/ledger/helpers/TokenHelpers.h @@ -34,6 +34,15 @@ enum class WaiveTransferFee : bool { No = false, Yes }; /** Controls whether accountSend is allowed to overflow OutstandingAmount **/ enum class AllowMPTOverflow : bool { No = false, Yes }; +/** Controls whether canTransfer enforces lsfMPTCanTransfer on MPTs. + * + * Default is No (enforce). Use Yes at call sites that must remain available + * even when an MPT issuer has cleared lsfMPTCanTransfer - for example, + * unwinding existing positions in SAV or the Lending Protocol. Has no + * effect on the IOU branch of canTransfer. + */ +enum class WaiveMPTCanTransfer : bool { No = false, Yes }; + /* Check if MPToken (for MPT) or trust line (for IOU) exists: * - StrongAuth - before checking if authorization is required * - WeakAuth @@ -54,16 +63,26 @@ enum class AuthType { StrongAuth, WeakAuth, Legacy }; [[nodiscard]] bool isGlobalFrozen(ReadView const& view, Asset const& asset); +[[nodiscard]] TER +checkGlobalFrozen(ReadView const& view, Asset const& asset); + [[nodiscard]] bool isIndividualFrozen(ReadView const& view, AccountID const& account, Asset const& asset); +[[nodiscard]] TER +checkIndividualFrozen(ReadView const& view, AccountID const& account, Asset const& asset); + /** * isFrozen check is recursive for MPT shares in a vault, descending to * assets in the vault, up to maxAssetCheckDepth recursion depth. This is * purely defensive, as we currently do not allow such vaults to be created. */ [[nodiscard]] bool -isFrozen(ReadView const& view, AccountID const& account, Asset const& asset, int depth = 0); +isFrozen( + ReadView const& view, + AccountID const& account, + Asset const& asset, + std::uint8_t depth = 0); [[nodiscard]] TER checkFrozen(ReadView const& view, AccountID const& account, Issue const& issue); @@ -85,14 +104,14 @@ isAnyFrozen( ReadView const& view, std::initializer_list const& accounts, Asset const& asset, - int depth = 0); + std::uint8_t depth = 0); [[nodiscard]] bool isDeepFrozen( ReadView const& view, AccountID const& account, MPTIssue const& mptIssue, - int depth = 0); + std::uint8_t depth = 0); /** * isFrozen check is recursive for MPT shares in a vault, descending to @@ -100,7 +119,11 @@ isDeepFrozen( * purely defensive, as we currently do not allow such vaults to be created. */ [[nodiscard]] bool -isDeepFrozen(ReadView const& view, AccountID const& account, Asset const& asset, int depth = 0); +isDeepFrozen( + ReadView const& view, + AccountID const& account, + Asset const& asset, + std::uint8_t depth = 0); [[nodiscard]] TER checkDeepFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue); @@ -108,6 +131,75 @@ checkDeepFrozen(ReadView const& view, AccountID const& account, MPTIssue const& [[nodiscard]] TER checkDeepFrozen(ReadView const& view, AccountID const& account, Asset const& asset); +/** + * Checks freeze compliance for withdrawing an asset from a pseudo-account (e.g. Vault, AMM, + * LoanBroker) to a destination account. + * + * Asserts that sourceAcct is a pseudo-account and that submitterAcct and dstAcct are not. + * + * Issuer exemption: returns tesSUCCESS immediately when dstAcct is the asset issuer — the issuer + * can always receive their own token, even when the pool is frozen. Callers that need to block + * withdrawals from a frozen pool even for the issuer (e.g. because the pool math cannot handle it) + * must check checkFrozen(sourceAcct, asset) separately before calling this function. + * + * Otherwise checks, in order: + * 1. If the asset is globally frozen the remaining checks are redundant. + * 2. For MPT shares: The pseudo-account's vault share must not be transitively frozen via its + * underlying asset. + * 3. The pseudo-account's trustline / MPToken must not be frozen for sending. + * 4. Skipped when submitter == dst (self-withdrawal); a regular freeze should not prevent + * recovering one's own funds. + * 5. The destination must not be deep-frozen (cannot receive under any circumstance). + * + * For IOUs a regular individual freeze on the withdrawer does NOT block self-withdrawal; only deep + * freeze does. For MPTs "locked" is equivalent to deep-frozen, so locked MPT holders are always + * blocked. + * + * @param view Ledger view to read freeze state from. + * @param srcAcct Pseudo-account the funds are withdrawn from (sender). + * @param submitterAcct Account that submitted the withdrawal transaction. + * @param dstAcct Account receiving the withdrawn funds. + * @param asset Asset being withdrawn. + * @return tesSUCCESS if the withdrawal is permitted, otherwise a freeze + * result (tecFROZEN for IOUs, tecLOCKED for MPTs). + */ +[[nodiscard]] TER +checkWithdrawFreeze( + ReadView const& view, + AccountID const& srcAcct, + AccountID const& submitterAcct, + AccountID const& dstAcct, + Asset const& asset); + +/** + * Checks freeze compliance for depositing an asset into a pseudo-account (e.g. Vault, AMM, + * LoanBroker). + * + * + * Checks, in order: + * 1. If the asset is globally frozen the remaining checks are redundant. + * 2. For MPT shares: the pseudo-account's vault share must not be transitively frozen via its + * underlying asset (returns tecLOCKED). + * 3. The depositor must not be individually frozen. Skipped when srcAcct is the asset issuer, + * since the issuer can always send its own asset. + * 4. The pseudo-account must not be individually frozen for the asset. Unlike regular accounts, + * pseudo-accounts cannot receive deposits under a regular freeze because the deposited funds + * could not later be withdrawn. + * + * @param view Ledger view to read freeze state from. + * @param srcAcct Depositor sending the funds. + * @param dstAcct Pseudo-account receiving the deposit. + * @param asset Asset being deposited. + * @return tesSUCCESS if the deposit is permitted, otherwise a freeze result + * (tecFROZEN for IOUs, tecLOCKED for MPTs). + */ +[[nodiscard]] TER +checkDepositFreeze( + ReadView const& view, + AccountID const& srcAcct, + AccountID const& dstAcct, + Asset const& asset); + //------------------------------------------------------------------------------ // // Account balance functions (Asset-based dispatchers) @@ -234,7 +326,13 @@ requireAuth( AuthType authType = AuthType::Legacy); [[nodiscard]] TER -canTransfer(ReadView const& view, Asset const& asset, AccountID const& from, AccountID const& to); +canTransfer( + ReadView const& view, + Asset const& asset, + AccountID const& from, + AccountID const& to, + WaiveMPTCanTransfer waive = WaiveMPTCanTransfer::No, + std::uint8_t depth = 0); //------------------------------------------------------------------------------ // diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 14b0c004cb..2344b4de77 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -1,9 +1,10 @@ #pragma once +#include +#include #include #include -#include #include namespace xrpl { @@ -19,10 +20,7 @@ namespace xrpl { @return The number of shares, or nullopt on error. */ [[nodiscard]] std::optional -assetsToSharesDeposit( - std::shared_ptr const& vault, - std::shared_ptr const& issuance, - STAmount const& assets); +assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets); /** From the perspective of a vault, return the number of assets to take from depositor when they receive a fixed amount of shares. Note, since shares are @@ -35,14 +33,19 @@ assetsToSharesDeposit( @return The number of assets, or nullopt on error. */ [[nodiscard]] std::optional -sharesToAssetsDeposit( - std::shared_ptr const& vault, - std::shared_ptr const& issuance, - STAmount const& shares); +sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares); /** Controls whether to truncate shares instead of rounding. */ enum class TruncateShares : bool { No = false, Yes = true }; +/** Controls whether the withdraw conversion helpers + (assetsToSharesWithdraw and sharesToAssetsWithdraw) subtract + sfLossUnrealized from sfAssetsTotal before computing the exchange rate. + The default (No) applies the standard discounted rate; Yes is used when + the redeemer is the sole remaining shareholder. +*/ +enum class WaiveUnrealizedLoss : bool { No = false, Yes = true }; + /** From the perspective of a vault, return the number of shares to demand from the depositor when they ask to withdraw a fixed amount of assets. Since shares are MPT this number is integral, and it will be rounded to nearest @@ -52,15 +55,18 @@ enum class TruncateShares : bool { No = false, Yes = true }; @param issuance The MPTokenIssuance SLE for the vault's shares. @param assets The amount of assets to convert. @param truncate Whether to truncate instead of rounding. + @param waive Whether to waive the unrealized-loss discount when computing + the exchange rate. @return The number of shares, or nullopt on error. */ [[nodiscard]] std::optional assetsToSharesWithdraw( - std::shared_ptr const& vault, - std::shared_ptr const& issuance, + SLE::const_ref vault, + SLE::const_ref issuance, STAmount const& assets, - TruncateShares truncate = TruncateShares::No); + TruncateShares truncate = TruncateShares::No, + WaiveUnrealizedLoss waive = WaiveUnrealizedLoss::No); /** From the perspective of a vault, return the number of assets to give the depositor when they redeem a fixed amount of shares. Note, since shares are @@ -69,13 +75,28 @@ assetsToSharesWithdraw( @param vault The vault SLE. @param issuance The MPTokenIssuance SLE for the vault's shares. @param shares The amount of shares to convert. + @param waive Whether to waive (i.e. not subtract) the vault's unrealized + loss when computing the exchange rate. @return The number of assets, or nullopt on error. */ [[nodiscard]] std::optional sharesToAssetsWithdraw( - std::shared_ptr const& vault, - std::shared_ptr const& issuance, - STAmount const& shares); + SLE::const_ref vault, + SLE::const_ref issuance, + STAmount const& shares, + WaiveUnrealizedLoss waive = WaiveUnrealizedLoss::No); + +/** Returns true iff `account` holds all of the vault's outstanding shares — + i.e. is the sole remaining shareholder. Returns false if the account + holds no shares or fewer than the total outstanding. + + @param view The ledger view. + @param account The candidate sole shareholder. + @param issuance The MPTokenIssuance SLE for the vault's shares; provides + both the share MPTID and the outstanding-amount total. +*/ +[[nodiscard]] bool +isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref issuance); } // namespace xrpl diff --git a/include/xrpl/net/HTTPClient.h b/include/xrpl/net/HTTPClient.h index f059b19047..456f769922 100644 --- a/include/xrpl/net/HTTPClient.h +++ b/include/xrpl/net/HTTPClient.h @@ -53,7 +53,7 @@ public: boost::system::error_code const& ecResult, int iStatus, std::string const& strData)> complete, - beast::Journal& j); + beast::Journal const& j); static void get(bool bSSL, @@ -67,7 +67,7 @@ public: boost::system::error_code const& ecResult, int iStatus, std::string const& strData)> complete, - beast::Journal& j); + beast::Journal const& j); static void request( @@ -82,7 +82,7 @@ public: boost::system::error_code const& ecResult, int iStatus, std::string const& strData)> complete, - beast::Journal& j); + beast::Journal const& j); }; } // namespace xrpl diff --git a/include/xrpl/net/HTTPClientSSLContext.h b/include/xrpl/net/HTTPClientSSLContext.h index d1744bbce4..ca1983f141 100644 --- a/include/xrpl/net/HTTPClientSSLContext.h +++ b/include/xrpl/net/HTTPClientSSLContext.h @@ -21,13 +21,13 @@ public: bool sslVerify, beast::Journal j, boost::asio::ssl::context_base::method method = boost::asio::ssl::context::sslv23) - : ssl_context_{method}, j_(j), verify_{sslVerify} + : sslContext_{method}, j_(j), verify_{sslVerify} { boost::system::error_code ec; if (sslVerifyFile.empty()) { - registerSSLCerts(ssl_context_, ec, j_); + registerSSLCerts(sslContext_, ec, j_); if (ec && sslVerifyDir.empty()) { @@ -37,12 +37,12 @@ public: } else { - ssl_context_.load_verify_file(sslVerifyFile); + sslContext_.load_verify_file(sslVerifyFile); } if (!sslVerifyDir.empty()) { - ssl_context_.add_verify_path(sslVerifyDir, ec); + sslContext_.add_verify_path(sslVerifyDir, ec); if (ec) { @@ -55,7 +55,7 @@ public: boost::asio::ssl::context& context() { - return ssl_context_; + return sslContext_; } [[nodiscard]] bool @@ -153,7 +153,7 @@ public: } private: - boost::asio::ssl::context ssl_context_; + boost::asio::ssl::context sslContext_; beast::Journal const j_; bool const verify_; }; diff --git a/include/xrpl/nodestore/Backend.h b/include/xrpl/nodestore/Backend.h index 124cd99db5..0061890237 100644 --- a/include/xrpl/nodestore/Backend.h +++ b/include/xrpl/nodestore/Backend.h @@ -83,10 +83,6 @@ public: virtual Status fetch(uint256 const& hash, std::shared_ptr* pObject) = 0; - /** Fetch a batch synchronously. */ - virtual std::pair>, Status> - fetchBatch(std::vector const& hashes) = 0; - /** Store a single object. Depending on the implementation this may happen immediately or deferred using a scheduled task. diff --git a/include/xrpl/nodestore/Database.h b/include/xrpl/nodestore/Database.h index ca2dde560c..68c5dcefb6 100644 --- a/include/xrpl/nodestore/Database.h +++ b/include/xrpl/nodestore/Database.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include @@ -10,6 +9,10 @@ #include +namespace xrpl { +class Section; +} // namespace xrpl + namespace xrpl::NodeStore { /** Persistency layer for NodeObject @@ -131,6 +134,10 @@ public: std::uint32_t ledgerSeq, std::function const&)>&& callback); + /** Remove expired entries from the positive and negative caches. */ + virtual void + sweep() = 0; + /** Gather statistics pertaining to read and write activities. * * @param obj Json object reference into which to place counters. diff --git a/include/xrpl/nodestore/Factory.h b/include/xrpl/nodestore/Factory.h index c40be62d21..3e6ba76a08 100644 --- a/include/xrpl/nodestore/Factory.h +++ b/include/xrpl/nodestore/Factory.h @@ -1,12 +1,15 @@ #pragma once -#include #include #include #include #include +namespace xrpl { +class Section; +} // namespace xrpl + namespace xrpl::NodeStore { /** Base class for backend factories. */ diff --git a/include/xrpl/nodestore/detail/DatabaseNodeImp.h b/include/xrpl/nodestore/detail/DatabaseNodeImp.h index add66c51a7..38b8763f31 100644 --- a/include/xrpl/nodestore/detail/DatabaseNodeImp.h +++ b/include/xrpl/nodestore/detail/DatabaseNodeImp.h @@ -2,6 +2,8 @@ #include #include +#include +#include #include namespace xrpl::NodeStore { @@ -22,6 +24,32 @@ public: beast::Journal j) : Database(scheduler, readThreads, config, j), backend_(std::move(backend)) { + std::optional cacheSize, cacheAge; + + if (config.exists(Keys::kCacheSize)) + { + cacheSize = get(config, Keys::kCacheSize); + if (cacheSize.value() < 0) + Throw("Specified negative value for cache_size"); + } + + if (config.exists(Keys::kCacheAge)) + { + cacheAge = get(config, Keys::kCacheAge); + if (cacheAge.value() < 0) + Throw("Specified negative value for cache_age"); + } + + if (cacheSize.has_value() || cacheAge.has_value()) + { + cache_ = std::make_shared>( + "DatabaseNodeImp", + cacheSize.value_or(0), + std::chrono::minutes(cacheAge.value_or(0)), + stopwatch(), + j); + } + XRPL_ASSERT( backend_, "xrpl::NodeStore::DatabaseNodeImp::DatabaseNodeImp : non-null " @@ -67,16 +95,19 @@ public: backend_->sync(); } - std::vector> - fetchBatch(std::vector const& hashes); - void asyncFetch( uint256 const& hash, std::uint32_t ledgerSeq, std::function const&)>&& callback) override; + void + sweep() override; + private: + // Cache for database objects. This cache is not always initialized. Check + // for null before using. + std::shared_ptr> cache_; // Persistent key/value storage std::shared_ptr backend_; diff --git a/include/xrpl/nodestore/detail/DatabaseRotatingImp.h b/include/xrpl/nodestore/detail/DatabaseRotatingImp.h index 39441ef4d8..1ba9435a5f 100644 --- a/include/xrpl/nodestore/detail/DatabaseRotatingImp.h +++ b/include/xrpl/nodestore/detail/DatabaseRotatingImp.h @@ -55,6 +55,9 @@ public: void sync() override; + void + sweep() override; + private: std::shared_ptr writableBackend_; std::shared_ptr archiveBackend_; diff --git a/include/xrpl/nodestore/detail/varint.h b/include/xrpl/nodestore/detail/varint.h index e6b78fcf08..0c49274d70 100644 --- a/include/xrpl/nodestore/detail/varint.h +++ b/include/xrpl/nodestore/detail/varint.h @@ -25,7 +25,7 @@ struct varint_traits { explicit varint_traits() = default; - static constexpr std::size_t kMax = (8 * sizeof(T) + 6) / 7; + static constexpr std::size_t kMax = ((8 * sizeof(T)) + 6) / 7; }; // Returns: Number of bytes consumed or 0 on error, diff --git a/include/xrpl/protocol/AMMCore.h b/include/xrpl/protocol/AMMCore.h index ced84c4c87..a83c8bfa84 100644 --- a/include/xrpl/protocol/AMMCore.h +++ b/include/xrpl/protocol/AMMCore.h @@ -65,7 +65,7 @@ invalidAMMAssetPair( std::optional ammAuctionTimeSlot(std::uint64_t current, STObject const& auctionSlot); -/** Return true if required AMM amendments are enabled +/** Return true if required AMM amendment is enabled */ bool ammEnabled(Rules const&); diff --git a/include/xrpl/protocol/ApiVersion.h b/include/xrpl/protocol/ApiVersion.h index 345049b377..10b7571641 100644 --- a/include/xrpl/protocol/ApiVersion.h +++ b/include/xrpl/protocol/ApiVersion.h @@ -102,25 +102,32 @@ getAPIVersionNumber(json::Value const& jv, bool betaEnabled) json::Value const maxVersion( betaEnabled ? RPC::kApiBetaVersion : RPC::kApiMaximumSupportedVersion); - if (jv.isObject()) + if (!jv.isObject() || !jv.isMember(jss::api_version)) + return RPC::kApiVersionIfUnspecified; + + try { - if (jv.isMember(jss::api_version)) + auto const& rawVersion = jv[jss::api_version]; + switch (rawVersion.type()) { - auto const specifiedVersion = jv[jss::api_version]; - if (!specifiedVersion.isInt() && !specifiedVersion.isUInt()) - { - return RPC::kApiInvalidVersion; + case json::ValueType::Int: + if (rawVersion.asInt() < 0) + return RPC::kApiInvalidVersion; + [[fallthrough]]; + case json::ValueType::UInt: { + auto const apiVersion = rawVersion.asUInt(); + if (apiVersion < kMinVersion || apiVersion > maxVersion) + return RPC::kApiInvalidVersion; + return apiVersion; } - auto const specifiedVersionInt = specifiedVersion.asInt(); - if (specifiedVersionInt < kMinVersion || specifiedVersionInt > maxVersion) - { + default: return RPC::kApiInvalidVersion; - } - return specifiedVersionInt; } } - - return RPC::kApiVersionIfUnspecified; + catch (...) + { + return RPC::kApiInvalidVersion; + } } } // namespace RPC diff --git a/include/xrpl/protocol/Batch.h b/include/xrpl/protocol/Batch.h index fa7641af70..2f2412b3ff 100644 --- a/include/xrpl/protocol/Batch.h +++ b/include/xrpl/protocol/Batch.h @@ -1,3 +1,5 @@ +#pragma once + #include #include #include diff --git a/include/xrpl/protocol/Book.h b/include/xrpl/protocol/Book.h index fc36abddc4..01dc40075b 100644 --- a/include/xrpl/protocol/Book.h +++ b/include/xrpl/protocol/Book.h @@ -140,8 +140,8 @@ private: using issue_hasher = std::hash; using mptissue_hasher = std::hash; - issue_hasher m_issue_hasher_; - mptissue_hasher m_mptissue_hasher_; + issue_hasher mIssueHasher_; + mptissue_hasher mMptissueHasher_; public: explicit hash() = default; @@ -151,11 +151,11 @@ public: { return asset.visit( [&](xrpl::Issue const& issue) { - value_type const result(m_issue_hasher_(issue)); + value_type const result(mIssueHasher_(issue)); return result; }, [&](xrpl::MPTIssue const& issue) { - value_type const result(m_mptissue_hasher_(issue)); + value_type const result(mMptissueHasher_(issue)); return result; }); } @@ -170,8 +170,8 @@ private: using asset_hasher = std::hash; using uint256_hasher = xrpl::uint256::hasher; - asset_hasher issue_hasher_; - uint256_hasher uint256_hasher_; + asset_hasher issueHasher_; + uint256_hasher uint256Hasher_; public: hash() = default; @@ -182,11 +182,11 @@ public: value_type operator()(argument_type const& value) const { - value_type result(issue_hasher_(value.in)); - boost::hash_combine(result, issue_hasher_(value.out)); + value_type result(issueHasher_(value.in)); + boost::hash_combine(result, issueHasher_(value.out)); if (value.domain) - boost::hash_combine(result, uint256_hasher_(*value.domain)); + boost::hash_combine(result, uint256Hasher_(*value.domain)); return result; } diff --git a/include/xrpl/protocol/ConfidentialTransfer.h b/include/xrpl/protocol/ConfidentialTransfer.h new file mode 100644 index 0000000000..5b1bcbf606 --- /dev/null +++ b/include/xrpl/protocol/ConfidentialTransfer.h @@ -0,0 +1,431 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace xrpl { + +/** + * @brief Bundles an ElGamal public key with its associated encrypted amount. + * + * Used to represent a recipient in confidential transfers, containing both + * the recipient's ElGamal public key and the ciphertext encrypting the + * transfer amount under that key. + */ +struct ConfidentialRecipient +{ + /** @brief The recipient's ElGamal public key (size=xrpl::kEcPubKeyLength). */ + Slice publicKey; + + /** + * @brief The encrypted amount ciphertext + * (size=xrpl::kEcGamalEncryptedTotalLength). + */ + Slice encryptedAmount; +}; + +/** + * @brief Holds two secp256k1 public key components representing an ElGamal + * ciphertext (C1, C2). + */ +struct EcPair +{ + /** @brief First ElGamal ciphertext component. */ + secp256k1_pubkey c1; + + /** @brief Second ElGamal ciphertext component. */ + secp256k1_pubkey c2; +}; + +/** + * @brief Increments the confidential balance version counter on an MPToken. + * + * The version counter is used to prevent replay attacks by binding proofs + * to a specific state of the account's confidential balance. Wraps to 0 + * on overflow (defined behavior for unsigned integers). + * + * @param mptoken The MPToken ledger entry to update. + */ +inline void +incrementConfidentialVersion(STObject& mptoken) +{ + // Retrieve current version and increment, wrapping back to 0 at UINT32_MAX. + // The wrap is computed explicitly rather than relying on unsigned overflow + // of `+ 1u`, as it trips the unsigned-integer-overflow sanitizer in the UBSan CI build. + auto const current = mptoken[~sfConfidentialBalanceVersion].valueOr(0u); + mptoken[sfConfidentialBalanceVersion] = + current == std::numeric_limits::max() ? 0u : current + 1u; +} + +/** + * @brief Generates the context hash for ConfidentialMPTSend transactions. + * + * Creates a unique 256-bit hash that binds the zero-knowledge proofs to + * this specific send transaction, preventing proof reuse across transactions. + * + * @param account The sender's account ID. + * @param issuanceID The MPToken Issuance ID. + * @param sequence The transaction sequence number or ticket number. + * @param destination The destination account ID. + * @param version The sender's confidential balance version. + * @return A 256-bit context hash unique to this transaction. + */ +uint256 +getSendContextHash( + AccountID const& account, + uint192 const& issuanceID, + std::uint32_t sequence, + AccountID const& destination, + std::uint32_t version); + +/** + * @brief Generates the context hash for ConfidentialMPTClawback transactions. + * + * Creates a unique 256-bit hash that binds the equality proof to this + * specific clawback transaction. + * + * @param account The issuer's account ID. + * @param issuanceID The MPToken Issuance ID. + * @param sequence The transaction sequence number or ticket number. + * @param holder The holder's account ID being clawed back from. + * @return A 256-bit context hash unique to this transaction. + */ +uint256 +getClawbackContextHash( + AccountID const& account, + uint192 const& issuanceID, + std::uint32_t sequence, + AccountID const& holder); + +/** + * @brief Generates the context hash for ConfidentialMPTConvert transactions. + * + * Creates a unique 256-bit hash that binds the Schnorr proof (for key + * registration) to this specific convert transaction. + * + * @param account The holder's account ID. + * @param issuanceID The MPToken Issuance ID. + * @param sequence The transaction sequence number or a ticket number. + * @return A 256-bit context hash unique to this transaction. + */ +uint256 +getConvertContextHash(AccountID const& account, uint192 const& issuanceID, std::uint32_t sequence); + +/** + * @brief Generates the context hash for ConfidentialMPTConvertBack transactions. + * + * Creates a unique 256-bit hash that binds the zero-knowledge proofs to + * this specific convert-back transaction. + * + * @param account The holder's account ID. + * @param issuanceID The MPToken Issuance ID. + * @param sequence The transaction sequence number or a ticket number. + * @param version The holder's confidential balance version. + * @return A 256-bit context hash unique to this transaction. + */ +uint256 +getConvertBackContextHash( + AccountID const& account, + uint192 const& issuanceID, + std::uint32_t sequence, + std::uint32_t version); + +/** + * @brief Parses an ElGamal ciphertext into two secp256k1 public key components. + * + * Breaks an encrypted amount (size=xrpl::kEcGamalEncryptedTotalLength, two + * compressed EC points of size=xrpl::kEcCiphertextComponentLength) into + * a pair containing (C1, C2) for use in cryptographic operations. + * + * @param buffer The buffer containing the compressed ciphertext + * (size=xrpl::kEcGamalEncryptedTotalLength). + * @return The parsed pair (c1, c2) if successful, std::nullopt if the buffer is invalid. + */ +std::optional +makeEcPair(Slice const& buffer); + +/** + * @brief Serializes an EcPair into compressed form. + * + * Converts an EcPair (C1, C2) back into a buffer + * (size=xrpl::kEcGamalEncryptedTotalLength) containing two compressed EC + * points (size=xrpl::kEcCiphertextComponentLength each). + * + * @param pair The EcPair to serialize. + * @return The buffer (size=xrpl::kEcGamalEncryptedTotalLength), or std::nullopt + * if serialization fails. + */ +std::optional +serializeEcPair(EcPair const& pair); + +/** + * @brief Verifies that a buffer contains two valid, parsable EC public keys. + * + * @param buffer The input buffer containing two concatenated components. + * @return true if both components can be parsed successfully, false otherwise. + */ +bool +isValidCiphertext(Slice const& buffer); + +/** + * @brief Verifies that a buffer contains a valid, parsable compressed EC point. + * + * Can be used to validate both compressed public keys and Pedersen commitments. + * Fails early if the prefix byte is not 0x02 or 0x03. + * + * @param buffer The input buffer containing a compressed EC point + * (size=xrpl::kCompressedEcPointLength). + * @return true if the point can be parsed successfully, false otherwise. + */ +bool +isValidCompressedECPoint(Slice const& buffer); + +/** + * @brief Homomorphically adds two ElGamal ciphertexts. + * + * Uses the additive homomorphic property of ElGamal encryption to compute + * Enc(a + b) from Enc(a) and Enc(b) without decryption. + * + * @param a The first ciphertext (size=xrpl::kEcGamalEncryptedTotalLength). + * @param b The second ciphertext (size=xrpl::kEcGamalEncryptedTotalLength). + * @return The resulting ciphertext Enc(a + b), or std::nullopt on failure. + */ +std::optional +homomorphicAdd(Slice const& a, Slice const& b); + +/** + * @brief Homomorphically subtracts two ElGamal ciphertexts. + * + * Uses the additive homomorphic property of ElGamal encryption to compute + * Enc(a - b) from Enc(a) and Enc(b) without decryption. + * + * @param a The minuend ciphertext (size=xrpl::kEcGamalEncryptedTotalLength). + * @param b The subtrahend ciphertext (size=xrpl::kEcGamalEncryptedTotalLength). + * @return The resulting ciphertext Enc(a - b), or std::nullopt on failure. + */ +std::optional +homomorphicSubtract(Slice const& a, Slice const& b); + +/** + * @brief Re-randomizes an ElGamal ciphertext without changing its plaintext. + * + * Adds Enc(0; randomness) under the supplied public key to the ciphertext. + * This is used when a public, deterministic scalar must perturb ciphertext + * randomness while preserving ledger reproducibility. + * + * @param ciphertext The ciphertext to re-randomize + * (size=xrpl::kEcGamalEncryptedTotalLength). + * @param pubKeySlice The ElGamal public key matching the ciphertext recipient. + * @param randomness The scalar used as zero-encryption randomness + * (size=xrpl::kEcScalarLength). + * @return The re-randomized ciphertext, or std::nullopt on failure. + */ +std::optional +rerandomizeCiphertext(Slice const& ciphertext, Slice const& pubKeySlice, Slice const& randomness); + +/** + * @brief Encrypts an amount using ElGamal encryption. + * + * Produces a ciphertext C = (C1, C2) where C1 = r*G and C2 = m*G + r*Pk, + * using the provided blinding factor r. + * + * @param amt The plaintext amount to encrypt. + * @param pubKeySlice The recipient's ElGamal public key (size=xrpl::kEcPubKeyLength). + * @param blindingFactor The randomness used as blinding factor r + * (size=xrpl::ecBlindingFactorLength). + * @return The ciphertext (size=xrpl::kEcGamalEncryptedTotalLength), or std::nullopt on failure. + */ +std::optional +encryptAmount(uint64_t const amt, Slice const& pubKeySlice, Slice const& blindingFactor); + +/** + * @brief Generates the canonical zero encryption for a specific MPToken. + * + * Creates a deterministic encryption of zero that is unique to the account + * and MPT issuance. Used to initialize confidential balance fields. + * + * @param pubKeySlice The holder's ElGamal public key (size=xrpl::kEcPubKeyLength). + * @param account The account ID of the token holder. + * @param mptId The MPToken Issuance ID. + * @return The canonical zero ciphertext (size=xrpl::kEcGamalEncryptedTotalLength), or std::nullopt + * on failure. + */ +std::optional +encryptCanonicalZeroAmount(Slice const& pubKeySlice, AccountID const& account, MPTID const& mptId); + +/** + * @brief Verifies a Schnorr proof of knowledge of an ElGamal private key. + * + * Proves that the submitter knows the secret key corresponding to the + * provided public key, without revealing the secret key itself. + * + * @param pubKeySlice The ElGamal public key (size=xrpl::kEcPubKeyLength). + * @param proofSlice The Schnorr proof (size=xrpl::ecSchnorrProofLength). + * @param contextHash The 256-bit context hash binding the proof. + * @return tesSUCCESS if valid, or an error code otherwise. + */ +TER +verifySchnorrProof(Slice const& pubKeySlice, Slice const& proofSlice, uint256 const& contextHash); + +/** + * @brief Validates the format of encrypted amount fields in a transaction. + * + * Checks that all ciphertext fields in the transaction object have the + * correct length and contain valid EC points. This function is only used + * by ConfidentialMPTConvert and ConfidentialMPTConvertBack transactions. + * + * @param object The transaction object containing encrypted amount fields. + * @return tesSUCCESS if all formats are valid, temMALFORMED if required fields + * are missing, or temBAD_CIPHERTEXT if format validation fails. + */ +NotTEC +checkEncryptedAmountFormat(STObject const& object); + +/** + * @brief Verifies revealed amount encryptions for all recipients. + * + * Validates that the same amount was correctly encrypted for the holder, + * issuer, and optionally the auditor using their respective public keys. + * + * @param amount The revealed plaintext amount. + * @param blindingFactor The blinding factor used in all encryptions + * (size=xrpl::ecBlindingFactorLength). + * @param holder The holder's public key and encrypted amount. + * @param issuer The issuer's public key and encrypted amount. + * @param auditor Optional auditor's public key and encrypted amount. + * @return tesSUCCESS if all encryptions are valid, or an error code otherwise. + */ +TER +verifyRevealedAmount( + uint64_t const amount, + Slice const& blindingFactor, + ConfidentialRecipient const& holder, + ConfidentialRecipient const& issuer, + std::optional const& auditor); + +/** + * @brief Returns the number of recipients in a confidential transfer. + * + * Returns 4 if an auditor is present (sender, destination, issuer, auditor), + * or 3 if no auditor (sender, destination, issuer). + * + * @param hasAuditor Whether the issuance has an auditor configured. + * @return The number of recipients (3 or 4). + */ +constexpr uint8_t +getConfidentialRecipientCount(bool hasAuditor) +{ + return hasAuditor ? 4 : 3; +} + +/** + * @brief Verifies a compact sigma clawback proof. + * + * Proves that the issuer knows the exact amount encrypted in the holder's + * balance ciphertext. Used in ConfidentialMPTClawback to verify the issuer + * can decrypt the balance using their private key. + * + * @param amount The revealed plaintext amount. + * @param proof The zero-knowledge proof bytes (ecClawbackProofLength). + * @param pubKeySlice The issuer's ElGamal public key (kEcPubKeyLength bytes). + * @param ciphertext The issuer's encrypted balance on the holder's account + * (kEcGamalEncryptedTotalLength bytes). + * @param contextHash The 256-bit context hash binding the proof. + * @return tesSUCCESS if the proof is valid, or an error code otherwise. + */ +TER +verifyClawbackProof( + uint64_t const amount, + Slice const& proof, + Slice const& pubKeySlice, + Slice const& ciphertext, + uint256 const& contextHash); + +/** + * @brief Generates a cryptographically secure blinding factor + * (size=xrpl::kEcBlindingFactorLength). + * + * Produces random bytes suitable for use as an ElGamal blinding factor + * or Pedersen commitment randomness. + * + * @return A buffer containing the random blinding factor + * (size=xrpl::kEcBlindingFactorLength). + */ +Buffer +generateBlindingFactor(); + +/** + * @brief Verifies all zero-knowledge proofs for a ConfidentialMPTSend transaction. + * + * This function calls mpt_verify_send_proof API in the mpt-crypto utility lib, which verifies the + * equality proof, amount linkage, balance linkage, and range proof. + * Equality proof: Proves the same value is encrypted for the sender, receiver, issuer, and auditor. + * Amount linkage: Proves the send amount matches the amount Pedersen commitment. + * Balance linkage: Proves the sender's balance matches the balance Pedersen + * commitment. + * Range proof: Proves the amount and the remaining balance are within range [0, 2^64-1]. + * + * @param proof The full proof blob. + * @param sender The sender's public key and encrypted amount. + * @param destination The destination's public key and encrypted amount. + * @param issuer The issuer's public key and encrypted amount. + * @param auditor The auditor's public key and encrypted amount if present. + * @param spendingBalance The sender's current spending balance ciphertext. + * @param amountCommitment The Pedersen commitment to the send amount. + * @param balanceCommitment The Pedersen commitment to the sender's balance. + * @param contextHash The context hash binding the proof. + * @return tesSUCCESS if all proofs are valid, or an error code otherwise. + */ +TER +verifySendProof( + Slice const& proof, + ConfidentialRecipient const& sender, + ConfidentialRecipient const& destination, + ConfidentialRecipient const& issuer, + std::optional const& auditor, + Slice const& spendingBalance, + Slice const& amountCommitment, + Slice const& balanceCommitment, + uint256 const& contextHash); + +/** + * @brief Verifies all zero-knowledge proofs for a ConfidentialMPTConvertBack transaction. + * + * This function calls mpt_verify_convert_back_proof API in the mpt-crypto utility lib, which + * verifies the balance linkage proof and range proof. Balance linkage proof: proves the balance + * commitment matches the spending ciphertext. Range proof: proves the remaining balance after + * convert back is within range [0, 2^64-1]. + * + * @param proof The full proof blob. + * @param pubKeySlice The holder's public key. + * @param spendingBalance The holder's spending balance ciphertext. + * @param balanceCommitment The Pedersen commitment to the balance. + * @param amount The amount being converted back to public. + * @param contextHash The context hash binding the proof. + * @return tesSUCCESS if all proofs are valid, or an error code otherwise. + */ +TER +verifyConvertBackProof( + Slice const& proof, + Slice const& pubKeySlice, + Slice const& spendingBalance, + Slice const& balanceCommitment, + uint64_t amount, + uint256 const& contextHash); + +} // namespace xrpl diff --git a/include/xrpl/protocol/ErrorCodes.h b/include/xrpl/protocol/ErrorCodes.h index a91adfc55a..f5e67fd572 100644 --- a/include/xrpl/protocol/ErrorCodes.h +++ b/include/xrpl/protocol/ErrorCodes.h @@ -172,24 +172,24 @@ struct ErrorInfo { // Default ctor needed to produce an empty std::array during constexpr eval. constexpr ErrorInfo() - : code(RpcUnknown), token("unknown"), message("An unknown error code."), http_status(200) + : code(RpcUnknown), token("unknown"), message("An unknown error code."), httpStatus(200) { } constexpr ErrorInfo(ErrorCodeI code, char const* token, char const* message) - : code(code), token(token), message(message), http_status(200) + : code(code), token(token), message(message), httpStatus(200) { } constexpr ErrorInfo(ErrorCodeI code, char const* token, char const* message, int httpStatus) - : code(code), token(token), message(message), http_status(httpStatus) + : code(code), token(token), message(message), httpStatus(httpStatus) { } ErrorCodeI code; json::StaticString token; json::StaticString message; - int http_status; + int httpStatus; }; /** Returns an ErrorInfo that reflects the error code. */ diff --git a/include/xrpl/protocol/IOUAmount.h b/include/xrpl/protocol/IOUAmount.h index 9e0fbe38eb..b057f1c245 100644 --- a/include/xrpl/protocol/IOUAmount.h +++ b/include/xrpl/protocol/IOUAmount.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include @@ -179,36 +178,4 @@ to_string(IOUAmount const& amount); IOUAmount mulRatio(IOUAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundUp); -// Since many uses of the number class do not have access to a ledger, -// getSTNumberSwitchover needs to be globally accessible. - -bool -getSTNumberSwitchover(); - -void -setSTNumberSwitchover(bool v); - -/** RAII class to set and restore the Number switchover. - */ - -class NumberSO -{ - bool saved_; - -public: - ~NumberSO() - { - setSTNumberSwitchover(saved_); - } - - NumberSO(NumberSO const&) = delete; - NumberSO& - operator=(NumberSO const&) = delete; - - explicit NumberSO(bool v) : saved_(getSTNumberSwitchover()) - { - setSTNumberSwitchover(v); - } -}; - } // namespace xrpl diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 887a208ec6..75a2335f6f 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -68,21 +68,15 @@ skip(LedgerIndex ledger) noexcept; /** The (fixed) index of the object containing the ledger fees. */ Keylet const& -fees() noexcept; +feeSettings() noexcept; /** The (fixed) index of the object containing the ledger negativeUNL. */ Keylet const& negativeUNL() noexcept; /** The beginning of an order book */ -struct BookT -{ - explicit BookT() = default; - - Keylet - operator()(Book const& b) const; -}; -static BookT const kBook{}; +Keylet +book(Book const& b); /** The index of a trust line for a given currency @@ -93,12 +87,12 @@ static BookT const kBook{}; */ /** @{ */ Keylet -line(AccountID const& id0, AccountID const& id1, Currency const& currency) noexcept; +trustLine(AccountID const& id0, AccountID const& id1, Currency const& currency) noexcept; inline Keylet -line(AccountID const& id, Issue const& issue) noexcept +trustLine(AccountID const& id, Issue const& issue) noexcept { - return line(id, issue.account, issue.currency); + return trustLine(id, issue.account, issue.currency); } /** @} */ @@ -119,37 +113,27 @@ Keylet quality(Keylet const& k, std::uint64_t q) noexcept; /** The directory for the next lower quality */ -struct NextT -{ - explicit NextT() = default; - - Keylet - operator()(Keylet const& k) const; -}; -static NextT const kNext{}; +Keylet +next(Keylet const& k); /** A ticket belonging to an account */ -struct TicketT +/** @{ */ +Keylet +ticket(AccountID const& id, std::uint32_t ticketSeq); + +Keylet +ticket(AccountID const& id, SeqProxy ticketSeq); + +inline Keylet +ticket(uint256 const& key) { - explicit TicketT() = default; - - Keylet - operator()(AccountID const& id, std::uint32_t ticketSeq) const; - - Keylet - operator()(AccountID const& id, SeqProxy ticketSeq) const; - - Keylet - operator()(uint256 const& key) const - { - return {ltTICKET, key}; - } -}; -static TicketT const kTicket{}; + return {ltTICKET, key}; +} +/** @} */ /** A SignerList */ Keylet -signers(AccountID const& account) noexcept; +signerList(AccountID const& account) noexcept; /** A Check */ /** @{ */ @@ -209,7 +193,7 @@ escrow(AccountID const& src, std::uint32_t seq) noexcept; /** A PaymentChannel */ Keylet -payChan(AccountID const& src, AccountID const& dst, std::uint32_t seq) noexcept; +payChannel(AccountID const& src, AccountID const& dst, std::uint32_t seq) noexcept; /** NFT page keylets @@ -221,22 +205,22 @@ payChan(AccountID const& src, AccountID const& dst, std::uint32_t seq) noexcept; /** @{ */ /** A keylet for the owner's first possible NFT page. */ Keylet -nftpageMin(AccountID const& owner); +nftokenPageMin(AccountID const& owner); /** A keylet for the owner's last possible NFT page. */ Keylet -nftpageMax(AccountID const& owner); +nftokenPageMax(AccountID const& owner); Keylet -nftpage(Keylet const& k, uint256 const& token); +nftokenPage(Keylet const& k, uint256 const& token); /** @} */ /** An offer from an account to buy or sell an NFT */ Keylet -nftoffer(AccountID const& owner, std::uint32_t seq); +nftokenOffer(AccountID const& owner, std::uint32_t seq); inline Keylet -nftoffer(uint256 const& offer) +nftokenOffer(uint256 const& offer) { return {ltNFTOKEN_OFFER, offer}; } @@ -287,13 +271,13 @@ credential(uint256 const& key) noexcept } Keylet -mptIssuance(std::uint32_t seq, AccountID const& issuer) noexcept; +mptokenIssuance(std::uint32_t seq, AccountID const& issuer) noexcept; Keylet -mptIssuance(MPTID const& issuanceID) noexcept; +mptokenIssuance(MPTID const& issuanceID) noexcept; inline Keylet -mptIssuance(uint256 const& issuanceKey) +mptokenIssuance(uint256 const& issuanceKey) { return {ltMPTOKEN_ISSUANCE, issuanceKey}; } @@ -320,10 +304,10 @@ vault(uint256 const& vaultKey) } Keylet -loanbroker(AccountID const& owner, std::uint32_t seq) noexcept; +loanBroker(AccountID const& owner, std::uint32_t seq) noexcept; inline Keylet -loanbroker(uint256 const& key) +loanBroker(uint256 const& key) { return {ltLOAN_BROKER, key}; } @@ -376,11 +360,15 @@ struct KeyletDesc std::array, 6> const kDirectAccountKeylets{ {{.function = &keylet::account, .expectedLEName = jss::AccountRoot, .includeInTests = false}, {.function = &keylet::ownerDir, .expectedLEName = jss::DirectoryNode, .includeInTests = true}, - {.function = &keylet::signers, .expectedLEName = jss::SignerList, .includeInTests = true}, + {.function = &keylet::signerList, .expectedLEName = jss::SignerList, .includeInTests = true}, // It's normally impossible to create an item at nftpage_min, but // test it anyway, since the invariant checks for it. - {.function = &keylet::nftpageMin, .expectedLEName = jss::NFTokenPage, .includeInTests = true}, - {.function = &keylet::nftpageMax, .expectedLEName = jss::NFTokenPage, .includeInTests = true}, + {.function = &keylet::nftokenPageMin, + .expectedLEName = jss::NFTokenPage, + .includeInTests = true}, + {.function = &keylet::nftokenPageMax, + .expectedLEName = jss::NFTokenPage, + .includeInTests = true}, {.function = &keylet::did, .expectedLEName = jss::DID, .includeInTests = true}}}; MPTID diff --git a/include/xrpl/protocol/KnownFormats.h b/include/xrpl/protocol/KnownFormats.h index 9aa914ba97..6e21d4bc3a 100644 --- a/include/xrpl/protocol/KnownFormats.h +++ b/include/xrpl/protocol/KnownFormats.h @@ -118,13 +118,13 @@ public: } // begin() and end() are provided for testing purposes. - [[nodiscard]] typename std::forward_list::const_iterator + [[nodiscard]] std::forward_list::const_iterator begin() const { return formats_.begin(); } - [[nodiscard]] typename std::forward_list::const_iterator + [[nodiscard]] std::forward_list::const_iterator end() const { return formats_.end(); diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index 99d5d818f1..70afd12f34 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -177,17 +177,19 @@ enum LedgerEntryType : std::uint16_t { LSF_FLAG(lsfMPTCanEscrow, 0x00000008) \ LSF_FLAG(lsfMPTCanTrade, 0x00000010) \ LSF_FLAG(lsfMPTCanTransfer, 0x00000020) \ - LSF_FLAG(lsfMPTCanClawback, 0x00000040)) \ + LSF_FLAG(lsfMPTCanClawback, 0x00000040) \ + LSF_FLAG(lsfMPTCanHoldConfidentialBalance, 0x00000080)) \ \ LEDGER_OBJECT(MPTokenIssuanceMutable, \ - LSF_FLAG(lsmfMPTCanMutateCanLock, 0x00000002) \ - LSF_FLAG(lsmfMPTCanMutateRequireAuth, 0x00000004) \ - LSF_FLAG(lsmfMPTCanMutateCanEscrow, 0x00000008) \ - LSF_FLAG(lsmfMPTCanMutateCanTrade, 0x00000010) \ - LSF_FLAG(lsmfMPTCanMutateCanTransfer, 0x00000020) \ - LSF_FLAG(lsmfMPTCanMutateCanClawback, 0x00000040) \ + LSF_FLAG(lsmfMPTCanEnableCanLock, 0x00000002) \ + LSF_FLAG(lsmfMPTCanEnableRequireAuth, 0x00000004) \ + LSF_FLAG(lsmfMPTCanEnableCanEscrow, 0x00000008) \ + LSF_FLAG(lsmfMPTCanEnableCanTrade, 0x00000010) \ + LSF_FLAG(lsmfMPTCanEnableCanTransfer, 0x00000020) \ + LSF_FLAG(lsmfMPTCanEnableCanClawback, 0x00000040) \ + LSF_FLAG(lsmfMPTCannotEnableCanHoldConfidentialBalance, 0x00000080) \ LSF_FLAG(lsmfMPTCanMutateMetadata, 0x00010000) \ - LSF_FLAG(lsmfMPTCanMutateTransferFee, 0x00020000)) \ + LSF_FLAG(lsmfMPTCanMutateTransferFee, 0x00020000)) \ \ LEDGER_OBJECT(MPToken, \ LSF_FLAG2(lsfMPTLocked, 0x00000001) \ diff --git a/include/xrpl/protocol/Permissions.h b/include/xrpl/protocol/Permissions.h index 5d56fa4461..c6f464082d 100644 --- a/include/xrpl/protocol/Permissions.h +++ b/include/xrpl/protocol/Permissions.h @@ -7,8 +7,13 @@ #include #include #include +#include +#include namespace xrpl { + +class STTx; + /** * We have both transaction type permissions and granular type permissions. * Since we will reuse the TransactionFormats to parse the Transaction @@ -19,15 +24,15 @@ namespace xrpl { // Macro-generated, complex // NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) enum GranularPermissionType : std::uint32_t { -#pragma push_macro("PERMISSION") -#undef PERMISSION +#pragma push_macro("GRANULAR_PERMISSION") +#undef GRANULAR_PERMISSION -#define PERMISSION(type, txType, value) type = (value), +#define GRANULAR_PERMISSION(name, txType, value, ...) name = (value), #include -#undef PERMISSION -#pragma pop_macro("PERMISSION") +#undef GRANULAR_PERMISSION +#pragma pop_macro("GRANULAR_PERMISSION") }; // Injected bare enumerators (xrpl::delegable / xrpl::notDelegable) are required by preprocessor @@ -40,15 +45,30 @@ class Permission private: Permission(); - std::unordered_map txFeatureMap_; + struct GranularPermissionEntry + { + std::string name; + TxType txType; + std::uint32_t permittedFlags; + SOTemplate permittedFields; - std::unordered_map delegableTx_; + GranularPermissionEntry( + std::string name, + TxType txType, + std::uint32_t permittedFlags, + std::vector fields); + }; - std::unordered_map granularPermissionMap_; + struct TxDelegationEntry + { + uint256 amendment; + Delegation delegable{NotDelegable}; + }; - std::unordered_map granularNameMap_; - - std::unordered_map granularTxTypeMap_; + std::unordered_set granularTxTypes_; + std::unordered_map txDelegationMap_; + std::unordered_map granularPermissionsByName_; + std::unordered_map granularPermissions_; public: static Permission const& @@ -59,30 +79,52 @@ public: operator=(Permission const&) = delete; [[nodiscard]] std::optional - getPermissionName(std::uint32_t const value) const; + getPermissionName(std::uint32_t value) const; [[nodiscard]] std::optional getGranularValue(std::string const& name) const; [[nodiscard]] std::optional - getGranularName(GranularPermissionType const& value) const; + getGranularName(GranularPermissionType value) const; [[nodiscard]] std::optional - getGranularTxType(GranularPermissionType const& gpType) const; + getGranularTxType(GranularPermissionType gpType) const; + // Returns a reference to avoid copying uint256 - 32 bytes. std::optional + // cannot hold references directly, so std::reference_wrapper is used. [[nodiscard]] std::optional> getTxFeature(TxType txType) const; [[nodiscard]] bool - isDelegable(std::uint32_t const& permissionValue, Rules const& rules) const; + isDelegable(std::uint32_t permissionValue, Rules const& rules) const; + + [[nodiscard]] bool + hasGranularPermissions(TxType txType) const; // for tx level permission, permission value is equal to tx type plus one - static uint32_t - txToPermissionType(TxType const& type); + [[nodiscard]] static uint32_t + txToPermissionType(TxType type); // tx type value is permission value minus one - static TxType - permissionToTxType(uint32_t const& value); + [[nodiscard]] static std::optional + permissionToTxType(std::uint32_t value); + + /** + * @brief Verifies a delegated transaction against its granular permission template. + * + * @note WARNING: Do not move this check before standard transaction-level + * format checks, which is in preclaim. This function assumes the transaction's + * base structural integrity (fees, sequence, signatures) has already been + * validated. + * + * @param tx The transaction to verify. + * @param heldPermissions The granular permissions that the sender hold. + * @return true if the transaction fields and flags comply with the granular template. + */ + [[nodiscard]] bool + checkGranularSandbox( + STTx const& tx, + std::unordered_set const& heldPermissions) const; }; } // namespace xrpl diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 6a96b2ccbe..7eac92e83c 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -4,6 +4,10 @@ #include #include +#include +#include + +#include #include namespace xrpl { @@ -307,4 +311,62 @@ constexpr std::size_t kPermissionMaxSize = 10; /** The maximum number of transactions that can be in a batch. */ constexpr std::size_t kMaxBatchTxCount = 8; +/** Length of a secp256k1 scalar in bytes. */ +constexpr std::size_t kEcScalarLength = kMPT_SCALAR_SIZE; + +/** Length of EC point (compressed) */ +constexpr std::size_t kCompressedEcPointLength = 33; + +/** Length of one compressed EC point component in an EC ElGamal ciphertext. */ +constexpr std::size_t kEcCiphertextComponentLength = kMPT_ELGAMAL_CIPHER_SIZE; + +/** EC ElGamal ciphertext length: two compressed EC points concatenated. */ +constexpr std::size_t kEcGamalEncryptedTotalLength = kMPT_ELGAMAL_TOTAL_SIZE; + +/** Length of EC public key (compressed) */ +constexpr std::size_t kEcPubKeyLength = kMPT_PUBKEY_SIZE; + +/** Length of EC private key in bytes */ +constexpr std::size_t kEcPrivKeyLength = kMPT_PRIVKEY_SIZE; + +/** Length of the EC blinding factor in bytes */ +constexpr std::size_t kEcBlindingFactorLength = kMPT_BLINDING_FACTOR_SIZE; + +/** Length of Schnorr ZKProof for public key registration (compact form) in bytes */ +constexpr std::size_t kEcSchnorrProofLength = kMPT_SCHNORR_PROOF_SIZE; + +/** Length of Pedersen Commitment (compressed) */ +constexpr std::size_t kEcPedersenCommitmentLength = kMPT_PEDERSEN_COMMIT_SIZE; + +/** Length of single bulletproof (range proof for 1 commitment) in bytes */ +constexpr std::size_t kEcSingleBulletproofLength = kMPT_SINGLE_BULLETPROOF_SIZE; + +/** Length of double bulletproof (range proof for 2 commitments) in bytes */ +constexpr std::size_t kEcDoubleBulletproofLength = kMPT_DOUBLE_BULLETPROOF_SIZE; + +/** Length of the compact sigma proof component for ConfidentialMPTSend. */ +constexpr std::size_t kEcSendSigmaProofLength = SECP256K1_COMPACT_STANDARD_PROOF_SIZE; + +/** 192 bytes compact sigma proof + 754 bytes double bulletproof. */ +constexpr std::size_t kEcSendProofLength = kEcSendSigmaProofLength + kEcDoubleBulletproofLength; + +/** Length of the compact sigma proof component for ConfidentialMPTConvertBack. */ +constexpr std::size_t kEcConvertBackSigmaProofLength = SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE; + +/** 128 bytes compact sigma proof + 688 bytes single bulletproof. */ +constexpr std::size_t kEcConvertBackProofLength = + kEcConvertBackSigmaProofLength + kEcSingleBulletproofLength; + +/** Length of the ZKProof for ConfidentialMPTClawback. */ +constexpr std::size_t kEcClawbackProofLength = SECP256K1_COMPACT_CLAWBACK_PROOF_SIZE; + +/** Extra base fee multiplier charged to confidential MPT transactions. */ +constexpr std::uint32_t kConfidentialFeeMultiplier = 9; + +/** Compressed EC point prefix for even y-coordinate */ +constexpr std::uint8_t kEcCompressedPrefixEvenY = 0x02; + +/** Compressed EC point prefix for odd y-coordinate */ +constexpr std::uint8_t kEcCompressedPrefixOddY = 0x03; + } // namespace xrpl diff --git a/include/xrpl/protocol/Rules.h b/include/xrpl/protocol/Rules.h index fbbd3d8805..47b20756db 100644 --- a/include/xrpl/protocol/Rules.h +++ b/include/xrpl/protocol/Rules.h @@ -122,4 +122,15 @@ private: std::optional saved_; }; +class NumberMantissaScaleGuard; + +bool +useRulesGuards(Rules const& rules); + +void +createGuards( + Rules const& rules, + std::optional& rulesGuard, + std::optional& mantissaScaleGuard); + } // namespace xrpl diff --git a/include/xrpl/protocol/SField.h b/include/xrpl/protocol/SField.h index 1d4e4e69a0..34fb66ce00 100644 --- a/include/xrpl/protocol/SField.h +++ b/include/xrpl/protocol/SField.h @@ -365,8 +365,8 @@ using SF_XCHAIN_BRIDGE = TypedField; #define UNTYPED_SFIELD(sfName, stiSuffix, fieldValue, ...) extern SField const sfName; #define TYPED_SFIELD(sfName, stiSuffix, fieldValue, ...) extern SF_##stiSuffix const sfName; -extern SField const kSfInvalid; -extern SField const kSfGeneric; +extern SField const sfInvalid; // NOLINT(readability-identifier-naming) +extern SField const sfGeneric; // NOLINT(readability-identifier-naming) #include diff --git a/include/xrpl/protocol/STAmount.h b/include/xrpl/protocol/STAmount.h index 01511c23ea..1a5b442d8b 100644 --- a/include/xrpl/protocol/STAmount.h +++ b/include/xrpl/protocol/STAmount.h @@ -3,11 +3,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -184,6 +186,23 @@ public: [[nodiscard]] STAmount const& value() const noexcept; + /** + * Checks if this amount evaluates to zero when constrained to a specific + * accounting scale. + * For XRP and MPT `roundToScale` is a no-op, returns true only when the amount itself is zero. + * The `scale` argument is ignored in that case. + * For IOU, the amount is rounded to the given scale using Number::RoundingMode::ToNearest mode + * and the result is checked for zero; if `scale <= exponent()`, `roundToScale` short-circuits + * and returns the value unchanged, so this returns false for any non-zero amount. + * + * @param scale The target accounting scale to evaluate against. + * @return `true` if this amount rounds to zero at the given scale, `false` otherwise. + * + * @see roundToScale + */ + [[nodiscard]] bool + isZeroAtScale(int scale) const; + //-------------------------------------------------------------------------- // // Operators @@ -540,7 +559,7 @@ STAmount::fromNumber(A const& a, Number const& number) return STAmount{asset, intValue, 0, negative}; } - auto const [mantissa, exponent] = working.normalizeToRange(kMinValue, kMaxValue); + auto const [mantissa, exponent] = working.normalizeToRange(); return STAmount{asset, mantissa, exponent, negative}; } @@ -575,12 +594,25 @@ STAmount::value() const noexcept return *this; } -inline bool +[[nodiscard]] inline bool isLegalNet(STAmount const& value) { return !value.native() || (value.mantissa() <= STAmount::kMaxNativeN); } +[[nodiscard]] inline bool +isLegalMPT(STAmount const& value) +{ + return !value.holds() || + (!value.negative() && value.exponent() == 0 && value.mantissa() <= kMaxMpTokenAmount); +} + +/* Check recursively if an object has invalid MPTAmount or XRPAmount in STAmount field. + * Calls isLegalNet() and isLegalMPT(). + */ +[[nodiscard]] bool +hasInvalidAmount(STBase const& field, beast::Journal j); + //------------------------------------------------------------------------------ // // Operators diff --git a/include/xrpl/protocol/STBitString.h b/include/xrpl/protocol/STBitString.h index 87c8cd4f45..0267eac22d 100644 --- a/include/xrpl/protocol/STBitString.h +++ b/include/xrpl/protocol/STBitString.h @@ -163,7 +163,7 @@ STBitString::setValue(BaseUInt const& v) } template -typename STBitString::value_type const& +STBitString::value_type const& STBitString::value() const { return value_; diff --git a/include/xrpl/protocol/STBlob.h b/include/xrpl/protocol/STBlob.h index c9110a367e..0667c54e30 100644 --- a/include/xrpl/protocol/STBlob.h +++ b/include/xrpl/protocol/STBlob.h @@ -24,7 +24,7 @@ public: STBlob(SField const& f, void const* data, std::size_t size); STBlob(SField const& f, Buffer&& b); STBlob(SField const& n); - STBlob(SerialIter&, SField const& name = kSfGeneric); + STBlob(SerialIter&, SField const& name = sfGeneric); [[nodiscard]] std::size_t size() const; diff --git a/include/xrpl/protocol/STInteger.h b/include/xrpl/protocol/STInteger.h index 4e3c9a8923..52e0f7a365 100644 --- a/include/xrpl/protocol/STInteger.h +++ b/include/xrpl/protocol/STInteger.h @@ -120,7 +120,7 @@ STInteger::operator=(value_type const& v) } template -inline typename STInteger::value_type +inline STInteger::value_type STInteger::value() const noexcept { return value_; diff --git a/include/xrpl/protocol/STObject.h b/include/xrpl/protocol/STObject.h index c635e8ce22..e65cc79c78 100644 --- a/include/xrpl/protocol/STObject.h +++ b/include/xrpl/protocol/STObject.h @@ -243,7 +243,7 @@ public: @throws STObject::FieldErr if the field is not present. */ template - typename T::value_type + T::value_type operator[](TypedField const& f) const; /** Get the value of a field as a std::optional @@ -290,7 +290,7 @@ public: @throws STObject::FieldErr if the field is not present. */ template - [[nodiscard]] typename T::value_type + [[nodiscard]] T::value_type at(TypedField const& f) const; /** Get the value of a field as std::optional @@ -478,7 +478,7 @@ template class STObject::Proxy { public: - using value_type = typename T::value_type; + using value_type = T::value_type; [[nodiscard]] value_type value() const; @@ -513,13 +513,10 @@ protected: template concept IsArithmeticNumber = std::is_arithmetic_v || std::is_same_v || std::is_same_v; -template < - typename U, - typename Value = typename U::value_type, - typename Unit = typename U::unit_type> +template concept IsArithmeticValueUnit = std::is_same_v> && IsArithmeticNumber && std::is_class_v; -template +template concept IsArithmeticST = !IsArithmeticValueUnit && IsArithmeticNumber; template concept IsArithmetic = IsArithmeticNumber || IsArithmeticST || IsArithmeticValueUnit; @@ -534,7 +531,7 @@ template class STObject::ValueProxy : public Proxy { private: - using value_type = typename T::value_type; + using value_type = T::value_type; public: ValueProxy(ValueProxy const&) = default; @@ -576,7 +573,7 @@ template class STObject::OptionalProxy : public Proxy { private: - using value_type = typename T::value_type; + using value_type = T::value_type; using optional_type = std::optional>; @@ -840,7 +837,7 @@ operator typename STObject::OptionalProxy::optional_type() const } template -typename STObject::OptionalProxy::optional_type +STObject::OptionalProxy::optional_type STObject::OptionalProxy::operator~() const { return optionalValue(); @@ -933,7 +930,7 @@ STObject::OptionalProxy::optionalValue() const -> optional_type } template -typename STObject::OptionalProxy::value_type +STObject::OptionalProxy::value_type STObject::OptionalProxy::valueOr(value_type val) const { return engaged() ? this->value() : val; @@ -1040,7 +1037,7 @@ STObject::getPIndex(int offset) } template -typename T::value_type +T::value_type STObject::operator[](TypedField const& f) const { return at(f); @@ -1068,7 +1065,7 @@ STObject::operator[](OptionaledField const& of) -> OptionalProxy } template -[[nodiscard]] typename T::value_type +[[nodiscard]] T::value_type STObject::at(TypedField const& f) const { auto const b = peekAtPField(f); diff --git a/include/xrpl/protocol/STPathSet.h b/include/xrpl/protocol/STPathSet.h index a1891164f6..1508dcb727 100644 --- a/include/xrpl/protocol/STPathSet.h +++ b/include/xrpl/protocol/STPathSet.h @@ -21,8 +21,8 @@ class STPathElement final : public CountedObject PathAsset assetID_; AccountID issuerID_; - bool is_offer_; - std::size_t hash_value_; + bool isOffer_; + std::size_t hashValue_; public: // Bitwise values (typeCurrency | typeMPT) @@ -235,9 +235,9 @@ private: // ------------ STPathElement ------------ -inline STPathElement::STPathElement() : type_(TypeNone), is_offer_(true) +inline STPathElement::STPathElement() : type_(TypeNone), isOffer_(true) { - hash_value_ = getHash(*this); + hashValue_ = getHash(*this); } inline STPathElement::STPathElement( @@ -248,11 +248,11 @@ inline STPathElement::STPathElement( { if (!account) { - is_offer_ = true; + isOffer_ = true; } else { - is_offer_ = false; + isOffer_ = false; accountID_ = *account; type_ |= TypeAccount; XRPL_ASSERT( @@ -272,7 +272,7 @@ inline STPathElement::STPathElement( XRPL_ASSERT(issuerID_ != noAccount(), "xrpl::STPathElement::STPathElement : issuer is set"); } - hash_value_ = getHash(*this); + hashValue_ = getHash(*this); } inline STPathElement::STPathElement( @@ -284,9 +284,9 @@ inline STPathElement::STPathElement( , accountID_(account) , assetID_(asset) , issuerID_(issuer) - , is_offer_(isXRP(accountID_)) + , isOffer_(isXRP(accountID_)) { - if (!is_offer_) + if (!isOffer_) type_ |= TypeAccount; if (forceAsset || !isXRP(assetID_)) @@ -295,7 +295,7 @@ inline STPathElement::STPathElement( if (!isXRP(issuer)) type_ |= TypeIssuer; - hash_value_ = getHash(*this); + hashValue_ = getHash(*this); } inline STPathElement::STPathElement( @@ -307,12 +307,12 @@ inline STPathElement::STPathElement( , accountID_(account) , assetID_(asset) , issuerID_(issuer) - , is_offer_(isXRP(accountID_)) + , isOffer_(isXRP(accountID_)) { assetID_.visit( [&](Currency const&) { type_ = type_ & (~Type::TypeMpt); }, [&](MPTID const&) { type_ = type_ & (~Type::TypeCurrency); }); - hash_value_ = getHash(*this); + hashValue_ = getHash(*this); } inline auto @@ -324,7 +324,7 @@ STPathElement::getNodeType() const inline bool STPathElement::isOffer() const { - return is_offer_; + return isOffer_; } inline bool @@ -404,7 +404,7 @@ STPathElement::getIssuerID() const inline bool STPathElement::operator==(STPathElement const& t) const { - return (type_ & TypeAccount) == (t.type_ & TypeAccount) && hash_value_ == t.hash_value_ && + return (type_ & TypeAccount) == (t.type_ & TypeAccount) && hashValue_ == t.hashValue_ && accountID_ == t.accountID_ && assetID_ == t.assetID_ && issuerID_ == t.issuerID_; } diff --git a/include/xrpl/protocol/STTx.h b/include/xrpl/protocol/STTx.h index 5aa70d1299..659fede31d 100644 --- a/include/xrpl/protocol/STTx.h +++ b/include/xrpl/protocol/STTx.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include @@ -11,6 +10,7 @@ #include +#include #include namespace xrpl { @@ -27,7 +27,7 @@ enum class TxnSql : char { class STTx final : public STObject, public CountedObject { uint256 tid_; - TxType tx_type_; + TxType txType_; public: static constexpr std::size_t kMinMultiSigners = 1; @@ -108,10 +108,10 @@ public: @param rules The current ledger rules. @return `true` if valid signature. If invalid, the error message string. */ - Expected + std::expected checkSign(Rules const& rules) const; - Expected + std::expected checkBatchSign(Rules const& rules) const; // SQL Functions with metadata. @@ -138,19 +138,19 @@ private: Will be *this more often than not. @return `true` if valid signature. If invalid, the error message string. */ - Expected + std::expected checkSign(Rules const& rules, STObject const& sigObject) const; - Expected + std::expected checkSingleSign(STObject const& sigObject) const; - Expected + std::expected checkMultiSign(Rules const& rules, STObject const& sigObject) const; - Expected + std::expected checkBatchSingleSign(STObject const& batchSigner) const; - Expected + std::expected checkBatchMultiSign(STObject const& batchSigner, Rules const& rules) const; STBase* @@ -187,7 +187,7 @@ inline STTx::STTx(SerialIter&& sit) // NOLINT(cppcoreguidelines-rvalue-referenc inline TxType STTx::getTxnType() const { - return tx_type_; + return txType_; } inline Blob diff --git a/include/xrpl/protocol/STVector256.h b/include/xrpl/protocol/STVector256.h index ab3a2f99e3..5c454b6be0 100644 --- a/include/xrpl/protocol/STVector256.h +++ b/include/xrpl/protocol/STVector256.h @@ -17,8 +17,8 @@ public: STVector256() = default; explicit STVector256(SField const& n); - explicit STVector256(std::vector const& vector); - STVector256(SField const& n, std::vector const& vector); + explicit STVector256(std::vector vector); + STVector256(SField const& n, std::vector vector); STVector256(SerialIter& sit, SField const& name); [[nodiscard]] SerializedTypeID @@ -103,12 +103,12 @@ inline STVector256::STVector256(SField const& n) : STBase(n) { } -inline STVector256::STVector256(std::vector const& vector) : value_(vector) +inline STVector256::STVector256(std::vector vector) : value_(std::move(vector)) { } -inline STVector256::STVector256(SField const& n, std::vector const& vector) - : STBase(n), value_(vector) +inline STVector256::STVector256(SField const& n, std::vector vector) + : STBase(n), value_(std::move(vector)) { } diff --git a/include/xrpl/protocol/TER.h b/include/xrpl/protocol/TER.h index c89610f354..84c344ea76 100644 --- a/include/xrpl/protocol/TER.h +++ b/include/xrpl/protocol/TER.h @@ -128,6 +128,7 @@ enum TEMcodes : TERUnderlyingType { temBAD_TRANSFER_FEE, temINVALID_INNER_BATCH, temBAD_MPT, + temBAD_CIPHERTEXT, }; //------------------------------------------------------------------------------ @@ -358,6 +359,11 @@ enum TECcodes : TERUnderlyingType { tecLIMIT_EXCEEDED = 195, tecPSEUDO_ACCOUNT = 196, tecPRECISION_LOSS = 197, + // DEPRECATED: This error code tecNO_DELEGATE_PERMISSION is reserved for + // backward compatibility with historical data on non-prod networks, can be + // reclaimed after those networks reset. + tecNO_DELEGATE_PERMISSION = 198, + tecBAD_PROOF = 199, }; //------------------------------------------------------------------------------ @@ -657,13 +663,13 @@ inline bool isTesSuccess(TER x) noexcept { // Makes use of TERSubset::operator bool() - return !(x); + return !x; } inline bool isTecClaim(TER x) noexcept { - return ((x) >= tecCLAIM); + return (x >= tecCLAIM); } std::unordered_map> const& diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index 4652cc1bf0..461afd24e7 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -140,7 +140,8 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal; TF_FLAG(tfMPTCanEscrow, lsfMPTCanEscrow) \ TF_FLAG(tfMPTCanTrade, lsfMPTCanTrade) \ TF_FLAG(tfMPTCanTransfer, lsfMPTCanTransfer) \ - TF_FLAG(tfMPTCanClawback, lsfMPTCanClawback), \ + TF_FLAG(tfMPTCanClawback, lsfMPTCanClawback) \ + TF_FLAG(tfMPTCanHoldConfidentialBalance, lsfMPTCanHoldConfidentialBalance), \ MASK_ADJ(0)) \ \ TRANSACTION(MPTokenAuthorize, \ @@ -341,38 +342,36 @@ inline constexpr FlagValue tfTrustSetPermissionMask = // MPTokenIssuanceCreate MutableFlags: // Indicating specific fields or flags may be changed after issuance. -inline constexpr FlagValue tmfMPTCanMutateCanLock = lsmfMPTCanMutateCanLock; -inline constexpr FlagValue tmfMPTCanMutateRequireAuth = lsmfMPTCanMutateRequireAuth; -inline constexpr FlagValue tmfMPTCanMutateCanEscrow = lsmfMPTCanMutateCanEscrow; -inline constexpr FlagValue tmfMPTCanMutateCanTrade = lsmfMPTCanMutateCanTrade; -inline constexpr FlagValue tmfMPTCanMutateCanTransfer = lsmfMPTCanMutateCanTransfer; -inline constexpr FlagValue tmfMPTCanMutateCanClawback = lsmfMPTCanMutateCanClawback; +inline constexpr FlagValue tmfMPTCanEnableCanLock = lsmfMPTCanEnableCanLock; +inline constexpr FlagValue tmfMPTCanEnableRequireAuth = lsmfMPTCanEnableRequireAuth; +inline constexpr FlagValue tmfMPTCanEnableCanEscrow = lsmfMPTCanEnableCanEscrow; +inline constexpr FlagValue tmfMPTCanEnableCanTrade = lsmfMPTCanEnableCanTrade; +inline constexpr FlagValue tmfMPTCanEnableCanTransfer = lsmfMPTCanEnableCanTransfer; +inline constexpr FlagValue tmfMPTCanEnableCanClawback = lsmfMPTCanEnableCanClawback; inline constexpr FlagValue tmfMPTCanMutateMetadata = lsmfMPTCanMutateMetadata; inline constexpr FlagValue tmfMPTCanMutateTransferFee = lsmfMPTCanMutateTransferFee; +inline constexpr FlagValue tmfMPTCannotEnableCanHoldConfidentialBalance = + lsmfMPTCannotEnableCanHoldConfidentialBalance; inline constexpr FlagValue tmfMPTokenIssuanceCreateMutableMask = - ~(tmfMPTCanMutateCanLock | tmfMPTCanMutateRequireAuth | tmfMPTCanMutateCanEscrow | - tmfMPTCanMutateCanTrade | tmfMPTCanMutateCanTransfer | tmfMPTCanMutateCanClawback | - tmfMPTCanMutateMetadata | tmfMPTCanMutateTransferFee); + ~(tmfMPTCanEnableCanLock | tmfMPTCanEnableRequireAuth | tmfMPTCanEnableCanEscrow | + tmfMPTCanEnableCanTrade | tmfMPTCanEnableCanTransfer | tmfMPTCanEnableCanClawback | + tmfMPTCanMutateMetadata | tmfMPTCanMutateTransferFee | + tmfMPTCannotEnableCanHoldConfidentialBalance); // MPTokenIssuanceSet MutableFlags: -// Set or Clear flags. +// Enable mutable capability flags. These flags are one-way: once enabled, +// the corresponding capability cannot be disabled by MPTokenIssuanceSet. inline constexpr FlagValue tmfMPTSetCanLock = 0x00000001; -inline constexpr FlagValue tmfMPTClearCanLock = 0x00000002; -inline constexpr FlagValue tmfMPTSetRequireAuth = 0x00000004; -inline constexpr FlagValue tmfMPTClearRequireAuth = 0x00000008; -inline constexpr FlagValue tmfMPTSetCanEscrow = 0x00000010; -inline constexpr FlagValue tmfMPTClearCanEscrow = 0x00000020; -inline constexpr FlagValue tmfMPTSetCanTrade = 0x00000040; -inline constexpr FlagValue tmfMPTClearCanTrade = 0x00000080; -inline constexpr FlagValue tmfMPTSetCanTransfer = 0x00000100; -inline constexpr FlagValue tmfMPTClearCanTransfer = 0x00000200; -inline constexpr FlagValue tmfMPTSetCanClawback = 0x00000400; -inline constexpr FlagValue tmfMPTClearCanClawback = 0x00000800; -inline constexpr FlagValue tmfMPTokenIssuanceSetMutableMask = ~( - tmfMPTSetCanLock | tmfMPTClearCanLock | tmfMPTSetRequireAuth | tmfMPTClearRequireAuth | - tmfMPTSetCanEscrow | tmfMPTClearCanEscrow | tmfMPTSetCanTrade | tmfMPTClearCanTrade | - tmfMPTSetCanTransfer | tmfMPTClearCanTransfer | tmfMPTSetCanClawback | tmfMPTClearCanClawback); +inline constexpr FlagValue tmfMPTSetRequireAuth = 0x00000002; +inline constexpr FlagValue tmfMPTSetCanEscrow = 0x00000004; +inline constexpr FlagValue tmfMPTSetCanTrade = 0x00000008; +inline constexpr FlagValue tmfMPTSetCanTransfer = 0x00000010; +inline constexpr FlagValue tmfMPTSetCanClawback = 0x00000020; +inline constexpr FlagValue tmfMPTSetCanHoldConfidentialBalance = 0x00000040; +inline constexpr FlagValue tmfMPTokenIssuanceSetMutableMask = + ~(tmfMPTSetCanLock | tmfMPTSetRequireAuth | tmfMPTSetCanEscrow | tmfMPTSetCanTrade | + tmfMPTSetCanTransfer | tmfMPTSetCanClawback | tmfMPTSetCanHoldConfidentialBalance); // Prior to fixRemoveNFTokenAutoTrustLine, transfer of an NFToken between accounts allowed a // TrustLine to be added to the issuer of that token without explicit permission from that issuer. diff --git a/include/xrpl/protocol/Units.h b/include/xrpl/protocol/Units.h index 7fedc05a0d..39f745e84e 100644 --- a/include/xrpl/protocol/Units.h +++ b/include/xrpl/protocol/Units.h @@ -391,7 +391,7 @@ mulDivU(Source1 value, Dest mul, Source2 div) return std::nullopt; } - using desttype = typename Dest::value_type; + using desttype = Dest::value_type; constexpr auto kMax = std::numeric_limits::max(); // Shortcuts, since these happen a lot in the real world diff --git a/include/xrpl/protocol/XChainAttestations.h b/include/xrpl/protocol/XChainAttestations.h index 993f478b5e..1aec5fe549 100644 --- a/include/xrpl/protocol/XChainAttestations.h +++ b/include/xrpl/protocol/XChainAttestations.h @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include #include @@ -15,6 +14,7 @@ #include #include +#include #include #include @@ -379,16 +379,16 @@ public: [[nodiscard]] STArray toSTArray() const; - [[nodiscard]] typename AttCollection::const_iterator + [[nodiscard]] AttCollection::const_iterator begin() const; - [[nodiscard]] typename AttCollection::const_iterator + [[nodiscard]] AttCollection::const_iterator end() const; - typename AttCollection::iterator + AttCollection::iterator begin(); - typename AttCollection::iterator + AttCollection::iterator end(); template @@ -419,7 +419,7 @@ operator==( } template -inline typename XChainAttestationsBase::AttCollection const& +inline XChainAttestationsBase::AttCollection const& XChainAttestationsBase::attestations() const { return attestations_; diff --git a/include/xrpl/protocol/detail/STVar.h b/include/xrpl/protocol/detail/STVar.h index 98a0b8dcd2..71077d4b33 100644 --- a/include/xrpl/protocol/detail/STVar.h +++ b/include/xrpl/protocol/detail/STVar.h @@ -37,7 +37,7 @@ private: // The largest "small object" we can accommodate static constexpr std::size_t kMaxSize = 72; - std::aligned_storage::type d_ = {}; + alignas(std::max_align_t) std::byte d_[kMaxSize] = {}; STBase* p_ = nullptr; public: diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index fd62b74d59..a9b237be32 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -14,16 +14,17 @@ // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. - -XRPL_FIX (Cleanup3_2_0, Supported::No, VoteBehavior::DefaultNo) +XRPL_FEATURE(ConfidentialTransfer, Supported::No, VoteBehavior::DefaultNo) +XRPL_FIX (Cleanup3_3_0, Supported::Yes, VoteBehavior::DefaultNo) +XRPL_FIX (Cleanup3_2_0, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(MPTokensV2, Supported::No, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_1_3, Supported::Yes, VoteBehavior::DefaultYes) XRPL_FIX (BatchInnerSigs, Supported::No, VoteBehavior::DefaultNo) XRPL_FEATURE(LendingProtocol, Supported::Yes, VoteBehavior::DefaultNo) -XRPL_FEATURE(PermissionDelegationV1_1, Supported::No, VoteBehavior::DefaultNo) +XRPL_FEATURE(PermissionDelegationV1_1, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (DirectoryLimit, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (IncludeKeyletFields, Supported::Yes, VoteBehavior::DefaultNo) -XRPL_FEATURE(DynamicMPT, Supported::No, VoteBehavior::DefaultNo) +XRPL_FEATURE(DynamicMPT, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (TokenEscrowV1, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (PriceOracleOrder, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (MPTDeliveredAmount, Supported::Yes, VoteBehavior::DefaultNo) @@ -57,14 +58,11 @@ XRPL_FIX (EmptyDID, Supported::Yes, VoteBehavior::DefaultNo XRPL_FEATURE(PriceOracle, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (AMMOverflowOffer, Supported::Yes, VoteBehavior::DefaultYes) XRPL_FIX (InnerObjTemplate, Supported::Yes, VoteBehavior::DefaultNo) -XRPL_FIX (NFTokenReserve, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (FillOrKill, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(DID, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (DisallowIncomingV1, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(XChainBridge, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(AMM, Supported::Yes, VoteBehavior::DefaultNo) -XRPL_FEATURE(Clawback, Supported::Yes, VoteBehavior::DefaultNo) -XRPL_FIX (UniversalNumber, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(XRPFees, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (RemoveNFTokenAutoTrustLine, Supported::Yes, VoteBehavior::DefaultYes) @@ -105,6 +103,7 @@ XRPL_RETIRE_FIX(CheckThreading) XRPL_RETIRE_FIX(MasterKeyAsRegularKey) XRPL_RETIRE_FIX(NonFungibleTokensV1_2) XRPL_RETIRE_FIX(NFTokenRemint) +XRPL_RETIRE_FIX(NFTokenReserve) XRPL_RETIRE_FIX(PayChanRecipientOwnerDir) XRPL_RETIRE_FIX(QualityUpperBound) XRPL_RETIRE_FIX(ReducedOffersV1) @@ -112,9 +111,11 @@ XRPL_RETIRE_FIX(RmSmallIncreasedQOffers) XRPL_RETIRE_FIX(STAmountCanonicalize) XRPL_RETIRE_FIX(TakerDryOfferRemoval) XRPL_RETIRE_FIX(TrustLinesToSelf) +XRPL_RETIRE_FIX(UniversalNumber) XRPL_RETIRE_FEATURE(Checks) XRPL_RETIRE_FEATURE(CheckCashMakesTrustLine) +XRPL_RETIRE_FEATURE(Clawback) XRPL_RETIRE_FEATURE(CryptoConditions) XRPL_RETIRE_FEATURE(CryptoConditionsSuite) XRPL_RETIRE_FEATURE(DeletableAccounts) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index c4b392a92f..2f403708c3 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -21,7 +21,7 @@ /** A ledger object which identifies an offer to buy or sell an NFT. - \sa keylet::nftoffer + \sa keylet::nftokenOffer */ LEDGER_ENTRY(ltNFTOKEN_OFFER, 0x0037, NFTokenOffer, nft_offer, ({ {sfOwner, SoeRequired}, @@ -84,7 +84,7 @@ LEDGER_ENTRY(ltNEGATIVE_UNL, 0x004e, NegativeUNL, nunl, ({ /** A ledger object which contains a list of NFTs - \sa keylet::nftpageMin, keylet::nftpageMax, keylet::nftpage + \sa keylet::nftokenPageMin, keylet::nftokenPageMax, keylet::nftokenPage */ LEDGER_ENTRY(ltNFTOKEN_PAGE, 0x0050, NFTokenPage, nft_page, ({ {sfPreviousPageMin, SoeOptional}, @@ -96,7 +96,7 @@ LEDGER_ENTRY(ltNFTOKEN_PAGE, 0x0050, NFTokenPage, nft_page, ({ /** A ledger object which contains a signer list for an account. - \sa keylet::signers + \sa keylet::signerList */ // All fields are SoeRequired because there is always a SignerEntries. // If there are no SignerEntries the node is deleted. @@ -112,7 +112,7 @@ LEDGER_ENTRY(ltSIGNER_LIST, 0x0053, SignerList, signer_list, ({ /** A ledger object which describes a ticket. - \sa keylet::kTicket + \sa keylet::ticket */ LEDGER_ENTRY(ltTICKET, 0x0054, Ticket, ticket, ({ {sfAccount, SoeRequired}, @@ -272,7 +272,7 @@ LEDGER_ENTRY(ltXCHAIN_OWNED_CLAIM_ID, 0x0071, XChainOwnedClaimID, xchain_owned_c @note Per Vinnie Falco this should be renamed to ltTRUST_LINE - \sa keylet::line + \sa keylet::trustLine */ LEDGER_ENTRY(ltRIPPLE_STATE, 0x0072, RippleState, state, ({ {sfBalance, SoeRequired}, @@ -292,7 +292,7 @@ LEDGER_ENTRY(ltRIPPLE_STATE, 0x0072, RippleState, state, ({ \note This is a singleton: only one such object exists in the ledger. - \sa keylet::fees + \sa keylet::feeSettings */ LEDGER_ENTRY(ltFEE_SETTINGS, 0x0073, FeeSettings, fee, ({ // Old version uses raw numbers @@ -346,7 +346,7 @@ LEDGER_ENTRY(ltESCROW, 0x0075, Escrow, escrow, ({ /** A ledger object describing a single unidirectional XRP payment channel. - \sa keylet::payChan + \sa keylet::payChannel */ LEDGER_ENTRY(ltPAYCHAN, 0x0078, PayChannel, payment_channel, ({ {sfAccount, SoeRequired}, @@ -384,7 +384,7 @@ LEDGER_ENTRY(ltAMM, 0x0079, AMM, amm, ({ })) /** A ledger object which tracks MPTokenIssuance - \sa keylet::mptIssuance + \sa keylet::mptokenIssuance */ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ {sfIssuer, SoeRequired}, @@ -400,19 +400,29 @@ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ {sfPreviousTxnLgrSeq, SoeRequired}, {sfDomainID, SoeOptional}, {sfMutableFlags, SoeDefault}, + {sfReferenceHolding, SoeOptional}, + {sfIssuerEncryptionKey, SoeOptional}, + {sfAuditorEncryptionKey, SoeOptional}, + {sfConfidentialOutstandingAmount, SoeDefault}, })) /** A ledger object which tracks MPToken \sa keylet::mptoken */ LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({ - {sfAccount, SoeRequired}, - {sfMPTokenIssuanceID, SoeRequired}, - {sfMPTAmount, SoeDefault}, - {sfLockedAmount, SoeOptional}, - {sfOwnerNode, SoeRequired}, - {sfPreviousTxnID, SoeRequired}, - {sfPreviousTxnLgrSeq, SoeRequired}, + {sfAccount, SoeRequired}, + {sfMPTokenIssuanceID, SoeRequired}, + {sfMPTAmount, SoeDefault}, + {sfLockedAmount, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfConfidentialBalanceInbox, SoeOptional}, + {sfConfidentialBalanceSpending, SoeOptional}, + {sfConfidentialBalanceVersion, SoeDefault}, + {sfIssuerEncryptedBalance, SoeOptional}, + {sfAuditorEncryptedBalance, SoeOptional}, + {sfHolderEncryptionKey, SoeOptional}, })) /** A ledger object which tracks Oracle @@ -498,7 +508,7 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ /** A ledger object representing a loan broker - \sa keylet::loanbroker + \sa keylet::loanBroker */ LEDGER_ENTRY(ltLOAN_BROKER, 0x0088, LoanBroker, loan_broker, ({ {sfPreviousTxnID, SoeRequired}, @@ -591,7 +601,7 @@ LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ // LoanBroker.ManagementFeeRate // The unrounded true total fee still owed to the broker. // - // Note the the "True" values may differ significantly from the tracked + // Note the "True" values may differ significantly from the tracked // rounded values. {sfPaymentRemaining, SoeDefault}, {sfPeriodicPayment, SoeRequired}, diff --git a/include/xrpl/protocol/detail/permissions.macro b/include/xrpl/protocol/detail/permissions.macro index 729861a013..35532a03ca 100644 --- a/include/xrpl/protocol/detail/permissions.macro +++ b/include/xrpl/protocol/detail/permissions.macro @@ -1,49 +1,74 @@ -#if !defined(PERMISSION) -#error "undefined macro: PERMISSION" +#if !defined(GRANULAR_PERMISSION) +#error "undefined macro: GRANULAR_PERMISSION" #endif /** - * PERMISSION(name, type, txType, value) + * GRANULAR_PERMISSION(name, txType, value, allowedFlags, allowedFields) * - * This macro defines a permission: - * name: the name of the permission. - * type: the GranularPermissionType enum. - * txType: the corresponding TxType for this permission. - * value: the uint32 numeric value for the enum type. + * Defines a granular permission: + * name: the granular permission name. + * txType: the corresponding TxType for this permission. + * value: the uint32 numeric value for the enum type. + * allowedFlags: transaction flags permitted under this permission. + * allowedFields: transaction fields permitted under this permission. */ -/** This permission grants the delegated account the ability to authorize a trustline. */ -PERMISSION(TrustlineAuthorize, ttTRUST_SET, 65537) +/** Grants the ability to authorize a trustline. */ +GRANULAR_PERMISSION(TrustlineAuthorize, ttTRUST_SET, 65537, tfUniversal | tfSetfAuth, + ({{sfLimitAmount, SoeRequired}})) -/** This permission grants the delegated account the ability to freeze a trustline. */ -PERMISSION(TrustlineFreeze, ttTRUST_SET, 65538) +/** Grants the ability to freeze a trustline. */ +GRANULAR_PERMISSION(TrustlineFreeze, ttTRUST_SET, 65538, tfUniversal | tfSetFreeze, + ({{sfLimitAmount, SoeRequired}})) -/** This permission grants the delegated account the ability to unfreeze a trustline. */ -PERMISSION(TrustlineUnfreeze, ttTRUST_SET, 65539) +/** Grants the ability to unfreeze a trustline. */ +GRANULAR_PERMISSION(TrustlineUnfreeze, ttTRUST_SET, 65539, tfUniversal | tfClearFreeze, + ({{sfLimitAmount, SoeRequired}})) -/** This permission grants the delegated account the ability to set Domain. */ -PERMISSION(AccountDomainSet, ttACCOUNT_SET, 65540) +/** Grants the ability to set Domain. */ +GRANULAR_PERMISSION(AccountDomainSet, ttACCOUNT_SET, 65540, tfUniversal, + ({{sfDomain, SoeOptional}})) -/** This permission grants the delegated account the ability to set EmailHashSet. */ -PERMISSION(AccountEmailHashSet, ttACCOUNT_SET, 65541) +/** Grants the ability to set EmailHash. */ +GRANULAR_PERMISSION(AccountEmailHashSet, ttACCOUNT_SET, 65541, tfUniversal, + ({{sfEmailHash, SoeOptional}})) -/** This permission grants the delegated account the ability to set MessageKey. */ -PERMISSION(AccountMessageKeySet, ttACCOUNT_SET, 65542) +/** Grants the ability to set MessageKey. */ +GRANULAR_PERMISSION(AccountMessageKeySet, ttACCOUNT_SET, 65542, tfUniversal, + ({{sfMessageKey, SoeOptional}})) -/** This permission grants the delegated account the ability to set TransferRate. */ -PERMISSION(AccountTransferRateSet, ttACCOUNT_SET, 65543) +/** Grants the ability to set TransferRate. */ +GRANULAR_PERMISSION(AccountTransferRateSet, ttACCOUNT_SET, 65543, tfUniversal, + ({{sfTransferRate, SoeOptional}})) -/** This permission grants the delegated account the ability to set TickSize. */ -PERMISSION(AccountTickSizeSet, ttACCOUNT_SET, 65544) +/** Grants the ability to set TickSize. */ +GRANULAR_PERMISSION(AccountTickSizeSet, ttACCOUNT_SET, 65544, tfUniversal, + ({{sfTickSize, SoeOptional}})) -/** This permission grants the delegated account the ability to mint payment, which means sending a payment for a currency where the sending account is the issuer. */ -PERMISSION(PaymentMint, ttPAYMENT, 65545) +/** Grants the ability to mint payment (sending account is the issuer). Cross-currency payments are disallowed. */ +GRANULAR_PERMISSION(PaymentMint, ttPAYMENT, 65545, tfUniversal, + ({{sfDestination, SoeRequired}, + {sfAmount, SoeRequired}, + {sfSendMax, SoeOptional}, + {sfInvoiceID, SoeOptional}, + {sfDestinationTag, SoeOptional}, + {sfCredentialIDs, SoeOptional}})) -/** This permission grants the delegated account the ability to burn payment, which means sending a payment for a currency where the destination account is the issuer */ -PERMISSION(PaymentBurn, ttPAYMENT, 65546) +/** Grants the ability to burn payment (destination account is the issuer). Cross-currency payments are disallowed. */ +GRANULAR_PERMISSION(PaymentBurn, ttPAYMENT, 65546, tfUniversal, + ({{sfDestination, SoeRequired}, + {sfAmount, SoeRequired}, + {sfSendMax, SoeOptional}, + {sfInvoiceID, SoeOptional}, + {sfDestinationTag, SoeOptional}, + {sfCredentialIDs, SoeOptional}})) -/** This permission grants the delegated account the ability to lock MPToken. */ -PERMISSION(MPTokenIssuanceLock, ttMPTOKEN_ISSUANCE_SET, 65547) +/** Grants the ability to lock an MPToken. */ +GRANULAR_PERMISSION(MPTokenIssuanceLock, ttMPTOKEN_ISSUANCE_SET, 65547, tfUniversal | tfMPTLock, + ({{sfMPTokenIssuanceID, SoeRequired}, + {sfHolder, SoeOptional}})) -/** This permission grants the delegated account the ability to unlock MPToken. */ -PERMISSION(MPTokenIssuanceUnlock, ttMPTOKEN_ISSUANCE_SET, 65548) +/** Grants the ability to unlock an MPToken. */ +GRANULAR_PERMISSION(MPTokenIssuanceUnlock, ttMPTOKEN_ISSUANCE_SET, 65548, tfUniversal | tfMPTUnlock, + ({{sfMPTokenIssuanceID, SoeRequired}, + {sfHolder, SoeOptional}})) diff --git a/include/xrpl/protocol/detail/secp256k1.h b/include/xrpl/protocol/detail/secp256k1.h index 17dfa3ff25..5ca9033eae 100644 --- a/include/xrpl/protocol/detail/secp256k1.h +++ b/include/xrpl/protocol/detail/secp256k1.h @@ -11,7 +11,10 @@ secp256k1Context() struct Holder { secp256k1_context* impl; - Holder() : impl(secp256k1_context_create(SECP256K1_CONTEXT_VERIFY | SECP256K1_CONTEXT_SIGN)) + // SECP256K1_CONTEXT_SIGN and SECP256K1_CONTEXT_VERIFY were deprecated. + // All contexts support both signing and verification, so + // SECP256K1_CONTEXT_NONE is the correct flag to use. + Holder() : impl(secp256k1_context_create(SECP256K1_CONTEXT_NONE)) { } diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 5d1689dce9..0d453eea11 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -113,6 +113,7 @@ TYPED_SFIELD(sfInterestRate, UINT32, 65) // 1/10 basis points (bi TYPED_SFIELD(sfLateInterestRate, UINT32, 66) // 1/10 basis points (bips) TYPED_SFIELD(sfCloseInterestRate, UINT32, 67) // 1/10 basis points (bips) TYPED_SFIELD(sfOverpaymentInterestRate, UINT32, 68) // 1/10 basis points (bips) +TYPED_SFIELD(sfConfidentialBalanceVersion, UINT32, 69) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -146,6 +147,7 @@ TYPED_SFIELD(sfSubjectNode, UINT64, 28) TYPED_SFIELD(sfLockedAmount, UINT64, 29, SField::kSmdBaseTen|SField::kSmdDefault) TYPED_SFIELD(sfVaultNode, UINT64, 30) TYPED_SFIELD(sfLoanBrokerNode, UINT64, 31) +TYPED_SFIELD(sfConfidentialOutstandingAmount, UINT64, 32, SField::kSmdBaseTen|SField::kSmdDefault) // 128-bit TYPED_SFIELD(sfEmailHash, UINT128, 1) @@ -205,6 +207,8 @@ TYPED_SFIELD(sfParentBatchID, UINT256, 36) TYPED_SFIELD(sfLoanBrokerID, UINT256, 37, SField::kSmdPseudoAccount | SField::kSmdDefault) TYPED_SFIELD(sfLoanID, UINT256, 38) +TYPED_SFIELD(sfReferenceHolding, UINT256, 39) +TYPED_SFIELD(sfBlindingFactor, UINT256, 40) // number (common) TYPED_SFIELD(sfNumber, NUMBER, 1) @@ -298,6 +302,21 @@ TYPED_SFIELD(sfAssetClass, VL, 28) TYPED_SFIELD(sfProvider, VL, 29) TYPED_SFIELD(sfMPTokenMetadata, VL, 30) TYPED_SFIELD(sfCredentialType, VL, 31) +TYPED_SFIELD(sfConfidentialBalanceInbox, VL, 32) +TYPED_SFIELD(sfConfidentialBalanceSpending, VL, 33) +TYPED_SFIELD(sfIssuerEncryptedBalance, VL, 34) +TYPED_SFIELD(sfIssuerEncryptionKey, VL, 35) +TYPED_SFIELD(sfHolderEncryptionKey, VL, 36) +TYPED_SFIELD(sfZKProof, VL, 37) +TYPED_SFIELD(sfHolderEncryptedAmount, VL, 38) +TYPED_SFIELD(sfIssuerEncryptedAmount, VL, 39) +TYPED_SFIELD(sfSenderEncryptedAmount, VL, 40) +TYPED_SFIELD(sfDestinationEncryptedAmount, VL, 41) +TYPED_SFIELD(sfAuditorEncryptedBalance, VL, 42) +TYPED_SFIELD(sfAuditorEncryptedAmount, VL, 43) +TYPED_SFIELD(sfAuditorEncryptionKey, VL, 44) +TYPED_SFIELD(sfAmountCommitment, VL, 45) +TYPED_SFIELD(sfBalanceCommitment, VL, 46) // account (common) TYPED_SFIELD(sfAccount, ACCOUNT, 1) diff --git a/include/xrpl/protocol/detail/token_errors.h b/include/xrpl/protocol/detail/token_errors.h index 9d5e98e646..97db9288f9 100644 --- a/include/xrpl/protocol/detail/token_errors.h +++ b/include/xrpl/protocol/detail/token_errors.h @@ -58,7 +58,6 @@ public: case TokenCodecErrc::InvalidEncodingChar: return "invalid encoding char"; case TokenCodecErrc::Unknown: - return "unknown"; default: return "unknown"; } diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 9bac9ef7db..8a3b0ff2ce 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -395,7 +395,7 @@ TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, #endif TRANSACTION(ttCLAWBACK, 30, Clawback, Delegation::Delegable, - featureClawback, + uint256{}, NoPriv, ({ {sfAmount, SoeRequired, SoeMptSupported}, @@ -688,6 +688,7 @@ TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, ({ {sfLedgerFixType, SoeRequired}, {sfOwner, SoeOptional}, + {sfBookDirectory, SoeOptional}, })) /** This transaction type creates a MPTokensIssuance instance */ @@ -734,6 +735,8 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, {sfMPTokenMetadata, SoeOptional}, {sfTransferFee, SoeOptional}, {sfMutableFlags, SoeOptional}, + {sfIssuerEncryptionKey, SoeOptional}, + {sfAuditorEncryptionKey, SoeOptional}, })) /** This transaction type authorizes a MPToken instance */ @@ -1076,6 +1079,91 @@ TRANSACTION(ttLOAN_PAY, 84, LoanPay, {sfAmount, SoeRequired, SoeMptSupported}, })) +/** This transaction type converts into confidential MPT balance. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, + Delegation::Delegable, + featureConfidentialTransfer, + NoPriv, + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfMPTAmount, SoeRequired}, + {sfHolderEncryptionKey, SoeOptional}, + {sfHolderEncryptedAmount, SoeRequired}, + {sfIssuerEncryptedAmount, SoeRequired}, + {sfAuditorEncryptedAmount, SoeOptional}, + {sfBlindingFactor, SoeRequired}, + {sfZKProof, SoeOptional}, +})) + +/** This transaction type merges MPT inbox. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, + Delegation::Delegable, + featureConfidentialTransfer, + NoPriv, + ({ + {sfMPTokenIssuanceID, SoeRequired}, +})) + +/** This transaction type converts back into public MPT balance. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, + Delegation::Delegable, + featureConfidentialTransfer, + NoPriv, + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfMPTAmount, SoeRequired}, + {sfHolderEncryptedAmount, SoeRequired}, + {sfIssuerEncryptedAmount, SoeRequired}, + {sfAuditorEncryptedAmount, SoeOptional}, + {sfBlindingFactor, SoeRequired}, + {sfZKProof, SoeRequired}, + {sfBalanceCommitment, SoeRequired}, +})) + +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, + Delegation::Delegable, + featureConfidentialTransfer, + NoPriv, + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfDestination, SoeRequired}, + {sfDestinationTag, SoeOptional}, + {sfSenderEncryptedAmount, SoeRequired}, + {sfDestinationEncryptedAmount, SoeRequired}, + {sfIssuerEncryptedAmount, SoeRequired}, + {sfAuditorEncryptedAmount, SoeOptional}, + {sfZKProof, SoeRequired}, + {sfAmountCommitment, SoeRequired}, + {sfBalanceCommitment, SoeRequired}, + {sfCredentialIDs, SoeOptional}, +})) + +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, + Delegation::Delegable, + featureConfidentialTransfer, + NoPriv, + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfHolder, SoeRequired}, + {sfMPTAmount, SoeRequired}, + {sfZKProof, SoeRequired}, +})) + /** This system-generated transaction type is used to update the status of the various amendments. For details, see: https://xrpl.org/amendments.html diff --git a/include/xrpl/protocol/digest.h b/include/xrpl/protocol/digest.h index 50bf2735fb..721ce60767 100644 --- a/include/xrpl/protocol/digest.h +++ b/include/xrpl/protocol/digest.h @@ -206,7 +206,7 @@ sha512Half(Args const&... args) sha512_half_hasher h; using beast::hash_append; hash_append(h, args...); - return static_cast(h); + return static_cast(h); } /** Returns the SHA512-Half of a series of objects. @@ -222,7 +222,7 @@ sha512HalfS(Args const&... args) sha512_half_hasher_s h; using beast::hash_append; hash_append(h, args...); - return static_cast(h); + return static_cast(h); } } // namespace xrpl diff --git a/include/xrpl/protocol/jss.h b/include/xrpl/protocol/jss.h index 8a2a112542..191ed385f3 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -137,6 +137,7 @@ JSS(authorized_credentials); // in: ledger_entry DepositPreauth JSS(auth_accounts); // out: amm_info JSS(auth_change); // out: AccountInfo JSS(auth_change_queued); // out: AccountInfo +JSS(auditor_encrypted_balance); // out: mpt_holders (confidential MPT) JSS(available); // out: ValidatorList JSS(avg_bps_recv); // out: Peers JSS(avg_bps_sent); // out: Peers @@ -161,9 +162,6 @@ JSS(build_path); // in: TransactionSign JSS(build_version); // out: NetworkOPs JSS(cancel_after); // out: AccountChannels JSS(can_delete); // out: CanDelete -JSS(mpt_amount); // out: mpt_holders -JSS(mpt_issuance_id); // in: Payment, mpt_holders -JSS(mptoken_index); // out: mpt_holders JSS(changes); // out: BookChanges JSS(channel_id); // out: AccountChannels JSS(channels); // out: AccountChannels @@ -185,165 +183,170 @@ JSS(command); // in: RPCHandler JSS(common); // out: RPC server_definitions JSS(complete); // out: NetworkOPs, InboundLedger JSS(complete_ledgers); // out: NetworkOPs, PeerImp -JSS(consensus); // out: NetworkOPs, LedgerConsensus -JSS(converge_time); // out: NetworkOPs -JSS(converge_time_s); // out: NetworkOPs -JSS(cookie); // out: NetworkOPs -JSS(count); // in: AccountTx*, ValidatorList -JSS(counters); // in/out: retrieve counters -JSS(credentials); // in: deposit_authorized -JSS(credential_type); // in: LedgerEntry DepositPreauth -JSS(ctid); // in/out: Tx RPC -JSS(currency_a); // out: BookChanges -JSS(currency_b); // out: BookChanges -JSS(currency); // in: paths/PathRequest, STAmount - // out: STPathSet, STAmount, AccountLines -JSS(current); // out: OwnerInfo -JSS(current_activities); // -JSS(current_ledger_size); // out: TxQ -JSS(current_queue_size); // out: TxQ -JSS(data); // out: LedgerData -JSS(date); // out: tx/Transaction, NetworkOPs -JSS(dbKBLedger); // out: getCounts -JSS(dbKBTotal); // out: getCounts -JSS(dbKBTransaction); // out: getCounts -JSS(debug_signing); // in: TransactionSign -JSS(deletion_blockers_only); // in: AccountObjects -JSS(delivered_amount); // out: insertDeliveredAmount -JSS(deposit_authorized); // out: deposit_authorized -JSS(deprecated); // -JSS(descending); // in: AccountTx* -JSS(description); // in/out: Reservations -JSS(destination); // in: nft_buy_offers, nft_sell_offers -JSS(destination_account); // in: PathRequest, RipplePathFind, account_lines - // out: AccountChannels -JSS(destination_amount); // in: PathRequest, RipplePathFind -JSS(destination_currencies); // in: PathRequest, RipplePathFind -JSS(destination_tag); // in: PathRequest - // out: AccountChannels -JSS(details); // out: Manifest, server_info -JSS(dir_entry); // out: DirectoryEntryIterator -JSS(dir_index); // out: DirectoryEntryIterator -JSS(dir_root); // out: DirectoryEntryIterator -JSS(discounted_fee); // out: amm_info -JSS(domain); // out: ValidatorInfo, Manifest -JSS(drops); // out: TxQ -JSS(duration_us); // out: NetworkOPs -JSS(effective); // out: ValidatorList - // in: UNL -JSS(enabled); // out: AmendmentTable -JSS(engine_result); // out: NetworkOPs, TransactionSign, Submit -JSS(engine_result_code); // out: NetworkOPs, TransactionSign, Submit -JSS(engine_result_message); // out: NetworkOPs, TransactionSign, Submit -JSS(entire_set); // out: get_aggregate_price -JSS(ephemeral_key); // out: ValidatorInfo - // in/out: Manifest -JSS(error); // out: error -JSS(errored); // -JSS(error_code); // out: error -JSS(error_exception); // out: Submit -JSS(error_message); // out: error -JSS(expand); // in: handler/Ledger -JSS(expected_date); // out: any (warnings) -JSS(expected_date_UTC); // out: any (warnings) -JSS(expected_ledger_size); // out: TxQ -JSS(expiration); // out: AccountOffers, AccountChannels, ValidatorList, amm_info -JSS(fail_hard); // in: Sign, Submit -JSS(failed); // out: InboundLedger -JSS(feature); // in: Feature -JSS(features); // out: Feature -JSS(fee_base); // out: NetworkOPs -JSS(fee_div_max); // in: TransactionSign -JSS(fee_level); // out: AccountInfo -JSS(fee_mult_max); // in: TransactionSign -JSS(fee_ref); // out: NetworkOPs, DEPRECATED -JSS(fetch_pack); // out: NetworkOPs -JSS(FIELDS); // out: RPC server_definitions - // matches definitions.json format -JSS(first); // out: rpc/Version -JSS(finished); // -JSS(fix_txns); // in: LedgerCleaner -JSS(flags); // out: AccountOffers, NetworkOPs -JSS(forward); // in: AccountTx -JSS(freeze); // out: AccountLines -JSS(freeze_peer); // out: AccountLines -JSS(deep_freeze); // out: AccountLines -JSS(deep_freeze_peer); // out: AccountLines -JSS(frozen_balances); // out: GatewayBalances -JSS(full); // in: LedgerClearer, handlers/Ledger -JSS(full_reply); // out: PathFind -JSS(fullbelow_size); // out: GetCounts -JSS(git); // out: server_info -JSS(good); // out: RPCVersion -JSS(hash); // out: NetworkOPs, InboundLedger, LedgerToJson, STTx; field -JSS(have_header); // out: InboundLedger -JSS(have_state); // out: InboundLedger -JSS(have_transactions); // out: InboundLedger -JSS(high); // out: BookChanges -JSS(highest_sequence); // out: AccountInfo -JSS(highest_ticket); // out: AccountInfo -JSS(historical_perminute); // historical_perminute. -JSS(holders); // out: MPTHolders -JSS(hostid); // out: NetworkOPs -JSS(hotwallet); // in: GatewayBalances -JSS(id); // websocket. -JSS(ident); // in: AccountCurrencies, AccountInfo, OwnerInfo -JSS(ignore_default); // in: AccountLines -JSS(in); // out: OverlayImpl -JSS(inLedger); // out: tx/Transaction -JSS(inbound); // out: PeerImp -JSS(index); // in: LedgerEntry - // out: STLedgerEntry, LedgerEntry, TxHistory, LedgerData -JSS(info); // out: ServerInfo, ConsensusInfo, FetchInfo -JSS(initial_sync_duration_us); // -JSS(internal_command); // in: Internal -JSS(invalid_API_version); // out: Many, when a request has an invalid version -JSS(io_latency_ms); // out: NetworkOPs -JSS(ip); // in: Connect, out: OverlayImpl -JSS(is_burned); // out: nft_info (clio) -JSS(isSerialized); // out: RPC server_definitions - // matches definitions.json format -JSS(isSigningField); // out: RPC server_definitions - // matches definitions.json format -JSS(isVLEncoded); // out: RPC server_definitions - // matches definitions.json format -JSS(issuer); // in: RipplePathFind, Subscribe, Unsubscribe, BookOffers - // out: STPathSet, STAmount -JSS(job); // -JSS(job_queue); // -JSS(jobs); // -JSS(jsonrpc); // json version -JSS(jq_trans_overflow); // JobQueue transaction limit overflow. -JSS(kept); // out: SubmitTransaction -JSS(key); // out -JSS(key_type); // in/out: WalletPropose, TransactionSign -JSS(latency); // out: PeerImp -JSS(last); // out: RPCVersion -JSS(last_close); // out: NetworkOPs -JSS(last_refresh_time); // out: ValidatorSite -JSS(last_refresh_status); // out: ValidatorSite -JSS(last_refresh_message); // out: ValidatorSite -JSS(ledger); // in: NetworkOPs, LedgerCleaner, RPCHelpers - // out: NetworkOPs, PeerImp -JSS(ledger_current_index); // out: NetworkOPs, RPCHelpers, LedgerCurrent, LedgerAccept, - // AccountLines -JSS(ledger_data); // out: LedgerHeader -JSS(ledger_hash); // in: RPCHelpers, LedgerRequest, RipplePathFind, - // TransactionEntry, handlers/Ledger - // out: NetworkOPs, RPCHelpers, LedgerClosed, LedgerData, - // AccountLines -JSS(ledger_hit_rate); // out: GetCounts -JSS(ledger_index); // in/out: many -JSS(ledger_index_max); // in, out: AccountTx* -JSS(ledger_index_min); // in, out: AccountTx* -JSS(ledger_max); // in, out: AccountTx* -JSS(ledger_min); // in, out: AccountTx* -JSS(ledger_time); // out: NetworkOPs -JSS(LEDGER_ENTRY_TYPES); // out: RPC server_definitions - // matches definitions.json format -JSS(LEDGER_ENTRY_FLAGS); // out: RPC server_definitions -JSS(LEDGER_ENTRY_FORMATS); // out: RPC server_definitions -JSS(levels); // LogLevels +JSS(confidential_balance_inbox); // out: mpt_holders (confidential MPT) +JSS(confidential_balance_spending); // out: mpt_holders (confidential MPT) +JSS(confidential_balance_version); // out: mpt_holders (confidential MPT) +JSS(consensus); // out: NetworkOPs, LedgerConsensus +JSS(converge_time); // out: NetworkOPs +JSS(converge_time_s); // out: NetworkOPs +JSS(cookie); // out: NetworkOPs +JSS(count); // in: AccountTx*, ValidatorList +JSS(counters); // in/out: retrieve counters +JSS(credentials); // in: deposit_authorized +JSS(credential_type); // in: LedgerEntry DepositPreauth +JSS(ctid); // in/out: Tx RPC +JSS(currency_a); // out: BookChanges +JSS(currency_b); // out: BookChanges +JSS(currency); // in: paths/PathRequest, STAmount + // out: STPathSet, STAmount, AccountLines +JSS(current); // out: OwnerInfo +JSS(current_activities); // +JSS(current_ledger_size); // out: TxQ +JSS(current_queue_size); // out: TxQ +JSS(data); // out: LedgerData +JSS(date); // out: tx/Transaction, NetworkOPs +JSS(dbKBLedger); // out: getCounts +JSS(dbKBTotal); // out: getCounts +JSS(dbKBTransaction); // out: getCounts +JSS(debug_signing); // in: TransactionSign +JSS(deletion_blockers_only); // in: AccountObjects +JSS(delivered_amount); // out: insertDeliveredAmount +JSS(deposit_authorized); // out: deposit_authorized +JSS(deprecated); // +JSS(descending); // in: AccountTx* +JSS(description); // in/out: Reservations +JSS(destination); // in: nft_buy_offers, nft_sell_offers +JSS(destination_account); // in: PathRequest, RipplePathFind, account_lines + // out: AccountChannels +JSS(destination_amount); // in: PathRequest, RipplePathFind +JSS(destination_currencies); // in: PathRequest, RipplePathFind +JSS(destination_tag); // in: PathRequest + // out: AccountChannels +JSS(details); // out: Manifest, server_info +JSS(dir_entry); // out: DirectoryEntryIterator +JSS(dir_index); // out: DirectoryEntryIterator +JSS(dir_root); // out: DirectoryEntryIterator +JSS(discounted_fee); // out: amm_info +JSS(domain); // out: ValidatorInfo, Manifest +JSS(drops); // out: TxQ +JSS(duration_us); // out: NetworkOPs +JSS(effective); // out: ValidatorList + // in: UNL +JSS(enabled); // out: AmendmentTable +JSS(engine_result); // out: NetworkOPs, TransactionSign, Submit +JSS(engine_result_code); // out: NetworkOPs, TransactionSign, Submit +JSS(engine_result_message); // out: NetworkOPs, TransactionSign, Submit +JSS(entire_set); // out: get_aggregate_price +JSS(ephemeral_key); // out: ValidatorInfo + // in/out: Manifest +JSS(error); // out: error +JSS(errored); // +JSS(error_code); // out: error +JSS(error_exception); // out: Submit +JSS(error_message); // out: error +JSS(expand); // in: handler/Ledger +JSS(expected_date); // out: any (warnings) +JSS(expected_date_UTC); // out: any (warnings) +JSS(expected_ledger_size); // out: TxQ +JSS(expiration); // out: AccountOffers, AccountChannels, ValidatorList, amm_info +JSS(fail_hard); // in: Sign, Submit +JSS(failed); // out: InboundLedger +JSS(feature); // in: Feature +JSS(features); // out: Feature +JSS(fee_base); // out: NetworkOPs +JSS(fee_div_max); // in: TransactionSign +JSS(fee_level); // out: AccountInfo +JSS(fee_mult_max); // in: TransactionSign +JSS(fee_ref); // out: NetworkOPs, DEPRECATED +JSS(fetch_pack); // out: NetworkOPs +JSS(FIELDS); // out: RPC server_definitions + // matches definitions.json format +JSS(first); // out: rpc/Version +JSS(finished); // +JSS(fix_txns); // in: LedgerCleaner +JSS(flags); // out: AccountOffers, NetworkOPs +JSS(forward); // in: AccountTx +JSS(freeze); // out: AccountLines +JSS(freeze_peer); // out: AccountLines +JSS(deep_freeze); // out: AccountLines +JSS(deep_freeze_peer); // out: AccountLines +JSS(frozen_balances); // out: GatewayBalances +JSS(full); // in: LedgerClearer, handlers/Ledger +JSS(full_reply); // out: PathFind +JSS(fullbelow_size); // out: GetCounts +JSS(git); // out: server_info +JSS(good); // out: RPCVersion +JSS(hash); // out: NetworkOPs, InboundLedger, LedgerToJson, STTx; field +JSS(have_header); // out: InboundLedger +JSS(have_state); // out: InboundLedger +JSS(have_transactions); // out: InboundLedger +JSS(high); // out: BookChanges +JSS(highest_sequence); // out: AccountInfo +JSS(highest_ticket); // out: AccountInfo +JSS(historical_perminute); // historical_perminute. +JSS(holders); // out: MPTHolders +JSS(holder_encryption_key); // out: mpt_holders (confidential MPT) +JSS(hostid); // out: NetworkOPs +JSS(hotwallet); // in: GatewayBalances +JSS(id); // websocket. +JSS(ident); // in: AccountCurrencies, AccountInfo, OwnerInfo +JSS(ignore_default); // in: AccountLines +JSS(in); // out: OverlayImpl +JSS(inLedger); // out: tx/Transaction +JSS(inbound); // out: PeerImp +JSS(index); // in: LedgerEntry + // out: STLedgerEntry, LedgerEntry, TxHistory, LedgerData +JSS(info); // out: ServerInfo, ConsensusInfo, FetchInfo +JSS(initial_sync_duration_us); // +JSS(internal_command); // in: Internal +JSS(invalid_API_version); // out: Many, when a request has an invalid version +JSS(io_latency_ms); // out: NetworkOPs +JSS(ip); // in: Connect, out: OverlayImpl +JSS(is_burned); // out: nft_info (clio) +JSS(isSerialized); // out: RPC server_definitions + // matches definitions.json format +JSS(isSigningField); // out: RPC server_definitions + // matches definitions.json format +JSS(isVLEncoded); // out: RPC server_definitions + // matches definitions.json format +JSS(issuer); // in: RipplePathFind, Subscribe, Unsubscribe, BookOffers + // out: STPathSet, STAmount +JSS(issuer_encrypted_balance); // out: mpt_holders (confidential MPT) +JSS(job); // +JSS(job_queue); // +JSS(jobs); // +JSS(jsonrpc); // json version +JSS(jq_trans_overflow); // JobQueue transaction limit overflow. +JSS(kept); // out: SubmitTransaction +JSS(key); // out +JSS(key_type); // in/out: WalletPropose, TransactionSign +JSS(latency); // out: PeerImp +JSS(last); // out: RPCVersion +JSS(last_close); // out: NetworkOPs +JSS(last_refresh_time); // out: ValidatorSite +JSS(last_refresh_status); // out: ValidatorSite +JSS(last_refresh_message); // out: ValidatorSite +JSS(ledger); // in: NetworkOPs, LedgerCleaner, RPCHelpers + // out: NetworkOPs, PeerImp +JSS(ledger_current_index); // out: NetworkOPs, RPCHelpers, LedgerCurrent, LedgerAccept, + // AccountLines +JSS(ledger_data); // out: LedgerHeader +JSS(ledger_hash); // in: RPCHelpers, LedgerRequest, RipplePathFind, + // TransactionEntry, handlers/Ledger + // out: NetworkOPs, RPCHelpers, LedgerClosed, LedgerData, + // AccountLines +JSS(ledger_hit_rate); // out: GetCounts +JSS(ledger_index); // in/out: many +JSS(ledger_index_max); // in, out: AccountTx* +JSS(ledger_index_min); // in, out: AccountTx* +JSS(ledger_max); // in, out: AccountTx* +JSS(ledger_min); // in, out: AccountTx* +JSS(ledger_time); // out: NetworkOPs +JSS(LEDGER_ENTRY_TYPES); // out: RPC server_definitions + // matches definitions.json format +JSS(LEDGER_ENTRY_FLAGS); // out: RPC server_definitions +JSS(LEDGER_ENTRY_FORMATS); // out: RPC server_definitions +JSS(levels); // LogLevels JSS(limit); // in/out: AccountTx*, AccountOffers, AccountLines, AccountObjects // in: LedgerData, BookOffers JSS(limit_peer); // out: AccountLines @@ -401,6 +404,9 @@ JSS(min_ledger); // in: LedgerCleaner JSS(minimum_fee); // out: TxQ JSS(minimum_level); // out: TxQ JSS(missingCommand); // error +JSS(mpt_amount); // out: mpt_holders +JSS(mpt_issuance_id); // in: Payment, mpt_holders +JSS(mptoken_index); // out: mpt_holders JSS(mpt_issuance_id_a); // out: BookChanges JSS(mpt_issuance_id_b); // out: BookChanges JSS(name); // out: AmendmentTableImpl, PeerImp diff --git a/include/xrpl/protocol/nft.h b/include/xrpl/protocol/nft.h index 1e79b3f285..54227dd1ea 100644 --- a/include/xrpl/protocol/nft.h +++ b/include/xrpl/protocol/nft.h @@ -52,7 +52,7 @@ getTransferFee(uint256 const& id) } inline std::uint32_t -getSerial(uint256 const& id) +getSequence(uint256 const& id) { std::uint32_t seq = 0; memcpy(&seq, id.begin() + 28, 4); @@ -92,7 +92,7 @@ getTaxon(uint256 const& id) // The taxon cipher is just an XOR, so it is reversible by applying the // XOR a second time. - return cipheredTaxon(getSerial(id), toTaxon(taxon)); + return cipheredTaxon(getSequence(id), toTaxon(taxon)); } inline AccountID diff --git a/include/xrpl/protocol/tokens.h b/include/xrpl/protocol/tokens.h index 125cfe8583..67cb25c7fb 100644 --- a/include/xrpl/protocol/tokens.h +++ b/include/xrpl/protocol/tokens.h @@ -1,10 +1,10 @@ #pragma once -#include #include #include #include +#include #include #include #include @@ -13,7 +13,7 @@ namespace xrpl { template -using B58Result = Expected; +using B58Result = std::expected; enum class TokenType : std::uint8_t { None = 1, // unused diff --git a/include/xrpl/protocol_autogen/LedgerEntryBase.h b/include/xrpl/protocol_autogen/LedgerEntryBase.h index ad513992c7..5758adbb24 100644 --- a/include/xrpl/protocol_autogen/LedgerEntryBase.h +++ b/include/xrpl/protocol_autogen/LedgerEntryBase.h @@ -27,7 +27,7 @@ public: * @brief Construct a ledger entry wrapper from an existing SLE object. * @param sle The underlying serialized ledger entry to wrap */ - explicit LedgerEntryBase(std::shared_ptr sle) : sle_(std::move(sle)) + explicit LedgerEntryBase(SLE::const_pointer sle) : sle_(std::move(sle)) { } @@ -151,7 +151,7 @@ public: * @return A constant reference to the underlying SLE object */ [[nodiscard]] - std::shared_ptr + SLE::const_pointer getSle() const { return sle_; @@ -159,7 +159,7 @@ public: protected: /** @brief The underlying serialized ledger entry being wrapped. */ - std::shared_ptr sle_; + SLE::const_pointer sle_; }; } // namespace xrpl::ledger_entries diff --git a/include/xrpl/protocol_autogen/README.md b/include/xrpl/protocol_autogen/README.md index 860ed1a242..608ffed085 100644 --- a/include/xrpl/protocol_autogen/README.md +++ b/include/xrpl/protocol_autogen/README.md @@ -15,8 +15,8 @@ Generation requires a one-time setup step to create a virtual environment and install Python dependencies, followed by running the generation target: ```bash -cmake --build . --target setup_code_gen # create venv and install dependencies (once) -cmake --build . --target code_gen # generate code +cmake --build . --target setup_code_gen # create venv and install dependencies (once) +cmake --build . --target code_gen # generate code ``` By default, `CODEGEN_VENV_DIR` points to `.venv` in the project root. The diff --git a/include/xrpl/protocol_autogen/ledger_entries/AMM.h b/include/xrpl/protocol_autogen/ledger_entries/AMM.h index 529e3f4df7..11fd3738c9 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/AMM.h +++ b/include/xrpl/protocol_autogen/ledger_entries/AMM.h @@ -33,7 +33,7 @@ public: * @brief Construct a AMM ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit AMM(std::shared_ptr sle) + explicit AMM(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -256,7 +256,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - AMMBuilder(std::shared_ptr sle) + AMMBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltAMM) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/AccountRoot.h b/include/xrpl/protocol_autogen/ledger_entries/AccountRoot.h index e20259330d..f9a12a027f 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/AccountRoot.h +++ b/include/xrpl/protocol_autogen/ledger_entries/AccountRoot.h @@ -33,7 +33,7 @@ public: * @brief Construct a AccountRoot ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit AccountRoot(std::shared_ptr sle) + explicit AccountRoot(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -555,7 +555,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - AccountRootBuilder(std::shared_ptr sle) + AccountRootBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltACCOUNT_ROOT) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/Amendments.h b/include/xrpl/protocol_autogen/ledger_entries/Amendments.h index 4f1b316b99..6a801308ca 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Amendments.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Amendments.h @@ -33,7 +33,7 @@ public: * @brief Construct a Amendments ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit Amendments(std::shared_ptr sle) + explicit Amendments(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -166,7 +166,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - AmendmentsBuilder(std::shared_ptr sle) + AmendmentsBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltAMENDMENTS) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/Bridge.h b/include/xrpl/protocol_autogen/ledger_entries/Bridge.h index fd7df1977f..2c7479b243 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Bridge.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Bridge.h @@ -33,7 +33,7 @@ public: * @brief Construct a Bridge ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit Bridge(std::shared_ptr sle) + explicit Bridge(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -210,7 +210,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - BridgeBuilder(std::shared_ptr sle) + BridgeBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltBRIDGE) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/Check.h b/include/xrpl/protocol_autogen/ledger_entries/Check.h index 750270cad9..5b3fd10b92 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Check.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Check.h @@ -33,7 +33,7 @@ public: * @brief Construct a Check ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit Check(std::shared_ptr sle) + explicit Check(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -269,7 +269,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - CheckBuilder(std::shared_ptr sle) + CheckBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltCHECK) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/Credential.h b/include/xrpl/protocol_autogen/ledger_entries/Credential.h index 5433f00b33..dfce76e45c 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Credential.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Credential.h @@ -33,7 +33,7 @@ public: * @brief Construct a Credential ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit Credential(std::shared_ptr sle) + explicit Credential(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -219,7 +219,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - CredentialBuilder(std::shared_ptr sle) + CredentialBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltCREDENTIAL) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/DID.h b/include/xrpl/protocol_autogen/ledger_entries/DID.h index 2f57961be4..ad423377e7 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/DID.h +++ b/include/xrpl/protocol_autogen/ledger_entries/DID.h @@ -33,7 +33,7 @@ public: * @brief Construct a DID ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit DID(std::shared_ptr sle) + explicit DID(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -193,7 +193,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - DIDBuilder(std::shared_ptr sle) + DIDBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltDID) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/Delegate.h b/include/xrpl/protocol_autogen/ledger_entries/Delegate.h index 7458b8103a..bfe5f5587a 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Delegate.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Delegate.h @@ -33,7 +33,7 @@ public: * @brief Construct a Delegate ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit Delegate(std::shared_ptr sle) + explicit Delegate(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -172,7 +172,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - DelegateBuilder(std::shared_ptr sle) + DelegateBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltDELEGATE) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/DepositPreauth.h b/include/xrpl/protocol_autogen/ledger_entries/DepositPreauth.h index 42b430a665..069bed6b77 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/DepositPreauth.h +++ b/include/xrpl/protocol_autogen/ledger_entries/DepositPreauth.h @@ -33,7 +33,7 @@ public: * @brief Construct a DepositPreauth ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit DepositPreauth(std::shared_ptr sle) + explicit DepositPreauth(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -170,7 +170,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - DepositPreauthBuilder(std::shared_ptr sle) + DepositPreauthBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltDEPOSIT_PREAUTH) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/DirectoryNode.h b/include/xrpl/protocol_autogen/ledger_entries/DirectoryNode.h index e47cb98a26..50659c33f6 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/DirectoryNode.h +++ b/include/xrpl/protocol_autogen/ledger_entries/DirectoryNode.h @@ -33,7 +33,7 @@ public: * @brief Construct a DirectoryNode ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit DirectoryNode(std::shared_ptr sle) + explicit DirectoryNode(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -431,7 +431,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - DirectoryNodeBuilder(std::shared_ptr sle) + DirectoryNodeBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltDIR_NODE) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/Escrow.h b/include/xrpl/protocol_autogen/ledger_entries/Escrow.h index ae8219a4f0..f3c033d26d 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Escrow.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Escrow.h @@ -33,7 +33,7 @@ public: * @brief Construct a Escrow ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit Escrow(std::shared_ptr sle) + explicit Escrow(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -363,7 +363,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - EscrowBuilder(std::shared_ptr sle) + EscrowBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltESCROW) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/FeeSettings.h b/include/xrpl/protocol_autogen/ledger_entries/FeeSettings.h index cfd7a591e8..8f43d3b782 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/FeeSettings.h +++ b/include/xrpl/protocol_autogen/ledger_entries/FeeSettings.h @@ -33,7 +33,7 @@ public: * @brief Construct a FeeSettings ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit FeeSettings(std::shared_ptr sle) + explicit FeeSettings(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -285,7 +285,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - FeeSettingsBuilder(std::shared_ptr sle) + FeeSettingsBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltFEE_SETTINGS) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/LedgerHashes.h b/include/xrpl/protocol_autogen/ledger_entries/LedgerHashes.h index 8c082be685..f1d3684b55 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/LedgerHashes.h +++ b/include/xrpl/protocol_autogen/ledger_entries/LedgerHashes.h @@ -33,7 +33,7 @@ public: * @brief Construct a LedgerHashes ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit LedgerHashes(std::shared_ptr sle) + explicit LedgerHashes(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -130,7 +130,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - LedgerHashesBuilder(std::shared_ptr sle) + LedgerHashesBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltLEDGER_HASHES) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/Loan.h b/include/xrpl/protocol_autogen/ledger_entries/Loan.h index 4408c278db..5d837736ec 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Loan.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Loan.h @@ -33,7 +33,7 @@ public: * @brief Construct a Loan ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit Loan(std::shared_ptr sle) + explicit Loan(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -607,7 +607,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - LoanBuilder(std::shared_ptr sle) + LoanBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltLOAN) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/LoanBroker.h b/include/xrpl/protocol_autogen/ledger_entries/LoanBroker.h index b1c6dd8a54..88f05e3433 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/LoanBroker.h +++ b/include/xrpl/protocol_autogen/ledger_entries/LoanBroker.h @@ -33,7 +33,7 @@ public: * @brief Construct a LoanBroker ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit LoanBroker(std::shared_ptr sle) + explicit LoanBroker(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -378,7 +378,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - LoanBrokerBuilder(std::shared_ptr sle) + LoanBrokerBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltLOAN_BROKER) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPToken.h b/include/xrpl/protocol_autogen/ledger_entries/MPToken.h index 8176b74fe9..379cfe53f5 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPToken.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPToken.h @@ -33,7 +33,7 @@ public: * @brief Construct a MPToken ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit MPToken(std::shared_ptr sle) + explicit MPToken(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -147,6 +147,150 @@ public: { return this->sle_->at(sfPreviousTxnLgrSeq); } + + /** + * @brief Get sfConfidentialBalanceInbox (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getConfidentialBalanceInbox() const + { + if (hasConfidentialBalanceInbox()) + return this->sle_->at(sfConfidentialBalanceInbox); + return std::nullopt; + } + + /** + * @brief Check if sfConfidentialBalanceInbox is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasConfidentialBalanceInbox() const + { + return this->sle_->isFieldPresent(sfConfidentialBalanceInbox); + } + + /** + * @brief Get sfConfidentialBalanceSpending (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getConfidentialBalanceSpending() const + { + if (hasConfidentialBalanceSpending()) + return this->sle_->at(sfConfidentialBalanceSpending); + return std::nullopt; + } + + /** + * @brief Check if sfConfidentialBalanceSpending is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasConfidentialBalanceSpending() const + { + return this->sle_->isFieldPresent(sfConfidentialBalanceSpending); + } + + /** + * @brief Get sfConfidentialBalanceVersion (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getConfidentialBalanceVersion() const + { + if (hasConfidentialBalanceVersion()) + return this->sle_->at(sfConfidentialBalanceVersion); + return std::nullopt; + } + + /** + * @brief Check if sfConfidentialBalanceVersion is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasConfidentialBalanceVersion() const + { + return this->sle_->isFieldPresent(sfConfidentialBalanceVersion); + } + + /** + * @brief Get sfIssuerEncryptedBalance (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getIssuerEncryptedBalance() const + { + if (hasIssuerEncryptedBalance()) + return this->sle_->at(sfIssuerEncryptedBalance); + return std::nullopt; + } + + /** + * @brief Check if sfIssuerEncryptedBalance is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasIssuerEncryptedBalance() const + { + return this->sle_->isFieldPresent(sfIssuerEncryptedBalance); + } + + /** + * @brief Get sfAuditorEncryptedBalance (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorEncryptedBalance() const + { + if (hasAuditorEncryptedBalance()) + return this->sle_->at(sfAuditorEncryptedBalance); + return std::nullopt; + } + + /** + * @brief Check if sfAuditorEncryptedBalance is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorEncryptedBalance() const + { + return this->sle_->isFieldPresent(sfAuditorEncryptedBalance); + } + + /** + * @brief Get sfHolderEncryptionKey (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getHolderEncryptionKey() const + { + if (hasHolderEncryptionKey()) + return this->sle_->at(sfHolderEncryptionKey); + return std::nullopt; + } + + /** + * @brief Check if sfHolderEncryptionKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasHolderEncryptionKey() const + { + return this->sle_->isFieldPresent(sfHolderEncryptionKey); + } }; /** @@ -182,7 +326,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - MPTokenBuilder(std::shared_ptr sle) + MPTokenBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltMPTOKEN) { @@ -270,6 +414,72 @@ public: return *this; } + /** + * @brief Set sfConfidentialBalanceInbox (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setConfidentialBalanceInbox(std::decay_t const& value) + { + object_[sfConfidentialBalanceInbox] = value; + return *this; + } + + /** + * @brief Set sfConfidentialBalanceSpending (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setConfidentialBalanceSpending(std::decay_t const& value) + { + object_[sfConfidentialBalanceSpending] = value; + return *this; + } + + /** + * @brief Set sfConfidentialBalanceVersion (SoeDefault) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setConfidentialBalanceVersion(std::decay_t const& value) + { + object_[sfConfidentialBalanceVersion] = value; + return *this; + } + + /** + * @brief Set sfIssuerEncryptedBalance (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setIssuerEncryptedBalance(std::decay_t const& value) + { + object_[sfIssuerEncryptedBalance] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptedBalance (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setAuditorEncryptedBalance(std::decay_t const& value) + { + object_[sfAuditorEncryptedBalance] = value; + return *this; + } + + /** + * @brief Set sfHolderEncryptionKey (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setHolderEncryptionKey(std::decay_t const& value) + { + object_[sfHolderEncryptionKey] = value; + return *this; + } + /** * @brief Build and return the completed MPToken wrapper. * @param index The ledger entry index. diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h index 8377fdf1a4..b6c77093ac 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h @@ -33,7 +33,7 @@ public: * @brief Construct a MPTokenIssuance ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit MPTokenIssuance(std::shared_ptr sle) + explicit MPTokenIssuance(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -278,6 +278,102 @@ public: { return this->sle_->isFieldPresent(sfMutableFlags); } + + /** + * @brief Get sfReferenceHolding (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getReferenceHolding() const + { + if (hasReferenceHolding()) + return this->sle_->at(sfReferenceHolding); + return std::nullopt; + } + + /** + * @brief Check if sfReferenceHolding is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasReferenceHolding() const + { + return this->sle_->isFieldPresent(sfReferenceHolding); + } + + /** + * @brief Get sfIssuerEncryptionKey (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getIssuerEncryptionKey() const + { + if (hasIssuerEncryptionKey()) + return this->sle_->at(sfIssuerEncryptionKey); + return std::nullopt; + } + + /** + * @brief Check if sfIssuerEncryptionKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasIssuerEncryptionKey() const + { + return this->sle_->isFieldPresent(sfIssuerEncryptionKey); + } + + /** + * @brief Get sfAuditorEncryptionKey (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorEncryptionKey() const + { + if (hasAuditorEncryptionKey()) + return this->sle_->at(sfAuditorEncryptionKey); + return std::nullopt; + } + + /** + * @brief Check if sfAuditorEncryptionKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorEncryptionKey() const + { + return this->sle_->isFieldPresent(sfAuditorEncryptionKey); + } + + /** + * @brief Get sfConfidentialOutstandingAmount (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getConfidentialOutstandingAmount() const + { + if (hasConfidentialOutstandingAmount()) + return this->sle_->at(sfConfidentialOutstandingAmount); + return std::nullopt; + } + + /** + * @brief Check if sfConfidentialOutstandingAmount is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasConfidentialOutstandingAmount() const + { + return this->sle_->isFieldPresent(sfConfidentialOutstandingAmount); + } }; /** @@ -315,7 +411,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - MPTokenIssuanceBuilder(std::shared_ptr sle) + MPTokenIssuanceBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltMPTOKEN_ISSUANCE) { @@ -469,6 +565,50 @@ public: return *this; } + /** + * @brief Set sfReferenceHolding (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceBuilder& + setReferenceHolding(std::decay_t const& value) + { + object_[sfReferenceHolding] = value; + return *this; + } + + /** + * @brief Set sfIssuerEncryptionKey (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceBuilder& + setIssuerEncryptionKey(std::decay_t const& value) + { + object_[sfIssuerEncryptionKey] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptionKey (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceBuilder& + setAuditorEncryptionKey(std::decay_t const& value) + { + object_[sfAuditorEncryptionKey] = value; + return *this; + } + + /** + * @brief Set sfConfidentialOutstandingAmount (SoeDefault) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceBuilder& + setConfidentialOutstandingAmount(std::decay_t const& value) + { + object_[sfConfidentialOutstandingAmount] = value; + return *this; + } + /** * @brief Build and return the completed MPTokenIssuance wrapper. * @param index The ledger entry index. diff --git a/include/xrpl/protocol_autogen/ledger_entries/NFTokenOffer.h b/include/xrpl/protocol_autogen/ledger_entries/NFTokenOffer.h index c0a6ee6cbc..072d3721f9 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/NFTokenOffer.h +++ b/include/xrpl/protocol_autogen/ledger_entries/NFTokenOffer.h @@ -33,7 +33,7 @@ public: * @brief Construct a NFTokenOffer ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit NFTokenOffer(std::shared_ptr sle) + explicit NFTokenOffer(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -208,7 +208,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - NFTokenOfferBuilder(std::shared_ptr sle) + NFTokenOfferBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltNFTOKEN_OFFER) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/NFTokenPage.h b/include/xrpl/protocol_autogen/ledger_entries/NFTokenPage.h index c4190f9068..5e00cb1120 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/NFTokenPage.h +++ b/include/xrpl/protocol_autogen/ledger_entries/NFTokenPage.h @@ -33,7 +33,7 @@ public: * @brief Construct a NFTokenPage ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit NFTokenPage(std::shared_ptr sle) + explicit NFTokenPage(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -157,7 +157,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - NFTokenPageBuilder(std::shared_ptr sle) + NFTokenPageBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltNFTOKEN_PAGE) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/NegativeUNL.h b/include/xrpl/protocol_autogen/ledger_entries/NegativeUNL.h index b09135e5f8..7ca9729082 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/NegativeUNL.h +++ b/include/xrpl/protocol_autogen/ledger_entries/NegativeUNL.h @@ -33,7 +33,7 @@ public: * @brief Construct a NegativeUNL ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit NegativeUNL(std::shared_ptr sle) + explicit NegativeUNL(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -190,7 +190,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - NegativeUNLBuilder(std::shared_ptr sle) + NegativeUNLBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltNEGATIVE_UNL) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/Offer.h b/include/xrpl/protocol_autogen/ledger_entries/Offer.h index a6d427cbbd..f51b54cfd2 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Offer.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Offer.h @@ -33,7 +33,7 @@ public: * @brief Construct a Offer ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit Offer(std::shared_ptr sle) + explicit Offer(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -259,7 +259,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - OfferBuilder(std::shared_ptr sle) + OfferBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltOFFER) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/Oracle.h b/include/xrpl/protocol_autogen/ledger_entries/Oracle.h index 31735a713c..902032f94f 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Oracle.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Oracle.h @@ -33,7 +33,7 @@ public: * @brief Construct a Oracle ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit Oracle(std::shared_ptr sle) + explicit Oracle(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -222,7 +222,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - OracleBuilder(std::shared_ptr sle) + OracleBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltORACLE) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/PayChannel.h b/include/xrpl/protocol_autogen/ledger_entries/PayChannel.h index 097f287425..61a4e2d044 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/PayChannel.h +++ b/include/xrpl/protocol_autogen/ledger_entries/PayChannel.h @@ -33,7 +33,7 @@ public: * @brief Construct a PayChannel ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit PayChannel(std::shared_ptr sle) + explicit PayChannel(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -330,7 +330,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - PayChannelBuilder(std::shared_ptr sle) + PayChannelBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltPAYCHAN) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/PermissionedDomain.h b/include/xrpl/protocol_autogen/ledger_entries/PermissionedDomain.h index cc5a1649ef..638dda2420 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/PermissionedDomain.h +++ b/include/xrpl/protocol_autogen/ledger_entries/PermissionedDomain.h @@ -33,7 +33,7 @@ public: * @brief Construct a PermissionedDomain ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit PermissionedDomain(std::shared_ptr sle) + explicit PermissionedDomain(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -148,7 +148,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - PermissionedDomainBuilder(std::shared_ptr sle) + PermissionedDomainBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltPERMISSIONED_DOMAIN) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/RippleState.h b/include/xrpl/protocol_autogen/ledger_entries/RippleState.h index cb221bb2ad..e8debfe792 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/RippleState.h +++ b/include/xrpl/protocol_autogen/ledger_entries/RippleState.h @@ -33,7 +33,7 @@ public: * @brief Construct a RippleState ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit RippleState(std::shared_ptr sle) + explicit RippleState(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -278,7 +278,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - RippleStateBuilder(std::shared_ptr sle) + RippleStateBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltRIPPLE_STATE) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/SignerList.h b/include/xrpl/protocol_autogen/ledger_entries/SignerList.h index dfc7eb4506..443e5588f9 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/SignerList.h +++ b/include/xrpl/protocol_autogen/ledger_entries/SignerList.h @@ -33,7 +33,7 @@ public: * @brief Construct a SignerList ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit SignerList(std::shared_ptr sle) + explicit SignerList(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -172,7 +172,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - SignerListBuilder(std::shared_ptr sle) + SignerListBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltSIGNER_LIST) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/Ticket.h b/include/xrpl/protocol_autogen/ledger_entries/Ticket.h index ceadd70765..6fa5b57f6c 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Ticket.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Ticket.h @@ -33,7 +33,7 @@ public: * @brief Construct a Ticket ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit Ticket(std::shared_ptr sle) + explicit Ticket(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -134,7 +134,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - TicketBuilder(std::shared_ptr sle) + TicketBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltTICKET) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/Vault.h b/include/xrpl/protocol_autogen/ledger_entries/Vault.h index 168894d8d2..d1aaeb4ed8 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -33,7 +33,7 @@ public: * @brief Construct a Vault ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit Vault(std::shared_ptr sle) + explicit Vault(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -330,7 +330,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - VaultBuilder(std::shared_ptr sle) + VaultBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltVAULT) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/XChainOwnedClaimID.h b/include/xrpl/protocol_autogen/ledger_entries/XChainOwnedClaimID.h index 4a3e9c9103..3f8058a4a1 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/XChainOwnedClaimID.h +++ b/include/xrpl/protocol_autogen/ledger_entries/XChainOwnedClaimID.h @@ -33,7 +33,7 @@ public: * @brief Construct a XChainOwnedClaimID ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit XChainOwnedClaimID(std::shared_ptr sle) + explicit XChainOwnedClaimID(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -187,7 +187,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - XChainOwnedClaimIDBuilder(std::shared_ptr sle) + XChainOwnedClaimIDBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltXCHAIN_OWNED_CLAIM_ID) { diff --git a/include/xrpl/protocol_autogen/ledger_entries/XChainOwnedCreateAccountClaimID.h b/include/xrpl/protocol_autogen/ledger_entries/XChainOwnedCreateAccountClaimID.h index 542de104ea..e24009a4b7 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/XChainOwnedCreateAccountClaimID.h +++ b/include/xrpl/protocol_autogen/ledger_entries/XChainOwnedCreateAccountClaimID.h @@ -33,7 +33,7 @@ public: * @brief Construct a XChainOwnedCreateAccountClaimID ledger entry wrapper from an existing SLE object. * @throws std::runtime_error if the ledger entry type doesn't match. */ - explicit XChainOwnedCreateAccountClaimID(std::shared_ptr sle) + explicit XChainOwnedCreateAccountClaimID(SLE::const_pointer sle) : LedgerEntryBase(std::move(sle)) { // Verify ledger entry type @@ -161,7 +161,7 @@ public: * @param sle The existing ledger entry to copy from. * @throws std::runtime_error if the ledger entry type doesn't match. */ - XChainOwnedCreateAccountClaimIDBuilder(std::shared_ptr sle) + XChainOwnedCreateAccountClaimIDBuilder(SLE::const_pointer sle) { if (sle->at(sfLedgerEntryType) != ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID) { diff --git a/include/xrpl/protocol_autogen/transactions/Clawback.h b/include/xrpl/protocol_autogen/transactions/Clawback.h index bbf1c411f3..ecd7ebe7a2 100644 --- a/include/xrpl/protocol_autogen/transactions/Clawback.h +++ b/include/xrpl/protocol_autogen/transactions/Clawback.h @@ -20,7 +20,7 @@ class ClawbackBuilder; * * Type: ttCLAWBACK (30) * Delegable: Delegation::Delegable - * Amendment: featureClawback + * Amendment: uint256{} * Privileges: NoPriv * * Immutable wrapper around STTx providing type-safe field access. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h new file mode 100644 index 0000000000..2b16590649 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h @@ -0,0 +1,201 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTClawbackBuilder; + +/** + * @brief Transaction: ConfidentialMPTClawback + * + * Type: ttCONFIDENTIAL_MPT_CLAWBACK (89) + * Delegable: Delegation::Delegable + * Amendment: featureConfidentialTransfer + * Privileges: NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTClawbackBuilder to construct new transactions. + */ +class ConfidentialMPTClawback : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_CLAWBACK; + + /** + * @brief Construct a ConfidentialMPTClawback transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTClawback(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTClawback"); + } + } + + // Transaction-specific field getters + + /** + * @brief Get sfMPTokenIssuanceID (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT192::type::value_type + getMPTokenIssuanceID() const + { + return this->tx_->at(sfMPTokenIssuanceID); + } + + /** + * @brief Get sfHolder (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_ACCOUNT::type::value_type + getHolder() const + { + return this->tx_->at(sfHolder); + } + + /** + * @brief Get sfMPTAmount (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT64::type::value_type + getMPTAmount() const + { + return this->tx_->at(sfMPTAmount); + } + + /** + * @brief Get sfZKProof (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getZKProof() const + { + return this->tx_->at(sfZKProof); + } +}; + +/** + * @brief Builder for ConfidentialMPTClawback transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class ConfidentialMPTClawbackBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTClawbackBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param holder The sfHolder field value. + * @param mPTAmount The sfMPTAmount field value. + * @param zKProof The sfZKProof field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTClawbackBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::decay_t const& holder, std::decay_t const& mPTAmount, std::decay_t const& zKProof, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_CLAWBACK, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + setHolder(holder); + setMPTAmount(mPTAmount); + setZKProof(zKProof); + } + + /** + * @brief Construct a ConfidentialMPTClawbackBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTClawbackBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_CLAWBACK) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTClawbackBuilder"); + } + object_ = *tx; + } + + /** @brief Transaction-specific field setters */ + + /** + * @brief Set sfMPTokenIssuanceID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTClawbackBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Set sfHolder (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTClawbackBuilder& + setHolder(std::decay_t const& value) + { + object_[sfHolder] = value; + return *this; + } + + /** + * @brief Set sfMPTAmount (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTClawbackBuilder& + setMPTAmount(std::decay_t const& value) + { + object_[sfMPTAmount] = value; + return *this; + } + + /** + * @brief Set sfZKProof (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTClawbackBuilder& + setZKProof(std::decay_t const& value) + { + object_[sfZKProof] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTClawback wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTClawback + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTClawback{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h new file mode 100644 index 0000000000..f7a4cb601a --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h @@ -0,0 +1,336 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTConvertBuilder; + +/** + * @brief Transaction: ConfidentialMPTConvert + * + * Type: ttCONFIDENTIAL_MPT_CONVERT (85) + * Delegable: Delegation::Delegable + * Amendment: featureConfidentialTransfer + * Privileges: NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTConvertBuilder to construct new transactions. + */ +class ConfidentialMPTConvert : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_CONVERT; + + /** + * @brief Construct a ConfidentialMPTConvert transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTConvert(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTConvert"); + } + } + + // Transaction-specific field getters + + /** + * @brief Get sfMPTokenIssuanceID (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT192::type::value_type + getMPTokenIssuanceID() const + { + return this->tx_->at(sfMPTokenIssuanceID); + } + + /** + * @brief Get sfMPTAmount (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT64::type::value_type + getMPTAmount() const + { + return this->tx_->at(sfMPTAmount); + } + + /** + * @brief Get sfHolderEncryptionKey (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getHolderEncryptionKey() const + { + if (hasHolderEncryptionKey()) + { + return this->tx_->at(sfHolderEncryptionKey); + } + return std::nullopt; + } + + /** + * @brief Check if sfHolderEncryptionKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasHolderEncryptionKey() const + { + return this->tx_->isFieldPresent(sfHolderEncryptionKey); + } + + /** + * @brief Get sfHolderEncryptedAmount (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getHolderEncryptedAmount() const + { + return this->tx_->at(sfHolderEncryptedAmount); + } + + /** + * @brief Get sfIssuerEncryptedAmount (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getIssuerEncryptedAmount() const + { + return this->tx_->at(sfIssuerEncryptedAmount); + } + + /** + * @brief Get sfAuditorEncryptedAmount (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorEncryptedAmount() const + { + if (hasAuditorEncryptedAmount()) + { + return this->tx_->at(sfAuditorEncryptedAmount); + } + return std::nullopt; + } + + /** + * @brief Check if sfAuditorEncryptedAmount is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorEncryptedAmount() const + { + return this->tx_->isFieldPresent(sfAuditorEncryptedAmount); + } + + /** + * @brief Get sfBlindingFactor (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT256::type::value_type + getBlindingFactor() const + { + return this->tx_->at(sfBlindingFactor); + } + + /** + * @brief Get sfZKProof (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getZKProof() const + { + if (hasZKProof()) + { + return this->tx_->at(sfZKProof); + } + return std::nullopt; + } + + /** + * @brief Check if sfZKProof is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasZKProof() const + { + return this->tx_->isFieldPresent(sfZKProof); + } +}; + +/** + * @brief Builder for ConfidentialMPTConvert transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class ConfidentialMPTConvertBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTConvertBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param mPTAmount The sfMPTAmount field value. + * @param holderEncryptedAmount The sfHolderEncryptedAmount field value. + * @param issuerEncryptedAmount The sfIssuerEncryptedAmount field value. + * @param blindingFactor The sfBlindingFactor field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTConvertBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::decay_t const& mPTAmount, std::decay_t const& holderEncryptedAmount, std::decay_t const& issuerEncryptedAmount, std::decay_t const& blindingFactor, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_CONVERT, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + setMPTAmount(mPTAmount); + setHolderEncryptedAmount(holderEncryptedAmount); + setIssuerEncryptedAmount(issuerEncryptedAmount); + setBlindingFactor(blindingFactor); + } + + /** + * @brief Construct a ConfidentialMPTConvertBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTConvertBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_CONVERT) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTConvertBuilder"); + } + object_ = *tx; + } + + /** @brief Transaction-specific field setters */ + + /** + * @brief Set sfMPTokenIssuanceID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Set sfMPTAmount (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setMPTAmount(std::decay_t const& value) + { + object_[sfMPTAmount] = value; + return *this; + } + + /** + * @brief Set sfHolderEncryptionKey (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setHolderEncryptionKey(std::decay_t const& value) + { + object_[sfHolderEncryptionKey] = value; + return *this; + } + + /** + * @brief Set sfHolderEncryptedAmount (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setHolderEncryptedAmount(std::decay_t const& value) + { + object_[sfHolderEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfIssuerEncryptedAmount (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setIssuerEncryptedAmount(std::decay_t const& value) + { + object_[sfIssuerEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptedAmount (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setAuditorEncryptedAmount(std::decay_t const& value) + { + object_[sfAuditorEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfBlindingFactor (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setBlindingFactor(std::decay_t const& value) + { + object_[sfBlindingFactor] = value; + return *this; + } + + /** + * @brief Set sfZKProof (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setZKProof(std::decay_t const& value) + { + object_[sfZKProof] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTConvert wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTConvert + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTConvert{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h new file mode 100644 index 0000000000..68bf326645 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h @@ -0,0 +1,310 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTConvertBackBuilder; + +/** + * @brief Transaction: ConfidentialMPTConvertBack + * + * Type: ttCONFIDENTIAL_MPT_CONVERT_BACK (87) + * Delegable: Delegation::Delegable + * Amendment: featureConfidentialTransfer + * Privileges: NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTConvertBackBuilder to construct new transactions. + */ +class ConfidentialMPTConvertBack : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_CONVERT_BACK; + + /** + * @brief Construct a ConfidentialMPTConvertBack transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTConvertBack(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTConvertBack"); + } + } + + // Transaction-specific field getters + + /** + * @brief Get sfMPTokenIssuanceID (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT192::type::value_type + getMPTokenIssuanceID() const + { + return this->tx_->at(sfMPTokenIssuanceID); + } + + /** + * @brief Get sfMPTAmount (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT64::type::value_type + getMPTAmount() const + { + return this->tx_->at(sfMPTAmount); + } + + /** + * @brief Get sfHolderEncryptedAmount (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getHolderEncryptedAmount() const + { + return this->tx_->at(sfHolderEncryptedAmount); + } + + /** + * @brief Get sfIssuerEncryptedAmount (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getIssuerEncryptedAmount() const + { + return this->tx_->at(sfIssuerEncryptedAmount); + } + + /** + * @brief Get sfAuditorEncryptedAmount (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorEncryptedAmount() const + { + if (hasAuditorEncryptedAmount()) + { + return this->tx_->at(sfAuditorEncryptedAmount); + } + return std::nullopt; + } + + /** + * @brief Check if sfAuditorEncryptedAmount is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorEncryptedAmount() const + { + return this->tx_->isFieldPresent(sfAuditorEncryptedAmount); + } + + /** + * @brief Get sfBlindingFactor (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT256::type::value_type + getBlindingFactor() const + { + return this->tx_->at(sfBlindingFactor); + } + + /** + * @brief Get sfZKProof (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getZKProof() const + { + return this->tx_->at(sfZKProof); + } + + /** + * @brief Get sfBalanceCommitment (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getBalanceCommitment() const + { + return this->tx_->at(sfBalanceCommitment); + } +}; + +/** + * @brief Builder for ConfidentialMPTConvertBack transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class ConfidentialMPTConvertBackBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTConvertBackBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param mPTAmount The sfMPTAmount field value. + * @param holderEncryptedAmount The sfHolderEncryptedAmount field value. + * @param issuerEncryptedAmount The sfIssuerEncryptedAmount field value. + * @param blindingFactor The sfBlindingFactor field value. + * @param zKProof The sfZKProof field value. + * @param balanceCommitment The sfBalanceCommitment field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTConvertBackBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::decay_t const& mPTAmount, std::decay_t const& holderEncryptedAmount, std::decay_t const& issuerEncryptedAmount, std::decay_t const& blindingFactor, std::decay_t const& zKProof, std::decay_t const& balanceCommitment, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_CONVERT_BACK, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + setMPTAmount(mPTAmount); + setHolderEncryptedAmount(holderEncryptedAmount); + setIssuerEncryptedAmount(issuerEncryptedAmount); + setBlindingFactor(blindingFactor); + setZKProof(zKProof); + setBalanceCommitment(balanceCommitment); + } + + /** + * @brief Construct a ConfidentialMPTConvertBackBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTConvertBackBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_CONVERT_BACK) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTConvertBackBuilder"); + } + object_ = *tx; + } + + /** @brief Transaction-specific field setters */ + + /** + * @brief Set sfMPTokenIssuanceID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Set sfMPTAmount (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setMPTAmount(std::decay_t const& value) + { + object_[sfMPTAmount] = value; + return *this; + } + + /** + * @brief Set sfHolderEncryptedAmount (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setHolderEncryptedAmount(std::decay_t const& value) + { + object_[sfHolderEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfIssuerEncryptedAmount (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setIssuerEncryptedAmount(std::decay_t const& value) + { + object_[sfIssuerEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptedAmount (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setAuditorEncryptedAmount(std::decay_t const& value) + { + object_[sfAuditorEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfBlindingFactor (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setBlindingFactor(std::decay_t const& value) + { + object_[sfBlindingFactor] = value; + return *this; + } + + /** + * @brief Set sfZKProof (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setZKProof(std::decay_t const& value) + { + object_[sfZKProof] = value; + return *this; + } + + /** + * @brief Set sfBalanceCommitment (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setBalanceCommitment(std::decay_t const& value) + { + object_[sfBalanceCommitment] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTConvertBack wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTConvertBack + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTConvertBack{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h new file mode 100644 index 0000000000..bb932080d8 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h @@ -0,0 +1,129 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTMergeInboxBuilder; + +/** + * @brief Transaction: ConfidentialMPTMergeInbox + * + * Type: ttCONFIDENTIAL_MPT_MERGE_INBOX (86) + * Delegable: Delegation::Delegable + * Amendment: featureConfidentialTransfer + * Privileges: NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTMergeInboxBuilder to construct new transactions. + */ +class ConfidentialMPTMergeInbox : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_MERGE_INBOX; + + /** + * @brief Construct a ConfidentialMPTMergeInbox transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTMergeInbox(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTMergeInbox"); + } + } + + // Transaction-specific field getters + + /** + * @brief Get sfMPTokenIssuanceID (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT192::type::value_type + getMPTokenIssuanceID() const + { + return this->tx_->at(sfMPTokenIssuanceID); + } +}; + +/** + * @brief Builder for ConfidentialMPTMergeInbox transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class ConfidentialMPTMergeInboxBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTMergeInboxBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTMergeInboxBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_MERGE_INBOX, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + } + + /** + * @brief Construct a ConfidentialMPTMergeInboxBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTMergeInboxBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_MERGE_INBOX) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTMergeInboxBuilder"); + } + object_ = *tx; + } + + /** @brief Transaction-specific field setters */ + + /** + * @brief Set sfMPTokenIssuanceID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMergeInboxBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTMergeInbox wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTMergeInbox + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTMergeInbox{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h new file mode 100644 index 0000000000..2d8a77d56f --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h @@ -0,0 +1,408 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTSendBuilder; + +/** + * @brief Transaction: ConfidentialMPTSend + * + * Type: ttCONFIDENTIAL_MPT_SEND (88) + * Delegable: Delegation::Delegable + * Amendment: featureConfidentialTransfer + * Privileges: NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTSendBuilder to construct new transactions. + */ +class ConfidentialMPTSend : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_SEND; + + /** + * @brief Construct a ConfidentialMPTSend transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTSend(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTSend"); + } + } + + // Transaction-specific field getters + + /** + * @brief Get sfMPTokenIssuanceID (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT192::type::value_type + getMPTokenIssuanceID() const + { + return this->tx_->at(sfMPTokenIssuanceID); + } + + /** + * @brief Get sfDestination (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_ACCOUNT::type::value_type + getDestination() const + { + return this->tx_->at(sfDestination); + } + + /** + * @brief Get sfDestinationTag (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getDestinationTag() const + { + if (hasDestinationTag()) + { + return this->tx_->at(sfDestinationTag); + } + return std::nullopt; + } + + /** + * @brief Check if sfDestinationTag is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasDestinationTag() const + { + return this->tx_->isFieldPresent(sfDestinationTag); + } + + /** + * @brief Get sfSenderEncryptedAmount (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getSenderEncryptedAmount() const + { + return this->tx_->at(sfSenderEncryptedAmount); + } + + /** + * @brief Get sfDestinationEncryptedAmount (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getDestinationEncryptedAmount() const + { + return this->tx_->at(sfDestinationEncryptedAmount); + } + + /** + * @brief Get sfIssuerEncryptedAmount (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getIssuerEncryptedAmount() const + { + return this->tx_->at(sfIssuerEncryptedAmount); + } + + /** + * @brief Get sfAuditorEncryptedAmount (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorEncryptedAmount() const + { + if (hasAuditorEncryptedAmount()) + { + return this->tx_->at(sfAuditorEncryptedAmount); + } + return std::nullopt; + } + + /** + * @brief Check if sfAuditorEncryptedAmount is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorEncryptedAmount() const + { + return this->tx_->isFieldPresent(sfAuditorEncryptedAmount); + } + + /** + * @brief Get sfZKProof (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getZKProof() const + { + return this->tx_->at(sfZKProof); + } + + /** + * @brief Get sfAmountCommitment (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getAmountCommitment() const + { + return this->tx_->at(sfAmountCommitment); + } + + /** + * @brief Get sfBalanceCommitment (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getBalanceCommitment() const + { + return this->tx_->at(sfBalanceCommitment); + } + + /** + * @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); + } +}; + +/** + * @brief Builder for ConfidentialMPTSend transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class ConfidentialMPTSendBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTSendBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param destination The sfDestination field value. + * @param senderEncryptedAmount The sfSenderEncryptedAmount field value. + * @param destinationEncryptedAmount The sfDestinationEncryptedAmount field value. + * @param issuerEncryptedAmount The sfIssuerEncryptedAmount field value. + * @param zKProof The sfZKProof field value. + * @param amountCommitment The sfAmountCommitment field value. + * @param balanceCommitment The sfBalanceCommitment field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTSendBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::decay_t const& destination, std::decay_t const& senderEncryptedAmount, std::decay_t const& destinationEncryptedAmount, std::decay_t const& issuerEncryptedAmount, std::decay_t const& zKProof, std::decay_t const& amountCommitment, std::decay_t const& balanceCommitment, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_SEND, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + setDestination(destination); + setSenderEncryptedAmount(senderEncryptedAmount); + setDestinationEncryptedAmount(destinationEncryptedAmount); + setIssuerEncryptedAmount(issuerEncryptedAmount); + setZKProof(zKProof); + setAmountCommitment(amountCommitment); + setBalanceCommitment(balanceCommitment); + } + + /** + * @brief Construct a ConfidentialMPTSendBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTSendBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_SEND) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTSendBuilder"); + } + object_ = *tx; + } + + /** @brief Transaction-specific field setters */ + + /** + * @brief Set sfMPTokenIssuanceID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Set sfDestination (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setDestination(std::decay_t const& value) + { + object_[sfDestination] = value; + return *this; + } + + /** + * @brief Set sfDestinationTag (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setDestinationTag(std::decay_t const& value) + { + object_[sfDestinationTag] = value; + return *this; + } + + /** + * @brief Set sfSenderEncryptedAmount (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setSenderEncryptedAmount(std::decay_t const& value) + { + object_[sfSenderEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfDestinationEncryptedAmount (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setDestinationEncryptedAmount(std::decay_t const& value) + { + object_[sfDestinationEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfIssuerEncryptedAmount (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setIssuerEncryptedAmount(std::decay_t const& value) + { + object_[sfIssuerEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptedAmount (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setAuditorEncryptedAmount(std::decay_t const& value) + { + object_[sfAuditorEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfZKProof (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setZKProof(std::decay_t const& value) + { + object_[sfZKProof] = value; + return *this; + } + + /** + * @brief Set sfAmountCommitment (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setAmountCommitment(std::decay_t const& value) + { + object_[sfAmountCommitment] = value; + return *this; + } + + /** + * @brief Set sfBalanceCommitment (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setBalanceCommitment(std::decay_t const& value) + { + object_[sfBalanceCommitment] = value; + return *this; + } + + /** + * @brief Set sfCredentialIDs (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setCredentialIDs(std::decay_t const& value) + { + object_[sfCredentialIDs] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTSend wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTSend + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTSend{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h b/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h index 3c58815a02..52723ad5eb 100644 --- a/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h +++ b/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h @@ -83,6 +83,32 @@ public: { return this->tx_->isFieldPresent(sfOwner); } + + /** + * @brief Get sfBookDirectory (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getBookDirectory() const + { + if (hasBookDirectory()) + { + return this->tx_->at(sfBookDirectory); + } + return std::nullopt; + } + + /** + * @brief Check if sfBookDirectory is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasBookDirectory() const + { + return this->tx_->isFieldPresent(sfBookDirectory); + } }; /** @@ -149,6 +175,17 @@ public: return *this; } + /** + * @brief Set sfBookDirectory (SoeOptional) + * @return Reference to this builder for method chaining. + */ + LedgerStateFixBuilder& + setBookDirectory(std::decay_t const& value) + { + object_[sfBookDirectory] = value; + return *this; + } + /** * @brief Build and return the LedgerStateFix wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h index a92521173b..8099af1148 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h @@ -187,6 +187,58 @@ public: { return this->tx_->isFieldPresent(sfMutableFlags); } + + /** + * @brief Get sfIssuerEncryptionKey (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getIssuerEncryptionKey() const + { + if (hasIssuerEncryptionKey()) + { + return this->tx_->at(sfIssuerEncryptionKey); + } + return std::nullopt; + } + + /** + * @brief Check if sfIssuerEncryptionKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasIssuerEncryptionKey() const + { + return this->tx_->isFieldPresent(sfIssuerEncryptionKey); + } + + /** + * @brief Get sfAuditorEncryptionKey (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorEncryptionKey() const + { + if (hasAuditorEncryptionKey()) + { + return this->tx_->at(sfAuditorEncryptionKey); + } + return std::nullopt; + } + + /** + * @brief Check if sfAuditorEncryptionKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorEncryptionKey() const + { + return this->tx_->isFieldPresent(sfAuditorEncryptionKey); + } }; /** @@ -297,6 +349,28 @@ public: return *this; } + /** + * @brief Set sfIssuerEncryptionKey (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceSetBuilder& + setIssuerEncryptionKey(std::decay_t const& value) + { + object_[sfIssuerEncryptionKey] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptionKey (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceSetBuilder& + setAuditorEncryptionKey(std::decay_t const& value) + { + object_[sfAuditorEncryptionKey] = value; + return *this; + } + /** * @brief Build and return the MPTokenIssuanceSet wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/resource/detail/Entry.h b/include/xrpl/resource/detail/Entry.h index b12bbf36ae..361c8409c8 100644 --- a/include/xrpl/resource/detail/Entry.h +++ b/include/xrpl/resource/detail/Entry.h @@ -21,7 +21,7 @@ struct Entry : public beast::List::Node @param now Construction time of Entry. */ explicit Entry(clock_type::time_point const now) - : refcount(0), local_balance(now), remote_balance(0) + : refcount(0), localBalance(now), remoteBalance(0) { } @@ -46,7 +46,7 @@ struct Entry : public beast::List::Node int balance(clock_type::time_point const now) { - return local_balance.value(now) + remote_balance; + return localBalance.value(now) + remoteBalance; } // Add a charge and return normalized balance @@ -54,7 +54,7 @@ struct Entry : public beast::List::Node int add(int charge, clock_type::time_point const now) { - return local_balance.add(charge, now) + remote_balance; + return localBalance.add(charge, now) + remoteBalance; } // The public key of the peer @@ -67,10 +67,10 @@ struct Entry : public beast::List::Node int refcount; // Exponentially decaying balance of resource consumption - DecayingSample local_balance; + DecayingSample localBalance; // Normalized balance contribution from imports - int remote_balance; + int remoteBalance; // Time of the last warning clock_type::time_point lastWarningTime; diff --git a/include/xrpl/resource/detail/Key.h b/include/xrpl/resource/detail/Key.h index 935d44425e..7d24b33955 100644 --- a/include/xrpl/resource/detail/Key.h +++ b/include/xrpl/resource/detail/Key.h @@ -25,11 +25,11 @@ struct Key std::size_t operator()(Key const& v) const { - return addr_hash_(v.address); + return addrHash_(v.address); } private: - beast::Uhash<> addr_hash_; + beast::Uhash<> addrHash_; }; struct KeyEqual diff --git a/include/xrpl/resource/detail/Logic.h b/include/xrpl/resource/detail/Logic.h index 7e63baca6c..b11ac100f5 100644 --- a/include/xrpl/resource/detail/Logic.h +++ b/include/xrpl/resource/detail/Logic.h @@ -194,34 +194,34 @@ public: for (auto& inboundEntry : inbound_) { - int const localBalance = inboundEntry.local_balance.value(now); - if ((localBalance + inboundEntry.remote_balance) >= threshold) + int const localBalance = inboundEntry.localBalance.value(now); + if ((localBalance + inboundEntry.remoteBalance) >= threshold) { json::Value& entry = (ret[inboundEntry.toString()] = json::ValueType::Object); entry[jss::local] = localBalance; - entry[jss::remote] = inboundEntry.remote_balance; + entry[jss::remote] = inboundEntry.remoteBalance; entry[jss::type] = "inbound"; } } for (auto& outboundEntry : outbound_) { - int const localBalance = outboundEntry.local_balance.value(now); - if ((localBalance + outboundEntry.remote_balance) >= threshold) + int const localBalance = outboundEntry.localBalance.value(now); + if ((localBalance + outboundEntry.remoteBalance) >= threshold) { json::Value& entry = (ret[outboundEntry.toString()] = json::ValueType::Object); entry[jss::local] = localBalance; - entry[jss::remote] = outboundEntry.remote_balance; + entry[jss::remote] = outboundEntry.remoteBalance; entry[jss::type] = "outbound"; } } for (auto& adminEntry : admin_) { - int const localBalance = adminEntry.local_balance.value(now); - if ((localBalance + adminEntry.remote_balance) >= threshold) + int const localBalance = adminEntry.localBalance.value(now); + if ((localBalance + adminEntry.remoteBalance) >= threshold) { json::Value& entry = (ret[adminEntry.toString()] = json::ValueType::Object); entry[jss::local] = localBalance; - entry[jss::remote] = adminEntry.remote_balance; + entry[jss::remote] = adminEntry.remoteBalance; entry[jss::type] = "admin"; } } @@ -242,7 +242,7 @@ public: for (auto& inboundEntry : inbound_) { Gossip::Item item; - item.balance = inboundEntry.local_balance.value(now); + item.balance = inboundEntry.localBalance.value(now); if (item.balance >= kMinimumGossipBalance) { item.address = inboundEntry.key->address; @@ -278,7 +278,7 @@ public: Import::Item item; item.balance = gossipItem.balance; item.consumer = newInboundEndpoint(gossipItem.address); - item.consumer.entry().remote_balance += item.balance; + item.consumer.entry().remoteBalance += item.balance; next.items.push_back(item); } } @@ -295,14 +295,14 @@ public: Import::Item item; item.balance = gossipItem.balance; item.consumer = newInboundEndpoint(gossipItem.address); - item.consumer.entry().remote_balance += item.balance; + item.consumer.entry().remoteBalance += item.balance; next.items.push_back(item); } Import& prev(resultIt->second); for (auto& item : prev.items) { - item.consumer.entry().remote_balance -= item.balance; + item.consumer.entry().remoteBalance -= item.balance; } std::swap(next, prev); @@ -345,7 +345,7 @@ public: for (auto itemIter(import.items.begin()); itemIter != import.items.end(); ++itemIter) { - itemIter->consumer.entry().remote_balance -= itemIter->balance; + itemIter->consumer.entry().remoteBalance -= itemIter->balance; } iter = importTable_.erase(iter); @@ -520,8 +520,8 @@ public: item["count"] = entry.refcount; item["name"] = entry.toString(); item["balance"] = entry.balance(now); - if (entry.remote_balance != 0) - item["remote_balance"] = entry.remote_balance; + if (entry.remoteBalance != 0) + item["remote_balance"] = entry.remoteBalance; } } diff --git a/include/xrpl/server/Handoff.h b/include/xrpl/server/Handoff.h index eba8c6c4de..b80a9c5745 100644 --- a/include/xrpl/server/Handoff.h +++ b/include/xrpl/server/Handoff.h @@ -21,7 +21,7 @@ struct Handoff bool moved = false; // If response is set, this determines the keep alive - bool keep_alive = false; + bool keepAlive = false; // When set, this will be sent back std::shared_ptr response; diff --git a/include/xrpl/server/InfoSub.h b/include/xrpl/server/InfoSub.h index e93676a938..f316885fd6 100644 --- a/include/xrpl/server/InfoSub.h +++ b/include/xrpl/server/InfoSub.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -26,6 +27,19 @@ public: }; /** Manages a client's subscription to data feeds. + * + * An InfoSub holds a non-owning reference to its `Source` (typically the + * process-wide `NetworkOPsImp`). The destructor reaches back into the + * `Source` to remove this subscriber from every server-side subscription + * map. + * + * @note Lifetime contract: every `InfoSub` instance MUST be destroyed + * before the backing `Source`. NetworkOPsImp shutdown drops all + * subscriber strong refs before its own teardown to satisfy this. + * @note Thread-safety: per-instance state is guarded by `lock_`. The + * destructor reads tracking sets without taking `lock_` because + * the strong-pointer ref-count is zero at destruction time, so + * no other thread can be calling the public mutators. */ class InfoSub : public CountedObject { @@ -117,8 +131,43 @@ public: virtual bool subBook(ref ispListener, Book const&) = 0; + + /** + * Remove a book subscription for a live subscriber. + * + * Clears the book from the subscriber's own tracking set + * (InfoSub::bookSubscriptions_) and then removes the server-side + * entry from subBook_. Call this from RPC unsubscribe handlers. + * + * @param ispListener The subscriber requesting removal. + * @param book The order book to unsubscribe from. + * @return true if the entry was present and removed, false if the + * subscriber was not subscribed to @p book. + * + * @note Thread-safety: acquires subLock_ internally. + * @note Do NOT call from ~InfoSub(). Use unsubBookInternal instead + * to avoid a redundant write-back to bookSubscriptions_ on a + * partially-destroyed object. + */ virtual bool - unsubBook(std::uint64_t uListener, Book const&) = 0; + unsubBook(ref ispListener, Book const&) = 0; + + /** + * Remove a book subscription during InfoSub teardown. + * + * Removes only the server-side entry from subBook_. Does NOT touch + * InfoSub::bookSubscriptions_ because the InfoSub is being destroyed. + * Called by ~InfoSub() for each book in bookSubscriptions_. + * + * @param uListener The sequence number of the subscriber being torn down. + * @param book The order book entry to remove. + * @return true if the entry was present and removed, false otherwise + * (e.g., already removed by a concurrent RPC unsubscribe). + * + * @note Thread-safety: acquires subLock_ internally. + */ + virtual bool + unsubBookInternal(std::uint64_t uListener, Book const&) = 0; virtual bool subTransactions(ref ispListener) = 0; @@ -158,6 +207,13 @@ public: addRpcSub(std::string const& strUrl, ref rspEntry) = 0; virtual bool tryRemoveRpcSub(std::string const& strUrl) = 0; + + /** Journal used by InfoSub for diagnostics that occur after the + * owning subsystem (e.g. application-level Logs) is the only + * surviving sink — primarily destructor-time cleanup failures. + */ + [[nodiscard]] virtual beast::Journal const& + journal() const = 0; }; public: @@ -184,6 +240,31 @@ public: void deleteSubAccountInfo(AccountID const& account, bool rt); + /** Record that this subscriber is following @p book. + * + * Called by NetworkOPsImp::subBook so that ~InfoSub() can issue a + * matching unsubBook for every book this subscriber is tracking, + * keeping per-subscriber state symmetric with the server-side map. + * + * @param book The order book this subscriber has just subscribed to. + * @note Idempotent: re-inserting an already-tracked book is a no-op. + * @note Thread-safe: takes InfoSub::lock_. + */ + void + insertBookSubscription(Book const& book); + + /** Stop tracking @p book for this subscriber. + * + * Called by the unsubscribe RPC handler so that the book is not + * re-unsubscribed by ~InfoSub(). Pairs with insertBookSubscription. + * + * @param book The order book to forget. + * @note No-op if @p book was not previously inserted. + * @note Thread-safe: takes InfoSub::lock_. + */ + void + deleteBookSubscription(Book const& book); + // return false if already subscribed to this account bool insertSubAccountHistory(AccountID const& account); @@ -217,6 +298,7 @@ private: std::shared_ptr request_; std::uint64_t seq_; hash_set accountHistorySubscriptions_; + hash_set bookSubscriptions_; unsigned int apiVersion_ = 0; static int diff --git a/include/xrpl/server/NetworkOPs.h b/include/xrpl/server/NetworkOPs.h index e2aa17566e..785d808935 100644 --- a/include/xrpl/server/NetworkOPs.h +++ b/include/xrpl/server/NetworkOPs.h @@ -249,6 +249,19 @@ public: virtual void stateAccounting(json::Value& obj) = 0; + + /** Total number of (book, subscriber) entries currently tracked. + * + * Counts every weak_ptr stored across every book in subBook_, NOT the + * number of distinct subscribers and NOT the number of distinct + * books: a single subscriber following N books contributes N entries. + * + * @note Diagnostic accessor; intended for tests and operator visibility + * into per-book subscription state. The returned value is a + * snapshot under the subscription lock. + */ + virtual std::size_t + getBookSubscribersCount() = 0; }; } // namespace xrpl diff --git a/include/xrpl/server/Port.h b/include/xrpl/server/Port.h index 9207e0552b..fd773c78fc 100644 --- a/include/xrpl/server/Port.h +++ b/include/xrpl/server/Port.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include @@ -14,6 +13,7 @@ #include #include #include +#include namespace boost::asio::ssl { class context; // NOLINT(readability-identifier-naming) -- external library name @@ -21,6 +21,8 @@ class context; // NOLINT(readability-identifier-naming) -- external library nam namespace xrpl { +class Section; + /** Configuration information for a Server listening port. */ struct Port { @@ -30,19 +32,19 @@ struct Port boost::asio::ip::address ip; std::uint16_t port = 0; std::set protocol; - std::vector admin_nets_v4; - std::vector admin_nets_v6; - std::vector secure_gateway_nets_v4; - std::vector secure_gateway_nets_v6; + std::vector adminNetsV4; + std::vector adminNetsV6; + std::vector secureGatewayNetsV4; + std::vector secureGatewayNetsV6; std::string user; std::string password; - std::string admin_user; - std::string admin_password; - std::string ssl_key; - std::string ssl_cert; - std::string ssl_chain; - std::string ssl_ciphers; - boost::beast::websocket::permessage_deflate pmd_options; + std::string adminUser; + std::string adminPassword; + std::string sslKey; + std::string sslCert; + std::string sslChain; + std::string sslCiphers; + boost::beast::websocket::permessage_deflate pmdOptions; std::shared_ptr context; // How many incoming connections are allowed on this @@ -50,7 +52,7 @@ struct Port int limit = 0; // Websocket disconnects if send queue exceeds this limit - std::uint16_t ws_queue_limit{}; + std::uint16_t wsQueueLimit{}; // Returns `true` if any websocket protocols are specified [[nodiscard]] bool @@ -78,22 +80,22 @@ struct ParsedPort std::set protocol; std::string user; std::string password; - std::string admin_user; - std::string admin_password; - std::string ssl_key; - std::string ssl_cert; - std::string ssl_chain; - std::string ssl_ciphers; - boost::beast::websocket::permessage_deflate pmd_options; + std::string adminUser; + std::string adminPassword; + std::string sslKey; + std::string sslCert; + std::string sslChain; + std::string sslCiphers; + boost::beast::websocket::permessage_deflate pmdOptions; int limit = 0; - std::uint16_t ws_queue_limit{}; + std::uint16_t wsQueueLimit{}; std::optional ip; std::optional port; - std::vector admin_nets_v4; - std::vector admin_nets_v6; - std::vector secure_gateway_nets_v4; - std::vector secure_gateway_nets_v6; + std::vector adminNetsV4; + std::vector adminNetsV6; + std::vector secureGatewayNetsV4; + std::vector secureGatewayNetsV6; }; void diff --git a/include/xrpl/server/Session.h b/include/xrpl/server/Session.h index 2cb991e130..151e57e7f2 100644 --- a/include/xrpl/server/Session.h +++ b/include/xrpl/server/Session.h @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace xrpl { @@ -53,10 +54,10 @@ public: /** Send a copy of data asynchronously. */ /** @{ */ void - write(std::string const& s) + write(std::string_view s) { if (!s.empty()) - write(&s[0], std::distance(s.begin(), s.end())); + write(s.data(), s.size()); } template diff --git a/include/xrpl/server/detail/BaseHTTPPeer.h b/include/xrpl/server/detail/BaseHTTPPeer.h index 04ec4862c6..d26817bc45 100644 --- a/include/xrpl/server/detail/BaseHTTPPeer.h +++ b/include/xrpl/server/detail/BaseHTTPPeer.h @@ -58,13 +58,13 @@ protected: Handler& handler_; boost::asio::executor_work_guard work_; boost::asio::strand strand_; - endpoint_type remote_address_; + endpoint_type remoteAddress_; beast::Journal const journal_; std::string id_; std::size_t nid_; - boost::asio::streambuf read_buf_; + boost::asio::streambuf readBuf_; http_request_type message_; std::vector wq_; std::vector wq2_; @@ -73,9 +73,9 @@ protected: bool complete_ = false; boost::system::error_code ec_; - int request_count_ = 0; - std::size_t bytes_in_ = 0; - std::size_t bytes_out_ = 0; + int requestCount_ = 0; + std::size_t bytesIn_ = 0; + std::size_t bytesOut_ = 0; //-------------------------------------------------------------------------- @@ -151,7 +151,7 @@ protected: beast::IP::Endpoint remoteAddress() override { - return beast::IPAddressConversion::fromAsio(remote_address_); + return beast::IPAddressConversion::fromAsio(remoteAddress_); } http_request_type& @@ -191,23 +191,23 @@ BaseHTTPPeer::BaseHTTPPeer( , handler_(handler) , work_(boost::asio::make_work_guard(executor)) , strand_(boost::asio::make_strand(executor)) - , remote_address_(std::move(remoteAddress)) + , remoteAddress_(std::move(remoteAddress)) , journal_(journal) { - read_buf_.commit( - boost::asio::buffer_copy(read_buf_.prepare(boost::asio::buffer_size(buffers)), buffers)); + readBuf_.commit( + boost::asio::buffer_copy(readBuf_.prepare(boost::asio::buffer_size(buffers)), buffers)); static std::atomic kSid; nid_ = ++kSid; id_ = std::string("#") + std::to_string(nid_) + " "; - JLOG(journal_.trace()) << id_ << "accept: " << remote_address_.address(); + JLOG(journal_.trace()) << id_ << "accept: " << remoteAddress_.address(); } template BaseHTTPPeer::~BaseHTTPPeer() { handler_.onClose(session(), ec_); - JLOG(journal_.trace()) << id_ << "destroyed: " << request_count_ - << ((request_count_ == 1) ? " request" : " requests"); + JLOG(journal_.trace()) << id_ << "destroyed: " << requestCount_ + << ((requestCount_ == 1) ? " request" : " requests"); } template @@ -245,7 +245,7 @@ BaseHTTPPeer::startTimer() boost::beast::get_lowest_layer(impl().stream_) .expires_after( std::chrono::seconds( - remote_address_.address().is_loopback() ? kTimeoutSecondsLocal : kTimeoutSeconds)); + remoteAddress_.address().is_loopback() ? kTimeoutSecondsLocal : kTimeoutSeconds)); } // Convenience for discarding the error code @@ -274,7 +274,7 @@ BaseHTTPPeer::doRead(yield_context doYield) complete_ = false; error_code ec; startTimer(); - boost::beast::http::async_read(impl().stream_, read_buf_, message_, doYield[ec]); + boost::beast::http::async_read(impl().stream_, readBuf_, message_, doYield[ec]); cancelTimer(); if (ec == boost::beast::http::error::end_of_stream) return doClose(); @@ -296,7 +296,7 @@ BaseHTTPPeer::onWrite(error_code const& ec, std::size_t bytesTran return onTimer(); if (ec) return fail(ec, "write"); - bytes_out_ += bytesTransferred; + bytesOut_ += bytesTransferred; { std::scoped_lock const lock(mutex_); wq2_.clear(); diff --git a/include/xrpl/server/detail/BasePeer.h b/include/xrpl/server/detail/BasePeer.h index 3705ef448e..edde28981c 100644 --- a/include/xrpl/server/detail/BasePeer.h +++ b/include/xrpl/server/detail/BasePeer.h @@ -27,7 +27,7 @@ protected: Port const& port_; Handler& handler_; - endpoint_type remote_address_; + endpoint_type remoteAddress_; beast::WrappedSink sink_; beast::Journal const j_; @@ -65,7 +65,7 @@ BasePeer::BasePeer( beast::Journal journal) : port_(port) , handler_(handler) - , remote_address_(std::move(remoteAddress)) + , remoteAddress_(std::move(remoteAddress)) , sink_( journal.sink(), [] { diff --git a/include/xrpl/server/detail/BaseWSPeer.h b/include/xrpl/server/detail/BaseWSPeer.h index 181cf41f81..13225dcba1 100644 --- a/include/xrpl/server/detail/BaseWSPeer.h +++ b/include/xrpl/server/detail/BaseWSPeer.h @@ -42,15 +42,15 @@ private: /// The socket has been closed, or will close after the next write /// finishes. Do not do any more writes, and don't try to close /// again. - bool do_close_ = false; + bool doClose_ = false; boost::beast::websocket::close_reason cr_; waitable_timer timer_; - bool close_on_timer_ = false; - bool ping_active_ = false; + bool closeOnTimer_ = false; + bool pingActive_ = false; boost::beast::websocket::ping_data payload_; error_code ec_; std::function - control_callback_; + controlCallback_; public: template @@ -85,7 +85,7 @@ public: [[nodiscard]] boost::asio::ip::tcp::endpoint const& remoteEndpoint() const override { - return this->remote_address_; + return this->remoteAddress_; } void @@ -173,14 +173,14 @@ BaseWSPeer::run() { if (!strand_.running_in_this_thread()) return post(strand_, std::bind(&BaseWSPeer::run, impl().shared_from_this())); - impl().ws_.set_option(port().pmd_options); + impl().ws_.set_option(port().pmdOptions); // Must manage the control callback memory outside of the `control_callback` // function - control_callback_ = + controlCallback_ = std::bind(&BaseWSPeer::onPingPong, this, std::placeholders::_1, std::placeholders::_2); - impl().ws_.control_callback(control_callback_); + impl().ws_.control_callback(controlCallback_); startTimer(); - close_on_timer_ = true; + closeOnTimer_ = true; impl().ws_.set_option(boost::beast::websocket::stream_base::decorator([](auto& res) { res.set(boost::beast::http::field::server, BuildInfo::getFullVersionString()); })); @@ -198,9 +198,9 @@ BaseWSPeer::send(std::shared_ptr w) { if (!strand_.running_in_this_thread()) return post(strand_, std::bind(&BaseWSPeer::send, impl().shared_from_this(), std::move(w))); - if (do_close_) + if (doClose_) return; - if (wq_.size() > port().ws_queue_limit) + if (wq_.size() > port().wsQueueLimit) { cr_.code = safeCast(boost::beast::websocket::close_code::policy_error); cr_.reason = "Policy error: client is too slow."; @@ -227,9 +227,9 @@ BaseWSPeer::close(boost::beast::websocket::close_reason const& re { if (!strand_.running_in_this_thread()) return post(strand_, [self = impl().shared_from_this(), reason] { self->close(reason); }); - if (do_close_) + if (doClose_) return; - do_close_ = true; + doClose_ = true; if (wq_.empty()) { impl().ws_.async_close( @@ -260,7 +260,7 @@ BaseWSPeer::onWsHandshake(error_code const& ec) { if (ec) return fail(ec, "on_ws_handshake"); - close_on_timer_ = false; + closeOnTimer_ = false; doRead(); } @@ -313,7 +313,7 @@ BaseWSPeer::onWriteFin(error_code const& ec) if (ec) return fail(ec, "write_fin"); wq_.pop_front(); - if (do_close_) + if (doClose_) { impl().ws_.async_close( cr_, @@ -409,7 +409,7 @@ BaseWSPeer::onPing(error_code const& ec) { if (ec == boost::asio::error::operation_aborted) return; - ping_active_ = false; + pingActive_ = false; if (!ec) return; fail(ec, "on_ping"); @@ -426,7 +426,7 @@ BaseWSPeer::onPingPong( boost::beast::string_view const p(payload_.begin()); if (payload == p) { - close_on_timer_ = false; + closeOnTimer_ = false; JLOG(this->j_.trace()) << "got matching pong"; } else @@ -444,11 +444,11 @@ BaseWSPeer::onTimer(error_code ec) return; if (!ec) { - if (!close_on_timer_ || !ping_active_) + if (!closeOnTimer_ || !pingActive_) { startTimer(); - close_on_timer_ = true; - ping_active_ = true; + closeOnTimer_ = true; + pingActive_ = true; // cryptographic is probably overkill.. beast::rngfill(payload_.begin(), payload_.size(), cryptoPrng()); impl().ws_.async_ping( diff --git a/include/xrpl/server/detail/Door.h b/include/xrpl/server/detail/Door.h index d3fff8b476..79d36cae0c 100644 --- a/include/xrpl/server/detail/Door.h +++ b/include/xrpl/server/detail/Door.h @@ -23,7 +23,6 @@ #include #include -#include #endif #include @@ -61,7 +60,7 @@ private: boost::asio::io_context& ioc_; stream_type stream_; socket_type& socket_; - endpoint_type remote_address_; + endpoint_type remoteAddress_; boost::asio::strand strand_; beast::Journal const j_; @@ -90,16 +89,19 @@ private: acceptor_type acceptor_; boost::asio::strand strand_; bool ssl_{ - port_.protocol.count("https") > 0 || port_.protocol.count("wss") > 0 || - port_.protocol.count("wss2") > 0 || port_.protocol.count("peer") > 0}; + port_.protocol.contains("https") || port_.protocol.contains("wss") || + port_.protocol.contains("wss2") || port_.protocol.contains("peer")}; bool plain_{ - port_.protocol.count("http") > 0 || port_.protocol.count("ws") > 0 || - (port_.protocol.count("ws2") != 0u)}; + port_.protocol.contains("http") || port_.protocol.contains("ws") || + (port_.protocol.contains("ws2"))}; static constexpr std::chrono::milliseconds kInitialAcceptDelay{50}; static constexpr std::chrono::milliseconds kMaxAcceptDelay{2000}; - std::chrono::milliseconds accept_delay_{kInitialAcceptDelay}; - boost::asio::steady_timer backoff_timer_; - static constexpr double kFreeFdThreshold = 0.70; + std::chrono::milliseconds acceptDelay_{kInitialAcceptDelay}; + boost::asio::steady_timer backoffTimer_; + static constexpr std::uint64_t kMaxUsedFdPercent = 70; + static constexpr std::chrono::milliseconds kFdSampleInterval{250}; + clock_type::time_point fdSampleAt_; + bool cachedThrottle_{false}; struct FDStats { @@ -164,7 +166,7 @@ Door::Detector::Detector( , ioc_(ioc) , stream_(std::move(stream)) , socket_(stream_.socket()) - , remote_address_(std::move(remoteAddress)) + , remoteAddress_(std::move(remoteAddress)) , strand_(boost::asio::make_strand(ioc_)) , j_(j) { @@ -199,18 +201,18 @@ Door::Detector::doDetect(boost::asio::yield_context doYield) if (ssl) { if (auto sp = ios().template emplace>( - port_, handler_, ioc_, j_, remote_address_, buf.data(), std::move(stream_))) + port_, handler_, ioc_, j_, remoteAddress_, buf.data(), std::move(stream_))) sp->run(); return; } if (auto sp = ios().template emplace>( - port_, handler_, ioc_, j_, remote_address_, buf.data(), std::move(stream_))) + port_, handler_, ioc_, j_, remoteAddress_, buf.data(), std::move(stream_))) sp->run(); return; } if (ec != boost::asio::error::operation_aborted) { - JLOG(j_.trace()) << "Error detecting ssl: " << ec.message() << " from " << remote_address_; + JLOG(j_.trace()) << "Error detecting ssl: " << ec.message() << " from " << remoteAddress_; } } @@ -279,7 +281,8 @@ Door::Door( , ioc_(ioContext) , acceptor_(ioContext) , strand_(boost::asio::make_strand(ioContext)) - , backoff_timer_(ioContext) + , backoffTimer_(ioContext) + , fdSampleAt_(clock_type::now() - kFdSampleInterval) { reOpen(); } @@ -302,7 +305,7 @@ Door::close() return boost::asio::post( strand_, std::bind(&Door::close, this->shared_from_this())); } - backoff_timer_.cancel(); + backoffTimer_.cancel(); error_code ec; acceptor_.close(ec); } @@ -338,11 +341,11 @@ Door::doAccept(boost::asio::yield_context doYield) { if (shouldThrottleForFds()) { - backoff_timer_.expires_after(accept_delay_); + JLOG(j_.warn()) << "Throttling do_accept for " << acceptDelay_.count() << "ms."; + backoffTimer_.expires_after(acceptDelay_); boost::system::error_code tec; - backoff_timer_.async_wait(doYield[tec]); - accept_delay_ = std::min(accept_delay_ * 2, kMaxAcceptDelay); - JLOG(j_.warn()) << "Throttling do_accept for " << accept_delay_.count() << "ms."; + backoffTimer_.async_wait(doYield[tec]); + acceptDelay_ = std::min(acceptDelay_ * 2, kMaxAcceptDelay); continue; } @@ -359,14 +362,17 @@ Door::doAccept(boost::asio::yield_context doYield) if (ec == boost::asio::error::no_descriptors || ec == boost::asio::error::no_buffer_space) { - JLOG(j_.warn()) << "accept: Too many open files. Pausing for " - << accept_delay_.count() << "ms."; + char const* const cause = (ec == boost::asio::error::no_descriptors) + ? "too many open files" + : "kernel buffer space exhausted"; + JLOG(j_.warn()) << "accept: " << cause << ". Pausing for " << acceptDelay_.count() + << "ms."; - backoff_timer_.expires_after(accept_delay_); + backoffTimer_.expires_after(acceptDelay_); boost::system::error_code tec; - backoff_timer_.async_wait(doYield[tec]); + backoffTimer_.async_wait(doYield[tec]); - accept_delay_ = std::min(accept_delay_ * 2, kMaxAcceptDelay); + acceptDelay_ = std::min(acceptDelay_ * 2, kMaxAcceptDelay); } else { @@ -375,7 +381,7 @@ Door::doAccept(boost::asio::yield_context doYield) continue; } - accept_delay_ = kInitialAcceptDelay; + acceptDelay_ = kInitialAcceptDelay; if (ssl_ && plain_) { @@ -428,14 +434,15 @@ Door::shouldThrottleForFds() #if BOOST_OS_WINDOWS return false; #else - auto const stats = queryFdStats(); - if (!stats || stats->limit == 0) - return false; + auto const now = clock_type::now(); + if (now - fdSampleAt_ < kFdSampleInterval) + return cachedThrottle_; - auto const& s = *stats; - auto const free = (s.limit > s.used) ? (s.limit - s.used) : 0ull; - double const freeRatio = static_cast(free) / static_cast(s.limit); - return freeRatio < kFreeFdThreshold; + fdSampleAt_ = now; + auto const stats = queryFdStats(); + cachedThrottle_ = + stats && stats->limit > 0 && stats->used * 100 > stats->limit * kMaxUsedFdPercent; + return cachedThrottle_; #endif } diff --git a/include/xrpl/server/detail/PlainHTTPPeer.h b/include/xrpl/server/detail/PlainHTTPPeer.h index 40f7fe2571..791ab91acf 100644 --- a/include/xrpl/server/detail/PlainHTTPPeer.h +++ b/include/xrpl/server/detail/PlainHTTPPeer.h @@ -82,7 +82,7 @@ template void PlainHTTPPeer::run() { - if (!this->handler_.onAccept(this->session(), this->remote_address_)) + if (!this->handler_.onAccept(this->session(), this->remoteAddress_)) { util::spawn(this->strand_, std::bind(&PlainHTTPPeer::doClose, this->shared_from_this())); return; @@ -103,7 +103,7 @@ PlainHTTPPeer::websocketUpgrade() auto ws = this->ios().template emplace>( this->port_, this->handler_, - this->remote_address_, + this->remoteAddress_, std::move(this->message_), std::move(stream_), this->journal_); @@ -114,20 +114,20 @@ template void PlainHTTPPeer::doRequest() { - ++this->request_count_; + ++this->requestCount_; auto const what = - this->handler_.onHandoff(this->session(), std::move(this->message_), this->remote_address_); + this->handler_.onHandoff(this->session(), std::move(this->message_), this->remoteAddress_); if (what.moved) return; boost::system::error_code ec; if (what.response) { // half-close on Connection: close - if (!what.keep_alive) + if (!what.keepAlive) socket_.shutdown(socket_type::shutdown_receive, ec); if (ec) return this->fail(ec, "request"); - return this->write(what.response, what.keep_alive); + return this->write(what.response, what.keepAlive); } // Perform half-close when Connection: close and not SSL diff --git a/include/xrpl/server/detail/SSLHTTPPeer.h b/include/xrpl/server/detail/SSLHTTPPeer.h index 2eec8ed60f..f3b047150f 100644 --- a/include/xrpl/server/detail/SSLHTTPPeer.h +++ b/include/xrpl/server/detail/SSLHTTPPeer.h @@ -26,7 +26,7 @@ private: using yield_context = boost::asio::yield_context; using error_code = boost::system::error_code; - std::unique_ptr stream_ptr_; + std::unique_ptr streamPtr_; stream_type& stream_; socket_type& socket_; @@ -80,8 +80,8 @@ SSLHTTPPeer::SSLHTTPPeer( journal, remoteAddress, buffers) - , stream_ptr_(std::make_unique(middle_type(std::move(stream)), *port.context)) - , stream_(*stream_ptr_) + , streamPtr_(std::make_unique(middle_type(std::move(stream)), *port.context)) + , stream_(*streamPtr_) , socket_(stream_.next_layer().socket()) { } @@ -91,7 +91,7 @@ template void SSLHTTPPeer::run() { - if (!this->handler_.onAccept(this->session(), this->remote_address_)) + if (!this->handler_.onAccept(this->session(), this->remoteAddress_)) { util::spawn(this->strand_, std::bind(&SSLHTTPPeer::doClose, this->shared_from_this())); return; @@ -110,9 +110,9 @@ SSLHTTPPeer::websocketUpgrade() auto ws = this->ios().template emplace>( this->port_, this->handler_, - this->remote_address_, + this->remoteAddress_, std::move(this->message_), - std::move(this->stream_ptr_), + std::move(this->streamPtr_), this->journal_); return ws; } @@ -124,8 +124,8 @@ SSLHTTPPeer::doHandshake(yield_context doYield) boost::system::error_code ec; stream_.set_verify_mode(boost::asio::ssl::verify_none); this->startTimer(); - this->read_buf_.consume( - stream_.async_handshake(stream_type::server, this->read_buf_.data(), doYield[ec])); + this->readBuf_.consume( + stream_.async_handshake(stream_type::server, this->readBuf_.data(), doYield[ec])); this->cancelTimer(); if (ec == boost::beast::error::timeout) return this->onTimer(); @@ -148,13 +148,13 @@ template void SSLHTTPPeer::doRequest() { - ++this->request_count_; + ++this->requestCount_; auto const what = this->handler_.onHandoff( - this->session(), std::move(stream_ptr_), std::move(this->message_), this->remote_address_); + this->session(), std::move(streamPtr_), std::move(this->message_), this->remoteAddress_); if (what.moved) return; if (what.response) - return this->write(what.response, what.keep_alive); + return this->write(what.response, what.keepAlive); // legacy this->handler_.onRequest(this->session()); } diff --git a/include/xrpl/server/detail/SSLWSPeer.h b/include/xrpl/server/detail/SSLWSPeer.h index 19b77ea932..cb03d5a796 100644 --- a/include/xrpl/server/detail/SSLWSPeer.h +++ b/include/xrpl/server/detail/SSLWSPeer.h @@ -28,7 +28,7 @@ class SSLWSPeer : public BaseWSPeer>, using stream_type = boost::beast::ssl_stream; using waitable_timer = boost::asio::basic_waitable_timer; - std::unique_ptr stream_ptr_; + std::unique_ptr streamPtr_; boost::beast::websocket::stream ws_; public: @@ -61,8 +61,8 @@ SSLWSPeer::SSLWSPeer( remoteEndpoint, std::move(request), journal) - , stream_ptr_(std::move(streamPtr)) - , ws_(*stream_ptr_) + , streamPtr_(std::move(streamPtr)) + , ws_(*streamPtr_) { } diff --git a/include/xrpl/server/detail/ServerImpl.h b/include/xrpl/server/detail/ServerImpl.h index df9a2dd3e8..48ffa26cfe 100644 --- a/include/xrpl/server/detail/ServerImpl.h +++ b/include/xrpl/server/detail/ServerImpl.h @@ -66,7 +66,7 @@ private: Handler& handler_; beast::Journal const j_; - boost::asio::io_context& io_context_; + boost::asio::io_context& ioContext_; boost::asio::strand strand_; std::optional> work_; @@ -104,7 +104,7 @@ public: boost::asio::io_context& getIoContext() { - return io_context_; + return ioContext_; } bool @@ -122,9 +122,9 @@ ServerImpl::ServerImpl( beast::Journal journal) : handler_(handler) , j_(journal) - , io_context_(ioContext) - , strand_(boost::asio::make_strand(io_context_)) - , work_(std::in_place, boost::asio::make_work_guard(io_context_)) + , ioContext_(ioContext) + , strand_(boost::asio::make_strand(ioContext_)) + , work_(std::in_place, boost::asio::make_work_guard(ioContext_)) { } @@ -150,7 +150,7 @@ ServerImpl::ports(std::vector const& ports) { ports_.push_back(port); auto& internalPort = ports_.back(); - if (auto sp = ios_.emplace>(handler_, io_context_, internalPort, j_)) + if (auto sp = ios_.emplace>(handler_, ioContext_, internalPort, j_)) { list_.push_back(sp); diff --git a/include/xrpl/shamap/FullBelowCache.h b/include/xrpl/shamap/FullBelowCache.h index 07290dfbd1..e9fd04ac58 100644 --- a/include/xrpl/shamap/FullBelowCache.h +++ b/include/xrpl/shamap/FullBelowCache.h @@ -25,7 +25,7 @@ public: static constexpr auto kDefaultCacheTargetSize = 0; using key_type = uint256; - using clock_type = typename CacheType::clock_type; + using clock_type = CacheType::clock_type; /** Construct the cache. diff --git a/include/xrpl/shamap/README.md b/include/xrpl/shamap/README.md index ae37d23ac3..8931a1b50c 100644 --- a/include/xrpl/shamap/README.md +++ b/include/xrpl/shamap/README.md @@ -231,7 +231,7 @@ The `fetchNodeNT()` method goes through three phases: will be 0. 2. If the node is not in the TreeNodeCache, we attempt to locate the node - in the historic data stored by the data base. The call to to + in the historic data stored by the data base. The call to `fetchNodeFromDB(hash)` does that work for us. 3. Finally if a filter exists, we check if it can supply the node. This is diff --git a/include/xrpl/shamap/SHAMap.h b/include/xrpl/shamap/SHAMap.h index f63fc95b27..3d08318cf6 100644 --- a/include/xrpl/shamap/SHAMap.h +++ b/include/xrpl/shamap/SHAMap.h @@ -85,7 +85,7 @@ private: /** The sequence of the ledger that this map references, if any. */ std::uint32_t ledgerSeq_ = 0; - intr_ptr::SharedPtr root_; + SHAMapTreeNodePtr root_; mutable SHAMapState state_; SHAMapType const type_; bool backed_ = true; // Map is backed by the database @@ -161,7 +161,7 @@ public: setLedgerSeq(std::uint32_t lseq); bool - fetchRoot(SHAMapHash const& hash, SHAMapSyncFilter* filter); + fetchRoot(SHAMapHash const& hash, SHAMapSyncFilter const* filter); // normal hash access functions @@ -248,7 +248,7 @@ public: @param return The nodes known to be missing */ std::vector> - getMissingNodes(int maxNodes, SHAMapSyncFilter* filter); + getMissingNodes(int maxNodes, SHAMapSyncFilter const* filter); bool getNodeFat( @@ -281,9 +281,9 @@ public: serializeRoot(Serializer& s) const; SHAMapAddNode - addRootNode(SHAMapHash const& hash, Slice const& rootNode, SHAMapSyncFilter* filter); + addRootNode(SHAMapHash const& hash, Slice const& rootNode, SHAMapSyncFilter const* filter); SHAMapAddNode - addKnownNode(SHAMapNodeID const& nodeID, Slice const& rawNode, SHAMapSyncFilter* filter); + addKnownNode(SHAMapNodeID const& nodeID, Slice const& rawNode, SHAMapSyncFilter const* filter); // status functions void @@ -326,36 +326,32 @@ public: invariants() const; private: - using SharedPtrNodeStack = - std::stack, SHAMapNodeID>>; + using SharedPtrNodeStack = std::stack>; using DeltaRef = std::pair, boost::intrusive_ptr>; // tree node cache operations - intr_ptr::SharedPtr + SHAMapTreeNodePtr cacheLookup(SHAMapHash const& hash) const; void - canonicalize(SHAMapHash const& hash, intr_ptr::SharedPtr&) const; + canonicalize(SHAMapHash const& hash, SHAMapTreeNodePtr&) const; // database operations - intr_ptr::SharedPtr + SHAMapTreeNodePtr fetchNodeFromDB(SHAMapHash const& hash) const; - intr_ptr::SharedPtr + SHAMapTreeNodePtr fetchNodeNT(SHAMapHash const& hash) const; - intr_ptr::SharedPtr - fetchNodeNT(SHAMapHash const& hash, SHAMapSyncFilter* filter) const; - intr_ptr::SharedPtr + SHAMapTreeNodePtr + fetchNodeNT(SHAMapHash const& hash, SHAMapSyncFilter const* filter) const; + SHAMapTreeNodePtr fetchNode(SHAMapHash const& hash) const; - intr_ptr::SharedPtr - checkFilter(SHAMapHash const& hash, SHAMapSyncFilter* filter) const; + SHAMapTreeNodePtr + checkFilter(SHAMapHash const& hash, SHAMapSyncFilter const* filter) const; /** Update hashes up to the root */ void - dirtyUp( - SharedPtrNodeStack& stack, - uint256 const& target, - intr_ptr::SharedPtr terminal); + dirtyUp(SharedPtrNodeStack& stack, uint256 const& target, SHAMapTreeNodePtr terminal); /** Walk towards the specified id, returning the node. Caller must check if the return is nullptr, and if not, if the node->peekItem()->key() == @@ -377,25 +373,21 @@ private: preFlushNode(intr_ptr::SharedPtr node) const; /** write and canonicalize modified node */ - intr_ptr::SharedPtr - writeNode(NodeObjectType t, intr_ptr::SharedPtr node) const; + SHAMapTreeNodePtr + writeNode(NodeObjectType t, SHAMapTreeNodePtr node) const; // returns the first item at or below this node SHAMapLeafNode* - firstBelow(intr_ptr::SharedPtr, SharedPtrNodeStack& stack, int branch = 0) - const; + firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch = 0) const; // returns the last item at or below this node SHAMapLeafNode* - lastBelow( - intr_ptr::SharedPtr node, - SharedPtrNodeStack& stack, - int branch = kBranchFactor) const; + lastBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch = kBranchFactor) const; // helper function for firstBelow and lastBelow SHAMapLeafNode* belowHelper( - intr_ptr::SharedPtr node, + SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch, std::tuple, std::function> const& loopParams) @@ -407,20 +399,19 @@ private: descend(SHAMapInnerNode*, int branch) const; SHAMapTreeNode* descendThrow(SHAMapInnerNode*, int branch) const; - intr_ptr::SharedPtr + SHAMapTreeNodePtr descend(SHAMapInnerNode&, int branch) const; - intr_ptr::SharedPtr + SHAMapTreeNodePtr descendThrow(SHAMapInnerNode&, int branch) const; // Descend with filter // If pending, callback is called as if it called fetchNodeNT - using descendCallback = - std::function, SHAMapHash const&)>; + using descendCallback = std::function; SHAMapTreeNode* descendAsync( SHAMapInnerNode* parent, int branch, - SHAMapSyncFilter* filter, + SHAMapSyncFilter const* filter, bool& pending, descendCallback&&) const; @@ -429,11 +420,11 @@ private: SHAMapInnerNode* parent, SHAMapNodeID const& parentID, int branch, - SHAMapSyncFilter* filter) const; + SHAMapSyncFilter const* filter) const; // Non-storing // Does not hook the returned node to its parent - intr_ptr::SharedPtr + SHAMapTreeNodePtr descendNoStore(SHAMapInnerNode&, int branch) const; /** If there is only one leaf below this node, get its contents */ @@ -470,7 +461,7 @@ private: // basic parameters int max; - SHAMapSyncFilter* filter; + SHAMapSyncFilter const* filter; int const maxDefer; std::uint32_t generation; @@ -495,10 +486,10 @@ private: // nodes we may have acquired from deferred reads using DeferredNode = std::tuple< - SHAMapInnerNode*, // parent node - SHAMapNodeID, // parent node ID - int, // branch - intr_ptr::SharedPtr>; // node + SHAMapInnerNode*, // parent node + SHAMapNodeID, // parent node ID + int, // branch + SHAMapTreeNodePtr>; // node int deferred; std::mutex deferLock; @@ -509,7 +500,11 @@ private: // reads std::map resumes; - MissingNodes(int max, SHAMapSyncFilter* filter, int maxDefer, std::uint32_t generation) + MissingNodes( + int max, + SHAMapSyncFilter const* filter, + int maxDefer, + std::uint32_t generation) : max(max), filter(filter), maxDefer(maxDefer), generation(generation), deferred(0) { missingNodes.reserve(max); @@ -524,7 +519,7 @@ private: gmnProcessDeferredReads(MissingNodes&); // fetch from DB helper function - intr_ptr::SharedPtr + SHAMapTreeNodePtr finishFetch(SHAMapHash const& hash, std::shared_ptr const& object) const; }; diff --git a/include/xrpl/shamap/SHAMapAccountStateLeafNode.h b/include/xrpl/shamap/SHAMapAccountStateLeafNode.h index c67b32d4e7..e388d205d1 100644 --- a/include/xrpl/shamap/SHAMapAccountStateLeafNode.h +++ b/include/xrpl/shamap/SHAMapAccountStateLeafNode.h @@ -27,7 +27,7 @@ public: { } - intr_ptr::SharedPtr + SHAMapTreeNodePtr clone(std::uint32_t cowid) const final { return intr_ptr::makeShared(item_, cowid, hash_); diff --git a/include/xrpl/shamap/SHAMapInnerNode.h b/include/xrpl/shamap/SHAMapInnerNode.h index 48416a93e6..cafb498218 100644 --- a/include/xrpl/shamap/SHAMapInnerNode.h +++ b/include/xrpl/shamap/SHAMapInnerNode.h @@ -87,7 +87,7 @@ public: void partialDestructor() override; - intr_ptr::SharedPtr + SHAMapTreeNodePtr clone(std::uint32_t cowid) const override; SHAMapNodeType @@ -121,19 +121,19 @@ public: getChildHash(int m) const; void - setChild(int m, intr_ptr::SharedPtr child); + setChild(int m, SHAMapTreeNodePtr child); void - shareChild(int m, intr_ptr::SharedPtr const& child); + shareChild(int m, SHAMapTreeNodePtr const& child); SHAMapTreeNode* getChildPointer(int branch); - intr_ptr::SharedPtr + SHAMapTreeNodePtr getChild(int branch); - intr_ptr::SharedPtr - canonicalizeChild(int branch, intr_ptr::SharedPtr node); + SHAMapTreeNodePtr + canonicalizeChild(int branch, SHAMapTreeNodePtr node); // sync functions bool @@ -161,10 +161,10 @@ public: void invariants(bool isRoot = false) const override; - static intr_ptr::SharedPtr + static SHAMapTreeNodePtr makeFullInner(Slice data, SHAMapHash const& hash, bool hashValid); - static intr_ptr::SharedPtr + static SHAMapTreeNodePtr makeCompressedInner(Slice data); }; diff --git a/include/xrpl/shamap/SHAMapNodeID.h b/include/xrpl/shamap/SHAMapNodeID.h index dbc087b356..248c9cb80b 100644 --- a/include/xrpl/shamap/SHAMapNodeID.h +++ b/include/xrpl/shamap/SHAMapNodeID.h @@ -5,6 +5,7 @@ #include #include +#include #include namespace xrpl { @@ -127,7 +128,7 @@ operator<<(std::ostream& out, SHAMapNodeID const& node) deserializeSHAMapNodeID(void const* data, std::size_t size); [[nodiscard]] inline std::optional -deserializeSHAMapNodeID(std::string const& s) +deserializeSHAMapNodeID(std::string_view s) { return deserializeSHAMapNodeID(s.data(), s.size()); } diff --git a/include/xrpl/shamap/SHAMapTreeNode.h b/include/xrpl/shamap/SHAMapTreeNode.h index ee74155ac4..5cca2ea41a 100644 --- a/include/xrpl/shamap/SHAMapTreeNode.h +++ b/include/xrpl/shamap/SHAMapTreeNode.h @@ -13,6 +13,9 @@ namespace xrpl { +class SHAMapTreeNode; +using SHAMapTreeNodePtr = intr_ptr::SharedPtr; + // These are wire-protocol identifiers used during serialization to encode the // type of a node. They should not be arbitrarily be changed. static constexpr unsigned char const kWireTypeTransaction = 0; @@ -112,7 +115,7 @@ public: } /** Make a copy of this node, setting the owner. */ - virtual intr_ptr::SharedPtr + virtual SHAMapTreeNodePtr clone(std::uint32_t cowid) const = 0; /** @} */ @@ -153,20 +156,20 @@ public: virtual void invariants(bool isRoot = false) const = 0; - static intr_ptr::SharedPtr + static SHAMapTreeNodePtr makeFromPrefix(Slice rawNode, SHAMapHash const& hash); - static intr_ptr::SharedPtr + static SHAMapTreeNodePtr makeFromWire(Slice rawNode); private: - static intr_ptr::SharedPtr + static SHAMapTreeNodePtr makeTransaction(Slice data, SHAMapHash const& hash, bool hashValid); - static intr_ptr::SharedPtr + static SHAMapTreeNodePtr makeAccountState(Slice data, SHAMapHash const& hash, bool hashValid); - static intr_ptr::SharedPtr + static SHAMapTreeNodePtr makeTransactionWithMeta(Slice data, SHAMapHash const& hash, bool hashValid); }; diff --git a/include/xrpl/shamap/SHAMapTxLeafNode.h b/include/xrpl/shamap/SHAMapTxLeafNode.h index 72be5b1962..49f4f90906 100644 --- a/include/xrpl/shamap/SHAMapTxLeafNode.h +++ b/include/xrpl/shamap/SHAMapTxLeafNode.h @@ -26,7 +26,7 @@ public: { } - intr_ptr::SharedPtr + SHAMapTreeNodePtr clone(std::uint32_t cowid) const final { return intr_ptr::makeShared(item_, cowid, hash_); diff --git a/include/xrpl/shamap/SHAMapTxPlusMetaLeafNode.h b/include/xrpl/shamap/SHAMapTxPlusMetaLeafNode.h index 44562aeaba..3f4163ac41 100644 --- a/include/xrpl/shamap/SHAMapTxPlusMetaLeafNode.h +++ b/include/xrpl/shamap/SHAMapTxPlusMetaLeafNode.h @@ -27,7 +27,7 @@ public: { } - intr_ptr::SharedPtr + SHAMapTreeNodePtr clone(std::uint32_t cowid) const override { return intr_ptr::makeShared(item_, cowid, hash_); diff --git a/include/xrpl/shamap/TreeNodeCache.h b/include/xrpl/shamap/TreeNodeCache.h index 4edb6348ec..2d5782c7e9 100644 --- a/include/xrpl/shamap/TreeNodeCache.h +++ b/include/xrpl/shamap/TreeNodeCache.h @@ -11,5 +11,5 @@ using TreeNodeCache = TaggedCache< SHAMapTreeNode, /*IsKeyCache*/ false, intr_ptr::SharedWeakUnionPtr, - intr_ptr::SharedPtr>; + SHAMapTreeNodePtr>; } // namespace xrpl diff --git a/include/xrpl/shamap/detail/TaggedPointer.h b/include/xrpl/shamap/detail/TaggedPointer.h index 94dbe95284..5eb3863de0 100644 --- a/include/xrpl/shamap/detail/TaggedPointer.h +++ b/include/xrpl/shamap/detail/TaggedPointer.h @@ -148,7 +148,7 @@ public: /** Get the number of elements in each array and a pointer to the start of each array. */ - [[nodiscard]] std::tuple*> + [[nodiscard]] std::tuple getHashesAndChildren() const; /** Get the `hashes` array */ @@ -156,7 +156,7 @@ public: getHashes() const; /** Get the `children` array */ - [[nodiscard]] intr_ptr::SharedPtr* + [[nodiscard]] SHAMapTreeNodePtr* getChildren() const; /** Call the `f` callback for all 16 (branchFactor) branches - even if diff --git a/include/xrpl/shamap/detail/TaggedPointer.ipp b/include/xrpl/shamap/detail/TaggedPointer.ipp index 2e6e31fed8..6606c49a6b 100644 --- a/include/xrpl/shamap/detail/TaggedPointer.ipp +++ b/include/xrpl/shamap/detail/TaggedPointer.ipp @@ -26,8 +26,7 @@ static_assert( // Terminology: A chunk is the memory being allocated from a block. A block // contains multiple chunks. This is the terminology the boost documentation // uses. Pools use "Simple Segregated Storage" as their storage format. -constexpr size_t kElementSizeBytes = - (sizeof(SHAMapHash) + sizeof(intr_ptr::SharedPtr)); +constexpr size_t kElementSizeBytes = sizeof(SHAMapHash) + sizeof(SHAMapTreeNodePtr); constexpr size_t kBlockSizeBytes = kilobytes(512); @@ -364,8 +363,7 @@ inline TaggedPointer::TaggedPointer( // keep new (&dstHashes[dstIndex]) SHAMapHash{srcHashes[srcIndex]}; - new (&dstChildren[dstIndex]) - intr_ptr::SharedPtr{std::move(srcChildren[srcIndex])}; + new (&dstChildren[dstIndex]) SHAMapTreeNodePtr{std::move(srcChildren[srcIndex])}; ++dstIndex; ++srcIndex; } @@ -376,7 +374,7 @@ inline TaggedPointer::TaggedPointer( if (dstIsDense) { new (&dstHashes[dstIndex]) SHAMapHash{}; - new (&dstChildren[dstIndex]) intr_ptr::SharedPtr{}; + new (&dstChildren[dstIndex]) SHAMapTreeNodePtr{}; ++dstIndex; } } @@ -384,7 +382,7 @@ inline TaggedPointer::TaggedPointer( { // add new (&dstHashes[dstIndex]) SHAMapHash{}; - new (&dstChildren[dstIndex]) intr_ptr::SharedPtr{}; + new (&dstChildren[dstIndex]) SHAMapTreeNodePtr{}; ++dstIndex; if (srcIsDense) { @@ -397,7 +395,7 @@ inline TaggedPointer::TaggedPointer( if (dstIsDense) { new (&dstHashes[dstIndex]) SHAMapHash{}; - new (&dstChildren[dstIndex]) intr_ptr::SharedPtr{}; + new (&dstChildren[dstIndex]) SHAMapTreeNodePtr{}; ++dstIndex; } if (srcIsDense) @@ -414,7 +412,7 @@ inline TaggedPointer::TaggedPointer( for (int i = dstIndex; i < dstNumAllocated; ++i) { new (&dstHashes[i]) SHAMapHash{}; - new (&dstChildren[i]) intr_ptr::SharedPtr{}; + new (&dstChildren[i]) SHAMapTreeNodePtr{}; } *this = std::move(dst); } @@ -433,8 +431,10 @@ inline TaggedPointer::TaggedPointer( // allocate hashes and children, but do not run constructors TaggedPointer newHashesAndChildren{RawAllocateTag{}, toAllocate}; - SHAMapHash *newHashes = nullptr, *oldHashes = nullptr; - intr_ptr::SharedPtr*newChildren = nullptr, *oldChildren = nullptr; + SHAMapHash* newHashes = nullptr; + SHAMapHash* oldHashes = nullptr; + SHAMapTreeNodePtr* newChildren = nullptr; + SHAMapTreeNodePtr* oldChildren = nullptr; std::uint8_t newNumAllocated = 0; // structured bindings can't be captured in c++ 17; use tie instead std::tie(newNumAllocated, newHashes, newChildren) = newHashesAndChildren.getHashesAndChildren(); @@ -445,8 +445,7 @@ inline TaggedPointer::TaggedPointer( // new arrays are dense, old arrays are sparse iterNonEmptyChildIndexes(isBranch, [&](auto branchNum, auto indexNum) { new (&newHashes[branchNum]) SHAMapHash{oldHashes[indexNum]}; - new (&newChildren[branchNum]) - intr_ptr::SharedPtr{std::move(oldChildren[indexNum])}; + new (&newChildren[branchNum]) SHAMapTreeNodePtr{std::move(oldChildren[indexNum])}; }); // Run the constructors for the remaining elements for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) @@ -454,7 +453,7 @@ inline TaggedPointer::TaggedPointer( if (((1 << i) & isBranch) != 0) continue; new (&newHashes[i]) SHAMapHash{}; - new (&newChildren[i]) intr_ptr::SharedPtr{}; + new (&newChildren[i]) SHAMapTreeNodePtr{}; } } else @@ -464,14 +463,14 @@ inline TaggedPointer::TaggedPointer( iterNonEmptyChildIndexes(isBranch, [&](auto branchNum, auto indexNum) { new (&newHashes[curCompressedIndex]) SHAMapHash{oldHashes[indexNum]}; new (&newChildren[curCompressedIndex]) - intr_ptr::SharedPtr{std::move(oldChildren[indexNum])}; + SHAMapTreeNodePtr{std::move(oldChildren[indexNum])}; ++curCompressedIndex; }); // Run the constructors for the remaining elements for (int i = curCompressedIndex; i < newNumAllocated; ++i) { new (&newHashes[i]) SHAMapHash{}; - new (&newChildren[i]) intr_ptr::SharedPtr{}; + new (&newChildren[i]) SHAMapTreeNodePtr{}; } } @@ -485,7 +484,7 @@ inline TaggedPointer::TaggedPointer(std::uint8_t numChildren) for (std::size_t i = 0; i < numAllocated; ++i) { new (&hashes[i]) SHAMapHash{}; - new (&children[i]) intr_ptr::SharedPtr{}; + new (&children[i]) SHAMapTreeNodePtr{}; } } @@ -523,14 +522,13 @@ TaggedPointer::isDense() const return (tp_ & kTagMask) == kBoundaries.size() - 1; } -[[nodiscard]] inline std::tuple*> +[[nodiscard]] inline std::tuple TaggedPointer::getHashesAndChildren() const { auto const [tag, ptr] = decode(); auto const hashes = reinterpret_cast(ptr); std::uint8_t const numAllocated = kBoundaries[tag]; - auto const children = - reinterpret_cast*>(hashes + numAllocated); + auto const children = reinterpret_cast(hashes + numAllocated); return {numAllocated, hashes, children}; }; @@ -540,7 +538,7 @@ TaggedPointer::getHashes() const return reinterpret_cast(tp_ & kPtrMask); }; -[[nodiscard]] inline intr_ptr::SharedPtr* +[[nodiscard]] inline SHAMapTreeNodePtr* TaggedPointer::getChildren() const { auto [unused1, unused2, result] = getHashesAndChildren(); diff --git a/include/xrpl/tx/ApplyContext.h b/include/xrpl/tx/ApplyContext.h index 910ec6be42..8540037601 100644 --- a/include/xrpl/tx/ApplyContext.h +++ b/include/xrpl/tx/ApplyContext.h @@ -93,8 +93,8 @@ public: std::function const& before, - std::shared_ptr const& after)> const& func); + SLE::const_ref before, + SLE::const_ref after)> const& func); void destroyXRP(XRPAmount const& fee) diff --git a/include/xrpl/tx/SignerEntries.h b/include/xrpl/tx/SignerEntries.h index 91fc4bd030..af0c9b9d28 100644 --- a/include/xrpl/tx/SignerEntries.h +++ b/include/xrpl/tx/SignerEntries.h @@ -1,11 +1,11 @@ #pragma once -#include // #include // beast::Journal #include // temMALFORMED #include // AccountID #include // NotTEC +#include #include #include @@ -60,7 +60,7 @@ public: // obj Contains a SignerEntries field that is an STArray. // journal For reporting error conditions. // annotation Source of SignerEntries, like "ledger" or "transaction". - static Expected, NotTEC> + static std::expected, NotTEC> deserialize(STObject const& obj, beast::Journal journal, std::string_view annotation); }; diff --git a/include/xrpl/tx/Transactor.h b/include/xrpl/tx/Transactor.h index 61d943c4d5..02fedfe970 100644 --- a/include/xrpl/tx/Transactor.h +++ b/include/xrpl/tx/Transactor.h @@ -7,6 +7,8 @@ #include #include +#include +#include #include namespace xrpl { @@ -185,6 +187,10 @@ public: static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx); + // Returns the base fee plus extra base fee units, not scaled for load. + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx, std::uint32_t extraBaseFeeMultiplier); + /* Do NOT define an invokePreflight function in a derived class. Instead, define: @@ -222,8 +228,63 @@ public: return tesSUCCESS; } + /** + * This function can be overridden to introduce additional semantic constraints beyond the + * granular template validation for granular permissions. It is called by the base + * invokeCheckPermission method only after the transaction has successfully passed + * checkGranularSandbox. + */ static NotTEC - checkPermission(ReadView const& view, STTx const& tx); + checkGranularSemantics( + ReadView const& view, + STTx const& tx, + std::unordered_set const& heldGranularPermissions) + { + return tesSUCCESS; + } + + /** + * Checks whether the transaction is authorized to be executed by the delegated account. + * This function enforces the strict permission check hierarchy. It is explicitly + * designed NOT to be overridden. Derived transactors must instead implement + * checkGranularSemantics to add custom validation logic for granular permissions. + * + * The evaluation proceeds as follows: + * - If transaction-level permission is granted, the function immediately returns tesSUCCESS. + * - If transaction-level permission is not granted, the function checks whether the transaction + * matches the granular permission template defined in permissions.macro. If it does, it then + * calls checkGranularSemantics to perform any additional, fine-grained validation. + * + */ + template + static NotTEC + invokeCheckPermission(ReadView const& view, STTx const& tx) + { + // heldGranularPermissions is passed by reference into checkPermission. + // It is populated with the sender’s granular permissions only when the sender + // lacks tx-level permission but has granular permissions that satisfy the + // granular permission template. + // + // - result is terNO_DELEGATE_PERMISSION: return immediately. + // - result is tesSUCCESS and heldGranularPermissions is empty: tx-level permission was + // granted, so we returned success before populating it. + // - result is tesSUCCESS and heldGranularPermissions is not empty: tx-level permission was + // not granted, but the held granular permissions passed checkGranularSandbox, so we proceed + // to checkGranularSemantics. + // + // WARNING: Do not simplify checkPermission to return only + // heldGranularPermissions or the ter code. Both the result and the + // populated set are required to enforce the strict permission hierarchy + // described above. + std::unordered_set heldGranularPermissions; + if (NotTEC const result = checkPermission(view, tx, heldGranularPermissions); + !isTesSuccess(result) || heldGranularPermissions.empty()) + { + return result; + } + + return T::checkGranularSemantics(view, tx, heldGranularPermissions); + } ///////////////////////////////////////////////////// // Interface used by AccountDelete @@ -263,10 +324,7 @@ protected: * to detect deletions. */ virtual void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) = 0; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) = 0; /** Check transaction-specific post-conditions after all entries have * been visited. @@ -356,20 +414,32 @@ protected: unit::ValueUnit min = unit::ValueUnit{}); private: + static NotTEC + checkPermission( + ReadView const& view, + STTx const& tx, + std::unordered_set& heldGranularPermissions); + std::pair reset(XRPAmount fee); TER consumeSeqProxy(SLE::pointer const& sleAccount); + TER payFee(); + + std::tuple + processPersistentChanges(TER result, XRPAmount fee); + static NotTEC checkSingleSign( ReadView const& view, AccountID const& idSigner, AccountID const& idAccount, - std::shared_ptr sleAccount, + SLE::const_pointer sleAccount, beast::Journal const j); + static NotTEC checkMultiSign( ReadView const& view, @@ -398,6 +468,15 @@ private: static NotTEC preflight2(PreflightContext const& ctx); + /** Universal validations + - Valid MPTAmount and XRPAmount + + Do not try to call preflightUniversal from preflight() in derived classes. See + the description of invokePreflight for details. + */ + static NotTEC + preflightUniversal(PreflightContext const& ctx); + /** Check transaction-specific invariants only. * * Walks every modified ledger entry via visitInvariantEntry, then @@ -463,6 +542,9 @@ Transactor::invokePreflight(PreflightContext const& ctx) if (auto const ret = preflight1(ctx, T::getFlagsMask(ctx))) return ret; + if (auto const ret = preflightUniversal(ctx)) + return ret; + if (auto const ret = T::preflight(ctx)) return ret; diff --git a/include/xrpl/tx/invariants/AMMInvariant.h b/include/xrpl/tx/invariants/AMMInvariant.h index 43d9c5ad0a..4b56370774 100644 --- a/include/xrpl/tx/invariants/AMMInvariant.h +++ b/include/xrpl/tx/invariants/AMMInvariant.h @@ -15,14 +15,16 @@ class ValidAMM std::optional ammAccount_; std::optional lptAMMBalanceAfter_; std::optional lptAMMBalanceBefore_; + std::optional lptAMMBalanceBeforeDeletion_; bool ammPoolChanged_{false}; + bool ammDeleted_{false}; public: enum class ZeroAllowed : bool { No = false, Yes = true }; ValidAMM() = default; void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); @@ -35,12 +37,17 @@ private: [[nodiscard]] bool finalizeCreate(STTx const&, ReadView const&, bool enforce, beast::Journal const&) const; [[nodiscard]] bool - finalizeDelete(bool enforce, TER res, beast::Journal const&) const; + finalizeDelete(bool enforce, bool enforceAMMDelete, TER res, beast::Journal const&) const; [[nodiscard]] bool finalizeDeposit(STTx const&, ReadView const&, bool enforce, beast::Journal const&) const; // Includes clawback [[nodiscard]] bool - finalizeWithdraw(STTx const&, ReadView const&, bool enforce, beast::Journal const&) const; + finalizeWithdraw( + STTx const&, + ReadView const&, + bool enforce, + bool enforceAMMDelete, + beast::Journal const&) const; [[nodiscard]] bool finalizeDEX(bool enforce, beast::Journal const&) const; [[nodiscard]] bool diff --git a/include/xrpl/tx/invariants/DirectoryInvariant.h b/include/xrpl/tx/invariants/DirectoryInvariant.h new file mode 100644 index 0000000000..96643ea465 --- /dev/null +++ b/include/xrpl/tx/invariants/DirectoryInvariant.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +class ValidBookDirectory +{ + bool badBookDirectory_ = false; + hash_set rootIndexes_; + +public: + void + visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + + bool + finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/invariants/FreezeInvariant.h b/include/xrpl/tx/invariants/FreezeInvariant.h index 645f444462..a76eb66497 100644 --- a/include/xrpl/tx/invariants/FreezeInvariant.h +++ b/include/xrpl/tx/invariants/FreezeInvariant.h @@ -22,7 +22,7 @@ class TransfersNotFrozen { struct BalanceChange { - std::shared_ptr const line; + SLE::const_pointer const line; int const balanceChangeSign; }; @@ -35,37 +35,34 @@ class TransfersNotFrozen using ByIssuer = std::map; ByIssuer balanceChanges_; - std::map const> possibleIssuers_; + std::map possibleIssuers_; public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); private: bool - isValidEntry(std::shared_ptr const& before, std::shared_ptr const& after); + isValidEntry(SLE::const_ref before, SLE::const_ref after); static STAmount - calculateBalanceChange( - std::shared_ptr const& before, - std::shared_ptr const& after, - bool isDelete); + calculateBalanceChange(SLE::const_ref before, SLE::const_ref after, bool isDelete); void recordBalance(Issue const& issue, BalanceChange change); void - recordBalanceChanges(std::shared_ptr const& after, STAmount const& balanceChange); + recordBalanceChanges(SLE::const_ref after, STAmount const& balanceChange); - std::shared_ptr + SLE::const_pointer findIssuer(AccountID const& issuerID, ReadView const& view); static bool validateIssuerChanges( - std::shared_ptr const& issuer, + SLE::const_ref issuer, IssuerChanges const& changes, STTx const& tx, beast::Journal const& j, diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index bc9608fd56..8931d189fd 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -69,10 +70,7 @@ public: * @param after ledger entry after modification by the transaction */ void - visitEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after); + visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after); /** * @brief called after all ledger entries have been visited to determine @@ -110,7 +108,7 @@ class TransactionFeeCheck { public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); static bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); @@ -130,7 +128,7 @@ class XRPNotCreated public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; @@ -150,7 +148,7 @@ class AccountRootsNotDeleted public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; @@ -173,11 +171,11 @@ class AccountRootsDeletedClean // deleted, it can still be found. After is used specifically for any checks // that are expected as part of the deletion, such as zeroing out the // balance. - std::vector, std::shared_ptr>> accountsDeleted_; + std::vector> accountsDeleted_; public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); @@ -196,7 +194,7 @@ class XRPBalanceChecks public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; @@ -213,7 +211,7 @@ class LedgerEntryTypesMatch public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; @@ -231,7 +229,7 @@ class NoXRPTrustLines public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; @@ -250,7 +248,7 @@ class NoDeepFreezeTrustLinesWithoutFreeze public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; @@ -269,7 +267,7 @@ class NoBadOffers public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; @@ -285,7 +283,7 @@ class NoZeroEscrow public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; @@ -305,7 +303,7 @@ class ValidNewAccountRoot public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; @@ -326,7 +324,7 @@ class ValidClawback public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; @@ -346,7 +344,7 @@ class ValidPseudoAccounts public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); @@ -366,12 +364,27 @@ class NoModifiedUnmodifiableFields public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); }; +/** Verify that MPT/XRP STAmounts are canonical in any ledger entries left after the + * transaction applies. + */ +class ValidAmounts +{ + std::vector> afterEntries_; + +public: + void + visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + + [[nodiscard]] bool + finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; +}; + // additional invariant checks can be declared above and then added to this // tuple using InvariantChecks = std::tuple< @@ -393,13 +406,17 @@ using InvariantChecks = std::tuple< ValidMPTIssuance, ValidPermissionedDomain, ValidPermissionedDEX, + ValidBookDirectory, ValidAMM, NoModifiedUnmodifiableFields, ValidPseudoAccounts, ValidLoanBroker, ValidLoan, ValidVault, - ValidMPTPayment>; + ValidConfidentialMPToken, + ValidMPTPayment, + ValidAmounts, + ValidMPTTransfer>; /** * @brief get a tuple of all invariant checks diff --git a/include/xrpl/tx/invariants/LoanBrokerInvariant.h b/include/xrpl/tx/invariants/LoanBrokerInvariant.h index e7d14a638b..684bbff423 100644 --- a/include/xrpl/tx/invariants/LoanBrokerInvariant.h +++ b/include/xrpl/tx/invariants/LoanBrokerInvariant.h @@ -46,7 +46,7 @@ class ValidLoanBroker public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); diff --git a/include/xrpl/tx/invariants/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index bda9c51653..3f408d169a 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -23,7 +23,7 @@ class ValidLoan public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); diff --git a/include/xrpl/tx/invariants/MPTInvariant.h b/include/xrpl/tx/invariants/MPTInvariant.h index 7f405814e7..aa90f1b8ef 100644 --- a/include/xrpl/tx/invariants/MPTInvariant.h +++ b/include/xrpl/tx/invariants/MPTInvariant.h @@ -2,10 +2,13 @@ #include #include +#include #include #include #include +#include +#include namespace xrpl { @@ -20,18 +23,55 @@ class ValidMPTIssuance // MPToken by an issuer bool mptCreatedByIssuer_ = false; -public: - void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + /// sfReferenceHolding is intended to be set exactly once at vault + /// creation and immutable thereafter; true when that rule was violated. + bool referenceHoldingSetOnCreate_ = false; + /// True when sfReferenceHolding was mutated on an existing MPTokenIssuance. + bool referenceHoldingMutated_ = false; + + /// MPTokens and RippleStates deleted during apply. finalize() checks each + /// holder's AccountRoot to detect vault pseudo-account holdings deleted + /// outside VaultDelete. All these checks are gated on fixCleanup3_2_0. + std::vector> deletedHoldings_; + +public: + /** + * @brief Track MPT issuance and holding creations, deletions, and + * mutations. + * + * @param isDelete Whether the ledger entry is being deleted. + * @param before The ledger entry before transaction application. + * @param after The ledger entry after transaction application. + */ + void + visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after); + + /** + * @brief Verify MPT issuance invariants after transaction application. + * + * @param tx The transaction being checked. + * @param result The transaction result code. + * @param fee The fee charged by the transaction. + * @param view The ledger view after transaction application. + * @param j Journal used for diagnostics. + * @return true if the invariant checks pass, otherwise false. + */ [[nodiscard]] bool - finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; + finalize( + STTx const& tx, + TER const result, + XRPAmount const fee, + ReadView const& view, + beast::Journal const& j) const; }; -/** Verify: - * - OutstandingAmount <= MaximumAmount for any MPT - * - OutstandingAmount after = OutstandingAmount before + - * sum (MPT after - MPT before) - this is total MPT credit/debit +/** + * @brief Verify public MPT amount and outstanding amount accounting. + * + * Checks that OutstandingAmount does not exceed MaximumAmount and that + * OutstandingAmount after application equals OutstandingAmount before + * application plus the net holder balance delta. */ class ValidMPTPayment { @@ -49,11 +89,173 @@ class ValidMPTPayment hash_map data_; public: + /** + * @brief Track MPT amount and outstanding amount changes. + * + * @param isDelete Whether the ledger entry is being deleted. + * @param before The ledger entry before transaction application. + * @param after The ledger entry after transaction application. + */ void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after); + /** + * @brief Verify public MPT payment accounting invariants. + * + * @param tx The transaction being checked. + * @param result The transaction result code. + * @param fee The fee charged by the transaction. + * @param view The ledger view after transaction application. + * @param j Journal used for diagnostics. + * @return true if the invariant checks pass, otherwise false. + */ bool - finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); + finalize( + STTx const& tx, + TER const result, + XRPAmount const fee, + ReadView const& view, + beast::Journal const& j); +}; + +/** + * @brief Invariants: Confidential MPToken consistency + * + * - Convert/ConvertBack symmetry: + * Regular MPToken balance change (±X) == COA (Confidential Outstanding Amount) change (∓X) + * - Cannot delete MPToken with non-zero confidential state: + * Cannot delete if sfIssuerEncryptedBalance exists + * Cannot delete if sfConfidentialBalanceInbox and sfConfidentialBalanceSpending exist + * - Privacy flag consistency: + * MPToken confidential balance fields can only be created or changed if + * lsfMPTCanHoldConfidentialBalance is set on the issuance. + * - Encrypted field existence consistency: + * If sfConfidentialBalanceSpending/sfConfidentialBalanceInbox exists, then + * sfIssuerEncryptedBalance must also exist (and vice versa). If + * sfAuditorEncryptedBalance exists, then those core encrypted balance fields + * must also exist. + * - COA <= OutstandingAmount: + * Confidential outstanding balance cannot exceed total outstanding. + * - Verifies sfConfidentialBalanceVersion is changed whenever sfConfidentialBalanceSpending is + * modified on an MPToken. + */ +class ValidConfidentialMPToken +{ + struct Changes + { + std::int64_t mptAmountDelta = 0; + std::int64_t coaDelta = 0; + std::int64_t outstandingDelta = 0; + SLE::const_pointer issuance; + bool deletedWithEncrypted = false; + bool badConsistency = false; + bool badCOA = false; + bool changesConfidentialFields = false; + bool badVersion = false; + }; + std::map changes_; + +public: + /** + * @brief Track confidential MPT balance, issuance, and version changes. + * + * @param isDelete Whether the ledger entry is being deleted. + * @param before The ledger entry before transaction application. + * @param after The ledger entry after transaction application. + */ + void + visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after); + + /** + * @brief Verify confidential MPT accounting and encrypted-field + * invariants. + * + * @param tx The transaction being checked. + * @param result The transaction result code. + * @param fee The fee charged by the transaction. + * @param view The ledger view after transaction application. + * @param j Journal used for diagnostics. + * @return true if the invariant checks pass, otherwise false. + */ + bool + finalize( + STTx const& tx, + TER const result, + XRPAmount const fee, + ReadView const& view, + beast::Journal const& j); +}; + +class ValidMPTTransfer +{ + struct Value + { + std::optional amtBefore; + std::optional amtAfter; + }; + // MPTID: {holder: Value} + hash_map> amount_; + // Deleted MPToken + // MPToken key: true if MPTAuthorized is set + hash_map deletedAuthorized_; + +public: + /** + * @brief Track MPT balance changes and deleted authorization state. + * + * @param isDelete Whether the ledger entry is being deleted. + * @param before The ledger entry before transaction application. + * @param after The ledger entry after transaction application. + */ + void + visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after); + + /** + * @brief Verify MPT transfer authorization invariants. + * + * @param tx The transaction being checked. + * @param result The transaction result code. + * @param fee The fee charged by the transaction. + * @param view The ledger view after transaction application. + * @param j Journal used for diagnostics. + * @return true if the invariant checks pass, otherwise false. + */ + bool + finalize( + STTx const& tx, + TER const result, + XRPAmount const fee, + ReadView const& view, + beast::Journal const& j); + +private: + /** + * @brief Check whether a holder is authorized to send or receive an MPToken. + * + * Deleted MPToken SLEs are no longer present in the view by the time + * finalize() runs, so their authorization state is captured during + * visitEntry() and stored in deletedAuthorized_. For deleted MPTokens, + * returns true if reqAuth is false or lsfMPTAuthorized was set at deletion. + * For existing MPTokens, returns the result of requireAuth(). + * + * @param view The ledger view after transaction application. + * @param mptid The MPToken issuance ID. + * @param holder The holder account being checked. + * @param requireAuth Whether the issuance requires explicit authorization. + * @return true if the holder is authorized, otherwise false. + */ + [[nodiscard]] bool + isAuthorized( + ReadView const& view, + MPTID const& mptid, + AccountID const& holder, + bool requireAuth) const; }; } // namespace xrpl diff --git a/include/xrpl/tx/invariants/NFTInvariant.h b/include/xrpl/tx/invariants/NFTInvariant.h index fa056ecbb0..698df05247 100644 --- a/include/xrpl/tx/invariants/NFTInvariant.h +++ b/include/xrpl/tx/invariants/NFTInvariant.h @@ -33,7 +33,7 @@ class ValidNFTokenPage public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; @@ -61,7 +61,7 @@ class NFTokenCountTracking public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; diff --git a/include/xrpl/tx/invariants/PermissionedDEXInvariant.h b/include/xrpl/tx/invariants/PermissionedDEXInvariant.h index 3784a32840..2ec22ded88 100644 --- a/include/xrpl/tx/invariants/PermissionedDEXInvariant.h +++ b/include/xrpl/tx/invariants/PermissionedDEXInvariant.h @@ -10,14 +10,15 @@ namespace xrpl { class ValidPermissionedDEX { - bool regularOffers_ = false; - bool badHybridsOld_ = false; // pre-fixCleanup3_1_3: missing field/domain or size > 1 - bool badHybrids_ = false; // post-fixCleanup3_1_3: also catches size == 0 (size != 1) + bool regularOffersOld_ = false; // pre-fixCleanup3_2_0: also flags deleted offers + bool regularOffers_ = false; // post-fixCleanup3_2_0: excludes deleted offers + bool badHybridsOld_ = false; // pre-fixCleanup3_1_3: missing field/domain or size > 1 + bool badHybrids_ = false; // post-fixCleanup3_1_3: also catches size == 0 (size != 1) hash_set domains_; public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); diff --git a/include/xrpl/tx/invariants/PermissionedDomainInvariant.h b/include/xrpl/tx/invariants/PermissionedDomainInvariant.h index 2475ed8f6b..19edcc0b39 100644 --- a/include/xrpl/tx/invariants/PermissionedDomainInvariant.h +++ b/include/xrpl/tx/invariants/PermissionedDomainInvariant.h @@ -32,7 +32,7 @@ class ValidPermissionedDomain public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index ab55cd086a..2a9ffc8282 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -4,9 +4,11 @@ #include #include #include +#include #include #include #include +#include #include #include @@ -79,16 +81,83 @@ private: std::vector beforeMPTs_; std::unordered_map deltas_; + /** + * @brief Compute the minimum STAmount scale for rounding invariant + * calculations. + * + * Post-amendment (@c fixCleanup3_2_0) this is simply the posterior + * @c assetsTotal scale. Pre-amendment it is the coarsest scale across + * @p vaultDelta and both asset-field deltas. + * + * @param vaultDelta Delta of the vault's asset balance for this transaction. + * @param rules Active ledger rules (used to check the amendment). + * @returns The minimum scale to apply when rounding vault-related amounts. + */ + [[nodiscard]] std::int32_t + computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const; + + /** + * @brief Return the vault-asset balance-change delta for an account. + * + * Looks up the ledger-entry delta recorded during @c visitEntry for the + * account entry (XRP), trust line (IOU), or MPToken (MPT) that corresponds + * to the vault asset held by @p id. + * + * @param id Account whose asset delta is requested. + * @returns The delta, or @c std::nullopt if the entry was not touched. + */ + [[nodiscard]] std::optional + deltaAssets(AccountID const& id) const; + + /** + * @brief Return the vault-asset delta for the transaction's sending + * account, adjusted for the fee. + * + * Calls @c deltaAssets for @c tx[sfAccount] and, for non-delegated XRP + * transactions, adds the consumed fee back so the invariant sees the net + * asset movement rather than the fee-reduced balance change. + * + * @param tx The transaction being applied. + * @param fee Fee charged by this transaction. + * @returns The fee-adjusted delta, or @c std::nullopt if the net delta is + * zero or the account entry was not touched. + */ + [[nodiscard]] std::optional + deltaAssetsTxAccount(STTx const& tx, XRPAmount fee) const; + + /** + * @brief Return the vault-share balance-change delta for an account. + * + * For the vault's pseudo-account the @c MPTokenIssuance outstanding-amount + * delta is returned; for all other accounts the @c MPToken delta is + * returned. + * + * @param id Account whose share delta is requested. + * @returns The delta, or @c std::nullopt if the entry was not touched. + */ + [[nodiscard]] std::optional + deltaShares(AccountID const& id) const; + + /** + * @brief Check whether a vault holds no assets. + * + * @param vault Snapshot of the vault to test. + * @returns @c true when both @c assetsAvailable and @c assetsTotal are + * zero. + */ + [[nodiscard]] static bool + isVaultEmpty(Vault const& vault); + public: - void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); - - bool - finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); - // Compute the coarsest scale required to represent all numbers [[nodiscard]] static std::int32_t computeCoarsestScale(std::vector const& numbers); + + void + visitEntry(bool, SLE::const_ref, SLE::const_ref); + + bool + finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); }; } // namespace xrpl diff --git a/include/xrpl/tx/paths/BookTip.h b/include/xrpl/tx/paths/BookTip.h index e06a2da86c..c4bdb0415c 100644 --- a/include/xrpl/tx/paths/BookTip.h +++ b/include/xrpl/tx/paths/BookTip.h @@ -21,7 +21,7 @@ private: uint256 end_; uint256 dir_; uint256 index_; - std::shared_ptr entry_; + SLE::pointer entry_; Quality quality_{}; public: diff --git a/include/xrpl/tx/paths/detail/AmountSpec.h b/include/xrpl/tx/paths/detail/AmountSpec.h deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/include/xrpl/tx/paths/detail/FlowDebugInfo.h b/include/xrpl/tx/paths/detail/FlowDebugInfo.h index ec7df86e53..1ccfba34ce 100644 --- a/include/xrpl/tx/paths/detail/FlowDebugInfo.h +++ b/include/xrpl/tx/paths/detail/FlowDebugInfo.h @@ -3,7 +3,6 @@ #include #include #include -#include #include diff --git a/include/xrpl/tx/paths/detail/StepChecks.h b/include/xrpl/tx/paths/detail/StepChecks.h index fea9f90a31..4955c4f8e6 100644 --- a/include/xrpl/tx/paths/detail/StepChecks.h +++ b/include/xrpl/tx/paths/detail/StepChecks.h @@ -27,7 +27,7 @@ checkFreeze( } } - if (auto sle = view.read(keylet::line(src, dst, currency))) + if (auto sle = view.read(keylet::trustLine(src, dst, currency))) { if (sle->isFlag((dst > src) ? lsfHighFreeze : lsfLowFreeze)) { @@ -71,8 +71,8 @@ checkNoRipple( beast::Journal j) { // fetch the ripple lines into and out of this node - auto sleIn = view.read(keylet::line(prev, cur, currency)); - auto sleOut = view.read(keylet::line(cur, next, currency)); + auto sleIn = view.read(keylet::trustLine(prev, cur, currency)); + auto sleOut = view.read(keylet::trustLine(cur, next, currency)); if (!sleIn || !sleOut) return terNO_LINE; diff --git a/include/xrpl/tx/paths/detail/StrandFlow.h b/include/xrpl/tx/paths/detail/StrandFlow.h index 31f0182258..4d988a4c7e 100644 --- a/include/xrpl/tx/paths/detail/StrandFlow.h +++ b/include/xrpl/tx/paths/detail/StrandFlow.h @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include diff --git a/include/xrpl/tx/transactors/account/AccountDelete.h b/include/xrpl/tx/transactors/account/AccountDelete.h index d2cbfa5ad2..16661a4b7c 100644 --- a/include/xrpl/tx/transactors/account/AccountDelete.h +++ b/include/xrpl/tx/transactors/account/AccountDelete.h @@ -29,10 +29,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/account/AccountSet.h b/include/xrpl/tx/transactors/account/AccountSet.h index 002779db64..91b38e7968 100644 --- a/include/xrpl/tx/transactors/account/AccountSet.h +++ b/include/xrpl/tx/transactors/account/AccountSet.h @@ -23,9 +23,6 @@ public: static NotTEC preflight(PreflightContext const& ctx); - static NotTEC - checkPermission(ReadView const& view, STTx const& tx); - static TER preclaim(PreclaimContext const& ctx); @@ -33,10 +30,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/account/SetRegularKey.h b/include/xrpl/tx/transactors/account/SetRegularKey.h index a9f1ce715d..6ff9c5aa52 100644 --- a/include/xrpl/tx/transactors/account/SetRegularKey.h +++ b/include/xrpl/tx/transactors/account/SetRegularKey.h @@ -23,10 +23,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/account/SignerListSet.h b/include/xrpl/tx/transactors/account/SignerListSet.h index 760f6e9358..46e3191323 100644 --- a/include/xrpl/tx/transactors/account/SignerListSet.h +++ b/include/xrpl/tx/transactors/account/SignerListSet.h @@ -42,10 +42,7 @@ public: preCompute() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/bridge/XChainBridge.h b/include/xrpl/tx/transactors/bridge/XChainBridge.h index 1033dee188..58a546de2f 100644 --- a/include/xrpl/tx/transactors/bridge/XChainBridge.h +++ b/include/xrpl/tx/transactors/bridge/XChainBridge.h @@ -28,10 +28,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( @@ -64,10 +61,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( @@ -111,10 +105,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( @@ -152,10 +143,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( @@ -195,10 +183,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( @@ -238,10 +223,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( @@ -272,10 +254,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( @@ -330,10 +309,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/check/CheckCancel.h b/include/xrpl/tx/transactors/check/CheckCancel.h index 5fdaa7e527..b8e8b6c52d 100644 --- a/include/xrpl/tx/transactors/check/CheckCancel.h +++ b/include/xrpl/tx/transactors/check/CheckCancel.h @@ -23,10 +23,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/check/CheckCash.h b/include/xrpl/tx/transactors/check/CheckCash.h index ad9de1e4c3..7d4e615cfd 100644 --- a/include/xrpl/tx/transactors/check/CheckCash.h +++ b/include/xrpl/tx/transactors/check/CheckCash.h @@ -26,10 +26,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/check/CheckCreate.h b/include/xrpl/tx/transactors/check/CheckCreate.h index e03677e5f5..178fe4707c 100644 --- a/include/xrpl/tx/transactors/check/CheckCreate.h +++ b/include/xrpl/tx/transactors/check/CheckCreate.h @@ -26,10 +26,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/credentials/CredentialAccept.h b/include/xrpl/tx/transactors/credentials/CredentialAccept.h index 97838f8a0a..8630ac3f7f 100644 --- a/include/xrpl/tx/transactors/credentials/CredentialAccept.h +++ b/include/xrpl/tx/transactors/credentials/CredentialAccept.h @@ -26,10 +26,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/credentials/CredentialCreate.h b/include/xrpl/tx/transactors/credentials/CredentialCreate.h index 7493aa4dc5..91b5e829d3 100644 --- a/include/xrpl/tx/transactors/credentials/CredentialCreate.h +++ b/include/xrpl/tx/transactors/credentials/CredentialCreate.h @@ -26,10 +26,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/credentials/CredentialDelete.h b/include/xrpl/tx/transactors/credentials/CredentialDelete.h index 4d9b4ddd18..70fe5cb3d0 100644 --- a/include/xrpl/tx/transactors/credentials/CredentialDelete.h +++ b/include/xrpl/tx/transactors/credentials/CredentialDelete.h @@ -26,10 +26,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/delegate/DelegateSet.h b/include/xrpl/tx/transactors/delegate/DelegateSet.h index c93c48e970..a55524ecff 100644 --- a/include/xrpl/tx/transactors/delegate/DelegateSet.h +++ b/include/xrpl/tx/transactors/delegate/DelegateSet.h @@ -23,10 +23,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( @@ -38,7 +35,7 @@ public: // Interface used by AccountDelete static TER - deleteDelegate(ApplyView& view, std::shared_ptr const& sle, beast::Journal j); + deleteDelegate(ApplyView& view, SLE::ref sle, beast::Journal j); }; } // namespace xrpl diff --git a/include/xrpl/tx/transactors/dex/AMMBid.h b/include/xrpl/tx/transactors/dex/AMMBid.h index 9328c48e79..dfa50d06ba 100644 --- a/include/xrpl/tx/transactors/dex/AMMBid.h +++ b/include/xrpl/tx/transactors/dex/AMMBid.h @@ -64,10 +64,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/dex/AMMClawback.h b/include/xrpl/tx/transactors/dex/AMMClawback.h index 7ac03ebb28..6f31480490 100644 --- a/include/xrpl/tx/transactors/dex/AMMClawback.h +++ b/include/xrpl/tx/transactors/dex/AMMClawback.h @@ -29,10 +29,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/dex/AMMCreate.h b/include/xrpl/tx/transactors/dex/AMMCreate.h index 04d6fe6f60..64d2a1e3b1 100644 --- a/include/xrpl/tx/transactors/dex/AMMCreate.h +++ b/include/xrpl/tx/transactors/dex/AMMCreate.h @@ -60,10 +60,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/dex/AMMDelete.h b/include/xrpl/tx/transactors/dex/AMMDelete.h index ff5776e3b7..d3e8cfeeb4 100644 --- a/include/xrpl/tx/transactors/dex/AMMDelete.h +++ b/include/xrpl/tx/transactors/dex/AMMDelete.h @@ -32,10 +32,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/dex/AMMDeposit.h b/include/xrpl/tx/transactors/dex/AMMDeposit.h index 9be53167f2..453046ddad 100644 --- a/include/xrpl/tx/transactors/dex/AMMDeposit.h +++ b/include/xrpl/tx/transactors/dex/AMMDeposit.h @@ -64,10 +64,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/dex/AMMVote.h b/include/xrpl/tx/transactors/dex/AMMVote.h index 1b5946aae1..8defc1369e 100644 --- a/include/xrpl/tx/transactors/dex/AMMVote.h +++ b/include/xrpl/tx/transactors/dex/AMMVote.h @@ -49,10 +49,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/dex/AMMWithdraw.h b/include/xrpl/tx/transactors/dex/AMMWithdraw.h index 9e6eb62d51..8b6d39349b 100644 --- a/include/xrpl/tx/transactors/dex/AMMWithdraw.h +++ b/include/xrpl/tx/transactors/dex/AMMWithdraw.h @@ -72,10 +72,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( @@ -155,13 +152,18 @@ public: static std::pair deleteAMMAccountIfEmpty( Sandbox& sb, - std::shared_ptr const ammSle, + SLE::pointer const ammSle, STAmount const& lpTokenBalance, Asset const& asset1, Asset const& asset2, beast::Journal const& journal); private: + /** Returns IgnoreFreeze when the withdrawer is the issuer of a pool + * asset (post-fixCleanup3_3_0), ZeroIfFrozen otherwise. */ + [[nodiscard]] FreezeHandling + issuerFreezeHandling() const; + std::pair applyGuts(Sandbox& view); diff --git a/include/xrpl/tx/transactors/dex/OfferCancel.h b/include/xrpl/tx/transactors/dex/OfferCancel.h index b2641049e6..2806b6942f 100644 --- a/include/xrpl/tx/transactors/dex/OfferCancel.h +++ b/include/xrpl/tx/transactors/dex/OfferCancel.h @@ -24,10 +24,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/dex/OfferCreate.h b/include/xrpl/tx/transactors/dex/OfferCreate.h index d1ac8b5626..7faf613d3a 100644 --- a/include/xrpl/tx/transactors/dex/OfferCreate.h +++ b/include/xrpl/tx/transactors/dex/OfferCreate.h @@ -41,10 +41,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( @@ -81,10 +78,11 @@ private: TER applyHybrid( Sandbox& sb, - std::shared_ptr sleOffer, + STLedgerEntry::pointer sleOffer, Keylet const& offerIndex, STAmount const& saTakerPays, STAmount const& saTakerGets, + std::uint64_t openRate, std::function)> const& setDir); }; diff --git a/include/xrpl/tx/transactors/did/DIDDelete.h b/include/xrpl/tx/transactors/did/DIDDelete.h index c750d4c95e..94615f51b6 100644 --- a/include/xrpl/tx/transactors/did/DIDDelete.h +++ b/include/xrpl/tx/transactors/did/DIDDelete.h @@ -20,16 +20,13 @@ public: deleteSLE(ApplyContext& ctx, Keylet sleKeylet, AccountID const owner); static TER - deleteSLE(ApplyView& view, std::shared_ptr sle, AccountID const owner, beast::Journal j); + deleteSLE(ApplyView& view, SLE::pointer sle, AccountID const owner, beast::Journal j); TER doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/did/DIDSet.h b/include/xrpl/tx/transactors/did/DIDSet.h index b2c3d97c81..8c84ea6c9b 100644 --- a/include/xrpl/tx/transactors/did/DIDSet.h +++ b/include/xrpl/tx/transactors/did/DIDSet.h @@ -20,10 +20,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/escrow/EscrowCancel.h b/include/xrpl/tx/transactors/escrow/EscrowCancel.h index 92b55374d5..af09f70202 100644 --- a/include/xrpl/tx/transactors/escrow/EscrowCancel.h +++ b/include/xrpl/tx/transactors/escrow/EscrowCancel.h @@ -23,10 +23,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/escrow/EscrowCreate.h b/include/xrpl/tx/transactors/escrow/EscrowCreate.h index 8682ed7369..8800e97b80 100644 --- a/include/xrpl/tx/transactors/escrow/EscrowCreate.h +++ b/include/xrpl/tx/transactors/escrow/EscrowCreate.h @@ -16,6 +16,9 @@ public: static TxConsequences makeTxConsequences(PreflightContext const& ctx); + static bool + checkExtraFeatures(PreflightContext const& ctx); + static NotTEC preflight(PreflightContext const& ctx); @@ -26,10 +29,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/escrow/EscrowFinish.h b/include/xrpl/tx/transactors/escrow/EscrowFinish.h index 806f947b8b..061fa0527c 100644 --- a/include/xrpl/tx/transactors/escrow/EscrowFinish.h +++ b/include/xrpl/tx/transactors/escrow/EscrowFinish.h @@ -32,10 +32,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/lending/LoanBrokerCoverClawback.h b/include/xrpl/tx/transactors/lending/LoanBrokerCoverClawback.h index 1b86ac41e7..81ea97ce7e 100644 --- a/include/xrpl/tx/transactors/lending/LoanBrokerCoverClawback.h +++ b/include/xrpl/tx/transactors/lending/LoanBrokerCoverClawback.h @@ -26,10 +26,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/lending/LoanBrokerCoverDeposit.h b/include/xrpl/tx/transactors/lending/LoanBrokerCoverDeposit.h index 63e96457dc..43b932726b 100644 --- a/include/xrpl/tx/transactors/lending/LoanBrokerCoverDeposit.h +++ b/include/xrpl/tx/transactors/lending/LoanBrokerCoverDeposit.h @@ -26,10 +26,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.h b/include/xrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.h index e3182c0851..a757ac51bf 100644 --- a/include/xrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.h +++ b/include/xrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.h @@ -26,10 +26,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/lending/LoanBrokerDelete.h b/include/xrpl/tx/transactors/lending/LoanBrokerDelete.h index 464a43e398..0ce5f29387 100644 --- a/include/xrpl/tx/transactors/lending/LoanBrokerDelete.h +++ b/include/xrpl/tx/transactors/lending/LoanBrokerDelete.h @@ -26,10 +26,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/lending/LoanBrokerSet.h b/include/xrpl/tx/transactors/lending/LoanBrokerSet.h index 72a339951b..75175e2dd6 100644 --- a/include/xrpl/tx/transactors/lending/LoanBrokerSet.h +++ b/include/xrpl/tx/transactors/lending/LoanBrokerSet.h @@ -29,10 +29,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/lending/LoanDelete.h b/include/xrpl/tx/transactors/lending/LoanDelete.h index 8dd9c4601b..9e8c3c172a 100644 --- a/include/xrpl/tx/transactors/lending/LoanDelete.h +++ b/include/xrpl/tx/transactors/lending/LoanDelete.h @@ -26,10 +26,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/lending/LoanManage.h b/include/xrpl/tx/transactors/lending/LoanManage.h index 08c873a5e7..d2344c0ec0 100644 --- a/include/xrpl/tx/transactors/lending/LoanManage.h +++ b/include/xrpl/tx/transactors/lending/LoanManage.h @@ -60,10 +60,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/lending/LoanPay.h b/include/xrpl/tx/transactors/lending/LoanPay.h index 561550e889..9be9695c64 100644 --- a/include/xrpl/tx/transactors/lending/LoanPay.h +++ b/include/xrpl/tx/transactors/lending/LoanPay.h @@ -32,10 +32,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/lending/LoanSet.h b/include/xrpl/tx/transactors/lending/LoanSet.h index 304c077f3d..d277629e44 100644 --- a/include/xrpl/tx/transactors/lending/LoanSet.h +++ b/include/xrpl/tx/transactors/lending/LoanSet.h @@ -39,10 +39,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/nft/NFTokenAcceptOffer.h b/include/xrpl/tx/transactors/nft/NFTokenAcceptOffer.h index 3c98d55141..c5cd10fa6a 100644 --- a/include/xrpl/tx/transactors/nft/NFTokenAcceptOffer.h +++ b/include/xrpl/tx/transactors/nft/NFTokenAcceptOffer.h @@ -11,10 +11,10 @@ private: pay(AccountID const& from, AccountID const& to, STAmount const& amount); TER - acceptOffer(std::shared_ptr const& offer); + acceptOffer(SLE::ref offer); TER - bridgeOffers(std::shared_ptr const& buy, std::shared_ptr const& sell); + bridgeOffers(SLE::ref buy, SLE::ref sell); TER transferNFToken(AccountID const& buyer, AccountID const& seller, uint256 const& nfTokenID); @@ -36,10 +36,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/nft/NFTokenBurn.h b/include/xrpl/tx/transactors/nft/NFTokenBurn.h index 4665a17aa7..849d09cb7e 100644 --- a/include/xrpl/tx/transactors/nft/NFTokenBurn.h +++ b/include/xrpl/tx/transactors/nft/NFTokenBurn.h @@ -23,10 +23,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/nft/NFTokenCancelOffer.h b/include/xrpl/tx/transactors/nft/NFTokenCancelOffer.h index 3eae44b389..a74a1c3e59 100644 --- a/include/xrpl/tx/transactors/nft/NFTokenCancelOffer.h +++ b/include/xrpl/tx/transactors/nft/NFTokenCancelOffer.h @@ -23,10 +23,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/nft/NFTokenCreateOffer.h b/include/xrpl/tx/transactors/nft/NFTokenCreateOffer.h index 20ccefc2cb..c874381dd0 100644 --- a/include/xrpl/tx/transactors/nft/NFTokenCreateOffer.h +++ b/include/xrpl/tx/transactors/nft/NFTokenCreateOffer.h @@ -26,10 +26,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/nft/NFTokenMint.h b/include/xrpl/tx/transactors/nft/NFTokenMint.h index 1a0deae29d..9267e8e801 100644 --- a/include/xrpl/tx/transactors/nft/NFTokenMint.h +++ b/include/xrpl/tx/transactors/nft/NFTokenMint.h @@ -31,10 +31,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/nft/NFTokenModify.h b/include/xrpl/tx/transactors/nft/NFTokenModify.h index 5b197d72b4..0d18e4a6d4 100644 --- a/include/xrpl/tx/transactors/nft/NFTokenModify.h +++ b/include/xrpl/tx/transactors/nft/NFTokenModify.h @@ -23,10 +23,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/oracle/OracleDelete.h b/include/xrpl/tx/transactors/oracle/OracleDelete.h index e83a334b87..c16d5fb2a9 100644 --- a/include/xrpl/tx/transactors/oracle/OracleDelete.h +++ b/include/xrpl/tx/transactors/oracle/OracleDelete.h @@ -32,10 +32,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( @@ -46,11 +43,7 @@ public: beast::Journal const& j) override; static TER - deleteOracle( - ApplyView& view, - std::shared_ptr const& sle, - AccountID const& account, - beast::Journal j); + deleteOracle(ApplyView& view, SLE::ref sle, AccountID const& account, beast::Journal j); }; } // namespace xrpl diff --git a/include/xrpl/tx/transactors/oracle/OracleSet.h b/include/xrpl/tx/transactors/oracle/OracleSet.h index 12c022470b..831c11b8c4 100644 --- a/include/xrpl/tx/transactors/oracle/OracleSet.h +++ b/include/xrpl/tx/transactors/oracle/OracleSet.h @@ -32,10 +32,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/payment/DepositPreauth.h b/include/xrpl/tx/transactors/payment/DepositPreauth.h index fbf22f8ca8..742b1ef3f7 100644 --- a/include/xrpl/tx/transactors/payment/DepositPreauth.h +++ b/include/xrpl/tx/transactors/payment/DepositPreauth.h @@ -26,10 +26,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/payment/Payment.h b/include/xrpl/tx/transactors/payment/Payment.h index ef42b67fa4..dd792aa1c2 100644 --- a/include/xrpl/tx/transactors/payment/Payment.h +++ b/include/xrpl/tx/transactors/payment/Payment.h @@ -32,7 +32,10 @@ public: preflight(PreflightContext const& ctx); static NotTEC - checkPermission(ReadView const& view, STTx const& tx); + checkGranularSemantics( + ReadView const& view, + STTx const& tx, + std::unordered_set const& heldGranularPermissions); static TER preclaim(PreclaimContext const& ctx); @@ -41,10 +44,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/payment_channel/PaymentChannelClaim.h b/include/xrpl/tx/transactors/payment_channel/PaymentChannelClaim.h index 98d4638e51..e13fea6d6c 100644 --- a/include/xrpl/tx/transactors/payment_channel/PaymentChannelClaim.h +++ b/include/xrpl/tx/transactors/payment_channel/PaymentChannelClaim.h @@ -29,10 +29,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/payment_channel/PaymentChannelCreate.h b/include/xrpl/tx/transactors/payment_channel/PaymentChannelCreate.h index 73059a7e46..56e984cd57 100644 --- a/include/xrpl/tx/transactors/payment_channel/PaymentChannelCreate.h +++ b/include/xrpl/tx/transactors/payment_channel/PaymentChannelCreate.h @@ -26,10 +26,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/payment_channel/PaymentChannelFund.h b/include/xrpl/tx/transactors/payment_channel/PaymentChannelFund.h index 587ee9f778..272076ff20 100644 --- a/include/xrpl/tx/transactors/payment_channel/PaymentChannelFund.h +++ b/include/xrpl/tx/transactors/payment_channel/PaymentChannelFund.h @@ -23,10 +23,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/permissioned_domain/PermissionedDomainDelete.h b/include/xrpl/tx/transactors/permissioned_domain/PermissionedDomainDelete.h index 77834f7683..88883fb86f 100644 --- a/include/xrpl/tx/transactors/permissioned_domain/PermissionedDomainDelete.h +++ b/include/xrpl/tx/transactors/permissioned_domain/PermissionedDomainDelete.h @@ -24,10 +24,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.h b/include/xrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.h index 47c35800dd..4afa8cef5a 100644 --- a/include/xrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.h +++ b/include/xrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.h @@ -27,10 +27,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/system/Batch.h b/include/xrpl/tx/transactors/system/Batch.h index e190714725..43f0103319 100644 --- a/include/xrpl/tx/transactors/system/Batch.h +++ b/include/xrpl/tx/transactors/system/Batch.h @@ -34,10 +34,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/system/Change.h b/include/xrpl/tx/transactors/system/Change.h index 33df426593..339723ae8e 100644 --- a/include/xrpl/tx/transactors/system/Change.h +++ b/include/xrpl/tx/transactors/system/Change.h @@ -19,10 +19,7 @@ public: preCompute() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/system/LedgerStateFix.h b/include/xrpl/tx/transactors/system/LedgerStateFix.h index 8d704467eb..973f89faa9 100644 --- a/include/xrpl/tx/transactors/system/LedgerStateFix.h +++ b/include/xrpl/tx/transactors/system/LedgerStateFix.h @@ -9,6 +9,7 @@ class LedgerStateFix : public Transactor public: enum class FixType : std::uint16_t { NfTokenPageLink = 1, + BookExchangeRate = 2, }; static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; @@ -30,10 +31,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/system/TicketCreate.h b/include/xrpl/tx/transactors/system/TicketCreate.h index 4991d1d08b..5783faa6d1 100644 --- a/include/xrpl/tx/transactors/system/TicketCreate.h +++ b/include/xrpl/tx/transactors/system/TicketCreate.h @@ -61,10 +61,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/token/Clawback.h b/include/xrpl/tx/transactors/token/Clawback.h index 7c99cef0d2..ed90776e59 100644 --- a/include/xrpl/tx/transactors/token/Clawback.h +++ b/include/xrpl/tx/transactors/token/Clawback.h @@ -23,10 +23,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTClawback.h b/include/xrpl/tx/transactors/token/ConfidentialMPTClawback.h new file mode 100644 index 0000000000..ff6f76e8ea --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTClawback.h @@ -0,0 +1,59 @@ +#pragma once + +#include + +namespace xrpl { + +/** + * @brief Allows an MPT issuer to clawback confidential balances from a holder. + * + * This transaction enables the issuer of an MPToken Issuance (with clawback + * enabled) to reclaim confidential tokens from a holder's account. Unlike + * regular clawback, the issuer cannot see the holder's balance directly. + * Instead, the issuer must provide a zero-knowledge proof that demonstrates + * they know the exact encrypted balance amount. + * + * @par Cryptographic Operations: + * - **Equality Proof Verification**: Verifies that the issuer's revealed + * amount matches the holder's encrypted balance using the issuer's + * ElGamal private key. + * + * @see ConfidentialMPTSend, ConfidentialMPTConvert + */ +class ConfidentialMPTClawback : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit ConfidentialMPTClawback(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTConvert.h b/include/xrpl/tx/transactors/token/ConfidentialMPTConvert.h new file mode 100644 index 0000000000..2e2591844f --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTConvert.h @@ -0,0 +1,61 @@ +#pragma once + +#include + +namespace xrpl { + +/** + * @brief Converts public (plaintext) MPT balance to confidential (encrypted) + * balance. + * + * This transaction allows a token holder to convert their publicly visible + * MPToken balance into an encrypted confidential balance. Once converted, + * the balance can only be spent using ConfidentialMPTSend transactions and + * remains hidden from public view on the ledger. + * + * @par Cryptographic Operations: + * - **Schnorr Proof Verification**: When registering a new ElGamal public key, + * verifies proof of knowledge of the corresponding private key. + * - **Revealed Amount Verification**: Verifies that the provided encrypted + * amounts (for holder, issuer, and optionally auditor) all encrypt the + * same plaintext amount using the provided blinding factor. + * + * @see ConfidentialMPTConvertBack, ConfidentialMPTSend + */ +class ConfidentialMPTConvert : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit ConfidentialMPTConvert(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTConvertBack.h b/include/xrpl/tx/transactors/token/ConfidentialMPTConvertBack.h new file mode 100644 index 0000000000..cb4b6295a0 --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTConvertBack.h @@ -0,0 +1,62 @@ +#pragma once + +#include + +namespace xrpl { + +/** + * @brief Converts confidential (encrypted) MPT balance back to public + * (plaintext) balance. + * + * This transaction allows a token holder to convert their encrypted + * confidential balance back into a publicly visible MPToken balance. The + * holder must prove they have sufficient confidential balance without + * revealing the actual balance amount. + * + * @par Cryptographic Operations: + * - **Revealed Amount Verification**: Verifies that the provided encrypted + * amounts correctly encrypt the conversion amount. + * - **Pedersen Linkage Proof**: Verifies that the provided balance commitment + * correctly links to the holder's encrypted spending balance. + * - **Bulletproof Range Proof**: Verifies that the remaining balance (after + * conversion) is non-negative, ensuring the holder has sufficient funds. + * + * @see ConfidentialMPTConvert, ConfidentialMPTSend + */ +class ConfidentialMPTConvertBack : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit ConfidentialMPTConvertBack(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTMergeInbox.h b/include/xrpl/tx/transactors/token/ConfidentialMPTMergeInbox.h new file mode 100644 index 0000000000..585c273e12 --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTMergeInbox.h @@ -0,0 +1,63 @@ +#pragma once + +#include + +namespace xrpl { + +/** + * @brief Merges the confidential inbox balance into the spending balance. + * + * In the confidential transfer system, incoming funds are deposited into an + * "inbox" balance that the recipient cannot immediately spend. This prevents + * front-running attacks where an attacker could invalidate a pending + * transaction by sending funds to the sender. This transaction merges the + * inbox into the spending balance, making those funds available for spending. + * + * @par Cryptographic Operations: + * - **Homomorphic Addition**: Adds the encrypted inbox balance to the + * encrypted spending balance using ElGamal homomorphic properties. + * - **Zero Encryption**: Resets the inbox to an encryption of zero. + * + * @note This transaction requires no zero-knowledge proofs because it only + * combines encrypted values that the holder already owns. The + * homomorphic properties of ElGamal encryption ensure correctness. + * + * @see ConfidentialMPTSend, ConfidentialMPTConvert + */ +class ConfidentialMPTMergeInbox : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit ConfidentialMPTMergeInbox(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTSend.h b/include/xrpl/tx/transactors/token/ConfidentialMPTSend.h new file mode 100644 index 0000000000..72599ab987 --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTSend.h @@ -0,0 +1,72 @@ +#pragma once + +#include + +namespace xrpl { + +/** + * @brief Transfers confidential MPT tokens between holders privately. + * + * This transaction enables private token transfers where the transfer amount + * is hidden from public view. Both sender and recipient must have initialized + * confidential balances. The transaction provides encrypted amounts for all + * parties (sender, destination, issuer, and optionally auditor) along with + * zero-knowledge proofs that verify correctness without revealing the amount. + * + * @par Cryptographic Operations: + * - **Multi-Ciphertext Equality Proof**: Verifies that all encrypted amounts + * (sender, destination, issuer, auditor) encrypt the same plaintext value. + * - **Amount Pedersen Linkage Proof**: Verifies that the amount commitment + * correctly links to the sender's encrypted amount. + * - **Balance Pedersen Linkage Proof**: Verifies that the balance commitment + * correctly links to the sender's encrypted spending balance. + * - **Bulletproof Range Proof**: Verifies remaining balance and + * transfer amount are non-negative. + * + * @note Funds are deposited into the destination's inbox, not spending + * balance. The recipient must call ConfidentialMPTMergeInbox to make + * received funds spendable. + * + * @see ConfidentialMPTMergeInbox, ConfidentialMPTConvert, + * ConfidentialMPTConvertBack + */ +class ConfidentialMPTSend : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit ConfidentialMPTSend(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/token/MPTokenAuthorize.h b/include/xrpl/tx/transactors/token/MPTokenAuthorize.h index b2cfa74f6e..e30d123e52 100644 --- a/include/xrpl/tx/transactors/token/MPTokenAuthorize.h +++ b/include/xrpl/tx/transactors/token/MPTokenAuthorize.h @@ -35,10 +35,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h b/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h index 3861c19afc..d946587e32 100644 --- a/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h +++ b/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h @@ -1,28 +1,34 @@ #pragma once -#include #include #include +#include + namespace xrpl { +// NOLINTBEGIN(readability-redundant-member-init) struct MPTCreateArgs { std::optional priorBalance; AccountID const& account; std::uint32_t sequence = 0; std::uint32_t flags = 0; - std::optional maxAmount = - std::nullopt; // NOLINT(readability-redundant-member-init) - std::optional assetScale = - std::nullopt; // NOLINT(readability-redundant-member-init) - std::optional transferFee = - std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional maxAmount = std::nullopt; + std::optional assetScale = std::nullopt; + std::optional transferFee = std::nullopt; std::optional const& metadata{}; - std::optional domainId = std::nullopt; // NOLINT(readability-redundant-member-init) - std::optional mutableFlags = - std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional domainId = std::nullopt; + std::optional mutableFlags = std::nullopt; + // Set only by callers that issue an MPT representing a wrapped asset + // (e.g. VaultCreate's share token). The keylet must point to an + // existing MPToken or RippleState owned by `account`. Surfaces on + // the resulting MPTokenIssuance via the optional sfReferenceHolding + // field. Used by readers (canTransfer, canTrade, freezing) to + // inherit the underlying asset's transferability. + std::optional referenceHolding = std::nullopt; }; +// NOLINTEND(readability-redundant-member-init) class MPTokenIssuanceCreate : public Transactor { @@ -46,10 +52,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( @@ -59,7 +62,7 @@ public: ReadView const& view, beast::Journal const& j) override; - static Expected + static std::expected create(ApplyView& view, beast::Journal journal, MPTCreateArgs const& args); }; diff --git a/include/xrpl/tx/transactors/token/MPTokenIssuanceDestroy.h b/include/xrpl/tx/transactors/token/MPTokenIssuanceDestroy.h index 65682c8f5e..21032e7337 100644 --- a/include/xrpl/tx/transactors/token/MPTokenIssuanceDestroy.h +++ b/include/xrpl/tx/transactors/token/MPTokenIssuanceDestroy.h @@ -23,10 +23,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h b/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h index 7397183bbf..428c573e2f 100644 --- a/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h +++ b/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h @@ -22,9 +22,6 @@ public: static NotTEC preflight(PreflightContext const& ctx); - static NotTEC - checkPermission(ReadView const& view, STTx const& tx); - static TER preclaim(PreclaimContext const& ctx); @@ -32,10 +29,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/token/TrustSet.h b/include/xrpl/tx/transactors/token/TrustSet.h index d439b676b5..d719f06326 100644 --- a/include/xrpl/tx/transactors/token/TrustSet.h +++ b/include/xrpl/tx/transactors/token/TrustSet.h @@ -21,7 +21,10 @@ public: preflight(PreflightContext const& ctx); static NotTEC - checkPermission(ReadView const& view, STTx const& tx); + checkGranularSemantics( + ReadView const& view, + STTx const& tx, + std::unordered_set const& heldGranularPermissions); static TER preclaim(PreclaimContext const& ctx); @@ -30,10 +33,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/vault/VaultClawback.h b/include/xrpl/tx/transactors/vault/VaultClawback.h index 4b9e283571..2ff97abca2 100644 --- a/include/xrpl/tx/transactors/vault/VaultClawback.h +++ b/include/xrpl/tx/transactors/vault/VaultClawback.h @@ -2,6 +2,8 @@ #include +#include + namespace xrpl { class VaultClawback : public Transactor @@ -23,10 +25,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( @@ -37,10 +36,10 @@ public: beast::Journal const& j) override; private: - Expected, TER> + std::expected, TER> assetsToClawback( - std::shared_ptr const& vault, - std::shared_ptr const& sleShareIssuance, + SLE::ref vault, + SLE::const_ref sleShareIssuance, AccountID const& holder, STAmount const& clawbackAmount); }; diff --git a/include/xrpl/tx/transactors/vault/VaultCreate.h b/include/xrpl/tx/transactors/vault/VaultCreate.h index bbe80b49c1..9b11f97957 100644 --- a/include/xrpl/tx/transactors/vault/VaultCreate.h +++ b/include/xrpl/tx/transactors/vault/VaultCreate.h @@ -29,10 +29,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/vault/VaultDelete.h b/include/xrpl/tx/transactors/vault/VaultDelete.h index d8fccd6024..b8bb3c4096 100644 --- a/include/xrpl/tx/transactors/vault/VaultDelete.h +++ b/include/xrpl/tx/transactors/vault/VaultDelete.h @@ -23,10 +23,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/vault/VaultDeposit.h b/include/xrpl/tx/transactors/vault/VaultDeposit.h index f1a81c928e..523b3f2e53 100644 --- a/include/xrpl/tx/transactors/vault/VaultDeposit.h +++ b/include/xrpl/tx/transactors/vault/VaultDeposit.h @@ -23,10 +23,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/vault/VaultSet.h b/include/xrpl/tx/transactors/vault/VaultSet.h index 48178f5048..5c362d5db4 100644 --- a/include/xrpl/tx/transactors/vault/VaultSet.h +++ b/include/xrpl/tx/transactors/vault/VaultSet.h @@ -26,10 +26,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/include/xrpl/tx/transactors/vault/VaultWithdraw.h b/include/xrpl/tx/transactors/vault/VaultWithdraw.h index 7461752ff2..7bbe06187d 100644 --- a/include/xrpl/tx/transactors/vault/VaultWithdraw.h +++ b/include/xrpl/tx/transactors/vault/VaultWithdraw.h @@ -23,10 +23,7 @@ public: doApply() override; void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) override; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; [[nodiscard]] bool finalizeInvariants( diff --git a/nix/ci-env.nix b/nix/ci-env.nix index d8021fe0bd..f823f71de0 100644 --- a/nix/ci-env.nix +++ b/nix/ci-env.nix @@ -1,39 +1,119 @@ { pkgs, - glibc231, + customGlibc, ... }: let inherit (import ./packages.nix { inherit pkgs; }) commonPackages; + inherit (pkgs) lib; - # binutils wrapped to emit binaries that reference glibc 2.31 (dynamic - # linker path, library search path, RPATH). - binutils231 = pkgs.wrapBintoolsWith { + # Underlying compiler toolchains to wrap. Bump these in one place to + # roll the whole environment forward. + customGccPackage = pkgs.gcc15; + customLlvmPackages = pkgs.llvmPackages_22; + customClangMajor = lib.versions.major (lib.getVersion customLlvmPackages.clang-unwrapped); + + # binutils wrapped to emit binaries that reference the custom glibc + # (dynamic linker path, library search path, RPATH). + customBinutils = pkgs.wrapBintoolsWith { bintools = pkgs.binutils-unwrapped; - libc = glibc231; + libc = customGlibc; }; - # Rebuild gcc 15 (specifically libstdc++ / libgcc_s) against glibc 2.31. - # The override swaps gcc15.cc's bootstrap stdenv for one that uses the - # existing gcc 15 binary but links against glibc 2.31, so the resulting - # compiler ships runtime libraries that only reference symbols available - # in glibc 2.31. - gcc15CcWithGlibc231 = pkgs.gcc15.cc.override { + # Rebuild gcc (specifically libstdc++ / libgcc_s) against the custom + # glibc. The override swaps gcc.cc's bootstrap stdenv for one that uses + # the existing gcc binary but links against the custom glibc, so the + # resulting compiler ships runtime libraries that only reference symbols + # available in that glibc. + customGccCc = customGccPackage.cc.override { stdenv = pkgs.stdenvAdapters.overrideCC pkgs.stdenv ( pkgs.wrapCCWith { - cc = pkgs.gcc15.cc; - libc = glibc231; - bintools = binutils231; + cc = customGccPackage.cc; + libc = customGlibc; + bintools = customBinutils; } ); }; - # cc-wrapper around the rebuilt compiler, pointing at glibc 2.31 headers - # and libraries. This is what we actually expose to users. - gcc15WithGlibc231 = pkgs.wrapCCWith { - cc = gcc15CcWithGlibc231; - libc = glibc231; - bintools = binutils231; + # cc-wrapper around the rebuilt compiler, pointing at the custom glibc + # headers and libraries. This is what we actually expose to users. + customGcc = pkgs.wrapCCWith { + cc = customGccCc; + libc = customGlibc; + bintools = customBinutils; + }; + + # gcov ships in gcc's `cc` output, but the cc-wrapper doesn't expose it. + # Surface the gcov from our rebuilt gcc (linked against the custom glibc, so + # it runs under the loader installed in the image) and matching the exact + # compiler version, so gcovr can produce coverage reports in the CI env. + customGcov = pkgs.runCommand "gcov-custom-for-ci-env" { } '' + mkdir -p "$out/bin" + ln -s "${customGccCc}/bin/gcov" "$out/bin/gcov" + ''; + + # stdenv built around the rebuilt gcc / custom glibc. Used to rebuild + # compiler-rt below so its sanitizer runtimes see the custom glibc + # headers. + customStdenv = pkgs.stdenvAdapters.overrideCC pkgs.stdenv customGcc; + + # Rebuild compiler-rt against the custom glibc so the sanitizer runtimes + # don't use glibc symbols (or sysconf constants like _SC_SIGSTKSZ) that + # only exist in newer glibc versions. scudo is dropped because its CMake + # includes CheckAtomic with -nostdinc++ in CMAKE_REQUIRED_FLAGS, which + # makes std::atomic unfindable in our stdenv; we don't use scudo (only + # asan/ubsan/tsan etc.). + customCompilerRt = + (customLlvmPackages.compiler-rt.override { + stdenv = customStdenv; + }).overrideAttrs + (old: { + postPatch = (old.postPatch or "") + '' + substituteInPlace lib/CMakeLists.txt \ + --replace-quiet 'add_subdirectory(scudo/standalone)' \ + '# scudo/standalone disabled in xrpld ci-env' + ''; + }); + + # cc-wrapper around clang, pointing at the custom glibc headers and + # libraries. Reuses the rebuilt gcc for libstdc++ / libgcc_s so that + # C++ binaries produced by clang also only reference symbols available + # in the custom glibc. compiler-rt is wired into a resource-root so + # sanitizer runtimes (libclang_rt.*.a) are found at link time; this + # mirrors what nixpkgs does internally when building llvmPackages.clang. + customClang = pkgs.wrapCCWith { + cc = customLlvmPackages.clang-unwrapped; + libc = customGlibc; + bintools = customBinutils; + gccForLibs = customGccCc; + extraPackages = [ customCompilerRt ]; + extraBuildCommands = '' + rsrc="$out/resource-root" + mkdir "$rsrc" + ln -s "${customLlvmPackages.clang-unwrapped.lib}/lib/clang/${customClangMajor}/include" "$rsrc/include" + ln -s "${customCompilerRt.out}/lib" "$rsrc/lib" + ln -s "${customCompilerRt.out}/share" "$rsrc/share" || true + echo "-resource-dir=$rsrc" >> $out/nix-support/cc-cflags + # compiler-rt ships the sanitizer/profile/xray interface headers (e.g. + # ) in its `dev` output. In a normal Nix + # build these reach the include path because compiler-rt is propagated + # via depsTargetTargetPropagated and stdenv's setup hooks add its + # dev/include. The CI image runs clang outside a Nix stdenv (binaries + # on PATH, no setup hooks), so that never happens; add the headers + # explicitly. gcc ships its own copy, which is why this is clang-only. + echo "-isystem ${customCompilerRt.dev}/include" >> $out/nix-support/cc-cflags + ''; + }; + + # Strip the generic cc/c++/cpp symlinks from the clang wrapper so it can + # coexist with the gcc wrapper in buildEnv. gcc remains the default + # compiler (cc/c++/cpp); clang is invoked explicitly as clang/clang++. + customClangForCiEnv = pkgs.symlinkJoin { + name = "clang-wrapper-custom-for-ci-env"; + paths = [ customClang ]; + postBuild = '' + rm -f $out/bin/cc $out/bin/c++ $out/bin/cpp + ''; }; in @@ -41,11 +121,17 @@ in default = pkgs.buildEnv { name = "xrpld-ci-env"; paths = commonPackages ++ [ - gcc15WithGlibc231 - binutils231 + customGcc + customGcov + customClangForCiEnv + customBinutils + # CA certificate bundle so HTTPS clients (git, curl, conan) can verify + # TLS connections without ca-certificates being installed in the system. + pkgs.cacert ]; pathsToLink = [ "/bin" + "/etc/ssl/certs" "/lib" "/include" "/share" diff --git a/nix/docker/Dockerfile b/nix/docker/Dockerfile new file mode 100644 index 0000000000..6d8980f897 --- /dev/null +++ b/nix/docker/Dockerfile @@ -0,0 +1,114 @@ +ARG BASE_IMAGE=nixos/nix:latest + +# Nix builder +FROM nixos/nix:latest AS builder-source + +RUN mkdir -p ~/.config/nix && \ + echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf + +# Copy our source and setup our working dir. +COPY nix/ci-env.nix /tmp/build/nix/ci-env.nix +COPY nix/packages.nix /tmp/build/nix/packages.nix +COPY nix/utils.nix /tmp/build/nix/utils.nix +COPY flake.nix /tmp/build/ +COPY flake.lock /tmp/build/ +WORKDIR /tmp/build + +FROM builder-source AS builder + +# Build our Nix CI environment (all build tools in a single store path) +RUN nix \ + --option filter-syscalls false \ + build + +# Copy the Nix store closure into a directory. The Nix store closure is the +# entire set of Nix store values that we need for our build. +RUN mkdir /tmp/nix-store-closure && \ + cp -R $(nix-store -qR result/) /tmp/nix-store-closure + +# Final image +FROM ${BASE_IMAGE} AS final + +ARG BASE_IMAGE + +# bash is not located at /bin/bash in nixos/nix, so we need to create a symlink to it. +RUN if echo "${BASE_IMAGE}" | grep -qiE 'nixos'; then \ + ln -s /root/.nix-profile/bin/bash /bin/bash; \ + fi + +# Use Bash as the default shell for RUN commands, using the options +# `set -o errexit -o pipefail`, and as the entrypoint. +SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] +ENTRYPOINT ["/bin/bash"] + +# Copy /nix/store and the env symlink tree +COPY --from=builder /tmp/nix-store-closure /nix/store +COPY --from=builder /tmp/build/result /nix/ci-env + +ENV PATH="/nix/ci-env/bin:${PATH}" + +# Point HTTPS clients (git, curl, conan, ...) at the CA bundle shipped in the +# Nix CI environment, so TLS verification works without ca-certificates being +# installed in the system. +ENV SSL_CERT_FILE="/nix/ci-env/etc/ssl/certs/ca-bundle.crt" +ENV GIT_SSL_CAINFO="/nix/ci-env/etc/ssl/certs/ca-bundle.crt" + +# Externally-built dynamically-linked ELF binaries hard-code the loader path +# (e.g. /lib64/ld-linux-x86-64.so.2) in their PT_INTERP header. Install it +# from the Nix store when the base image doesn't already provide one. +COPY nix/docker/loader-path.sh /tmp/loader-path.sh + +RUN <&2; exit 1; } + mkdir -p "$(dirname "${target}")" + cp "${src}" "${target}" +fi +EOF + +COPY bin/check-tools.sh /tmp/check-tools.sh +RUN /tmp/check-tools.sh + +# Sanity-check that the g++/clang++ are able to build binaries, including sanitizer-instrumented ones. +COPY nix/docker/test_files/cpp_sources/ /tmp/cpp_sources/ +COPY nix/docker/test_files/compile-cpp-sources.sh /tmp/compile-cpp-sources.sh +RUN /tmp/compile-cpp-sources.sh /tmp/cpp_sources /tmp/bins + +# Tester: start from a clean BASE_IMAGE, install sanitizer runtime libraries, +# and run the compiled test binaries to verify they execute correctly. +FROM ${BASE_IMAGE} AS tester + +ARG BASE_IMAGE + +# bash is not located at /bin/bash in nixos/nix, so we need to create a symlink to it. +RUN if echo "${BASE_IMAGE}" | grep -qiE 'nixos'; then \ + ln -s /root/.nix-profile/bin/bash /bin/bash; \ + fi + +SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] + +# Sanity-check that the built binaries run correctly in the vanilla base image, with the necessary sanitizer runtime libraries installed. +COPY bin/install-sanitizer-libs.sh /tmp/install-sanitizer-libs.sh +COPY nix/docker/test_files/run-test-binaries.sh /tmp/run-test-binaries.sh +COPY --from=final /tmp/bins /tmp/bins + +RUN <`. The `` is +selected through the `BASE_IMAGE` build argument; the base images are the +**oldest supported version** of each distribution we target: + +| Image | `BASE_IMAGE` | Notes | +| ------------ | -------------------------------------------- | -------------------------------------------------- | +| `nix-nixos` | `nixos/nix:latest` | Build/lint only; binaries are not run (see below). | +| `nix-ubuntu` | `ubuntu:20.04` | Oldest supported Ubuntu (glibc 2.31). | +| `nix-debian` | `debian:bookworm` | | +| `nix-rhel` | `registry.access.redhat.com/ubi9/ubi:latest` | | + +All images carry the full toolchain on `PATH` (via `/nix/ci-env/bin`) plus the +CA bundle shipped in the Nix environment, so HTTPS clients (git, curl, Conan) +work without `ca-certificates` being installed in the base image. + +## Build stages + +[`Dockerfile`](./Dockerfile) is a multi-stage build: + +1. **`builder`** — On a `nixos/nix` builder, evaluate the flake and build the + CI environment (`nix/ci-env.nix`). The resulting Nix store closure (the + complete set of store paths the toolchain depends on) is copied into a + staging directory. +2. **`final`** — Start from `BASE_IMAGE`, copy in the Nix store closure and the + `ci-env` symlink tree, and wire up `PATH` and the CA bundle. It then: + - installs the dynamic linker if the base image lacks one (see + [How libc is handled](#how-libc-is-handled)), + - runs [`bin/check-tools.sh`](../../bin/check-tools.sh) to verify every + expected tool is present and runnable, and + - compiles the C++ test programs in + [`test_files/`](./test_files) with both `g++` and `clang++`, and sanitizers. +3. **`tester`** — Start again from a clean `BASE_IMAGE` (no Nix toolchain), + install only the sanitizer runtime libraries + ([`install-sanitizer-libs.sh`](./install-sanitizer-libs.sh)), and run the + binaries compiled in `final`. This proves the binaries built with the Nix + toolchain actually run on a vanilla base image. On `nixos/nix` this step is + skipped (the binaries are patched for a conventional FHS loader). +4. **Output** — The final image is gated on the tester succeeding: it copies a + sentinel file out of `tester`, so a failed test run fails the whole build. + +## How libc is handled + +The goal is for binaries built in these images to run on the **oldest supported +base image** (Ubuntu 20.04, glibc 2.31) and newer — without the developer's Nix +toolchain being present at runtime. Two pieces make that work: + +- **Compilers linked against an old glibc.** The Nix CI environment does not use + nixpkgs' current glibc. Instead it pins a 2020 nixpkgs snapshot whose primary + glibc is **2.31** (matching Ubuntu 20.04), via the `nixpkgs-custom-glibc` + flake input. GCC, Clang, binutils and compiler-rt are all rebuilt/wrapped + against this custom glibc (see [`nix/ci-env.nix`](../ci-env.nix)). As a result + the libraries they emit (`libstdc++`, `libgcc_s`, the sanitizer runtimes) + reference only symbols available in glibc 2.31. + +- **An expected dynamic linker in the image.** + Binaries built in Nix environments reference a dynamic linker from Nix store paths, which won't be present in the base image. However, + [`loader-path.sh`](./loader-path.sh) reports the expected loader path for the + current architecture, so we can patch the binaries to use the correct loader. + +The build then verifies all of this end to end: the test programs in +`test_files/` (a regular binary plus ASan/TSan/UBSan variants) are compiled in +`final`, their `PT_INTERP` is patched to the target loader, and they are run in +the clean `tester` stage to confirm each emits the expected sanitizer +diagnostic on a stock base image. + +## Files + +| File | Purpose | +| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| [`./Dockerfile`](./Dockerfile) | Multi-stage build described above. | +| [`./loader-path.sh`](./loader-path.sh) | Print the dynamic-linker (`PT_INTERP`) path for the current architecture. | +| [`./test_files/`](./test_files) | C++ sources and scripts to compile and run the sanitizer smoke tests. | +| [`/bin/check-tools.sh`](../../bin/check-tools.sh) | Verify every expected tools are present and runnable. | +| [`/bin/install-sanitizer-libs.sh`](../../bin/install-sanitizer-libs.sh) | Install `libasan`/`libtsan`/`libubsan` runtimes on the supported base images. | diff --git a/nix/docker/loader-path.sh b/nix/docker/loader-path.sh new file mode 100755 index 0000000000..b8b9f0de51 --- /dev/null +++ b/nix/docker/loader-path.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +case "$(uname -m)" in + x86_64) LOADER=/lib64/ld-linux-x86-64.so.2 ;; + aarch64) LOADER=/lib/ld-linux-aarch64.so.1 ;; + *) + echo "Unsupported arch: $(uname -m)" >&2 + exit 1 + ;; +esac + +echo "${LOADER}" diff --git a/nix/docker/test_files/compile-cpp-sources.sh b/nix/docker/test_files/compile-cpp-sources.sh new file mode 100755 index 0000000000..c63e250c90 --- /dev/null +++ b/nix/docker/test_files/compile-cpp-sources.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Compile all C++ test binaries during the Docker image build. +# Each binary has the target system's ELF PT_INTERP (dynamic-linker path) +# baked in so it can run on the (potentially minimal) final BASE_IMAGE. + +set -eo pipefail + +src_dir="${1:?usage: $0 }" +dst_dir="${2:?usage: $0 }" + +loader="$(/tmp/loader-path.sh)" + +mkdir -p "${dst_dir}" + +function compile() { + local compiler="${1}" + local name="${2}" + local san_flag="${3:-}" + + local src="${src_dir}/${name}.cpp" + local binary="${dst_dir}/${name}-${compiler}" + + echo "=== Compiling ${name} with ${compiler} ===" + # Always statically link libstdc++ so the test binary does not depend on + # the host's libstdc++.so.6 version. + local compile_cmd="${compiler} -std=c++23 -O1 -g \ + -pthread \ + -static-libstdc++ \ + ${san_flag} \ + ${src} -o ${binary}" + echo "Compile cmd: ${compile_cmd}" + eval "${compile_cmd}" + + echo "=== Patching ${binary} to use ${loader} as PT_INTERP ===" + local patch_cmd="patchelf --set-interpreter ${loader} --remove-rpath ${binary}" + echo "Patch cmd: ${patch_cmd}" + eval "${patch_cmd}" +} + +declare -A sanitize=( + [regular]="" + + [asan]="-fsanitize=address" + [tsan]="-fsanitize=thread" + [ubsan]="-fsanitize=undefined -fno-sanitize-recover=all" +) + +for name in regular asan tsan ubsan; do + san_flag="${sanitize[${name}]}" + for compiler in g++ clang++; do + compile "${compiler}" "${name}" "${san_flag}" + done +done + +echo "=== All binaries compiled ===" + +ls -la "${dst_dir}" diff --git a/nix/docker/test_files/cpp_sources/asan.cpp b/nix/docker/test_files/cpp_sources/asan.cpp new file mode 100644 index 0000000000..deefdec79a --- /dev/null +++ b/nix/docker/test_files/cpp_sources/asan.cpp @@ -0,0 +1,35 @@ +#include +#include +#include + +// Regression test: the compiler-rt sanitizer interface headers must be on the +// include path. A bare on-PATH clang in the Nix CI env doesn't get them +// propagated automatically, so this include would fail to compile with clang++ +// if the env isn't wired up correctly. abseil hits the same include during +// sanitizer builds. LeakSanitizer ships with AddressSanitizer. +#include + +#if defined(__clang__) || defined(__GNUC__) +__attribute__((noinline)) +#elif defined(_MSC_VER) +__declspec(noinline) +#endif +int +read_after_free(volatile int* array, std::size_t index) +{ + std::atomic_signal_fence(std::memory_order_seq_cst); + int value = array[index]; + std::atomic_signal_fence(std::memory_order_seq_cst); + return value; +} + +int +main() +{ + int* array = new int[5]{10, 20, 30, 40, 50}; + delete[] array; + + std::cout << "Value at index 2: " << read_after_free(array, 2) << std::endl; + + return 0; +} diff --git a/nix/docker/test_files/cpp_sources/regular.cpp b/nix/docker/test_files/cpp_sources/regular.cpp new file mode 100644 index 0000000000..637dafa1fd --- /dev/null +++ b/nix/docker/test_files/cpp_sources/regular.cpp @@ -0,0 +1,28 @@ +#include +#include +#include +#include + +static std::mutex gMutex; + +void +worker(int id) +{ + std::lock_guard lock(gMutex); + std::cout << "Hello from thread " << id << "\n"; +} + +int +main() +{ + constexpr int kNumThreads = 10; + std::vector threads; + threads.reserve(kNumThreads); + for (int i = 0; i < kNumThreads; ++i) + threads.emplace_back(worker, i); + for (auto& t : threads) + t.join(); + + std::cout << "Hello from main thread\n"; + return 0; +} diff --git a/nix/docker/test_files/cpp_sources/tsan.cpp b/nix/docker/test_files/cpp_sources/tsan.cpp new file mode 100644 index 0000000000..34b0990a6d --- /dev/null +++ b/nix/docker/test_files/cpp_sources/tsan.cpp @@ -0,0 +1,26 @@ +#include +#include + +static int kCounter = 0; + +void +increment() +{ + for (int i = 0; i < 100'000; ++i) + { + ++kCounter; + } +} + +int +main() +{ + std::thread t1(increment); + std::thread t2(increment); + + t1.join(); + t2.join(); + + std::cout << "Final counter value: " << kCounter << std::endl; + return 0; +} diff --git a/nix/docker/test_files/cpp_sources/ubsan.cpp b/nix/docker/test_files/cpp_sources/ubsan.cpp new file mode 100644 index 0000000000..db86119070 --- /dev/null +++ b/nix/docker/test_files/cpp_sources/ubsan.cpp @@ -0,0 +1,13 @@ +#include +#include + +int +main() +{ + int maxInt = std::numeric_limits::max(); + int volatile one = 1; + std::cout << "Current max: " << maxInt << std::endl; + int overflowed = maxInt + one; + std::cout << "Overflowed result: " << overflowed << std::endl; + return 0; +} diff --git a/nix/docker/test_files/run-test-binaries.sh b/nix/docker/test_files/run-test-binaries.sh new file mode 100755 index 0000000000..a4aded7d77 --- /dev/null +++ b/nix/docker/test_files/run-test-binaries.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# Run pre-compiled sanitizer binaries and confirm each emits its expected diagnostic. +# Binaries must already exist in with the layout: +# -g++ and -clang++ for name in {regular,asan,tsan,ubsan} + +set -eo pipefail + +bins_dir="${1:?usage: $0 }" + +failed_binaries=() + +# Run a binary and verify its exit code and output. +# Usage: run +function run() { + local binary="${1}" + local expected_output="${2}" + local expected_rc="${3}" + + local out_file + out_file="$(mktemp)" + + echo "=== Run ${binary} ===" + set +e + "${binary}" >"${out_file}" 2>&1 + local rc=$? + set -e + + cat "${out_file}" + + local failed=0 + if [ "${expected_rc}" = "nonzero" ]; then + if [ "${rc}" -eq 0 ]; then + echo "ERROR: expected non-zero exit code from ${binary}, got ${rc}" >&2 + failed=1 + fi + elif [ "${rc}" -ne "${expected_rc}" ]; then + echo "ERROR: expected exit code ${expected_rc} from ${binary}, got ${rc}" >&2 + failed=1 + fi + + if ! grep -q "${expected_output}" "${out_file}"; then + echo "ERROR: expected '${expected_output}' from ${binary}" >&2 + failed=1 + fi + + if [ "${failed}" -eq 0 ]; then + echo "OK: '${expected_output}' detected" + else + failed_binaries+=("${binary}") + fi +} + +declare -A expect=( + [regular]="Hello from main thread" + + [asan]="heap-use-after-free" + [tsan]="data race" + [ubsan]="signed integer overflow" +) + +for compiler in g++ clang++; do + for name in regular asan tsan ubsan; do + binary="${bins_dir}/${name}-${compiler}" + + if [ "${name}" = "tsan" ] && [ "${compiler}" = "g++" ] && + grep -qi 'debian' /etc/os-release 2>/dev/null && + [ "$(uname -m)" = "aarch64" ]; then + echo "=== Skipping ${binary} (tsan-g++ unsupported on Debian ARM64) ===" + echo " NOTE: to enable it, add --security-opt seccomp=unconfined to your docker run command" + continue + fi + + if [ "${name}" = "regular" ]; then + expected_rc=0 + else + expected_rc=nonzero + fi + run "${binary}" "${expect[$name]}" "${expected_rc}" + done +done + +if [ "${#failed_binaries[@]}" -gt 0 ]; then + echo "ERROR: the following binaries failed:" >&2 + printf ' %s\n' "${failed_binaries[@]}" >&2 + exit 1 +fi diff --git a/nix/packages.nix b/nix/packages.nix index edfe302ec9..6202168733 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -9,19 +9,32 @@ in { commonPackages = with pkgs; [ ccache + clangbuildanalyzer cmake conan + curlMinimal # needed for codecov/codecov-action + doxygen + file # needed for cpack in Clio gcovr + gh git + git-cliff + git-lfs gnumake + gnupg # needed for signing commits & codecov/codecov-action + graphviz llvmPackages_22.clang-tools + less # needed for git diff mold + nettools # provides netstat, used to debug failures in CI ninja + patchelf perl # needed for openssl pkg-config pre-commit python3 runClangTidy vim + zip ]; } diff --git a/nix/utils.nix b/nix/utils.nix index 07ff169c44..d83e612c16 100644 --- a/nix/utils.nix +++ b/nix/utils.nix @@ -1,4 +1,4 @@ -{ nixpkgs, nixpkgs-glibc231 }: +{ nixpkgs, nixpkgs-custom-glibc }: function: nixpkgs.lib.genAttrs [ @@ -12,10 +12,10 @@ nixpkgs.lib.genAttrs function { pkgs = import nixpkgs { inherit system; }; # glibc 2.31 — matches the system libc on Ubuntu 20.04 LTS. Sourced - # from the nixpkgs snapshot pinned via the `nixpkgs-glibc231` flake - # input, so the build uses the compiler from that snapshot + # from the nixpkgs snapshot pinned via the `nixpkgs-custom-glibc` + # flake input, so the build uses the compiler from that snapshot # (gcc 9.3.0) along with the matching patches, configure flags, and # hardening defaults. - glibc231 = (import nixpkgs-glibc231 { inherit system; }).glibc; + customGlibc = (import nixpkgs-custom-glibc { inherit system; }).glibc; } ) diff --git a/package/Dockerfile b/package/Dockerfile new file mode 100644 index 0000000000..6cb2a09933 --- /dev/null +++ b/package/Dockerfile @@ -0,0 +1,14 @@ +ARG BASE_IMAGE=debian:bookworm + +FROM ${BASE_IMAGE} + +# Packaging runs in a vanilla distro image, so the tooling has to come +# from the distro's archive: debhelper for deb, rpm-build (and the +# systemd / find-debuginfo macros it depends on) for rpm. +# The container also uses git (real history) for +# build_pkg.sh's SOURCE_DATE_EPOCH; otherwise it falls back to a tarball +# download and the timestamp comes from wall-clock time. + +COPY package/install-packaging-tools.sh /tmp/install-packaging-tools.sh + +RUN /tmp/install-packaging-tools.sh diff --git a/package/README.md b/package/README.md index 2089e32e64..4b78106c4c 100644 --- a/package/README.md +++ b/package/README.md @@ -6,41 +6,37 @@ This directory contains all files needed to build RPM and Debian packages for `x ``` package/ - build_pkg.sh Staging and build script (called by CMake targets and CI) + build_pkg.sh Staging and build script (called by the CMake `package` target and CI) rpm/ - xrpld.spec RPM spec (xrpld_version/pkg_release passed via rpmbuild --define) - debian/ Debian control files (control, rules, install, links, conffiles, ...) + xrpld.spec RPM spec + debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, source/format) shared/ xrpld.service systemd unit file (used by both RPM and DEB) xrpld.sysusers sysusers.d config (used by both RPM and DEB) xrpld.tmpfiles tmpfiles.d config (used by both RPM and DEB) xrpld.logrotate logrotate config (installed to /etc/logrotate.d/xrpld) - update-xrpld auto-update script (installed to /usr/libexec/xrpld/, run by update-xrpld.timer) ``` ## Prerequisites Packaging targets and their container images are declared in [`.github/scripts/strategy-matrix/linux.json`](../.github/scripts/strategy-matrix/linux.json) -via a `"package": true` field on specific os entries. Today only -`linux/amd64` is emitted; the architecture is hardcoded in `generate.py` -and the workflow runner. The package format -(deb or rpm) is inferred at build time from the container's package manager -(`apt-get` -> deb, `dnf`/`yum` -> rpm). The image tag is composed as -`ghcr.io/xrplf/ci/{distro}-{version}:{compiler}-{cver}-sha-{image_sha}` — -the same scheme used by `reusable-build-test.yml`. Bump `image_sha` in -`linux.json` and both CI and local builds pick up the new image with no -workflow edits. +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). -| Package type | Image (derived from `linux.json`) | Tool required | -| ------------ | ---------------------------------------------------- | --------------------------------------------------------------- | -| RPM | `ghcr.io/xrplf/ci/rhel-9:gcc-12-sha-` | `rpmbuild` | -| DEB | `ghcr.io/xrplf/ci/ubuntu-jammy:gcc-12-sha-` | `dpkg-buildpackage`, `debhelper (>= 13)`, `dh-sequence-systemd` | +| Package type | Image (`package_configs.[].image` in `linux.json`) | Tools required | +| ------------ | ---------------------------------------------------------- | --------------------------------------------------- | +| RPM | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-` | `rpmbuild` | +| DEB | `ghcr.io/xrplf/xrpld/packaging-debian:sha-` | `dpkg-buildpackage`, debhelper with compat level 13 | -To print the exact image tags for the current `linux.json`: +To print the full packaging matrix (artifact names and images) for the current +`linux.json`: ```bash -./.github/scripts/strategy-matrix/generate.py --packaging --config=.github/scripts/strategy-matrix/linux.json +./.github/scripts/strategy-matrix/generate.py --packaging ``` ## Building packages @@ -48,12 +44,13 @@ To print the exact image tags for the current `linux.json`: ### Via CI Caller workflows (`on-pr.yml`, `on-tag.yml`, `on-trigger.yml`) call -`reusable-strategy-matrix.yml` with `mode: packaging` to generate the matrix of -`{artifact_name, os}` entries, then fan out to -`reusable-package.yml` per entry. That workflow downloads the pre-built `xrpld` -binary artifact, detects the package format from the container, and calls -`build_pkg.sh` directly — no CMake configure or build step is needed inside -the packaging job. +`reusable-package.yml`. That workflow generates its own packaging matrix from +`package_configs` in `linux.json` (via `generate.py --packaging`) and fans out +one job per distro. Each job downloads the pre-built `xrpld` binary artifact and +runs in that distro's container, so the package format follows from the +container's package manager. The packaging script derives the package version +from the downloaded binary's `xrpld --version` output; no CMake configure or +build step is needed inside the packaging job. ### Locally (mirrors CI) @@ -62,22 +59,19 @@ inside the same container CI uses. The image tag is derived from `linux.json` so you don't need to hardcode a SHA. ```bash -# From the repo root. Pick any image flagged with `"package": true` in -# linux.json; the package format is inferred from the container's package -# manager. Example for the rpm-producing image: -IMAGE=$(jq -r ' - .os | map(select(.package == true))[0] | - "ghcr.io/xrplf/ci/\(.distro_name)-\(.distro_version):\(.compiler_name)-\(.compiler_version)-sha-\(.image_sha)" -' .github/scripts/strategy-matrix/linux.json) +# 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) -VERSION=2.4.0-local PKG_RELEASE=1 docker run --rm \ - -v "$(pwd):/src" \ - -w /src \ - "$IMAGE" \ - ./package/build_pkg.sh --pkg-version "$VERSION" --pkg-release "$PKG_RELEASE" + -v "$(pwd):/src" \ + -w /src \ + "${IMAGE}" \ + ./package/build_pkg.sh --pkg-release "${PKG_RELEASE}" # Output: # build/debbuild/*.deb (DEB + dbgsym .ddeb) @@ -92,42 +86,74 @@ needed, but the host toolchain replaces the pinned CI image: ```bash cmake \ - -Dxrpld=ON \ - -Dxrpld_version=2.4.0-local \ - -Dtests=OFF \ - .. + -Dxrpld=ON \ + -Dpkg_release=1 \ + -Dtests=OFF \ + .. -cmake --build . --target package # deb on Debian/Ubuntu, rpm on RHEL +cmake --build . --target package # deb on Debian/Ubuntu, rpm on RHEL ``` -The `cmake/XrplPackaging.cmake` module defines the target only if at least one -of `rpmbuild` / `dpkg-buildpackage` is present; `build_pkg.sh` then infers the -package format from the host's package manager. The packaging script installs -to FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of +The `cmake/XrplPackaging.cmake` module defines the `package` target only if at +least one of `rpmbuild` / `dpkg-buildpackage` is present; `build_pkg.sh` then +infers the package format from the host's package manager. The packaging script +installs to FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of `CMAKE_INSTALL_PREFIX`. +The package version is not a CMake input on this path: `build_pkg.sh` derives it +from the just-built `xrpld` binary's `xrpld --version` output. The package +release defaults to 1 and is overridable with `-Dpkg_release=N`. + ## How `build_pkg.sh` works -`build_pkg.sh` accepts long-form flags, each of which can also be set via an -environment variable. Flags override env vars; env vars override the built-in -defaults. Run `./package/build_pkg.sh --help` for the same table: +`build_pkg.sh` derives the `xrpld` software version from +`${BUILD_DIR}/xrpld --version` in both package formats. -| Flag | Env var | Default | Purpose | -| -------------------------- | ------------------- | ----------------------------- | ----------------------------------- | -| `--src-dir DIR` | `SRC_DIR` | `$PWD` | repo root | -| `--build-dir DIR` | `BUILD_DIR` | `$PWD/build` | directory holding pre-built `xrpld` | -| `--pkg-version STR` | `PKG_VERSION` | parsed from `xrpld --version` | version string, e.g. `3.2.0-b1` | -| `--pkg-release N` | `PKG_RELEASE` | `1` | package release number | -| `--source-date-epoch SECS` | `SOURCE_DATE_EPOCH` | latest git commit ctime | reproducibility timestamp | +The binary's version is already SemVer-validated by `BuildInfo`. +`build_pkg.sh` 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 +separator converted to `~`). It is not a separate user input. + +`PKG_RELEASE` is a different value: the package release iteration for that +`xrpld` version. RPM receives the normalized `pkg_version` and `PKG_RELEASE` as +the `pkg_version` and `pkg_release` macros for its `Version` and `Release` +values; DEB writes them as `${pkg_version}-${PKG_RELEASE}` in +`debian/changelog`. + +With `PKG_RELEASE=1`, the package metadata becomes: + +| Input version | RPM version/release | Debian version | +| ------------------ | ---------------------------- | -------------------- | +| `3.2.0` | `3.2.0-1%{?dist}` | `3.2.0-1` | +| `3.2.0-b0+abc1234` | `3.2.0~b0+abc1234-1%{?dist}` | `3.2.0~b0+abc1234-1` | +| `3.2.0-b1` | `3.2.0~b1-1%{?dist}` | `3.2.0~b1-1` | +| `3.2.0-rc1` | `3.2.0~rc1-1%{?dist}` | `3.2.0~rc1-1` | + +The Debian changelog entry carries the repository component: final releases use +`stable`, `b0` builds, including `b0+metadata`, use `develop`, and `bN`/`rcN` +pre-releases use `unstable`. +Build metadata on a final release, such as `3.2.0+abc123`, is rejected. + +The 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. Flags are for explicit invocation; environment variables are intended for -CMake/systemd/CI integration. The CI workflow and the CMake `package` target -both invoke `build_pkg.sh` with no flags, configuring it entirely via env -(see `cmake/XrplPackaging.cmake`). +CMake/CI integration. The CI workflow and the CMake `package` target both invoke +`build_pkg.sh` with no flags; CMake supplies `SRC_DIR`, `BUILD_DIR`, and +`PKG_RELEASE` via env, while CI supplies `BUILD_DIR` and `PKG_RELEASE` via env +and lets the script use defaults for the rest. It resolves `SRC_DIR` and `BUILD_DIR` to absolute paths, then calls `stage_common()` to copy the binary, config files, and shared support files @@ -136,18 +162,32 @@ into the staging area, and invokes the platform build tool. ### RPM 1. Creates the standard `rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}` tree inside the build directory. -2. Copies `xrpld.spec` and all source files (binary, configs, service files) into `SOURCES/`. -3. Runs `rpmbuild -bb --define "xrpld_version ..." --define "pkg_release ..."`. The spec uses manual `install` commands to place files. +2. Copies `xrpld.spec` and all shared source files (binary, configs, service files) into `SOURCES/`. +3. Runs `rpmbuild -bb`, passing the normalized package metadata version as the + `pkg_version` RPM macro and `PKG_RELEASE` as the `pkg_release` RPM macro. + The spec uses manual `install` commands to place files, disables `dwz`, and + writes uncompressed RPM payloads while generating debuginfo packages. 4. Output: `rpmbuild/RPMS/x86_64/xrpld-*.rpm` +The uncompressed RPM payload setting is intentionally unconditional for +generated RPMs. It trades larger RPM artifacts for much shorter package +build/validation time, which keeps RPM package validation in the same rough time +class as Debian package validation. + +RPM upgrades intentionally do not restart a running `xrpld` service. The spec +uses `%systemd_postun`, matching Debian's `dh_installsystemd +--no-stop-on-upgrade` behavior; operators pick up the new binary on the next +service restart. + ### DEB 1. Creates a staging source tree at `debbuild/source/` inside the build directory. 2. Stages the binary, configs, `README.md`, and `LICENSE.md`. 3. Copies `package/debian/` control files into `debbuild/source/debian/`. 4. Copies shared service/sysusers/tmpfiles into `debian/` where `dh_installsystemd`, `dh_installsysusers`, and `dh_installtmpfiles` pick them up automatically. -5. Generates a minimal `debian/changelog` (pre-release versions use `~` instead of `-`). -6. Runs `dpkg-buildpackage -b --no-sign`. `debian/rules` uses manual `install` commands. +5. Generates a minimal `debian/changelog` using `${pkg_version}-${PKG_RELEASE}`, + where `pkg_version` is derived from the binary-reported `xrpld` version. +6. Runs `dpkg-buildpackage -b --no-sign -d` (`-d` skips the build-dependency check, since the binary is already built). `debian/rules` uses manual `install` commands. 7. Output: `debbuild/*.deb` and `debbuild/*.ddeb` (dbgsym package) ## Post-build verification @@ -163,11 +203,14 @@ rpm -qlp rpmbuild/RPMS/x86_64/*.rpm ## Reproducibility -The following environment variables improve build reproducibility. They are not -set automatically by `build_pkg.sh`; set them manually if needed: +`build_pkg.sh` already defaults `SOURCE_DATE_EPOCH` to the latest git commit +time, or the current time outside a git tree, and exports it (override with +`--source-date-epoch` / `SOURCE_DATE_EPOCH`); the RPM spec clamps file +modification times to it via `%build_mtime_policy`. The remaining variables +below further improve reproducibility but are _not_ set by the script — export +them yourself if needed: ```bash -export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) export TZ=UTC export LC_ALL=C.UTF-8 export GZIP=-n diff --git a/package/build_pkg.sh b/package/build_pkg.sh index c834951493..3684fc096a 100755 --- a/package/build_pkg.sh +++ b/package/build_pkg.sh @@ -3,20 +3,18 @@ set -euo pipefail # Build an RPM or Debian package from a pre-built xrpld binary. # -# Flags override env vars; env vars override defaults. Env vars are intended -# for CMake/systemd/CI integration; flags are for explicit invocation. +# Flags override env vars; env vars override defaults. usage() { cat <<'EOF' Usage: build_pkg.sh [options] Options (each can also be set via the env var shown): - --src-dir DIR repo root [SRC_DIR; default: $PWD] - --build-dir DIR directory holding xrpld [BUILD_DIR; default: $PWD/build] - --pkg-version STR version, e.g. 3.2.0-b1 [PKG_VERSION; default: parsed from xrpld --version] - --pkg-release N package release number [PKG_RELEASE; default: 1] - --source-date-epoch SECS reproducibility timestamp [SOURCE_DATE_EPOCH; default: latest git commit ctime] - -h, --help show this help and exit + --src-dir DIR repo root [SRC_DIR; default: ${PWD}] + --build-dir DIR directory holding xrpld [BUILD_DIR; default: ${PWD}/build] + --pkg-release N package release iteration [PKG_RELEASE; default: 1] + --source-date-epoch SECS reproducibility timestamp [SOURCE_DATE_EPOCH; latest git ctime; fallback: current time] + -h, --help show this help and exit EOF } @@ -30,18 +28,35 @@ need_arg() { # Seed from env. CLI parsing below overrides these directly. SRC_DIR="${SRC_DIR:-}" BUILD_DIR="${BUILD_DIR:-}" -PKG_VERSION="${PKG_VERSION:-}" -PKG_RELEASE="${PKG_RELEASE:-}" +PKG_RELEASE="${PKG_RELEASE:-1}" SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-}" while [[ $# -gt 0 ]]; do case "$1" in - --src-dir) need_arg "$@"; SRC_DIR="$2"; shift 2 ;; - --build-dir) need_arg "$@"; BUILD_DIR="$2"; shift 2 ;; - --pkg-version) need_arg "$@"; PKG_VERSION="$2"; shift 2 ;; - --pkg-release) need_arg "$@"; PKG_RELEASE="$2"; shift 2 ;; - --source-date-epoch) need_arg "$@"; SOURCE_DATE_EPOCH="$2"; shift 2 ;; - -h|--help) usage; exit 0 ;; + --src-dir) + need_arg "$@" + SRC_DIR="$2" + shift 2 + ;; + --build-dir) + need_arg "$@" + BUILD_DIR="$2" + shift 2 + ;; + --pkg-release) + need_arg "$@" + PKG_RELEASE="$2" + shift 2 + ;; + --source-date-epoch) + need_arg "$@" + SOURCE_DATE_EPOCH="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; *) echo "Unknown argument: $1" >&2 usage >&2 @@ -51,19 +66,61 @@ while [[ $# -gt 0 ]]; do done SRC_DIR="$(cd "${SRC_DIR:-${PWD}}" && pwd)" -BUILD_DIR="$(cd "${BUILD_DIR:-${PWD}/build}" && pwd)" -PKG_RELEASE="${PKG_RELEASE:-1}" - -if [[ -z "${PKG_VERSION}" ]]; then - PKG_VERSION="$("${BUILD_DIR}/xrpld" --version | awk 'NR==1 {print $3; exit}')" +BUILD_DIR="${BUILD_DIR:-${PWD}/build}" +if [[ ! -d "${BUILD_DIR}" ]]; then + echo "build_pkg.sh: build directory not found: ${BUILD_DIR}" >&2 + echo "Build xrpld before packaging, or set BUILD_DIR to the directory containing xrpld." >&2 + exit 1 fi +BUILD_DIR="$(cd "${BUILD_DIR}" && pwd)" -if [[ -z "${PKG_VERSION}" ]]; then - echo "PKG_VERSION is empty (not provided and could not be derived)." >&2 +xrpld_binary="${BUILD_DIR}/xrpld" +if [[ ! -x "${xrpld_binary}" ]]; then + echo "build_pkg.sh: expected executable xrpld binary at ${xrpld_binary}." >&2 + echo "Build xrpld before packaging, or set BUILD_DIR to the directory containing xrpld." >&2 exit 1 fi -VERSION="${PKG_VERSION}" +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 @@ -75,31 +132,15 @@ else 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)" + 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")" - -# Split VERSION at the first '-' into base and optional pre-release suffix. -# Examples: "3.2.0" -> ("3.2.0", ""); "3.2.0-b1" -> ("3.2.0", "b1"). -VER_BASE="${VERSION%%-*}" -VER_SUFFIX="${VERSION#*-}" -[[ "${VER_SUFFIX}" == "${VERSION}" ]] && VER_SUFFIX="" - -# Reject multi-segment suffixes (e.g. "beta-1", "rc1-15-gabc123"). The RPM -# Release field forbids '-', and the convention here is single-token suffixes -# like b1 or rc2. Fail early with a clear message rather than letting either -# rpmbuild blow up or silently mangling dashes into dots. -if [[ "${VER_SUFFIX}" == *-* ]]; then - echo "build_pkg.sh: multi-segment pre-release in VERSION='${VERSION}' (suffix '${VER_SUFFIX}')." >&2 - echo "Use single-token suffixes like 3.2.0-b1 or 3.2.0-rc2." >&2 - exit 1 -fi +CHANGELOG_DATE="$(date -u -R -d "@${SOURCE_DATE_EPOCH}")" SHARED="${SRC_DIR}/package/shared" DEBIAN_DIR="${SRC_DIR}/package/debian" @@ -109,20 +150,16 @@ stage_common() { local dest="$1" mkdir -p "${dest}" - cp "${BUILD_DIR}/xrpld" "${dest}/xrpld" - cp "${SRC_DIR}/cfg/xrpld-example.cfg" "${dest}/xrpld.cfg" - cp "${SRC_DIR}/cfg/validators-example.txt" "${dest}/validators.txt" - cp "${SRC_DIR}/LICENSE.md" "${dest}/LICENSE.md" - cp "${SRC_DIR}/README.md" "${dest}/README.md" + cp "${BUILD_DIR}/xrpld" "${dest}/xrpld" + cp "${SRC_DIR}/cfg/xrpld-example.cfg" "${dest}/xrpld.cfg" + cp "${SRC_DIR}/cfg/validators-example.txt" "${dest}/validators.txt" + cp "${SRC_DIR}/LICENSE.md" "${dest}/LICENSE.md" + cp "${SRC_DIR}/README.md" "${dest}/README.md" - cp "${SHARED}/xrpld.service" "${dest}/xrpld.service" - cp "${SHARED}/xrpld.sysusers" "${dest}/xrpld.sysusers" - cp "${SHARED}/xrpld.tmpfiles" "${dest}/xrpld.tmpfiles" - cp "${SHARED}/xrpld.logrotate" "${dest}/xrpld.logrotate" - cp "${SHARED}/update-xrpld" "${dest}/update-xrpld" - cp "${SHARED}/update-xrpld.service" "${dest}/update-xrpld.service" - cp "${SHARED}/update-xrpld.timer" "${dest}/update-xrpld.timer" - cp "${SHARED}/50-xrpld.preset" "${dest}/50-xrpld.preset" + 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() { @@ -133,16 +170,11 @@ build_rpm() { cp "${SRC_DIR}/package/rpm/xrpld.spec" "${topdir}/SPECS/xrpld.spec" stage_common "${topdir}/SOURCES" - # RPM Version can't contain '-'. A pre-release goes in Release with a - # leading "0." so 3.2.0-b1 sorts before the final 3.2.0-. - local rpm_release="${PKG_RELEASE}" - [[ -n "${VER_SUFFIX}" ]] && rpm_release="0.${VER_SUFFIX}.${PKG_RELEASE}" - set -x rpmbuild -bb \ --define "_topdir ${topdir}" \ - --define "xrpld_version ${VER_BASE}" \ - --define "xrpld_release ${rpm_release}" \ + --define "pkg_version ${pkg_version}" \ + --define "pkg_release ${PKG_RELEASE}" \ "${topdir}/SPECS/xrpld.spec" } @@ -154,31 +186,31 @@ build_deb() { stage_common "${staging}" cp -r "${DEBIAN_DIR}" "${staging}/debian" - # Debhelper auto-discovers these only from 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" - cp "${staging}/update-xrpld.service" "${staging}/debian/xrpld.update-xrpld.service" - cp "${staging}/update-xrpld.timer" "${staging}/debian/xrpld.update-xrpld.timer" + 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 '~' marks a pre-release; 3.2.0~b1 sorts before 3.2.0. - local deb_full_version="${VER_BASE}${VER_SUFFIX:+~${VER_SUFFIX}}-${PKG_RELEASE}" + # Choose the Debian repository component for this package. + # 3.2.0 -> stable, *-b0[+metadata] -> develop, + # bN/rcN pre-releases -> unstable. + local deb_component + if [[ -z "${pre_release}" ]]; then + deb_component="stable" + elif [[ "${pre_release}" =~ ^b0(\+.*)?$ ]]; then + deb_component="develop" + elif [[ "${pre_release}" =~ ^(b[1-9][0-9]*|rc[0-9]+)(\+.*)?$ ]]; then + deb_component="unstable" + else + echo "build_pkg.sh: unsupported xrpld pre-release '${pre_release}'." >&2 + echo "Use bN or rcN, e.g. 3.2.0-b1 or 3.2.0-rc2." >&2 + exit 1 + fi - # Derive release channel from the version suffix: - # (none) -> stable (tagged release) - # b0 -> develop (develop-branch build) - # b, rc -> unstable (pre-release) - local deb_distribution - case "${VER_SUFFIX}" in - "") deb_distribution="stable" ;; - b0) deb_distribution="develop" ;; - *) deb_distribution="unstable" ;; - esac - - cat > "${staging}/debian/changelog" <[~
]-.
+    cat >"${staging}/debian/changelog" <  ${CHANGELOG_DATE}
 EOF
@@ -186,7 +218,7 @@ EOF
     chmod +x "${staging}/debian/rules"
 
     set -x
-    ( cd "${staging}" && dpkg-buildpackage -b --no-sign -d )
+    (cd "${staging}" && dpkg-buildpackage -b --no-sign -d)
 }
 
 "build_${pkg_type}"
diff --git a/package/debian/rules b/package/debian/rules
index 0fae101358..16574bca3f 100644
--- a/package/debian/rules
+++ b/package/debian/rules
@@ -9,8 +9,7 @@ override_dh_auto_configure override_dh_auto_build override_dh_auto_test:
 	@:
 
 override_dh_installsystemd:
-	dh_installsystemd --no-start xrpld.service
-	dh_installsystemd --name=update-xrpld --no-start update-xrpld.service update-xrpld.timer
+	dh_installsystemd --no-stop-on-upgrade xrpld.service
 
 execute_before_dh_installtmpfiles:
 	dh_installsysusers
@@ -21,7 +20,6 @@ override_dh_install:
 	install -D -m 0755 xrpld            debian/xrpld/usr/bin/xrpld
 	install -D -m 0644 xrpld.cfg        debian/xrpld/etc/xrpld/xrpld.cfg
 	install -D -m 0644 validators.txt   debian/xrpld/etc/xrpld/validators.txt
-	install -D -m 0755 update-xrpld     debian/xrpld/usr/libexec/xrpld/update-xrpld
 
 override_dh_dwz:
 	@:
diff --git a/package/debian/xrpld.docs b/package/debian/xrpld.docs
index 1217b6db43..b43bf86b50 100644
--- a/package/debian/xrpld.docs
+++ b/package/debian/xrpld.docs
@@ -1,2 +1 @@
 README.md
-LICENSE.md
diff --git a/package/install-packaging-tools.sh b/package/install-packaging-tools.sh
new file mode 100755
index 0000000000..a26159a204
--- /dev/null
+++ b/package/install-packaging-tools.sh
@@ -0,0 +1,68 @@
+#!/bin/bash
+
+set -euo pipefail
+
+if [ ! -f /etc/os-release ]; then
+    echo "ERROR: /etc/os-release not found; cannot detect OS" >&2
+    exit 1
+fi
+
+# shellcheck source=/dev/null
+. /etc/os-release
+
+echo "Detected OS: ${ID} ${VERSION_ID:-}"
+
+case "${ID}" in
+    ubuntu | debian | rhel | centos | rocky | almalinux)
+        echo "Supported OS detected: ${ID}"
+        ;;
+    *)
+        echo "ERROR: unsupported OS '${ID}'. Supported: debian, ubuntu, rhel-family" >&2
+        exit 1
+        ;;
+esac
+
+function install() {
+    case "${ID}" in
+        debian | ubuntu)
+            apt-get update -y
+            apt-get install -y --no-install-recommends \
+                ca-certificates \
+                debhelper \
+                debhelper-compat \
+                dpkg-dev \
+                git
+            ;;
+
+        rhel | centos | rocky | almalinux)
+            dnf install -y --setopt=install_weak_deps=False \
+                git \
+                rpm-build \
+                redhat-rpm-config \
+                systemd-rpm-macros
+            ;;
+    esac
+}
+
+function postinstall() {
+    # Don't clear cache in non-CI environments
+    if [ -z "${CI:-}" ]; then
+        echo "Not running in CI environment; skipping cache cleanup"
+        return
+    fi
+
+    case "${ID}" in
+        debian | ubuntu)
+            apt-get clean
+            rm -rf /var/lib/apt/lists/*
+            ;;
+
+        rhel | centos | rocky | almalinux)
+            dnf clean -y all
+            rm -rf /var/cache/dnf/*
+            ;;
+    esac
+}
+
+install
+postinstall
diff --git a/package/rpm/xrpld.spec b/package/rpm/xrpld.spec
index 4933c724f7..61c2d61ec6 100644
--- a/package/rpm/xrpld.spec
+++ b/package/rpm/xrpld.spec
@@ -1,6 +1,14 @@
+%if "%{?pkg_version}" == ""
+%{error:pkg_version must be defined}
+%endif
+
+%if "%{?pkg_release}" == ""
+%{error:pkg_release must be defined}
+%endif
+
 Name:     xrpld
-Version:  %{xrpld_version}
-Release:  %{xrpld_release}%{?dist}
+Version:  %{pkg_version}
+Release:  %{pkg_release}%{?dist}
 Summary:  XRP Ledger daemon
 
 License:  ISC
@@ -11,6 +19,9 @@ BuildRequires: systemd-rpm-macros
 
 %undefine _debugsource_packages
 %debug_package
+# Intentionally trade larger RPM artifacts for faster package validation.
+%global _binary_payload w.ufdio
+%global _find_debuginfo_dwz_opts %{nil}
 
 %build_mtime_policy clamp_to_source_date_epoch
 
@@ -35,18 +46,16 @@ install -Dm0644 %{_sourcedir}/validators.txt       %{buildroot}%{_sysconfdir}/%{
 
 # systemd units, sysusers, tmpfiles, preset
 install -Dm0644 %{_sourcedir}/xrpld.service        %{buildroot}%{_unitdir}/xrpld.service
-install -Dm0644 %{_sourcedir}/update-xrpld.service %{buildroot}%{_unitdir}/update-xrpld.service
-install -Dm0644 %{_sourcedir}/update-xrpld.timer   %{buildroot}%{_unitdir}/update-xrpld.timer
 install -Dm0644 %{_sourcedir}/xrpld.sysusers       %{buildroot}%{_sysusersdir}/xrpld.conf
 install -Dm0644 %{_sourcedir}/xrpld.tmpfiles       %{buildroot}%{_tmpfilesdir}/xrpld.conf
-install -Dm0644 %{_sourcedir}/50-xrpld.preset      %{buildroot}%{_presetdir}/50-xrpld.preset
+install -Dm0644 /dev/null %{buildroot}%{_presetdir}/50-xrpld.preset
+cat >%{buildroot}%{_presetdir}/50-xrpld.preset <<'EOF'
+enable xrpld.service
+EOF
 
 # Logrotate config
 install -Dm0644 %{_sourcedir}/xrpld.logrotate      %{buildroot}%{_sysconfdir}/logrotate.d/%{name}
 
-# Update helper
-install -Dm0755 %{_sourcedir}/update-xrpld         %{buildroot}%{_libexecdir}/%{name}/update-xrpld
-
 # Docs
 install -Dm0644 %{_sourcedir}/LICENSE.md %{buildroot}%{_docdir}/%{name}/LICENSE.md
 install -Dm0644 %{_sourcedir}/README.md  %{buildroot}%{_docdir}/%{name}/README.md
@@ -61,20 +70,19 @@ ln -s %{_bindir}/%{name} %{buildroot}/usr/local/bin/rippled
 
 %post
 systemd-tmpfiles --create %{_tmpfilesdir}/xrpld.conf || :
-%systemd_post xrpld.service update-xrpld.timer
+%systemd_post xrpld.service
 
 %preun
-%systemd_preun xrpld.service update-xrpld.timer
+%systemd_preun xrpld.service
 
 %postun
-%systemd_postun_with_restart xrpld.service
+%systemd_postun xrpld.service
 
 %files
 %license %{_docdir}/%{name}/LICENSE.md
 %doc %{_docdir}/%{name}/README.md
 
 %dir %{_sysconfdir}/%{name}
-%dir %{_libexecdir}/%{name}
 
 %{_bindir}/%{name}
 
@@ -82,18 +90,13 @@ systemd-tmpfiles --create %{_tmpfilesdir}/xrpld.conf || :
 %config(noreplace) %{_sysconfdir}/%{name}/validators.txt
 %config(noreplace) %{_sysconfdir}/logrotate.d/%{name}
 
-%{_libexecdir}/%{name}/update-xrpld
 
 %{_unitdir}/xrpld.service
-%{_unitdir}/update-xrpld.service
-%{_unitdir}/update-xrpld.timer
 %{_presetdir}/50-xrpld.preset
 %{_sysusersdir}/xrpld.conf
 %{_tmpfilesdir}/xrpld.conf
-
-%ghost %dir /var/lib/%{name}
-%ghost %dir /var/log/%{name}
-
+%ghost %dir /var/lib/xrpld
+%ghost %dir /var/log/xrpld
 
 # Legacy compatibility for pre-FHS package layouts.
 # TODO: remove after rippled fully deprecated.
diff --git a/package/shared/50-xrpld.preset b/package/shared/50-xrpld.preset
deleted file mode 100644
index 6264e00131..0000000000
--- a/package/shared/50-xrpld.preset
+++ /dev/null
@@ -1,4 +0,0 @@
-# /usr/lib/systemd/system-preset/50-xrpld.preset
-enable xrpld.service
-# Don't enable automatic updates
-disable update-xrpld.timer
diff --git a/package/shared/update-xrpld b/package/shared/update-xrpld
deleted file mode 100755
index 86be33118b..0000000000
--- a/package/shared/update-xrpld
+++ /dev/null
@@ -1,152 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Optional: also write logs to a legacy file in addition to journald.
-# By default, this script logs to systemd/journald, viewable via:
-#   journalctl -t update-xrpld
-#
-# Uncomment the line below if you need a flat file for compatibility with
-# external tooling, manual inspection, or environments where journald logs
-# are not persisted or easily accessible.
-#
-# Note: This duplicates all output (stdout/stderr) to both journald and the file.
-# It is generally not needed on modern systems and may cause log file growth
-# if left enabled long-term.
-#
-# Requires /var/log/xrpld/ to exist and be writable by the service (root).
-#
-# exec > >(tee -a /var/log/xrpld/update.log) 2>&1
-
-PATH=/usr/sbin:/usr/bin:/sbin:/bin
-
-PKG_NAME=${PKG_NAME:-xrpld}
-
-log() {
-# If running under systemd/journald, let it handle timestamps.
-    if [[ -n "${JOURNAL_STREAM:-}" ]]; then
-        printf '%s\n' "$*"
-    else
-        printf '%s %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"
-    fi
-}
-
-require_root() {
-    if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
-        log "RESULT: failed reason=not-root"
-            exit 1
-    fi
-}
-
-get_installed_version() {
-    if command -v dpkg-query >/dev/null 2>&1; then
-        dpkg-query -W -f='${Version}' "$PKG_NAME" 2>/dev/null || printf 'unknown'
-    elif command -v rpm >/dev/null 2>&1; then
-        rpm -q --qf '%{VERSION}-%{RELEASE}' "$PKG_NAME" 2>/dev/null || printf 'unknown'
-    else
-        printf 'unknown'
-    fi
-}
-
-trap 'log "RESULT: failed reason=script-error exit_code=$?"' ERR
-
-apt_can_update() {
-    apt-get update -qq
-    apt-get -s --only-upgrade install "$PKG_NAME" 2>/dev/null | grep -q "^Inst ${PKG_NAME}\b"
-}
-
-apt_apply_update() {
-    DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
-        -o Dpkg::Options::="--force-confdef" \
-        -o Dpkg::Options::="--force-confold" \
-        "$PKG_NAME"
-}
-
-get_rpm_pm() {
-    if command -v dnf >/dev/null 2>&1; then
-        printf 'dnf\n'
-    elif command -v yum >/dev/null 2>&1; then
-        printf 'yum\n'
-    else
-        return 1
-    fi
-}
-
-rpm_refresh_metadata() {
-    local pm=$1
-    if [[ "$pm" == "dnf" ]]; then
-        dnf makecache --refresh -q >/dev/null
-    else
-        yum clean expire-cache -q >/dev/null
-    fi
-}
-
-rpm_can_update() {
-    local pm=$1
-
-    rpm_refresh_metadata "$pm"
-    local rc=0
-    set +e
-    "$pm" check-update -q "$PKG_NAME" >/dev/null 2>&1
-    rc=$?
-    set -e
-
-    if [[ $rc -eq 100 ]]; then
-        return 0
-    elif [[ $rc -eq 0 ]]; then
-        return 1
-    else
-        log "$pm check-update failed with exit code ${rc}."
-        exit 1
-    fi
-}
-
-rpm_apply_update() {
-    local pm=$1
-    "$pm" update -y "$PKG_NAME"
-}
-
-restart_service() {
-    # Preserve the operator's prior service state: if xrpld was intentionally
-    # stopped before the update, don't bring it back up just because the
-    # auto-update timer fired.
-    if systemctl is-active --quiet "${PKG_NAME}.service"; then
-        systemctl restart "${PKG_NAME}.service"
-        log "${PKG_NAME} service restarted successfully."
-    else
-        log "${PKG_NAME} service was not running; skipping restart to preserve prior state."
-    fi
-}
-
-main() {
-    require_root
-    if command -v apt-get >/dev/null 2>&1; then
-        log "Checking for ${PKG_NAME} updates via apt"
-        if apt_can_update; then
-            log "Update available; installing."
-            apt_apply_update
-            restart_service
-            log "RESULT: updated ${PKG_NAME}=$(get_installed_version)"
-        else
-            log "RESULT: no-update ${PKG_NAME}=$(get_installed_version)"
-        fi
-        return
-    fi
-
-    local rpm_pm=""
-    if rpm_pm="$(get_rpm_pm)"; then
-        log "Checking for ${PKG_NAME} updates via ${rpm_pm}"
-        if rpm_can_update "$rpm_pm"; then
-            log "Update available; installing"
-            rpm_apply_update "$rpm_pm"
-            restart_service
-            log "RESULT: updated ${PKG_NAME}=$(get_installed_version)"
-        else
-            log "RESULT: no-update ${PKG_NAME}=$(get_installed_version)"
-        fi
-        return
-    fi
-    log "RESULT: failed reason=no-package-manager"
-    exit 1
-}
-
-main "$@"
diff --git a/package/shared/update-xrpld.service b/package/shared/update-xrpld.service
deleted file mode 100644
index a964ca5482..0000000000
--- a/package/shared/update-xrpld.service
+++ /dev/null
@@ -1,16 +0,0 @@
-[Unit]
-Description=Check for and install xrpld package updates
-Documentation=man:systemd.service(5)
-Wants=network-online.target
-After=network-online.target
-ConditionPathExists=/usr/libexec/xrpld/update-xrpld
-ConditionPathExists=/usr/bin/xrpld
-
-[Service]
-Type=oneshot
-ExecStart=/usr/bin/flock -n /run/lock/xrpld-update.lock /usr/libexec/xrpld/update-xrpld
-StandardOutput=journal
-StandardError=journal
-SyslogIdentifier=update-xrpld
-TimeoutStartSec=30min
-PrivateTmp=true
diff --git a/package/shared/update-xrpld.timer b/package/shared/update-xrpld.timer
deleted file mode 100644
index 21dabf1400..0000000000
--- a/package/shared/update-xrpld.timer
+++ /dev/null
@@ -1,10 +0,0 @@
-[Unit]
-Description=Daily xrpld update check
-
-[Timer]
-OnCalendar=*-*-* 00:00:00
-RandomizedDelaySec=24h
-Persistent=true
-
-[Install]
-WantedBy=timers.target
diff --git a/package/shared/xrpld.service b/package/shared/xrpld.service
index 72b6cc9938..f54e47aa14 100644
--- a/package/shared/xrpld.service
+++ b/package/shared/xrpld.service
@@ -2,21 +2,31 @@
 Description=XRP Ledger Daemon
 After=network-online.target
 Wants=network-online.target
-StartLimitIntervalSec=300
+StartLimitIntervalSec=5min
 StartLimitBurst=5
 
 [Service]
 Type=simple
 ExecStart=/usr/bin/xrpld --net --silent --conf /etc/xrpld/xrpld.cfg
-Restart=always
+Restart=on-failure
 RestartSec=5s
+TimeoutStopSec=5min
 NoNewPrivileges=true
 ProtectSystem=full
 ProtectHome=true
 PrivateTmp=true
 User=xrpld
 Group=xrpld
+StateDirectory=xrpld
+StateDirectoryMode=0750
+LogsDirectory=xrpld
+LogsDirectoryMode=0750
 LimitNOFILE=65536
+SystemCallArchitectures=native
+
+# Uncomment both lines to allow xrpld to bind to privileged ports (<1024)
+#CapabilityBoundingSet=CAP_NET_BIND_SERVICE
+#AmbientCapabilities=CAP_NET_BIND_SERVICE
 
 [Install]
 WantedBy=multi-user.target
diff --git a/sanitizers/suppressions/runtime-ubsan-options.txt b/sanitizers/suppressions/runtime-ubsan-options.txt
index fcfccf7bae..4b48efbe08 100644
--- a/sanitizers/suppressions/runtime-ubsan-options.txt
+++ b/sanitizers/suppressions/runtime-ubsan-options.txt
@@ -1 +1 @@
-halt_on_error=false
+halt_on_error=true
diff --git a/sanitizers/suppressions/ubsan.supp b/sanitizers/suppressions/ubsan.supp
index 88d8e82e33..7e3e02f855 100644
--- a/sanitizers/suppressions/ubsan.supp
+++ b/sanitizers/suppressions/ubsan.supp
@@ -72,7 +72,7 @@ vptr:boost
 
 # Google protobuf - intentional overflows in hash functions
 undefined:protobuf
-unsigned-integer-overflow:google/protobuf/stubs/stringpiece.h
+unsigned-integer-overflow:protobuf
 
 # gRPC intentional overflows in timer calculations
 unsigned-integer-overflow:grpc
@@ -102,47 +102,103 @@ undefined:nudb
 # Snappy compression library intentional overflows
 unsigned-integer-overflow:snappy.cc
 
-# Abseil intentional overflows
-unsigned-integer-overflow:absl/strings/numbers.cc
-unsigned-integer-overflow:absl/strings/internal/cord_rep_flat.h
-unsigned-integer-overflow:absl/base/internal/low_level_alloc.cc
-unsigned-integer-overflow:absl/hash/internal/hash.h
-unsigned-integer-overflow:absl/container/internal/raw_hash_set.h
+# Abseil intentional overflows in hashing, RNG and time arithmetic.
+# Matched at library scope (like boost above): the wraparound is by design
+# across many absl files (hash mixing, raw_hash_set probing, duration math,
+# int128, uniform_int_distribution), so listing individual files just churns.
+unsigned-integer-overflow:absl
 
 # Standard library intentional overflows
 unsigned-integer-overflow:basic_string.h
+unsigned-integer-overflow:bits/align.h
+unsigned-integer-overflow:bits/basic_string.tcc
 unsigned-integer-overflow:bits/chrono.h
 unsigned-integer-overflow:bits/random.h
 unsigned-integer-overflow:bits/random.tcc
 unsigned-integer-overflow:bits/stl_algobase.h
+unsigned-integer-overflow:bits/string_view.tcc
 unsigned-integer-overflow:bits/uniform_int_dist.h
 unsigned-integer-overflow:string_view
 unsigned-integer-overflow:__random/seed_seq.h
 unsigned-integer-overflow:__charconv/traits.h
 unsigned-integer-overflow:__chrono/duration.h
+# libstdc++  (std::__bit_ceil etc.) negates an unsigned width;  is a
+# distinct header from the bits/ directory so it needs its own entry.
+unsigned-integer-overflow:include/c++/*/bit
 
 # =============================================================================
 # Rippled code suppressions
 # =============================================================================
 
-# Signed integer negation (-value) in amount types.
-# INT64_MIN cannot occur in practice due to domain invariants (mantissa ranges
-# are well within int64_t bounds), but UBSan flags the pattern as potential
-# signed overflow. Narrowed to operator- to avoid suppressing unrelated
-# overflows anywhere in a stack trace containing these type names.
-signed-integer-overflow:operator-*IOUAmount*
-signed-integer-overflow:operator-*XRPAmount*
-signed-integer-overflow:operator-*MPTAmount*
-signed-integer-overflow:operator-*STAmount*
+# These suppressions are keyed by SOURCE FILE, not function name. This UBSan
+# build runs without symbol information, so the runtime only knows the
+# file:line of each report, never the enclosing function — function-name
+# patterns silently never match. Each entry below is therefore scoped to the
+# file whose arithmetic is intentional; the comment names the specific
+# construct.
 
-# STAmount::operator+ signed addition — operands are bounded by total supply
-# (~10^17 for XRP, ~10^18 for MPT) so overflow cannot occur in practice.
-signed-integer-overflow:operator+*STAmount*
+# STAmount amount-type arithmetic. Unary negation of the mantissa in xrp()/
+# iou()/mpt()/canonicalize() and getInt64Value, plus bounded +/- on amounts:
+# INT64_MIN cannot occur because canonicalize() keeps the mantissa well within
+# int64_t, and operands are bounded by total supply (~10^17 XRP, ~10^18 MPT).
+signed-integer-overflow:protocol/STAmount.cpp
 
-# STAmount::getRate uses unsigned shift and addition
-unsigned-integer-overflow:*STAmount*getRate*
-# STAmount::serialize uses unsigned bitwise operations
-unsigned-integer-overflow:*STAmount*serialize*
+# nft::cipheredTaxon uses intentional uint32 wraparound (LCG permutation);
+# the helper lives in the generated protocol header nft.h.
+unsigned-integer-overflow:protocol/nft.h
 
-# nft::cipheredTaxon uses intentional uint32 wraparound (LCG permutation)
-unsigned-integer-overflow:cipheredTaxon
+# STPathElement::getHash multiplies/adds accumulators (non-secure, speed-first).
+unsigned-integer-overflow:protocol/STPathSet.cpp
+
+# beast XorShiftEngine PRNG and murmurhash3 mixing wrap by design.
+unsigned-integer-overflow:beast/xor_shift_engine.h
+
+# Number::normalizeToRange multiplies the mantissa by powers of ten; the result
+# is intentionally allowed to wrap while searching for the in-range value.
+unsigned-integer-overflow:basics/Number.h
+
+# Counter / sequence arithmetic with intentional unsigned wraparound, each
+# guarded by an explicit overflow or domain check at the call site:
+#   base_uint operator++/-- wrap by definition;
+#   ApplyView::insertPage ++page is asserted to wrap to 0 (page exhaustion);
+#   confineOwnerCount documents "overflow is well defined on unsigned";
+#   NFTokenMint checks tokenSeq + 1u == 0u; AmendmentTable does (seq - 1) / 256.
+unsigned-integer-overflow:basics/base_uint.h
+unsigned-integer-overflow:ledger/ApplyView.cpp
+unsigned-integer-overflow:ledger/helpers/AccountRootHelpers.cpp
+unsigned-integer-overflow:tx/transactors/nft/NFTokenMint.cpp
+unsigned-integer-overflow:app/misc/detail/AmendmentTable.cpp
+
+# Sentinel / bounded subtractions that wrap by design (loop counters, reverse
+# iteration, "not found" sentinels, balance math bounded by issuance invariants,
+# base58/base64 codec index math, hash-router and role bit math).
+unsigned-integer-overflow:shamap/SHAMap.cpp
+unsigned-integer-overflow:protocol/Permissions.cpp
+unsigned-integer-overflow:protocol/tokens.cpp
+unsigned-integer-overflow:basics/base64.cpp
+unsigned-integer-overflow:json/json_value.cpp
+unsigned-integer-overflow:app/misc/NetworkOPs.cpp
+unsigned-integer-overflow:rpc/detail/Role.cpp
+unsigned-integer-overflow:tx/transactors/oracle/OracleSet.cpp
+unsigned-integer-overflow:ledger/helpers/MPTokenHelpers.cpp
+unsigned-integer-overflow:crypto/RFC1751.cpp
+unsigned-integer-overflow:tx/paths/detail/StrandFlow.h
+unsigned-integer-overflow:protocol/STObject.h
+
+# GetAggregatePrice negates an unsigned trim count to step a reverse iterator;
+# trimCount is bounded by the price set size.
+unsigned-integer-overflow:rpc/handlers/orderbook/GetAggregatePrice.cpp
+
+# Test-only intentional overflow/underflow in fixture and unit-test arithmetic.
+unsigned-integer-overflow:tests/libxrpl/basics/RangeSet.cpp
+unsigned-integer-overflow:test/app/Batch_test.cpp
+unsigned-integer-overflow:test/app/Invariants_test.cpp
+unsigned-integer-overflow:test/app/Loan_test.cpp
+unsigned-integer-overflow:test/app/NFToken_test.cpp
+unsigned-integer-overflow:test/app/OfferMPT_test.cpp
+unsigned-integer-overflow:test/app/Offer_test.cpp
+unsigned-integer-overflow:test/app/Path_test.cpp
+unsigned-integer-overflow:test/jtx/impl/acctdelete.cpp
+unsigned-integer-overflow:test/ledger/SkipList_test.cpp
+unsigned-integer-overflow:test/rpc/Subscribe_test.cpp
+signed-integer-overflow:test/basics/XRPAmount_test.cpp
diff --git a/src/libxrpl/basics/Log.cpp b/src/libxrpl/basics/Log.cpp
index 1079f91280..d1e54a515f 100644
--- a/src/libxrpl/basics/Log.cpp
+++ b/src/libxrpl/basics/Log.cpp
@@ -61,7 +61,8 @@ Logs::File::open(boost::filesystem::path const& path)
     bool wasOpened = false;
 
     // VFALCO TODO Make this work with Unicode file paths
-    std::unique_ptr stream(new std::ofstream(path.c_str(), std::fstream::app));
+    std::unique_ptr stream =
+        std::make_unique(path.c_str(), std::fstream::app);
 
     if (stream->good())
     {
diff --git a/src/libxrpl/basics/Number.cpp b/src/libxrpl/basics/Number.cpp
index 06bd78d8b0..23e913bbdc 100644
--- a/src/libxrpl/basics/Number.cpp
+++ b/src/libxrpl/basics/Number.cpp
@@ -10,9 +10,11 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #ifdef _MSC_VER
@@ -28,7 +30,76 @@ using int128_t = __int128_t;
 namespace xrpl {
 
 thread_local Number::RoundingMode Number::mode = Number::RoundingMode::ToNearest;
-thread_local std::reference_wrapper Number::kRange = kLargeRange;
+thread_local std::reference_wrapper Number::kRange =
+    MantissaRange::getMantissaRange(MantissaRange::MantissaScale::Large);
+
+std::set const&
+MantissaRange::getAllScales()
+{
+    static std::set const kScales = {
+        MantissaRange::MantissaScale::Small,
+        MantissaRange::MantissaScale::LargeLegacy,
+        MantissaRange::MantissaScale::Large,
+    };
+    return kScales;
+}
+
+std::unordered_map const&
+MantissaRange::getRanges()
+{
+    static auto const kMap = []() {
+        std::unordered_map map;
+        for (auto const scale : getAllScales())
+        {
+            map.emplace(scale, scale);
+        }
+
+        // Use these constexpr declarations to do static_asserts to verify the MantissaRanges are
+        // created correctly, but nothing else.
+        {
+            [[maybe_unused]]
+            constexpr static MantissaRange kRange{MantissaRange::MantissaScale::Small};
+            static_assert(isPowerOfTen(kRange.min));
+            static_assert(kRange.min == 1'000'000'000'000'000LL);
+            static_assert(kRange.max == 9'999'999'999'999'999LL);
+            static_assert(kRange.log == 15);
+            static_assert(kRange.min < Number::kMaxRep);
+            static_assert(kRange.max < Number::kMaxRep);
+            static_assert(kRange.cuspRoundingFixEnabled == CuspRoundingFix::Disabled);
+        }
+        {
+            [[maybe_unused]]
+            constexpr static MantissaRange kRange{MantissaRange::MantissaScale::LargeLegacy};
+            static_assert(isPowerOfTen(kRange.min));
+            static_assert(kRange.min == 1'000'000'000'000'000'000ULL);
+            static_assert(kRange.max == rep(9'999'999'999'999'999'999ULL));
+            static_assert(kRange.log == 18);
+            static_assert(kRange.min < Number::kMaxRep);
+            static_assert(kRange.max > Number::kMaxRep);
+            static_assert(kRange.cuspRoundingFixEnabled == CuspRoundingFix::Disabled);
+        }
+        {
+            [[maybe_unused]]
+            constexpr static MantissaRange kRange{MantissaRange::MantissaScale::Large};
+            static_assert(isPowerOfTen(kRange.min));
+            static_assert(kRange.min == 1'000'000'000'000'000'000ULL);
+            static_assert(kRange.max == rep(9'999'999'999'999'999'999ULL));
+            static_assert(kRange.log == 18);
+            static_assert(kRange.min < Number::kMaxRep);
+            static_assert(kRange.max > Number::kMaxRep);
+            static_assert(kRange.cuspRoundingFixEnabled == CuspRoundingFix::Enabled);
+        }
+        return map;
+    }();
+
+    return kMap;
+}
+
+MantissaRange const&
+MantissaRange::getMantissaRange(MantissaScale scale)
+{
+    return getRanges().at(scale);
+}
 
 Number::RoundingMode
 Number::getround()
@@ -51,10 +122,37 @@ Number::getMantissaScale()
 void
 Number::setMantissaScale(MantissaRange::MantissaScale scale)
 {
-    if (scale != MantissaRange::MantissaScale::Small &&
-        scale != MantissaRange::MantissaScale::Large)
+    if (!MantissaRange::getAllScales().contains(scale))
         logicError("Unknown mantissa scale");
-    kRange = scale == MantissaRange::MantissaScale::Small ? kSmallRange : kLargeRange;
+    kRange = MantissaRange::getMantissaRange(scale);
+}
+
+// Optimization equivalent to:
+// auto r = static_cast(u % 10);
+// u /= 10;
+// return r;
+// Derived from Hacker's Delight Second Edition Chapter 10
+// by Henry S. Warren, Jr.
+static inline unsigned
+divu10(uint128_t& u)
+{
+    // q = u * 0.75
+    auto q = (u >> 1) + (u >> 2);
+    // iterate towards q = u * 0.8
+    q += q >> 4;
+    q += q >> 8;
+    q += q >> 16;
+    q += q >> 32;
+    q += q >> 64;
+    // q /= 8 approximately == u / 10
+    q >>= 3;
+    // r = u - q * 10  approximately == u % 10
+    auto r = static_cast(u - ((q << 3) + (q << 1)));
+    // correction c is 1 if r >= 10 else 0
+    auto c = (r + 6) >> 4;
+    u = q + c;
+    r -= c * 10;
+    return r;
 }
 
 // Guard
@@ -80,6 +178,10 @@ public:
     setPositive() noexcept;
     void
     setNegative() noexcept;
+    // Should only be called by doNormalize, and then only for division
+    // operations with remainders.
+    void
+    setDropped() noexcept;
     [[nodiscard]] bool
     isNegative() const noexcept;
 
@@ -92,6 +194,18 @@ public:
     unsigned
     pop() noexcept;
 
+    /** Drop a digit from the mantissa, and increment the exponent, storing the dropped digit in
+     * this Guard.
+     *
+     * Substitute for:
+                push(mantissa % 10);
+                mantissa /= 10;
+                ++exponent;
+     */
+    template 
+    void
+    doDropDigit(T& mantissa, int& exponent) noexcept;
+
     // Indicate round direction:  1 is up, -1 is down, 0 is even
     // This enables the client to round towards nearest, and on
     // tie, round towards even.
@@ -107,6 +221,7 @@ public:
         int& exponent,
         internalrep const& minMantissa,
         internalrep const& maxMantissa,
+        MantissaRange::CuspRoundingFix cuspRoundingFixEnabled,
         std::string location);
 
     // Modify the result to the correctly rounded value
@@ -139,6 +254,12 @@ Number::Guard::setNegative() noexcept
     sbit_ = 1;
 }
 
+inline void
+Number::Guard::setDropped() noexcept
+{
+    xbit_ = 1;
+}
+
 inline bool
 Number::Guard::isNegative() const noexcept
 {
@@ -168,6 +289,27 @@ Number::Guard::pop() noexcept
     return d;
 }
 
+template 
+void
+Number::Guard::doDropDigit(T& mantissa, int& exponent) noexcept
+{
+    push(mantissa % 10);
+    mantissa /= 10;
+    ++exponent;
+}
+
+// Use the divu10 optimization for uint128s
+template <>
+void
+Number::Guard::doDropDigit(uint128_t& mantissa, int& exponent) noexcept
+{
+    // The following is optimization for:
+    // push(static_cast(mantissa % 10));
+    // mantissa /= 10;
+    push(divu10(mantissa));
+    ++exponent;
+}
+
 // Returns:
 //     -1 if Guard is less than half
 //      0 if Guard is exactly half
@@ -242,18 +384,60 @@ Number::Guard::doRoundUp(
     int& exponent,
     internalrep const& minMantissa,
     internalrep const& maxMantissa,
+    MantissaRange::CuspRoundingFix cuspRoundingFixEnabled,
     std::string location)
 {
     auto r = round();
     if (r == 1 || (r == 0 && (mantissa & 1) == 1))
     {
-        ++mantissa;
-        // Ensure mantissa after incrementing fits within both the
-        // min/maxMantissa range and is a valid "rep".
-        if (mantissa > maxMantissa || mantissa > kMaxRep)
+        auto const safeToIncrement = [&maxMantissa](auto const& mantissa) {
+            return mantissa < maxMantissa && mantissa < kMaxRep;
+        };
+        if (cuspRoundingFixEnabled == MantissaRange::CuspRoundingFix::Enabled)
         {
-            mantissa /= 10;
-            ++exponent;
+            // Ensure mantissa after incrementing fits within both the
+            // min/maxMantissa range and is a valid "rep".
+            if (safeToIncrement(mantissa))
+            {
+                // Nothing unusual here, just increment the mantissa
+                ++mantissa;
+            }
+            else
+            {
+                // Incrementing the mantissa will require dividing, which will require rounding. So
+                // _don't_ increment the mantissa. Instead, divide and round recursively. It should
+                // be impossible to recurse more than once, because once the mantissa is divided by
+                // 10, it will be _well_ under maxMantissa and kMaxRep, so adding 1 will have no
+                // chance of bringing it back over.
+                doDropDigit(mantissa, exponent);
+                XRPL_ASSERT_PARTS(
+                    safeToIncrement(mantissa),
+                    "xrpl::Number::Guard::doRoundUp",
+                    "can't recurse more than once");
+                doRoundUp(
+                    negative,
+                    mantissa,
+                    exponent,
+                    minMantissa,
+                    maxMantissa,
+                    cuspRoundingFixEnabled,
+                    location);
+                return;
+            }
+        }
+        else
+        {
+            // Need to preserve the incorrect behavior until the fix amendment can be retired,
+            // because otherwise would risk an unplanned ledger fork.
+            ++mantissa;
+            // Ensure mantissa after incrementing fits within both the
+            // min/maxMantissa range and is a valid "rep".
+            if (mantissa > maxMantissa || mantissa > kMaxRep)
+            {
+                // Don't use doDropDigit here
+                mantissa /= 10;
+                ++exponent;
+            }
         }
     }
     bringIntoRange(negative, mantissa, exponent, minMantissa);
@@ -293,9 +477,9 @@ Number::Guard::doRound(rep& drops, std::string location) const
         {
             static_assert(sizeof(internalrep) == sizeof(rep));
             // This should be impossible, because it's impossible to represent
-            // "maxRep + 0.6" in Number, regardless of the scale. There aren't
-            // enough digits available. You'd either get a mantissa of "maxRep"
-            // or "(maxRep + 1) / 10", neither of which will round up when
+            // "kMaxRep + 0.6" in Number, regardless of the scale. There aren't
+            // enough digits available. You'd either get a mantissa of "kMaxRep"
+            // or "(kMaxRep + 1) / 10", neither of which will round up when
             // converting to rep, though the latter might overflow _before_
             // rounding.
             Throw(std::string(location));  // LCOV_EXCL_LINE
@@ -331,33 +515,13 @@ Number::externalToInternal(rep mantissa)
     return static_cast(-temp);
 }
 
-constexpr Number
-Number::oneSmall()
-{
-    return Number{false, Number::kSmallRange.min, -Number::kSmallRange.log, Number::Unchecked{}};
-};
-
-constexpr Number kOneSml = Number::oneSmall();
-
-constexpr Number
-Number::oneLarge()
-{
-    return Number{false, Number::kLargeRange.min, -Number::kLargeRange.log, Number::Unchecked{}};
-};
-
-constexpr Number kOneLrg = Number::oneLarge();
-
 Number
 Number::one()
 {
-    if (&kRange.get() == &kSmallRange)
-        return kOneSml;
-    XRPL_ASSERT(&kRange.get() == &kLargeRange, "Number::one() : valid range");
-    return kOneLrg;
+    auto const& range = kRange.get();
+    return Number{false, range.min, -range.log, Number::Unchecked{}};
 }
 
-// Use the member names in this static function for now so the diff is cleaner
-// TODO: Rename the function parameters to get rid of the "_" suffix
 template 
 void
 doNormalize(
@@ -365,7 +529,9 @@ doNormalize(
     T& mantissa,
     int& exponent,
     MantissaRange::rep const& minMantissa,
-    MantissaRange::rep const& maxMantissa)
+    MantissaRange::rep const& maxMantissa,
+    MantissaRange::CuspRoundingFix cuspRoundingFixEnabled,
+    bool dropped)
 {
     static constexpr auto kMinExponent = Number::kMinExponent;
     static constexpr auto kMaxExponent = Number::kMaxExponent;
@@ -390,13 +556,13 @@ doNormalize(
     Guard g;
     if (negative)
         g.setNegative();
+    if (dropped)
+        g.setDropped();
     while (m > maxMantissa)
     {
         if (exponent >= kMaxExponent)
             throw std::overflow_error("Number::normalize 1");
-        g.push(m % 10);
-        m /= 10;
-        ++exponent;
+        g.doDropDigit(m, exponent);
     }
     if ((exponent < kMinExponent) || (m < minMantissa))
     {
@@ -407,7 +573,7 @@ doNormalize(
     }
 
     // When using the largeRange, "m" needs fit within an int64, even if
-    // the final mantissa_ is going to end up larger to fit within the
+    // the final mantissa is going to end up larger to fit within the
     // MantissaRange. Cut it down here so that the rounding will be done while
     // it's smaller.
     //
@@ -415,26 +581,31 @@ doNormalize(
     //      so "m" will be modified to 990,000,000,000,012,345. Then that value
     //      will be rounded to 990,000,000,000,012,345 or
     //      990,000,000,000,012,346, depending on the rounding mode. Finally,
-    //      mantissa_ will be "m*10" so it fits within the range, and end up as
+    //      mantissa will be "m*10" so it fits within the range, and end up as
     //      9,900,000,000,000,123,450 or 9,900,000,000,000,123,460.
-    // mantissa() will return mantissa_ / 10, and exponent() will return
-    // exponent_ + 1.
+    // mantissa() will return mantissa / 10, and exponent() will return
+    // exponent + 1.
     if (m > kMaxRep)
     {
         if (exponent >= kMaxExponent)
             throw std::overflow_error("Number::normalize 1.5");
-        g.push(m % 10);
-        m /= 10;
-        ++exponent;
+        g.doDropDigit(m, exponent);
     }
     // Before modification, m should be within the min/max range. After
-    // modification, it must be less than maxRep. In other words, the original
-    // value should have been no more than maxRep * 10.
-    // (maxRep * 10 > maxMantissa)
+    // modification, it must be less than kMaxRep. In other words, the original
+    // value should have been no more than kMaxRep * 10.
+    // (kMaxRep * 10 > maxMantissa)
     XRPL_ASSERT_PARTS(m <= kMaxRep, "xrpl::doNormalize", "intermediate mantissa fits in int64");
     mantissa = m;
 
-    g.doRoundUp(negative, mantissa, exponent, minMantissa, maxMantissa, "Number::normalize 2");
+    g.doRoundUp(
+        negative,
+        mantissa,
+        exponent,
+        minMantissa,
+        maxMantissa,
+        cuspRoundingFixEnabled,
+        "Number::normalize 2");
     XRPL_ASSERT_PARTS(
         mantissa >= minMantissa && mantissa <= maxMantissa,
         "xrpl::doNormalize",
@@ -448,9 +619,15 @@ Number::normalize(
     uint128_t& mantissa,
     int& exponent,
     internalrep const& minMantissa,
-    internalrep const& maxMantissa)
+    internalrep const& maxMantissa,
+    MantissaRange::CuspRoundingFix cuspRoundingFixEnabled)
 {
-    doNormalize(negative, mantissa, exponent, minMantissa, maxMantissa);
+    // Not used by every compiler version, and thus not necessarily
+    // counted by coverage build
+    // LCOV_EXCL_START
+    doNormalize(
+        negative, mantissa, exponent, minMantissa, maxMantissa, cuspRoundingFixEnabled, false);
+    // LCOV_EXCL_STOP
 }
 
 template <>
@@ -460,9 +637,15 @@ Number::normalize(
     unsigned long long& mantissa,
     int& exponent,
     internalrep const& minMantissa,
-    internalrep const& maxMantissa)
+    internalrep const& maxMantissa,
+    MantissaRange::CuspRoundingFix cuspRoundingFixEnabled)
 {
-    doNormalize(negative, mantissa, exponent, minMantissa, maxMantissa);
+    // Not used by every compiler version, and thus not necessarily
+    // counted by coverage build
+    // LCOV_EXCL_START
+    doNormalize(
+        negative, mantissa, exponent, minMantissa, maxMantissa, cuspRoundingFixEnabled, false);
+    // LCOV_EXCL_STOP
 }
 
 template <>
@@ -472,16 +655,17 @@ Number::normalize(
     unsigned long& mantissa,
     int& exponent,
     internalrep const& minMantissa,
-    internalrep const& maxMantissa)
+    internalrep const& maxMantissa,
+    MantissaRange::CuspRoundingFix cuspRoundingFixEnabled)
 {
-    doNormalize(negative, mantissa, exponent, minMantissa, maxMantissa);
+    doNormalize(
+        negative, mantissa, exponent, minMantissa, maxMantissa, cuspRoundingFixEnabled, false);
 }
 
 void
-Number::normalize()
+Number::normalize(MantissaRange const& range)
 {
-    auto const& range = kRange.get();
-    normalize(negative_, mantissa_, exponent_, range.min, range.max);
+    normalize(negative_, mantissa_, exponent_, range.min, range.max, range.cuspRoundingFixEnabled);
 }
 
 // Copy the number, but set a new exponent. Because the mantissa doesn't change,
@@ -542,9 +726,7 @@ Number::operator+=(Number const& y)
             g.setNegative();
         do
         {
-            g.push(xm % 10);
-            xm /= 10;
-            ++xe;
+            g.doDropDigit(xm, xe);
         } while (xe < ye);
     }
     else if (xe > ye)
@@ -553,26 +735,30 @@ Number::operator+=(Number const& y)
             g.setNegative();
         do
         {
-            g.push(ym % 10);
-            ym /= 10;
-            ++ye;
+            g.doDropDigit(ym, ye);
         } while (xe > ye);
     }
 
     auto const& range = kRange.get();
     auto const& minMantissa = range.min;
     auto const& maxMantissa = range.max;
+    auto const cuspRoundingFixEnabled = range.cuspRoundingFixEnabled;
 
     if (xn == yn)
     {
         xm += ym;
         if (xm > maxMantissa || xm > kMaxRep)
         {
-            g.push(xm % 10);
-            xm /= 10;
-            ++xe;
+            g.doDropDigit(xm, xe);
         }
-        g.doRoundUp(xn, xm, xe, minMantissa, maxMantissa, "Number::addition overflow");
+        g.doRoundUp(
+            xn,
+            xm,
+            xe,
+            minMantissa,
+            maxMantissa,
+            cuspRoundingFixEnabled,
+            "Number::addition overflow");
     }
     else
     {
@@ -598,38 +784,10 @@ Number::operator+=(Number const& y)
     negative_ = xn;
     mantissa_ = static_cast(xm);
     exponent_ = xe;
-    normalize();
+    normalize(range);
     return *this;
 }
 
-// Optimization equivalent to:
-// auto r = static_cast(u % 10);
-// u /= 10;
-// return r;
-// Derived from Hacker's Delight Second Edition Chapter 10
-// by Henry S. Warren, Jr.
-static inline unsigned
-divu10(uint128_t& u)
-{
-    // q = u * 0.75
-    auto q = (u >> 1) + (u >> 2);
-    // iterate towards q = u * 0.8
-    q += q >> 4;
-    q += q >> 8;
-    q += q >> 16;
-    q += q >> 32;
-    q += q >> 64;
-    // q /= 8 approximately == u / 10
-    q >>= 3;
-    // r = u - q * 10  approximately == u % 10
-    auto r = static_cast(u - ((q << 3) + (q << 1)));
-    // correction c is 1 if r >= 10 else 0
-    auto c = (r + 6) >> 4;
-    u = q + c;
-    r -= c * 10;
-    return r;
-}
-
 Number&
 Number::operator*=(Number const& y)
 {
@@ -667,15 +825,13 @@ Number::operator*=(Number const& y)
     auto const& range = kRange.get();
     auto const& minMantissa = range.min;
     auto const& maxMantissa = range.max;
+    auto const cuspRoundingFixEnabled = range.cuspRoundingFixEnabled;
 
     while (zm > maxMantissa || zm > kMaxRep)
     {
-        // The following is optimization for:
-        // g.push(static_cast(zm % 10));
-        // zm /= 10;
-        g.push(divu10(zm));
-        ++ze;
+        g.doDropDigit(zm, ze);
     }
+
     xm = static_cast(zm);
     xe = ze;
     g.doRoundUp(
@@ -684,12 +840,13 @@ Number::operator*=(Number const& y)
         xe,
         minMantissa,
         maxMantissa,
+        cuspRoundingFixEnabled,
         "Number::multiplication overflow : exponent is " + std::to_string(xe));
     negative_ = zn;
     mantissa_ = xm;
     exponent_ = xe;
 
-    normalize();
+    normalize(range);
     return *this;
 }
 
@@ -703,7 +860,9 @@ Number::operator/=(Number const& y)
         return *this;
     // n* = numerator
     // d* = denominator
-    // *p = negative (positive?)
+    // z* = result (quotient)
+    // *p = negative (p for positive, even though the value means not
+    //      positive?)
     // *s = sign
     // *m = mantissa
     // *e = exponent
@@ -715,72 +874,155 @@ Number::operator/=(Number const& y)
 
     bool const dp = y.negative_;
     int const ds = (dp ? -1 : 1);
-    auto dm = y.mantissa_;
-    auto de = y.exponent_;
+    // Create the denominator as 128-bit unsigned, since that's what we
+    // need to work with.
+    uint128_t const dm = static_cast(y.mantissa_);
+    auto const de = y.exponent_;
 
     auto const& range = kRange.get();
     auto const& minMantissa = range.min;
     auto const& maxMantissa = range.max;
+    auto const cuspRoundingFixEnabled = range.cuspRoundingFixEnabled;
 
-    // Shift by 10^17 gives greatest precision while not overflowing
-    // uint128_t or the cast back to int64_t
-    // TODO: Can/should this be made bigger for largeRange?
-    // log(2^128,10) ~ 38.5
-    // largeRange.log = 18, fits in 10^19
-    // f can be up to 10^(38-19) = 10^19 safely
-    static_assert(kSmallRange.log == 15);
-    static_assert(kLargeRange.log == 18);
-    bool const small = Number::getMantissaScale() == MantissaRange::MantissaScale::Small;
-    uint128_t const f = small ? 100'000'000'000'000'000 : 10'000'000'000'000'000'000ULL;
-    XRPL_ASSERT_PARTS(f >= minMantissa * 10, "Number::operator/=", "factor expected size");
+    // Division operates on two large integers (16-digit for small
+    // mantissas, 19-digit for large) using integer math. If the values
+    // were just divided directly, the result would be only ever be one
+    // digit or zero - not very useful.
+    // e.g. 9'876'543'210'987'654 / 1'234'567'890'123'456 = 8
+    //      1'234'567'890'123'456 / 9'876'543'210'987'654 = 0
+    // Introduce a power-of-ten multiplication factor for the numerator
+    // which will ensure the result has a meaningful number of digits.
+    //
+    // Consider numbers with a 2-digit mantissa:
+    // * Assume both numbers have an exponent of 0, using "ToNearest" rounding
+    // * 23 / 67 = 0
+    // * Use a factor of 10^4
+    // * 230'000 / 67 = 3432 with an exponent of -4
+    // * The normalized result will be 34, exponent -2, or 0.34
+    //
+    // The most extreme results are 10/99 and 99/10
+    // * 100'000 / 99 =  1'010e-4 = 10e-2 or 0.10
+    // * 990'000 / 10 = 99'000e-4 = 99e-1 or 9.9
+    //
+    // Note that the computations give 2 or 3 digits after the
+    // decimal point to determine which way to round for most scenarios.
+    //
+    // For small mantissas (where the MantissaRange.log == 15), shifting by 10^17 gives sufficient
+    // precision while not overflowing uint128_t or the cast back to int64_t. (This is legacy
+    // behavior, which must not be changed.)
+    //
+    // For large mantissas (where the MantissaRange.log == 18), a shift by 10^20 would be optimal
+    // for most scenarios. However, larger mantissa values would overflow 2^128.
+    //
+    // * log(2^128,10) ~ 38.5
+    // * largeRange.log = 18, fits in 10^19
+    // * The expanded numerator must fit in 10^38
+    // * f not be more than 10^(38-19) = 10^19 safely
+    //
+    // So, we do the division into stages:
+    //
+    // Stage 1: Use the same factor of 10^17, for the initial division. This
+    // will frequently not result in a whole number quotient.
+    //
+    // Stage 2: If there is a remainder from the first step, repeat the
+    // process with a "correction" factor of 10^5. Shift the
+    // result of Stage 1 over by 5 places, and add the second result to it.
+    // This is equivalent to if we had used an initial factor of 10^22,
+    // a couple digits more than we actually need.
+    //
+    // Stage 3: If there is still a remainder, and the CuspRoundingFix
+    // is enabled, pass a flag indicating such to doNormalize. The Guard
+    // in doNormalize will treat that flag as if non-zero digits had
+    // been dropped from the mantissa when shrinking it into range.
+    // This is only relevant when rounding away from zero (Upward for
+    // positive numbers, Downward for negative), or if the "regular"
+    // remainder is exactly 0.5 for "ToNearest". This will give the
+    // rounding the most accurate result possible, as if infinite
+    // precision was used in the initial calculation.
 
-    // unsigned denominator
-    auto const dmu = static_cast(dm);
-    // correctionFactor can be anything between 10 and f, depending on how much
-    // extra precision we want to only use for rounding with the
-    // largeRange. Three digits seems like plenty, and is more than
-    // the smallRange uses.
-    uint128_t const correctionFactor = 1'000;
+    // Stage 1: Do the initial division with a factor of 10^17.
+    auto constexpr factorExponent = 17;
+
+    uint128_t constexpr f = kPowerOfTen[factorExponent];
 
     auto const numerator = uint128_t(nm) * f;
 
-    auto zm = numerator / dmu;
-    auto ze = ne - de - (small ? 17 : 19);
-    bool zn = (ns * ds) < 0;
-    if (!small)
+    auto zm = numerator / dm;
+    auto ze = ne - de - factorExponent;
+    bool zp = (ns * ds) < 0;
+    // dropped is used in the same way as Guard::xbit_. In the case of
+    // division, it indicates if there's any remainder left over after
+    // we have been as precise as reasonable. If there is, it would be as
+    // if we were using infinite precision math, and a non-zero digit
+    // had been shifted off the end of the result when normalizing.
+    bool dropped = false;
+
+    if (range.scale != MantissaRange::MantissaScale::Small)
     {
-        // Virtually multiply numerator by correctionFactor. Since that would
-        // overflow in the existing uint128_t, we'll do that part separately.
+        // Stage 2
+        //
+        // If there is a remainder, treat it as a secondary numerator.
+        // Multiply by correctionFactor separately from stage 1.
         // The math for this would work for small mantissas, but we need to
-        // preserve existing behavior.
+        // preserve legacy behavior.
         //
         // Consider:
-        // ((numerator * correctionFactor) / dmu) / correctionFactor
-        // = ((numerator / dmu) * correctionFactor) / correctionFactor)
+        // ((numerator * correctionFactor) / dm) / correctionFactor
+        // = ((numerator / dm) * correctionFactor) / correctionFactor)
         //
         // But that assumes infinite precision. With integer math, this is
         // equivalent to
         //
-        // = ((numerator / dmu * correctionFactor)
-        //   + ((numerator % dmu) * correctionFactor) / dmu) / correctionFactor
+        // = ((numerator / dm * correctionFactor)
+        //   + ((numerator % dm) * correctionFactor) / dm) / correctionFactor
+        // = ((zm * correctionFactor)
+        //   + (remainder * correctionFactor) / dm) / correctionFactor
         //
-        // We have already set `mantissa_ = numerator / dmu`. Now we
-        // compute `remainder = numerator % dmu`, and if it is
-        // nonzero, we do the rest of the arithmetic. If it's zero, we can skip
-        // it.
-        auto const remainder = (numerator % dmu);
+        // The trick is that multiplication by correctionFactor is done on the mantissa, but
+        // division by correctionFactor is done by modifying the exponent, so no precision is lost
+        // until we normalize.
+        //
+        // If remainder is zero, we can skip this stage entirely because
+        // the first stage gave an exact answer.
+        auto constexpr correctionExponent = 5;
+        uint128_t constexpr correctionFactor = kPowerOfTen[correctionExponent];
+        static_assert(factorExponent + correctionExponent == 22);
+
+        auto const remainder = (numerator % dm);
         if (remainder != 0)
         {
-            zm *= correctionFactor;
-            auto const correction = remainder * correctionFactor / dmu;
-            zm += correction;
-            // divide by 1000 by moving the exponent, so we don't lose the
-            // integer value we just computed
-            ze -= 3;
+            auto const partialNumerator = remainder * correctionFactor;
+            auto const correction = partialNumerator / dm;
+
+            // If the correction is zero, we do not have to make any
+            // modifications to z*, because it will not have any
+            // effect on the final result. (We'd be adding a bunch of
+            // zeros to the end of zm that would just be removed in
+            // normalize.) However, if that is the case, then Stage 3 is
+            // even more important for accuracy.
+            if (correction != 0)
+            {
+                zm *= correctionFactor;
+                // divide by the correctionFactor by moving the exponent, so we don't lose the
+                // integer value we just computed
+                ze -= correctionExponent;
+
+                zm += correction;
+            }
+
+            // Stage 3: If there's still anything left, and the cusp
+            // rounding fix is enabled, flag if there is still
+            // a remainder from stage 2.
+            bool const useTrailingRemainder =
+                cuspRoundingFixEnabled == MantissaRange::CuspRoundingFix::Enabled;
+            if (useTrailingRemainder)
+            {
+                dropped = partialNumerator % dm != 0;
+            }
         }
     }
-    normalize(zn, zm, ze, minMantissa, maxMantissa);
-    negative_ = zn;
+    doNormalize(zp, zm, ze, minMantissa, maxMantissa, cuspRoundingFixEnabled, dropped);
+    negative_ = zp;
     mantissa_ = static_cast(zm);
     exponent_ = ze;
     XRPL_ASSERT_PARTS(isnormal(), "xrpl::Number::operator/=", "result is normalized");
@@ -801,10 +1043,9 @@ operator rep() const
             g.setNegative();
             drops = -drops;
         }
-        for (; offset < 0; ++offset)
+        while (offset < 0)
         {
-            g.push(drops % 10);
-            drops /= 10;
+            g.doDropDigit(drops, offset);
         }
         for (; offset > 0; --offset)
         {
@@ -831,7 +1072,7 @@ Number::truncate() const noexcept
     }
     // We are guaranteed that normalize() will never throw an exception
     // because exponent is either negative or zero at this point.
-    ret.normalize();
+    ret.normalize(kRange);
     return ret;
 }
 
@@ -1000,9 +1241,11 @@ root(Number f, unsigned d)
     }
 
     // Quadratic least squares curve fit of f^(1/d) in the range [0, 1]
-    auto const D = (((6 * di + 11) * di + 6) * di) + 1;  // NOLINT(readability-identifier-naming)
-    auto const a0 = 3 * di * ((2 * di - 3) * di + 1);
-    auto const a1 = 24 * di * (2 * di - 1);
+
+    // NOLINTNEXTLINE(readability-identifier-naming)
+    auto const D = (((((6 * di) + 11) * di) + 6) * di) + 1;
+    auto const a0 = 3 * di * ((((2 * di) - 3) * di) + 1);
+    auto const a1 = 24 * di * ((2 * di) - 1);
     auto const a2 = -30 * (di - 1) * di;
     Number r = ((Number{a2} * f + Number{a1}) * f + Number{a0}) / Number{D};
     if (neg)
diff --git a/src/libxrpl/basics/ResolverAsio.cpp b/src/libxrpl/basics/ResolverAsio.cpp
index 4a5ceb3d8d..ddf006b681 100644
--- a/src/libxrpl/basics/ResolverAsio.cpp
+++ b/src/libxrpl/basics/ResolverAsio.cpp
@@ -108,7 +108,7 @@ public:
 
     beast::Journal journal;
 
-    boost::asio::io_context& io_context;
+    boost::asio::io_context& ioContext;
     boost::asio::strand strand;
     boost::asio::ip::tcp::resolver resolver;
 
@@ -116,7 +116,7 @@ public:
     std::mutex mut;
     bool asyncHandlersCompleted{true};
 
-    std::atomic stop_called;
+    std::atomic stopCalled;
     std::atomic stopped;
 
     // Represents a unit of work for the resolver to do
@@ -138,10 +138,10 @@ public:
 
     ResolverAsioImpl(boost::asio::io_context& ioContext, beast::Journal journal)
         : journal(journal)
-        , io_context(ioContext)
+        , ioContext(ioContext)
         , strand(boost::asio::make_strand(ioContext))
         , resolver(ioContext)
-        , stop_called(false)
+        , stopCalled(false)
         , stopped(true)
     {
     }
@@ -172,7 +172,7 @@ public:
     start() override
     {
         XRPL_ASSERT(stopped == true, "xrpl::ResolverAsioImpl::start : stopped");
-        XRPL_ASSERT(stop_called == false, "xrpl::ResolverAsioImpl::start : not stopping");
+        XRPL_ASSERT(stopCalled == false, "xrpl::ResolverAsioImpl::start : not stopping");
 
         if (stopped.exchange(false))
         {
@@ -187,10 +187,10 @@ public:
     void
     stopAsync() override
     {
-        if (!stop_called.exchange(true))
+        if (!stopCalled.exchange(true))
         {
             boost::asio::dispatch(
-                io_context,
+                ioContext,
                 boost::asio::bind_executor(
                     strand, std::bind(&ResolverAsioImpl::doStop, this, CompletionCounter(this))));
 
@@ -213,13 +213,13 @@ public:
     void
     resolve(std::vector const& names, HandlerType const& handler) override
     {
-        XRPL_ASSERT(stop_called == false, "xrpl::ResolverAsioImpl::resolve : not stopping");
+        XRPL_ASSERT(stopCalled == false, "xrpl::ResolverAsioImpl::resolve : not stopping");
         XRPL_ASSERT(!names.empty(), "xrpl::ResolverAsioImpl::resolve : names non-empty");
 
         // TODO NIKB use rvalue references to construct and move
         //           reducing cost.
         boost::asio::dispatch(
-            io_context,
+            ioContext,
             boost::asio::bind_executor(
                 strand,
                 std::bind(
@@ -231,7 +231,7 @@ public:
     void
     doStop(CompletionCounter)
     {
-        XRPL_ASSERT(stop_called == true, "xrpl::ResolverAsioImpl::doStop : stopping");
+        XRPL_ASSERT(stopCalled == true, "xrpl::ResolverAsioImpl::doStop : stopping");
 
         if (!stopped.exchange(true))
         {
@@ -270,7 +270,7 @@ public:
         handler(name, addresses);
 
         boost::asio::post(
-            io_context,
+            ioContext,
             boost::asio::bind_executor(
                 strand, std::bind(&ResolverAsioImpl::doWork, this, CompletionCounter(this))));
     }
@@ -324,7 +324,7 @@ public:
     void
     doWork(CompletionCounter)
     {
-        if (stop_called)
+        if (stopCalled)
             return;
 
         // We don't have any work to do at this time
@@ -346,7 +346,7 @@ public:
             JLOG(journal.error()) << "Unable to parse '" << name << "'";
 
             boost::asio::post(
-                io_context,
+                ioContext,
                 boost::asio::bind_executor(
                     strand, std::bind(&ResolverAsioImpl::doWork, this, CompletionCounter(this))));
 
@@ -371,7 +371,7 @@ public:
     {
         XRPL_ASSERT(!names.empty(), "xrpl::ResolverAsioImpl::doResolve : names non-empty");
 
-        if (!stop_called)
+        if (!stopCalled)
         {
             work.emplace_back(names, handler);
 
@@ -381,7 +381,7 @@ public:
             if (!work.empty())
             {
                 boost::asio::post(
-                    io_context,
+                    ioContext,
                     boost::asio::bind_executor(
                         strand,
                         std::bind(&ResolverAsioImpl::doWork, this, CompletionCounter(this))));
diff --git a/src/libxrpl/beast/core/CurrentThreadName.cpp b/src/libxrpl/beast/core/CurrentThreadName.cpp
index 52d9063179..628fec5b7a 100644
--- a/src/libxrpl/beast/core/CurrentThreadName.cpp
+++ b/src/libxrpl/beast/core/CurrentThreadName.cpp
@@ -71,7 +71,6 @@ setCurrentThreadNameImpl(std::string_view name)
 #if BOOST_OS_LINUX
 #include 
 
-#include 
 #include   // IWYU pragma: keep
 
 namespace beast::detail {
diff --git a/src/libxrpl/beast/insight/StatsDCollector.cpp b/src/libxrpl/beast/insight/StatsDCollector.cpp
index 7d460d54ef..0d0e013274 100644
--- a/src/libxrpl/beast/insight/StatsDCollector.cpp
+++ b/src/libxrpl/beast/insight/StatsDCollector.cpp
@@ -66,7 +66,7 @@ public:
 class StatsDHookImpl : public HookImpl, public StatsDMetricBase
 {
 public:
-    StatsDHookImpl(HandlerType handler, std::shared_ptr const& impl);
+    StatsDHookImpl(HandlerType handler, std::shared_ptr impl);
 
     ~StatsDHookImpl() override;
 
@@ -86,7 +86,7 @@ private:
 class StatsDCounterImpl : public CounterImpl, public StatsDMetricBase
 {
 public:
-    StatsDCounterImpl(std::string name, std::shared_ptr const& impl);
+    StatsDCounterImpl(std::string name, std::shared_ptr impl);
 
     ~StatsDCounterImpl() override;
 
@@ -115,7 +115,7 @@ private:
 class StatsDEventImpl : public EventImpl
 {
 public:
-    StatsDEventImpl(std::string name, std::shared_ptr const& impl);
+    StatsDEventImpl(std::string name, std::shared_ptr impl);
 
     ~StatsDEventImpl() override = default;
 
@@ -140,7 +140,7 @@ private:
 class StatsDGaugeImpl : public GaugeImpl, public StatsDMetricBase
 {
 public:
-    StatsDGaugeImpl(std::string name, std::shared_ptr const& impl);
+    StatsDGaugeImpl(std::string name, std::shared_ptr impl);
 
     ~StatsDGaugeImpl() override;
 
@@ -164,7 +164,7 @@ public:
 private:
     std::shared_ptr impl_;
     std::string name_;
-    GaugeImpl::value_type last_value_{0};
+    GaugeImpl::value_type lastValue_{0};
     GaugeImpl::value_type value_{0};
     bool dirty_{false};
 };
@@ -174,7 +174,7 @@ private:
 class StatsDMeterImpl : public MeterImpl, public StatsDMetricBase
 {
 public:
-    explicit StatsDMeterImpl(std::string name, std::shared_ptr const& impl);
+    explicit StatsDMeterImpl(std::string name, std::shared_ptr impl);
 
     ~StatsDMeterImpl() override;
 
@@ -209,7 +209,7 @@ private:
     Journal journal_;
     IP::Endpoint address_;
     std::string prefix_;
-    boost::asio::io_context io_context_;
+    boost::asio::io_context ioContext_;
     std::optional> work_;
     boost::asio::strand strand_;
     boost::asio::basic_waitable_timer timer_;
@@ -232,10 +232,10 @@ public:
         : journal_(journal)
         , address_(std::move(address))
         , prefix_(std::move(prefix))
-        , work_(boost::asio::make_work_guard(io_context_))
-        , strand_(boost::asio::make_strand(io_context_))
-        , timer_(io_context_)
-        , socket_(io_context_)
+        , work_(boost::asio::make_work_guard(ioContext_))
+        , strand_(boost::asio::make_strand(ioContext_))
+        , timer_(ioContext_)
+        , socket_(ioContext_)
         , thread_(&StatsDCollectorImp::run, this)
     {
     }
@@ -306,7 +306,7 @@ public:
     boost::asio::io_context&
     getIoContext()
     {
-        return io_context_;
+        return ioContext_;
     }
 
     std::string const&
@@ -325,7 +325,7 @@ public:
     postBuffer(std::string&& buffer)
     {
         boost::asio::dispatch(
-            io_context_,
+            ioContext_,
             boost::asio::bind_executor(
                 strand_, std::bind(&StatsDCollectorImp::doPostBuffer, this, std::move(buffer))));
     }
@@ -465,21 +465,21 @@ public:
 
         setTimer();
 
-        io_context_.run();
+        ioContext_.run();
 
         // NOLINTNEXTLINE(bugprone-unused-return-value)
         socket_.shutdown(boost::asio::ip::udp::socket::shutdown_send, ec);
 
         socket_.close();
 
-        io_context_.poll();
+        ioContext_.poll();
     }
 };
 
 //------------------------------------------------------------------------------
 
-StatsDHookImpl::StatsDHookImpl(HandlerType handler, std::shared_ptr const& impl)
-    : impl_(impl), handler_(std::move(handler))
+StatsDHookImpl::StatsDHookImpl(HandlerType handler, std::shared_ptr impl)
+    : impl_(std::move(impl)), handler_(std::move(handler))
 {
     impl_->add(*this);
 }
@@ -497,10 +497,8 @@ StatsDHookImpl::doProcess()
 
 //------------------------------------------------------------------------------
 
-StatsDCounterImpl::StatsDCounterImpl(
-    std::string name,
-    std::shared_ptr const& impl)
-    : impl_(impl), name_(std::move(name))
+StatsDCounterImpl::StatsDCounterImpl(std::string name, std::shared_ptr impl)
+    : impl_(std::move(impl)), name_(std::move(name))
 {
     impl_->add(*this);
 }
@@ -550,8 +548,8 @@ StatsDCounterImpl::doProcess()
 
 //------------------------------------------------------------------------------
 
-StatsDEventImpl::StatsDEventImpl(std::string name, std::shared_ptr const& impl)
-    : impl_(impl), name_(std::move(name))
+StatsDEventImpl::StatsDEventImpl(std::string name, std::shared_ptr impl)
+    : impl_(std::move(impl)), name_(std::move(name))
 {
 }
 
@@ -577,8 +575,8 @@ StatsDEventImpl::doNotify(EventImpl::value_type const& value)
 
 //------------------------------------------------------------------------------
 
-StatsDGaugeImpl::StatsDGaugeImpl(std::string name, std::shared_ptr const& impl)
-    : impl_(impl), name_(std::move(name))
+StatsDGaugeImpl::StatsDGaugeImpl(std::string name, std::shared_ptr impl)
+    : impl_(std::move(impl)), name_(std::move(name))
 {
     impl_->add(*this);
 }
@@ -628,9 +626,9 @@ StatsDGaugeImpl::doSet(GaugeImpl::value_type value)
 {
     value_ = value;
 
-    if (value_ != last_value_)
+    if (value_ != lastValue_)
     {
-        last_value_ = value_;
+        lastValue_ = value_;
         dirty_ = true;
     }
 }
@@ -664,8 +662,8 @@ StatsDGaugeImpl::doProcess()
 
 //------------------------------------------------------------------------------
 
-StatsDMeterImpl::StatsDMeterImpl(std::string name, std::shared_ptr const& impl)
-    : impl_(impl), name_(std::move(name))
+StatsDMeterImpl::StatsDMeterImpl(std::string name, std::shared_ptr impl)
+    : impl_(std::move(impl)), name_(std::move(name))
 {
     impl_->add(*this);
 }
diff --git a/src/libxrpl/conditions/Condition.cpp b/src/libxrpl/conditions/Condition.cpp
index cb950c9e24..3be7aa7757 100644
--- a/src/libxrpl/conditions/Condition.cpp
+++ b/src/libxrpl/conditions/Condition.cpp
@@ -190,17 +190,8 @@ Condition::deserialize(Slice s, std::error_code& ec)
             break;
 
         case 1:  // PrefixSha256
-            ec = Error::UnsupportedType;
-            return {};
-
         case 2:  // ThresholdSha256
-            ec = Error::UnsupportedType;
-            return {};
-
         case 3:  // RsaSha256
-            ec = Error::UnsupportedType;
-            return {};
-
         case 4:  // Ed25519Sha256
             ec = Error::UnsupportedType;
             return {};
diff --git a/src/libxrpl/conditions/Fulfillment.cpp b/src/libxrpl/conditions/Fulfillment.cpp
index eaad6b8cdb..23a2a5eb32 100644
--- a/src/libxrpl/conditions/Fulfillment.cpp
+++ b/src/libxrpl/conditions/Fulfillment.cpp
@@ -101,20 +101,8 @@ Fulfillment::deserialize(Slice s, std::error_code& ec)
             break;
 
         case safeCast(Type::PrefixSha256):
-            ec = Error::UnsupportedType;
-            return {};
-            break;
-
         case safeCast(Type::ThresholdSha256):
-            ec = Error::UnsupportedType;
-            return {};
-            break;
-
         case safeCast(Type::RsaSha256):
-            ec = Error::UnsupportedType;
-            return {};
-            break;
-
         case safeCast(Type::Ed25519Sha256):
             ec = Error::UnsupportedType;
             return {};
diff --git a/src/libxrpl/basics/BasicConfig.cpp b/src/libxrpl/config/BasicConfig.cpp
similarity index 99%
rename from src/libxrpl/basics/BasicConfig.cpp
rename to src/libxrpl/config/BasicConfig.cpp
index 9fe79bdf4e..6b94473392 100644
--- a/src/libxrpl/basics/BasicConfig.cpp
+++ b/src/libxrpl/config/BasicConfig.cpp
@@ -1,4 +1,4 @@
-#include 
+#include 
 
 #include 
 
diff --git a/src/libxrpl/core/detail/Job.cpp b/src/libxrpl/core/detail/Job.cpp
index a1b88864d7..89fe42db05 100644
--- a/src/libxrpl/core/detail/Job.cpp
+++ b/src/libxrpl/core/detail/Job.cpp
@@ -25,7 +25,7 @@ Job::Job(
     std::uint64_t index,
     LoadMonitor& lm,
     std::function const& job)
-    : type_(type), jobIndex_(index), job_(job), name_(name), queue_time_(clock_type::now())
+    : type_(type), jobIndex_(index), job_(job), name_(name), queueTime_(clock_type::now())
 {
     loadEvent_ = std::make_shared(std::ref(lm), name, false);
 }
@@ -39,7 +39,7 @@ Job::getType() const
 Job::clock_type::time_point const&
 Job::queueTime() const
 {
-    return queue_time_;
+    return queueTime_;
 }
 
 void
diff --git a/src/libxrpl/core/detail/JobQueue.cpp b/src/libxrpl/core/detail/JobQueue.cpp
index 50f95dcdbf..5c07bf68cc 100644
--- a/src/libxrpl/core/detail/JobQueue.cpp
+++ b/src/libxrpl/core/detail/JobQueue.cpp
@@ -36,7 +36,7 @@ JobQueue::JobQueue(
     JLOG(journal_.info()) << "Using " << threadCount << "  threads";
 
     hook_ = collector_->makeHook(std::bind(&JobQueue::collect, this));
-    job_count_ = collector_->makeGauge("job_count");
+    jobCount_ = collector_->makeGauge("job_count");
 
     {
         std::scoped_lock const lock(mutex_);
@@ -66,7 +66,7 @@ void
 JobQueue::collect()
 {
     std::scoped_lock const lock(mutex_);
-    job_count_ = jobSet_.size();
+    jobCount_ = jobSet_.size();
 }
 
 bool
diff --git a/src/libxrpl/crypto/RFC1751.cpp b/src/libxrpl/crypto/RFC1751.cpp
index 30f2c3a5b8..16482945d2 100644
--- a/src/libxrpl/crypto/RFC1751.cpp
+++ b/src/libxrpl/crypto/RFC1751.cpp
@@ -13,6 +13,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl {
@@ -306,7 +307,7 @@ RFC1751::standard(std::string& strWord)
 
 // Binary search of dictionary.
 int
-RFC1751::wsrch(std::string const& strWord, int iMin, int iMax)
+RFC1751::wsrch(std::string_view strWord, int iMin, int iMax)
 {
     int iResult = -1;
 
diff --git a/src/libxrpl/json/json_valueiterator.cpp b/src/libxrpl/json/json_valueiterator.cpp
index 5a3a5ffcdb..bb49902f70 100644
--- a/src/libxrpl/json/json_valueiterator.cpp
+++ b/src/libxrpl/json/json_valueiterator.cpp
@@ -132,7 +132,7 @@ ValueConstIterator::ValueConstIterator(Value::ObjectValues::iterator const& curr
 }
 
 ValueConstIterator&
-ValueConstIterator::operator=(ValueIteratorBase const& other)
+ValueConstIterator::operator=(SelfType const& other)
 {
     copy(other);
     return *this;
diff --git a/src/libxrpl/ledger/ApplyStateTable.cpp b/src/libxrpl/ledger/ApplyStateTable.cpp
index 70fa0aef5d..abcbaac0aa 100644
--- a/src/libxrpl/ledger/ApplyStateTable.cpp
+++ b/src/libxrpl/ledger/ApplyStateTable.cpp
@@ -80,11 +80,9 @@ ApplyStateTable::size() const
 void
 ApplyStateTable::visit(
     ReadView const& to,
-    std::function const& before,
-        std::shared_ptr const& after)> const& func) const
+    std::function<
+        void(uint256 const& key, bool isDelete, SLE::const_ref before, SLE::const_ref after)> const&
+        func) const
 {
     for (auto& item : items_)
     {
@@ -339,7 +337,7 @@ ApplyStateTable::succ(
     return next;
 }
 
-std::shared_ptr
+SLE::const_pointer
 ApplyStateTable::read(ReadView const& base, Keylet const& k) const
 {
     auto const iter = items_.find(k.key);
@@ -361,7 +359,7 @@ ApplyStateTable::read(ReadView const& base, Keylet const& k) const
     return sle;
 }
 
-std::shared_ptr
+SLE::pointer
 ApplyStateTable::peek(ReadView const& base, Keylet const& k)
 {
     auto iter = items_.lower_bound(k.key);
@@ -396,7 +394,7 @@ ApplyStateTable::peek(ReadView const& base, Keylet const& k)
 }
 
 void
-ApplyStateTable::erase(ReadView const& base, std::shared_ptr const& sle)
+ApplyStateTable::erase(ReadView const& base, SLE::ref sle)
 {
     auto const iter = items_.find(sle->key());
     if (iter == items_.end())
@@ -420,7 +418,7 @@ ApplyStateTable::erase(ReadView const& base, std::shared_ptr const& sle)
 }
 
 void
-ApplyStateTable::rawErase(ReadView const& base, std::shared_ptr const& sle)
+ApplyStateTable::rawErase(ReadView const& base, SLE::ref sle)
 {
     using namespace std;
     auto const result = items_.emplace(
@@ -445,7 +443,7 @@ ApplyStateTable::rawErase(ReadView const& base, std::shared_ptr const& sle)
 }
 
 void
-ApplyStateTable::insert(ReadView const& base, std::shared_ptr const& sle)
+ApplyStateTable::insert(ReadView const& base, SLE::ref sle)
 {
     auto const iter = items_.lower_bound(sle->key());
     if (iter == items_.end() || iter->first != sle->key())
@@ -475,7 +473,7 @@ ApplyStateTable::insert(ReadView const& base, std::shared_ptr const& sle)
 }
 
 void
-ApplyStateTable::replace(ReadView const& base, std::shared_ptr const& sle)
+ApplyStateTable::replace(ReadView const& base, SLE::ref sle)
 {
     auto const iter = items_.lower_bound(sle->key());
     if (iter == items_.end() || iter->first != sle->key())
@@ -504,7 +502,7 @@ ApplyStateTable::replace(ReadView const& base, std::shared_ptr const& sle)
 }
 
 void
-ApplyStateTable::update(ReadView const& base, std::shared_ptr const& sle)
+ApplyStateTable::update(ReadView const& base, SLE::ref sle)
 {
     auto const iter = items_.find(sle->key());
     if (iter == items_.end())
@@ -536,7 +534,7 @@ ApplyStateTable::destroyXRP(XRPAmount const& fee)
 
 // Insert this transaction to the SLE's threading list
 void
-ApplyStateTable::threadItem(TxMeta& meta, std::shared_ptr const& sle)
+ApplyStateTable::threadItem(TxMeta& meta, SLE::ref sle)
 {
     key_type prevTxID;
     LedgerIndex prevLgrID = 0;
@@ -568,7 +566,7 @@ ApplyStateTable::threadItem(TxMeta& meta, std::shared_ptr const& sle)
     }
 }
 
-std::shared_ptr
+SLE::pointer
 ApplyStateTable::getForMod(ReadView const& base, key_type const& key, Mods& mods, beast::Journal j)
 {
     {
@@ -640,7 +638,7 @@ void
 ApplyStateTable::threadOwners(
     ReadView const& base,
     TxMeta& meta,
-    std::shared_ptr const& sle,
+    SLE::const_ref sle,
     Mods& mods,
     beast::Journal j)
 {
diff --git a/src/libxrpl/ledger/ApplyView.cpp b/src/libxrpl/ledger/ApplyView.cpp
index 2200afecac..8575bc2f52 100644
--- a/src/libxrpl/ledger/ApplyView.cpp
+++ b/src/libxrpl/ledger/ApplyView.cpp
@@ -31,7 +31,7 @@ createRoot(
     ApplyView& view,
     Keylet const& directory,
     uint256 const& key,
-    std::function const&)> const& describe)
+    std::function const& describe)
 {
     auto newRoot = std::make_shared(directory);
     newRoot->setFieldH256(sfRootIndex, directory.key);
@@ -110,7 +110,7 @@ insertPage(
     SLE::ref next,
     uint256 const& key,
     Keylet const& directory,
-    std::function const&)> const& describe)
+    std::function const& describe)
 {
     // We rely on modulo arithmetic of unsigned integers (guaranteed in
     // [basic.fundamental] paragraph 2) to detect page representation overflow.
@@ -166,7 +166,7 @@ ApplyView::dirAdd(
     bool preserveOrder,
     Keylet const& directory,
     uint256 const& key,
-    std::function const&)> const& describe)
+    std::function const& describe)
 {
     auto root = peek(directory);
 
diff --git a/src/libxrpl/ledger/ApplyViewBase.cpp b/src/libxrpl/ledger/ApplyViewBase.cpp
index e5a8e11b4c..1b6ba78046 100644
--- a/src/libxrpl/ledger/ApplyViewBase.cpp
+++ b/src/libxrpl/ledger/ApplyViewBase.cpp
@@ -58,7 +58,7 @@ ApplyViewBase::succ(key_type const& key, std::optional const& last) co
     return items_.succ(*base_, key, last);
 }
 
-std::shared_ptr
+SLE::const_pointer
 ApplyViewBase::read(Keylet const& k) const
 {
     return items_.read(*base_, k);
@@ -114,26 +114,26 @@ ApplyViewBase::flags() const
     return flags_;
 }
 
-std::shared_ptr
+SLE::pointer
 ApplyViewBase::peek(Keylet const& k)
 {
     return items_.peek(*base_, k);
 }
 
 void
-ApplyViewBase::erase(std::shared_ptr const& sle)
+ApplyViewBase::erase(SLE::ref sle)
 {
     items_.erase(*base_, sle);
 }
 
 void
-ApplyViewBase::insert(std::shared_ptr const& sle)
+ApplyViewBase::insert(SLE::ref sle)
 {
     items_.insert(*base_, sle);
 }
 
 void
-ApplyViewBase::update(std::shared_ptr const& sle)
+ApplyViewBase::update(SLE::ref sle)
 {
     items_.update(*base_, sle);
 }
@@ -141,19 +141,19 @@ ApplyViewBase::update(std::shared_ptr const& sle)
 //---
 
 void
-ApplyViewBase::rawErase(std::shared_ptr const& sle)
+ApplyViewBase::rawErase(SLE::ref sle)
 {
     items_.rawErase(*base_, sle);
 }
 
 void
-ApplyViewBase::rawInsert(std::shared_ptr const& sle)
+ApplyViewBase::rawInsert(SLE::ref sle)
 {
     items_.insert(*base_, sle);
 }
 
 void
-ApplyViewBase::rawReplace(std::shared_ptr const& sle)
+ApplyViewBase::rawReplace(SLE::ref sle)
 {
     items_.replace(*base_, sle);
 }
diff --git a/src/libxrpl/ledger/ApplyViewImpl.cpp b/src/libxrpl/ledger/ApplyViewImpl.cpp
index 9650190a3e..66c009cd88 100644
--- a/src/libxrpl/ledger/ApplyViewImpl.cpp
+++ b/src/libxrpl/ledger/ApplyViewImpl.cpp
@@ -13,7 +13,6 @@
 
 #include 
 #include 
-#include 
 #include 
 
 namespace xrpl {
@@ -43,11 +42,9 @@ ApplyViewImpl::size()
 void
 ApplyViewImpl::visit(
     OpenView& to,
-    std::function const& before,
-        std::shared_ptr const& after)> const& func)
+    std::function<
+        void(uint256 const& key, bool isDelete, SLE::const_ref before, SLE::const_ref after)> const&
+        func)
 {
     items_.visit(to, func);
 }
diff --git a/src/libxrpl/ledger/BookDirs.cpp b/src/libxrpl/ledger/BookDirs.cpp
index f3e41e4f58..099fec99e1 100644
--- a/src/libxrpl/ledger/BookDirs.cpp
+++ b/src/libxrpl/ledger/BookDirs.cpp
@@ -14,8 +14,8 @@ namespace xrpl {
 BookDirs::BookDirs(ReadView const& view, Book const& book)
     : view_(&view)
     , root_(keylet::page(getBookBase(book)).key)
-    , next_quality_(getQualityNext(root_))
-    , key_(view_->succ(root_, next_quality_).value_or(beast::kZero))
+    , nextQuality_(getQualityNext(root_))
+    , key_(view_->succ(root_, nextQuality_).value_or(beast::kZero))
 {
     XRPL_ASSERT(root_ != beast::kZero, "xrpl::BookDirs::BookDirs : nonzero root");
     if (key_ != beast::kZero)
@@ -35,7 +35,7 @@ BookDirs::begin() const -> BookDirs::const_iterator
     auto it = BookDirs::const_iterator(*view_, root_, key_);
     if (key_ != beast::kZero)
     {
-        it.next_quality_ = next_quality_;
+        it.nextQuality_ = nextQuality_;
         it.sle_ = sle_;
         it.entry_ = entry_;
         it.index_ = index_;
@@ -59,7 +59,7 @@ BookDirs::const_iterator::operator==(BookDirs::const_iterator const& other) cons
         view_ == other.view_ && root_ == other.root_,
         "xrpl::BookDirs::const_iterator::operator== : views and roots are "
         "matching");
-    return entry_ == other.entry_ && cur_key_ == other.cur_key_ && index_ == other.index_;
+    return entry_ == other.entry_ && curKey_ == other.curKey_ && index_ == other.index_;
 }
 
 BookDirs::const_iterator::reference
@@ -78,18 +78,18 @@ BookDirs::const_iterator::operator++()
     using beast::kZero;
 
     XRPL_ASSERT(index_ != kZero, "xrpl::BookDirs::const_iterator::operator++ : nonzero index");
-    if (!cdirNext(*view_, cur_key_, sle_, entry_, index_))
+    if (!cdirNext(*view_, curKey_, sle_, entry_, index_))
     {
         if (index_ == 0)
-            cur_key_ = view_->succ(++cur_key_, next_quality_).value_or(kZero);
+            curKey_ = view_->succ(++curKey_, nextQuality_).value_or(kZero);
 
-        if (index_ != 0 || cur_key_ == kZero)
+        if (index_ != 0 || curKey_ == kZero)
         {
-            cur_key_ = key_;
+            curKey_ = key_;
             entry_ = 0;
             index_ = kZero;
         }
-        else if (!cdirFirst(*view_, cur_key_, sle_, entry_, index_))
+        else if (!cdirFirst(*view_, curKey_, sle_, entry_, index_))
         {
             // LCOV_EXCL_START
             UNREACHABLE("xrpl::BookDirs::const_iterator::operator++ : directory is empty");
diff --git a/src/libxrpl/ledger/BookListeners.cpp b/src/libxrpl/ledger/BookListeners.cpp
deleted file mode 100644
index d78da4c73e..0000000000
--- a/src/libxrpl/ledger/BookListeners.cpp
+++ /dev/null
@@ -1,55 +0,0 @@
-#include 
-
-#include 
-#include 
-#include 
-#include 
-
-#include 
-#include 
-
-namespace xrpl {
-
-void
-BookListeners::addSubscriber(InfoSub::ref sub)
-{
-    std::scoped_lock const sl(lock_);
-    listeners_[sub->getSeq()] = sub;
-}
-
-void
-BookListeners::removeSubscriber(std::uint64_t seq)
-{
-    std::scoped_lock const sl(lock_);
-    listeners_.erase(seq);
-}
-
-void
-BookListeners::publish(MultiApiJson const& jvObj, hash_set& havePublished)
-{
-    std::scoped_lock const sl(lock_);
-    auto it = listeners_.cbegin();
-
-    while (it != listeners_.cend())
-    {
-        InfoSub::pointer p = it->second.lock();
-
-        if (p)
-        {
-            // Only publish jvObj if this is the first occurrence
-            if (havePublished.emplace(p->getSeq()).second)
-            {
-                jvObj.visit(
-                    p->getApiVersion(),  //
-                    [&](json::Value const& jv) { p->send(jv, true); });
-            }
-            ++it;
-        }
-        else
-        {
-            it = listeners_.erase(it);
-        }
-    }
-}
-
-}  // namespace xrpl
diff --git a/src/libxrpl/ledger/CachedView.cpp b/src/libxrpl/ledger/CachedView.cpp
index 8a6c266b8f..1853cc0ac8 100644
--- a/src/libxrpl/ledger/CachedView.cpp
+++ b/src/libxrpl/ledger/CachedView.cpp
@@ -7,7 +7,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 
@@ -19,7 +18,7 @@ CachedViewImpl::exists(Keylet const& k) const
     return read(k) != nullptr;
 }
 
-std::shared_ptr
+SLE::const_pointer
 CachedViewImpl::read(Keylet const& k) const
 {
     static CountedObjects::Counter kHits{"CachedView::hit"};
diff --git a/src/libxrpl/ledger/CanonicalTXSet.cpp b/src/libxrpl/ledger/CanonicalTXSet.cpp
index a06576342a..12cec5e321 100644
--- a/src/libxrpl/ledger/CanonicalTXSet.cpp
+++ b/src/libxrpl/ledger/CanonicalTXSet.cpp
@@ -40,14 +40,11 @@ CanonicalTXSet::accountKey(AccountID const& account)
 }
 
 void
-CanonicalTXSet::insert(std::shared_ptr const& txn)
+CanonicalTXSet::insert(std::shared_ptr txn)
 {
-    map_.insert(
-        std::make_pair(
-            Key(accountKey(txn->getAccountID(sfAccount)),
-                txn->getSeqProxy(),
-                txn->getTransactionID()),
-            txn));
+    Key const key(
+        accountKey(txn->getAccountID(sfAccount)), txn->getSeqProxy(), txn->getTransactionID());
+    map_.emplace(key, std::move(txn));
 }
 
 std::shared_ptr
diff --git a/src/libxrpl/ledger/Ledger.cpp b/src/libxrpl/ledger/Ledger.cpp
index fe7db9a158..188fb3cd2c 100644
--- a/src/libxrpl/ledger/Ledger.cpp
+++ b/src/libxrpl/ledger/Ledger.cpp
@@ -180,7 +180,7 @@ Ledger::Ledger(
     }
 
     {
-        auto sle = std::make_shared(keylet::fees());
+        auto sle = std::make_shared(keylet::feeSettings());
         // Whether featureXRPFees is supported will depend on startup options.
         if (std::ranges::find(amendments, featureXRPFees) != amendments.end())
         {
@@ -401,7 +401,7 @@ Ledger::succ(uint256 const& key, std::optional const& last) const
     return item->key();
 }
 
-std::shared_ptr
+SLE::const_pointer
 Ledger::read(Keylet const& k) const
 {
     if (k.key == beast::kZero)
@@ -486,7 +486,7 @@ Ledger::digest(key_type const& key) const -> std::optional
 //------------------------------------------------------------------------------
 
 void
-Ledger::rawErase(std::shared_ptr const& sle)
+Ledger::rawErase(SLE::ref sle)
 {
     if (!stateMap_.delItem(sle->key()))
         logicError("Ledger::rawErase: key not found");
@@ -500,7 +500,7 @@ Ledger::rawErase(uint256 const& key)
 }
 
 void
-Ledger::rawInsert(std::shared_ptr const& sle)
+Ledger::rawInsert(SLE::ref sle)
 {
     Serializer ss;
     sle->add(ss);
@@ -512,7 +512,7 @@ Ledger::rawInsert(std::shared_ptr const& sle)
 }
 
 void
-Ledger::rawReplace(std::shared_ptr const& sle)
+Ledger::rawReplace(SLE::ref sle)
 {
     Serializer ss;
     sle->add(ss);
@@ -560,7 +560,7 @@ Ledger::setup()
 
     try
     {
-        if (auto const sle = read(keylet::fees()))
+        if (auto const sle = read(keylet::feeSettings()))
         {
             bool oldFees = false;
             bool newFees = false;
@@ -623,7 +623,7 @@ Ledger::setup()
     return ret;
 }
 
-std::shared_ptr
+SLE::pointer
 Ledger::peek(Keylet const& k) const
 {
     auto const& value = stateMap_.peekItem(k.key);
diff --git a/src/libxrpl/ledger/OpenView.cpp b/src/libxrpl/ledger/OpenView.cpp
index c5bda2d0c8..26cb109f85 100644
--- a/src/libxrpl/ledger/OpenView.cpp
+++ b/src/libxrpl/ledger/OpenView.cpp
@@ -78,9 +78,9 @@ public:
 OpenView::OpenView(OpenView const& rhs)
     : ReadView(rhs)
     , TxsRawView(rhs)
-    , monotonic_resource_{std::make_unique(
+    , monotonicResource_{std::make_unique(
           kInitialBufferSize)}
-    , txs_{rhs.txs_, monotonic_resource_.get()}
+    , txs_{rhs.txs_, monotonicResource_.get()}
     , rules_{rhs.rules_}
     , header_{rhs.header_}
     , base_{rhs.base_}
@@ -89,9 +89,9 @@ OpenView::OpenView(OpenView const& rhs)
     , open_{rhs.open_} {};
 
 OpenView::OpenView(OpenLedgerT, ReadView const* base, Rules rules, std::shared_ptr hold)
-    : monotonic_resource_{
+    : monotonicResource_{
           std::make_unique(kInitialBufferSize)}
-    , txs_{monotonic_resource_.get()}
+    , txs_{monotonicResource_.get()}
     , rules_(std::move(rules))
     , header_(base->header())
     , base_(base)
@@ -105,9 +105,9 @@ OpenView::OpenView(OpenLedgerT, ReadView const* base, Rules rules, std::shared_p
 }
 
 OpenView::OpenView(ReadView const* base, std::shared_ptr hold)
-    : monotonic_resource_{
+    : monotonicResource_{
           std::make_unique(kInitialBufferSize)}
-    , txs_{monotonic_resource_.get()}
+    , txs_{monotonicResource_.get()}
     , rules_(base->rules())
     , header_(base->header())
     , base_(base)
@@ -163,7 +163,7 @@ OpenView::succ(key_type const& key, std::optional const& last) const
     return items_.succ(*base_, key, last);
 }
 
-std::shared_ptr
+SLE::const_pointer
 OpenView::read(Keylet const& k) const
 {
     return items_.read(*base_, k);
@@ -228,19 +228,19 @@ OpenView::txRead(key_type const& key) const -> tx_type
 //---
 
 void
-OpenView::rawErase(std::shared_ptr const& sle)
+OpenView::rawErase(SLE::ref sle)
 {
     items_.erase(sle);
 }
 
 void
-OpenView::rawInsert(std::shared_ptr const& sle)
+OpenView::rawInsert(SLE::ref sle)
 {
     items_.insert(sle);
 }
 
 void
-OpenView::rawReplace(std::shared_ptr const& sle)
+OpenView::rawReplace(SLE::ref sle)
 {
     items_.replace(sle);
 }
diff --git a/src/libxrpl/ledger/RawStateTable.cpp b/src/libxrpl/ledger/RawStateTable.cpp
index 69e2f5c0fe..fbe7bf1d82 100644
--- a/src/libxrpl/ledger/RawStateTable.cpp
+++ b/src/libxrpl/ledger/RawStateTable.cpp
@@ -20,10 +20,10 @@ namespace xrpl::detail {
 class RawStateTable::SlesIterImpl : public ReadView::SlesType::iter_base
 {
 private:
-    std::shared_ptr sle0_;
+    SLE::const_pointer sle0_;
     ReadView::SlesType::Iterator iter0_;
     ReadView::SlesType::Iterator end0_;
-    std::shared_ptr sle1_;
+    SLE::const_pointer sle1_;
     items_t::const_iterator iter1_;
     items_t::const_iterator end1_;
 
@@ -241,7 +241,7 @@ RawStateTable::succ(ReadView const& base, key_type const& key, std::optional const& sle)
+RawStateTable::erase(SLE::ref sle)
 {
     // The base invariant is checked during apply
     auto const result = items_.emplace(
@@ -267,7 +267,7 @@ RawStateTable::erase(std::shared_ptr const& sle)
 }
 
 void
-RawStateTable::insert(std::shared_ptr const& sle)
+RawStateTable::insert(SLE::ref sle)
 {
     auto const result = items_.emplace(
         std::piecewise_construct,
@@ -292,7 +292,7 @@ RawStateTable::insert(std::shared_ptr const& sle)
 }
 
 void
-RawStateTable::replace(std::shared_ptr const& sle)
+RawStateTable::replace(SLE::ref sle)
 {
     auto const result = items_.emplace(
         std::piecewise_construct,
@@ -313,7 +313,7 @@ RawStateTable::replace(std::shared_ptr const& sle)
     }
 }
 
-std::shared_ptr
+SLE::const_pointer
 RawStateTable::read(ReadView const& base, Keylet const& k) const
 {
     auto const iter = items_.find(k.key);
@@ -323,7 +323,7 @@ RawStateTable::read(ReadView const& base, Keylet const& k) const
     if (item.action == Action::Erase)
         return nullptr;
     // Convert to SLE const
-    std::shared_ptr sle = item.sle;
+    SLE::const_pointer sle = item.sle;
     if (!k.check(*sle))
         return nullptr;
     return sle;
diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp
index 4b67bb9867..ebac76a754 100644
--- a/src/libxrpl/ledger/View.cpp
+++ b/src/libxrpl/ledger/View.cpp
@@ -12,6 +12,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -31,7 +32,6 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 
@@ -57,19 +57,45 @@ isVaultPseudoAccountFrozen(
     ReadView const& view,
     AccountID const& account,
     MPTIssue const& mptShare,
-    int depth)
+    std::uint8_t depth)
 {
     if (!view.rules().enabled(featureSingleAssetVault))
         return false;
 
     if (depth >= kMaxAssetCheckDepth)
-        return true;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::View::isVaultPseudoAccountFrozen : reached asset check depth");
+        return true;
+        // LCOV_EXCL_STOP
+    }
 
-    auto const mptIssuance = view.read(keylet::mptIssuance(mptShare.getMptID()));
+    auto const mptIssuance = view.read(keylet::mptokenIssuance(mptShare.getMptID()));
     if (mptIssuance == nullptr)
         return false;  // zero MPToken won't block deletion of MPTokenIssuance
 
     auto const issuer = mptIssuance->getAccountID(sfIssuer);
+
+    // Post-fixCleanup3_2_0: vault shares carry sfReferenceHolding pointing
+    // to the vault pseudo's MPToken or RippleState for the underlying.
+    // Read it to derive the underlying asset and recurse, skipping the
+    // issuer-account-then-vault chain. Pre-amendment shares (no field)
+    // fall back to the chain lookup below.
+    if (mptIssuance->isFieldPresent(sfReferenceHolding))
+    {
+        auto const sleHolding =
+            view.read(keylet::unchecked(mptIssuance->getFieldH256(sfReferenceHolding)));
+        if (!sleHolding)
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE("xrpl::isVaultPseudoAccountFrozen : dangling sfReferenceHolding");
+            return false;
+            // LCOV_EXCL_STOP
+        }
+        return isAnyFrozen(
+            view, {issuer, account}, assetOfHolding(*mptIssuance, *sleHolding), depth + 1);
+    }
+
     auto const mptIssuer = view.read(keylet::account(issuer));
     if (mptIssuer == nullptr)
     {
@@ -305,11 +331,7 @@ hashOfSeq(ReadView const& ledger, LedgerIndex seq, beast::Journal journal)
 //------------------------------------------------------------------------------
 
 TER
-dirLink(
-    ApplyView& view,
-    AccountID const& owner,
-    std::shared_ptr& object,
-    SF_UINT64 const& node)
+dirLink(ApplyView& view, AccountID const& owner, SLE::pointer& object, SF_UINT64 const& node)
 {
     auto const page =
         view.dirInsert(keylet::ownerDir(owner), object->key(), describeOwnerDir(owner));
@@ -461,7 +483,7 @@ cleanupOnAccountDelete(
     std::optional maxNodesToDelete)
 {
     // Delete all the entries in the account directory.
-    std::shared_ptr sleDirNode{};
+    SLE::pointer sleDirNode{};
     unsigned int uDirEntry{0};
     uint256 dirEntry{beast::kZero};
     std::uint32_t deleted = 0;
diff --git a/src/libxrpl/ledger/helpers/AMMHelpers.cpp b/src/libxrpl/ledger/helpers/AMMHelpers.cpp
index f7aabc8ea5..7cd27a1f34 100644
--- a/src/libxrpl/ledger/helpers/AMMHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/AMMHelpers.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -34,8 +33,8 @@
 #include 
 #include 
 #include 
+#include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -434,7 +433,44 @@ ammPoolHolds(
     return std::make_pair(assetInBalance, assetOutBalance);
 }
 
-Expected, TER>
+TER
+checkAMMPrecisionLoss(Number const& poolProductMean, STAmount const& newLPTokenBalance)
+{
+    if (newLPTokenBalance <= beast::kZero)
+        return tesSUCCESS;
+    if (poolProductMean >= newLPTokenBalance)
+        return tesSUCCESS;
+    // Strong check failed. Allow the same relative tolerance as the invariant
+    // checker's weak check. Only return tecPRECISION_LOSS when both fail.
+    if (withinRelativeDistance(
+            poolProductMean, Number{newLPTokenBalance}, kAMMInvariantRelativeTolerance))
+        return tesSUCCESS;
+    return tecPRECISION_LOSS;
+}
+
+TER
+checkAMMPrecisionLoss(
+    ReadView const& view,
+    AccountID const& ammAccountID,
+    Asset const& asset1,
+    Asset const& asset2,
+    STAmount const& newLPTokenBalance,
+    beast::Journal const j)
+{
+    if (newLPTokenBalance <= beast::kZero)
+        return tesSUCCESS;
+    auto const [amount, amount2] = ammPoolHolds(
+        view,
+        ammAccountID,
+        asset1,
+        asset2,
+        FreezeHandling::IgnoreFreeze,
+        AuthHandling::IgnoreAuth,
+        j);
+    return checkAMMPrecisionLoss(root2(amount * amount2), newLPTokenBalance);
+}
+
+std::expected, TER>
 ammHolds(
     ReadView const& view,
     SLE const& ammSle,
@@ -490,7 +526,7 @@ ammHolds(
         return std::make_optional(std::make_pair(asset1, asset2));
     }();
     if (!assets)
-        return Unexpected(tecAMM_INVALID_TOKENS);
+        return std::unexpected(tecAMM_INVALID_TOKENS);
     auto const [amount1, amount2] = ammPoolHolds(
         view,
         ammSle.getAccountID(sfAccount),
@@ -519,7 +555,7 @@ ammLPHolds(
     auto const currency = ammLPTCurrency(asset1, asset2);
     STAmount amount;
 
-    auto const sle = view.read(keylet::line(lpAccount, ammAccount, currency));
+    auto const sle = view.read(keylet::trustLine(lpAccount, ammAccount, currency));
     if (!sle)
     {
         amount.clear(Issue{currency, ammAccount});
@@ -611,7 +647,7 @@ ammAccountHolds(ReadView const& view, AccountID const& ammAccountID, Asset const
             }
             else if (
                 auto const sle =
-                    view.read(keylet::line(ammAccountID, issue.account, issue.currency));
+                    view.read(keylet::trustLine(ammAccountID, issue.account, issue.currency));
                 sle && !isFrozen(view, ammAccountID, issue.currency, issue.account))
             {
                 STAmount amount = (*sle)[sfBalance];
@@ -636,7 +672,7 @@ deleteAMMTrustLines(
         keylet::ownerDir(ammAccountID),
         [&](LedgerEntryType nodeType,
             uint256 const&,
-            std::shared_ptr& sleItem) -> std::pair {
+            SLE::pointer& sleItem) -> std::pair {
             // Skip AMM and MPToken
             if (nodeType == ltAMM || nodeType == ltMPTOKEN)
                 return {tesSUCCESS, SkipEntry::Yes};
@@ -672,7 +708,7 @@ deleteAMMMPTokens(Sandbox& sb, AccountID const& ammAccountID, beast::Journal j)
         keylet::ownerDir(ammAccountID),
         [&](LedgerEntryType nodeType,
             uint256 const&,
-            std::shared_ptr& sleItem) -> std::pair {
+            SLE::pointer& sleItem) -> std::pair {
             // Skip AMM
             if (nodeType == ltAMM)
                 return {tesSUCCESS, SkipEntry::Yes};
@@ -768,7 +804,7 @@ deleteAMMAccount(Sandbox& sb, Asset const& asset, Asset const& asset2, beast::Jo
 void
 initializeFeeAuctionVote(
     ApplyView& view,
-    std::shared_ptr& ammSle,
+    SLE::pointer& ammSle,
     AccountID const& account,
     Asset const& lptAsset,
     std::uint16_t tfee)
@@ -822,7 +858,7 @@ initializeFeeAuctionVote(
         auctionSlot.makeFieldAbsent(sfAuthAccounts);
 }
 
-Expected
+std::expected
 isOnlyLiquidityProvider(ReadView const& view, Issue const& ammIssue, AccountID const& lpAccount)
 {
     // Liquidity Provider (LP) must have one LPToken trustline
@@ -853,18 +889,18 @@ isOnlyLiquidityProvider(ReadView const& view, Issue const& ammIssue, AccountID c
     {
         auto const ownerDir = view.read(currentIndex);
         if (!ownerDir)
-            return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+            return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
         for (auto const& key : ownerDir->getFieldV256(sfIndexes))
         {
             auto const sle = view.read(keylet::child(key));
             if (!sle)
-                return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+                return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
             auto const entryType = sle->getFieldU16(sfLedgerEntryType);
             // Only one AMM object
             if (entryType == ltAMM)
             {
                 if (hasAMM)
-                    return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+                    return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
                 hasAMM = true;
                 continue;
             }
@@ -874,7 +910,7 @@ isOnlyLiquidityProvider(ReadView const& view, Issue const& ammIssue, AccountID c
                 continue;
             }
             if (entryType != ltRIPPLE_STATE)
-                return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+                return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
             auto const lowLimit = sle->getFieldAmount(sfLowLimit);
             auto const highLimit = sle->getFieldAmount(sfHighLimit);
             auto const isLPTrustline =
@@ -890,12 +926,12 @@ isOnlyLiquidityProvider(ReadView const& view, Issue const& ammIssue, AccountID c
                 {
                     // LP has exactly one LPToken trustline
                     if (++nLPTokenTrustLines > 1)
-                        return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+                        return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
                 }
                 // AMM account has at most two IOU trustlines
                 else if (++nIOUTrustLines > 2)
                 {
-                    return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+                    return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
                 }
             }
             // Another Liquidity Provider LPToken trustline
@@ -906,7 +942,7 @@ isOnlyLiquidityProvider(ReadView const& view, Issue const& ammIssue, AccountID c
             // AMM account has at most two IOU trustlines
             else if (++nIOUTrustLines > 2)
             {
-                return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+                return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
             }
         }
         auto const uNodeNext = ownerDir->getFieldU64(sfIndexNext);
@@ -914,25 +950,25 @@ isOnlyLiquidityProvider(ReadView const& view, Issue const& ammIssue, AccountID c
         {
             if (nLPTokenTrustLines != 1 || (nIOUTrustLines == 0 && nMPT == 0) ||
                 (nIOUTrustLines > 2 || nMPT > 2) || (nIOUTrustLines + nMPT) > 2)
-                return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+                return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
             return true;
         }
         currentIndex = keylet::page(root, uNodeNext);
     }
-    return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+    return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
 }
 
-Expected
+std::expected
 verifyAndAdjustLPTokenBalance(
     Sandbox& sb,
     STAmount const& lpTokens,
-    std::shared_ptr& ammSle,
+    SLE::pointer& ammSle,
     AccountID const& account)
 {
     auto const res = isOnlyLiquidityProvider(sb, lpTokens.get(), account);
     if (!res.has_value())
     {
-        return Unexpected(res.error());
+        return std::unexpected(res.error());
     }
 
     if (res.value())
@@ -945,7 +981,7 @@ verifyAndAdjustLPTokenBalance(
         }
         else
         {
-            return Unexpected(tecAMM_INVALID_TOKENS);
+            return std::unexpected(tecAMM_INVALID_TOKENS);
         }
     }
     return true;
diff --git a/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp b/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp
index 029cb5cd92..1c4acc7bc4 100644
--- a/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -22,6 +21,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -125,11 +125,7 @@ transferRate(ReadView const& view, AccountID const& issuer)
 }
 
 void
-adjustOwnerCount(
-    ApplyView& view,
-    std::shared_ptr const& sle,
-    std::int32_t amount,
-    beast::Journal j)
+adjustOwnerCount(ApplyView& view, SLE::ref sle, std::int32_t amount, beast::Journal j)
 {
     if (!sle)
         return;
@@ -192,9 +188,7 @@ getPseudoAccountFields()
 }
 
 [[nodiscard]] bool
-isPseudoAccount(
-    std::shared_ptr sleAcct,
-    std::set const& pseudoFieldFilter)
+isPseudoAccount(SLE::const_pointer sleAcct, std::set const& pseudoFieldFilter)
 {
     auto const& fields = getPseudoAccountFields();
 
@@ -208,7 +202,7 @@ isPseudoAccount(
             }) > 0;
 }
 
-Expected, TER>
+std::expected
 createPseudoAccount(ApplyView& view, uint256 const& pseudoOwnerKey, SField const& ownerField)
 {
     [[maybe_unused]]
@@ -222,7 +216,7 @@ createPseudoAccount(ApplyView& view, uint256 const& pseudoOwnerKey, SField const
 
     auto const accountId = pseudoAccountAddress(view, pseudoOwnerKey);
     if (accountId == beast::kZero)
-        return Unexpected(tecDUPLICATE);
+        return std::unexpected(tecDUPLICATE);
 
     // Create pseudo-account.
     auto account = std::make_shared(keylet::account(accountId));
diff --git a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp
index 838e72d364..6fc2faf03e 100644
--- a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -24,8 +23,8 @@
 #include 
 
 #include 
+#include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -44,7 +43,7 @@ checkExpired(SLE const& sleCredential, NetClock::time_point const& closed)
 }
 
 [[nodiscard]]
-static Expected
+static std::expected
 removeExpired(ApplyView& view, STVector256 const& arr, beast::Journal const j)
 {
     auto const closeTime = view.header().parentCloseTime;
@@ -62,7 +61,7 @@ removeExpired(ApplyView& view, STVector256 const& arr, beast::Journal const j)
             // delete expired credentials even if the transaction failed
             auto const err = deleteSLE(view, sleCred, j);
             if (view.rules().enabled(fixCleanup3_1_3) && !isTesSuccess(err))
-                return Unexpected(err);
+                return std::unexpected(err);
             foundExpired = true;
         }
     }
@@ -71,7 +70,7 @@ removeExpired(ApplyView& view, STVector256 const& arr, beast::Journal const j)
 }
 
 TER
-deleteSLE(ApplyView& view, std::shared_ptr const& sleCredential, beast::Journal j)
+deleteSLE(ApplyView& view, SLE::ref sleCredential, beast::Journal j)
 {
     if (!sleCredential)
         return tecNO_ENTRY;
@@ -231,7 +230,7 @@ TER
 authorizedDepositPreauth(ReadView const& view, STVector256 const& credIDs, AccountID const& dst)
 {
     std::set> sorted;
-    std::vector> lifeExtender;
+    std::vector lifeExtender;
     lifeExtender.reserve(credIDs.size());
     for (auto const& h : credIDs)
     {
@@ -347,12 +346,12 @@ verifyValidDomain(ApplyView& view, AccountID const& account, uint256 domainID, b
 }
 
 TER
-verifyDepositPreauth(
+checkDepositPreauth(
     STTx const& tx,
-    ApplyView& view,
+    ReadView const& view,
     AccountID const& src,
     AccountID const& dst,
-    std::shared_ptr const& sleDst,
+    SLE::const_ref sleDst,
     beast::Journal j)
 {
     // If depositPreauth is enabled, then an account that requires
@@ -361,9 +360,27 @@ verifyDepositPreauth(
     //  2. If src is deposit preauthorized by dst (either by account or by
     //  credentials).
 
-    bool const credentialsPresent = tx.isFieldPresent(sfCredentialIDs);
+    if (sleDst && ((sleDst->getFlags() & lsfDepositAuth) != 0u))
+    {
+        if (src != dst)
+        {
+            if (!view.exists(keylet::depositPreauth(dst, src)))
+            {
+                return !tx.isFieldPresent(sfCredentialIDs)
+                    ? tecNO_PERMISSION
+                    : credentials::authorizedDepositPreauth(
+                          view, tx.getFieldV256(sfCredentialIDs), dst);
+            }
+        }
+    }
 
-    if (credentialsPresent)
+    return tesSUCCESS;
+}
+
+TER
+cleanupExpiredCredentials(STTx const& tx, ApplyView& view, beast::Journal j)
+{
+    if (tx.isFieldPresent(sfCredentialIDs))
     {
         auto const foundExpired =
             credentials::removeExpired(view, tx.getFieldV256(sfCredentialIDs), j);
@@ -373,20 +390,22 @@ verifyDepositPreauth(
             return tecEXPIRED;
     }
 
-    if (sleDst && sleDst->isFlag(lsfDepositAuth))
-    {
-        if (src != dst)
-        {
-            if (!view.exists(keylet::depositPreauth(dst, src)))
-            {
-                return !credentialsPresent ? tecNO_PERMISSION
-                                           : credentials::authorizedDepositPreauth(
-                                                 view, tx.getFieldV256(sfCredentialIDs), dst);
-            }
-        }
-    }
-
     return tesSUCCESS;
 }
 
+TER
+verifyDepositPreauth(
+    STTx const& tx,
+    ApplyView& view,
+    AccountID const& src,
+    AccountID const& dst,
+    SLE::const_ref sleDst,
+    beast::Journal j)
+{
+    if (auto const err = cleanupExpiredCredentials(tx, view, j); !isTesSuccess(err))
+        return err;
+
+    return checkDepositPreauth(tx, view, src, dst, sleDst, j);
+}
+
 }  // namespace xrpl
diff --git a/src/libxrpl/ledger/helpers/DirectoryHelpers.cpp b/src/libxrpl/ledger/helpers/DirectoryHelpers.cpp
index 8b4eeae7b7..43f8219b6a 100644
--- a/src/libxrpl/ledger/helpers/DirectoryHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/DirectoryHelpers.cpp
@@ -13,15 +13,13 @@
 
 #include 
 #include 
-#include 
-
 namespace xrpl {
 
 bool
 dirFirst(
     ApplyView& view,
     uint256 const& root,
-    std::shared_ptr& page,
+    SLE::pointer& page,
     unsigned int& index,
     uint256& entry)
 {
@@ -32,7 +30,7 @@ bool
 dirNext(
     ApplyView& view,
     uint256 const& root,
-    std::shared_ptr& page,
+    SLE::pointer& page,
     unsigned int& index,
     uint256& entry)
 {
@@ -43,7 +41,7 @@ bool
 cdirFirst(
     ReadView const& view,
     uint256 const& root,
-    std::shared_ptr& page,
+    SLE::const_pointer& page,
     unsigned int& index,
     uint256& entry)
 {
@@ -54,7 +52,7 @@ bool
 cdirNext(
     ReadView const& view,
     uint256 const& root,
-    std::shared_ptr& page,
+    SLE::const_pointer& page,
     unsigned int& index,
     uint256& entry)
 {
@@ -62,10 +60,7 @@ cdirNext(
 }
 
 void
-forEachItem(
-    ReadView const& view,
-    Keylet const& root,
-    std::function const&)> const& f)
+forEachItem(ReadView const& view, Keylet const& root, std::function const& f)
 {
     XRPL_ASSERT(root.type == ltDIR_NODE, "xrpl::forEachItem : valid root type");
 
@@ -95,7 +90,7 @@ forEachItemAfter(
     uint256 const& after,
     std::uint64_t const hint,
     unsigned int limit,
-    std::function const&)> const& f)
+    std::function const& f)
 {
     XRPL_ASSERT(root.type == ltDIR_NODE, "xrpl::forEachItemAfter : valid root type");
 
@@ -184,7 +179,7 @@ dirIsEmpty(ReadView const& view, Keylet const& k)
 std::function
 describeOwnerDir(AccountID const& account)
 {
-    return [account](std::shared_ptr const& sle) { (*sle)[sfOwner] = account; };
+    return [account](SLE::ref sle) { (*sle)[sfOwner] = account; };
 }
 
 }  // namespace xrpl
diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp
index 26e320c15d..f7ec8a8bc3 100644
--- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -8,6 +7,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -24,10 +24,43 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 
 namespace xrpl {
 
+[[nodiscard]] TER
+canApplyToBrokerCover(
+    ReadView const& view,
+    SLE::const_ref sleBroker,
+    Asset const& vaultAsset,
+    STAmount const& amount,
+    beast::Journal j,
+    std::string_view logPrefix)
+{
+    XRPL_ASSERT(
+        sleBroker && sleBroker->getType() == ltLOAN_BROKER,
+        "xrpl::canApplyToBrokerCover : valid LoanBroker sle");
+    XRPL_ASSERT(vaultAsset == amount.asset(), "xrpl::canApplyToBrokerCover : valid asset");
+
+    if (!view.rules().enabled(fixCleanup3_2_0))
+        return tesSUCCESS;
+
+    if (amount == beast::kZero)
+        return tecPRECISION_LOSS;
+
+    int const coverScale = scale(sleBroker->at(sfCoverAvailable), vaultAsset);
+    if (amount.isZeroAtScale(coverScale))
+    {
+        JLOG(j.warn()) << logPrefix << ": amount " << amount.getFullText()
+                       << " rounds to zero at cover scale " << coverScale;
+        return tecPRECISION_LOSS;
+    }
+
+    return tesSUCCESS;
+}
+
 bool
 checkLendingProtocolDependencies(Rules const& rules, STTx const& tx)
 {
@@ -481,7 +514,7 @@ doPayment(
  * The function preserves accumulated rounding errors across the re-amortization
  * to ensure the loan state remains consistent with its payment history.
  */
-Expected, TER>
+std::expected, TER>
 tryOverpayment(
     Rules const& rules,
     Asset const& asset,
@@ -526,15 +559,34 @@ tryOverpayment(
                     << ", new total value: " << newLoanProperties.loanState.valueOutstanding
                     << ", first payment principal: " << newLoanProperties.firstPaymentPrincipal;
 
-    // Calculate what the new loan state should be with the new periodic payment
-    // including rounding errors
-    auto const newTheoreticalState = computeTheoreticalLoanState(
-                                         rules,
-                                         newLoanProperties.periodicPayment,
-                                         periodicRate,
-                                         paymentRemaining,
-                                         managementFeeRate) +
-        errors;
+    // Calculate what the new loan state should be with the new periodic payment,
+    // including the preserved rounding errors.
+
+    auto const newTheoreticalState = [&]() {
+        auto const state = computeTheoreticalLoanState(
+                               rules,
+                               newLoanProperties.periodicPayment,
+                               periodicRate,
+                               paymentRemaining,
+                               managementFeeRate) +
+            errors;
+
+        if (!rules.enabled(fixCleanup3_2_0))
+            return state;
+
+        // The new principal is known exactly: it is reduced by the overpayment's
+        // principal portion. computeTheoreticalLoanState instead derives the
+        // principal -- and, from it, the management fee and interest -- via a
+        // lossy (P * factor) / factor round-trip. Pin the principal to the exact
+        // value and re-derive the management fee from the exact interest gross
+        // (value - principal), so the intermediate state is fully consistent with
+        // the exact principal rather than the one-scale-unit-high round-trip.
+        Number const principal =
+            roundedOldState.principalOutstanding - overpaymentComponents.trackedPrincipalDelta;
+        Number const managementFee =
+            tenthBipsOfValue(state.valueOutstanding - principal, managementFeeRate);
+        return constructLoanState(state.valueOutstanding, principal, managementFee);
+    }();
 
     JLOG(j.debug()) << "new theoretical value: " << newTheoreticalState.valueOutstanding
                     << ", principal: " << newTheoreticalState.principalOutstanding
@@ -591,7 +643,7 @@ tryOverpayment(
         JLOG(j.warn()) << "Principal overpayment would cause the loan to be in "
                           "an invalid state. Ignore the overpayment";
 
-        return Unexpected(tesSUCCESS);
+        return std::unexpected(tesSUCCESS);
     }
 
     // Validate that all computed properties are reasonable. These checks should
@@ -608,7 +660,7 @@ tryOverpayment(
                        << ", PeriodicPayment : " << newLoanProperties.periodicPayment
                        << ", ManagementFeeOwedToBroker: "
                        << newLoanProperties.loanState.managementFeeDue;
-        return Unexpected(tesSUCCESS);
+        return std::unexpected(tesSUCCESS);
         // LCOV_EXCL_STOP
     }
 
@@ -633,7 +685,7 @@ tryOverpayment(
     {
         JLOG(j.warn()) << "Principal overpayment would increase the value of "
                           "the loan. Ignore the overpayment";
-        return Unexpected(tesSUCCESS);
+        return std::unexpected(tesSUCCESS);
     }
 
     return std::make_pair(
@@ -666,7 +718,7 @@ tryOverpayment(
  * gracefully without corrupting the ledger data.
  */
 template 
-Expected
+std::expected
 doOverpayment(
     Rules const& rules,
     Asset const& asset,
@@ -708,7 +760,7 @@ doOverpayment(
         managementFeeRate,
         j);
     if (!ret)
-        return Unexpected(ret.error());
+        return std::unexpected(ret.error());
 
     auto const& [loanPaymentParts, newLoanProperties] = *ret;
     auto const newRoundedLoanState = newLoanProperties.loanState;
@@ -722,23 +774,13 @@ doOverpayment(
         JLOG(j.warn()) << "Overpayment not allowed: principal "
                        << "outstanding did not decrease. Before: " << *principalOutstandingProxy
                        << ". After: " << newRoundedLoanState.principalOutstanding;
-        return Unexpected(tesSUCCESS);
+        return std::unexpected(tesSUCCESS);
         // LCOV_EXCL_STOP
     }
 
     // The proxies still hold the original (pre-overpayment) values, which
     // allows us to compute deltas and verify they match what we expect
     // from the overpaymentComponents and loanPaymentParts.
-
-    XRPL_ASSERT_PARTS(
-        overpaymentComponents.trackedPrincipalDelta ==
-            principalOutstandingProxy - newRoundedLoanState.principalOutstanding,
-        "xrpl::detail::doOverpayment",
-        "principal change agrees");
-
-    // I'm not 100% sure the following asserts are correct. If in doubt, and
-    // everything else works, remove any that cause trouble.
-
     JLOG(j.debug()) << "valueChange: " << loanPaymentParts.valueChange
                     << ", totalValue before: " << *totalValueOutstandingProxy
                     << ", totalValue after: " << newRoundedLoanState.valueOutstanding
@@ -750,18 +792,50 @@ doOverpayment(
                     << overpaymentComponents.trackedPrincipalDelta -
             (totalValueOutstandingProxy - newRoundedLoanState.valueOutstanding);
 
-    XRPL_ASSERT_PARTS(
-        loanPaymentParts.valueChange ==
-            newRoundedLoanState.valueOutstanding -
-                (totalValueOutstandingProxy - overpaymentComponents.trackedPrincipalDelta) +
-                overpaymentComponents.trackedInterestPart(),
-        "xrpl::detail::doOverpayment",
-        "interest paid agrees");
+    // The three assertions below are invariants that only hold once
+    // fixCleanup3_2_0 pins the new principal to the exact reduction
+    // (oldPrincipal - trackedPrincipalDelta). Before the amendment, the lossy
+    // (P * factor) / factor round-trip can leave the new principal one
+    // scale-unit high, so these equalities do not hold on the pre-amendment
+    // code path and must be gated to match the fix they verify.
+    //
+    // The valueChange returned by tryOverpayment satisfies
+    //   valueChange = (newInterestDue - oldInterestDue) + untrackedInterest.
+    // Using the loan-state identity v = p + i + m and the adjacent
+    // `principal change agrees` assertion (dp = oldP - newP), this
+    // rearranges into three independently-computable terms:
+    //
+    //   1. TVO change beyond what principal repayment alone explains:
+    //        newTVO - (oldTVO - dp)
+    //   2. Management fee released by re-amortization (positive when
+    //      mfee decreased; zero when managementFeeRate == 0):
+    //        oldMfee - newMfee
+    //   3. The overpayment's penalty interest part (= untrackedInterest
+    //      for the overpayment path; see computeOverpaymentComponents):
+    //        trackedInterestPart()
+    [[maybe_unused]] bool const fix320Enabled = rules.enabled(fixCleanup3_2_0);
+    XRPL_ASSERT_IF(
+        fix320Enabled,
+        overpaymentComponents.trackedPrincipalDelta ==
+            principalOutstandingProxy - newRoundedLoanState.principalOutstanding,
+        "xrpl::detail::doOverpayment : principal change agrees");
 
-    XRPL_ASSERT_PARTS(
+    XRPL_ASSERT_IF(
+        fix320Enabled,
+        [&] {
+            Number const tvoChange = newRoundedLoanState.valueOutstanding -
+                (totalValueOutstandingProxy - overpaymentComponents.trackedPrincipalDelta);
+            Number const managementFeeReleased =
+                managementFeeOutstandingProxy - newRoundedLoanState.managementFeeDue;
+            Number const interestPart = overpaymentComponents.trackedInterestPart();
+            return loanPaymentParts.valueChange == tvoChange + managementFeeReleased + interestPart;
+        }(),
+        "xrpl::detail::doOverpayment : interest paid agrees");
+
+    XRPL_ASSERT_IF(
+        fix320Enabled,
         overpaymentComponents.trackedPrincipalDelta == loanPaymentParts.principalPaid,
-        "xrpl::detail::doOverpayment",
-        "principal payment matches");
+        "xrpl::detail::doOverpayment : principal payment matches");
 
     // All validations passed, so update the proxy objects (which will
     // modify the actual Loan ledger object)
@@ -786,7 +860,7 @@ doOverpayment(
  *
  * Implements equation (15) from XLS-66 spec, Section A-2 Equation Glossary
  */
-Expected
+std::expected
 computeLatePayment(
     Asset const& asset,
     ApplyView const& view,
@@ -803,7 +877,7 @@ computeLatePayment(
     // Check if the due date has passed. If not, reject the payment as
     // being too soon
     if (!hasExpired(view, nextDueDate))
-        return Unexpected(tecTOO_SOON);
+        return std::unexpected(tecTOO_SOON);
 
     // Calculate the penalty interest based on how long the payment is overdue.
     auto const latePaymentInterest = loanLatePaymentInterest(
@@ -855,7 +929,7 @@ computeLatePayment(
     {
         JLOG(j.warn()) << "Late loan payment amount is insufficient. Due: " << late.totalDue
                        << ", paid: " << amount;
-        return Unexpected(tecINSUFFICIENT_PAYMENT);
+        return std::unexpected(tecINSUFFICIENT_PAYMENT);
     }
 
     return late;
@@ -880,7 +954,7 @@ computeLatePayment(
  *
  * Implements equation (26) from XLS-66 spec, Section A-2 Equation Glossary
  */
-Expected
+std::expected
 computeFullPayment(
     Asset const& asset,
     ApplyView& view,
@@ -905,7 +979,7 @@ computeFullPayment(
     {
         // If this is the last payment, it has to be a regular payment
         JLOG(j.warn()) << "Last payment cannot be a full payment.";
-        return Unexpected(tecKILLED);
+        return std::unexpected(tecKILLED);
     }
 
     // Calculate the theoretical principal based on the payment schedule.
@@ -985,7 +1059,7 @@ computeFullPayment(
     {
         // If the payment is less than the full payment amount, it's not
         // sufficient to be a full payment.
-        return Unexpected(tecINSUFFICIENT_PAYMENT);
+        return std::unexpected(tecINSUFFICIENT_PAYMENT);
     }
 
     return full;
@@ -1058,11 +1132,22 @@ computePaymentComponents(
         rules, periodicPayment, periodicRate, paymentRemaining - 1, managementFeeRate);
 
     // Round the target to the loan's scale to match how actual loan values
-    // are stored.
+    // are stored. With fixCleanup3_2_0 enabled, principal is rounded upward
+    // and interest downward so that at coarse scale principal sticks at the
+    // floor (until the final payment clears it) while interest absorbs each
+    // periodic payment. Without the amendment the pre-existing round-to-
+    // nearest behavior is preserved (which can hit the "Partial principal
+    // payment" assertion on degenerate integer-scale loans).
+    bool const fixCleanup320Enabled = rules.enabled(fixCleanup3_2_0);
+    Number::RoundingMode const principalRounding =
+        fixCleanup320Enabled ? Number::RoundingMode::Upward : Number::getround();
+    Number::RoundingMode const interestRounding =
+        fixCleanup320Enabled ? Number::RoundingMode::Downward : Number::getround();
     LoanState const roundedTarget = LoanState{
         .valueOutstanding = roundToAsset(asset, trueTarget.valueOutstanding, scale),
-        .principalOutstanding = roundToAsset(asset, trueTarget.principalOutstanding, scale),
-        .interestDue = roundToAsset(asset, trueTarget.interestDue, scale),
+        .principalOutstanding =
+            roundToAsset(asset, trueTarget.principalOutstanding, scale, principalRounding),
+        .interestDue = roundToAsset(asset, trueTarget.interestDue, scale, interestRounding),
         .managementFeeDue = roundToAsset(asset, trueTarget.managementFeeDue, scale)};
 
     // Get the current actual loan state from the ledger values
@@ -1086,11 +1171,13 @@ computePaymentComponents(
     // Cap each component to never exceed what's actually outstanding
     deltas.principal = std::min(deltas.principal, currentLedgerState.principalOutstanding);
 
-    XRPL_ASSERT_PARTS(
-        deltas.interest <= currentLedgerState.interestDue,
-        "xrpl::detail::computePaymentComponents",
-        "interest due delta not greater than outstanding");
-
+    if (fixCleanup320Enabled)
+    {
+        XRPL_ASSERT_PARTS(
+            deltas.interest <= currentLedgerState.interestDue,
+            "xrpl::detail::computePaymentComponents",
+            "interest due delta not greater than outstanding");
+    }
     // Cap interest to both the outstanding amount AND what's left of the
     // periodic payment after principal is paid
     deltas.interest = std::min(
@@ -1231,6 +1318,7 @@ computePaymentComponents(
  */
 ExtendedPaymentComponents
 computeOverpaymentComponents(
+    Rules const& rules,
     Asset const& asset,
     int32_t const loanScale,
     Number const& overpayment,
@@ -1238,7 +1326,8 @@ computeOverpaymentComponents(
     TenthBips32 const overpaymentFeeRate,
     TenthBips16 const managementFeeRate)
 {
-    XRPL_ASSERT(
+    XRPL_ASSERT_IF(
+        rules.enabled(fixCleanup3_2_0),
         overpayment > 0 && isRounded(asset, overpayment, loanScale),
         "xrpl::detail::computeOverpaymentComponents : valid overpayment "
         "amount");
@@ -1691,7 +1780,7 @@ computeLoanProperties(
  * It is an implementation of the make_payment function from the XLS-66
  * spec. Section 3.2.4.4
  */
-Expected
+std::expected
 loanMakePayment(
     Asset const& asset,
     ApplyView& view,
@@ -1711,7 +1800,7 @@ loanMakePayment(
         // Loan complete this is already checked in LoanPay::preclaim()
         // LCOV_EXCL_START
         JLOG(j.warn()) << "Loan is already paid off.";
-        return Unexpected(tecKILLED);
+        return std::unexpected(tecKILLED);
         // LCOV_EXCL_STOP
     }
 
@@ -1723,7 +1812,7 @@ loanMakePayment(
     if (*nextDueDateProxy == 0)
     {
         JLOG(j.warn()) << "Loan next payment due date is not set.";
-        return Unexpected(tecINTERNAL);
+        return std::unexpected(tecINTERNAL);
     }
 
     std::int32_t const loanScale = loan->at(sfLoanScale);
@@ -1761,7 +1850,7 @@ loanMakePayment(
                        << startDate << ", prev payment due date is " << prevPaymentDateProxy
                        << ", next payment due date is " << nextDueDateProxy << ", ledger time is "
                        << view.parentCloseTime().time_since_epoch().count();
-        return Unexpected(tecEXPIRED);
+        return std::unexpected(tecEXPIRED);
     }
 
     // -------------------------------------------------------------
@@ -1811,13 +1900,13 @@ loanMakePayment(
             // error() will be the TER returned if a payment is not made. It
             // will only evaluate to true if it's unsuccessful. Otherwise,
             // tesSUCCESS means nothing was done, so continue.
-            return Unexpected(fullPaymentComponents.error());
+            return std::unexpected(fullPaymentComponents.error());
         }
 
         // LCOV_EXCL_START
         UNREACHABLE("xrpl::loanMakePayment : invalid full payment result");
         JLOG(j.error()) << "Full payment computation failed unexpectedly.";
-        return Unexpected(tecINTERNAL);
+        return std::unexpected(tecINTERNAL);
         // LCOV_EXCL_STOP
     }
 
@@ -1879,13 +1968,13 @@ loanMakePayment(
         {
             // error() will be the TER returned if a payment is not made. It
             // will only evaluate to true if it's unsuccessful.
-            return Unexpected(latePaymentComponents.error());
+            return std::unexpected(latePaymentComponents.error());
         }
 
         // LCOV_EXCL_START
         UNREACHABLE("xrpl::loanMakePayment : invalid late payment result");
         JLOG(j.error()) << "Late payment computation failed unexpectedly.";
-        return Unexpected(tecINTERNAL);
+        return std::unexpected(tecINTERNAL);
         // LCOV_EXCL_STOP
     }
 
@@ -1952,7 +2041,7 @@ loanMakePayment(
     {
         JLOG(j.warn()) << "Regular loan payment amount is insufficient. Due: " << periodic.totalDue
                        << ", paid: " << amount;
-        return Unexpected(tecINSUFFICIENT_PAYMENT);
+        return std::unexpected(tecINSUFFICIENT_PAYMENT);
     }
 
     XRPL_ASSERT_PARTS(
@@ -1983,51 +2072,63 @@ loanMakePayment(
         // It shouldn't be possible for the overpayment to be greater than
         // totalValueOutstanding, because that would have been processed as
         // another normal payment. But cap it just in case.
-        Number const overpayment = std::min(roundedAmount - totalPaid, *totalValueOutstandingProxy);
+        Number const overpaymentRaw =
+            std::min(roundedAmount - totalPaid, *totalValueOutstandingProxy);
 
-        detail::ExtendedPaymentComponents const overpaymentComponents =
-            detail::computeOverpaymentComponents(
-                asset,
-                loanScale,
-                overpayment,
-                overpaymentInterestRate,
-                overpaymentFeeRate,
-                managementFeeRate);
+        bool const fixEnabled = view.rules().enabled(fixCleanup3_2_0);
+        Number const overpayment = fixEnabled
+            ? roundToAsset(asset, overpaymentRaw, loanScale, Number::RoundingMode::Downward)
+            : overpaymentRaw;
 
-        // Don't process an overpayment if the whole amount (or more!)
-        // gets eaten by fees and interest.
-        if (overpaymentComponents.trackedPrincipalDelta > 0)
+        // Post-amendment, the rounded overpayment can be zero; pre-amendment
+        // it's always positive given the surrounding guards.
+        if (!fixEnabled || overpayment > 0)
         {
-            XRPL_ASSERT_PARTS(
-                overpaymentComponents.untrackedInterest >= beast::kZero,
-                "xrpl::loanMakePayment",
-                "overpayment penalty did not reduce value of loan");
-            // Can't just use `periodicPayment` here, because it might
-            // change
-            auto periodicPaymentProxy = loan->at(sfPeriodicPayment);
-            if (auto const overResult = detail::doOverpayment(
+            detail::ExtendedPaymentComponents const overpaymentComponents =
+                detail::computeOverpaymentComponents(
                     view.rules(),
                     asset,
                     loanScale,
-                    overpaymentComponents,
-                    totalValueOutstandingProxy,
-                    principalOutstandingProxy,
-                    managementFeeOutstandingProxy,
-                    periodicPaymentProxy,
-                    periodicRate,
-                    paymentRemainingProxy,
-                    managementFeeRate,
-                    j))
+                    overpayment,
+                    overpaymentInterestRate,
+                    overpaymentFeeRate,
+                    managementFeeRate);
+
+            // Don't process an overpayment if the whole amount (or more!)
+            // gets eaten by fees and interest.
+            if (overpaymentComponents.trackedPrincipalDelta > 0)
             {
-                totalParts += *overResult;
-            }
-            else if (overResult.error())
-            {
-                // error() will be the TER returned if a payment is not
-                // made. It will only evaluate to true if it's unsuccessful.
-                // Otherwise, tesSUCCESS means nothing was done, so
-                // continue.
-                return Unexpected(overResult.error());
+                XRPL_ASSERT_PARTS(
+                    overpaymentComponents.untrackedInterest >= beast::kZero,
+                    "xrpl::loanMakePayment",
+                    "overpayment penalty did not reduce value of loan");
+                // Can't just use `periodicPayment` here, because it might
+                // change
+                auto periodicPaymentProxy = loan->at(sfPeriodicPayment);
+                if (auto const overResult = detail::doOverpayment(
+                        view.rules(),
+                        asset,
+                        loanScale,
+                        overpaymentComponents,
+                        totalValueOutstandingProxy,
+                        principalOutstandingProxy,
+                        managementFeeOutstandingProxy,
+                        periodicPaymentProxy,
+                        periodicRate,
+                        paymentRemainingProxy,
+                        managementFeeRate,
+                        j))
+                {
+                    totalParts += *overResult;
+                }
+                else if (overResult.error())
+                {
+                    // error() will be the TER returned if a payment is not
+                    // made. It will only evaluate to true if it's unsuccessful.
+                    // Otherwise, tesSUCCESS means nothing was done, so
+                    // continue.
+                    return std::unexpected(overResult.error());
+                }
             }
         }
     }
diff --git a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp
index 925f37c8ed..3c4e55a16e 100644
--- a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp
@@ -25,7 +25,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 
@@ -41,7 +40,7 @@ namespace xrpl {
 bool
 isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue)
 {
-    if (auto const sle = view.read(keylet::mptIssuance(mptIssue.getMptID())))
+    if (auto const sle = view.read(keylet::mptokenIssuance(mptIssue.getMptID())))
         return sle->isFlag(lsfMPTLocked);
     return false;
 }
@@ -55,7 +54,11 @@ isIndividualFrozen(ReadView const& view, AccountID const& account, MPTIssue cons
 }
 
 bool
-isFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue, int depth)
+isFrozen(
+    ReadView const& view,
+    AccountID const& account,
+    MPTIssue const& mptIssue,
+    std::uint8_t depth)
 {
     return isGlobalFrozen(view, mptIssue) || isIndividualFrozen(view, account, mptIssue) ||
         isVaultPseudoAccountFrozen(view, account, mptIssue, depth);
@@ -66,7 +69,7 @@ isAnyFrozen(
     ReadView const& view,
     std::initializer_list const& accounts,
     MPTIssue const& mptIssue,
-    int depth)
+    std::uint8_t depth)
 {
     if (isGlobalFrozen(view, mptIssue))
         return true;
@@ -92,7 +95,7 @@ transferRate(ReadView const& view, MPTID const& issuanceID)
     // fee is 0-50,000 (0-50%), rate is 1,000,000,000-2,000,000,000
     // For example, if transfer fee is 50% then 10,000 * 50,000 = 500,000
     // which represents 50% of 1,000,000,000
-    if (auto const sle = view.read(keylet::mptIssuance(issuanceID));
+    if (auto const sle = view.read(keylet::mptokenIssuance(issuanceID));
         sle && sle->isFieldPresent(sfTransferFee))
     {
         auto const fee = sle->getFieldU16(sfTransferFee);
@@ -107,7 +110,7 @@ transferRate(ReadView const& view, MPTID const& issuanceID)
 canAddHolding(ReadView const& view, MPTIssue const& mptIssue)
 {
     auto mptID = mptIssue.getMptID();
-    auto issuance = view.read(keylet::mptIssuance(mptID));
+    auto issuance = view.read(keylet::mptokenIssuance(mptID));
     if (!issuance)
     {
         return tecOBJECT_NOT_FOUND;
@@ -129,7 +132,7 @@ addEmptyHolding(
     beast::Journal journal)
 {
     auto const& mptID = mptIssue.getMptID();
-    auto const mpt = view.peek(keylet::mptIssuance(mptID));
+    auto const mpt = view.peek(keylet::mptokenIssuance(mptID));
     if (!mpt)
         return tefINTERNAL;  // LCOV_EXCL_LINE
     if (mpt->isFlag(lsfMPTLocked))
@@ -201,7 +204,7 @@ authorizeMPToken(
             return tecINSUFFICIENT_RESERVE;
 
         // Defensive check before we attempt to create MPToken for the issuer
-        auto const mpt = view.read(keylet::mptIssuance(mptIssuanceID));
+        auto const mpt = view.read(keylet::mptokenIssuance(mptIssuanceID));
         if (!mpt || mpt->getAccountID(sfIssuer) == account)
         {
             // LCOV_EXCL_START
@@ -227,7 +230,7 @@ authorizeMPToken(
         return tesSUCCESS;
     }
 
-    auto const sleMptIssuance = view.read(keylet::mptIssuance(mptIssuanceID));
+    auto const sleMptIssuance = view.read(keylet::mptokenIssuance(mptIssuanceID));
     if (!sleMptIssuance)
         return tecINTERNAL;  // LCOV_EXCL_LINE
 
@@ -287,6 +290,15 @@ removeEmptyHolding(
         (view.rules().enabled(fixCleanup3_1_3) && (*mptoken)[~sfLockedAmount].valueOr(0) != 0))
         return tecHAS_OBLIGATIONS;
 
+    // Don't delete if the token still has confidential balances
+    if (mptoken->isFieldPresent(sfConfidentialBalanceInbox) ||
+        mptoken->isFieldPresent(sfConfidentialBalanceSpending) ||
+        mptoken->isFieldPresent(sfIssuerEncryptedBalance) ||
+        mptoken->isFieldPresent(sfAuditorEncryptedBalance))
+    {
+        return tecHAS_OBLIGATIONS;
+    }
+
     return authorizeMPToken(
         view,
         {},  // priorBalance
@@ -303,9 +315,21 @@ requireAuth(
     MPTIssue const& mptIssue,
     AccountID const& account,
     AuthType authType,
-    int depth)
+    std::uint8_t depth)
 {
-    auto const mptID = keylet::mptIssuance(mptIssue.getMptID());
+    bool const fix330Enabled = view.rules().enabled(fixCleanup3_3_0);
+    bool const featureSAVEnabled = view.rules().enabled(featureSingleAssetVault);
+    bool const featureMPTV2Enabled = view.rules().enabled(featureMPTokensV2);
+
+    // Pseudo-accounts (Vault, LoanBroker, AMM) hold assets on behalf of their participants.
+    // 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});
+    };
+
+    auto const mptID = keylet::mptokenIssuance(mptIssue.getMptID());
     auto const sleIssuance = view.read(mptID);
     if (!sleIssuance)
         return tecOBJECT_NOT_FOUND;
@@ -316,12 +340,19 @@ requireAuth(
     if (mptIssuer == account)  // Issuer won't have MPToken
         return tesSUCCESS;
 
-    bool const featureSAVEnabled = view.rules().enabled(featureSingleAssetVault);
+    // Post-fix330: exempt before the recursive underlying-asset auth check.
+    if (fix330Enabled && isPseudoAccountExempt())
+        return tesSUCCESS;
 
     if (featureSAVEnabled)
     {
         if (depth >= kMaxAssetCheckDepth)
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE("xrpl::MPTokenHelpers::requireAuth : reached asset check depth");
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
 
         // requireAuth is recursive if the issuer is a vault pseudo-account
         auto const sleIssuer = view.read(keylet::account(mptIssuer));
@@ -374,12 +405,9 @@ requireAuth(
         // belong to someone who is explicitly authorized e.g. a vault owner.
     }
 
-    if (featureSAVEnabled)
-    {
-        // Implicitly authorize Vault and LoanBroker pseudo-accounts
-        if (isPseudoAccount(view, account, {&sfVaultID, &sfLoanBrokerID}))
-            return tesSUCCESS;
-    }
+    // Pre-fix330: exempt after domain/sleToken checks, preserving prior behavior.
+    if (!fix330Enabled && isPseudoAccountExempt())
+        return tesSUCCESS;
 
     // mptoken must be authorized if issuance enabled requireAuth
     if (sleIssuance->isFlag(lsfMPTRequireAuth) &&
@@ -397,7 +425,7 @@ enforceMPTokenAuthorization(
     XRPAmount const& priorBalance,  // for MPToken authorization
     beast::Journal j)
 {
-    auto const sleIssuance = view.read(keylet::mptIssuance(mptIssuanceID));
+    auto const sleIssuance = view.read(keylet::mptokenIssuance(mptIssuanceID));
     if (!sleIssuance)
         return tefINTERNAL;  // LCOV_EXCL_LINE
 
@@ -489,46 +517,147 @@ enforceMPTokenAuthorization(
     // LCOV_EXCL_STOP
 }
 
+[[nodiscard]] Asset
+assetOfHolding(SLE const& sleShareIssuance, SLE const& sleHolding)
+{
+    XRPL_ASSERT_PARTS(
+        sleHolding.getType() == ltRIPPLE_STATE || sleHolding.getType() == ltMPTOKEN,
+        "xrpl::assetOfHolding",
+        "unexpected holding type");
+    XRPL_ASSERT_PARTS(
+        sleShareIssuance.getType() == ltMPTOKEN_ISSUANCE,
+        "xrpl::assetOfHolding",
+        "not SLE MPTokenIssuance");
+
+    if (sleHolding.getType() == ltMPTOKEN)
+        return MPTIssue{sleHolding.getFieldH192(sfMPTokenIssuanceID)};
+
+    auto const vaultPseudo = sleShareIssuance.at(sfIssuer);
+    auto const lowLimit = sleHolding.getFieldAmount(sfLowLimit);
+    auto const highLimit = sleHolding.getFieldAmount(sfHighLimit);
+    auto const& iouIssuer =
+        (lowLimit.getIssuer() != vaultPseudo) ? lowLimit.getIssuer() : highLimit.getIssuer();
+    return Issue{lowLimit.get().currency, iouIssuer};
+}
+
 TER
 canTransfer(
     ReadView const& view,
     MPTIssue const& mptIssue,
     AccountID const& from,
-    AccountID const& to)
+    AccountID const& to,
+    WaiveMPTCanTransfer waive,
+    std::uint8_t depth)
 {
-    auto const mptID = keylet::mptIssuance(mptIssue.getMptID());
+    auto const mptID = keylet::mptokenIssuance(mptIssue.getMptID());
     auto const sleIssuance = view.read(mptID);
     if (!sleIssuance)
         return tecOBJECT_NOT_FOUND;
 
+    auto const issuer = (*sleIssuance)[sfIssuer];
+    if (waive == WaiveMPTCanTransfer::Yes || from == issuer || to == issuer)
+        return tesSUCCESS;
+
     if (!sleIssuance->isFlag(lsfMPTCanTransfer))
+        return TER{tecNO_AUTH};
+
+    // Post-fixCleanup3_2_0: vault shares carry sfReferenceHolding pointing
+    // to the vault pseudo's MPToken or RippleState for the underlying asset.
+    // Third-party transfers inherit the underlying's transferability.
+    // Issuer-involving transfers and waived callers returned tesSUCCESS above.
+    //
+    // The recursive call always passes WaiveMPTCanTransfer::No so that
+    // a waived outer caller does not transitively unlock the underlying.
+    if (view.rules().enabled(fixCleanup3_2_0) && sleIssuance->isFieldPresent(sfReferenceHolding))
     {
-        if (from != (*sleIssuance)[sfIssuer] && to != (*sleIssuance)[sfIssuer])
-            return TER{tecNO_AUTH};
+        // Defensive depth bound on the inheritance recursion. Unreachable
+        // in practice (vault-of-vault-shares is forbidden at VaultCreate).
+        if (depth >= kMaxAssetCheckDepth)
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE("xrpl::MPTokenHelpers::canTransfer : reached asset check depth");
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        auto const sleHolding =
+            view.read(keylet::unchecked(sleIssuance->getFieldH256(sfReferenceHolding)));
+        if (!sleHolding)
+            return tefINTERNAL;  // LCOV_EXCL_LINE
+
+        return canTransfer(
+            view,
+            assetOfHolding(*sleIssuance, *sleHolding),
+            from,
+            to,
+            WaiveMPTCanTransfer::No,
+            depth + 1);
     }
+
     return tesSUCCESS;
 }
 
 TER
-canTrade(ReadView const& view, Asset const& asset)
+canTrade(ReadView const& view, Asset const& asset, std::uint8_t depth)
 {
     return asset.visit(
         [&](Issue const&) -> TER { return tesSUCCESS; },
         [&](MPTIssue const& mptIssue) -> TER {
-            auto const sleIssuance = view.read(keylet::mptIssuance(mptIssue.getMptID()));
+            auto const sleIssuance = view.read(keylet::mptokenIssuance(mptIssue.getMptID()));
             if (!sleIssuance)
                 return tecOBJECT_NOT_FOUND;
             if (!sleIssuance->isFlag(lsfMPTCanTrade))
                 return tecNO_PERMISSION;
+
+            // Post-fixCleanup3_2_0: vault shares inherit the underlying
+            // asset's tradability. A share whose underlying has been
+            // removed from trading cannot itself be placed on the DEX.
+            if (view.rules().enabled(fixCleanup3_2_0) &&
+                sleIssuance->isFieldPresent(sfReferenceHolding))
+            {
+                // Defensive depth bound on the inheritance recursion.
+                // Unreachable in practice (vault-of-vault-shares
+                // forbidden at VaultCreate).
+                if (depth >= kMaxAssetCheckDepth)
+                {
+                    // LCOV_EXCL_START
+                    UNREACHABLE("xrpl::MPTokenHelpers::canTrade : reached asset check depth");
+                    return tecINTERNAL;
+                    // LCOV_EXCL_STOP
+                }
+                auto const sleHolding =
+                    view.read(keylet::unchecked(sleIssuance->getFieldH256(sfReferenceHolding)));
+                if (!sleHolding)
+                    return tefINTERNAL;  // LCOV_EXCL_LINE
+
+                return canTrade(view, assetOfHolding(*sleIssuance, *sleHolding), depth + 1);
+            }
+
             return tesSUCCESS;
         });
 }
 
+TER
+canMPTTradeAndTransfer(
+    ReadView const& view,
+    Asset const& asset,
+    AccountID const& from,
+    AccountID const& to)
+{
+    if (!asset.holds())
+        return tesSUCCESS;
+
+    if (auto const ter = canTrade(view, asset); !isTesSuccess(ter))
+        return ter;
+
+    return canTransfer(view, asset, from, to);
+}
+
 TER
 lockEscrowMPT(ApplyView& view, AccountID const& sender, STAmount const& amount, beast::Journal j)
 {
     auto const mptIssue = amount.get();
-    auto const mptID = keylet::mptIssuance(mptIssue.getMptID());
+    auto const mptID = keylet::mptokenIssuance(mptIssue.getMptID());
     auto sleIssuance = view.peek(mptID);
     if (!sleIssuance)
     {  // LCOV_EXCL_START
@@ -626,14 +755,14 @@ unlockEscrowMPT(
     STAmount const& grossAmount,
     beast::Journal j)
 {
-    if (!view.rules().enabled(fixTokenEscrowV1))
-    {
-        XRPL_ASSERT(netAmount == grossAmount, "xrpl::unlockEscrowMPT : netAmount == grossAmount");
-    }
+    XRPL_ASSERT_IF(
+        !view.rules().enabled(fixTokenEscrowV1),
+        netAmount == grossAmount,
+        "xrpl::unlockEscrowMPT : netAmount == grossAmount");
 
     auto const& issuer = netAmount.getIssuer();
     auto const& mptIssue = netAmount.get();
-    auto const mptID = keylet::mptIssuance(mptIssue.getMptID());
+    auto const mptID = keylet::mptokenIssuance(mptIssue.getMptID());
     auto sleIssuance = view.peek(mptID);
     if (!sleIssuance)
     {  // LCOV_EXCL_START
@@ -817,7 +946,7 @@ checkCreateMPT(
     if (mptIssue.getIssuer() == holder)
         return tesSUCCESS;
 
-    auto const mptIssuanceID = keylet::mptIssuance(mptIssue.getMptID());
+    auto const mptIssuanceID = keylet::mptokenIssuance(mptIssue.getMptID());
     auto const mptokenID = keylet::mptoken(mptIssuanceID.key, holder);
     if (!view.exists(mptokenID))
     {
@@ -853,7 +982,7 @@ availableMPTAmount(SLE const& sleIssuance)
 std::int64_t
 availableMPTAmount(ReadView const& view, MPTID const& mptID)
 {
-    auto const sle = view.read(keylet::mptIssuance(mptID));
+    auto const sle = view.read(keylet::mptokenIssuance(mptID));
     if (!sle)
         Throw(transHuman(tecINTERNAL));
     return availableMPTAmount(*sle);
@@ -877,7 +1006,7 @@ issuerFundsToSelfIssue(ReadView const& view, MPTIssue const& issue)
 {
     STAmount amount{issue};
 
-    auto const sle = view.read(keylet::mptIssuance(issue));
+    auto const sle = view.read(keylet::mptokenIssuance(issue));
     if (!sle)
         return amount;
     auto const available = availableMPTAmount(*sle);
@@ -891,65 +1020,4 @@ issuerSelfDebitHookMPT(ApplyView& view, MPTIssue const& issue, std::uint64_t amo
     view.issuerSelfDebitHookMPT(issue, amount, available);
 }
 
-static TER
-checkMPTAllowed(ReadView const& view, TxType txType, Asset const& asset, AccountID const& accountID)
-{
-    if (!asset.holds())
-        return tesSUCCESS;
-
-    auto const& issuanceID = asset.get().getMptID();
-    auto const validTx = txType == ttAMM_CREATE || txType == ttAMM_DEPOSIT ||
-        txType == ttAMM_WITHDRAW || txType == ttOFFER_CREATE || txType == ttCHECK_CREATE ||
-        txType == ttCHECK_CASH || txType == ttPAYMENT;
-    XRPL_ASSERT(validTx, "xrpl::checkMPTAllowed : all MPT tx or DEX");
-    if (!validTx)
-        return tefINTERNAL;  // LCOV_EXCL_LINE
-
-    auto const& issuer = asset.getIssuer();
-    if (!view.exists(keylet::account(issuer)))
-        return tecNO_ISSUER;  // LCOV_EXCL_LINE
-
-    auto const issuanceKey = keylet::mptIssuance(issuanceID);
-    auto const issuanceSle = view.read(issuanceKey);
-    if (!issuanceSle)
-        return tecOBJECT_NOT_FOUND;  // LCOV_EXCL_LINE
-
-    auto const flags = issuanceSle->getFlags();
-
-    if ((flags & lsfMPTLocked) != 0u)
-        return tecLOCKED;  // LCOV_EXCL_LINE
-    // Offer crossing and Payment
-    if ((flags & lsfMPTCanTrade) == 0)
-        return tecNO_PERMISSION;
-
-    if (accountID != issuer)
-    {
-        if ((flags & lsfMPTCanTransfer) == 0)
-            return tecNO_PERMISSION;
-
-        auto const mptSle = view.read(keylet::mptoken(issuanceKey.key, accountID));
-        // Allow to succeed since some tx create MPToken if it doesn't exist.
-        // Tx's have their own check for missing MPToken.
-        if (!mptSle)
-            return tesSUCCESS;
-
-        if (mptSle->isFlag(lsfMPTLocked))
-            return tecLOCKED;
-    }
-
-    return tesSUCCESS;
-}
-
-TER
-checkMPTTxAllowed(
-    ReadView const& view,
-    TxType txType,
-    Asset const& asset,
-    AccountID const& accountID)
-{
-    // use isDEXAllowed for payment/offer crossing
-    XRPL_ASSERT(txType != ttPAYMENT, "xrpl::checkMPTTxAllowed : not payment");
-    return checkMPTAllowed(view, txType, asset, accountID);
-}
-
 }  // namespace xrpl
diff --git a/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp b/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp
index 3041d707a2..9f2c09dc49 100644
--- a/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp
@@ -41,11 +41,11 @@
 
 namespace xrpl::nft {
 
-static std::shared_ptr
+static SLE::const_pointer
 locatePage(ReadView const& view, AccountID const& owner, uint256 const& id)
 {
-    auto const first = keylet::nftpage(keylet::nftpageMin(owner), id);
-    auto const last = keylet::nftpageMax(owner);
+    auto const first = keylet::nftokenPage(keylet::nftokenPageMin(owner), id);
+    auto const last = keylet::nftokenPageMax(owner);
 
     // This NFT can only be found in the first page with a key that's strictly
     // greater than `first`, so look for that, up until the maximum possible
@@ -54,11 +54,11 @@ locatePage(ReadView const& view, AccountID const& owner, uint256 const& id)
         Keylet(ltNFTOKEN_PAGE, view.succ(first.key, last.key.next()).value_or(last.key)));
 }
 
-static std::shared_ptr
+static SLE::pointer
 locatePage(ApplyView& view, AccountID const& owner, uint256 const& id)
 {
-    auto const first = keylet::nftpage(keylet::nftpageMin(owner), id);
-    auto const last = keylet::nftpageMax(owner);
+    auto const first = keylet::nftokenPage(keylet::nftokenPageMin(owner), id);
+    auto const last = keylet::nftokenPageMax(owner);
 
     // This NFT can only be found in the first page with a key that's strictly
     // greater than `first`, so look for that, up until the maximum possible
@@ -67,16 +67,16 @@ locatePage(ApplyView& view, AccountID const& owner, uint256 const& id)
         Keylet(ltNFTOKEN_PAGE, view.succ(first.key, last.key.next()).value_or(last.key)));
 }
 
-static std::shared_ptr
+static SLE::pointer
 getPageForToken(
     ApplyView& view,
     AccountID const& owner,
     uint256 const& id,
     std::function const& createCallback)
 {
-    auto const base = keylet::nftpageMin(owner);
-    auto const first = keylet::nftpage(base, id);
-    auto const last = keylet::nftpageMax(owner);
+    auto const base = keylet::nftokenPageMin(owner);
+    auto const first = keylet::nftokenPage(base, id);
+    auto const last = keylet::nftokenPageMax(owner);
 
     // This NFT can only be found in the first page with a key that's strictly
     // greater than `first`, so look for that, up until the maximum possible
@@ -182,7 +182,7 @@ getPageForToken(
         ? narr[kDirMaxTokensPerPage - 1].getFieldH256(sfNFTokenID).next()
         : carr[0].getFieldH256(sfNFTokenID);
 
-    auto np = std::make_shared(keylet::nftpage(base, tokenIDForNewPage));
+    auto np = std::make_shared(keylet::nftokenPage(base, tokenIDForNewPage));
     XRPL_ASSERT(np->key() > base.key, "xrpl::nft::getPageForToken : valid NFT page index");
     np->setFieldArray(sfNFTokens, narr);
     np->setFieldH256(sfNextPageMin, cp->key());
@@ -230,7 +230,7 @@ changeTokenURI(
     uint256 const& nftokenID,
     std::optional const& uri)
 {
-    std::shared_ptr const page = locatePage(view, owner, nftokenID);
+    SLE::pointer const page = locatePage(view, owner, nftokenID);
 
     // If the page couldn't be found, the given NFT isn't owned by this account
     if (!page)
@@ -267,7 +267,7 @@ insertToken(ApplyView& view, AccountID owner, STObject&& nft)
     // First, we need to locate the page the NFT belongs to, creating it
     // if necessary. This operation may fail if it is impossible to insert
     // the NFT.
-    std::shared_ptr const page =
+    SLE::pointer const page =
         getPageForToken(view, owner, nft[sfNFTokenID], [](ApplyView& view, AccountID const& owner) {
             adjustOwnerCount(
                 view,
@@ -296,7 +296,7 @@ insertToken(ApplyView& view, AccountID owner, STObject&& nft)
 }
 
 static bool
-mergePages(ApplyView& view, std::shared_ptr const& p1, std::shared_ptr const& p2)
+mergePages(ApplyView& view, SLE::ref p1, SLE::ref p2)
 {
     if (p1->key() >= p2->key())
         Throw("mergePages: pages passed in out of order!");
@@ -355,7 +355,7 @@ mergePages(ApplyView& view, std::shared_ptr const& p1, std::shared_ptr
 TER
 removeToken(ApplyView& view, AccountID const& owner, uint256 const& nftokenID)
 {
-    std::shared_ptr const page = locatePage(view, owner, nftokenID);
+    SLE::pointer const page = locatePage(view, owner, nftokenID);
 
     // If the page couldn't be found, the given NFT isn't owned by this account
     if (!page)
@@ -366,11 +366,7 @@ removeToken(ApplyView& view, AccountID const& owner, uint256 const& nftokenID)
 
 /** Remove the token from the owner's token directory. */
 TER
-removeToken(
-    ApplyView& view,
-    AccountID const& owner,
-    uint256 const& nftokenID,
-    std::shared_ptr const& curr)
+removeToken(ApplyView& view, AccountID const& owner, uint256 const& nftokenID, SLE::ref curr)
 {
     // We found a page, but the given NFT may not be in it.
     auto arr = curr->getFieldArray(sfNFTokens);
@@ -386,8 +382,8 @@ removeToken(
     }
 
     // Page management:
-    auto const loadPage = [&view](std::shared_ptr const& page1, SF_UINT256 const& field) {
-        std::shared_ptr page2;
+    auto const loadPage = [&view](SLE::ref page1, SF_UINT256 const& field) {
+        SLE::pointer page2;
 
         if (auto const id = (*page1)[~field])
         {
@@ -535,7 +531,7 @@ removeToken(
 std::optional
 findToken(ReadView const& view, AccountID const& owner, uint256 const& nftokenID)
 {
-    std::shared_ptr const page = locatePage(view, owner, nftokenID);
+    SLE::const_pointer const page = locatePage(view, owner, nftokenID);
 
     // If the page couldn't be found, the given NFT isn't owned by this account
     if (!page)
@@ -554,7 +550,7 @@ findToken(ReadView const& view, AccountID const& owner, uint256 const& nftokenID
 std::optional
 findTokenAndPage(ApplyView& view, AccountID const& owner, uint256 const& nftokenID)
 {
-    std::shared_ptr page = locatePage(view, owner, nftokenID);
+    SLE::pointer page = locatePage(view, owner, nftokenID);
 
     // If the page couldn't be found, the given NFT isn't owned by this account
     if (!page)
@@ -601,7 +597,7 @@ removeTokenOffersWithLimit(ApplyView& view, Keylet const& directory, std::size_t
         // deleting during iteration.
         for (int i = offerIndexes.size() - 1; i >= 0; --i)
         {
-            if (auto const offer = view.peek(keylet::nftoffer(offerIndexes[i])))
+            if (auto const offer = view.peek(keylet::nftokenOffer(offerIndexes[i])))
             {
                 if (deleteTokenOffer(view, offer))
                 {
@@ -650,7 +646,7 @@ notTooManyOffers(ReadView const& view, uint256 const& nftokenID)
 }
 
 bool
-deleteTokenOffer(ApplyView& view, std::shared_ptr const& offer)
+deleteTokenOffer(ApplyView& view, SLE::ref offer)
 {
     if (offer->getType() != ltNFTOKEN_OFFER)
         return false;
@@ -682,11 +678,11 @@ repairNFTokenDirectoryLinks(ApplyView& view, AccountID const& owner)
 {
     bool didRepair = false;
 
-    auto const last = keylet::nftpageMax(owner);
+    auto const last = keylet::nftokenPageMax(owner);
 
-    std::shared_ptr page = view.peek(Keylet(
+    SLE::pointer page = view.peek(Keylet(
         ltNFTOKEN_PAGE,
-        view.succ(keylet::nftpageMin(owner).key, last.key.next()).value_or(last.key)));
+        view.succ(keylet::nftokenPageMin(owner).key, last.key.next()).value_or(last.key)));
 
     if (!page)
         return didRepair;
@@ -718,7 +714,7 @@ repairNFTokenDirectoryLinks(ApplyView& view, AccountID const& owner)
         view.update(page);
     }
 
-    std::shared_ptr nextPage;
+    SLE::pointer nextPage;
     while (
         (nextPage = view.peek(Keylet(
              ltNFTOKEN_PAGE, view.succ(page->key().next(), last.key.next()).value_or(last.key)))))
@@ -870,10 +866,10 @@ tokenOfferCreatePreclaim(
         if (view.rules().enabled(featureNFTokenMintOffer))
         {
             if (nftIssuer != amount.getIssuer() &&
-                !view.read(keylet::line(nftIssuer, amount.get())))
+                !view.read(keylet::trustLine(nftIssuer, amount.get())))
                 return tecNO_LINE;
         }
-        else if (!view.exists(keylet::line(nftIssuer, amount.get())))
+        else if (!view.exists(keylet::trustLine(nftIssuer, amount.get())))
         {
             return tecNO_LINE;
         }
@@ -965,7 +961,7 @@ tokenOfferCreateApply(
         priorBalance < view.fees().accountReserve((*acct)[sfOwnerCount] + 1))
         return tecINSUFFICIENT_RESERVE;
 
-    auto const offerID = keylet::nftoffer(acctID, seqProxy.value());
+    auto const offerID = keylet::nftokenOffer(acctID, seqProxy.value());
 
     // Create the offer:
     {
@@ -983,7 +979,7 @@ tokenOfferCreateApply(
         auto const offerNode = view.dirInsert(
             isSellOffer ? keylet::nftSells(nftokenID) : keylet::nftBuys(nftokenID),
             offerID,
-            [&nftokenID, isSellOffer](std::shared_ptr const& sle) {
+            [&nftokenID, isSellOffer](SLE::ref sle) {
                 (*sle)[sfFlags] = isSellOffer ? lsfNFTokenSellOffers : lsfNFTokenBuyOffers;
                 (*sle)[sfNFTokenID] = nftokenID;
             });
@@ -1051,7 +1047,7 @@ checkTrustlineAuthorized(
 
         if (issuerAccount->isFlag(lsfRequireAuth))
         {
-            auto const trustLine = view.read(keylet::line(id, issue.account, issue.currency));
+            auto const trustLine = view.read(keylet::trustLine(id, issue.account, issue.currency));
 
             if (!trustLine)
             {
@@ -1101,7 +1097,7 @@ checkTrustlineDeepFrozen(
             return tesSUCCESS;
         }
 
-        auto const trustLine = view.read(keylet::line(id, issue.account, issue.currency));
+        auto const trustLine = view.read(keylet::trustLine(id, issue.account, issue.currency));
 
         if (!trustLine)
         {
diff --git a/src/libxrpl/ledger/helpers/OfferHelpers.cpp b/src/libxrpl/ledger/helpers/OfferHelpers.cpp
index 03a1170aad..5249870143 100644
--- a/src/libxrpl/ledger/helpers/OfferHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/OfferHelpers.cpp
@@ -12,12 +12,10 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 TER
-offerDelete(ApplyView& view, std::shared_ptr const& sle, beast::Journal j)
+offerDelete(ApplyView& view, SLE::ref sle, beast::Journal j)
 {
     if (!sle)
         return tesSUCCESS;
diff --git a/src/libxrpl/ledger/helpers/PaymentChannelHelpers.cpp b/src/libxrpl/ledger/helpers/PaymentChannelHelpers.cpp
index 31c206d85b..03b68e8860 100644
--- a/src/libxrpl/ledger/helpers/PaymentChannelHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/PaymentChannelHelpers.cpp
@@ -5,23 +5,24 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 
-#include 
+#include 
+#include 
+#include 
+#include 
 
 namespace xrpl {
 
 TER
-closeChannel(
-    std::shared_ptr const& slep,
-    ApplyView& view,
-    uint256 const& key,
-    beast::Journal j)
+closeChannel(SLE::ref slep, ApplyView& view, uint256 const& key, beast::Journal j)
 {
     AccountID const src = (*slep)[sfAccount];
     // Remove PayChan from owner directory
@@ -65,4 +66,28 @@ closeChannel(
     return tesSUCCESS;
 }
 
+uint32_t
+saturatingAdd(Rules const& rules, uint32_t const lhs, uint32_t const rhs)
+{
+    if (rules.enabled(fixCleanup3_2_0))
+    {
+        static constexpr auto kUint32Max =
+            static_cast(std::numeric_limits::max());
+        uint64_t const saturatedResult = std::min(uint64_t{lhs} + rhs, kUint32Max);
+        return static_cast(saturatedResult);
+    }
+
+    return lhs + rhs;
+}
+
+bool
+isChannelExpired(ApplyView const& view, std::optional timeField)
+{
+    if (!timeField)
+        return false;
+    if (view.rules().enabled(fixCleanup3_2_0))
+        return after(view.header().parentCloseTime, *timeField);
+    return view.header().parentCloseTime.time_since_epoch().count() >= *timeField;
+}
+
 }  // namespace xrpl
diff --git a/src/libxrpl/ledger/helpers/PermissionedDEXHelpers.cpp b/src/libxrpl/ledger/helpers/PermissionedDEXHelpers.cpp
index 0151c5b2df..f8653f7111 100644
--- a/src/libxrpl/ledger/helpers/PermissionedDEXHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/PermissionedDEXHelpers.cpp
@@ -3,6 +3,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -19,6 +21,16 @@ namespace xrpl::permissioned_dex {
 bool
 accountInDomain(ReadView const& view, AccountID const& account, Domain const& domainID)
 {
+    // Avoid constructing a zero-key PermissionedDomain keylet.
+    // keylet::permissionedDomain(uint256) uses the DomainID as the ledger key.
+    if (view.rules().enabled(fixCleanup3_2_0) && domainID == beast::kZero)
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::permissioned_dex::accountInDomain : domainID is zero");
+        return false;
+        // LCOV_EXCL_STOP
+    }
+
     auto const sleDomain = view.read(keylet::permissionedDomain(domainID));
     if (!sleDomain)
         return false;
diff --git a/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp b/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp
index 5aaa417ad9..5a2995c030 100644
--- a/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp
@@ -47,7 +47,7 @@ creditLimit(
 {
     STAmount result(Issue{currency, account});
 
-    auto sleRippleState = view.read(keylet::line(account, issuer, currency));
+    auto sleRippleState = view.read(keylet::trustLine(account, issuer, currency));
 
     if (sleRippleState)
     {
@@ -78,7 +78,7 @@ creditBalance(
 {
     STAmount result(Issue{currency, account});
 
-    auto sleRippleState = view.read(keylet::line(account, issuer, currency));
+    auto sleRippleState = view.read(keylet::trustLine(account, issuer, currency));
 
     if (sleRippleState)
     {
@@ -114,7 +114,7 @@ isIndividualFrozen(
     if (issuer != account)
     {
         // Check if the issuer froze the line
-        auto const sle = view.read(keylet::line(account, issuer, currency));
+        auto const sle = view.read(keylet::trustLine(account, issuer, currency));
         if (sle && sle->isFlag((issuer > account) ? lsfHighFreeze : lsfLowFreeze))
             return true;
     }
@@ -138,7 +138,7 @@ isFrozen(
     if (issuer != account)
     {
         // Check if the issuer froze the line
-        sle = view.read(keylet::line(account, issuer, currency));
+        sle = view.read(keylet::trustLine(account, issuer, currency));
         if (sle && sle->isFlag((issuer > account) ? lsfHighFreeze : lsfLowFreeze))
             return true;
     }
@@ -162,7 +162,7 @@ isDeepFrozen(
         return false;
     }
 
-    auto const sle = view.read(keylet::line(account, issuer, currency));
+    auto const sle = view.read(keylet::trustLine(account, issuer, currency));
     if (!sle)
     {
         return false;
@@ -294,7 +294,7 @@ trustCreate(
 TER
 trustDelete(
     ApplyView& view,
-    std::shared_ptr const& sleRippleState,
+    SLE::ref sleRippleState,
     AccountID const& uLowAccountID,
     AccountID const& uHighAccountID,
     beast::Journal j)
@@ -403,7 +403,7 @@ issueIOU(
 
     bool const bSenderHigh = issue.account > account;
 
-    auto const index = keylet::line(issue.account, account, issue.currency);
+    auto const index = keylet::trustLine(issue.account, account, issue.currency);
 
     if (auto state = view.peek(index))
     {
@@ -497,7 +497,7 @@ redeemIOU(
 
     bool const bSenderHigh = account > issue.account;
 
-    if (auto state = view.peek(keylet::line(account, issue.account, issue.currency)))
+    if (auto state = view.peek(keylet::trustLine(account, issue.account, issue.currency)))
     {
         STAmount finalBalance = state->getFieldAmount(sfBalance);
 
@@ -558,7 +558,7 @@ requireAuth(ReadView const& view, Issue const& issue, AccountID const& account,
     if (isXRP(issue) || issue.account == account)
         return tesSUCCESS;
 
-    auto const trustLine = view.read(keylet::line(account, issue.account, issue.currency));
+    auto const trustLine = view.read(keylet::trustLine(account, issue.account, issue.currency));
     // If account has no line, and this is a strong check, fail
     if (!trustLine && authType == AuthType::StrongAuth)
         return tecNO_LINE;
@@ -596,7 +596,7 @@ canTransfer(ReadView const& view, Issue const& issue, AccountID const& from, Acc
     auto const isRippleDisabled = [&](AccountID account) -> bool {
         // Line might not exist, but some transfers can create it. If this
         // is the case, just check the default ripple on the issuer account.
-        auto const line = view.read(keylet::line(account, issue));
+        auto const line = view.read(keylet::trustLine(account, issue));
         if (line)
         {
             bool const issuerHigh = issuerId > account;
@@ -638,7 +638,7 @@ addEmptyHolding(
     auto const& srcId = issuerId;
     auto const& dstId = accountID;
     auto const high = srcId > dstId;
-    auto const index = keylet::line(srcId, dstId, currency);
+    auto const index = keylet::trustLine(srcId, dstId, currency);
     auto const sleSrc = view.peek(keylet::account(srcId));
     auto const sleDst = view.peek(keylet::account(dstId));
     if (!sleDst || !sleSrc)
@@ -696,7 +696,7 @@ removeEmptyHolding(
     // If the account is the issuer, then no line should exist. Check anyway.
     // If a line does exist, it will get deleted. If not, return success.
     bool const accountIsIssuer = accountID == issue.account;
-    auto const line = view.peek(keylet::line(accountID, issue));
+    auto const line = view.peek(keylet::trustLine(accountID, issue));
     if (!line)
         return accountIsIssuer ? (TER)tesSUCCESS : (TER)tecOBJECT_NOT_FOUND;
     if (!accountIsIssuer && line->at(sfBalance)->iou() != beast::kZero)
@@ -738,7 +738,7 @@ removeEmptyHolding(
 TER
 deleteAMMTrustLine(
     ApplyView& view,
-    std::shared_ptr sleState,
+    SLE::pointer sleState,
     std::optional const& ammAccountID,
     beast::Journal j)
 {
@@ -786,7 +786,7 @@ deleteAMMTrustLine(
 TER
 deleteAMMMPToken(
     ApplyView& view,
-    std::shared_ptr sleMpt,
+    SLE::pointer sleMpt,
     AccountID const& ammAccountID,
     beast::Journal j)
 {
diff --git a/src/libxrpl/ledger/helpers/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp
index c9dccb884d..7988e24a56 100644
--- a/src/libxrpl/ledger/helpers/TokenHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/TokenHelpers.cpp
@@ -6,6 +6,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -28,19 +29,12 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 
 namespace xrpl {
 
-// Forward declaration for function that remains in View.h/cpp
-bool
-isLPTokenFrozen(
-    ReadView const& view,
-    AccountID const& account,
-    Asset const& asset,
-    Asset const& asset2);
-
 //------------------------------------------------------------------------------
 //
 // Freeze checking (Asset-based)
@@ -55,6 +49,14 @@ isGlobalFrozen(ReadView const& view, Asset const& asset)
         [&](MPTIssue const& issue) { return isGlobalFrozen(view, issue); });
 }
 
+TER
+checkGlobalFrozen(ReadView const& view, Asset const& asset)
+{
+    if (isGlobalFrozen(view, asset))
+        return asset.holds() ? tecLOCKED : tecFROZEN;
+    return tesSUCCESS;
+}
+
 bool
 isIndividualFrozen(ReadView const& view, AccountID const& account, Asset const& asset)
 {
@@ -62,8 +64,16 @@ isIndividualFrozen(ReadView const& view, AccountID const& account, Asset const&
         [&](auto const& issue) { return isIndividualFrozen(view, account, issue); }, asset.value());
 }
 
+TER
+checkIndividualFrozen(ReadView const& view, AccountID const& account, Asset const& asset)
+{
+    if (isIndividualFrozen(view, account, asset))
+        return asset.holds() ? tecLOCKED : tecFROZEN;
+    return tesSUCCESS;
+}
+
 bool
-isFrozen(ReadView const& view, AccountID const& account, Asset const& asset, int depth)
+isFrozen(ReadView const& view, AccountID const& account, Asset const& asset, std::uint8_t depth)
 {
     return std::visit(
         [&](auto const& issue) { return isFrozen(view, account, issue, depth); }, asset.value());
@@ -107,7 +117,7 @@ isAnyFrozen(
     ReadView const& view,
     std::initializer_list const& accounts,
     Asset const& asset,
-    int depth)
+    std::uint8_t depth)
 {
     return asset.visit(
         [&](Issue const& issue) { return isAnyFrozen(view, accounts, issue); },
@@ -115,7 +125,11 @@ isAnyFrozen(
 }
 
 bool
-isDeepFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue, int depth)
+isDeepFrozen(
+    ReadView const& view,
+    AccountID const& account,
+    MPTIssue const& mptIssue,
+    std::uint8_t depth)
 {
     // Unlike IOUs, frozen / locked MPTs are not allowed to send or receive
     // funds, so checking "deep frozen" is the same as checking "frozen".
@@ -123,7 +137,7 @@ isDeepFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mpt
 }
 
 bool
-isDeepFrozen(ReadView const& view, AccountID const& account, Asset const& asset, int depth)
+isDeepFrozen(ReadView const& view, AccountID const& account, Asset const& asset, std::uint8_t depth)
 {
     return std::visit(
         [&](auto const& issue) { return isDeepFrozen(view, account, issue, depth); },
@@ -143,6 +157,90 @@ checkDeepFrozen(ReadView const& view, AccountID const& account, Asset const& ass
         [&](auto const& issue) { return checkDeepFrozen(view, account, issue); }, asset.value());
 }
 
+[[nodiscard]] TER
+checkWithdrawFreeze(
+    ReadView const& view,
+    AccountID const& srcAcct,
+    AccountID const& submitterAcct,
+    AccountID const& dstAcct,
+    Asset const& asset)
+{
+    XRPL_ASSERT(
+        isPseudoAccount(view, srcAcct), "xrpl::checkWithdrawFreeze : source is a pseudo-account");
+    XRPL_ASSERT(
+        !isPseudoAccount(view, submitterAcct),
+        "xrpl::checkWithdrawFreeze : submitter is not a pseudo-account");
+    XRPL_ASSERT(
+        !isPseudoAccount(view, dstAcct),
+        "xrpl::checkWithdrawFreeze : destination is not a pseudo-account");
+
+    // Funds can always be sent to the issuer
+    if (dstAcct == asset.getIssuer())
+        return tesSUCCESS;
+
+    // If the asset is globally frozen, other checks are redundant
+    if (auto const ret = checkGlobalFrozen(view, asset); !isTesSuccess(ret))
+        return ret;
+
+    // Special case for shares - check if the shares (and the transitive asset) is not frozen
+    if (asset.holds() &&
+        isVaultPseudoAccountFrozen(view, srcAcct, asset.get(), 0))
+    {
+        return tecLOCKED;
+    }
+
+    // The transfer is from Submitter to Destination via Source (pseudo-account)
+    // Both Source and Submitter must not be frozen to allow sending funds
+    if (auto const ret = checkIndividualFrozen(view, srcAcct, asset); !isTesSuccess(ret))
+        return ret;
+
+    // Check submitter's individual freeze only when Submitter != Destination (a regular freeze
+    // should not block self-withdrawal).
+    if (submitterAcct != dstAcct)
+    {
+        if (auto const ret = checkIndividualFrozen(view, submitterAcct, asset); !isTesSuccess(ret))
+            return ret;
+    }
+
+    // The destination account must not be deep frozen to receive the funds
+    return checkDeepFrozen(view, dstAcct, asset);
+}
+
+[[nodiscard]] TER
+checkDepositFreeze(
+    ReadView const& view,
+    AccountID const& srcAcct,
+    AccountID const& dstAcct,
+    Asset const& asset)
+{
+    XRPL_ASSERT(
+        isPseudoAccount(view, dstAcct),
+        "xrpl::checkDepositFreeze : destination is a pseudo-account");
+    XRPL_ASSERT(
+        !isPseudoAccount(view, srcAcct),
+        "xrpl::checkDepositFreeze : source is not a pseudo-account");
+
+    if (auto const ret = checkGlobalFrozen(view, asset); !isTesSuccess(ret))
+        return ret;
+
+    // Special case for shares - check if the shares and the transitive asset is not frozen
+    if (asset.holds() &&
+        isVaultPseudoAccountFrozen(view, dstAcct, asset.get(), 0))
+    {
+        return tecLOCKED;
+    }
+
+    if (srcAcct != asset.getIssuer())
+    {
+        if (auto const ret = checkIndividualFrozen(view, srcAcct, asset); !isTesSuccess(ret))
+            return ret;
+    }
+
+    // Unlike regular accounts, pseudo-accounts cannot receive deposits under a regular freeze
+    // because those funds cannot be later withdrawn
+    return checkIndividualFrozen(view, dstAcct, asset);
+}
+
 //------------------------------------------------------------------------------
 //
 // Account balance functions
@@ -158,7 +256,7 @@ getLineIfUsable(
     FreezeHandling zeroIfFrozen,
     beast::Journal j)
 {
-    auto sle = view.read(keylet::line(account, issuer, currency));
+    auto sle = view.read(keylet::trustLine(account, issuer, currency));
 
     if (!sle)
     {
@@ -299,7 +397,7 @@ accountHolds(
     {
         // if the account is the issuer, and the issuance exists, their limit is
         // the issuance limit minus the outstanding value
-        auto const issuance = view.read(keylet::mptIssuance(mptIssue.getMptID()));
+        auto const issuance = view.read(keylet::mptokenIssuance(mptIssue.getMptID()));
 
         if (!issuance)
         {
@@ -313,11 +411,8 @@ accountHolds(
 
     auto const sleMpt = view.read(keylet::mptoken(mptIssue.getMptID(), account));
 
-    if (!sleMpt)
-    {
-        amount.clear(mptIssue);
-    }
-    else if (zeroIfFrozen == FreezeHandling::ZeroIfFrozen && isFrozen(view, account, mptIssue))
+    if (!sleMpt ||
+        (zeroIfFrozen == FreezeHandling::ZeroIfFrozen && isFrozen(view, account, mptIssue)))
     {
         amount.clear(mptIssue);
     }
@@ -328,7 +423,8 @@ accountHolds(
         // Only if auth check is needed, as it needs to do an additional read
         // operation. Note featureSingleAssetVault will affect error codes.
         if (zeroIfUnauthorized == AuthHandling::ZeroIfUnauthorized &&
-            view.rules().enabled(featureSingleAssetVault))
+            (view.rules().enabled(featureSingleAssetVault) ||
+             view.rules().enabled(featureConfidentialTransfer)))
         {
             if (auto const err = requireAuth(view, mptIssue, account, AuthType::StrongAuth);
                 !isTesSuccess(err))
@@ -336,7 +432,7 @@ accountHolds(
         }
         else if (zeroIfUnauthorized == AuthHandling::ZeroIfUnauthorized)
         {
-            auto const sleIssuance = view.read(keylet::mptIssuance(mptIssue.getMptID()));
+            auto const sleIssuance = view.read(keylet::mptokenIssuance(mptIssue.getMptID()));
 
             // if auth is enabled on the issuance and mpt is not authorized,
             // clear amount
@@ -500,13 +596,19 @@ requireAuth(ReadView const& view, Asset const& asset, AccountID const& account,
 }
 
 TER
-canTransfer(ReadView const& view, Asset const& asset, AccountID const& from, AccountID const& to)
+canTransfer(
+    ReadView const& view,
+    Asset const& asset,
+    AccountID const& from,
+    AccountID const& to,
+    WaiveMPTCanTransfer waive,
+    std::uint8_t depth)
 {
-    return std::visit(
-        [&](TIss const& issue) -> TER {
-            return canTransfer(view, issue, from, to);
+    return asset.visit(
+        [&](MPTIssue const& issue) -> TER {
+            return canTransfer(view, issue, from, to, waive, depth);
         },
-        asset.value());
+        [&](Issue const& issue) -> TER { return canTransfer(view, issue, from, to); });
 }
 
 //------------------------------------------------------------------------------
@@ -541,7 +643,7 @@ directSendNoFeeIOU(
     XRPL_ASSERT(uSenderID != uReceiverID, "xrpl::directSendNoFeeIOU : sender is not receiver");
 
     bool const bSenderHigh = uSenderID > uReceiverID;
-    auto const index = keylet::line(uSenderID, uReceiverID, currency);
+    auto const index = keylet::trustLine(uSenderID, uReceiverID, currency);
 
     XRPL_ASSERT(
         !isXRP(uSenderID) && uSenderID != noAccount(),
@@ -749,7 +851,8 @@ directSendNoLimitMultiIOU(
         if (senderID == issuer || receiverID == issuer || issuer == noAccount())
         {
             // Direct send: redeeming IOUs and/or sending own IOUs.
-            if (auto const ter = directSendNoFeeIOU(view, senderID, receiverID, amount, false, j))
+            if (auto const ter = directSendNoFeeIOU(view, senderID, receiverID, amount, false, j);
+                !isTesSuccess(ter))
                 return ter;
             actual += amount;
             // Do not add amount to takeFromSender, because directSendNoFeeIOU took
@@ -1039,7 +1142,7 @@ directSendNoFeeMPT(
     beast::Journal j)
 {
     // Do not check MPT authorization here - it must have been checked earlier
-    auto const mptID = keylet::mptIssuance(saAmount.get().getMptID());
+    auto const mptID = keylet::mptokenIssuance(saAmount.get().getMptID());
     auto const& issuer = saAmount.getIssuer();
     auto sleIssuance = view.peek(mptID);
     if (!sleIssuance)
@@ -1095,6 +1198,13 @@ directSendNoFeeMPT(
         auto const mptokenID = keylet::mptoken(mptID.key, uReceiverID);
         if (auto sle = view.peek(mptokenID))
         {
+            if (view.rules().enabled(featureMPTokensV2))
+            {
+                if ((*sle)[sfMPTAmount] > (std::numeric_limits::max() - amt))
+                {
+                    return tecINTERNAL;  // LCOV_EXCL_LINE
+                }
+            }
             view.creditHookMPT(uSenderID, uReceiverID, saAmount, (*sle)[sfMPTAmount], available);
             (*sle)[sfMPTAmount] += amt;
             view.update(sle);
@@ -1124,7 +1234,7 @@ directSendNoLimitMPT(
     // Safe to get MPT since directSendNoLimitMPT is only called by accountSendMPT
     auto const& issuer = saAmount.getIssuer();
 
-    auto const sle = view.read(keylet::mptIssuance(saAmount.get().getMptID()));
+    auto const sle = view.read(keylet::mptokenIssuance(saAmount.get().getMptID()));
     if (!sle)
         return tecOBJECT_NOT_FOUND;
 
@@ -1181,7 +1291,7 @@ directSendNoLimitMultiMPT(
 {
     auto const& issuer = mptIssue.getIssuer();
 
-    auto const sle = view.read(keylet::mptIssuance(mptIssue.getMptID()));
+    auto const sle = view.read(keylet::mptokenIssuance(mptIssue.getMptID()));
     if (!sle)
         return tecOBJECT_NOT_FOUND;
 
diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp
index 8832e0078f..3a3a756499 100644
--- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp
@@ -2,21 +2,22 @@
 
 #include 
 #include 
+#include 
+#include 
+#include 
+#include 
 #include 
 #include 
 #include 
 #include   // IWYU pragma: keep
 
-#include 
+#include 
 #include 
 
 namespace xrpl {
 
 [[nodiscard]] std::optional
-assetsToSharesDeposit(
-    std::shared_ptr const& vault,
-    std::shared_ptr const& issuance,
-    STAmount const& assets)
+assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets)
 {
     XRPL_ASSERT(!assets.negative(), "xrpl::assetsToSharesDeposit : non-negative assets");
     XRPL_ASSERT(
@@ -40,10 +41,7 @@ assetsToSharesDeposit(
 }
 
 [[nodiscard]] std::optional
-sharesToAssetsDeposit(
-    std::shared_ptr const& vault,
-    std::shared_ptr const& issuance,
-    STAmount const& shares)
+sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares)
 {
     XRPL_ASSERT(!shares.negative(), "xrpl::sharesToAssetsDeposit : non-negative shares");
     XRPL_ASSERT(
@@ -67,10 +65,11 @@ sharesToAssetsDeposit(
 
 [[nodiscard]] std::optional
 assetsToSharesWithdraw(
-    std::shared_ptr const& vault,
-    std::shared_ptr const& issuance,
+    SLE::const_ref vault,
+    SLE::const_ref issuance,
     STAmount const& assets,
-    TruncateShares truncate)
+    TruncateShares truncate,
+    WaiveUnrealizedLoss waive)
 {
     XRPL_ASSERT(!assets.negative(), "xrpl::assetsToSharesWithdraw : non-negative assets");
     XRPL_ASSERT(
@@ -80,7 +79,8 @@ assetsToSharesWithdraw(
         return std::nullopt;  // LCOV_EXCL_LINE
 
     Number assetTotal = vault->at(sfAssetsTotal);
-    assetTotal -= vault->at(sfLossUnrealized);
+    if (waive == WaiveUnrealizedLoss::No)
+        assetTotal -= vault->at(sfLossUnrealized);
     STAmount shares{vault->at(sfShareMPTID)};
     if (assetTotal == 0)
         return shares;
@@ -94,9 +94,10 @@ assetsToSharesWithdraw(
 
 [[nodiscard]] std::optional
 sharesToAssetsWithdraw(
-    std::shared_ptr const& vault,
-    std::shared_ptr const& issuance,
-    STAmount const& shares)
+    SLE::const_ref vault,
+    SLE::const_ref issuance,
+    STAmount const& shares,
+    WaiveUnrealizedLoss waive)
 {
     XRPL_ASSERT(!shares.negative(), "xrpl::sharesToAssetsWithdraw : non-negative shares");
     XRPL_ASSERT(
@@ -106,7 +107,8 @@ sharesToAssetsWithdraw(
         return std::nullopt;  // LCOV_EXCL_LINE
 
     Number assetTotal = vault->at(sfAssetsTotal);
-    assetTotal -= vault->at(sfLossUnrealized);
+    if (waive == WaiveUnrealizedLoss::No)
+        assetTotal -= vault->at(sfLossUnrealized);
     STAmount assets{vault->at(sfAsset)};
     if (assetTotal == 0)
         return assets;
@@ -115,4 +117,24 @@ sharesToAssetsWithdraw(
     return assets;
 }
 
+[[nodiscard]] bool
+isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref issuance)
+{
+    XRPL_ASSERT(
+        issuance && issuance->getType() == ltMPTOKEN_ISSUANCE,
+        "xrpl::isSoleShareholder : valid issuance SLE");
+
+    std::uint64_t const outstanding = issuance->at(sfOutstandingAmount);
+    if (outstanding == 0)
+        return false;
+
+    auto const shareMPTID =
+        makeMptID(issuance->getFieldU32(sfSequence), issuance->getAccountID(sfIssuer));
+    auto const sleToken = view.read(keylet::mptoken(shareMPTID, account));
+    if (!sleToken)
+        return false;  // LCOV_EXCL_LINE
+
+    return sleToken->getFieldU64(sfMPTAmount) == outstanding;
+}
+
 }  // namespace xrpl
diff --git a/src/libxrpl/net/HTTPClient.cpp b/src/libxrpl/net/HTTPClient.cpp
index 78ee5eb577..4b9cc9d6e6 100644
--- a/src/libxrpl/net/HTTPClient.cpp
+++ b/src/libxrpl/net/HTTPClient.cpp
@@ -64,7 +64,7 @@ public:
         boost::asio::io_context& ioContext,
         unsigned short const port,
         std::size_t maxResponseSize,
-        beast::Journal& j)
+        beast::Journal const& j)
         : socket_(
               ioContext,
               gHttpClientSslContext->context())  // NOLINT(bugprone-unchecked-optional-access)
@@ -552,7 +552,7 @@ HTTPClient::get(
     std::function<
         bool(boost::system::error_code const& ecResult, int iStatus, std::string const& strData)>
         complete,
-    beast::Journal& j)
+    beast::Journal const& j)
 {
     auto client = std::make_shared(ioContext, port, responseMax, j);
     client->get(bSSL, deqSites, strPath, timeout, complete);
@@ -570,7 +570,7 @@ HTTPClient::get(
     std::function<
         bool(boost::system::error_code const& ecResult, int iStatus, std::string const& strData)>
         complete,
-    beast::Journal& j)
+    beast::Journal const& j)
 {
     std::deque const deqSites(1, strSite);
 
@@ -590,7 +590,7 @@ HTTPClient::request(
     std::function<
         bool(boost::system::error_code const& ecResult, int iStatus, std::string const& strData)>
         complete,
-    beast::Journal& j)
+    beast::Journal const& j)
 {
     std::deque const deqSites(1, strSite);
 
diff --git a/src/libxrpl/nodestore/Database.cpp b/src/libxrpl/nodestore/Database.cpp
index b584aca268..ac51dbfb2c 100644
--- a/src/libxrpl/nodestore/Database.cpp
+++ b/src/libxrpl/nodestore/Database.cpp
@@ -1,12 +1,13 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -38,8 +39,8 @@ Database::Database(
     beast::Journal journal)
     : j_(journal)
     , scheduler_(scheduler)
-    , earliestLedgerSeq_(get(config, "earliest_seq", kXrpLedgerEarliestSeq))
-    , requestBundle_(get(config, "rq_bundle", 4))
+    , earliestLedgerSeq_(get(config, Keys::kEarliestSeq, kXrpLedgerEarliestSeq))
+    , requestBundle_(get(config, Keys::kRqBundle, 4))
     , readThreads_(std::max(1, readThreads))
 {
     XRPL_ASSERT(readThreads, "xrpl::NodeStore::Database::Database : nonzero threads input");
diff --git a/src/libxrpl/nodestore/DatabaseNodeImp.cpp b/src/libxrpl/nodestore/DatabaseNodeImp.cpp
index ef84862de6..9323d69131 100644
--- a/src/libxrpl/nodestore/DatabaseNodeImp.cpp
+++ b/src/libxrpl/nodestore/DatabaseNodeImp.cpp
@@ -4,21 +4,16 @@
 #include 
 #include 
 #include 
-#include 
-#include 
 #include 
 #include 
 #include 
 #include 
 
-#include 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
-#include 
 
 namespace xrpl::NodeStore {
 
@@ -29,6 +24,13 @@ DatabaseNodeImp::store(NodeObjectType type, Blob&& data, uint256 const& hash, st
 
     auto obj = NodeObject::createObject(type, std::move(data), hash);
     backend_->store(obj);
+    if (cache_)
+    {
+        // After the store, replace a negative cache entry if there is one
+        cache_->canonicalize(hash, obj, [](std::shared_ptr const& n) {
+            return n->getType() == NodeObjectType::Dummy;
+        });
+    }
 }
 
 void
@@ -37,9 +39,25 @@ DatabaseNodeImp::asyncFetch(
     std::uint32_t ledgerSeq,
     std::function const&)>&& callback)
 {
+    if (cache_)
+    {
+        std::shared_ptr const obj = cache_->fetch(hash);
+        if (obj)
+        {
+            callback(obj->getType() == NodeObjectType::Dummy ? nullptr : obj);
+            return;
+        }
+    }
     Database::asyncFetch(hash, ledgerSeq, std::move(callback));
 }
 
+void
+DatabaseNodeImp::sweep()
+{
+    if (cache_)
+        cache_->sweep();
+}
+
 std::shared_ptr
 DatabaseNodeImp::fetchNodeObject(
     uint256 const& hash,
@@ -47,32 +65,58 @@ DatabaseNodeImp::fetchNodeObject(
     FetchReport& fetchReport,
     bool duplicate)
 {
-    std::shared_ptr nodeObject = nullptr;
-    Status status = Status::Ok;
+    std::shared_ptr nodeObject = cache_ ? cache_->fetch(hash) : nullptr;
+    if (!nodeObject)
+    {
+        JLOG(j_.trace()) << "fetchNodeObject " << hash << ": record not "
+                         << (cache_ ? "cached" : "found");
 
-    try
-    {
-        status = backend_->fetch(hash, &nodeObject);
-    }
-    catch (std::exception const& e)
-    {
-        JLOG(j_.fatal()) << "fetchNodeObject " << hash
-                         << ": Exception fetching from backend: " << e.what();
-        rethrow();
-    }
+        Status status = Status::Ok;
+        try
+        {
+            status = backend_->fetch(hash, &nodeObject);
+        }
+        catch (std::exception const& e)
+        {
+            JLOG(j_.fatal()) << "fetchNodeObject " << hash
+                             << ": Exception fetching from backend: " << e.what();
+            rethrow();
+        }
 
-    switch (status)
+        switch (status)
+        {
+            case Status::Ok:
+                if (cache_)
+                {
+                    if (nodeObject)
+                    {
+                        cache_->canonicalizeReplaceClient(hash, nodeObject);
+                    }
+                    else
+                    {
+                        auto notFound = NodeObject::createObject(NodeObjectType::Dummy, {}, hash);
+                        cache_->canonicalizeReplaceClient(hash, notFound);
+                        if (notFound->getType() != NodeObjectType::Dummy)
+                            nodeObject = notFound;
+                    }
+                }
+                break;
+            case Status::NotFound:
+                break;
+            case Status::DataCorrupt:
+                JLOG(j_.fatal()) << "fetchNodeObject " << hash << ": nodestore data is corrupted";
+                break;
+            default:
+                JLOG(j_.warn()) << "fetchNodeObject " << hash << ": backend returns unknown result "
+                                << static_cast(status);
+                break;
+        }
+    }
+    else
     {
-        case Status::Ok:
-        case Status::NotFound:
-            break;
-        case Status::DataCorrupt:
-            JLOG(j_.fatal()) << "fetchNodeObject " << hash << ": nodestore data is corrupted";
-            break;
-        default:
-            JLOG(j_.warn()) << "fetchNodeObject " << hash << ": backend returns unknown result "
-                            << static_cast(status);
-            break;
+        JLOG(j_.trace()) << "fetchNodeObject " << hash << ": record found in cache";
+        if (nodeObject->getType() == NodeObjectType::Dummy)
+            nodeObject.reset();
     }
 
     if (nodeObject)
@@ -81,33 +125,4 @@ DatabaseNodeImp::fetchNodeObject(
     return nodeObject;
 }
 
-std::vector>
-DatabaseNodeImp::fetchBatch(std::vector const& hashes)
-{
-    using namespace std::chrono;
-    auto const before = steady_clock::now();
-
-    // Get the node objects that match the hashes from the backend. To protect
-    // against the backends returning fewer or more results than expected, the
-    // container is resized to the number of hashes.
-    auto results = backend_->fetchBatch(hashes).first;
-    XRPL_ASSERT(
-        results.size() == hashes.size() || results.empty(),
-        "number of output objects either matches number of input hashes or is empty");
-    results.resize(hashes.size());
-    for (size_t i = 0; i < results.size(); ++i)
-    {
-        if (!results[i])
-        {
-            JLOG(j_.error()) << "fetchBatch - "
-                             << "record not found in db. hash = " << strHex(hashes[i]);
-        }
-    }
-
-    auto fetchDurationUs =
-        std::chrono::duration_cast(steady_clock::now() - before).count();
-    updateFetchMetrics(hashes.size(), 0, fetchDurationUs);
-    return results;
-}
-
 }  // namespace xrpl::NodeStore
diff --git a/src/libxrpl/nodestore/DatabaseRotatingImp.cpp b/src/libxrpl/nodestore/DatabaseRotatingImp.cpp
index 24b0e2de2e..7f4dca3ed1 100644
--- a/src/libxrpl/nodestore/DatabaseRotatingImp.cpp
+++ b/src/libxrpl/nodestore/DatabaseRotatingImp.cpp
@@ -1,11 +1,11 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -113,6 +113,12 @@ DatabaseRotatingImp::store(NodeObjectType type, Blob&& data, uint256 const& hash
     storeStats(1, nObj->getData().size());
 }
 
+void
+DatabaseRotatingImp::sweep()
+{
+    // Nothing to do.
+}
+
 std::shared_ptr
 DatabaseRotatingImp::fetchNodeObject(
     uint256 const& hash,
diff --git a/src/libxrpl/nodestore/ManagerImp.cpp b/src/libxrpl/nodestore/ManagerImp.cpp
index 5cefbfd357..a3db22ce74 100644
--- a/src/libxrpl/nodestore/ManagerImp.cpp
+++ b/src/libxrpl/nodestore/ManagerImp.cpp
@@ -1,9 +1,10 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -44,8 +45,10 @@ ManagerImp::missingBackend()
 // the Factory classes is an undefined behaviour.
 void
 registerNuDBFactory(Manager& manager);
+#if XRPL_ROCKSDB_AVAILABLE
 void
 registerRocksDBFactory(Manager& manager);
+#endif
 void
 registerNullFactory(Manager& manager);
 void
@@ -54,7 +57,9 @@ registerMemoryFactory(Manager& manager);
 ManagerImp::ManagerImp()
 {
     registerNuDBFactory(*this);
+#if XRPL_ROCKSDB_AVAILABLE
     registerRocksDBFactory(*this);
+#endif
     registerNullFactory(*this);
     registerMemoryFactory(*this);
 }
@@ -66,7 +71,7 @@ ManagerImp::makeBackend(
     Scheduler& scheduler,
     beast::Journal journal)
 {
-    std::string const type{get(parameters, "type")};
+    std::string const type{get(parameters, Keys::kType)};
     if (type.empty())
         missingBackend();
 
diff --git a/src/libxrpl/nodestore/backend/MemoryFactory.cpp b/src/libxrpl/nodestore/backend/MemoryFactory.cpp
index 621ad1357c..70578c8613 100644
--- a/src/libxrpl/nodestore/backend/MemoryFactory.cpp
+++ b/src/libxrpl/nodestore/backend/MemoryFactory.cpp
@@ -1,8 +1,9 @@
-#include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -22,7 +23,6 @@
 #include 
 #include 
 #include 
-#include 
 
 namespace xrpl::NodeStore {
 
@@ -91,7 +91,7 @@ private:
 
 public:
     MemoryBackend(size_t keyBytes, Section const& keyValues, beast::Journal journal)
-        : name_(get(keyValues, "path")), journal_(journal)
+        : name_(get(keyValues, Keys::kPath)), journal_(journal)
     {
         boost::ignore_unused(journal_);  // Keep unused journal_ just in case.
         if (name_.empty())
@@ -146,28 +146,6 @@ public:
         return Status::Ok;
     }
 
-    std::pair>, Status>
-    fetchBatch(std::vector const& hashes) override
-    {
-        std::vector> results;
-        results.reserve(hashes.size());
-        for (auto const& h : hashes)
-        {
-            std::shared_ptr nObj;
-            Status const status = fetch(h, &nObj);
-            if (status != Status::Ok)
-            {
-                results.push_back({});
-            }
-            else
-            {
-                results.push_back(nObj);
-            }
-        }
-
-        return {results, Status::Ok};
-    }
-
     void
     store(std::shared_ptr const& object) override
     {
diff --git a/src/libxrpl/nodestore/backend/NuDBFactory.cpp b/src/libxrpl/nodestore/backend/NuDBFactory.cpp
index d026dc254c..749d4020b5 100644
--- a/src/libxrpl/nodestore/backend/NuDBFactory.cpp
+++ b/src/libxrpl/nodestore/backend/NuDBFactory.cpp
@@ -1,10 +1,11 @@
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -42,7 +43,6 @@
 #include 
 #include 
 #include 
-#include 
 
 namespace xrpl::NodeStore {
 
@@ -73,7 +73,7 @@ public:
         : j(journal)
         , keyBytes(keyBytes)
         , burstSize(burstSize)
-        , name(get(keyValues, "path"))
+        , name(get(keyValues, Keys::kPath))
         , blockSize(parseBlockSize(name, keyValues, journal))
         , deletePath(false)
         , scheduler(scheduler)
@@ -92,7 +92,7 @@ public:
         : j(journal)
         , keyBytes(keyBytes)
         , burstSize(burstSize)
-        , name(get(keyValues, "path"))
+        , name(get(keyValues, Keys::kPath))
         , blockSize(parseBlockSize(name, keyValues, journal))
         , db(context)
         , deletePath(false)
@@ -232,28 +232,6 @@ public:
         return status;
     }
 
-    std::pair>, Status>
-    fetchBatch(std::vector const& hashes) override
-    {
-        std::vector> results;
-        results.reserve(hashes.size());
-        for (auto const& h : hashes)
-        {
-            std::shared_ptr nObj;
-            Status const status = fetch(h, &nObj);
-            if (status != Status::Ok)
-            {
-                results.push_back({});
-            }
-            else
-            {
-                results.push_back(nObj);
-            }
-        }
-
-        return {results, Status::Ok};
-    }
-
     void
     doInsert(std::shared_ptr const& no)
     {
@@ -382,7 +360,7 @@ private:
         std::size_t const blockSize = defaultSize;
         std::string blockSizeStr;
 
-        if (!getIfExists(keyValues, "nudb_block_size", blockSizeStr))
+        if (!getIfExists(keyValues, Keys::kNudbBlockSize, blockSizeStr))
         {
             return blockSize;  // Early return with default
         }
diff --git a/src/libxrpl/nodestore/backend/NullFactory.cpp b/src/libxrpl/nodestore/backend/NullFactory.cpp
index ae33b7daa2..e36b13a2e1 100644
--- a/src/libxrpl/nodestore/backend/NullFactory.cpp
+++ b/src/libxrpl/nodestore/backend/NullFactory.cpp
@@ -1,6 +1,6 @@
-#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -12,8 +12,6 @@
 #include 
 #include 
 #include 
-#include 
-#include 
 
 namespace xrpl::NodeStore {
 
@@ -52,12 +50,6 @@ public:
         return Status::NotFound;
     }
 
-    std::pair>, Status>
-    fetchBatch(std::vector const& hashes) override
-    {
-        return {};
-    }
-
     void
     store(std::shared_ptr const& object) override
     {
diff --git a/src/libxrpl/nodestore/backend/RocksDBFactory.cpp b/src/libxrpl/nodestore/backend/RocksDBFactory.cpp
index c1baf6aaaa..69a648f2f4 100644
--- a/src/libxrpl/nodestore/backend/RocksDBFactory.cpp
+++ b/src/libxrpl/nodestore/backend/RocksDBFactory.cpp
@@ -1,12 +1,23 @@
-#include 
+#if XRPL_ROCKSDB_AVAILABLE
+#include 
 #include 
 #include 
+#include 
+#include 
+#include 
 #include 
 #include 
+#include 
+#include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
+#include 
+#include 
+#include 
 
 #include 
 #include 
@@ -24,27 +35,12 @@
 #include 
 #include 
 
-#include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
-#include 
-#include 
-
-#if XRPL_ROCKSDB_AVAILABLE
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-#include 
 
 namespace xrpl::NodeStore {
 
@@ -113,17 +109,18 @@ public:
         RocksDBEnv* env)
         : deletePath_(false), journal(journal), keyBytes(keyBytes), batch(*this, scheduler)
     {
-        if (!getIfExists(keyValues, "path", name))
+        if (!getIfExists(keyValues, Keys::kPath, name))
             Throw("Missing path in RocksDBFactory backend");
 
         rocksdb::BlockBasedTableOptions tableOptions;
         options.env = env;
 
-        bool const hardSet = keyValues.exists("hard_set") && get(keyValues, "hard_set");
+        bool const hardSet =
+            keyValues.exists(Keys::kHardSet) && get(keyValues, Keys::kHardSet);
 
-        if (keyValues.exists("cache_mb"))
+        if (keyValues.exists(Keys::kCacheMb))
         {
-            auto size = get(keyValues, "cache_mb");
+            auto size = get(keyValues, Keys::kCacheMb);
 
             if (!hardSet && size == 256)
                 size = 1024;
@@ -131,14 +128,14 @@ public:
             tableOptions.block_cache = rocksdb::NewLRUCache(megabytes(size));
         }
 
-        if (auto const v = get(keyValues, "filter_bits"))
+        if (auto const v = get(keyValues, Keys::kFilterBits))
         {
-            bool const filterBlocks =
-                !keyValues.exists("filter_full") || (get(keyValues, "filter_full") == 0);
+            bool const filterBlocks = !keyValues.exists(Keys::kFilterFull) ||
+                (get(keyValues, Keys::kFilterFull) == 0);
             tableOptions.filter_policy.reset(rocksdb::NewBloomFilterPolicy(v, filterBlocks));
         }
 
-        if (getIfExists(keyValues, "open_files", options.max_open_files))
+        if (getIfExists(keyValues, Keys::kOpenFiles, options.max_open_files))
         {
             if (!hardSet && options.max_open_files == 2000)
                 options.max_open_files = 8000;
@@ -146,9 +143,9 @@ public:
             fdMinRequired = options.max_open_files + 128;
         }
 
-        if (keyValues.exists("file_size_mb"))
+        if (keyValues.exists(Keys::kFileSizeMb))
         {
-            auto fileSizeMb = get(keyValues, "file_size_mb");
+            auto fileSizeMb = get(keyValues, Keys::kFileSizeMb);
 
             if (!hardSet && fileSizeMb == 8)
                 fileSizeMb = 256;
@@ -158,16 +155,17 @@ public:
             options.write_buffer_size = 2 * options.target_file_size_base;
         }
 
-        getIfExists(keyValues, "file_size_mult", options.target_file_size_multiplier);
+        getIfExists(keyValues, Keys::kFileSizeMult, options.target_file_size_multiplier);
 
-        if (keyValues.exists("bg_threads"))
+        if (keyValues.exists(Keys::kBgThreads))
         {
-            options.env->SetBackgroundThreads(get(keyValues, "bg_threads"), rocksdb::Env::LOW);
+            options.env->SetBackgroundThreads(
+                get(keyValues, Keys::kBgThreads), rocksdb::Env::LOW);
         }
 
-        if (keyValues.exists("high_threads"))
+        if (keyValues.exists(Keys::kHighThreads))
         {
-            auto const highThreads = get(keyValues, "high_threads");
+            auto const highThreads = get(keyValues, Keys::kHighThreads);
             options.env->SetBackgroundThreads(highThreads, rocksdb::Env::HIGH);
 
             // If we have high-priority threads, presumably we want to
@@ -178,10 +176,10 @@ public:
 
         options.compression = rocksdb::kSnappyCompression;
 
-        getIfExists(keyValues, "block_size", tableOptions.block_size);
+        getIfExists(keyValues, Keys::kBlockSize, tableOptions.block_size);
 
-        if (keyValues.exists("universal_compaction") &&
-            (get(keyValues, "universal_compaction") != 0))
+        if (keyValues.exists(Keys::kUniversalCompaction) &&
+            (get(keyValues, Keys::kUniversalCompaction) != 0))
         {
             options.compaction_style = rocksdb::kCompactionStyleUniversal;
             options.min_write_buffer_number_to_merge = 2;
@@ -189,11 +187,11 @@ public:
             options.write_buffer_size = 6 * options.target_file_size_base;
         }
 
-        if (keyValues.exists("bbt_options"))
+        if (keyValues.exists(Keys::kBbtOptions))
         {
             rocksdb::ConfigOptions const configOptions;
             auto const s = rocksdb::GetBlockBasedTableOptionsFromString(
-                configOptions, tableOptions, get(keyValues, "bbt_options"), &tableOptions);
+                configOptions, tableOptions, get(keyValues, Keys::kBbtOptions), &tableOptions);
             if (!s.ok())
             {
                 Throw(
@@ -203,10 +201,10 @@ public:
 
         options.table_factory.reset(NewBlockBasedTableFactory(tableOptions));
 
-        if (keyValues.exists("options"))
+        if (keyValues.exists(Keys::kOptions))
         {
             auto const s =
-                rocksdb::GetOptionsFromString(options, get(keyValues, "options"), &options);
+                rocksdb::GetOptionsFromString(options, get(keyValues, Keys::kOptions), &options);
             if (!s.ok())
             {
                 Throw(
@@ -287,7 +285,7 @@ public:
         Status status = Status::Ok;
 
         rocksdb::ReadOptions const options;
-        rocksdb::Slice const slice(std::bit_cast(hash.data()), keyBytes);
+        rocksdb::Slice const slice(reinterpret_cast(hash.data()), keyBytes);
 
         std::string string;
 
@@ -330,28 +328,6 @@ public:
         return status;
     }
 
-    std::pair>, Status>
-    fetchBatch(std::vector const& hashes) override
-    {
-        std::vector> results;
-        results.reserve(hashes.size());
-        for (auto const& h : hashes)
-        {
-            std::shared_ptr nObj;
-            Status const status = fetch(h, &nObj);
-            if (status != Status::Ok)
-            {
-                results.push_back({});
-            }
-            else
-            {
-                results.push_back(nObj);
-            }
-        }
-
-        return {results, Status::Ok};
-    }
-
     void
     store(std::shared_ptr const& object) override
     {
@@ -372,8 +348,9 @@ public:
             EncodedBlob const encoded(e);
 
             wb.Put(
-                rocksdb::Slice(std::bit_cast(encoded.getKey()), keyBytes),
-                rocksdb::Slice(std::bit_cast(encoded.getData()), encoded.getSize()));
+                rocksdb::Slice(reinterpret_cast(encoded.getKey()), keyBytes),
+                rocksdb::Slice(
+                    reinterpret_cast(encoded.getData()), encoded.getSize()));
         }
 
         rocksdb::WriteOptions const options;
diff --git a/src/libxrpl/protocol/AMMCore.cpp b/src/libxrpl/protocol/AMMCore.cpp
index eccb581c6d..e58ab29257 100644
--- a/src/libxrpl/protocol/AMMCore.cpp
+++ b/src/libxrpl/protocol/AMMCore.cpp
@@ -127,7 +127,7 @@ ammAuctionTimeSlot(std::uint64_t current, STObject const& auctionSlot)
 bool
 ammEnabled(Rules const& rules)
 {
-    return rules.enabled(featureAMM) && rules.enabled(fixUniversalNumber);
+    return rules.enabled(featureAMM);
 }
 
 }  // namespace xrpl
diff --git a/src/libxrpl/protocol/ConfidentialTransfer.cpp b/src/libxrpl/protocol/ConfidentialTransfer.cpp
new file mode 100644
index 0000000000..0baff1e33a
--- /dev/null
+++ b/src/libxrpl/protocol/ConfidentialTransfer.cpp
@@ -0,0 +1,487 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+namespace {
+
+account_id
+toAccountId(AccountID const& account)
+{
+    account_id res;
+    std::memcpy(res.bytes, account.data(), kMPT_ACCOUNT_ID_SIZE);
+    return res;
+}
+
+mpt_issuance_id
+toIssuanceId(uint192 const& issuance)
+{
+    mpt_issuance_id res;
+    std::memcpy(res.bytes, issuance.data(), kMPT_ISSUANCE_ID_SIZE);
+    return res;
+}
+
+/**
+ * @brief Pack a ConfidentialRecipient (public key + ElGamal ciphertext) into the
+ * secp256k1-mpt participant struct. Callers MUST have already validated that
+ * r.publicKey.size() == kEcPubKeyLength and
+ * r.encryptedAmount.size() == kEcGamalEncryptedTotalLength;
+ */
+mpt_confidential_participant
+toParticipant(ConfidentialRecipient const& r)
+{
+    mpt_confidential_participant p{};
+    std::memcpy(p.pubkey, r.publicKey.data(), kEcPubKeyLength);
+    std::memcpy(p.ciphertext, r.encryptedAmount.data(), kEcGamalEncryptedTotalLength);
+    return p;
+}
+
+}  // namespace
+
+uint256
+getSendContextHash(
+    AccountID const& account,
+    uint192 const& issuanceID,
+    std::uint32_t sequence,
+    AccountID const& destination,
+    std::uint32_t version)
+{
+    uint256 result;
+    mpt_get_send_context_hash(
+        toAccountId(account),
+        toIssuanceId(issuanceID),
+        sequence,
+        toAccountId(destination),
+        version,
+        result.data());
+    return result;
+}
+
+uint256
+getClawbackContextHash(
+    AccountID const& account,
+    uint192 const& issuanceID,
+    std::uint32_t sequence,
+    AccountID const& holder)
+{
+    uint256 result;
+    mpt_get_clawback_context_hash(
+        toAccountId(account),
+        toIssuanceId(issuanceID),
+        sequence,
+        toAccountId(holder),
+        result.data());
+    return result;
+}
+
+uint256
+getConvertContextHash(AccountID const& account, uint192 const& issuanceID, std::uint32_t sequence)
+{
+    uint256 result;
+    mpt_get_convert_context_hash(
+        toAccountId(account), toIssuanceId(issuanceID), sequence, result.data());
+    return result;
+}
+
+uint256
+getConvertBackContextHash(
+    AccountID const& account,
+    uint192 const& issuanceID,
+    std::uint32_t sequence,
+    std::uint32_t version)
+{
+    uint256 result;
+    mpt_get_convert_back_context_hash(
+        toAccountId(account), toIssuanceId(issuanceID), sequence, version, result.data());
+    return result;
+}
+
+std::optional
+makeEcPair(Slice const& buffer)
+{
+    if (buffer.length() != 2 * kEcCiphertextComponentLength)
+        return std::nullopt;  // LCOV_EXCL_LINE
+
+    auto parsePubKey = [](Slice const& slice, secp256k1_pubkey& out) {
+        return secp256k1_ec_pubkey_parse(secp256k1Context(), &out, slice.data(), slice.length());
+    };
+
+    Slice const s1{buffer.data(), kEcCiphertextComponentLength};
+    Slice const s2{buffer.data() + kEcCiphertextComponentLength, kEcCiphertextComponentLength};
+
+    EcPair pair{};
+    if (parsePubKey(s1, pair.c1) != 1 || parsePubKey(s2, pair.c2) != 1)
+        return std::nullopt;
+
+    return pair;
+}
+
+std::optional
+serializeEcPair(EcPair const& pair)
+{
+    auto serializePubKey = [](secp256k1_pubkey const& pub, unsigned char* out) {
+        size_t outLen = kEcCiphertextComponentLength;  // 33 bytes
+        auto const ret = secp256k1_ec_pubkey_serialize(
+            secp256k1Context(), out, &outLen, &pub, SECP256K1_EC_COMPRESSED);
+        return ret == 1 && outLen == kEcCiphertextComponentLength;
+    };
+
+    Buffer buffer(kEcGamalEncryptedTotalLength);
+    auto const ptr = buffer.data();
+    bool const res1 = serializePubKey(pair.c1, ptr);
+    bool const res2 = serializePubKey(pair.c2, ptr + kEcCiphertextComponentLength);
+
+    if (!res1 || !res2)
+        return std::nullopt;
+
+    return buffer;
+}
+
+bool
+isValidCiphertext(Slice const& buffer)
+{
+    return makeEcPair(buffer).has_value();
+}
+
+bool
+isValidCompressedECPoint(Slice const& buffer)
+{
+    if (buffer.size() != kCompressedEcPointLength)
+        return false;
+
+    // Compressed EC points must start with 0x02 or 0x03
+    if (buffer[0] != kEcCompressedPrefixEvenY && buffer[0] != kEcCompressedPrefixOddY)
+        return false;
+
+    secp256k1_pubkey point;
+    return secp256k1_ec_pubkey_parse(secp256k1Context(), &point, buffer.data(), buffer.size()) == 1;
+}
+
+std::optional
+homomorphicAdd(Slice const& a, Slice const& b)
+{
+    if (a.length() != kEcGamalEncryptedTotalLength || b.length() != kEcGamalEncryptedTotalLength)
+        return std::nullopt;
+
+    auto const pairA = makeEcPair(a);
+    auto const pairB = makeEcPair(b);
+
+    if (!pairA || !pairB)
+        return std::nullopt;
+
+    EcPair sum{};
+    if (auto res = secp256k1_elgamal_add(
+            secp256k1Context(), &sum.c1, &sum.c2, &pairA->c1, &pairA->c2, &pairB->c1, &pairB->c2);
+        res != 1)
+    {
+        return std::nullopt;
+    }
+
+    return serializeEcPair(sum);
+}
+
+std::optional
+homomorphicSubtract(Slice const& a, Slice const& b)
+{
+    if (a.length() != kEcGamalEncryptedTotalLength || b.length() != kEcGamalEncryptedTotalLength)
+        return std::nullopt;
+
+    auto const pairA = makeEcPair(a);
+    auto const pairB = makeEcPair(b);
+
+    if (!pairA || !pairB)
+        return std::nullopt;
+
+    EcPair diff{};
+    if (auto const res = secp256k1_elgamal_subtract(
+            secp256k1Context(), &diff.c1, &diff.c2, &pairA->c1, &pairA->c2, &pairB->c1, &pairB->c2);
+        res != 1)
+    {
+        return std::nullopt;
+    }
+
+    return serializeEcPair(diff);
+}
+
+std::optional
+rerandomizeCiphertext(Slice const& ciphertext, Slice const& pubKeySlice, Slice const& randomness)
+{
+    auto zero = encryptAmount(0, pubKeySlice, randomness);
+    if (!zero)
+        return std::nullopt;
+
+    return homomorphicAdd(ciphertext, *zero);
+}
+
+Buffer
+generateBlindingFactor()
+{
+    unsigned char blindingFactor[kEcBlindingFactorLength];
+
+    // todo: might need to be updated using another RNG
+    if (RAND_bytes(blindingFactor, kEcBlindingFactorLength) != 1)
+        Throw("Failed to generate random number");
+
+    return Buffer(blindingFactor, kEcBlindingFactorLength);
+}
+
+std::optional
+encryptAmount(uint64_t const amt, Slice const& pubKeySlice, Slice const& blindingFactor)
+{
+    if (blindingFactor.size() != kEcBlindingFactorLength || pubKeySlice.size() != kEcPubKeyLength)
+        return std::nullopt;
+
+    Buffer out(kEcGamalEncryptedTotalLength);
+    if (mpt_encrypt_amount(amt, pubKeySlice.data(), blindingFactor.data(), out.data()) != 0)
+        return std::nullopt;
+
+    return out;
+}
+
+std::optional
+encryptCanonicalZeroAmount(Slice const& pubKeySlice, AccountID const& account, MPTID const& mptId)
+{
+    if (pubKeySlice.size() != kEcPubKeyLength)
+        return std::nullopt;  // LCOV_EXCL_LINE
+
+    EcPair pair{};
+    secp256k1_pubkey pubKey;
+    if (auto res = secp256k1_ec_pubkey_parse(
+            secp256k1Context(), &pubKey, pubKeySlice.data(), kEcPubKeyLength);
+        res != 1)
+    {
+        return std::nullopt;  // LCOV_EXCL_LINE
+    }
+
+    if (auto res = generate_canonical_encrypted_zero(
+            secp256k1Context(), &pair.c1, &pair.c2, &pubKey, account.data(), mptId.data());
+        res != 1)
+    {
+        return std::nullopt;  // LCOV_EXCL_LINE
+    }
+
+    return serializeEcPair(pair);
+}
+
+TER
+verifyRevealedAmount(
+    uint64_t const amount,
+    Slice const& blindingFactor,
+    ConfidentialRecipient const& holder,
+    ConfidentialRecipient const& issuer,
+    std::optional const& auditor)
+{
+    if (blindingFactor.size() != kEcBlindingFactorLength ||
+        holder.publicKey.size() != kEcPubKeyLength ||
+        holder.encryptedAmount.size() != kEcGamalEncryptedTotalLength ||
+        issuer.publicKey.size() != kEcPubKeyLength ||
+        issuer.encryptedAmount.size() != kEcGamalEncryptedTotalLength)
+    {
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+    }
+
+    auto const holderP = toParticipant(holder);
+    auto const issuerP = toParticipant(issuer);
+    mpt_confidential_participant auditorP{};
+    mpt_confidential_participant const* auditorPtr = nullptr;
+    if (auditor)
+    {
+        if (auditor->publicKey.size() != kEcPubKeyLength ||
+            auditor->encryptedAmount.size() != kEcGamalEncryptedTotalLength)
+        {
+            return tecINTERNAL;  // LCOV_EXCL_LINE
+        }
+        auditorP = toParticipant(*auditor);
+        auditorPtr = &auditorP;
+    }
+
+    if (mpt_verify_revealed_amount(amount, blindingFactor.data(), &holderP, &issuerP, auditorPtr) !=
+        0)
+    {
+        return tecBAD_PROOF;
+    }
+
+    return tesSUCCESS;
+}
+
+NotTEC
+checkEncryptedAmountFormat(STObject const& object)
+{
+    // Current usage of this function is only for ConfidentialMPTConvert and
+    // ConfidentialMPTConvertBack transactions, which already enforce that these fields
+    // are present.
+    if (!object.isFieldPresent(sfHolderEncryptedAmount) ||
+        !object.isFieldPresent(sfIssuerEncryptedAmount))
+    {
+        return temMALFORMED;  // LCOV_EXCL_LINE
+    }
+
+    if (object[sfHolderEncryptedAmount].length() != kEcGamalEncryptedTotalLength ||
+        object[sfIssuerEncryptedAmount].length() != kEcGamalEncryptedTotalLength)
+    {
+        return temBAD_CIPHERTEXT;
+    }
+
+    bool const hasAuditor = object.isFieldPresent(sfAuditorEncryptedAmount);
+    if (hasAuditor && object[sfAuditorEncryptedAmount].length() != kEcGamalEncryptedTotalLength)
+        return temBAD_CIPHERTEXT;
+
+    if (!isValidCiphertext(object[sfHolderEncryptedAmount]) ||
+        !isValidCiphertext(object[sfIssuerEncryptedAmount]))
+    {
+        return temBAD_CIPHERTEXT;
+    }
+
+    if (hasAuditor && !isValidCiphertext(object[sfAuditorEncryptedAmount]))
+        return temBAD_CIPHERTEXT;
+
+    return tesSUCCESS;
+}
+
+TER
+verifySchnorrProof(Slice const& pubKeySlice, Slice const& proofSlice, uint256 const& contextHash)
+{
+    if (proofSlice.size() != kEcSchnorrProofLength || pubKeySlice.size() != kEcPubKeyLength)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    if (mpt_verify_convert_proof(proofSlice.data(), pubKeySlice.data(), contextHash.data()) != 0)
+        return tecBAD_PROOF;
+
+    return tesSUCCESS;
+}
+
+TER
+verifyClawbackProof(
+    uint64_t const amount,
+    Slice const& proof,
+    Slice const& pubKeySlice,
+    Slice const& ciphertext,
+    uint256 const& contextHash)
+{
+    if (ciphertext.size() != kEcGamalEncryptedTotalLength ||
+        pubKeySlice.size() != kEcPubKeyLength || proof.size() != kEcClawbackProofLength)
+    {
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+    }
+
+    if (mpt_verify_clawback_proof(
+            proof.data(), amount, pubKeySlice.data(), ciphertext.data(), contextHash.data()) != 0)
+    {
+        return tecBAD_PROOF;
+    }
+
+    return tesSUCCESS;
+}
+
+TER
+verifySendProof(
+    Slice const& proof,
+    ConfidentialRecipient const& sender,
+    ConfidentialRecipient const& destination,
+    ConfidentialRecipient const& issuer,
+    std::optional const& auditor,
+    Slice const& spendingBalance,
+    Slice const& amountCommitment,
+    Slice const& balanceCommitment,
+    uint256 const& contextHash)
+{
+    auto const recipientCount = getConfidentialRecipientCount(auditor.has_value());
+    if (proof.size() != kEcSendProofLength || sender.publicKey.size() != kEcPubKeyLength ||
+        sender.encryptedAmount.size() != kEcGamalEncryptedTotalLength ||
+        destination.publicKey.size() != kEcPubKeyLength ||
+        destination.encryptedAmount.size() != kEcGamalEncryptedTotalLength ||
+        issuer.publicKey.size() != kEcPubKeyLength ||
+        issuer.encryptedAmount.size() != kEcGamalEncryptedTotalLength ||
+        spendingBalance.size() != kEcGamalEncryptedTotalLength ||
+        amountCommitment.size() != kEcPedersenCommitmentLength ||
+        balanceCommitment.size() != kEcPedersenCommitmentLength)
+    {
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+    }
+
+    std::vector participants;
+    participants.reserve(recipientCount);
+    participants.push_back(toParticipant(sender));
+    participants.push_back(toParticipant(destination));
+    participants.push_back(toParticipant(issuer));
+    if (auditor)
+    {
+        if (auditor->publicKey.size() != kEcPubKeyLength ||
+            auditor->encryptedAmount.size() != kEcGamalEncryptedTotalLength)
+        {
+            return tecINTERNAL;  // LCOV_EXCL_LINE
+        }
+        participants.push_back(toParticipant(*auditor));
+    }
+    if (participants.size() != recipientCount)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    if (mpt_verify_send_proof(
+            proof.data(),
+            participants.data(),
+            recipientCount,
+            spendingBalance.data(),
+            amountCommitment.data(),
+            balanceCommitment.data(),
+            contextHash.data()) != 0)
+    {
+        return tecBAD_PROOF;
+    }
+
+    return tesSUCCESS;
+}
+
+TER
+verifyConvertBackProof(
+    Slice const& proof,
+    Slice const& pubKeySlice,
+    Slice const& spendingBalance,
+    Slice const& balanceCommitment,
+    uint64_t amount,
+    uint256 const& contextHash)
+{
+    if (proof.size() != kEcConvertBackProofLength || pubKeySlice.size() != kEcPubKeyLength ||
+        spendingBalance.size() != kEcGamalEncryptedTotalLength ||
+        balanceCommitment.size() != kEcPedersenCommitmentLength)
+    {
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+    }
+
+    if (mpt_verify_convert_back_proof(
+            proof.data(),
+            pubKeySlice.data(),
+            spendingBalance.data(),
+            balanceCommitment.data(),
+            amount,
+            contextHash.data()) != 0)
+    {
+        return tecBAD_PROOF;
+    }
+
+    return tesSUCCESS;
+}
+
+}  // namespace xrpl
diff --git a/src/libxrpl/protocol/ErrorCodes.cpp b/src/libxrpl/protocol/ErrorCodes.cpp
index 25c5fbe033..87761ce13e 100644
--- a/src/libxrpl/protocol/ErrorCodes.cpp
+++ b/src/libxrpl/protocol/ErrorCodes.cpp
@@ -212,7 +212,7 @@ containsError(json::Value const& json)
 int
 errorCodeHttpStatus(ErrorCodeI code)
 {
-    return getErrorInfo(code).http_status;
+    return getErrorInfo(code).httpStatus;
 }
 
 }  // namespace RPC
diff --git a/src/libxrpl/protocol/IOUAmount.cpp b/src/libxrpl/protocol/IOUAmount.cpp
index d65ba41a01..acbf6724e1 100644
--- a/src/libxrpl/protocol/IOUAmount.cpp
+++ b/src/libxrpl/protocol/IOUAmount.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -17,29 +16,6 @@
 
 namespace xrpl {
 
-namespace {
-
-// Use a static inside a function to help prevent order-of-initialization issues
-LocalValue&
-getStaticSTNumberSwitchover()
-{
-    static LocalValue kR{true};
-    return kR;
-}
-}  // namespace
-
-bool
-getSTNumberSwitchover()
-{
-    return *getStaticSTNumberSwitchover();
-}
-
-void
-setSTNumberSwitchover(bool v)
-{
-    *getStaticSTNumberSwitchover() = v;
-}
-
 /* The range for the mantissa when normalized */
 // log(2^63,10) ~ 18.96
 //
@@ -56,7 +32,7 @@ IOUAmount::fromNumber(Number const& number)
     // to normalize, which calls fromNumber
     IOUAmount result{};
     std::tie(result.mantissa_, result.exponent_) =
-        number.normalizeToRange(kMinMantissa, kMaxMantissa);
+        number.normalizeToRange();
     return result;
 }
 
@@ -75,56 +51,20 @@ IOUAmount::normalize()
         return;
     }
 
-    if (getSTNumberSwitchover())
-    {
-        Number const v{mantissa_, exponent_};
-        *this = fromNumber(v);
-        if (exponent_ > kMaxExponent)
-            Throw("value overflow");
-        if (exponent_ < kMinExponent)
-            *this = beast::kZero;
-        return;
-    }
-
-    bool const negative = (mantissa_ < 0);
-
-    if (negative)
-        mantissa_ = -mantissa_;
-
-    while ((mantissa_ < kMinMantissa) && (exponent_ > kMinExponent))
-    {
-        mantissa_ *= 10;
-        --exponent_;
-    }
-
-    while (mantissa_ > kMaxMantissa)
-    {
-        if (exponent_ >= kMaxExponent)
-            Throw("IOUAmount::normalize");
-
-        mantissa_ /= 10;
-        ++exponent_;
-    }
-
-    if ((exponent_ < kMinExponent) || (mantissa_ < kMinMantissa))
-    {
-        *this = beast::kZero;
-        return;
-    }
-
-    if (exponent_ > kMaxExponent)
-        Throw("value overflow");
-
-    if (negative)
-        mantissa_ = -mantissa_;
+    Number const v{mantissa_, exponent_};
+    *this = IOUAmount(v);
 }
 
 IOUAmount::IOUAmount(Number const& other) : IOUAmount(fromNumber(other))
 {
     if (exponent_ > kMaxExponent)
+    {
         Throw("value overflow");
+    }
     if (exponent_ < kMinExponent)
+    {
         *this = beast::kZero;
+    }
 }
 
 IOUAmount&
@@ -139,37 +79,7 @@ IOUAmount::operator+=(IOUAmount const& other)
         return *this;
     }
 
-    if (getSTNumberSwitchover())
-    {
-        *this = IOUAmount{Number{*this} + Number{other}};
-        return *this;
-    }
-    auto m = other.mantissa_;
-    auto e = other.exponent_;
-
-    while (exponent_ < e)
-    {
-        mantissa_ /= 10;
-        ++exponent_;
-    }
-
-    while (e < exponent_)
-    {
-        m /= 10;
-        ++e;
-    }
-
-    // This addition cannot overflow an std::int64_t but we may throw from
-    // normalize if the result isn't representable.
-    mantissa_ += m;
-
-    if (mantissa_ >= -10 && mantissa_ <= 10)
-    {
-        *this = beast::kZero;
-        return *this;
-    }
-
-    normalize();
+    *this = IOUAmount{Number{*this} + Number{other}};
     return *this;
 }
 
diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp
index ae29bd3297..a49f7b85ee 100644
--- a/src/libxrpl/protocol/Indexes.cpp
+++ b/src/libxrpl/protocol/Indexes.cpp
@@ -218,7 +218,7 @@ amendments() noexcept
 }
 
 Keylet const&
-fees() noexcept
+feeSettings() noexcept
 {
     static Keylet const kRet{ltFEE_SETTINGS, indexHash(LedgerNameSpace::FeeSettings)};
     return kRet;
@@ -232,18 +232,18 @@ negativeUNL() noexcept
 }
 
 Keylet
-BookT::operator()(Book const& b) const
+book(Book const& b)
 {
     return {ltDIR_NODE, getBookBase(b)};
 }
 
 Keylet
-line(AccountID const& id0, AccountID const& id1, Currency const& currency) noexcept
+trustLine(AccountID const& id0, AccountID const& id1, Currency const& currency) noexcept
 {
     // There is code in TrustSet that calls us with id0 == id1, to allow users
     // to locate and delete such "weird" trustlines. If we remove that code, we
     // could enable this assert:
-    // XRPL_ASSERT(id0 != id1, "xrpl::keylet::line : accounts must be
+    // XRPL_ASSERT(id0 != id1, "xrpl::keylet::trustLine : accounts must be
     // different");
 
     // A trust line is shared between two accounts; while we typically think
@@ -285,20 +285,20 @@ quality(Keylet const& k, std::uint64_t q) noexcept
 }
 
 Keylet
-NextT::operator()(Keylet const& k) const
+next(Keylet const& k)
 {
-    XRPL_ASSERT(k.type == ltDIR_NODE, "xrpl::keylet::NextT::operator() : valid input type");
+    XRPL_ASSERT(k.type == ltDIR_NODE, "xrpl::keylet::next : valid input type");
     return {ltDIR_NODE, getQualityNext(k.key)};
 }
 
 Keylet
-TicketT::operator()(AccountID const& id, std::uint32_t ticketSeq) const
+ticket(AccountID const& id, std::uint32_t ticketSeq)
 {
     return {ltTICKET, getTicketIndex(id, ticketSeq)};
 }
 
 Keylet
-TicketT::operator()(AccountID const& id, SeqProxy ticketSeq) const
+ticket(AccountID const& id, SeqProxy ticketSeq)
 {
     return {ltTICKET, getTicketIndex(id, ticketSeq)};
 }
@@ -307,15 +307,15 @@ TicketT::operator()(AccountID const& id, SeqProxy ticketSeq) const
 // else. If we ever support multiple pages of signer lists, this would be the
 // keylet used to locate them.
 static Keylet
-signers(AccountID const& account, std::uint32_t page) noexcept
+signerList(AccountID const& account, std::uint32_t page) noexcept
 {
     return {ltSIGNER_LIST, indexHash(LedgerNameSpace::SignerList, account, page)};
 }
 
 Keylet
-signers(AccountID const& account) noexcept
+signerList(AccountID const& account) noexcept
 {
-    return signers(account, 0);
+    return signerList(account, 0);
 }
 
 Keylet
@@ -375,13 +375,13 @@ escrow(AccountID const& src, std::uint32_t seq) noexcept
 }
 
 Keylet
-payChan(AccountID const& src, AccountID const& dst, std::uint32_t seq) noexcept
+payChannel(AccountID const& src, AccountID const& dst, std::uint32_t seq) noexcept
 {
     return {ltPAYCHAN, indexHash(LedgerNameSpace::XRPPaymentChannel, src, dst, seq)};
 }
 
 Keylet
-nftpageMin(AccountID const& owner)
+nftokenPageMin(AccountID const& owner)
 {
     std::array buf{};
     std::memcpy(buf.data(), owner.data(), owner.size());
@@ -389,7 +389,7 @@ nftpageMin(AccountID const& owner)
 }
 
 Keylet
-nftpageMax(AccountID const& owner)
+nftokenPageMax(AccountID const& owner)
 {
     uint256 id = nft::kPageMask;
     std::memcpy(id.data(), owner.data(), owner.size());
@@ -397,14 +397,14 @@ nftpageMax(AccountID const& owner)
 }
 
 Keylet
-nftpage(Keylet const& k, uint256 const& token)
+nftokenPage(Keylet const& k, uint256 const& token)
 {
-    XRPL_ASSERT(k.type == ltNFTOKEN_PAGE, "xrpl::keylet::nftpage : valid input type");
+    XRPL_ASSERT(k.type == ltNFTOKEN_PAGE, "xrpl::keylet::nftokenPage : valid input type");
     return {ltNFTOKEN_PAGE, (k.key & ~nft::kPageMask) + (token & nft::kPageMask)};
 }
 
 Keylet
-nftoffer(AccountID const& owner, std::uint32_t seq)
+nftokenOffer(AccountID const& owner, std::uint32_t seq)
 {
     return {ltNFTOKEN_OFFER, indexHash(LedgerNameSpace::NftokenOffer, owner, seq)};
 }
@@ -518,13 +518,13 @@ oracle(AccountID const& account, std::uint32_t const& documentID) noexcept
 }
 
 Keylet
-mptIssuance(std::uint32_t seq, AccountID const& issuer) noexcept
+mptokenIssuance(std::uint32_t seq, AccountID const& issuer) noexcept
 {
-    return mptIssuance(makeMptID(seq, issuer));
+    return mptokenIssuance(makeMptID(seq, issuer));
 }
 
 Keylet
-mptIssuance(MPTID const& issuanceID) noexcept
+mptokenIssuance(MPTID const& issuanceID) noexcept
 {
     return {ltMPTOKEN_ISSUANCE, indexHash(LedgerNameSpace::MPTokenIssuance, issuanceID)};
 }
@@ -532,7 +532,7 @@ mptIssuance(MPTID const& issuanceID) noexcept
 Keylet
 mptoken(MPTID const& issuanceID, AccountID const& holder) noexcept
 {
-    return mptoken(mptIssuance(issuanceID).key, holder);
+    return mptoken(mptokenIssuance(issuanceID).key, holder);
 }
 
 Keylet
@@ -554,9 +554,9 @@ vault(AccountID const& owner, std::uint32_t seq) noexcept
 }
 
 Keylet
-loanbroker(AccountID const& owner, std::uint32_t seq) noexcept
+loanBroker(AccountID const& owner, std::uint32_t seq) noexcept
 {
-    return loanbroker(indexHash(LedgerNameSpace::LoanBroker, owner, seq));
+    return loanBroker(indexHash(LedgerNameSpace::LoanBroker, owner, seq));
 }
 
 Keylet
diff --git a/src/libxrpl/protocol/Permissions.cpp b/src/libxrpl/protocol/Permissions.cpp
index ce3baeb35e..80aa1b1f4e 100644
--- a/src/libxrpl/protocol/Permissions.cpp
+++ b/src/libxrpl/protocol/Permissions.cpp
@@ -1,91 +1,137 @@
 #include 
 
 #include 
+#include 
 #include 
-#include   // IWYU pragma: keep
 #include 
+#include 
+#include 
+#include 
+#include   // IWYU pragma: keep
 #include 
 
+#include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
+#include 
+#include 
+#include 
 
 namespace xrpl {
 
+Permission::GranularPermissionEntry::GranularPermissionEntry(
+    std::string name,
+    TxType txType,
+    std::uint32_t permittedFlags,
+    std::vector permittedFields)
+    : name(std::move(name))
+    , txType(txType)
+    , permittedFlags(permittedFlags)
+    , permittedFields(std::move(permittedFields), TxFormats::getCommonFields())
+{
+}
+
 Permission::Permission()
 {
-    txFeatureMap_ = {
-#pragma push_macro("TRANSACTION")
-#undef TRANSACTION
-
-#define TRANSACTION(tag, value, name, delegable, amendment, ...) {value, amendment},
-
-#include 
-
-#undef TRANSACTION
-#pragma pop_macro("TRANSACTION")
-    };
-
-    delegableTx_ = {
-#pragma push_macro("TRANSACTION")
-#undef TRANSACTION
-
-#define TRANSACTION(tag, value, name, delegable, ...) {value, delegable},
-
-#include 
-
-#undef TRANSACTION
-#pragma pop_macro("TRANSACTION")
-    };
-
-    granularPermissionMap_ = {
-#pragma push_macro("PERMISSION")
-#undef PERMISSION
-
-#define PERMISSION(type, txType, value) {#type, type},
-
-#include 
-
-#undef PERMISSION
-#pragma pop_macro("PERMISSION")
-    };
-
-    granularNameMap_ = {
-#pragma push_macro("PERMISSION")
-#undef PERMISSION
-
-#define PERMISSION(type, txType, value) {type, #type},
-
-#include 
-
-#undef PERMISSION
-#pragma pop_macro("PERMISSION")
-    };
-
-    granularTxTypeMap_ = {
-#pragma push_macro("PERMISSION")
-#undef PERMISSION
-
-#define PERMISSION(type, txType, value) {type, txType},
-
-#include 
-
-#undef PERMISSION
-#pragma pop_macro("PERMISSION")
-    };
-
-    XRPL_ASSERT(
-        txFeatureMap_.size() == delegableTx_.size(),
-        "xrpl::Permission : txFeatureMap_ and delegableTx_ must have same "
-        "size");
-
-    for ([[maybe_unused]] auto const& permission : granularPermissionMap_)
     {
-        XRPL_ASSERT(
-            permission.second > UINT16_MAX,
-            "xrpl::Permission::granularPermissionMap_ : granular permission "
-            "value must not exceed the maximum uint16_t value.");
+#pragma push_macro("TRANSACTION")
+#undef TRANSACTION
+
+#define TRANSACTION(tag, value, name, delegable, amendment, ...) \
+    txDelegationMap_[static_cast(value)] = {amendment, delegable};
+
+#include 
+
+#undef TRANSACTION
+#pragma pop_macro("TRANSACTION")
+    }
+
+    granularPermissionsByName_ = {
+#pragma push_macro("GRANULAR_PERMISSION")
+#undef GRANULAR_PERMISSION
+
+#define GRANULAR_PERMISSION(type, ...) {#type, type},
+
+#include 
+
+#undef GRANULAR_PERMISSION
+#pragma pop_macro("GRANULAR_PERMISSION")
+    };
+
+    {
+#pragma push_macro("GRANULAR_PERMISSION")
+#undef GRANULAR_PERMISSION
+
+// NOLINTBEGIN(bugprone-macro-parentheses)
+#define GRANULAR_PERMISSION(type, txType, value, flags, fields) \
+    granularPermissions_.emplace(                               \
+        std::piecewise_construct,                               \
+        std::forward_as_tuple(GranularPermissionType::type),    \
+        std::forward_as_tuple(                                  \
+            #type, txType, static_cast(flags), std::vector fields));
+        // NOLINTEND(bugprone-macro-parentheses)
+
+#include 
+
+#undef GRANULAR_PERMISSION
+#pragma pop_macro("GRANULAR_PERMISSION")
+    }
+
+    if (granularPermissionsByName_.size() != granularPermissions_.size())
+    {
+        // LCOV_EXCL_START
+        Throw(
+            "granularPermissionsByName_ and granularPermissions_ must have same size");
+        // LCOV_EXCL_STOP
+    }
+
+    for (auto const& [name, type] : granularPermissionsByName_)
+    {
+        if (type <= UINT16_MAX)
+        {
+            // LCOV_EXCL_START
+            Throw(
+                "Granular permission value must exceed the maximum uint16_t value: " + name);
+            // LCOV_EXCL_STOP
+        }
+    }
+
+    for (auto const& [type, entry] : granularPermissions_)
+        granularTxTypes_.insert(entry.txType);
+
+    // Validate that all fields listed in permissions.macro exist in the
+    // corresponding transaction type's format, catching typos at startup.
+    for (auto const& [type, entry] : granularPermissions_)
+    {
+        if (!txDelegationMap_.contains(entry.txType))
+        {
+            // LCOV_EXCL_START
+            Throw("Invalid granular permission txType in txDelegationMap_");
+            // LCOV_EXCL_STOP
+        }
+
+        auto const* fmt = TxFormats::getInstance().findByType(entry.txType);
+        if (fmt == nullptr)
+        {
+            // LCOV_EXCL_START
+            Throw("Invalid granular permission txType");
+            // LCOV_EXCL_STOP
+        }
+
+        for (auto const& field : entry.permittedFields)
+        {
+            if (fmt->getSOTemplate().getIndex(field.sField()) == -1)
+            {
+                // LCOV_EXCL_START
+                Throw("Invalid granular permission field");
+                // LCOV_EXCL_STOP
+            }
+        }
     }
 }
 
@@ -97,16 +143,21 @@ Permission::getInstance()
 }
 
 std::optional
-Permission::getPermissionName(std::uint32_t const value) const
+Permission::getPermissionName(std::uint32_t value) const
 {
+    if (value == 0)
+        return std::nullopt;
+
     auto const permissionValue = static_cast(value);
     if (auto const granular = getGranularName(permissionValue))
         return granular;
 
     // not a granular permission, check if it maps to a transaction type
-    auto const txType = permissionToTxType(value);
-    if (auto const* item = TxFormats::getInstance().findByType(txType); item != nullptr)
-        return item->getName();
+    if (auto const txType = permissionToTxType(value))
+    {
+        if (auto const* item = TxFormats::getInstance().findByType(*txType); item != nullptr)
+            return item->getName();
+    }
 
     return std::nullopt;
 }
@@ -114,90 +165,138 @@ Permission::getPermissionName(std::uint32_t const value) const
 std::optional
 Permission::getGranularValue(std::string const& name) const
 {
-    auto const it = granularPermissionMap_.find(name);
-    if (it != granularPermissionMap_.end())
+    auto const it = granularPermissionsByName_.find(name);
+    if (it != granularPermissionsByName_.end())
         return static_cast(it->second);
 
     return std::nullopt;
 }
 
 std::optional
-Permission::getGranularName(GranularPermissionType const& value) const
+Permission::getGranularName(GranularPermissionType value) const
 {
-    auto const it = granularNameMap_.find(value);
-    if (it != granularNameMap_.end())
-        return it->second;
+    auto const it = granularPermissions_.find(value);
+    if (it != granularPermissions_.end())
+        return it->second.name;
 
     return std::nullopt;
 }
 
 std::optional
-Permission::getGranularTxType(GranularPermissionType const& gpType) const
+Permission::getGranularTxType(GranularPermissionType gpType) const
 {
-    auto const it = granularTxTypeMap_.find(gpType);
-    if (it != granularTxTypeMap_.end())
-        return it->second;
+    auto const it = granularPermissions_.find(gpType);
+    if (it != granularPermissions_.end())
+        return it->second.txType;
 
     return std::nullopt;
 }
 
+bool
+Permission::hasGranularPermissions(TxType txType) const
+{
+    return granularTxTypes_.contains(txType);
+}
+
 std::optional>
 Permission::getTxFeature(TxType txType) const
 {
-    auto const txFeaturesIt = txFeatureMap_.find(txType);
+    auto const it = txDelegationMap_.find(txType);
     XRPL_ASSERT(
-        txFeaturesIt != txFeatureMap_.end(),
-        "xrpl::Permissions::getTxFeature : tx exists in txFeatureMap_");
+        it != txDelegationMap_.end(),
+        "xrpl::Permission::getTxFeature : tx exists in txDelegationMap_");
 
-    if (txFeaturesIt->second == uint256{})
+    if (it->second.amendment == uint256{})
         return std::nullopt;
-    return txFeaturesIt->second;
+
+    return std::optional{std::cref(it->second.amendment)};
 }
 
 bool
-Permission::isDelegable(std::uint32_t const& permissionValue, Rules const& rules) const
+Permission::isDelegable(std::uint32_t permissionValue, Rules const& rules) const
 {
-    auto const granularPermission =
-        getGranularName(static_cast(permissionValue));
-    if (granularPermission)
+    if (permissionValue == 0)
+        return false;  // LCOV_EXCL_LINE
+
+    auto const amendmentEnabled = [&rules](TxDelegationEntry const& entry) {
+        return entry.amendment == uint256{} || rules.enabled(entry.amendment);
+    };
+
+    // Granular permissions may authorize a limited subset of a tx type even
+    // when the full tx type is not delegable. They still require the
+    // underlying transaction amendment to be enabled.
+    if (auto const granularIt =
+            granularPermissions_.find(static_cast(permissionValue));
+        granularIt != granularPermissions_.end())
     {
-        // granular permissions are always allowed to be delegated
-        return true;
+        auto const txIt = txDelegationMap_.find(granularIt->second.txType);
+        return txIt != txDelegationMap_.end() && amendmentEnabled(txIt->second);
     }
 
     auto const txType = permissionToTxType(permissionValue);
-    auto const it = delegableTx_.find(txType);
-
-    if (it == delegableTx_.end())
+    if (!txType)
         return false;
 
-    auto const txFeaturesIt = txFeatureMap_.find(txType);
-    XRPL_ASSERT(
-        txFeaturesIt != txFeatureMap_.end(),
-        "xrpl::Permissions::isDelegable : tx exists in txFeatureMap_");
+    auto const txIt = txDelegationMap_.find(*txType);
 
-    // Delegation is only allowed if the required amendment for the transaction
-    // is enabled. For transactions that do not require an amendment, delegation
-    // is always allowed.
-    if (txFeaturesIt->second != uint256{} && !rules.enabled(txFeaturesIt->second))
-        return false;
-
-    if (it->second == Delegation::NotDelegable)
-        return false;
-
-    return true;
+    // Tx-level permissions require the transaction type itself to be delegable, and
+    // the corresponding amendment enabled.
+    return txIt != txDelegationMap_.end() && txIt->second.delegable != NotDelegable &&
+        amendmentEnabled(txIt->second);
 }
 
 uint32_t
-Permission::txToPermissionType(TxType const& type)
+Permission::txToPermissionType(TxType const type)
 {
     return static_cast(type) + 1;
 }
 
-TxType
-Permission::permissionToTxType(uint32_t const& value)
+std::optional
+Permission::permissionToTxType(uint32_t value)
 {
+    // Values outside this range [1, 65536] would silently truncate when cast to
+    // uint16_t, for example, 65537 would become 1, mapping to the Payment transaction.
+    if (value == 0 || value > std::numeric_limits::max() + 1u)
+        return std::nullopt;
+
     return static_cast(value - 1);
 }
 
+bool
+Permission::checkGranularSandbox(
+    STTx const& tx,
+    std::unordered_set const& heldPermissions) const
+{
+    // Build union of flags upfront to enable an early exit. Fields are not stored and
+    // grouped in advance to avoid heap allocation.
+    std::uint32_t unionFlags = 0;
+    for (auto const& gp : heldPermissions)
+    {
+        auto const it = granularPermissions_.find(gp);
+        if (it != granularPermissions_.end())
+            unionFlags |= it->second.permittedFlags;
+    }
+
+    // Check if flags are permitted
+    if ((tx.getFlags() & ~unionFlags) != 0)
+        return false;
+
+    // Check if fields are permitted. Every present field must appear in at least one held
+    // permission's template. The common fields are included in the constructor.
+    for (auto const& field : tx)
+    {
+        if (field.getSType() == STI_NOTPRESENT)
+            continue;
+
+        if (!std::ranges::any_of(heldPermissions, [&](auto const& gp) {
+                auto const it = granularPermissions_.find(gp);
+                return it != granularPermissions_.end() &&
+                    it->second.permittedFields.getIndex(field.getFName()) != -1;
+            }))
+            return false;
+    }
+
+    return true;
+}
+
 }  // namespace xrpl
diff --git a/src/libxrpl/protocol/PublicKey.cpp b/src/libxrpl/protocol/PublicKey.cpp
index 7472f059e9..c6e6c1d324 100644
--- a/src/libxrpl/protocol/PublicKey.cpp
+++ b/src/libxrpl/protocol/PublicKey.cpp
@@ -5,6 +5,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -211,7 +212,7 @@ publicKeyType(Slice const& slice)
         if (slice[0] == 0xED)
             return KeyType::Ed25519;
 
-        if (slice[0] == 0x02 || slice[0] == 0x03)
+        if (slice[0] == kEcCompressedPrefixEvenY || slice[0] == kEcCompressedPrefixOddY)
             return KeyType::Secp256k1;
     }
 
diff --git a/src/libxrpl/protocol/Rules.cpp b/src/libxrpl/protocol/Rules.cpp
index 2c971749b6..e0968ea868 100644
--- a/src/libxrpl/protocol/Rules.cpp
+++ b/src/libxrpl/protocol/Rules.cpp
@@ -38,15 +38,65 @@ setCurrentTransactionRules(std::optional r)
     // Make global changes associated with the rules before the value is moved.
     // Push the appropriate setting, instead of having the class pull every time
     // the value is needed. That could get expensive fast.
-    bool const enableLargeNumbers =
+
+    // If any new conditions with new amendments are added, those amendments must also be added to
+    // useRulesGuards.
+    bool const enableVaultNumbers =
         !r || (r->enabled(featureSingleAssetVault) || r->enabled(featureLendingProtocol));
-    Number::setMantissaScale(
-        enableLargeNumbers ? MantissaRange::MantissaScale::Large
-                           : MantissaRange::MantissaScale::Small);
+    bool const enableCuspRoundingFix = !r || r->enabled(fixCleanup3_2_0);
+    XRPL_ASSERT(
+        !r || useRulesGuards(*r) == (enableCuspRoundingFix || enableVaultNumbers),
+        "setCurrentTransactionRules : rule decisions match");
+
+    // Declare the range this way to keep clang-tidy from complaining
+    auto const range = [enableCuspRoundingFix, enableVaultNumbers]() {
+        if (enableVaultNumbers)
+        {
+            if (enableCuspRoundingFix)
+            {
+                return MantissaRange::MantissaScale::Large;
+            }
+            return MantissaRange::MantissaScale::LargeLegacy;
+        }
+        return MantissaRange::MantissaScale::Small;
+    }();
+    Number::setMantissaScale(range);
 
     *getCurrentTransactionRulesRef() = std::move(r);
 }
 
+bool
+useRulesGuards(Rules const& rules)
+{
+    // The list of amendments used here - to decide whether to create a RulesGuard - must be a
+    // superset of the list used to figure out which mantissa scale to use in
+    // setCurrentTransactionRules. Additional amendments can be added if desired.
+    //
+    // As soon as any one of these amendments is retired, this whole function can be removed, along
+    // with createGuards, and any other callers, and the first set of guards can be created directly
+    // at the call site, without using optional.
+    return rules.enabled(fixCleanup3_2_0) || rules.enabled(featureSingleAssetVault) ||
+        rules.enabled(featureLendingProtocol);
+}
+
+void
+createGuards(
+    Rules const& rules,
+    std::optional& rulesGuard,
+    std::optional& mantissaScaleGuard)
+{
+    if (useRulesGuards(rules))
+    {
+        // raii classes for the current ledger rules.
+        rulesGuard.emplace(rules);
+    }
+    else
+    {
+        // Without those features enabled, always use the old number rules.
+        mantissaScaleGuard.emplace(MantissaRange::MantissaScale::Small);
+    }
+}
+
 class Rules::Impl
 {
 private:
diff --git a/src/libxrpl/protocol/SField.cpp b/src/libxrpl/protocol/SField.cpp
index f2a523db49..a2d4903c6a 100644
--- a/src/libxrpl/protocol/SField.cpp
+++ b/src/libxrpl/protocol/SField.cpp
@@ -53,8 +53,8 @@ TypedField::TypedField(PrivateAccessTagT pat, Args&&... args)
         ##__VA_ARGS__);
 
 // SFields which, for historical reasons, do not follow naming conventions.
-SField const kSfInvalid(access, -1, "");
-SField const kSfGeneric(access, 0, "Generic");
+SField const sfInvalid(access, -1, "");
+SField const sfGeneric(access, 0, "Generic");
 // The following two fields aren't used anywhere, but they break tests/have
 // downstream effects.
 SField const kSfHash(access, STI_UINT256, 257, "hash");
@@ -121,7 +121,7 @@ SField::getField(int code)
     {
         return *(it->second);
     }
-    return kSfInvalid;
+    return sfInvalid;
 }
 
 int
@@ -149,7 +149,7 @@ SField::getField(std::string const& fieldName)
     {
         return *(it->second);
     }
-    return kSfInvalid;
+    return sfInvalid;
 }
 
 }  // namespace xrpl
diff --git a/src/libxrpl/protocol/STAmount.cpp b/src/libxrpl/protocol/STAmount.cpp
index 3cbf03575b..748d00f25a 100644
--- a/src/libxrpl/protocol/STAmount.cpp
+++ b/src/libxrpl/protocol/STAmount.cpp
@@ -19,8 +19,10 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -386,47 +388,9 @@ operator+(STAmount const& v1, STAmount const& v2)
     if (v1.holds())
         return {v1.asset_, v1.mpt().value() + v2.mpt().value()};
 
-    if (getSTNumberSwitchover())
-    {
-        auto x = v1;
-        x = v1.iou() + v2.iou();
-        return x;
-    }
-
-    int ov1 = v1.exponent(), ov2 = v2.exponent();
-    std::int64_t vv1 = static_cast(v1.mantissa());
-    std::int64_t vv2 = static_cast(v2.mantissa());
-
-    if (v1.negative())
-        vv1 = -vv1;
-
-    if (v2.negative())
-        vv2 = -vv2;
-
-    while (ov1 < ov2)
-    {
-        vv1 /= 10;
-        ++ov1;
-    }
-
-    while (ov2 < ov1)
-    {
-        vv2 /= 10;
-        ++ov2;
-    }
-
-    // This addition cannot overflow an std::int64_t. It can overflow an
-    // STAmount and the constructor will throw.
-
-    std::int64_t const fv = vv1 + vv2;
-
-    if ((fv >= -10) && (fv <= 10))
-        return {v1.getFName(), v1.asset()};
-
-    if (fv >= 0)
-        return STAmount{v1.getFName(), v1.asset(), static_cast(fv), ov1, false};
-
-    return STAmount{v1.getFName(), v1.asset(), static_cast(-fv), ov1, true};
+    auto x = v1;
+    x = v1.iou() + v2.iou();
+    return x;
 }
 
 STAmount
@@ -875,53 +839,25 @@ STAmount::canonicalize()
         if (asset_.holds() && offset_ > 18)
             Throw("MPT amount out of range");
 
-        if (getSTNumberSwitchover())
+        Number const num(isNegative_, value_, offset_, Number::Unchecked{});
+        auto set = [&](auto const& val) {
+            auto const value = val.value();
+            isNegative_ = value < 0;
+            value_ = isNegative_ ? -value : value;
+        };
+        if (native())
         {
-            Number const num(isNegative_, value_, offset_, Number::Unchecked{});
-            auto set = [&](auto const& val) {
-                auto const value = val.value();
-                isNegative_ = value < 0;
-                value_ = isNegative_ ? -value : value;
-            };
-            if (native())
-            {
-                set(XRPAmount{num});
-            }
-            else if (asset_.holds())
-            {
-                set(MPTAmount{num});
-            }
-            else
-            {
-                Throw("Unknown integral asset type");
-            }
-            offset_ = 0;
+            set(XRPAmount{num});
+        }
+        else if (asset_.holds())
+        {
+            set(MPTAmount{num});
         }
         else
         {
-            while (offset_ < 0)
-            {
-                value_ /= 10;
-                ++offset_;
-            }
-
-            while (offset_ > 0)
-            {
-                // N.B. do not move the overflow check to after the
-                // multiplication
-                if (native() && value_ > kMaxNativeN)
-                {
-                    Throw("Native currency amount out of range");
-                }
-                else if (!native() && value_ > kMaxMpTokenAmount)
-                {
-                    Throw("MPT amount out of range");
-                }
-
-                value_ *= 10;
-                --offset_;
-            }
+            Throw("Unknown integral asset type");  // LCOV_EXCL_LINE
         }
+        offset_ = 0;
 
         if (native() && value_ > kMaxNativeN)
         {
@@ -935,53 +871,7 @@ STAmount::canonicalize()
         return;
     }
 
-    if (getSTNumberSwitchover())
-    {
-        *this = iou();
-        return;
-    }
-
-    if (value_ == 0)
-    {
-        offset_ = -100;
-        isNegative_ = false;
-        return;
-    }
-
-    while ((value_ < kMinValue) && (offset_ > kMinOffset))
-    {
-        value_ *= 10;
-        --offset_;
-    }
-
-    while (value_ > kMaxValue)
-    {
-        if (offset_ >= kMaxOffset)
-            Throw("value overflow");
-
-        value_ /= 10;
-        ++offset_;
-    }
-
-    if ((offset_ < kMinOffset) || (value_ < kMinValue))
-    {
-        value_ = 0;
-        isNegative_ = false;
-        offset_ = -100;
-        return;
-    }
-
-    if (offset_ > kMaxOffset)
-        Throw("value overflow");
-
-    XRPL_ASSERT(
-        (value_ == 0) || ((value_ >= kMinValue) && (value_ <= kMaxValue)),
-        "xrpl::STAmount::canonicalize : value inside range");
-    XRPL_ASSERT(
-        (value_ == 0) || ((offset_ >= kMinOffset) && (offset_ <= kMaxOffset)),
-        "xrpl::STAmount::canonicalize : offset inside range");
-    XRPL_ASSERT(
-        (value_ != 0) || (offset_ != -100), "xrpl::STAmount::canonicalize : value or offset set");
+    *this = iou();
 }
 
 void
@@ -1151,7 +1041,7 @@ amountFromJsonNoThrow(STAmount& result, json::Value const& jvSource)
 {
     try
     {
-        result = amountFromJson(kSfGeneric, jvSource);
+        result = amountFromJson(sfGeneric, jvSource);
         return true;
     }
     catch (std::exception const& e)
@@ -1222,6 +1112,68 @@ operator-(STAmount const& value)
         STAmount::Unchecked{});
 }
 
+static bool
+hasInvalidAmount(STBase const& field, int depth, beast::Journal j);
+
+static bool
+hasInvalidAmount(STObject const& object, int depth, beast::Journal j)
+{
+    return std::ranges::any_of(
+        object, [&](STBase const& field) { return hasInvalidAmount(field, depth, j); });
+}
+
+static bool
+hasInvalidAmount(STArray const& array, int depth, beast::Journal j)
+{
+    return std::ranges::any_of(
+        array, [&](STObject const& object) { return hasInvalidAmount(object, depth, j); });
+}
+
+static bool
+hasInvalidAmount(STBase const& field, int depth, beast::Journal j)
+{
+    if (depth > 10)
+    {
+        JLOG(j.error()) << "hasInvalidAmount: depth exceeds 10";
+        return true;
+    }
+
+    // Dispatch on the serialized type tag rather than RTTI: this is on the invariant-checking path
+    // and a dynamic_cast chain over every field of every modified entry is measurably expensive.
+    // The object-like tags below all denote STObject subclasses (STLedgerEntry, STTx), so the
+    // downcast is sound; nested fields are only ever plain STI_OBJECT / STI_ARRAY containers.
+    // safeDowncast keeps a dynamic_cast validity assert in debug builds while compiling to
+    // static_cast in release.
+    switch (field.getSType())
+    {
+        case STI_AMOUNT: {
+            auto const& amount = safeDowncast(field);
+            return !isLegalMPT(amount) || !isLegalNet(amount);
+        }
+
+        case STI_OBJECT:
+        case STI_LEDGERENTRY:
+        case STI_TRANSACTION:
+            return hasInvalidAmount(safeDowncast(field), depth + 1, j);
+
+        case STI_ARRAY:
+            return hasInvalidAmount(safeDowncast(field), depth + 1, j);
+
+        default: {
+            XRPL_ASSERT(
+                dynamic_cast(&field) == nullptr,
+                "xrpl::hasInvalidAmount : unhandled STObject type");
+            return false;
+        }
+    }
+}
+
+bool
+hasInvalidAmount(STBase const& field, beast::Journal j)
+{
+    return hasInvalidAmount(field, 0, j);
+}
+
 //------------------------------------------------------------------------------
 //
 // Arithmetic
@@ -1349,44 +1301,8 @@ multiply(STAmount const& v1, STAmount const& v2, Asset const& asset)
         return STAmount(asset, minV * maxV);
     }
 
-    if (getSTNumberSwitchover())
-    {
-        auto const r = Number{v1} * Number{v2};
-        return STAmount{asset, r};
-    }
-
-    std::uint64_t value1 = v1.mantissa();
-    std::uint64_t value2 = v2.mantissa();
-    int offset1 = v1.exponent();
-    int offset2 = v2.exponent();
-
-    if (v1.integral())
-    {
-        while (value1 < STAmount::kMinValue)
-        {
-            value1 *= 10;
-            --offset1;
-        }
-    }
-
-    if (v2.integral())
-    {
-        while (value2 < STAmount::kMinValue)
-        {
-            value2 *= 10;
-            --offset2;
-        }
-    }
-
-    // We multiply the two mantissas (each is between 10^15
-    // and 10^16), so their product is in the 10^30 to 10^32
-    // range. Dividing their product by 10^14 maintains the
-    // precision, by scaling the result to 10^16 to 10^18.
-    return STAmount(
-        asset,
-        muldiv(value1, value2, kTenTO14) + 7,
-        offset1 + offset2 + 14,
-        v1.negative() != v2.negative());
+    auto const r = Number{v1} * Number{v2};
+    return STAmount{asset, r};
 }
 
 // This is the legacy version of canonicalizeRound.  It's been in use
@@ -1505,7 +1421,7 @@ roundToScale(STAmount const& value, std::int32_t scale, Number::RoundingMode rou
     STAmount const referenceValue{value.asset(), STAmount::kMinValue, scale, value.negative()};
 
     NumberRoundModeGuard const mg(rounding);
-    // With an IOU, the the result of addition will be truncated to the
+    // With an IOU, the result of addition will be truncated to the
     // precision of the larger value, which in this case is referenceValue. Then
     // remove the reference value via subtraction, and we're left with the
     // rounded value.
@@ -1738,4 +1654,9 @@ divRoundStrict(STAmount const& num, STAmount const& den, Asset const& asset, boo
     return divRoundImpl(num, den, asset, roundUp);
 }
 
+[[nodiscard]] bool
+STAmount::isZeroAtScale(int scale) const
+{
+    return roundToScale(*this, scale, Number::RoundingMode::ToNearest).signum() == 0;
+}
 }  // namespace xrpl
diff --git a/src/libxrpl/protocol/STBase.cpp b/src/libxrpl/protocol/STBase.cpp
index d1ae1e5aa8..f029f10e75 100644
--- a/src/libxrpl/protocol/STBase.cpp
+++ b/src/libxrpl/protocol/STBase.cpp
@@ -12,7 +12,7 @@
 
 namespace xrpl {
 
-STBase::STBase() : fName_(&kSfGeneric)
+STBase::STBase() : fName_(&sfGeneric)
 {
 }
 
diff --git a/src/libxrpl/protocol/STNumber.cpp b/src/libxrpl/protocol/STNumber.cpp
index aa3e83515c..8ef7b9760f 100644
--- a/src/libxrpl/protocol/STNumber.cpp
+++ b/src/libxrpl/protocol/STNumber.cpp
@@ -96,7 +96,8 @@ STNumber::add(Serializer& s) const
             // Json. Regardless, the only time we should be serializing an
             // STNumber is when the scale is large.
             XRPL_ASSERT_PARTS(
-                Number::getMantissaScale() == MantissaRange::MantissaScale::Large,
+                Number::getMantissaScale() == MantissaRange::MantissaScale::LargeLegacy ||
+                    Number::getMantissaScale() == MantissaRange::MantissaScale::Large,
                 "xrpl::STNumber::add",
                 "STNumber only used with large mantissa scale");
 #endif
diff --git a/src/libxrpl/protocol/STParsedJSON.cpp b/src/libxrpl/protocol/STParsedJSON.cpp
index 7b9989afac..64c4dfd1be 100644
--- a/src/libxrpl/protocol/STParsedJSON.cpp
+++ b/src/libxrpl/protocol/STParsedJSON.cpp
@@ -258,7 +258,7 @@ parseUInt16(
                         safeCast(static_cast(
                             TxFormats::getInstance().findTypeByName(strValue))));
 
-                    if (*name == kSfGeneric)
+                    if (*name == sfGeneric)
                         name = &sfTransaction;
                 }
                 else if (field == sfLedgerEntryType)
@@ -268,7 +268,7 @@ parseUInt16(
                         safeCast(static_cast(
                             LedgerFormats::getInstance().findTypeByName(strValue))));
 
-                    if (*name == kSfGeneric)
+                    if (*name == sfGeneric)
                         name = &sfLedgerEntry;
                 }
                 else
@@ -361,7 +361,7 @@ parseLeaf(
     auto const& field = SField::getField(fieldName);
 
     // checked in parseObject
-    if (field == kSfInvalid)
+    if (field == sfInvalid)
     {
         // LCOV_EXCL_START
         error = unknownField(jsonName, fieldName);
@@ -1013,7 +1013,7 @@ parseObject(
             json::Value const& value = json[fieldName];
             auto const& field = SField::getField(fieldName);
 
-            if (field == kSfInvalid)
+            if (field == sfInvalid)
             {
                 error = unknownField(jsonName, fieldName);
                 return std::nullopt;
@@ -1144,7 +1144,7 @@ parseArray(
             std::string const memberName(json[i].getMemberNames()[0]);
             auto const& nameField(SField::getField(memberName));
 
-            if (nameField == kSfInvalid)
+            if (nameField == sfInvalid)
             {
                 error = unknownField(jsonName, memberName);
                 return std::nullopt;
@@ -1189,7 +1189,7 @@ parseArray(
 STParsedJSONObject::STParsedJSONObject(std::string const& name, json::Value const& json)
 {
     using namespace STParsedJSONDetail;
-    object = parseObject(name, json, kSfGeneric, 0, error);
+    object = parseObject(name, json, sfGeneric, 0, error);
 }
 
 }  // namespace xrpl
diff --git a/src/libxrpl/protocol/STPathSet.cpp b/src/libxrpl/protocol/STPathSet.cpp
index 35d5fd782b..cb70ac36fe 100644
--- a/src/libxrpl/protocol/STPathSet.cpp
+++ b/src/libxrpl/protocol/STPathSet.cpp
@@ -90,8 +90,12 @@ STPathSet::STPathSet(SerialIter& sit, SField const& name) : STBase(name)
             if (hasAccount)
                 account = sit.get160();
 
-            XRPL_ASSERT(
-                !(hasCurrency && hasMPT), "xrpl::STPathSet::STPathSet : not has Currency and MPT");
+            if (hasCurrency && hasMPT)
+            {
+                JLOG(debugLog().error()) << "Bad path element MPT and Currency in pathset";
+                Throw("bad path element: MPT and Currency");
+            }
+
             if (hasCurrency)
                 asset = Currency::fromRaw(sit.get160());
 
@@ -101,7 +105,7 @@ STPathSet::STPathSet(SerialIter& sit, SField const& name) : STBase(name)
             if (hasIssuer)
                 issuer = sit.get160();
 
-            path.emplace_back(account, asset, issuer, hasCurrency);
+            path.emplace_back(account, asset, issuer, hasCurrency || hasMPT);
         }
     }
 }
diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp
index 878aa296dd..be3b1a082f 100644
--- a/src/libxrpl/protocol/STTx.cpp
+++ b/src/libxrpl/protocol/STTx.cpp
@@ -1,7 +1,6 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -40,6 +39,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -69,8 +69,8 @@ getTxFormat(TxType type)
 
 STTx::STTx(STObject&& object) : STObject(std::move(object))
 {
-    tx_type_ = safeCast(getFieldU16(sfTransactionType));
-    applyTemplate(getTxFormat(tx_type_)->getSOTemplate());  //  may throw
+    txType_ = safeCast(getFieldU16(sfTransactionType));
+    applyTemplate(getTxFormat(txType_)->getSOTemplate());  //  may throw
     tid_ = getHash(HashPrefix::TransactionId);
 }
 
@@ -84,9 +84,9 @@ STTx::STTx(SerialIter& sit) : STObject(sfTransaction)
     if (set(sit))
         Throw("Transaction contains an object terminator");
 
-    tx_type_ = safeCast(getFieldU16(sfTransactionType));
+    txType_ = safeCast(getFieldU16(sfTransactionType));
 
-    applyTemplate(getTxFormat(tx_type_)->getSOTemplate());  // May throw
+    applyTemplate(getTxFormat(txType_)->getSOTemplate());  // May throw
     tid_ = getHash(HashPrefix::TransactionId);
 }
 
@@ -99,9 +99,9 @@ STTx::STTx(TxType type, std::function assembler) : STObject(sfT
 
     assembler(*this);
 
-    tx_type_ = safeCast(getFieldU16(sfTransactionType));
+    txType_ = safeCast(getFieldU16(sfTransactionType));
 
-    if (tx_type_ != type)
+    if (txType_ != type)
         logicError("Transaction type was mutated during assembly");
 
     tid_ = getHash(HashPrefix::TransactionId);
@@ -217,7 +217,7 @@ STTx::getFeePayer() const
 {
     // If sfDelegate is present, the delegate account is the payer
     // note: if a delegate is specified, its authorization to act on behalf of the account is
-    // enforced in `Transactor::checkPermission`
+    // enforced in `Transactor::invokeCheckPermission`
     // cryptographic signature validity is checked separately (e.g., in `Transactor::checkSign`)
     if (isFieldPresent(sfDelegate))
         return getAccountID(sfDelegate);
@@ -248,7 +248,7 @@ STTx::sign(
     tid_ = getHash(HashPrefix::TransactionId);
 }
 
-Expected
+std::expected
 STTx::checkSign(Rules const& rules, STObject const& sigObject) const
 {
     try
@@ -263,11 +263,11 @@ STTx::checkSign(Rules const& rules, STObject const& sigObject) const
     }
     catch (...)
     {
-        return Unexpected("Internal signature check failure.");
+        return std::unexpected("Internal signature check failure.");
     }
 }
 
-Expected
+std::expected
 STTx::checkSign(Rules const& rules) const
 {
     if (auto const ret = checkSign(rules, *this); !ret)
@@ -277,12 +277,12 @@ STTx::checkSign(Rules const& rules) const
     {
         auto const counterSig = getFieldObject(sfCounterpartySignature);
         if (auto const ret = checkSign(rules, counterSig); !ret)
-            return Unexpected("Counterparty: " + ret.error());
+            return std::unexpected("Counterparty: " + ret.error());
     }
     return {};
 }
 
-Expected
+std::expected
 STTx::checkBatchSign(Rules const& rules) const
 {
     try
@@ -291,7 +291,7 @@ STTx::checkBatchSign(Rules const& rules) const
         if (getTxnType() != ttBATCH)
         {
             JLOG(debugLog().fatal()) << "not a batch transaction";
-            return Unexpected("Not a batch transaction.");
+            return std::unexpected("Not a batch transaction.");
         }
         STArray const& signers{getFieldArray(sfBatchSigners)};
         for (auto const& signer : signers)
@@ -309,7 +309,7 @@ STTx::checkBatchSign(Rules const& rules) const
     {
         JLOG(debugLog().error()) << "Batch signature check failed: " << e.what();
     }
-    return Unexpected("Internal batch signature check failure.");
+    return std::unexpected("Internal batch signature check failure.");
 }
 
 json::Value
@@ -380,7 +380,7 @@ STTx::getMetaSQL(
     static boost::format const kBfTrans("('%s', '%s', '%s', '%d', '%d', '%c', %s, %s)");
     std::string rTxn = sqlBlobLiteral(rawTxn.peekData());
 
-    auto format = TxFormats::getInstance().findByType(tx_type_);
+    auto format = TxFormats::getInstance().findByType(txType_);
     XRPL_ASSERT(format, "xrpl::STTx::getMetaSQL : non-null type format");
 
     return str(
@@ -389,14 +389,14 @@ STTx::getMetaSQL(
         safeCast(status) % rTxn % escapedMetaData);
 }
 
-static Expected
+static std::expected
 singleSignHelper(STObject const& sigObject, Slice const& data)
 {
     // We don't allow both a non-empty sfSigningPubKey and an sfSigners.
     // That would allow the transaction to be signed two ways.  So if both
     // fields are present the signature is invalid.
     if (sigObject.isFieldPresent(sfSigners))
-        return Unexpected("Cannot both single- and multi-sign.");
+        return std::unexpected("Cannot both single- and multi-sign.");
 
     bool validSig = false;
     try
@@ -414,19 +414,19 @@ singleSignHelper(STObject const& sigObject, Slice const& data)
     }
 
     if (!validSig)
-        return Unexpected("Invalid signature.");
+        return std::unexpected("Invalid signature.");
 
     return {};
 }
 
-Expected
+std::expected
 STTx::checkSingleSign(STObject const& sigObject) const
 {
     auto const data = getSigningData(*this);
     return singleSignHelper(sigObject, makeSlice(data));
 }
 
-Expected
+std::expected
 STTx::checkBatchSingleSign(STObject const& batchSigner) const
 {
     Serializer msg;
@@ -434,7 +434,7 @@ STTx::checkBatchSingleSign(STObject const& batchSigner) const
     return singleSignHelper(batchSigner, msg.slice());
 }
 
-Expected
+std::expected
 multiSignHelper(
     STObject const& sigObject,
     std::optional txnAccountID,
@@ -444,18 +444,18 @@ multiSignHelper(
     // Make sure the MultiSigners are present.  Otherwise they are not
     // attempting multi-signing and we just have a bad SigningPubKey.
     if (!sigObject.isFieldPresent(sfSigners))
-        return Unexpected("Empty SigningPubKey.");
+        return std::unexpected("Empty SigningPubKey.");
 
     // We don't allow both an sfSigners and an sfTxnSignature.  Both fields
     // being present would indicate that the transaction is signed both ways.
     if (sigObject.isFieldPresent(sfTxnSignature))
-        return Unexpected("Cannot both single- and multi-sign.");
+        return std::unexpected("Cannot both single- and multi-sign.");
 
     STArray const& signers{sigObject.getFieldArray(sfSigners)};
 
     // There are well known bounds that the number of signers must be within.
     if (signers.size() < STTx::kMinMultiSigners || signers.size() > STTx::kMaxMultiSigners)
-        return Unexpected("Invalid Signers array size.");
+        return std::unexpected("Invalid Signers array size.");
 
     // Signers must be in sorted order by AccountID.
     AccountID lastAccountID(beast::kZero);
@@ -468,15 +468,15 @@ multiSignHelper(
         // If they can, txnAccountID will be unseated, which is not equal to any
         // value.
         if (txnAccountID == accountID)
-            return Unexpected("Invalid multisigner.");
+            return std::unexpected("Invalid multisigner.");
 
         // No duplicate signers allowed.
         if (lastAccountID == accountID)
-            return Unexpected("Duplicate Signers not allowed.");
+            return std::unexpected("Duplicate Signers not allowed.");
 
         // Accounts must be in order by account ID.  No duplicates allowed.
         if (lastAccountID > accountID)
-            return Unexpected("Unsorted Signers array.");
+            return std::unexpected("Unsorted Signers array.");
 
         // The next signature must be greater than this one.
         lastAccountID = accountID;
@@ -502,7 +502,7 @@ multiSignHelper(
         }
         if (!validSig)
         {
-            return Unexpected(
+            return std::unexpected(
                 std::string("Invalid signature on account ") + toBase58(accountID) +
                 errorWhat.value_or("") + ".");
         }
@@ -511,7 +511,7 @@ multiSignHelper(
     return {};
 }
 
-Expected
+std::expected
 STTx::checkBatchMultiSign(STObject const& batchSigner, Rules const& rules) const
 {
     // We can ease the computational load inside the loop a bit by
@@ -530,13 +530,15 @@ STTx::checkBatchMultiSign(STObject const& batchSigner, Rules const& rules) const
         rules);
 }
 
-Expected
+std::expected
 STTx::checkMultiSign(Rules const& rules, STObject const& sigObject) const
 {
     // Used inside the loop in multiSignHelper to enforce that
     // the account owner may not multisign for themselves.
+    // For delegated transactions sfDelegate is the account whose signer list is checked,
+    // the delegate account itself can not be among the signers.
     auto const txnAccountID =
-        &sigObject != this ? std::nullopt : std::optional(getAccountID(sfAccount));
+        &sigObject != this ? std::nullopt : std::optional(getFeePayer());
 
     // We can ease the computational load inside the loop a bit by
     // pre-constructing part of the data that we hash.  Fill a Serializer
diff --git a/src/libxrpl/protocol/TER.cpp b/src/libxrpl/protocol/TER.cpp
index 6b8dfc6811..e5c1d17b1a 100644
--- a/src/libxrpl/protocol/TER.cpp
+++ b/src/libxrpl/protocol/TER.cpp
@@ -106,6 +106,7 @@ transResults()
         MAKE_ERROR(tecLIMIT_EXCEEDED,                "Limit exceeded."),
         MAKE_ERROR(tecPSEUDO_ACCOUNT,                "This operation is not allowed against a pseudo-account."),
         MAKE_ERROR(tecPRECISION_LOSS,                "The amounts used by the transaction cannot interact."),
+        MAKE_ERROR(tecBAD_PROOF,                     "Proof cannot be verified"),
 
         MAKE_ERROR(tefALREADY,                     "The exact transaction was already in this ledger."),
         MAKE_ERROR(tefBAD_ADD_AUTH,                "Not authorized to add account."),
@@ -199,6 +200,7 @@ transResults()
         MAKE_ERROR(temARRAY_TOO_LARGE,           "Malformed: Array is too large."),
         MAKE_ERROR(temBAD_TRANSFER_FEE,          "Malformed: Transfer fee is outside valid range."),
         MAKE_ERROR(temINVALID_INNER_BATCH,       "Malformed: Invalid inner batch transaction."),
+        MAKE_ERROR(temBAD_CIPHERTEXT,            "Malformed: Invalid ciphertext."),
 
         MAKE_ERROR(terRETRY,                  "Retry transaction."),
         MAKE_ERROR(terFUNDS_SPENT,            "DEPRECATED."),
diff --git a/src/libxrpl/protocol/XChainAttestations.cpp b/src/libxrpl/protocol/XChainAttestations.cpp
index e41e5f3d61..792fe5da9d 100644
--- a/src/libxrpl/protocol/XChainAttestations.cpp
+++ b/src/libxrpl/protocol/XChainAttestations.cpp
@@ -195,7 +195,7 @@ AttestationClaim::message(
     std::uint64_t claimID,
     std::optional const& dst)
 {
-    STObject o{kSfGeneric};
+    STObject o{sfGeneric};
     // Serialize in SField order to make python serializers easier to write
     o[sfXChainClaimID] = claimID;
     o[sfAmount] = sendingAmount;
@@ -332,7 +332,7 @@ AttestationCreateAccount::message(
     std::uint64_t createCount,
     AccountID const& dst)
 {
-    STObject o{kSfGeneric};
+    STObject o{sfGeneric};
     // Serialize in SField order to make python serializers easier to write
     o[sfXChainAccountCreateCount] = createCount;
     o[sfAmount] = sendingAmount;
@@ -628,28 +628,28 @@ XChainAttestationsBase::XChainAttestationsBase(
 }
 
 template 
-typename XChainAttestationsBase::AttCollection::const_iterator
+XChainAttestationsBase::AttCollection::const_iterator
 XChainAttestationsBase::begin() const
 {
     return attestations_.begin();
 }
 
 template 
-typename XChainAttestationsBase::AttCollection::const_iterator
+XChainAttestationsBase::AttCollection::const_iterator
 XChainAttestationsBase::end() const
 {
     return attestations_.end();
 }
 
 template 
-typename XChainAttestationsBase::AttCollection::iterator
+XChainAttestationsBase::AttCollection::iterator
 XChainAttestationsBase::begin()
 {
     return attestations_.begin();
 }
 
 template 
-typename XChainAttestationsBase::AttCollection::iterator
+XChainAttestationsBase::AttCollection::iterator
 XChainAttestationsBase::end()
 {
     return attestations_.end();
diff --git a/src/libxrpl/protocol/tokens.cpp b/src/libxrpl/protocol/tokens.cpp
index a43cbd9c85..d04ceaa3a6 100644
--- a/src/libxrpl/protocol/tokens.cpp
+++ b/src/libxrpl/protocol/tokens.cpp
@@ -9,7 +9,6 @@
 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -23,6 +22,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -135,16 +135,16 @@ static constexpr std::array const kAlphabetReverse = []() {
 }();
 
 template 
-static typename Hasher::result_type
+static Hasher::result_type
 digest(void const* data, std::size_t size) noexcept
 {
     Hasher h;
     h(data, size);
-    return static_cast(h);
+    return static_cast(h);
 }
 
 template >
-static typename Hasher::result_type
+static Hasher::result_type
 digest(std::array const& v)
 {
     return digest(v.data(), v.size());
@@ -152,7 +152,7 @@ digest(std::array const& v)
 
 // Computes a double digest (e.g. digest of the digest)
 template 
-static typename Hasher::result_type
+static Hasher::result_type
 digest2(Args const&... args)
 {
     return digest(digest(args...));
@@ -353,7 +353,7 @@ b256ToB58Be(std::span input, std::span out)
     // (33 bytes for nodepublic + 1 byte token + 4 bytes checksum)
     if (input.size() > 38)
     {
-        return Unexpected(TokenCodecErrc::InputTooLarge);
+        return std::unexpected(TokenCodecErrc::InputTooLarge);
     };
 
     auto countLeadingZeros = [](std::span const& col) -> std::size_t {
@@ -441,7 +441,7 @@ b256ToB58Be(std::span input, std::span out)
         static constexpr std::uint64_t kB5810 = 430804206899405824;  // 58^10;
         if (base5810Coeff[i] >= kB5810)
         {
-            return Unexpected(TokenCodecErrc::InputTooLarge);
+            return std::unexpected(TokenCodecErrc::InputTooLarge);
         }
         std::array const b58Be =
             xrpl::b58_fast::detail::b5810ToB58Be(base5810Coeff[i]);
@@ -453,7 +453,7 @@ b256ToB58Be(std::span input, std::span out)
             skipZeros = false;
             if (out.size() < ((i + 1) * 10) - toSkip)
             {
-                return Unexpected(TokenCodecErrc::OutputTooSmall);
+                return std::unexpected(TokenCodecErrc::OutputTooSmall);
             }
         }
         for (auto b58Coeff : b58BeS.subspan(toSkip))
@@ -476,11 +476,11 @@ b58ToB256Be(std::string_view input, std::span out)
     // log(2^(38*8),58) ~= 51.9
     if (input.size() > 52)
     {
-        return Unexpected(TokenCodecErrc::InputTooLarge);
+        return std::unexpected(TokenCodecErrc::InputTooLarge);
     };
     if (out.size() < 8)
     {
-        return Unexpected(TokenCodecErrc::OutputTooSmall);
+        return std::unexpected(TokenCodecErrc::OutputTooSmall);
     }
 
     auto countLeadingZeros = [&](auto const& col) -> std::size_t {
@@ -513,7 +513,7 @@ b58ToB256Be(std::string_view input, std::span out)
         auto curVal = ::xrpl::kAlphabetReverse[c];
         if (curVal < 0)
         {
-            return Unexpected(TokenCodecErrc::InvalidEncodingChar);
+            return std::unexpected(TokenCodecErrc::InvalidEncodingChar);
         }
         b5810Coeff[0] *= 58;
         b5810Coeff[0] += curVal;
@@ -526,7 +526,7 @@ b58ToB256Be(std::string_view input, std::span out)
             auto curVal = ::xrpl::kAlphabetReverse[c];
             if (curVal < 0)
             {
-                return Unexpected(TokenCodecErrc::InvalidEncodingChar);
+                return std::unexpected(TokenCodecErrc::InvalidEncodingChar);
             }
             b5810Coeff[numPartialCoeffs + j] *= 58;
             b5810Coeff[numPartialCoeffs + j] += curVal;
@@ -548,7 +548,7 @@ b58ToB256Be(std::string_view input, std::span out)
                 std::span(&result[0], curResultSize + 1), kB5810);
             if (code != TokenCodecErrc::Success)
             {
-                return Unexpected(code);
+                return std::unexpected(code);
             }
         }
         {
@@ -556,7 +556,7 @@ b58ToB256Be(std::string_view input, std::span out)
                 std::span(&result[0], curResultSize + 1), c);
             if (code != TokenCodecErrc::Success)
             {
-                return Unexpected(code);
+                return std::unexpected(code);
             }
         }
         if (result[curResultSize] != 0)
@@ -589,7 +589,7 @@ b58ToB256Be(std::string_view input, std::span out)
     }
     if ((curOutI + (8 * (curResultSize - 1))) > out.size())
     {
-        return Unexpected(TokenCodecErrc::OutputTooSmall);
+        return std::unexpected(TokenCodecErrc::OutputTooSmall);
     }
 
     for (int i = curResultSize - 2; i >= 0; --i)
@@ -614,11 +614,11 @@ encodeBase58Token(
     std::array buf{};
     if (input.size() > kTmpBufSize - 5)
     {
-        return Unexpected(TokenCodecErrc::InputTooLarge);
+        return std::unexpected(TokenCodecErrc::InputTooLarge);
     }
     if (input.empty())
     {
-        return Unexpected(TokenCodecErrc::InputTooSmall);
+        return std::unexpected(TokenCodecErrc::InputTooSmall);
     }
     // 
     buf[0] = static_cast(tokenType);
@@ -648,23 +648,23 @@ decodeBase58Token(TokenType type, std::string_view s, std::span ou
 
     // Reject zero length tokens
     if (ret.size() < 6)
-        return Unexpected(TokenCodecErrc::InputTooSmall);
+        return std::unexpected(TokenCodecErrc::InputTooSmall);
 
     // The type must match.
     if (type != static_cast(static_cast(ret[0])))
-        return Unexpected(TokenCodecErrc::MismatchedTokenType);
+        return std::unexpected(TokenCodecErrc::MismatchedTokenType);
 
     // And the checksum must as well.
     std::array guard{};
     checksum(guard.data(), ret.data(), ret.size() - guard.size());
     if (!std::equal(guard.rbegin(), guard.rend(), ret.rbegin()))
     {
-        return Unexpected(TokenCodecErrc::MismatchedChecksum);
+        return std::unexpected(TokenCodecErrc::MismatchedChecksum);
     }
 
     std::size_t const outSize = ret.size() - 1 - guard.size();
     if (outBuf.size() < outSize)
-        return Unexpected(TokenCodecErrc::OutputTooSmall);
+        return std::unexpected(TokenCodecErrc::OutputTooSmall);
     // Skip the leading type byte and the trailing checksum.
     std::copy(ret.begin() + 1, ret.begin() + outSize + 1, outBuf.begin());
     return outBuf.subspan(0, outSize);
diff --git a/src/libxrpl/rdb/SociDB.cpp b/src/libxrpl/rdb/SociDB.cpp
index 541933b3b0..06e58d373f 100644
--- a/src/libxrpl/rdb/SociDB.cpp
+++ b/src/libxrpl/rdb/SociDB.cpp
@@ -1,5 +1,6 @@
-#include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -53,13 +54,13 @@ getSociSqliteInit(std::string const& name, std::string const& dir, std::string c
 std::string
 getSociInit(BasicConfig const& config, std::string const& dbName)
 {
-    auto const& section = config.section("sqdb");
-    auto const backendName = get(section, "backend", "sqlite");
+    auto const& section = config.section(Sections::kSqdb);
+    auto const backendName = get(section, Keys::kBackend, "sqlite");
 
     if (backendName != "sqlite")
         Throw("Unsupported soci backend: " + backendName);
 
-    auto const path = config.legacy("database_path");
+    auto const path = config.legacy(Sections::kDatabasePath);
     auto const ext = dbName == "validators" || dbName == "peerfinder" ? ".sqlite" : ".db";
     return detail::getSociSqliteInit(dbName, path, ext);
 }
diff --git a/src/libxrpl/server/InfoSub.cpp b/src/libxrpl/server/InfoSub.cpp
index 87b48296a1..353c295856 100644
--- a/src/libxrpl/server/InfoSub.cpp
+++ b/src/libxrpl/server/InfoSub.cpp
@@ -1,15 +1,47 @@
 #include 
 
+#include 
+#include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
+#include 
 #include 
 #include 
 
 namespace xrpl {
 
+namespace {
+
+// Wraps a Source teardown call so that an exception from one cleanup
+// step does not prevent the subsequent steps from running. Source methods
+// acquire a lock and can throw std::system_error; a throw out of ~InfoSub
+// during stack unwinding would terminate the process. Failures are
+// reported through the Source's Journal so they reach the configured log
+// sinks; JLOG itself cannot throw, so the noexcept guarantee holds.
+template 
+void
+safeUnsub(std::uint64_t seq, F&& f, beast::Journal j) noexcept
+{
+    try
+    {
+        f();
+    }
+    catch (std::exception const& e)
+    {
+        JLOG(j.warn()) << "~InfoSub[seq=" << seq << "]: cleanup step failed: " << e.what();
+    }
+    catch (...)
+    {
+        JLOG(j.warn()) << "~InfoSub[seq=" << seq << "]: cleanup step failed: unknown exception";
+    }
+}
+
+}  // namespace
+
 // This is the primary interface into the "client" portion of the program.
 // Code that wants to do normal operations on the network such as
 // creating and monitoring accounts, creating transactions, and so on
@@ -32,25 +64,44 @@ InfoSub::InfoSub(Source& source, Consumer consumer)
 
 InfoSub::~InfoSub()
 {
-    source_.unsubTransactions(seq_);
-    source_.unsubRTTransactions(seq_);
-    source_.unsubLedger(seq_);
-    source_.unsubManifests(seq_);
-    source_.unsubServer(seq_);
-    source_.unsubValidations(seq_);
-    source_.unsubPeerStatus(seq_);
-    source_.unsubConsensus(seq_);
+    // Each Source teardown call below acquires a server-side lock and
+    // can throw. Wrap each independent call so partial failure does not
+    // skip the remaining teardown steps.
+
+    auto const& j = source_.journal();
+
+    safeUnsub(seq_, [&] { source_.unsubTransactions(seq_); }, j);
+    safeUnsub(seq_, [&] { source_.unsubRTTransactions(seq_); }, j);
+    safeUnsub(seq_, [&] { source_.unsubLedger(seq_); }, j);
+    safeUnsub(seq_, [&] { source_.unsubManifests(seq_); }, j);
+    safeUnsub(seq_, [&] { source_.unsubServer(seq_); }, j);
+    safeUnsub(seq_, [&] { source_.unsubValidations(seq_); }, j);
+    safeUnsub(seq_, [&] { source_.unsubPeerStatus(seq_); }, j);
+    safeUnsub(seq_, [&] { source_.unsubConsensus(seq_); }, j);
 
     // Use the internal unsubscribe so that it won't call
     // back to us and modify its own parameter
     if (!realTimeSubscriptions_.empty())
-        source_.unsubAccountInternal(seq_, realTimeSubscriptions_, true);
+    {
+        safeUnsub(
+            seq_, [&] { source_.unsubAccountInternal(seq_, realTimeSubscriptions_, true); }, j);
+    }
 
     if (!normalSubscriptions_.empty())
-        source_.unsubAccountInternal(seq_, normalSubscriptions_, false);
+    {
+        safeUnsub(
+            seq_, [&] { source_.unsubAccountInternal(seq_, normalSubscriptions_, false); }, j);
+    }
 
     for (auto const& account : accountHistorySubscriptions_)
-        source_.unsubAccountHistoryInternal(seq_, account, false);
+    {
+        safeUnsub(seq_, [&] { source_.unsubAccountHistoryInternal(seq_, account, false); }, j);
+    }
+
+    for (auto const& book : bookSubscriptions_)
+    {
+        safeUnsub(seq_, [&] { source_.unsubBookInternal(seq_, book); }, j);
+    }
 }
 
 Resource::Consumer&
@@ -114,6 +165,20 @@ InfoSub::deleteSubAccountHistory(AccountID const& account)
     accountHistorySubscriptions_.erase(account);
 }
 
+void
+InfoSub::insertBookSubscription(Book const& book)
+{
+    std::scoped_lock const sl(lock_);
+    bookSubscriptions_.insert(book);
+}
+
+void
+InfoSub::deleteBookSubscription(Book const& book)
+{
+    std::scoped_lock const sl(lock_);
+    bookSubscriptions_.erase(book);
+}
+
 void
 InfoSub::clearRequest()
 {
diff --git a/src/libxrpl/server/Manifest.cpp b/src/libxrpl/server/Manifest.cpp
index 1b0493dd9d..b26c67e531 100644
--- a/src/libxrpl/server/Manifest.cpp
+++ b/src/libxrpl/server/Manifest.cpp
@@ -90,7 +90,7 @@ deserializeManifest(Slice s, beast::Journal journal)
     try
     {
         SerialIter sit{s};
-        STObject st{sit, kSfGeneric};
+        STObject st{sit, sfGeneric};
 
         st.applyTemplate(kManifestFormat);
 
@@ -193,7 +193,7 @@ logMftAct(
 bool
 Manifest::verify() const
 {
-    STObject st(kSfGeneric);
+    STObject st(sfGeneric);
     SerialIter sit(serialized.data(), serialized.size());
     st.set(sit);
 
@@ -213,7 +213,7 @@ Manifest::verify() const
 uint256
 Manifest::hash() const
 {
-    STObject st(kSfGeneric);
+    STObject st(sfGeneric);
     SerialIter sit(serialized.data(), serialized.size());
     st.set(sit);
     return st.getHash(HashPrefix::Manifest);
@@ -240,7 +240,7 @@ Manifest::revoked(std::uint32_t sequence)
 std::optional
 Manifest::getSignature() const
 {
-    STObject st(kSfGeneric);
+    STObject st(sfGeneric);
     SerialIter sit(serialized.data(), serialized.size());
     st.set(sit);
     if (!get(st, sfSignature))
@@ -251,7 +251,7 @@ Manifest::getSignature() const
 Blob
 Manifest::getMasterSignature() const
 {
-    STObject st(kSfGeneric);
+    STObject st(sfGeneric);
     SerialIter sit(serialized.data(), serialized.size());
     st.set(sit);
     return st.getFieldVL(sfMasterSignature);
diff --git a/src/libxrpl/server/Port.cpp b/src/libxrpl/server/Port.cpp
index 9a6b6dce35..c1a79019af 100644
--- a/src/libxrpl/server/Port.cpp
+++ b/src/libxrpl/server/Port.cpp
@@ -1,11 +1,12 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 
 #include 
 #include 
@@ -26,8 +27,8 @@ namespace xrpl {
 bool
 Port::secure() const
 {
-    return protocol.count("peer") > 0 || protocol.count("https") > 0 || protocol.count("wss") > 0 ||
-        protocol.count("wss2") > 0;
+    return protocol.contains("peer") || protocol.contains("https") || protocol.contains("wss") ||
+        protocol.contains("wss2");
 }
 
 std::string
@@ -44,30 +45,30 @@ operator<<(std::ostream& os, Port const& p)
 {
     os << "'" << p.name << "' (ip=" << p.ip << ":" << p.port << ", ";
 
-    if (!p.admin_nets_v4.empty() || !p.admin_nets_v6.empty())
+    if (!p.adminNetsV4.empty() || !p.adminNetsV6.empty())
     {
         os << "admin nets:";
-        for (auto const& net : p.admin_nets_v4)
+        for (auto const& net : p.adminNetsV4)
         {
             os << net.to_string();
             os << ", ";
         }
-        for (auto const& net : p.admin_nets_v6)
+        for (auto const& net : p.adminNetsV6)
         {
             os << net.to_string();
             os << ", ";
         }
     }
 
-    if (!p.secure_gateway_nets_v4.empty() || !p.secure_gateway_nets_v6.empty())
+    if (!p.secureGatewayNetsV4.empty() || !p.secureGatewayNetsV6.empty())
     {
         os << "secure_gateway nets:";
-        for (auto const& net : p.secure_gateway_nets_v4)
+        for (auto const& net : p.secureGatewayNetsV4)
         {
             os << net.to_string();
             os << ", ";
         }
-        for (auto const& net : p.secure_gateway_nets_v6)
+        for (auto const& net : p.secureGatewayNetsV6)
         {
             os << net.to_string();
             os << ", ";
@@ -195,7 +196,7 @@ parsePort(ParsedPort& port, Section const& section, std::ostream& log)
 {
     port.name = section.name();
     {
-        auto const optResult = section.get("ip");
+        auto const optResult = section.get(Keys::kIp);
         if (optResult)
         {
             try
@@ -212,7 +213,7 @@ parsePort(ParsedPort& port, Section const& section, std::ostream& log)
     }
 
     {
-        auto const optResult = section.get("port");
+        auto const optResult = section.get(Keys::kPort);
         if (optResult)
         {
             try
@@ -233,7 +234,7 @@ parsePort(ParsedPort& port, Section const& section, std::ostream& log)
     }
 
     {
-        auto const optResult = section.get("protocol");
+        auto const optResult = section.get(Keys::kProtocol);
         if (optResult)
         {
             for (auto const& s : beast::rfc2616::splitCommas(optResult->begin(), optResult->end()))
@@ -242,7 +243,7 @@ parsePort(ParsedPort& port, Section const& section, std::ostream& log)
     }
 
     {
-        auto const lim = get(section, "limit", "unlimited");
+        auto const lim = get(section, Keys::kLimit, "unlimited");
 
         if (!boost::iequals(lim, "unlimited"))
         {
@@ -260,15 +261,15 @@ parsePort(ParsedPort& port, Section const& section, std::ostream& log)
     }
 
     {
-        auto const optResult = section.get("send_queue_limit");
+        auto const optResult = section.get(Keys::kSendQueueLimit);
         if (optResult)
         {
             try
             {
-                port.ws_queue_limit = beast::lexicalCastThrow(*optResult);
+                port.wsQueueLimit = beast::lexicalCastThrow(*optResult);
 
                 // Queue must be greater than 0
-                if (port.ws_queue_limit == 0)
+                if (port.wsQueueLimit == 0)
                     Throw();
             }
             catch (std::exception const&)
@@ -281,32 +282,32 @@ parsePort(ParsedPort& port, Section const& section, std::ostream& log)
         else
         {
             // Default Websocket send queue size limit
-            port.ws_queue_limit = 100;
+            port.wsQueueLimit = 100;
         }
     }
 
-    populate(section, "admin", log, port.admin_nets_v4, port.admin_nets_v6);
+    populate(section, Keys::kAdmin, log, port.adminNetsV4, port.adminNetsV6);
     populate(
-        section, "secure_gateway", log, port.secure_gateway_nets_v4, port.secure_gateway_nets_v6);
+        section, Keys::kSecureGateway, log, port.secureGatewayNetsV4, port.secureGatewayNetsV6);
 
-    set(port.user, "user", section);
-    set(port.password, "password", section);
-    set(port.admin_user, "admin_user", section);
-    set(port.admin_password, "admin_password", section);
-    set(port.ssl_key, "ssl_key", section);
-    set(port.ssl_cert, "ssl_cert", section);
-    set(port.ssl_chain, "ssl_chain", section);
-    set(port.ssl_ciphers, "ssl_ciphers", section);
+    set(port.user, Keys::kUser, section);
+    set(port.password, Keys::kPassword, section);
+    set(port.adminUser, Keys::kAdminUser, section);
+    set(port.adminPassword, Keys::kAdminPassword, section);
+    set(port.sslKey, Keys::kSslKey, section);
+    set(port.sslCert, Keys::kSslCert, section);
+    set(port.sslChain, Keys::kSslChain, section);
+    set(port.sslCiphers, Keys::kSslCiphers, section);
 
-    port.pmd_options.server_enable = section.valueOr("permessage_deflate", true);
-    port.pmd_options.client_max_window_bits = section.valueOr("client_max_window_bits", 15);
-    port.pmd_options.server_max_window_bits = section.valueOr("server_max_window_bits", 15);
-    port.pmd_options.client_no_context_takeover =
-        section.valueOr("client_no_context_takeover", false);
-    port.pmd_options.server_no_context_takeover =
-        section.valueOr("server_no_context_takeover", false);
-    port.pmd_options.compLevel = section.valueOr("compress_level", 8);
-    port.pmd_options.memLevel = section.valueOr("memory_level", 4);
+    port.pmdOptions.server_enable = section.valueOr(Keys::kPermessageDeflate, true);
+    port.pmdOptions.client_max_window_bits = section.valueOr(Keys::kClientMaxWindowBits, 15);
+    port.pmdOptions.server_max_window_bits = section.valueOr(Keys::kServerMaxWindowBits, 15);
+    port.pmdOptions.client_no_context_takeover =
+        section.valueOr(Keys::kClientNoContextTakeover, false);
+    port.pmdOptions.server_no_context_takeover =
+        section.valueOr(Keys::kServerNoContextTakeover, false);
+    port.pmdOptions.compLevel = section.valueOr(Keys::kCompressLevel, 8);
+    port.pmdOptions.memLevel = section.valueOr(Keys::kMemoryLevel, 4);
 }
 
 }  // namespace xrpl
diff --git a/src/libxrpl/server/State.cpp b/src/libxrpl/server/State.cpp
index b9cb7c6ff2..d9793f53d0 100644
--- a/src/libxrpl/server/State.cpp
+++ b/src/libxrpl/server/State.cpp
@@ -1,7 +1,7 @@
 #include 
 
-#include 
 #include 
+#include 
 #include 
 #include 
 
diff --git a/src/libxrpl/shamap/SHAMap.cpp b/src/libxrpl/shamap/SHAMap.cpp
index d3a7d49da6..8a521f6a47 100644
--- a/src/libxrpl/shamap/SHAMap.cpp
+++ b/src/libxrpl/shamap/SHAMap.cpp
@@ -97,10 +97,7 @@ SHAMap::snapShot(bool isMutable) const
 }
 
 void
-SHAMap::dirtyUp(
-    SharedPtrNodeStack& stack,
-    uint256 const& target,
-    intr_ptr::SharedPtr child)
+SHAMap::dirtyUp(SharedPtrNodeStack& stack, uint256 const& target, SHAMapTreeNodePtr child)
 {
     // walk the tree up from through the inner nodes to the root_
     // update hashes and links
@@ -165,7 +162,7 @@ SHAMap::findKey(uint256 const& id) const
     return leaf;
 }
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMap::fetchNodeFromDB(SHAMapHash const& hash) const
 {
     XRPL_ASSERT(backed_, "xrpl::SHAMap::fetchNodeFromDB : is backed");
@@ -173,7 +170,7 @@ SHAMap::fetchNodeFromDB(SHAMapHash const& hash) const
     return finishFetch(hash, obj);
 }
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMap::finishFetch(SHAMapHash const& hash, std::shared_ptr const& object) const
 {
     XRPL_ASSERT(backed_, "xrpl::SHAMap::finishFetch : is backed");
@@ -208,8 +205,8 @@ SHAMap::finishFetch(SHAMapHash const& hash, std::shared_ptr const& o
 }
 
 // See if a sync filter has a node
-intr_ptr::SharedPtr
-SHAMap::checkFilter(SHAMapHash const& hash, SHAMapSyncFilter* filter) const
+SHAMapTreeNodePtr
+SHAMap::checkFilter(SHAMapHash const& hash, SHAMapSyncFilter const* filter) const
 {
     if (auto nodeData = filter->getNode(hash))
     {
@@ -234,8 +231,8 @@ SHAMap::checkFilter(SHAMapHash const& hash, SHAMapSyncFilter* filter) const
 
 // Get a node without throwing
 // Used on maps where missing nodes are expected
-intr_ptr::SharedPtr
-SHAMap::fetchNodeNT(SHAMapHash const& hash, SHAMapSyncFilter* filter) const
+SHAMapTreeNodePtr
+SHAMap::fetchNodeNT(SHAMapHash const& hash, SHAMapSyncFilter const* filter) const
 {
     auto node = cacheLookup(hash);
     if (node)
@@ -257,7 +254,7 @@ SHAMap::fetchNodeNT(SHAMapHash const& hash, SHAMapSyncFilter* filter) const
     return node;
 }
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMap::fetchNodeNT(SHAMapHash const& hash) const
 {
     auto node = cacheLookup(hash);
@@ -269,7 +266,7 @@ SHAMap::fetchNodeNT(SHAMapHash const& hash) const
 }
 
 // Throw if the node is missing
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMap::fetchNode(SHAMapHash const& hash) const
 {
     auto node = fetchNodeNT(hash);
@@ -291,10 +288,10 @@ SHAMap::descendThrow(SHAMapInnerNode* parent, int branch) const
     return ret;
 }
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMap::descendThrow(SHAMapInnerNode& parent, int branch) const
 {
-    intr_ptr::SharedPtr ret = descend(parent, branch);
+    SHAMapTreeNodePtr ret = descend(parent, branch);
 
     if (!ret && !parent.isEmptyBranch(branch))
         Throw(type_, parent.getChildHash(branch));
@@ -309,7 +306,7 @@ SHAMap::descend(SHAMapInnerNode* parent, int branch) const
     if ((ret != nullptr) || !backed_)
         return ret;
 
-    intr_ptr::SharedPtr node = fetchNodeNT(parent->getChildHash(branch));
+    SHAMapTreeNodePtr node = fetchNodeNT(parent->getChildHash(branch));
     if (!node)
         return nullptr;
 
@@ -317,10 +314,10 @@ SHAMap::descend(SHAMapInnerNode* parent, int branch) const
     return node.get();
 }
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMap::descend(SHAMapInnerNode& parent, int branch) const
 {
-    intr_ptr::SharedPtr node = parent.getChild(branch);
+    SHAMapTreeNodePtr node = parent.getChild(branch);
     if (node || !backed_)
         return node;
 
@@ -334,10 +331,10 @@ SHAMap::descend(SHAMapInnerNode& parent, int branch) const
 
 // Gets the node that would be hooked to this branch,
 // but doesn't hook it up.
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMap::descendNoStore(SHAMapInnerNode& parent, int branch) const
 {
-    intr_ptr::SharedPtr ret = parent.getChild(branch);
+    SHAMapTreeNodePtr ret = parent.getChild(branch);
     if (!ret && backed_)
         ret = fetchNode(parent.getChildHash(branch));
     return ret;
@@ -348,7 +345,7 @@ SHAMap::descend(
     SHAMapInnerNode* parent,
     SHAMapNodeID const& parentID,
     int branch,
-    SHAMapSyncFilter* filter) const
+    SHAMapSyncFilter const* filter) const
 {
     XRPL_ASSERT(parent->isInner(), "xrpl::SHAMap::descend : valid parent input");
     XRPL_ASSERT(
@@ -361,7 +358,7 @@ SHAMap::descend(
     if (child == nullptr)
     {
         auto const& childHash = parent->getChildHash(branch);
-        intr_ptr::SharedPtr childNode = fetchNodeNT(childHash, filter);
+        SHAMapTreeNodePtr childNode = fetchNodeNT(childHash, filter);
 
         if (childNode)
         {
@@ -377,7 +374,7 @@ SHAMapTreeNode*
 SHAMap::descendAsync(
     SHAMapInnerNode* parent,
     int branch,
-    SHAMapSyncFilter* filter,
+    SHAMapSyncFilter const* filter,
     bool& pending,
     descendCallback&& callback) const
 {
@@ -434,7 +431,7 @@ SHAMap::unshareNode(intr_ptr::SharedPtr node, SHAMapNodeID const& nodeID)
 
 SHAMapLeafNode*
 SHAMap::belowHelper(
-    intr_ptr::SharedPtr node,
+    SHAMapTreeNodePtr node,
     SharedPtrNodeStack& stack,
     int branch,
     std::tuple, std::function> const& loopParams) const
@@ -479,8 +476,7 @@ SHAMap::belowHelper(
     return nullptr;
 }
 SHAMapLeafNode*
-SHAMap::lastBelow(intr_ptr::SharedPtr node, SharedPtrNodeStack& stack, int branch)
-    const
+SHAMap::lastBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch) const
 {
     auto init = kBranchFactor - 1;
     auto cmp = [](int i) { return i >= 0; };
@@ -489,8 +485,7 @@ SHAMap::lastBelow(intr_ptr::SharedPtr node, SharedPtrNodeStack&
     return belowHelper(node, stack, branch, {init, cmp, incr});
 }
 SHAMapLeafNode*
-SHAMap::firstBelow(intr_ptr::SharedPtr node, SharedPtrNodeStack& stack, int branch)
-    const
+SHAMap::firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch) const
 {
     auto init = 0;
     auto cmp = [](int i) { return i <= kBranchFactor; };
@@ -699,10 +694,8 @@ SHAMap::delItem(uint256 const& id)
 
     SHAMapNodeType const type = leaf->getType();
 
-    using TreeNodeType = intr_ptr::SharedPtr;
-
     // What gets attached to the end of the chain (For now, nothing, since we deleted the leaf)
-    TreeNodeType prevNode;
+    SHAMapTreeNodePtr prevNode;
 
     while (!stack.empty())
     {
@@ -728,7 +721,7 @@ SHAMap::delItem(uint256 const& id)
                 // no children below this branch
                 //
                 // Note: This is unnecessary due to the std::move above but left here for safety
-                prevNode = TreeNodeType{};
+                prevNode = SHAMapTreeNodePtr{};
             }
             else if (bc == 1)
             {
@@ -741,7 +734,7 @@ SHAMap::delItem(uint256 const& id)
                     {
                         if (!node->isEmptyBranch(i))
                         {
-                            node->setChild(i, TreeNodeType{});
+                            node->setChild(i, SHAMapTreeNodePtr{});
                             break;
                         }
                     }
@@ -892,7 +885,7 @@ SHAMap::updateGiveItem(SHAMapNodeType type, boost::intrusive_ptrgetHash())
         return true;
@@ -937,8 +930,8 @@ SHAMap::fetchRoot(SHAMapHash const& hash, SHAMapSyncFilter* filter)
     @note The node must have already been unshared by having the caller
           first call SHAMapTreeNode::unshare().
  */
-intr_ptr::SharedPtr
-SHAMap::writeNode(NodeObjectType t, intr_ptr::SharedPtr node) const
+SHAMapTreeNodePtr
+SHAMap::writeNode(NodeObjectType t, SHAMapTreeNodePtr node) const
 {
     XRPL_ASSERT(node->cowid() == 0, "xrpl::SHAMap::writeNode : valid input node");
     XRPL_ASSERT(backed_, "xrpl::SHAMap::writeNode : is backed");
@@ -1155,7 +1148,7 @@ SHAMap::dump(bool hash) const
     JLOG(journal_.info()) << leafCount << " resident leaves";
 }
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMap::cacheLookup(SHAMapHash const& hash) const
 {
     auto ret = f_.getTreeNodeCache()->fetch(hash.asUInt256());
@@ -1164,7 +1157,7 @@ SHAMap::cacheLookup(SHAMapHash const& hash) const
 }
 
 void
-SHAMap::canonicalize(SHAMapHash const& hash, intr_ptr::SharedPtr& node) const
+SHAMap::canonicalize(SHAMapHash const& hash, SHAMapTreeNodePtr& node) const
 {
     XRPL_ASSERT(backed_, "xrpl::SHAMap::canonicalize : is backed");
     XRPL_ASSERT(node->cowid() == 0, "xrpl::SHAMap::canonicalize : valid node input");
diff --git a/src/libxrpl/shamap/SHAMapDelta.cpp b/src/libxrpl/shamap/SHAMapDelta.cpp
index b1aeac18e8..8336ce5481 100644
--- a/src/libxrpl/shamap/SHAMapDelta.cpp
+++ b/src/libxrpl/shamap/SHAMapDelta.cpp
@@ -261,7 +261,7 @@ SHAMap::walkMap(std::vector& missingNodes, int maxMissing) co
         {
             if (!node->isEmptyBranch(i))
             {
-                intr_ptr::SharedPtr const nextNode = descendNoStore(*node, i);
+                SHAMapTreeNodePtr const nextNode = descendNoStore(*node, i);
 
                 if (nextNode)
                 {
@@ -286,7 +286,7 @@ SHAMap::walkMapParallel(std::vector& missingNodes, int maxMis
         return false;
 
     using StackEntry = intr_ptr::SharedPtr;
-    std::array, 16> topChildren;
+    std::array topChildren;
     {
         auto const& innerRoot = intr_ptr::staticPointerCast(root_);
         for (int i = 0; i < 16; ++i)
@@ -331,8 +331,7 @@ SHAMap::walkMapParallel(std::vector& missingNodes, int maxMis
                         {
                             if (node->isEmptyBranch(i))
                                 continue;
-                            intr_ptr::SharedPtr const nextNode =
-                                descendNoStore(*node, i);
+                            SHAMapTreeNodePtr const nextNode = descendNoStore(*node, i);
 
                             if (nextNode)
                             {
diff --git a/src/libxrpl/shamap/SHAMapInnerNode.cpp b/src/libxrpl/shamap/SHAMapInnerNode.cpp
index f31b75ad39..74a0e4515f 100644
--- a/src/libxrpl/shamap/SHAMapInnerNode.cpp
+++ b/src/libxrpl/shamap/SHAMapInnerNode.cpp
@@ -37,7 +37,7 @@ SHAMapInnerNode::~SHAMapInnerNode() = default;
 void
 SHAMapInnerNode::partialDestructor()
 {
-    intr_ptr::SharedPtr* children = nullptr;
+    SHAMapTreeNodePtr* children = nullptr;
     // structured bindings can't be captured in c++ 17; use tie instead
     std::tie(std::ignore, std::ignore, children) = hashesAndChildren_.getHashesAndChildren();
     iterNonEmptyChildIndexes([&](auto branchNum, auto indexNum) { children[indexNum].reset(); });
@@ -69,7 +69,7 @@ SHAMapInnerNode::getChildIndex(int i) const
     return hashesAndChildren_.getChildIndex(isBranch_, i);
 }
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMapInnerNode::clone(std::uint32_t cowid) const
 {
     auto const branchCount = getBranchCount();
@@ -78,8 +78,10 @@ SHAMapInnerNode::clone(std::uint32_t cowid) const
     p->hash_ = hash_;
     p->isBranch_ = isBranch_;
     p->fullBelowGen_ = fullBelowGen_;
-    SHAMapHash *cloneHashes = nullptr, *thisHashes = nullptr;
-    intr_ptr::SharedPtr*cloneChildren = nullptr, *thisChildren = nullptr;
+    SHAMapHash* cloneHashes = nullptr;
+    SHAMapHash* thisHashes = nullptr;
+    SHAMapTreeNodePtr* cloneChildren = nullptr;
+    SHAMapTreeNodePtr* thisChildren = nullptr;
     // structured bindings can't be captured in c++ 17; use tie instead
     std::tie(std::ignore, cloneHashes, cloneChildren) =
         p->hashesAndChildren_.getHashesAndChildren();
@@ -118,7 +120,7 @@ SHAMapInnerNode::clone(std::uint32_t cowid) const
     return p;
 }
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMapInnerNode::makeFullInner(Slice data, SHAMapHash const& hash, bool hashValid)
 {
     // A full inner node is serialized as 16 256-bit hashes, back to back:
@@ -153,7 +155,7 @@ SHAMapInnerNode::makeFullInner(Slice data, SHAMapHash const& hash, bool hashVali
     return ret;
 }
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMapInnerNode::makeCompressedInner(Slice data)
 {
     // A compressed inner node is serialized as a series of 33 byte chunks,
@@ -198,7 +200,7 @@ SHAMapInnerNode::updateHash()
         using beast::hash_append;
         hash_append(h, HashPrefix::InnerNode);
         iterChildren([&](SHAMapHash const& hh) { hash_append(h, hh); });
-        nh = static_cast(h);
+        nh = static_cast(h);
     }
     hash_ = SHAMapHash{nh};
 }
@@ -207,7 +209,7 @@ void
 SHAMapInnerNode::updateHashDeep()
 {
     SHAMapHash* hashes = nullptr;
-    intr_ptr::SharedPtr* children = nullptr;
+    SHAMapTreeNodePtr* children = nullptr;
     // structured bindings can't be captured in c++ 17; use tie instead
     std::tie(std::ignore, hashes, children) = hashesAndChildren_.getHashesAndChildren();
     iterNonEmptyChildIndexes([&](auto branchNum, auto indexNum) {
@@ -265,7 +267,7 @@ SHAMapInnerNode::getString(SHAMapNodeID const& id) const
 
 // We are modifying an inner node
 void
-SHAMapInnerNode::setChild(int m, intr_ptr::SharedPtr child)
+SHAMapInnerNode::setChild(int m, SHAMapTreeNodePtr child)
 {
     XRPL_ASSERT(
         (m >= 0) && (m < kBranchFactor), "xrpl::SHAMapInnerNode::setChild : valid branch input");
@@ -307,7 +309,7 @@ SHAMapInnerNode::setChild(int m, intr_ptr::SharedPtr child)
 
 // finished modifying, now make shareable
 void
-SHAMapInnerNode::shareChild(int m, intr_ptr::SharedPtr const& child)
+SHAMapInnerNode::shareChild(int m, SHAMapTreeNodePtr const& child)
 {
     XRPL_ASSERT(
         (m >= 0) && (m < kBranchFactor), "xrpl::SHAMapInnerNode::shareChild : valid branch input");
@@ -337,7 +339,7 @@ SHAMapInnerNode::getChildPointer(int branch)
     return hashesAndChildren_.getChildren()[index].get();
 }
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMapInnerNode::getChild(int branch)
 {
     XRPL_ASSERT(
@@ -365,8 +367,8 @@ SHAMapInnerNode::getChildHash(int m) const
     return kZeroShaMapHash;
 }
 
-intr_ptr::SharedPtr
-SHAMapInnerNode::canonicalizeChild(int branch, intr_ptr::SharedPtr node)
+SHAMapTreeNodePtr
+SHAMapInnerNode::canonicalizeChild(int branch, SHAMapTreeNodePtr node)
 {
     XRPL_ASSERT(
         branch >= 0 && branch < kBranchFactor,
diff --git a/src/libxrpl/shamap/SHAMapSync.cpp b/src/libxrpl/shamap/SHAMapSync.cpp
index cd2654c603..1f38049abe 100644
--- a/src/libxrpl/shamap/SHAMapSync.cpp
+++ b/src/libxrpl/shamap/SHAMapSync.cpp
@@ -66,7 +66,7 @@ SHAMap::visitNodes(std::function const& function) const
         {
             if (!node->isEmptyBranch(pos))
             {
-                intr_ptr::SharedPtr const child = descendNoStore(*node, pos);
+                SHAMapTreeNodePtr const child = descendNoStore(*node, pos);
                 if (!function(*child))
                     return;
 
@@ -204,8 +204,7 @@ SHAMap::gmnProcessNodes(MissingNodes& mn, MissingNodes::StackEntry& se)
                 branch,
                 mn.filter,
                 pending,
-                [node, nodeID, branch, &mn](
-                    intr_ptr::SharedPtr found, SHAMapHash const&) {
+                [node, nodeID, branch, &mn](SHAMapTreeNodePtr found, SHAMapHash const&) {
                     // a read completed asynchronously
                     std::unique_lock const lock{mn.deferLock};
                     mn.finishedReads.emplace_back(node, nodeID, branch, std::move(found));
@@ -266,8 +265,7 @@ SHAMap::gmnProcessDeferredReads(MissingNodes& mn)
     int complete = 0;
     while (complete != mn.deferred)
     {
-        std::tuple>
-            deferredNode;
+        std::tuple deferredNode;
         {
             std::unique_lock lock{mn.deferLock};
 
@@ -307,7 +305,7 @@ SHAMap::gmnProcessDeferredReads(MissingNodes& mn)
     nodes that are not permanently stored locally
 */
 std::vector>
-SHAMap::getMissingNodes(int max, SHAMapSyncFilter* filter)
+SHAMap::getMissingNodes(int max, SHAMapSyncFilter const* filter)
 {
     XRPL_ASSERT(root_->getHash().isNonZero(), "xrpl::SHAMap::getMissingNodes : nonzero root hash");
     XRPL_ASSERT(max > 0, "xrpl::SHAMap::getMissingNodes : valid max input");
@@ -509,7 +507,7 @@ SHAMap::serializeRoot(Serializer& s) const
 }
 
 SHAMapAddNode
-SHAMap::addRootNode(SHAMapHash const& hash, Slice const& rootNode, SHAMapSyncFilter* filter)
+SHAMap::addRootNode(SHAMapHash const& hash, Slice const& rootNode, SHAMapSyncFilter const* filter)
 {
     // we already have a root_ node
     if (root_->getHash().isNonZero())
@@ -544,7 +542,7 @@ SHAMap::addRootNode(SHAMapHash const& hash, Slice const& rootNode, SHAMapSyncFil
 }
 
 SHAMapAddNode
-SHAMap::addKnownNode(SHAMapNodeID const& node, Slice const& rawNode, SHAMapSyncFilter* filter)
+SHAMap::addKnownNode(SHAMapNodeID const& node, Slice const& rawNode, SHAMapSyncFilter const* filter)
 {
     XRPL_ASSERT(!node.isRoot(), "xrpl::SHAMap::addKnownNode : valid node input");
 
diff --git a/src/libxrpl/shamap/SHAMapTreeNode.cpp b/src/libxrpl/shamap/SHAMapTreeNode.cpp
index 3b8d976c69..1ae7cf18af 100644
--- a/src/libxrpl/shamap/SHAMapTreeNode.cpp
+++ b/src/libxrpl/shamap/SHAMapTreeNode.cpp
@@ -25,7 +25,7 @@
 
 namespace xrpl {
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMapTreeNode::makeTransaction(Slice data, SHAMapHash const& hash, bool hashValid)
 {
     if (data.size() < kMinShaMapItemBytes)
@@ -43,7 +43,7 @@ SHAMapTreeNode::makeTransaction(Slice data, SHAMapHash const& hash, bool hashVal
     return intr_ptr::makeShared(std::move(item), 0);
 }
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMapTreeNode::makeTransactionWithMeta(Slice data, SHAMapHash const& hash, bool hashValid)
 {
     Serializer s(data.data(), data.size());
@@ -83,7 +83,7 @@ SHAMapTreeNode::makeTransactionWithMeta(Slice data, SHAMapHash const& hash, bool
     return intr_ptr::makeShared(std::move(item), 0);
 }
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMapTreeNode::makeAccountState(Slice data, SHAMapHash const& hash, bool hashValid)
 {
     Serializer s(data.data(), data.size());
@@ -124,7 +124,7 @@ SHAMapTreeNode::makeAccountState(Slice data, SHAMapHash const& hash, bool hashVa
     return intr_ptr::makeShared(std::move(item), 0);
 }
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMapTreeNode::makeFromWire(Slice rawNode)
 {
     if (rawNode.empty())
@@ -155,7 +155,7 @@ SHAMapTreeNode::makeFromWire(Slice rawNode)
     Throw("wire: Unknown type (" + std::to_string(type) + ")");
 }
 
-intr_ptr::SharedPtr
+SHAMapTreeNodePtr
 SHAMapTreeNode::makeFromPrefix(Slice rawNode, SHAMapHash const& hash)
 {
     if (rawNode.size() < 4)
diff --git a/src/libxrpl/tx/ApplyContext.cpp b/src/libxrpl/tx/ApplyContext.cpp
index 88e37baf00..5e5ab90441 100644
--- a/src/libxrpl/tx/ApplyContext.cpp
+++ b/src/libxrpl/tx/ApplyContext.cpp
@@ -14,11 +14,11 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -70,11 +70,7 @@ ApplyContext::size()
 
 void
 ApplyContext::visit(
-    std::function const&,
-        std::shared_ptr const&)> const& func)
+    std::function const& func)
 {
     view_->visit(base_, func);  // NOLINT(bugprone-unchecked-optional-access)
 }
@@ -104,13 +100,11 @@ ApplyContext::checkInvariantsHelper(
         auto checkers = getInvariantChecks();
 
         // call each check's per-entry method
-        visit([&checkers](
-                  uint256 const& index,
-                  bool isDelete,
-                  std::shared_ptr const& before,
-                  std::shared_ptr const& after) {
-            (..., std::get(checkers).visitEntry(isDelete, before, after));
-        });
+        visit(
+            [&checkers](
+                uint256 const& index, bool isDelete, SLE::const_ref before, SLE::const_ref after) {
+                (..., std::get(checkers).visitEntry(isDelete, before, after));
+            });
 
         // Note: do not replace this logic with a `...&&` fold expression.
         // The fold expression will only run until the first check fails (it
@@ -121,7 +115,7 @@ ApplyContext::checkInvariantsHelper(
             tx, result, fee, *view_, journal)...}};  // NOLINT(bugprone-unchecked-optional-access)
 
         // call each check's finalizer to see that it passes
-        if (!std::all_of(finalizers.cbegin(), finalizers.cend(), [](auto const& b) { return b; }))
+        if (!std::ranges::all_of(finalizers, [](auto const& b) { return b; }))
         {
             JLOG(journal.fatal()) << "Transaction has failed one or more global invariants: "
                                   << to_string(tx.getJson(JsonOptions::Values::None));
diff --git a/src/libxrpl/tx/SignerEntries.cpp b/src/libxrpl/tx/SignerEntries.cpp
index 7251b8260f..943b66292e 100644
--- a/src/libxrpl/tx/SignerEntries.cpp
+++ b/src/libxrpl/tx/SignerEntries.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -12,19 +11,20 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 
 namespace xrpl {
 
-Expected, NotTEC>
+std::expected, NotTEC>
 SignerEntries::deserialize(STObject const& obj, beast::Journal journal, std::string_view annotation)
 {
     if (!obj.isFieldPresent(sfSignerEntries))
     {
         JLOG(journal.trace()) << "Malformed " << annotation << ": Need signer entry array.";
-        return Unexpected(temMALFORMED);
+        return std::unexpected(temMALFORMED);
     }
 
     std::vector accountVec;
@@ -37,7 +37,7 @@ SignerEntries::deserialize(STObject const& obj, beast::Journal journal, std::str
         if (sEntry.getFName() != sfSignerEntry)
         {
             JLOG(journal.trace()) << "Malformed " << annotation << ": Expected SignerEntry.";
-            return Unexpected(temMALFORMED);
+            return std::unexpected(temMALFORMED);
         }
 
         // Extract SignerEntry fields.
diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp
index 97f2cabff2..9ab8779f03 100644
--- a/src/libxrpl/tx/Transactor.cpp
+++ b/src/libxrpl/tx/Transactor.cpp
@@ -20,13 +20,14 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include   // IWYU pragma: keep
@@ -44,9 +45,11 @@
 #include 
 #include 
 #include 
-#include 
+#include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 
@@ -176,6 +179,16 @@ Transactor::preflight1(PreflightContext const& ctx, std::uint32_t flagMask)
 
         if (ctx.tx[sfDelegate] == ctx.tx[sfAccount])
             return temBAD_SIGNER;
+
+        auto const& perm = Permission::getInstance();
+        auto const txType = ctx.tx.getTxnType();
+
+        // If the transaction is not delegable and does not have granular permissions, fail earlier
+        // with temINVALID. This is to prevent transactions that are not delegable at all from
+        // being processed further in the invokeCheckPermission function.
+        if (!perm.isDelegable(Permission::txToPermissionType(txType), ctx.rules) &&
+            !perm.hasGranularPermissions(txType))
+            return temINVALID;
     }
 
     if (auto const ret = preflight0(ctx, flagMask))
@@ -256,6 +269,15 @@ Transactor::preflight2(PreflightContext const& ctx)
     return tesSUCCESS;
 }
 
+NotTEC
+Transactor::preflightUniversal(PreflightContext const& ctx)
+{
+    if (ctx.rules.enabled(fixCleanup3_2_0) && hasInvalidAmount(ctx.tx, ctx.j))
+        return temBAD_AMOUNT;
+
+    return tesSUCCESS;
+}
+
 //------------------------------------------------------------------------------
 
 Transactor::Transactor(ApplyContext& ctx)
@@ -287,19 +309,33 @@ Transactor::preflightSigValidated(PreflightContext const& ctx)
 }
 
 NotTEC
-Transactor::checkPermission(ReadView const& view, STTx const& tx)
+Transactor::checkPermission(
+    ReadView const& view,
+    STTx const& tx,
+    std::unordered_set& heldGranularPermissions)
 {
     auto const delegate = tx[~sfDelegate];
     if (!delegate)
         return tesSUCCESS;
 
-    auto const delegateKey = keylet::delegate(tx[sfAccount], *delegate);
-    auto const sle = view.read(delegateKey);
-
+    auto const sle = view.read(keylet::delegate(tx[sfAccount], *delegate));
     if (!sle)
         return terNO_DELEGATE_PERMISSION;
 
-    return checkTxPermission(sle, tx);
+    if (isTesSuccess(checkTxPermission(sle, tx)))
+        return tesSUCCESS;
+
+    if (!Permission::getInstance().hasGranularPermissions(tx.getTxnType()))
+        return terNO_DELEGATE_PERMISSION;
+
+    heldGranularPermissions = getGranularPermission(sle, tx.getTxnType());
+    if (heldGranularPermissions.empty())
+        return terNO_DELEGATE_PERMISSION;
+
+    if (!Permission::getInstance().checkGranularSandbox(tx, heldGranularPermissions))
+        return terNO_DELEGATE_PERMISSION;
+
+    return tesSUCCESS;
 }
 
 XRPAmount
@@ -320,6 +356,15 @@ Transactor::calculateBaseFee(ReadView const& view, STTx const& tx)
     return baseFee + (signerCount * baseFee);
 }
 
+XRPAmount
+Transactor::calculateBaseFee(
+    ReadView const& view,
+    STTx const& tx,
+    std::uint32_t extraBaseFeeMultiplier)
+{
+    return calculateBaseFee(view, tx) + view.fees().base * extraBaseFeeMultiplier;
+}
+
 // Returns the fee in fee units, not scaled for load.
 XRPAmount
 Transactor::calculateOwnerReserveFee(ReadView const& view, STTx const& tx)
@@ -492,7 +537,7 @@ Transactor::checkSeqProxy(ReadView const& view, STTx const& tx, beast::Journal j
         }
 
         // Transaction can never succeed if the Ticket is not in the ledger.
-        if (!view.exists(keylet::kTicket(id, tSeqProx)))
+        if (!view.exists(keylet::ticket(id, tSeqProx)))
         {
             JLOG(j.trace()) << "applyTransaction: ticket already used or never created "
                             << "a_seq=" << aSeq << " t_seq=" << tSeqProx;
@@ -557,7 +602,7 @@ Transactor::ticketDelete(
 {
     // Delete the Ticket, adjust the account root ticket count, and
     // reduce the owner count.
-    SLE::pointer const sleTicket = view.peek(keylet::kTicket(ticketIndex));
+    SLE::pointer const sleTicket = view.peek(keylet::ticket(ticketIndex));
     if (!sleTicket)
     {
         // LCOV_EXCL_START
@@ -779,7 +824,7 @@ Transactor::checkSingleSign(
     ReadView const& view,
     AccountID const& idSigner,
     AccountID const& idAccount,
-    std::shared_ptr sleAccount,
+    SLE::const_pointer sleAccount,
     beast::Journal const j)
 {
     bool const isMasterDisabled = sleAccount->isFlag(lsfDisableMaster);
@@ -815,7 +860,7 @@ Transactor::checkMultiSign(
     beast::Journal const j)
 {
     // Get id's SignerList and Quorum.
-    std::shared_ptr const sleAccountSigners = view.read(keylet::signers(id));
+    STLedgerEntry::const_pointer const sleAccountSigners = view.read(keylet::signerList(id));
     // If the signer list doesn't exist the account is not multi-signing.
     if (!sleAccountSigners)
     {
@@ -995,7 +1040,7 @@ removeExpiredNFTokenOffers(
 
     for (auto const& index : offers)
     {
-        if (auto const offer = view.peek(keylet::nftoffer(index)))
+        if (auto const offer = view.peek(keylet::nftokenOffer(index)))
         {
             nft::deleteTokenOffer(view, offer);
             if (++removed == kExpiredOfferRemoveLimit)
@@ -1044,26 +1089,6 @@ removeDeletedTrustLines(
     }
 }
 
-static void
-removeDeletedMPTs(ApplyView& view, std::vector const& mpts, beast::Journal viewJ)
-{
-    // There could be at most two MPTs - one for each side of AMM pool
-    if (mpts.size() > 2)
-    {
-        JLOG(viewJ.error()) << "removeDeletedMPTs: deleted mpts exceed 2 " << mpts.size();
-        return;
-    }
-
-    for (auto const& index : mpts)
-    {
-        if (auto const sleState = view.peek({ltMPTOKEN, index}); sleState &&
-            deleteAMMMPToken(view, sleState, (*sleState)[sfIssuer], viewJ) != tesSUCCESS)
-        {
-            JLOG(viewJ.error()) << "removeDeletedMPTs: failed to delete AMM MPT";
-        }
-    }
-}
-
 /** Reset the context, discarding any changes made and adjust the fee.
 
     @param fee The transaction fee to be charged.
@@ -1126,6 +1151,118 @@ Transactor::trapTransaction(uint256 txHash) const
     JLOG(j_.debug()) << "Transaction trapped: " << txHash;
 }
 
+std::tuple
+Transactor::processPersistentChanges(TER result, XRPAmount fee)
+{
+    JLOG(j_.trace()) << "reapplying because of " << transToken(result);
+
+    // FIXME: This mechanism for doing work while returning a `tec` is
+    //        awkward and very limiting. A more general purpose approach
+    //        should be used, making it possible to do more useful work
+    //        when transactions fail with a `tec` code.
+
+    auto typesForResult = [](TER const ter) {
+        std::unordered_set types;
+        if ((ter == tecOVERSIZE) || (ter == tecKILLED))
+        {
+            types.insert(ltOFFER);
+        }
+        else if (ter == tecINCOMPLETE)
+        {
+            types.insert(ltRIPPLE_STATE);
+        }
+        else if (ter == tecEXPIRED)
+        {
+            types.insert(ltNFTOKEN_OFFER);
+            types.insert(ltCREDENTIAL);
+        }
+        return types;
+    };
+
+    // Build a list of ledger entry types to collect, based on the
+    // result code. Only deleted objects of these types will be
+    // re-applied after the context is reset.
+    auto const typesToCollect = typesForResult(result);
+
+    std::map> deletedObjects;
+    if (!typesToCollect.empty())
+    {
+        ctx_.visit(
+            [&typesToCollect, &deletedObjects](
+                uint256 const& index, bool isDelete, SLE::const_ref before, SLE::const_ref after) {
+                if (isDelete)
+                {
+                    XRPL_ASSERT(
+                        before && after,
+                        "xrpl::Transactor::processPersistentChanges : non-null "
+                        "SLE inputs");
+                    if (before && after)
+                    {
+                        auto const type = before->getType();
+                        if (typesToCollect.contains(type))
+                        {
+                            // For offers, only collect unfunded removals
+                            // (where TakerPays is unchanged)
+                            if (type == ltOFFER &&
+                                before->getFieldAmount(sfTakerPays) !=
+                                    after->getFieldAmount(sfTakerPays))
+                                return;
+
+                            deletedObjects[type].push_back(index);
+                        }
+                    }
+                }
+            });
+    }
+
+    // Reset the context, potentially adjusting the fee.
+    {
+        auto const resetResult = reset(fee);
+        if (!isTesSuccess(resetResult.first))
+            result = resetResult.first;
+
+        fee = resetResult.second;
+    }
+
+    // Re-apply the collected deletions, but only if the reset succeeded
+    // and the post-reset result still allows the same deletion type.
+    auto const typesToApply = typesForResult(result);
+    if (isTecClaim(result) && !typesToApply.empty())
+    {
+        auto const viewJ = ctx_.registry.get().getJournal("View");
+        for (auto const& [type, ids] : deletedObjects)
+        {
+            if (ids.empty() || !typesToApply.contains(type))
+                continue;
+
+            switch (type)
+            {
+                case ltOFFER:
+                    removeUnfundedOffers(view(), ids, viewJ);
+                    break;
+                case ltNFTOKEN_OFFER:
+                    removeExpiredNFTokenOffers(view(), ids, viewJ);
+                    break;
+                case ltRIPPLE_STATE:
+                    removeDeletedTrustLines(view(), ids, viewJ);
+                    break;
+                case ltCREDENTIAL:
+                    removeExpiredCredentials(view(), ids, viewJ);
+                    break;
+                // LCOV_EXCL_START
+                default:
+                    UNREACHABLE(
+                        "xrpl::Transactor::processPersistentChanges() : "
+                        "unexpected type");
+                    break;
+                    // LCOV_EXCL_STOP
+            }
+        }
+    }
+
+    return {result, fee, isTecClaim(result)};
+}
+
 [[nodiscard]] TER
 Transactor::checkTransactionInvariants(TER result, XRPAmount fee)
 {
@@ -1163,22 +1300,19 @@ Transactor::checkTransactionInvariants(TER result, XRPAmount fee)
 [[nodiscard]] TER
 Transactor::checkInvariants(TER result, XRPAmount fee)
 {
-    // Transaction invariants first (more specific). These check post-conditions of the specific
-    // transaction. If these fail, the transaction's core logic is wrong.
-    auto const txResult = checkTransactionInvariants(result, fee);
-
-    // Protocol invariants second (broader). These check properties that must hold regardless of
-    // transaction type.
-    auto const protoResult = ctx_.checkInvariants(result, fee);
-
-    // Fail if either check failed. tef (fatal) takes priority over tec.
-    if (protoResult == tefINVARIANT_FAILED)
-        return tefINVARIANT_FAILED;
-    if (txResult == tecINVARIANT_FAILED || protoResult == tecINVARIANT_FAILED)
-        return tecINVARIANT_FAILED;
-
-    return result;
+    /*
+     * DISABLED for 3.2.0 — Must be re-introduced for 3.3.0
+     *
+     * Transaction invariants are disabled due to a performance regression:
+     * the two-pass design (transaction-specific invariants + protocol invariants)
+     * iterates over modified ledger entries twice per transaction.
+     *
+     * Until resolved, only protocol invariants are checked (delegated to ctx_).
+     * This is safe because all transaction invariants in 3.2.0 are  no-ops.
+     */
+    return ctx_.checkInvariants(result, fee);
 }
+
 //------------------------------------------------------------------------------
 ApplyResult
 Transactor::operator()()
@@ -1190,8 +1324,6 @@ Transactor::operator()()
     // with_txn_type().
     //
     // raii classes for the current ledger rules.
-    // fixUniversalNumber predate the rulesGuard and should be replaced.
-    NumberSO const stNumberSO{view().rules().enabled(fixUniversalNumber)};
     CurrentTransactionRulesGuard const currentTransactionRulesGuard(view().rules());
 
 #ifdef DEBUG
@@ -1247,108 +1379,7 @@ Transactor::operator()()
         (result == tecOVERSIZE) || (result == tecKILLED) || (result == tecINCOMPLETE) ||
         (result == tecEXPIRED) || (isTecClaimHardFail(result, view().flags())))
     {
-        JLOG(j_.trace()) << "reapplying because of " << transToken(result);
-
-        // FIXME: This mechanism for doing work while returning a `tec` is
-        //        awkward and very limiting. A more general purpose approach
-        //        should be used, making it possible to do more useful work
-        //        when transactions fail with a `tec` code.
-        std::vector removedOffers;
-        std::vector removedTrustLines;
-        std::vector removedMPTs;
-        std::vector expiredNFTokenOffers;
-        std::vector expiredCredentials;
-
-        bool const doOffers = ((result == tecOVERSIZE) || (result == tecKILLED));
-        bool const doLinesOrMPTs = (result == tecINCOMPLETE);
-        bool const doNFTokenOffers = (result == tecEXPIRED);
-        bool const doCredentials = (result == tecEXPIRED);
-        if (doOffers || doLinesOrMPTs || doNFTokenOffers || doCredentials)
-        {
-            ctx_.visit([doOffers,
-                        &removedOffers,
-                        doLinesOrMPTs,
-                        &removedTrustLines,
-                        &removedMPTs,
-                        doNFTokenOffers,
-                        &expiredNFTokenOffers,
-                        doCredentials,
-                        &expiredCredentials](
-                           uint256 const& index,
-                           bool isDelete,
-                           std::shared_ptr const& before,
-                           std::shared_ptr const& after) {
-                if (isDelete)
-                {
-                    XRPL_ASSERT(
-                        before && after,
-                        "xrpl::Transactor::operator()::visit : non-null SLE "
-                        "inputs");
-                    if (doOffers && before && after && (before->getType() == ltOFFER) &&
-                        (before->getFieldAmount(sfTakerPays) == after->getFieldAmount(sfTakerPays)))
-                    {
-                        // Removal of offer found or made unfunded
-                        removedOffers.push_back(index);
-                    }
-
-                    if (doLinesOrMPTs && before && after)
-                    {
-                        // Removal of obsolete AMM trust line
-                        if (before->getType() == ltRIPPLE_STATE)
-                        {
-                            removedTrustLines.push_back(index);
-                        }
-                        else if (before->getType() == ltMPTOKEN)
-                        {
-                            removedMPTs.push_back(index);
-                        }
-                    }
-
-                    if (doNFTokenOffers && before && after &&
-                        (before->getType() == ltNFTOKEN_OFFER))
-                        expiredNFTokenOffers.push_back(index);
-
-                    if (doCredentials && before && after && (before->getType() == ltCREDENTIAL))
-                        expiredCredentials.push_back(index);
-                }
-            });
-        }
-
-        // Reset the context, potentially adjusting the fee.
-        {
-            auto const resetResult = reset(fee);
-            if (!isTesSuccess(resetResult.first))
-                result = resetResult.first;
-
-            fee = resetResult.second;
-        }
-
-        // If necessary, remove any offers found unfunded during processing
-        if ((result == tecOVERSIZE) || (result == tecKILLED))
-        {
-            removeUnfundedOffers(view(), removedOffers, ctx_.registry.get().getJournal("View"));
-        }
-
-        if (result == tecEXPIRED)
-        {
-            removeExpiredNFTokenOffers(
-                view(), expiredNFTokenOffers, ctx_.registry.get().getJournal("View"));
-        }
-
-        if (result == tecINCOMPLETE)
-        {
-            removeDeletedTrustLines(
-                view(), removedTrustLines, ctx_.registry.get().getJournal("View"));
-            removeDeletedMPTs(view(), removedMPTs, ctx_.registry.get().getJournal("View"));
-        }
-
-        if (result == tecEXPIRED)
-        {
-            removeExpiredCredentials(
-                view(), expiredCredentials, ctx_.registry.get().getJournal("View"));
-        }
-
-        applied = isTecClaim(result);
+        std::tie(result, fee, applied) = processPersistentChanges(result, fee);
     }
 
     if (applied)
diff --git a/src/libxrpl/tx/apply.cpp b/src/libxrpl/tx/apply.cpp
index 0fc0275eb0..b70cb0d345 100644
--- a/src/libxrpl/tx/apply.cpp
+++ b/src/libxrpl/tx/apply.cpp
@@ -9,7 +9,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -133,7 +132,6 @@ template 
 ApplyResult
 apply(ServiceRegistry& registry, OpenView& view, PreflightChecks&& preflightChecks)
 {
-    NumberSO const stNumberSO{view.rules().enabled(fixUniversalNumber)};
     return doApply(preclaim(preflightChecks(), registry, view), registry, view);
 }
 
diff --git a/src/libxrpl/tx/applySteps.cpp b/src/libxrpl/tx/applySteps.cpp
index e0b1af80e2..caaacfd010 100644
--- a/src/libxrpl/tx/applySteps.cpp
+++ b/src/libxrpl/tx/applySteps.cpp
@@ -7,8 +7,6 @@
 #include 
 #include 
 #include 
-#include 
-#include 
 #include 
 #include 
 #include 
@@ -66,26 +64,14 @@ withTxnType(Rules const& rules, TxType txnType, F&& f)
     // so these need to be more global.
     //
     // To prevent unintentional side effects on existing checks, they will be
-    // set for every operation only once SingleAssetVault (or later
-    // LendingProtocol) are enabled.
+    // set for every operation only once at least one of the relevant amendments
+    // are enabled.
     //
     // See also Transactor::operator().
     //
-    std::optional stNumberSO;
     std::optional rulesGuard;
     std::optional mantissaScaleGuard;
-    if (rules.enabled(featureSingleAssetVault) || rules.enabled(featureLendingProtocol))
-    {
-        // raii classes for the current ledger rules.
-        // fixUniversalNumber predates the rulesGuard and should be replaced.
-        stNumberSO.emplace(rules.enabled(fixUniversalNumber));
-        rulesGuard.emplace(rules);
-    }
-    else
-    {
-        // Without those features enabled, always use the old number rules.
-        mantissaScaleGuard.emplace(MantissaRange::MantissaScale::Small);
-    }
+    createGuards(rules, rulesGuard, mantissaScaleGuard);
 
     switch (txnType)
     {
@@ -195,7 +181,8 @@ invokePreclaim(PreclaimContext const& ctx)
                         if (NotTEC const result = T::checkPriorTxAndLastLedger(ctx))
                             return result;
 
-                        if (NotTEC const result = T::checkPermission(ctx.view, ctx.tx))
+                        if (NotTEC const result =
+                                Transactor::invokeCheckPermission(ctx.view, ctx.tx))
                             return result;
 
                         if (NotTEC const result = T::checkSign(ctx))
diff --git a/src/libxrpl/tx/invariants/AMMInvariant.cpp b/src/libxrpl/tx/invariants/AMMInvariant.cpp
index e13684eca5..cca0ce149c 100644
--- a/src/libxrpl/tx/invariants/AMMInvariant.cpp
+++ b/src/libxrpl/tx/invariants/AMMInvariant.cpp
@@ -19,19 +19,22 @@
 #include 
 #include 
 
-#include 
 #include 
 
 namespace xrpl {
 
 void
-ValidAMM::visitEntry(
-    bool isDelete,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+ValidAMM::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
     if (isDelete)
+    {
+        if (before && before->getType() == ltAMM)
+        {
+            ammDeleted_ = true;
+            lptAMMBalanceBeforeDeletion_ = before->getFieldAmount(sfLPTokenBalance);
+        }
         return;
+    }
 
     if (after)
     {
@@ -45,7 +48,8 @@ ValidAMM::visitEntry(
         // AMM pool changed
         else if (
             (type == ltRIPPLE_STATE && after->isFlag(lsfAMMNode)) ||
-            (type == ltACCOUNT_ROOT && after->isFieldPresent(sfAMMID)))
+            (type == ltACCOUNT_ROOT && after->isFieldPresent(sfAMMID)) ||
+            (type == ltMPTOKEN && after->isFlag(lsfMPTAMM)))
         {
             ammPoolChanged_ = true;
         }
@@ -85,9 +89,9 @@ ValidAMM::finalizeVote(bool enforce, beast::Journal const& j) const
     {
         // LPTokens and the pool can not change on vote
         // LCOV_EXCL_START
-        JLOG(j.error()) << "AMMVote invariant failed: " << lptAMMBalanceBefore_.value_or(STAmount{})
-                        << " " << lptAMMBalanceAfter_.value_or(STAmount{}) << " "
-                        << ammPoolChanged_;
+        JLOG(j.error()) << "Invariant failed: AMMVote failed, "
+                        << lptAMMBalanceBefore_.value_or(STAmount{}) << " "
+                        << lptAMMBalanceAfter_.value_or(STAmount{}) << " " << ammPoolChanged_;
         if (enforce)
             return false;
         // LCOV_EXCL_STOP
@@ -103,7 +107,7 @@ ValidAMM::finalizeBid(bool enforce, beast::Journal const& j) const
     {
         // The pool can not change on bid
         // LCOV_EXCL_START
-        JLOG(j.error()) << "AMMBid invariant failed: pool changed";
+        JLOG(j.error()) << "Invariant failed: AMMBid failed, pool changed";
         if (enforce)
             return false;
         // LCOV_EXCL_STOP
@@ -114,7 +118,7 @@ ValidAMM::finalizeBid(bool enforce, beast::Journal const& j) const
         (*lptAMMBalanceAfter_ > *lptAMMBalanceBefore_ || *lptAMMBalanceAfter_ <= beast::kZero))
     {
         // LCOV_EXCL_START
-        JLOG(j.error()) << "AMMBid invariant failed: " << *lptAMMBalanceBefore_ << " "
+        JLOG(j.error()) << "Invariant failed: AMMBid failed, " << *lptAMMBalanceBefore_ << " "
                         << *lptAMMBalanceAfter_;
         if (enforce)
             return false;
@@ -134,7 +138,7 @@ ValidAMM::finalizeCreate(
     if (!ammAccount_)
     {
         // LCOV_EXCL_START
-        JLOG(j.error()) << "AMMCreate invariant failed: AMM object is not created";
+        JLOG(j.error()) << "Invariant failed: AMMCreate failed, AMM object is not created";
         if (enforce)
             return false;
         // LCOV_EXCL_STOP
@@ -157,8 +161,8 @@ ValidAMM::finalizeCreate(
         if (!validBalances(amount, amount2, *lptAMMBalanceAfter_, ZeroAllowed::No) ||
             ammLPTokens(amount, amount2, lptAMMBalanceAfter_->get()) != *lptAMMBalanceAfter_)
         {
-            JLOG(j.error()) << "AMMCreate invariant failed: " << amount << " " << amount2 << " "
-                            << *lptAMMBalanceAfter_;
+            JLOG(j.error()) << "Invariant failed: AMMCreate failed, " << amount << " " << amount2
+                            << " " << *lptAMMBalanceAfter_;
             if (enforce)
                 return false;
         }
@@ -169,18 +173,60 @@ ValidAMM::finalizeCreate(
 }
 
 bool
-ValidAMM::finalizeDelete(bool enforce, TER res, beast::Journal const& j) const
+ValidAMM::finalizeDelete(bool enforce, bool enforceAMMDelete, TER res, beast::Journal const& j)
+    const
 {
     if (ammAccount_)
     {
         // LCOV_EXCL_START
-        std::string const msg = (isTesSuccess(res)) ? "AMM object is not deleted on tesSUCCESS"
-                                                    : "AMM object is changed on tecINCOMPLETE";
-        JLOG(j.error()) << "AMMDelete invariant failed: " << msg;
+        std::string const msg = (isTesSuccess(res)) ? "AMM object remained on tesSUCCESS"
+                                                    : "AMM object changed on tecINCOMPLETE";
+        JLOG(j.error()) << "Invariant failed: AMMDelete failed, " << msg;
         if (enforce)
             return false;
         // LCOV_EXCL_STOP
     }
+    if (enforceAMMDelete)
+    {
+        if (isTesSuccess(res))
+        {
+            if (!ammDeleted_)
+            {
+                // LCOV_EXCL_START
+                JLOG(j.error())
+                    << "Invariant failed: AMMDelete failed, AMM object remained on tesSUCCESS";
+                return false;
+                // LCOV_EXCL_STOP
+            }
+            if (!lptAMMBalanceBeforeDeletion_)
+            {
+                // LCOV_EXCL_START
+                JLOG(j.error())
+                    << "Invariant failed: AMMDelete failed, AMM object deleted without LP balance";
+                return false;
+                // LCOV_EXCL_STOP
+            }
+            if (*lptAMMBalanceBeforeDeletion_ != beast::kZero)
+            {
+                // LCOV_EXCL_START
+                JLOG(j.error())
+                    << "Invariant failed: AMMDelete failed, AMM object deleted with non-zero LP "
+                       "balance: "
+                    << *lptAMMBalanceBeforeDeletion_;
+                return false;
+                // LCOV_EXCL_STOP
+            }
+        }
+        else if (ammDeleted_)
+        {
+            // AMM should only be fully deleted when AMMDelete returns tesSUCCESS.
+            // LCOV_EXCL_START
+            JLOG(j.error()) << "Invariant failed: AMMDelete failed, AMM object deleted when result "
+                               "is not tesSUCCESS";
+            return false;
+            // LCOV_EXCL_STOP
+        }
+    }
 
     return true;
 }
@@ -191,7 +237,7 @@ ValidAMM::finalizeDEX(bool enforce, beast::Journal const& j) const
     if (ammAccount_)
     {
         // LCOV_EXCL_START
-        JLOG(j.error()) << "AMM swap invariant failed: AMM object changed";
+        JLOG(j.error()) << "Invariant failed: AMM swap failed, AMM object changed";
         if (enforce)
             return false;
         // LCOV_EXCL_STOP
@@ -224,18 +270,13 @@ ValidAMM::generalInvariant(
     auto const poolProductMean = root2(amount * amount2);
     bool const nonNegativeBalances =
         validBalances(amount, amount2, *lptAMMBalanceAfter_, zeroAllowed);
-    bool const strongInvariantCheck = poolProductMean >= *lptAMMBalanceAfter_;
-    // Allow for a small relative error if strongInvariantCheck fails
-    auto weakInvariantCheck = [&]() {
-        return *lptAMMBalanceAfter_ != beast::kZero &&
-            withinRelativeDistance(poolProductMean, Number{*lptAMMBalanceAfter_}, Number{1, -11});
-    };
-    if (!nonNegativeBalances || (!strongInvariantCheck && !weakInvariantCheck()))
+    auto const precisionLoss = checkAMMPrecisionLoss(poolProductMean, *lptAMMBalanceAfter_);
+    if (!nonNegativeBalances || !isTesSuccess(precisionLoss))
     {
-        JLOG(j.error()) << "AMM " << tx.getTxnType()
-                        << " invariant failed: " << tx.getHash(HashPrefix::TransactionId) << " "
-                        << ammPoolChanged_ << " " << amount << " " << amount2 << " "
-                        << poolProductMean << " " << lptAMMBalanceAfter_->getText() << " "
+        JLOG(j.error()) << "Invariant failed: AMM " << tx.getTxnType() << " "
+                        << tx.getHash(HashPrefix::TransactionId) << " " << ammPoolChanged_ << " "
+                        << amount << " " << amount2 << " " << poolProductMean << " "
+                        << lptAMMBalanceAfter_->getText() << " "
                         << ((*lptAMMBalanceAfter_ == beast::kZero)
                                 ? Number{1}
                                 : ((*lptAMMBalanceAfter_ - poolProductMean) / poolProductMean));
@@ -256,7 +297,7 @@ ValidAMM::finalizeDeposit(
     if (!ammAccount_)
     {
         // LCOV_EXCL_START
-        JLOG(j.error()) << "AMMDeposit invariant failed: AMM object is deleted";
+        JLOG(j.error()) << "Invariant failed: AMMDeposit failed, AMM object is deleted";
         if (enforce)
             return false;
         // LCOV_EXCL_STOP
@@ -274,16 +315,20 @@ ValidAMM::finalizeWithdraw(
     xrpl::STTx const& tx,
     xrpl::ReadView const& view,
     bool enforce,
+    bool enforceAMMDelete,
     beast::Journal const& j) const
 {
-    if (!ammAccount_)
+    if (enforceAMMDelete && ammDeleted_)
     {
-        // Last Withdraw or Clawback deleted AMM
+        // Last Withdraw or Clawback can delete the AMM. We don't have to check
+        // the LPToken balance because a final AMMWithdraw or AMMClawback can
+        // redeem the remaining LP tokens and delete the AMM entry in the same
+        // transaction.
+        return true;
     }
-    else if (!generalInvariant(tx, view, ZeroAllowed::Yes, j))
+    if (ammAccount_ && !generalInvariant(tx, view, ZeroAllowed::Yes, j) && enforce)
     {
-        if (enforce)
-            return false;
+        return false;
     }
 
     return true;
@@ -303,6 +348,25 @@ ValidAMM::finalize(
         return true;
 
     bool const enforce = view.rules().enabled(fixAMMv1_3);
+    bool const enforceAMMDelete = view.rules().enabled(fixCleanup3_3_0);
+
+    // AMM can only be deleted by AMMWithdraw, AMMClawback, and AMMDelete
+    if (enforceAMMDelete && ammDeleted_)
+    {
+        switch (tx.getTxnType())
+        {
+            case ttAMM_WITHDRAW:
+            case ttAMM_CLAWBACK:
+            case ttAMM_DELETE:
+                break;
+            default:
+                // LCOV_EXCL_START
+                JLOG(j.error()) << "Invariant failed: AMM failed, unexpected AMM deletion by "
+                                << tx.getTxnType();
+                return false;
+                // LCOV_EXCL_STOP
+        }
+    }
 
     switch (tx.getTxnType())
     {
@@ -312,13 +376,13 @@ ValidAMM::finalize(
             return finalizeDeposit(tx, view, enforce, j);
         case ttAMM_CLAWBACK:
         case ttAMM_WITHDRAW:
-            return finalizeWithdraw(tx, view, enforce, j);
+            return finalizeWithdraw(tx, view, enforce, enforceAMMDelete, j);
         case ttAMM_BID:
             return finalizeBid(enforce, j);
         case ttAMM_VOTE:
             return finalizeVote(enforce, j);
         case ttAMM_DELETE:
-            return finalizeDelete(enforce, result, j);
+            return finalizeDelete(enforce, enforceAMMDelete, result, j);
         case ttCHECK_CASH:
         case ttOFFER_CREATE:
         case ttPAYMENT:
diff --git a/src/libxrpl/tx/invariants/DirectoryInvariant.cpp b/src/libxrpl/tx/invariants/DirectoryInvariant.cpp
new file mode 100644
index 0000000000..1624a19830
--- /dev/null
+++ b/src/libxrpl/tx/invariants/DirectoryInvariant.cpp
@@ -0,0 +1,104 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+namespace xrpl {
+
+namespace {
+
+[[nodiscard]] bool
+isRootBookDirectory(SLE const& dir)
+{
+    // Child page keys do not encode book quality.
+    return dir.isFieldPresent(sfExchangeRate) || dir.isFieldPresent(sfTakerPaysCurrency) ||
+        dir.isFieldPresent(sfTakerPaysIssuer) || dir.isFieldPresent(sfTakerPaysMPT) ||
+        dir.isFieldPresent(sfTakerGetsCurrency) || dir.isFieldPresent(sfTakerGetsIssuer) ||
+        dir.isFieldPresent(sfTakerGetsMPT) || dir.isFieldPresent(sfDomainID);
+}
+
+[[nodiscard]] bool
+badExchangeRate(SLE const& dir)
+{
+    return isRootBookDirectory(dir) &&
+        (!dir.isFieldPresent(sfExchangeRate) ||
+         dir.getFieldU64(sfExchangeRate) != getQuality(dir.key()));
+}
+
+}  // namespace
+
+void
+ValidBookDirectory::visitEntry(
+    bool isDelete,
+    std::shared_ptr const& before,
+    std::shared_ptr const& after)
+{
+    // New root directories must have matching exchange-rate metadata. New
+    // child directories, and modified directories that change sfRootIndex, must
+    // point to an existing root.
+
+    // Only validate newly-created directories and sfRootIndex changes;
+    // LedgerStateFix handles legacy bad exchange-rate metadata. Skip deletions
+    // because `after` is not guaranteed to be null.
+    if (badBookDirectory_ || isDelete || !after || after->getType() != ltDIR_NODE)
+        return;
+
+    auto const rootIndex = after->getFieldH256(sfRootIndex);
+    // Ignore ordinary modifications that do not change which root this
+    // directory belongs to. That tolerates legacy bad exchange-rate metadata
+    // during normal operation while still checking sfRootIndex changes.
+    if (before && before->getFieldH256(sfRootIndex) == rootIndex)
+        return;
+
+    if (after->key() == rootIndex && !badBookDirectory_)
+    {
+        badBookDirectory_ = badBookDirectory_ || badExchangeRate(*after);
+        return;
+    }
+
+    rootIndexes_.insert(rootIndex);
+}
+
+bool
+ValidBookDirectory::finalize(
+    STTx const&,
+    TER const,
+    XRPAmount const,
+    ReadView const& view,
+    beast::Journal const& j)
+{
+    if (!view.rules().enabled(fixCleanup3_2_0))
+        return true;
+
+    if (badBookDirectory_)
+    {
+        JLOG(j.fatal()) << "Invariant failed: book directory exchange rate "
+                           "does not match directory quality";
+        return false;
+    }
+
+    for (auto const& rootIndex : rootIndexes_)
+    {
+        auto const root = view.read(Keylet(ltDIR_NODE, rootIndex));
+        if (!root)
+        {
+            JLOG(j.fatal()) << "Invariant failed: book directory root missing";
+            return false;
+        }
+    }
+
+    return true;
+}
+
+}  // namespace xrpl
diff --git a/src/libxrpl/tx/invariants/FreezeInvariant.cpp b/src/libxrpl/tx/invariants/FreezeInvariant.cpp
index eaaeb7fc6f..0293d42e97 100644
--- a/src/libxrpl/tx/invariants/FreezeInvariant.cpp
+++ b/src/libxrpl/tx/invariants/FreezeInvariant.cpp
@@ -16,16 +16,12 @@
 #include 
 #include 
 
-#include 
 #include 
 
 namespace xrpl {
 
 void
-TransfersNotFrozen::visitEntry(
-    bool isDelete,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+TransfersNotFrozen::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
     /*
      * A trust line freeze state alone doesn't determine if a transfer is
@@ -107,9 +103,7 @@ TransfersNotFrozen::finalize(
 }
 
 bool
-TransfersNotFrozen::isValidEntry(
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+TransfersNotFrozen::isValidEntry(SLE::const_ref before, SLE::const_ref after)
 {
     // `after` can never be null, even if the trust line is deleted.
     XRPL_ASSERT(after, "xrpl::TransfersNotFrozen::isValidEntry : valid after.");
@@ -135,8 +129,8 @@ TransfersNotFrozen::isValidEntry(
 
 STAmount
 TransfersNotFrozen::calculateBalanceChange(
-    std::shared_ptr const& before,
-    std::shared_ptr const& after,
+    SLE::const_ref before,
+    SLE::const_ref after,
     bool isDelete)
 {
     auto const getBalance = [](auto const& line, auto const& other, bool zero) {
@@ -180,9 +174,7 @@ TransfersNotFrozen::recordBalance(Issue const& issue, BalanceChange change)
 }
 
 void
-TransfersNotFrozen::recordBalanceChanges(
-    std::shared_ptr const& after,
-    STAmount const& balanceChange)
+TransfersNotFrozen::recordBalanceChanges(SLE::const_ref after, STAmount const& balanceChange)
 {
     auto const balanceChangeSign = balanceChange.signum();
     auto const currency = after->at(sfBalance).get().currency;
@@ -198,7 +190,7 @@ TransfersNotFrozen::recordBalanceChanges(
         {.line = after, .balanceChangeSign = -balanceChangeSign});
 }
 
-std::shared_ptr
+SLE::const_pointer
 TransfersNotFrozen::findIssuer(AccountID const& issuerID, ReadView const& view)
 {
     if (auto it = possibleIssuers_.find(issuerID); it != possibleIssuers_.end())
@@ -211,7 +203,7 @@ TransfersNotFrozen::findIssuer(AccountID const& issuerID, ReadView const& view)
 
 bool
 TransfersNotFrozen::validateIssuerChanges(
-    std::shared_ptr const& issuer,
+    SLE::const_ref issuer,
     IssuerChanges const& changes,
     STTx const& tx,
     beast::Journal const& j,
diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp
index 2019e378e2..308342da74 100644
--- a/src/libxrpl/tx/invariants/InvariantCheck.cpp
+++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp
@@ -64,10 +64,7 @@ hasPrivilege(STTx const& tx, Privilege priv)
 #pragma pop_macro("TRANSACTION")
 
 void
-TransactionFeeCheck::visitEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+TransactionFeeCheck::visitEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // nothing to do
 }
@@ -110,10 +107,7 @@ TransactionFeeCheck::finalize(
 //------------------------------------------------------------------------------
 
 void
-XRPNotCreated::visitEntry(
-    bool isDelete,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+XRPNotCreated::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
     /* We go through all modified ledger entries, looking only at account roots,
      * escrow payments, and payment channels. We remove from the total any
@@ -192,10 +186,7 @@ XRPNotCreated::finalize(
 //------------------------------------------------------------------------------
 
 void
-XRPBalanceChecks::visitEntry(
-    bool,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+XRPBalanceChecks::visitEntry(bool, SLE::const_ref before, SLE::const_ref after)
 {
     auto isBad = [](STAmount const& balance) {
         if (!balance.native())
@@ -242,10 +233,7 @@ XRPBalanceChecks::finalize(
 //------------------------------------------------------------------------------
 
 void
-NoBadOffers::visitEntry(
-    bool isDelete,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+NoBadOffers::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
     auto isBad = [](STAmount const& pays, STAmount const& gets) {
         // An offer should never be negative
@@ -286,10 +274,7 @@ NoBadOffers::finalize(
 //------------------------------------------------------------------------------
 
 void
-NoZeroEscrow::visitEntry(
-    bool isDelete,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+NoZeroEscrow::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
     auto isBad = [](STAmount const& amount) {
         // XRP case
@@ -393,10 +378,7 @@ NoZeroEscrow::finalize(
 //------------------------------------------------------------------------------
 
 void
-AccountRootsNotDeleted::visitEntry(
-    bool isDelete,
-    std::shared_ptr const& before,
-    std::shared_ptr const&)
+AccountRootsNotDeleted::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref)
 {
     if (isDelete && before && before->getType() == ltACCOUNT_ROOT)
         accountsDeleted_++;
@@ -425,8 +407,10 @@ AccountRootsNotDeleted::finalize(
                                "succeeded without deleting an account";
         }
         else
+        {
             JLOG(j.fatal()) << "Invariant failed: account deletion "
                                "succeeded but deleted multiple accounts!";
+        }
         return false;
     }
 
@@ -446,10 +430,7 @@ AccountRootsNotDeleted::finalize(
 //------------------------------------------------------------------------------
 
 void
-AccountRootsDeletedClean::visitEntry(
-    bool isDelete,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+AccountRootsDeletedClean::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
     if (isDelete && before && before->getType() == ltACCOUNT_ROOT)
         accountsDeleted_.emplace_back(before, after);
@@ -537,8 +518,8 @@ AccountRootsDeletedClean::finalize(
             // checked above as entries in directAccountKeylets. This uses
             // view.succ() to check for any NFT pages in between the two
             // endpoints.
-            Keylet const first = keylet::nftpageMin(accountID);
-            Keylet const last = keylet::nftpageMax(accountID);
+            Keylet const first = keylet::nftokenPageMin(accountID);
+            Keylet const last = keylet::nftokenPageMax(accountID);
 
             std::optional key = view.succ(first.key, last.key.next());
 
@@ -566,10 +547,7 @@ AccountRootsDeletedClean::finalize(
 //------------------------------------------------------------------------------
 
 void
-LedgerEntryTypesMatch::visitEntry(
-    bool,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+LedgerEntryTypesMatch::visitEntry(bool, SLE::const_ref before, SLE::const_ref after)
 {
     if (before && after && before->getType() != after->getType())
         typeMismatch_ = true;
@@ -623,10 +601,7 @@ LedgerEntryTypesMatch::finalize(
 //------------------------------------------------------------------------------
 
 void
-NoXRPTrustLines::visitEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const& after)
+NoXRPTrustLines::visitEntry(bool, SLE::const_ref, SLE::const_ref after)
 {
     bool const overwriteFixEnabled = isFeatureEnabled(fixCleanup3_1_3, true);
 
@@ -666,10 +641,7 @@ NoXRPTrustLines::finalize(
 //------------------------------------------------------------------------------
 
 void
-NoDeepFreezeTrustLinesWithoutFreeze::visitEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const& after)
+NoDeepFreezeTrustLinesWithoutFreeze::visitEntry(bool, SLE::const_ref, SLE::const_ref after)
 {
     if (after && after->getType() == ltRIPPLE_STATE)
     {
@@ -712,10 +684,7 @@ NoDeepFreezeTrustLinesWithoutFreeze::finalize(
 //------------------------------------------------------------------------------
 
 void
-ValidNewAccountRoot::visitEntry(
-    bool,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+ValidNewAccountRoot::visitEntry(bool, SLE::const_ref before, SLE::const_ref after)
 {
     if (!before && after->getType() == ltACCOUNT_ROOT)
     {
@@ -789,10 +758,7 @@ ValidNewAccountRoot::finalize(
 //------------------------------------------------------------------------------
 
 void
-ValidClawback::visitEntry(
-    bool,
-    std::shared_ptr const& before,
-    std::shared_ptr const&)
+ValidClawback::visitEntry(bool, SLE::const_ref before, SLE::const_ref)
 {
     if (before && before->getType() == ltRIPPLE_STATE)
         trustlinesChanged_++;
@@ -840,7 +806,7 @@ ValidClawback::finalize(
                 [&](MPTIssue const& issue) {
                     return accountHolds(
                         view,
-                        issuer,
+                        holder,
                         issue,
                         FreezeHandling::IgnoreFreeze,
                         AuthHandling::IgnoreAuth,
@@ -877,10 +843,7 @@ ValidClawback::finalize(
 //------------------------------------------------------------------------------
 
 void
-ValidPseudoAccounts::visitEntry(
-    bool isDelete,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+ValidPseudoAccounts::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
     if (isDelete)
     {
@@ -913,10 +876,9 @@ ValidPseudoAccounts::visitEntry(
             {
                 std::vector const& fields = getPseudoAccountFields();
 
-                auto const numFields =
-                    std::count_if(fields.begin(), fields.end(), [&after](SField const* sf) -> bool {
-                        return after->isFieldPresent(*sf);
-                    });
+                auto const numFields = std::ranges::count_if(
+                    fields,
+                    [&after](SField const* sf) -> bool { return after->isFieldPresent(*sf); });
                 if (numFields != 1)
                 {
                     std::stringstream error;
@@ -968,10 +930,7 @@ ValidPseudoAccounts::finalize(
 //------------------------------------------------------------------------------
 
 void
-NoModifiedUnmodifiableFields::visitEntry(
-    bool isDelete,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+NoModifiedUnmodifiableFields::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
     if (isDelete || !before)
     {
@@ -1080,4 +1039,35 @@ NoModifiedUnmodifiableFields::finalize(
     return true;
 }
 
+void
+ValidAmounts::visitEntry(
+    bool isDelete,
+    std::shared_ptr const&,
+    std::shared_ptr const& after)
+{
+    if (!isDelete && after)
+        afterEntries_.push_back(after);
+}
+
+bool
+ValidAmounts::finalize(
+    STTx const&,
+    TER const,
+    XRPAmount const,
+    ReadView const& view,
+    beast::Journal const& j) const
+{
+    bool const badLedgerEntry = std::ranges::any_of(
+        afterEntries_, [&](auto const& sle) { return hasInvalidAmount(*sle, j); });
+
+    if (badLedgerEntry)
+    {
+        JLOG(j.fatal())
+            << "Invariant failed: ledger entry contains non-canonical MPT or XRP amount";
+        return !view.rules().enabled(fixCleanup3_2_0);
+    }
+
+    return true;
+}
+
 }  // namespace xrpl
diff --git a/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp b/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp
index 56995ef94c..d239acd417 100644
--- a/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp
+++ b/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp
@@ -15,15 +15,10 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 void
-ValidLoanBroker::visitEntry(
-    bool isDelete,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+ValidLoanBroker::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
     if (after)
     {
@@ -135,7 +130,7 @@ ValidLoanBroker::finalize(
     for (auto const& [brokerID, broker] : brokers_)
     {
         auto const& after =
-            broker.brokerAfter ? broker.brokerAfter : view.read(keylet::loanbroker(brokerID));
+            broker.brokerAfter ? broker.brokerAfter : view.read(keylet::loanBroker(brokerID));
 
         if (!after)
         {
diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp
index 3eef0957e5..ce9a7c6e03 100644
--- a/src/libxrpl/tx/invariants/LoanInvariant.cpp
+++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp
@@ -12,15 +12,10 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 void
-ValidLoan::visitEntry(
-    bool isDelete,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
     if (after && after->getType() == ltLOAN)
     {
diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp
index 43379b0b86..26bee4effb 100644
--- a/src/libxrpl/tx/invariants/MPTInvariant.cpp
+++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp
@@ -1,35 +1,80 @@
 #include 
 
 #include 
+#include 
 #include 
+#include 
 #include 
 #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 {
 
-void
-ValidMPTIssuance::visitEntry(
-    bool isDelete,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+namespace {
+constexpr auto kConfidentialMptTxTypes = std::to_array({
+    ttCONFIDENTIAL_MPT_SEND,
+    ttCONFIDENTIAL_MPT_CONVERT,
+    ttCONFIDENTIAL_MPT_CONVERT_BACK,
+    ttCONFIDENTIAL_MPT_MERGE_INBOX,
+    ttCONFIDENTIAL_MPT_CLAWBACK,
+});
+
+// Clamp to the cap (== INT64_MAX) before the signed conversion. Invariant
+// tests can inject INT64_MAX + 1, which would result in undefined behavior
+// under UBSan if converted directly.
+std::int64_t
+toSignedMPTAmount(std::uint64_t amount)
 {
+    return static_cast(std::min(amount, kMaxMpTokenAmount));
+}
+
+std::int64_t
+addMPTAmountDelta(std::int64_t delta, std::uint64_t amount)
+{
+    return delta + toSignedMPTAmount(amount);
+}
+
+std::int64_t
+subtractMPTAmountDelta(std::int64_t delta, std::uint64_t amount)
+{
+    return delta - toSignedMPTAmount(amount);
+}
+
+}  // namespace
+
+void
+ValidMPTIssuance::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
+{
+    // The sfReferenceHolding tracking and the deleted-holding capture are
+    // only meaningful post-fixCleanup3_2_0 (the field is never set
+    // pre-amendment, and the holding-deletion rule does not apply).
+    // Skip both blocks when the amendment is off so we avoid wasted work
+    // on the hot path.
+    bool const fix320Enabled = isFeatureEnabled(fixCleanup3_2_0);
+
     if (after && after->getType() == ltMPTOKEN_ISSUANCE)
     {
         if (isDelete)
@@ -39,6 +84,21 @@ ValidMPTIssuance::visitEntry(
         else if (!before)
         {
             mptIssuancesCreated_++;
+            if (fix320Enabled && after->isFieldPresent(sfReferenceHolding))
+                referenceHoldingSetOnCreate_ = true;
+        }
+        else if (fix320Enabled)
+        {
+            // Modified issuance: detect any change to sfReferenceHolding.
+            bool const beforePresent = before->isFieldPresent(sfReferenceHolding);
+            bool const afterPresent = after->isFieldPresent(sfReferenceHolding);
+            if (beforePresent != afterPresent ||
+                (afterPresent &&
+                 before->getFieldH256(sfReferenceHolding) !=
+                     after->getFieldH256(sfReferenceHolding)))
+            {
+                referenceHoldingMutated_ = true;
+            }
         }
     }
 
@@ -47,6 +107,8 @@ ValidMPTIssuance::visitEntry(
         if (isDelete)
         {
             mptokensDeleted_++;
+            if (fix320Enabled)
+                deletedHoldings_.push_back(after);
         }
         else if (!before)
         {
@@ -56,6 +118,11 @@ ValidMPTIssuance::visitEntry(
                 mptCreatedByIssuer_ = true;
         }
     }
+
+    // Capture deleted RippleState SLEs so finalize() can verify none of
+    // them were owned by a vault pseudo-account outside VaultDelete.
+    if (fix320Enabled && isDelete && after && after->getType() == ltRIPPLE_STATE)
+        deletedHoldings_.push_back(after);
 }
 
 bool
@@ -68,6 +135,63 @@ ValidMPTIssuance::finalize(
 {
     auto const& rules = view.rules();
     bool const mptV2Enabled = rules.enabled(featureMPTokensV2);
+
+    // Post-fixCleanup3_2_0:
+    //   - sfReferenceHolding is set only by VaultCreate at share-issuance
+    //     creation, and is immutable thereafter.
+    //   - A vault pseudo-account's MPToken or RippleState may only be
+    //     deleted by VaultDelete; the share's sfReferenceHolding pointer
+    //     must not dangle outside that controlled lifecycle.
+    if (rules.enabled(fixCleanup3_2_0))
+    {
+        bool invariantPasses = true;
+        if (referenceHoldingMutated_)
+        {
+            JLOG(j.fatal()) << "Invariant failed: sfReferenceHolding was modified "
+                               "on an existing MPTokenIssuance";
+            invariantPasses = false;
+        }
+        if (referenceHoldingSetOnCreate_ && tx.getTxnType() != ttVAULT_CREATE)
+        {
+            JLOG(j.fatal()) << "Invariant failed: sfReferenceHolding set on a new "
+                               "MPTokenIssuance by a non-VaultCreate transaction";
+            invariantPasses = false;
+        }
+        if (!deletedHoldings_.empty() && tx.getTxnType() != ttVAULT_DELETE)
+        {
+            auto const isVaultPseudo = [&](AccountID const& acct) {
+                auto const sle = view.read(keylet::account(acct));
+                return sle && sle->isFieldPresent(sfVaultID);
+            };
+            for (auto const& sleHolding : deletedHoldings_)
+            {
+                bool offending = false;
+                if (sleHolding->getType() == ltMPTOKEN)
+                {
+                    offending = isVaultPseudo(sleHolding->at(sfAccount));
+                }
+                else  // ltRIPPLE_STATE
+                {
+                    auto const lowLimit = sleHolding->getFieldAmount(sfLowLimit);
+                    auto const highLimit = sleHolding->getFieldAmount(sfHighLimit);
+                    // Each limit's STAmount.issuer is the COUNTERPARTY of
+                    // that side's owner: lowLimit's issuer is the high
+                    // account, highLimit's issuer is the low account.
+                    offending =
+                        isVaultPseudo(lowLimit.getIssuer()) || isVaultPseudo(highLimit.getIssuer());
+                }
+                if (offending)
+                {
+                    JLOG(j.fatal()) << "Invariant failed: vault pseudo-account holding "
+                                       "deleted by a non-VaultDelete transaction";
+                    invariantPasses = false;
+                }
+            }
+        }
+        if (!invariantPasses)
+            return false;
+    }
+
     if (isTesSuccess(result) || (mptV2Enabled && result == tecINCOMPLETE))
     {
         [[maybe_unused]]
@@ -280,10 +404,7 @@ ValidMPTIssuance::finalize(
 }
 
 void
-ValidMPTPayment::visitEntry(
-    bool,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+ValidMPTPayment::visitEntry(bool, SLE::const_ref before, SLE::const_ref after)
 {
     if (overflow_)
         return;
@@ -354,11 +475,21 @@ ValidMPTPayment::finalize(
 {
     if (isTesSuccess(result))
     {
-        bool const enforce = view.rules().enabled(featureMPTokensV2);
+        // Confidential transactions are validated by ValidConfidentialMPToken.
+        // They modify encrypted fields and sfConfidentialOutstandingAmount
+        // rather than sfMPTAmount/sfOutstandingAmount in the standard way,
+        // so ValidMPTPayment's accounting does not apply to them.
+        if (std::ranges::find(kConfidentialMptTxTypes, tx.getTxnType()) !=
+            kConfidentialMptTxTypes.end())
+        {
+            return true;
+        }
+
+        bool const invariantPasses = !view.rules().enabled(featureMPTokensV2);
         if (overflow_)
         {
             JLOG(j.fatal()) << "Invariant failed: OutstandingAmount overflow";
-            return !enforce;
+            return invariantPasses;
         }
 
         auto const signedMax = static_cast(kMaxMpTokenAmount);
@@ -376,7 +507,7 @@ ValidMPTPayment::finalize(
                 JLOG(j.fatal()) << "Invariant failed: invalid OutstandingAmount balance "
                                 << data.outstanding[kIBefore] << " " << data.outstanding[kIAfter]
                                 << " " << data.mptAmount;
-                return !enforce;
+                return invariantPasses;
             }
         }
     }
@@ -384,4 +515,430 @@ ValidMPTPayment::finalize(
     return true;
 }
 
+void
+ValidConfidentialMPToken::visitEntry(
+    bool isDelete,
+    std::shared_ptr const& before,
+    std::shared_ptr const& after)
+{
+    // Helper to get MPToken Issuance ID safely
+    auto const getMptID = [](std::shared_ptr const& sle) -> uint192 {
+        if (!sle)
+            return beast::kZero;
+        if (sle->getType() == ltMPTOKEN)
+            return sle->getFieldH192(sfMPTokenIssuanceID);
+        if (sle->getType() == ltMPTOKEN_ISSUANCE)
+            return makeMptID(sle->getFieldU32(sfSequence), sle->getAccountID(sfIssuer));
+        return beast::kZero;
+    };
+
+    if (before && before->getType() == ltMPTOKEN)
+    {
+        uint192 const id = getMptID(before);
+        auto& change = changes_[id];
+        change.mptAmountDelta =
+            subtractMPTAmountDelta(change.mptAmountDelta, before->getFieldU64(sfMPTAmount));
+
+        // Cannot delete MPToken with non-zero confidential state or non-zero public amount
+        if (isDelete)
+        {
+            bool const hasPublicBalance = before->getFieldU64(sfMPTAmount) > 0;
+            bool const hasEncryptedFields = before->isFieldPresent(sfConfidentialBalanceSpending) ||
+                before->isFieldPresent(sfConfidentialBalanceInbox) ||
+                before->isFieldPresent(sfIssuerEncryptedBalance) ||
+                before->isFieldPresent(sfAuditorEncryptedBalance);
+
+            if (hasPublicBalance || hasEncryptedFields)
+                changes_[id].deletedWithEncrypted = true;
+        }
+    }
+
+    if (after && after->getType() == ltMPTOKEN)
+    {
+        uint192 const id = getMptID(after);
+        auto& change = changes_[id];
+        change.mptAmountDelta =
+            addMPTAmountDelta(change.mptAmountDelta, after->getFieldU64(sfMPTAmount));
+
+        // Encrypted field existence consistency
+        bool const hasIssuerBalance = after->isFieldPresent(sfIssuerEncryptedBalance);
+        bool const hasHolderInbox = after->isFieldPresent(sfConfidentialBalanceInbox);
+        bool const hasHolderSpending = after->isFieldPresent(sfConfidentialBalanceSpending);
+        bool const hasAuditorBalance = after->isFieldPresent(sfAuditorEncryptedBalance);
+
+        // The core encrypted balances must all exist or not exist at the same time. The auditor
+        // balance is optional, but cannot exist without the core fields.
+        if (hasHolderInbox != hasHolderSpending || hasHolderInbox != hasIssuerBalance ||
+            (hasAuditorBalance && !hasIssuerBalance))
+            changes_[id].badConsistency = true;
+
+        auto const confidentialBalanceFieldChanged = [&before, &after](auto const& field) {
+            auto const afterValue = (*after)[~field];
+            if (!afterValue)
+                return false;
+
+            if (!before || before->getType() != ltMPTOKEN)
+                return true;  // LCOV_EXCL_LINE
+
+            return (*before)[~field] != afterValue;
+        };
+
+        if (confidentialBalanceFieldChanged(sfConfidentialBalanceInbox) ||
+            confidentialBalanceFieldChanged(sfConfidentialBalanceSpending) ||
+            confidentialBalanceFieldChanged(sfIssuerEncryptedBalance) ||
+            confidentialBalanceFieldChanged(sfAuditorEncryptedBalance))
+        {
+            changes_[id].changesConfidentialFields = true;
+        }
+    }
+
+    if (before && before->getType() == ltMPTOKEN_ISSUANCE)
+    {
+        uint192 const id = getMptID(before);
+        auto& change = changes_[id];
+        if (before->isFieldPresent(sfConfidentialOutstandingAmount))
+        {
+            change.coaDelta = subtractMPTAmountDelta(
+                change.coaDelta, before->getFieldU64(sfConfidentialOutstandingAmount));
+        }
+        change.outstandingDelta = subtractMPTAmountDelta(
+            change.outstandingDelta, before->getFieldU64(sfOutstandingAmount));
+    }
+
+    if (after && after->getType() == ltMPTOKEN_ISSUANCE)
+    {
+        uint192 const id = getMptID(after);
+        auto& change = changes_[id];
+
+        bool const hasCOA = after->isFieldPresent(sfConfidentialOutstandingAmount);
+        std::uint64_t const coa = (*after)[~sfConfidentialOutstandingAmount].value_or(0);
+        std::uint64_t const oa = after->getFieldU64(sfOutstandingAmount);
+
+        if (hasCOA)
+            change.coaDelta = addMPTAmountDelta(change.coaDelta, coa);
+
+        change.outstandingDelta = addMPTAmountDelta(change.outstandingDelta, oa);
+        change.issuance = after;
+
+        // COA <= OutstandingAmount
+        if (coa > oa)
+            change.badCOA = true;
+    }
+
+    if (before && after && before->getType() == ltMPTOKEN && after->getType() == ltMPTOKEN)
+    {
+        uint192 const id = getMptID(after);
+
+        // sfConfidentialBalanceVersion must change when spending changes
+        auto const spendingBefore = (*before)[~sfConfidentialBalanceSpending];
+        auto const spendingAfter = (*after)[~sfConfidentialBalanceSpending];
+        auto const versionBefore = (*before)[~sfConfidentialBalanceVersion];
+        auto const versionAfter = (*after)[~sfConfidentialBalanceVersion];
+
+        if (spendingBefore.has_value() && spendingBefore != spendingAfter)
+        {
+            if (versionBefore == versionAfter)
+                changes_[id].badVersion = true;
+        }
+    }
+}
+
+bool
+ValidConfidentialMPToken::finalize(
+    STTx const& tx,
+    TER const result,
+    XRPAmount const,
+    ReadView const& view,
+    beast::Journal const& j)
+{
+    if (result != tesSUCCESS)
+        return true;
+
+    for (auto const& [id, checks] : changes_)
+    {
+        // Find the MPTokenIssuance
+        auto const issuance = [&]() -> std::shared_ptr {
+            if (checks.issuance)
+                return checks.issuance;
+            return view.read(keylet::mptokenIssuance(id));
+        }();
+
+        // Skip all invariance checks if issuance doesn't exist because that means the MPT has been
+        // deleted
+        if (!issuance)
+            continue;
+
+        // Cannot delete MPToken with non-zero confidential state
+        if (checks.deletedWithEncrypted)
+        {
+            if ((*issuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0)
+            {
+                JLOG(j.fatal())
+                    << "Invariant failed: MPToken deleted with encrypted fields while COA > 0";
+                return false;
+            }
+        }
+
+        // Encrypted field existence consistency
+        if (checks.badConsistency)
+        {
+            JLOG(j.fatal()) << "Invariant failed: MPToken encrypted field "
+                               "existence inconsistency";
+            return false;
+        }
+
+        // COA <= OutstandingAmount
+        if (checks.badCOA)
+        {
+            JLOG(j.fatal()) << "Invariant failed: Confidential outstanding amount "
+                               "exceeds total outstanding amount";
+            return false;
+        }
+
+        // Confidential balance fields may remain on a holder MPToken after all
+        // confidential balances have returned to zero. Only creating or
+        // changing those fields requires the issuance privacy flag.
+        if (checks.changesConfidentialFields)
+        {
+            if (!issuance->isFlag(lsfMPTCanHoldConfidentialBalance))
+            {
+                JLOG(j.fatal()) << "Invariant failed: MPToken has encrypted "
+                                   "fields but Issuance does not have "
+                                   "lsfMPTCanHoldConfidentialBalance set";
+                return false;
+            }
+        }
+
+        // We only enforce this when Confidential Outstanding Amount changes (Convert, ConvertBack,
+        // ConfidentialClawback). This avoids falsely failing on Escrow or AMM operations that lock
+        // public tokens outside of ltMPTOKEN. Convert / ConvertBack:
+        // - COA and MPTAmount must have opposite deltas, which cancel each other out to zero.
+        // - OA remains unchanged.
+        // - Therefore, the net delta on both sides of the equation is zero.
+        //
+        // Clawback:
+        // - MPTAmount remains unchanged.
+        // - COA and OA must have identical deltas (mirrored on each side).
+        // - The equation remains balanced as both sides have equal offsets.
+        if (checks.coaDelta != 0)
+        {
+            if (checks.mptAmountDelta + checks.coaDelta != checks.outstandingDelta)
+            {
+                JLOG(j.fatal()) << "Invariant failed: Token conservation "
+                                   "violation for MPT "
+                                << to_string(id);
+                return false;
+            }
+        }
+        else if (
+            std::ranges::find(kConfidentialMptTxTypes, tx.getTxnType()) !=
+            kConfidentialMptTxTypes.end())
+        {
+            // Confidential Txns should not modify public MPTAmount balance
+            // if Confidential Amount Delta is 0
+            if (checks.mptAmountDelta != 0)
+            {
+                JLOG(j.fatal()) << "Invariant failed: MPTAmount changed by confidential "
+                                   "transaction that should not modify this field."
+                                << to_string(id);
+                return false;
+            }
+
+            // Among confidential MPT transactions, only ConfidentialMPTSend and
+            // ConfidentialMPTMergeInbox leave coaDelta unmodified. Therefore, if a confidential MPT
+            // transaction reaches here, it must be one of these two types, neither of which will
+            // modify sfOutstandingAmount
+            if (checks.outstandingDelta != 0)
+            {
+                JLOG(j.fatal()) << "Invariant failed: OutstandingAmount changed "
+                                   "by confidential transaction that should not "
+                                   "modify it for MPT "
+                                << to_string(id);
+                return false;
+            }
+        }
+
+        if (checks.badVersion)
+        {
+            JLOG(j.fatal())
+                << "Invariant failed: MPToken sfConfidentialBalanceVersion not updated when "
+                   "sfConfidentialBalanceSpending changed";
+            return false;
+        }
+    }
+
+    return true;
+}
+
+void
+ValidMPTTransfer::visitEntry(
+    bool isDelete,
+    std::shared_ptr const& before,
+    std::shared_ptr const& after)
+{
+    // Record the before/after MPTAmount for each (issuanceID, account) pair
+    // so finalize() can determine whether a transfer actually occurred.
+    auto update = [&](SLE const& sle, bool isBefore) {
+        if (sle.getType() == ltMPTOKEN)
+        {
+            auto const issuanceID = sle[sfMPTokenIssuanceID];
+            auto const account = sle[sfAccount];
+            auto const amount = sle[sfMPTAmount];
+            if (isBefore)
+            {
+                amount_[issuanceID][account].amtBefore = amount;
+            }
+            else
+            {
+                amount_[issuanceID][account].amtAfter = amount;
+            }
+            if (isDelete && isBefore)
+            {
+                deletedAuthorized_[sle.key()] = sle.isFlag(lsfMPTAuthorized);
+            }
+        }
+    };
+
+    if (before)
+        update(*before, true);
+
+    if (after)
+        update(*after, false);
+}
+
+bool
+ValidMPTTransfer::isAuthorized(
+    ReadView const& view,
+    MPTID const& mptid,
+    AccountID const& holder,
+    bool reqAuth) const
+{
+    // Pseudo-accounts (Vault, LoanBroker, AMM) hold assets on behalf of their
+    // participants and are implicitly authorized for any MPT they hold,
+    // including vault shares whose underlying asset would otherwise require
+    // auth.  Exempt them here rather than relying on requireAuth: the recursive
+    // 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}))
+        return true;
+
+    auto const key = keylet::mptoken(mptid, holder);
+    auto const it = deletedAuthorized_.find(key.key);
+    if (it != deletedAuthorized_.end())
+        return !reqAuth || it->second;
+    return isTesSuccess(requireAuth(view, MPTIssue{mptid}, holder));
+}
+
+bool
+ValidMPTTransfer::finalize(
+    STTx const& tx,
+    TER const,
+    XRPAmount const,
+    ReadView const& view,
+    beast::Journal const& j)
+{
+    auto const fix330Enabled = view.rules().enabled(fixCleanup3_3_0);
+
+    if (hasPrivilege(tx, OverrideFreeze))
+        return true;
+
+    // DEX transactions (AMM[Create,Deposit], cross-currency payments, offer creates) are
+    // subject to the MPTCanTrade flag in addition to the standard transfer rules.
+    // A payment is only DEX if it is a cross-currency payment.
+    auto const txnType = tx.getTxnType();
+    auto const isDEX = [&] {
+        if (txnType == ttPAYMENT)
+        {
+            // A payment is cross-currency (and thus DEX) only if SendMax is present
+            // and its asset differs from the destination asset.
+            auto const amount = tx[sfAmount];
+            return tx[~sfSendMax].value_or(amount).asset() != amount.asset();
+        }
+        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);
+
+    for (auto const& [mptID, values] : amount_)
+    {
+        std::uint16_t senders = 0;
+        std::uint16_t receivers = 0;
+        bool invalidTransfer = false;
+        auto const sleIssuance = view.read(keylet::mptokenIssuance(mptID));
+        if (!sleIssuance)
+        {
+            continue;
+        }
+
+        // These transactions are recovery/settlement paths. They may move an
+        // existing MPT position even after the issuer clears CanTransfer, so
+        // holders are not trapped in AMM, vault, or loan protocol accounts.
+        auto const waivesCanTransfer = txnType == ttAMM_WITHDRAW ||
+            (view.rules().enabled(fixCleanup3_2_0) &&
+             (txnType == ttVAULT_WITHDRAW || txnType == ttLOAN_BROKER_COVER_WITHDRAW ||
+              txnType == ttLOAN_PAY));
+        auto const canTransfer = sleIssuance->isFlag(lsfMPTCanTransfer) || waivesCanTransfer;
+        auto const canTrade = sleIssuance->isFlag(lsfMPTCanTrade);
+        auto const reqAuth = sleIssuance->isFlag(lsfMPTRequireAuth);
+
+        for (auto const& [account, value] : values)
+        {
+            // Classify each account as a sender or receiver based on whether their MPTAmount
+            // decreased or increased. Count new MPToken holders (no amtBefore) as receivers.
+            // Skip deleted MPToken holders (amtAfter is nullopt); deletion requires zero balance.
+            if (value.amtAfter.has_value() && value.amtBefore.value_or(0) != *value.amtAfter)
+            {
+                if (!value.amtBefore.has_value() || *value.amtAfter > *value.amtBefore)
+                {
+                    ++receivers;
+                }
+                else
+                {
+                    ++senders;
+                }
+
+                // Check once: if any involved account is frozen, the whole issuance transfer is
+                // considered frozen. Only need to check for frozen if there is a transfer of funds.
+                //
+                // Post-fix330: full isFrozen() applies — vault-share transitive freeze is part of
+                // the freeze semantics for all changed holders.
+                //
+                // Pre-fix330: legacy AMM withdraw only checked individual freeze on the
+                // destination, not the transitive vault freeze.  All other paths (and the AMM
+                // account itself as sender) did apply the full check.
+                MPTIssue const issue{mptID};
+                auto const legacyAccountFrozen = [&] {
+                    if (isGlobalFrozen(view, issue) || isIndividualFrozen(view, account, issue))
+                        return true;
+                    bool const isReceiver =
+                        !value.amtBefore.has_value() || *value.amtAfter > *value.amtBefore;
+                    if (txnType == ttAMM_WITHDRAW && isReceiver)
+                        return false;
+                    return isVaultPseudoAccountFrozen(view, account, issue, 0);
+                };
+                bool const accountFrozen =
+                    fix330Enabled ? isFrozen(view, account, issue) : legacyAccountFrozen();
+                if (!invalidTransfer &&
+                    (accountFrozen || !isAuthorized(view, mptID, account, reqAuth)))
+                {
+                    invalidTransfer = true;
+                }
+            }
+        }
+        // A transfer between holders has occurred (senders > 0 && receivers > 0).
+        // Fail if the issuance is frozen, does not permit transfers, or — for
+        // DEX transactions — does not permit trading.
+        if ((invalidTransfer || !canTransfer || (isDEX && !canTrade)) && senders > 0 &&
+            receivers > 0)
+        {
+            JLOG(j.fatal()) << "Invariant failed: invalid MPToken transfer between holders";
+            return invariantPasses;
+        }
+    }
+
+    return true;
+}
+
 }  // namespace xrpl
diff --git a/src/libxrpl/tx/invariants/NFTInvariant.cpp b/src/libxrpl/tx/invariants/NFTInvariant.cpp
index 09ae9db925..52ecbcd9d1 100644
--- a/src/libxrpl/tx/invariants/NFTInvariant.cpp
+++ b/src/libxrpl/tx/invariants/NFTInvariant.cpp
@@ -20,16 +20,12 @@
 #include 
 
 #include 
-#include 
 #include 
 
 namespace xrpl {
 
 void
-ValidNFTokenPage::visitEntry(
-    bool isDelete,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+ValidNFTokenPage::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
     static constexpr uint256 const& kPageBits = nft::kPageMask;
     static constexpr uint256 kAccountBits = ~kPageBits;
@@ -38,7 +34,7 @@ ValidNFTokenPage::visitEntry(
         (after && after->getType() != ltNFTOKEN_PAGE))
         return;
 
-    auto check = [this, isDelete](std::shared_ptr const& sle) {
+    auto check = [this, isDelete](SLE::const_ref sle) {
         uint256 const account = sle->key() & kAccountBits;
         uint256 const hiLimit = sle->key() & kPageBits;
         std::optional const prev = (*sle)[~sfPreviousPageMin];
@@ -187,10 +183,7 @@ ValidNFTokenPage::finalize(
 
 //------------------------------------------------------------------------------
 void
-NFTokenCountTracking::visitEntry(
-    bool,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+NFTokenCountTracking::visitEntry(bool, SLE::const_ref before, SLE::const_ref after)
 {
     if (before && before->getType() == ltACCOUNT_ROOT)
     {
diff --git a/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp b/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp
index b480b90a31..1014642b36 100644
--- a/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp
+++ b/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp
@@ -14,15 +14,10 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 void
-ValidPermissionedDEX::visitEntry(
-    bool,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+ValidPermissionedDEX::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
     if (after && after->getType() == ltDIR_NODE)
     {
@@ -38,7 +33,9 @@ ValidPermissionedDEX::visitEntry(
         }
         else
         {
-            regularOffers_ = true;
+            regularOffersOld_ = true;
+            if (!isDelete)
+                regularOffers_ = true;
         }
 
         // pre-fixCleanup3_1_3: hybrid offer missing domain, missing
@@ -100,7 +97,9 @@ ValidPermissionedDEX::finalize(
         }
     }
 
-    if (regularOffers_)
+    bool const hasRegularOffers =
+        view.rules().enabled(fixCleanup3_2_0) ? regularOffers_ : regularOffersOld_;
+    if (hasRegularOffers)
     {
         JLOG(j.fatal()) << "Invariant failed: domain transaction"
                            " affected regular offers";
diff --git a/src/libxrpl/tx/invariants/PermissionedDomainInvariant.cpp b/src/libxrpl/tx/invariants/PermissionedDomainInvariant.cpp
index 93c1b2058e..544a3af2dc 100644
--- a/src/libxrpl/tx/invariants/PermissionedDomainInvariant.cpp
+++ b/src/libxrpl/tx/invariants/PermissionedDomainInvariant.cpp
@@ -15,23 +15,19 @@
 #include 
 #include 
 
-#include 
 #include 
 
 namespace xrpl {
 
 void
-ValidPermissionedDomain::visitEntry(
-    bool isDel,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+ValidPermissionedDomain::visitEntry(bool isDel, SLE::const_ref before, SLE::const_ref after)
 {
     if (before && before->getType() != ltPERMISSIONED_DOMAIN)
         return;
     if (after && after->getType() != ltPERMISSIONED_DOMAIN)
         return;
 
-    auto check = [isDel](std::vector& sleStatus, std::shared_ptr const& sle) {
+    auto check = [isDel](std::vector& sleStatus, SLE::const_ref sle) {
         auto const& credentials = sle->getFieldArray(sfAcceptedCredentials);
         auto const sorted = credentials::makeSorted(credentials);
 
diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp
index 2947be3bd4..84814977db 100644
--- a/src/libxrpl/tx/invariants/VaultInvariant.cpp
+++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp
@@ -23,7 +23,6 @@
 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -63,10 +62,7 @@ ValidVault::Shares::make(SLE const& from)
 }
 
 void
-ValidVault::visitEntry(
-    bool isDelete,
-    std::shared_ptr const& before,
-    std::shared_ptr const& after)
+ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
     // If `before` is empty, this means an object is being created, in which
     // case `isDelete` must be false. Otherwise `before` and `after` are set and
@@ -186,6 +182,101 @@ ValidVault::visitEntry(
     }
 }
 
+std::optional
+ValidVault::deltaAssets(AccountID const& id) const
+{
+    auto const& vaultAsset = afterVault_[0].asset;
+    auto const lookup = [&](uint256 const& key) -> std::optional {
+        auto const it = deltas_.find(key);
+        if (it == deltas_.end())
+            return std::nullopt;
+        return it->second;
+    };
+
+    return std::visit(
+        [&](TIss const& issue) -> std::optional {
+            if constexpr (std::is_same_v)
+            {
+                if (isXRP(issue))
+                    return lookup(keylet::account(id).key);
+                auto result = lookup(keylet::trustLine(id, issue).key);
+                // Trust-line balance is stored from the low-account's perspective;
+                // negate if id is the high account so the delta is in id's terms.
+                if (result && id > issue.getIssuer())
+                    result->delta = -result->delta;
+                return result;
+            }
+            else if constexpr (std::is_same_v)
+            {
+                return lookup(keylet::mptoken(issue.getMptID(), id).key);
+            }
+        },
+        vaultAsset.value());
+}
+
+std::optional
+ValidVault::deltaAssetsTxAccount(STTx const& tx, XRPAmount fee) const
+{
+    auto const& vaultAsset = afterVault_[0].asset;
+    auto ret = deltaAssets(tx[sfAccount]);
+    if (!ret.has_value() || !vaultAsset.native())
+        return ret;
+
+    if (auto const delegate = tx[~sfDelegate]; delegate.has_value() && *delegate != tx[sfAccount])
+        return ret;
+
+    ret->delta += fee.drops();
+    if (ret->delta == kZero)
+        return std::nullopt;
+
+    return ret;
+}
+
+std::optional
+ValidVault::deltaShares(AccountID const& id) const
+{
+    auto const& afterVault = afterVault_[0];
+    auto const it = [&]() {
+        if (id == afterVault.pseudoId)
+            return deltas_.find(keylet::mptokenIssuance(afterVault.shareMPTID).key);
+        return deltas_.find(keylet::mptoken(afterVault.shareMPTID, id).key);
+    }();
+
+    return it != deltas_.end() ? std::optional(it->second) : std::nullopt;
+}
+
+bool
+ValidVault::isVaultEmpty(Vault const& vault)
+{
+    return vault.assetsAvailable == 0 && vault.assetsTotal == 0;
+}
+
+std::int32_t
+ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const
+{
+    // Returns the posterior `assetsTotal` scale.
+    //
+    // 1. Because STAmounts are normalized, `assetsTotal` (being >= `assetsAvailable`)
+    // safely represents the coarsest exponent needed for both fields.
+    //
+    // 2. The scale may decrease (withdraw/clawback) or increase (deposit). In both cases
+    // we ensure the vault is in a legitimate state in the post-transaction scale.
+    auto const& afterVault = afterVault_[0];
+    auto const& vaultAsset = afterVault.asset;
+    if (rules.enabled(fixCleanup3_2_0))
+    {
+        NumberRoundModeGuard const roundGuard(Number::RoundingMode::ToNearest);
+        return scale(afterVault.assetsTotal, vaultAsset);
+    }
+
+    auto const& beforeVault = beforeVault_[0];
+    auto const totalDelta =
+        DeltaInfo::makeDelta(beforeVault.assetsTotal, afterVault.assetsTotal, vaultAsset);
+    auto const availableDelta =
+        DeltaInfo::makeDelta(beforeVault.assetsAvailable, afterVault.assetsAvailable, vaultAsset);
+    return computeCoarsestScale({vaultDelta, totalDelta, availableDelta});
+}
+
 bool
 ValidVault::finalize(
     STTx const& tx,
@@ -320,7 +411,7 @@ ValidVault::finalize(
                 return e;
         }
 
-        auto const sleShares = view.read(keylet::mptIssuance(afterVault.shareMPTID));
+        auto const sleShares = view.read(keylet::mptokenIssuance(afterVault.shareMPTID));
 
         return sleShares ? std::optional(Shares::make(*sleShares)) : std::nullopt;
     }();
@@ -445,61 +536,6 @@ ValidVault::finalize(
     }
 
     auto const& vaultAsset = afterVault.asset;
-    auto const deltaAssets = [&](AccountID const& id) -> std::optional {
-        auto const get =  //
-            [&](auto const& it, std::int8_t sign = 1) -> std::optional {
-            if (it == deltas_.end())
-                return std::nullopt;
-
-            return DeltaInfo{it->second.delta * sign, it->second.scale};
-        };
-
-        return std::visit(
-            [&](TIss const& issue) {
-                if constexpr (std::is_same_v)
-                {
-                    if (isXRP(issue))
-                        return get(deltas_.find(keylet::account(id).key));
-                    return get(
-                        deltas_.find(keylet::line(id, issue).key), id > issue.getIssuer() ? -1 : 1);
-                }
-                else if constexpr (std::is_same_v)
-                {
-                    return get(deltas_.find(keylet::mptoken(issue.getMptID(), id).key));
-                }
-            },
-            vaultAsset.value());
-    };
-    auto const deltaAssetsTxAccount = [&]() -> std::optional {
-        auto ret = deltaAssets(tx[sfAccount]);
-        // Nothing returned or not XRP transaction
-        if (!ret.has_value() || !vaultAsset.native())
-            return ret;
-
-        // Delegated transaction; no need to compensate for fees
-        if (auto const delegate = tx[~sfDelegate];
-            delegate.has_value() && *delegate != tx[sfAccount])
-            return ret;
-
-        ret->delta += fee.drops();
-        if (ret->delta == kZero)
-            return std::nullopt;
-
-        return ret;
-    };
-    auto const deltaShares = [&](AccountID const& id) -> std::optional {
-        auto const it = [&]() {
-            if (id == afterVault.pseudoId)
-                return deltas_.find(keylet::mptIssuance(afterVault.shareMPTID).key);
-            return deltas_.find(keylet::mptoken(afterVault.shareMPTID, id).key);
-        }();
-
-        return it != deltas_.end() ? std::optional(it->second) : std::nullopt;
-    };
-
-    auto const vaultHoldsNoAssets = [&](Vault const& vault) {
-        return vault.assetsAvailable == 0 && vault.assetsTotal == 0;
-    };
 
     // Technically this does not need to be a lambda, but it's more
     // convenient thanks to early "return false"; the not-so-nice
@@ -629,16 +665,8 @@ ValidVault::finalize(
                     return false;  // That's all we can do
                 }
 
-                // Get the coarsest scale to round calculations to
-                auto const totalDelta = DeltaInfo::makeDelta(
-                    beforeVault.assetsTotal, afterVault.assetsTotal, vaultAsset);
-                auto const availableDelta = DeltaInfo::makeDelta(
-                    beforeVault.assetsAvailable, afterVault.assetsAvailable, vaultAsset);
-                auto const minScale = computeCoarsestScale({
-                    *maybeVaultDeltaAssets,
-                    totalDelta,
-                    availableDelta,
-                });
+                // Get the posterior scale to round calculations to
+                auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
 
                 auto const vaultDeltaAssets =
                     roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
@@ -669,12 +697,11 @@ ValidVault::finalize(
 
                 if (!issuerDeposit)
                 {
-                    auto const maybeAccDeltaAssets = deltaAssetsTxAccount();
+                    auto const maybeAccDeltaAssets = deltaAssetsTxAccount(tx, fee);
                     if (!maybeAccDeltaAssets)
                     {
-                        JLOG(j.fatal()) <<  //
-                            "Invariant failed: deposit must change depositor "
-                            "balance";
+                        JLOG(j.fatal())
+                            << "Invariant failed: deposit must change depositor balance";
                         return false;
                     }
                     auto const localMinScale =
@@ -685,19 +712,20 @@ ValidVault::finalize(
                     auto const localVaultDeltaAssets =
                         roundToAsset(vaultAsset, vaultDeltaAssets, localMinScale);
 
+                    // For IOUs, if the deposit amount is not-representable at depositor trustline
+                    // scale deposit amount could round to zero, giving depositor shares for no
+                    // assets. Unlike withdrawal, we do not allow that.
                     if (accountDeltaAssets >= kZero)
                     {
-                        JLOG(j.fatal()) <<  //
-                            "Invariant failed: deposit must decrease depositor "
-                            "balance";
+                        JLOG(j.fatal())
+                            << "Invariant failed: deposit must decrease depositor balance";
                         result = false;
                     }
 
                     if (localVaultDeltaAssets * -1 != accountDeltaAssets)
                     {
-                        JLOG(j.fatal()) <<  //
-                            "Invariant failed: deposit must change vault and "
-                            "depositor balance by equal amount";
+                        JLOG(j.fatal()) << "Invariant failed: " <<  //
+                            "deposit must change vault and depositor balance by equal amount";
                         result = false;
                     }
                 }
@@ -705,45 +733,38 @@ ValidVault::finalize(
                 if (afterVault.assetsMaximum > kZero &&
                     afterVault.assetsTotal > afterVault.assetsMaximum)
                 {
-                    JLOG(j.fatal()) <<  //
-                        "Invariant failed: deposit assets outstanding must not "
-                        "exceed assets maximum";
+                    JLOG(j.fatal()) << "Invariant failed: " <<  //
+                        "deposit assets outstanding must not exceed assets maximum";
                     result = false;
                 }
 
                 auto const maybeAccDeltaShares = deltaShares(tx[sfAccount]);
                 if (!maybeAccDeltaShares)
                 {
-                    JLOG(j.fatal()) <<  //
-                        "Invariant failed: deposit must change depositor "
-                        "shares";
+                    JLOG(j.fatal()) << "Invariant failed: deposit must change depositor shares";
                     return false;  // That's all we can do
                 }
-                // We don't need to round shares, they are integral MPT
+                // We don't round shares, they are integral MPT
                 auto const& accountDeltaShares = *maybeAccDeltaShares;
                 if (accountDeltaShares.delta <= kZero)
                 {
-                    JLOG(j.fatal()) <<  //
-                        "Invariant failed: deposit must increase depositor "
-                        "shares";
+                    JLOG(j.fatal()) << "Invariant failed: deposit must increase depositor shares";
                     result = false;
                 }
 
                 auto const maybeVaultDeltaShares = deltaShares(afterVault.pseudoId);
                 if (!maybeVaultDeltaShares || maybeVaultDeltaShares->delta == kZero)
                 {
-                    JLOG(j.fatal()) <<  //
-                        "Invariant failed: deposit must change vault shares";
+                    JLOG(j.fatal()) << "Invariant failed: deposit must change vault shares";
                     return false;  // That's all we can do
                 }
 
-                // We don't need to round shares, they are integral MPT
+                // We don't round shares, they are integral MPT
                 auto const& vaultDeltaShares = *maybeVaultDeltaShares;
                 if (vaultDeltaShares.delta * -1 != accountDeltaShares.delta)
                 {
-                    JLOG(j.fatal()) <<  //
-                        "Invariant failed: deposit must change depositor and "
-                        "vault shares by equal amount";
+                    JLOG(j.fatal()) << "Invariant failed: " <<  //
+                        "deposit must change depositor and vault shares by equal amount";
                     result = false;
                 }
 
@@ -751,8 +772,8 @@ ValidVault::finalize(
                     vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
                 if (assetTotalDelta != vaultDeltaAssets)
                 {
-                    JLOG(j.fatal()) << "Invariant failed: deposit and assets "
-                                       "outstanding must add up";
+                    JLOG(j.fatal())
+                        << "Invariant failed: deposit and assets outstanding must add up";
                     result = false;
                 }
 
@@ -760,8 +781,7 @@ ValidVault::finalize(
                     vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
                 if (assetAvailableDelta != vaultDeltaAssets)
                 {
-                    JLOG(j.fatal()) << "Invariant failed: deposit and assets "
-                                       "available must add up";
+                    JLOG(j.fatal()) << "Invariant failed: deposit and assets available must add up";
                     result = false;
                 }
 
@@ -772,34 +792,25 @@ ValidVault::finalize(
 
                 XRPL_ASSERT(
                     !beforeVault_.empty(),
-                    "xrpl::ValidVault::finalize : withdrawal updated a "
-                    "vault");
+                    "xrpl::ValidVault::finalize : withdrawal updated a vault");
                 auto const& beforeVault = beforeVault_[0];
 
                 auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
-
                 if (!maybeVaultDeltaAssets)
                 {
-                    JLOG(j.fatal()) << "Invariant failed: withdrawal must "
-                                       "change vault balance";
+                    JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault balance";
                     return false;  // That's all we can do
                 }
 
-                // Get the most coarse scale to round calculations to
-                auto const totalDelta = DeltaInfo::makeDelta(
-                    beforeVault.assetsTotal, afterVault.assetsTotal, vaultAsset);
-                auto const availableDelta = DeltaInfo::makeDelta(
-                    beforeVault.assetsAvailable, afterVault.assetsAvailable, vaultAsset);
-                auto const minScale =
-                    computeCoarsestScale({*maybeVaultDeltaAssets, totalDelta, availableDelta});
+                // Get the posterior scale to round calculations to
+                auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
 
                 auto const vaultPseudoDeltaAssets =
                     roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
 
                 if (vaultPseudoDeltaAssets >= kZero)
                 {
-                    JLOG(j.fatal()) << "Invariant failed: withdrawal must "
-                                       "decrease vault balance";
+                    JLOG(j.fatal()) << "Invariant failed: withdrawal must decrease vault balance";
                     result = false;
                 }
 
@@ -814,7 +825,7 @@ ValidVault::finalize(
 
                 if (!issuerWithdrawal)
                 {
-                    auto const maybeAccDelta = deltaAssetsTxAccount();
+                    auto const maybeAccDelta = deltaAssetsTxAccount(tx, fee);
                     auto const maybeOtherAccDelta = [&]() -> std::optional {
                         if (auto const destination = tx[~sfDestination];
                             destination && *destination != tx[sfAccount])
@@ -825,8 +836,7 @@ ValidVault::finalize(
                     if (maybeAccDelta.has_value() == maybeOtherAccDelta.has_value())
                     {
                         JLOG(j.fatal()) <<  //
-                            "Invariant failed: withdrawal must change one "
-                            "destination balance";
+                            "Invariant failed: withdrawal must change one destination balance";
                         return false;
                     }
 
@@ -835,63 +845,83 @@ ValidVault::finalize(
 
                     // the scale of destinationDelta can be coarser than
                     // minScale, so we take that into account when rounding
-                    auto const localMinScale =
-                        std::max(minScale, computeCoarsestScale({destinationDelta}));
+                    auto const destinationScale = computeCoarsestScale({destinationDelta});
+                    auto const localMinScale = std::max(minScale, destinationScale);
 
                     auto const roundedDestinationDelta =
                         roundToAsset(vaultAsset, destinationDelta.delta, localMinScale);
 
-                    if (roundedDestinationDelta <= kZero)
+                    // Post-fixCleanup3_2_0: Tolerate zero-rounded destination deltas for IOUs only.
+                    // If the receiver's trust line sits at a coarser scale, the inflow may
+                    // safely round down to zero.
+                    //
+                    // XRP and MPT remain strict. Because they are integer-exact, a zero
+                    // destination delta indicates a true accounting bug, not a rounding artifact.
+                    bool const tolerateZeroDelta =
+                        view.rules().enabled(fixCleanup3_2_0) && !vaultAsset.integral();
+                    auto const invalidBalanceChange = tolerateZeroDelta
+                        ? roundedDestinationDelta < kZero
+                        : roundedDestinationDelta <= kZero;
+                    if (invalidBalanceChange)
                     {
                         JLOG(j.fatal()) <<  //
-                            "Invariant failed: withdrawal must increase "
-                            "destination balance";
+                            "Invariant failed: withdrawal must increase destination balance";
                         result = false;
                     }
 
                     auto const localPseudoDeltaAssets =
                         roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
-                    if (localPseudoDeltaAssets * -1 != roundedDestinationDelta)
+                    // For IOU assets near a precision boundary the destination's STAmount
+                    // exponent can shift, making part of the sent value unrepresentable at the
+                    // receiver's new scale — that portion is irreversibly absorbed by the IOU
+                    // rail.  Tolerate the mismatch only when the destroyed amount (vault outflow
+                    // minus destination inflow, in Number space) is itself sub-ULP at the
+                    // destination's scale.  Floor rounding is used so that values exactly at the
+                    // step boundary are not mistakenly dismissed.  Any representable discrepancy
+                    // indicates a real accounting bug and must be caught.
+                    auto const destroyedIsSubUlp = tolerateZeroDelta &&
+                        roundToAsset(
+                            vaultAsset,
+                            maybeVaultDeltaAssets->delta * -1 - destinationDelta.delta,
+                            destinationScale,
+                            Number::RoundingMode::Downward) == kZero;
+                    if (!destroyedIsSubUlp &&
+                        localPseudoDeltaAssets * -1 != roundedDestinationDelta)
                     {
-                        JLOG(j.fatal()) <<  //
-                            "Invariant failed: withdrawal must change vault "
-                            "and destination balance by equal amount";
+                        JLOG(j.fatal()) << "Invariant failed: " <<  //
+                            "withdrawal must change vault and destination balance by equal "
+                            "amount";
                         result = false;
                     }
                 }
 
-                // We don't need to round shares, they are integral MPT
+                // We don't round shares, they are integral MPT
                 auto const accountDeltaShares = deltaShares(tx[sfAccount]);
                 if (!accountDeltaShares)
                 {
-                    JLOG(j.fatal()) <<  //
-                        "Invariant failed: withdrawal must change depositor "
-                        "shares";
+                    JLOG(j.fatal()) << "Invariant failed: withdrawal must change depositor shares";
                     return false;
                 }
 
                 if (accountDeltaShares->delta >= kZero)
                 {
-                    JLOG(j.fatal()) <<  //
-                        "Invariant failed: withdrawal must decrease depositor "
-                        "shares";
+                    JLOG(j.fatal())
+                        << "Invariant failed: withdrawal must decrease depositor shares";
                     result = false;
                 }
 
-                // We don't need to round shares, they are integral MPT
+                // We don't round shares, they are integral MPT
                 auto const vaultDeltaShares = deltaShares(afterVault.pseudoId);
                 if (!vaultDeltaShares || vaultDeltaShares->delta == kZero)
                 {
-                    JLOG(j.fatal()) <<  //
-                        "Invariant failed: withdrawal must change vault shares";
+                    JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault shares";
                     return false;  // That's all we can do
                 }
 
                 if (vaultDeltaShares->delta * -1 != accountDeltaShares->delta)
                 {
-                    JLOG(j.fatal()) <<  //
-                        "Invariant failed: withdrawal must change depositor "
-                        "and vault shares by equal amount";
+                    JLOG(j.fatal()) << "Invariant failed: " <<  //
+                        "withdrawal must change depositor and vault shares by equal amount";
                     result = false;
                 }
 
@@ -900,8 +930,8 @@ ValidVault::finalize(
                 // Note, vaultBalance is negative (see check above)
                 if (assetTotalDelta != vaultPseudoDeltaAssets)
                 {
-                    JLOG(j.fatal()) << "Invariant failed: withdrawal and "
-                                       "assets outstanding must add up";
+                    JLOG(j.fatal())
+                        << "Invariant failed: withdrawal and assets outstanding must add up";
                     result = false;
                 }
 
@@ -910,8 +940,8 @@ ValidVault::finalize(
 
                 if (assetAvailableDelta != vaultPseudoDeltaAssets)
                 {
-                    JLOG(j.fatal()) << "Invariant failed: withdrawal and "
-                                       "assets available must add up";
+                    JLOG(j.fatal())
+                        << "Invariant failed: withdrawal and assets available must add up";
                     result = false;
                 }
 
@@ -929,12 +959,11 @@ ValidVault::finalize(
                     // The owner can use clawback to force-burn shares when the
                     // vault is empty but there are outstanding shares
                     if (!(beforeShares && beforeShares->sharesTotal > 0 &&
-                          vaultHoldsNoAssets(beforeVault) && beforeVault.owner == tx[sfAccount]))
+                          isVaultEmpty(beforeVault) && beforeVault.owner == tx[sfAccount]))
                     {
-                        JLOG(j.fatal()) <<  //
-                            "Invariant failed: clawback may only be performed "
-                            "by the asset issuer, or by the vault owner of an "
-                            "empty vault";
+                        JLOG(j.fatal()) << "Invariant failed: " <<  //
+                            "clawback may only be performed by the asset issuer, or by the vault "
+                            "owner of an empty vault";
                         return false;  // That's all we can do
                     }
                 }
@@ -942,19 +971,13 @@ ValidVault::finalize(
                 auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
                 if (maybeVaultDeltaAssets)
                 {
-                    auto const totalDelta = DeltaInfo::makeDelta(
-                        beforeVault.assetsTotal, afterVault.assetsTotal, vaultAsset);
-                    auto const availableDelta = DeltaInfo::makeDelta(
-                        beforeVault.assetsAvailable, afterVault.assetsAvailable, vaultAsset);
                     auto const minScale =
-                        computeCoarsestScale({*maybeVaultDeltaAssets, totalDelta, availableDelta});
+                        computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
                     auto const vaultDeltaAssets =
                         roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
                     if (vaultDeltaAssets >= kZero)
                     {
-                        JLOG(j.fatal()) <<  //
-                            "Invariant failed: clawback must decrease vault "
-                            "balance";
+                        JLOG(j.fatal()) << "Invariant failed: clawback must decrease vault balance";
                         result = false;
                     }
 
@@ -963,8 +986,7 @@ ValidVault::finalize(
                     if (assetsTotalDelta != vaultDeltaAssets)
                     {
                         JLOG(j.fatal()) <<  //
-                            "Invariant failed: clawback and assets outstanding "
-                            "must add up";
+                            "Invariant failed: clawback and assets outstanding must add up";
                         result = false;
                     }
 
@@ -975,12 +997,11 @@ ValidVault::finalize(
                     if (assetAvailableDelta != vaultDeltaAssets)
                     {
                         JLOG(j.fatal()) <<  //
-                            "Invariant failed: clawback and assets available "
-                            "must add up";
+                            "Invariant failed: clawback and assets available must add up";
                         result = false;
                     }
                 }
-                else if (!vaultHoldsNoAssets(beforeVault))
+                else if (!isVaultEmpty(beforeVault))
                 {
                     JLOG(j.fatal()) <<  //
                         "Invariant failed: clawback must change vault balance";
@@ -998,8 +1019,7 @@ ValidVault::finalize(
                 if (maybeAccountDeltaShares->delta >= kZero)
                 {
                     JLOG(j.fatal()) <<  //
-                        "Invariant failed: clawback must decrease holder "
-                        "shares";
+                        "Invariant failed: clawback must decrease holder shares";
                     result = false;
                 }
 
@@ -1014,9 +1034,8 @@ ValidVault::finalize(
 
                 if (vaultDeltaShares->delta * -1 != maybeAccountDeltaShares->delta)
                 {
-                    JLOG(j.fatal()) <<  //
-                        "Invariant failed: clawback must change holder and "
-                        "vault shares by equal amount";
+                    JLOG(j.fatal()) << "Invariant failed: " <<  //
+                        "clawback must change holder and vault shares by equal amount";
                     result = false;
                 }
 
diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp
index 0391aa5f77..adb73f126e 100644
--- a/src/libxrpl/tx/paths/BookStep.cpp
+++ b/src/libxrpl/tx/paths/BookStep.cpp
@@ -905,6 +905,11 @@ BookStep::getAMMOffer(
     ReadView const& view,
     std::optional const& clobQuality) const
 {
+    // AMM doesn't support domain books. When fixCleanup3_3_0 is enabled, exclude
+    // AMM liquidity so quality estimation matches actual crossing (tryAMM skips
+    // AMM for domain books).
+    if (book_.domain && view.rules().enabled(fixCleanup3_3_0))
+        return std::nullopt;
     if (ammLiquidity_)
         return ammLiquidity_->getOffer(view, clobQuality);
     return std::nullopt;
@@ -1354,26 +1359,25 @@ BookStep::check(StrandContext const& ctx) const
 
             auto const err = book_.in.visit(
                 [&](Issue const& issue) -> std::optional {
-                    auto sle = view.read(keylet::line(*prev, cur, issue.currency));
+                    auto sle = view.read(keylet::trustLine(*prev, cur, issue.currency));
                     if (!sle)
                         return terNO_LINE;
                     if (sle->isFlag((cur > *prev) ? lsfHighNoRipple : lsfLowNoRipple))
                         return terNO_RIPPLE;
                     return std::nullopt;
                 },
-                [&](MPTIssue const& issue) -> std::optional {
-                    // Check if can trade on DEX.
-                    if (auto const ter = canTrade(view, book_.in); !isTesSuccess(ter))
-                        return ter;
-                    if (auto const ter = canTrade(view, book_.out); !isTesSuccess(ter))
-                        return ter;
-                    return std::nullopt;
-                });
+                [&](MPTIssue const& issue) -> std::optional { return std::nullopt; });
             if (err)
                 return *err;
         }
     }
 
+    // Check if the offer can be traded on DEX.
+    if (auto const ter = canTrade(ctx.view, book_.in); !isTesSuccess(ter))
+        return ter;
+    if (auto const ter = canTrade(ctx.view, book_.out); !isTesSuccess(ter))
+        return ter;
+
     return tesSUCCESS;
 }
 
@@ -1384,12 +1388,22 @@ BookStep::rate(
     Asset const& asset,
     AccountID const& dstAccount) const
 {
-    auto const& issuer = asset.getIssuer();
-    if (isXRP(issuer) || issuer == dstAccount)
-        return kParityRate;
     return asset.visit(
-        [&](Issue const&) { return transferRate(view, issuer); },
-        [&](MPTIssue const& issue) { return transferRate(view, issue.getMptID()); });
+        [&](Issue const& issue) -> Rate {
+            if (isXRP(issue.account) || issue.account == dstAccount)
+                return kParityRate;
+            return transferRate(view, issue.account);
+        },
+        [&](MPTIssue const& mptIssue) -> Rate {
+            // For MPT, parity applies only when this asset is the final strand
+            // delivery AND the destination is the MPT issuer (holder → issuer,
+            // which is fee-free). Using strandDst_ alone is wrong because it
+            // incorrectly suppresses the fee when MPT is an intermediate or
+            // the in-side of a book that precedes the issuer's XRP receipt.
+            if (asset == strandDeliver_ && mptIssue.getIssuer() == dstAccount)
+                return kParityRate;
+            return transferRate(view, mptIssue.getMptID());
+        });
 };
 
 template 
@@ -1463,8 +1477,8 @@ bookStepEqual(Step const& step, xrpl::Book const& book)
 {
     return std::visit(
         [&](TIn const&, TOut const&) {
-            using TIn_ = typename TIn::amount_type;
-            using TOut_ = typename TOut::amount_type;
+            using TIn_ = TIn::amount_type;
+            using TOut_ = TOut::amount_type;
 
             if constexpr (ValidTaker)
             {
diff --git a/src/libxrpl/tx/paths/BookTip.cpp b/src/libxrpl/tx/paths/BookTip.cpp
index 5be512aa84..eda11e5d72 100644
--- a/src/libxrpl/tx/paths/BookTip.cpp
+++ b/src/libxrpl/tx/paths/BookTip.cpp
@@ -8,8 +8,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 BookTip::BookTip(ApplyView& view, Book const& book)
@@ -40,7 +38,7 @@ BookTip::step(beast::Journal j)
             return false;
 
         unsigned int di = 0;
-        std::shared_ptr dir;
+        SLE::pointer dir;
 
         if (dirFirst(view_, *firstPage, dir, di, index_))
         {
diff --git a/src/libxrpl/tx/paths/DirectStep.cpp b/src/libxrpl/tx/paths/DirectStep.cpp
index a16c3afd6a..f8f12bd421 100644
--- a/src/libxrpl/tx/paths/DirectStep.cpp
+++ b/src/libxrpl/tx/paths/DirectStep.cpp
@@ -269,7 +269,7 @@ public:
     // Verify the consistency of the step.  These checks are specific to
     // payments and assume that general checks were already performed.
     [[nodiscard]] TER
-    check(StrandContext const& ctx, std::shared_ptr const& sleSrc) const;
+    check(StrandContext const& ctx, SLE::const_ref sleSrc) const;
 
     [[nodiscard]] std::string
     logString() const override
@@ -327,7 +327,7 @@ public:
     // Verify the consistency of the step.  These checks are specific to
     // offer crossing and assume that general checks were already performed.
     static TER
-    check(StrandContext const& ctx, std::shared_ptr const& sleSrc);
+    check(StrandContext const& ctx, SLE::const_ref sleSrc);
 
     [[nodiscard]] std::string
     logString() const override
@@ -344,7 +344,7 @@ DirectIPaymentStep::quality(ReadView const& sb, QualityDirection qDir) const
     if (src_ == dst_)
         return QUALITY_ONE;
 
-    auto const sle = sb.read(keylet::line(dst_, src_, currency_));
+    auto const sle = sb.read(keylet::trustLine(dst_, src_, currency_));
 
     if (!sle)
         return QUALITY_ONE;
@@ -415,12 +415,12 @@ DirectIOfferCrossingStep::maxFlow(ReadView const& sb, IOUAmount const& desired)
 }
 
 TER
-DirectIPaymentStep::check(StrandContext const& ctx, std::shared_ptr const& sleSrc) const
+DirectIPaymentStep::check(StrandContext const& ctx, SLE::const_ref sleSrc) const
 {
     // Since this is a payment a trust line must be present.  Perform all
     // trust line related checks.
     {
-        auto const sleLine = ctx.view.read(keylet::line(src_, dst_, currency_));
+        auto const sleLine = ctx.view.read(keylet::trustLine(src_, dst_, currency_));
         if (!sleLine)
         {
             JLOG(j_.trace()) << "DirectStepI: No credit line. " << *this;
@@ -463,7 +463,7 @@ DirectIPaymentStep::check(StrandContext const& ctx, std::shared_ptr c
 }
 
 TER
-DirectIOfferCrossingStep::check(StrandContext const&, std::shared_ptr const&)
+DirectIOfferCrossingStep::check(StrandContext const&, SLE::const_ref)
 {
     // The standard checks are all we can do because any remaining checks
     // require the existence of a trust line.  Offer crossing does not
diff --git a/src/libxrpl/tx/paths/Flow.cpp b/src/libxrpl/tx/paths/Flow.cpp
index 39a9e83e69..80e05f058c 100644
--- a/src/libxrpl/tx/paths/Flow.cpp
+++ b/src/libxrpl/tx/paths/Flow.cpp
@@ -10,7 +10,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -126,8 +125,8 @@ flow(
     // amount types.
     return std::visit(
         [&, &strands = strands](TIn const&, TOut const&) {
-            using TIn_ = typename TIn::amount_type;
-            using TOut_ = typename TOut::amount_type;
+            using TIn_ = TIn::amount_type;
+            using TOut_ = TOut::amount_type;
             return finishFlow(
                 sb,
                 srcAsset,
diff --git a/src/libxrpl/tx/paths/MPTEndpointStep.cpp b/src/libxrpl/tx/paths/MPTEndpointStep.cpp
index 65f8409379..a47cfa15a5 100644
--- a/src/libxrpl/tx/paths/MPTEndpointStep.cpp
+++ b/src/libxrpl/tx/paths/MPTEndpointStep.cpp
@@ -264,7 +264,7 @@ public:
     // Verify the consistency of the step.  These checks are specific to
     // payments and assume that general checks were already performed.
     [[nodiscard]] TER
-    check(StrandContext const& ctx, std::shared_ptr const& sleSrc) const;
+    check(StrandContext const& ctx, SLE::const_ref sleSrc) const;
 
     [[nodiscard]] std::string
     logString() const override
@@ -312,7 +312,7 @@ public:
     // Verify the consistency of the step.  These checks are specific to
     // offer crossing and assume that general checks were already performed.
     static TER
-    check(StrandContext const& ctx, std::shared_ptr const& sleSrc);
+    check(StrandContext const& ctx, SLE::const_ref sleSrc);
 
     [[nodiscard]] std::string
     logString() const override
@@ -328,8 +328,7 @@ public:
 //------------------------------------------------------------------------------
 
 TER
-MPTEndpointPaymentStep::check(StrandContext const& ctx, std::shared_ptr const& sleSrc)
-    const
+MPTEndpointPaymentStep::check(StrandContext const& ctx, SLE::const_ref sleSrc) const
 {
     // Since this is a payment, MPToken must be present.  Perform all
     // MPToken related checks.
@@ -393,8 +392,11 @@ MPTEndpointPaymentStep::check(StrandContext const& ctx, std::shared_ptr const&)
+MPTEndpointOfferCrossingStep::check(StrandContext const& ctx, SLE::const_ref)
 {
+    // The standard checks are all we can do because any remaining checks
+    // require the existence of a MPToken.  Offer crossing does not
+    // require a pre-existing MPToken.
     return tesSUCCESS;
 }
 
@@ -432,7 +434,7 @@ MPTEndpointStep::maxPaymentFlow(ReadView const& sb) const
         return {toAmount(maxFlow), DebtDirection::Redeems};
 
     // From an issuer to a holder
-    if (auto const sle = sb.read(keylet::mptIssuance(mptIssue_)))
+    if (auto const sle = sb.read(keylet::mptokenIssuance(mptIssue_)))
     {
         // If issuer is the source account, and it is direct payment then
         // MPTEndpointStep is the only step. Provide available maxFlow.
@@ -838,10 +840,17 @@ MPTEndpointStep::check(StrandContext const& ctx) const
     }
 
     // pure issue/redeem can't be frozen (issuer/holder)
+    // For the first step: check global freeze of the step's own asset.
+    // For the last step: check only the per-holder MPToken lock.
+    // Global freeze of the deliver asset is not checked here
+    // because MPT semantics allow issuer<->holder transfers even when globally
+    // locked — only holder-to-holder DEX paths are restricted.
     if (!(ctx.isLast && ctx.isFirst))
     {
         auto const& account = ctx.isFirst ? src_ : dst_;
-        if (isFrozen(ctx.view, account, mptIssue_))
+        bool const frozen = (ctx.isFirst && isGlobalFrozen(ctx.view, mptIssue_)) ||
+            isIndividualFrozen(ctx.view, account, mptIssue_);
+        if (frozen)
             return terLOCKED;
     }
 
diff --git a/src/libxrpl/tx/paths/OfferStream.cpp b/src/libxrpl/tx/paths/OfferStream.cpp
index bfe2bbd1bb..ecc8416a2b 100644
--- a/src/libxrpl/tx/paths/OfferStream.cpp
+++ b/src/libxrpl/tx/paths/OfferStream.cpp
@@ -17,6 +17,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -27,7 +28,6 @@
 #include 
 
 #include 
-#include 
 #include 
 
 namespace xrpl {
@@ -205,7 +205,7 @@ TOfferStreamBase::step()
         if (!tip_.step(j_))
             return false;
 
-        std::shared_ptr const entry = tip_.entry();
+        SLE::pointer const entry = tip_.entry();
 
         // If we exceed the maximum number of allowed steps, we're done.
         if (!counter_.step())
@@ -250,7 +250,13 @@ TOfferStreamBase::step()
             continue;
         }
 
-        if (entry->isFieldPresent(sfDomainID) &&
+        // Pre-fixCleanup3_3_0: validate domain membership for any book.
+        // Post-fixCleanup3_3_0: only validate when walking a domain book.
+        // Hybrid offers carry sfDomainID but also participate in the open
+        // book; expiry of the owner's domain credential should not evict
+        // the offer from the open book.
+        if ((!view_.rules().enabled(fixCleanup3_3_0) || book_.domain.has_value()) &&
+            entry->isFieldPresent(sfDomainID) &&
             !permissioned_dex::offerInDomain(
                 view_, entry->key(), entry->getFieldH256(sfDomainID), j_))
         {
diff --git a/src/libxrpl/tx/paths/XRPEndpointStep.cpp b/src/libxrpl/tx/paths/XRPEndpointStep.cpp
index 314780c3a7..05d893a50d 100644
--- a/src/libxrpl/tx/paths/XRPEndpointStep.cpp
+++ b/src/libxrpl/tx/paths/XRPEndpointStep.cpp
@@ -16,7 +16,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -204,7 +203,7 @@ private:
         {
             return ctx.strandDeliver.visit(
                 [&](Issue const& issue) {
-                    if (!ctx.view.exists(keylet::line(acc, issue)))
+                    if (!ctx.view.exists(keylet::trustLine(acc, issue)))
                         return -1;
                     return 0;
                 },
diff --git a/src/libxrpl/tx/transactors/account/AccountDelete.cpp b/src/libxrpl/tx/transactors/account/AccountDelete.cpp
index c0e8fe05c6..254733a618 100644
--- a/src/libxrpl/tx/transactors/account/AccountDelete.cpp
+++ b/src/libxrpl/tx/transactors/account/AccountDelete.cpp
@@ -32,7 +32,6 @@
 #include 
 
 #include 
-#include 
 #include 
 
 namespace xrpl {
@@ -72,7 +71,7 @@ using DeleterFuncPtr = TER (*)(
     ApplyView& view,
     AccountID const& account,
     uint256 const& delIndex,
-    std::shared_ptr const& sleDel,
+    SLE::ref sleDel,
     beast::Journal j);
 
 // Local function definitions that provides signature compatibility.
@@ -82,7 +81,7 @@ offerDelete(
     ApplyView& view,
     AccountID const& account,
     uint256 const& delIndex,
-    std::shared_ptr const& sleDel,
+    SLE::ref sleDel,
     beast::Journal j)
 {
     return offerDelete(view, sleDel, j);
@@ -94,7 +93,7 @@ removeSignersFromLedger(
     ApplyView& view,
     AccountID const& account,
     uint256 const& delIndex,
-    std::shared_ptr const& sleDel,
+    SLE::ref sleDel,
     beast::Journal j)
 {
     return SignerListSet::removeFromLedger(registry, view, account, j);
@@ -106,7 +105,7 @@ removeTicketFromLedger(
     ApplyView& view,
     AccountID const& account,
     uint256 const& delIndex,
-    std::shared_ptr const&,
+    SLE::ref,
     beast::Journal j)
 {
     return Transactor::ticketDelete(view, account, delIndex, j);
@@ -118,7 +117,7 @@ removeDepositPreauthFromLedger(
     ApplyView& view,
     AccountID const&,
     uint256 const& delIndex,
-    std::shared_ptr const&,
+    SLE::ref,
     beast::Journal j)
 {
     return DepositPreauth::removeFromLedger(view, delIndex, j);
@@ -130,7 +129,7 @@ removeNFTokenOfferFromLedger(
     ApplyView& view,
     AccountID const& account,
     uint256 const& delIndex,
-    std::shared_ptr const& sleDel,
+    SLE::ref sleDel,
     beast::Journal)
 {
     if (!nft::deleteTokenOffer(view, sleDel))
@@ -145,7 +144,7 @@ removeDIDFromLedger(
     ApplyView& view,
     AccountID const& account,
     uint256 const& delIndex,
-    std::shared_ptr const& sleDel,
+    SLE::ref sleDel,
     beast::Journal j)
 {
     return DIDDelete::deleteSLE(view, sleDel, account, j);
@@ -157,7 +156,7 @@ removeOracleFromLedger(
     ApplyView& view,
     AccountID const& account,
     uint256 const&,
-    std::shared_ptr const& sleDel,
+    SLE::ref sleDel,
     beast::Journal j)
 {
     return OracleDelete::deleteOracle(view, sleDel, account, j);
@@ -169,7 +168,7 @@ removeCredentialFromLedger(
     ApplyView& view,
     AccountID const&,
     uint256 const&,
-    std::shared_ptr const& sleDel,
+    SLE::ref sleDel,
     beast::Journal j)
 {
     return credentials::deleteSLE(view, sleDel, j);
@@ -181,7 +180,7 @@ removeDelegateFromLedger(
     ApplyView& view,
     AccountID const&,
     uint256 const&,
-    std::shared_ptr const& sleDel,
+    SLE::ref sleDel,
     beast::Journal j)
 {
     return DelegateSet::deleteDelegate(view, sleDel, j);
@@ -261,8 +260,8 @@ AccountDelete::preclaim(PreclaimContext const& ctx)
         return tecHAS_OBLIGATIONS;
 
     // If the account owns any NFTs it cannot be deleted.
-    Keylet const first = keylet::nftpageMin(account);
-    Keylet const last = keylet::nftpageMax(account);
+    Keylet const first = keylet::nftokenPageMin(account);
+    Keylet const last = keylet::nftokenPageMax(account);
 
     auto const cp = ctx.view.read(
         Keylet(ltNFTOKEN_PAGE, ctx.view.succ(first.key, last.key.next()).value_or(last.key)));
@@ -301,7 +300,7 @@ AccountDelete::preclaim(PreclaimContext const& ctx)
     if (dirIsEmpty(ctx.view, ownerDirKeylet))
         return tesSUCCESS;
 
-    std::shared_ptr sleDirNode{};
+    SLE::const_pointer sleDirNode{};
     unsigned int uDirEntry{0};
     uint256 dirEntry{beast::kZero};
 
@@ -368,7 +367,7 @@ AccountDelete::doApply()
         ownerDirKeylet,
         [&](LedgerEntryType nodeType,
             uint256 const& dirEntry,
-            std::shared_ptr& sleItem) -> std::pair {
+            SLE::pointer& sleItem) -> std::pair {
             if (auto deleter = nonObligationDeleter(nodeType))
             {
                 TER const result{deleter(ctx_.registry, view(), accountID_, dirEntry, sleItem, j_)};
@@ -417,10 +416,7 @@ AccountDelete::doApply()
 }
 
 void
-AccountDelete::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+AccountDelete::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/account/AccountSet.cpp b/src/libxrpl/tx/transactors/account/AccountSet.cpp
index b52db14720..f0ad5b113a 100644
--- a/src/libxrpl/tx/transactors/account/AccountSet.cpp
+++ b/src/libxrpl/tx/transactors/account/AccountSet.cpp
@@ -6,7 +6,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -20,14 +19,11 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
 
 #include 
-#include 
-#include 
 
 namespace xrpl {
 
@@ -169,54 +165,6 @@ AccountSet::preflight(PreflightContext const& ctx)
     return tesSUCCESS;
 }
 
-NotTEC
-AccountSet::checkPermission(ReadView const& view, STTx const& tx)
-{
-    // AccountSet is prohibited to be granted on a transaction level,
-    // but some granular permissions are allowed.
-    auto const delegate = tx[~sfDelegate];
-    if (!delegate)
-        return tesSUCCESS;
-
-    auto const delegateKey = keylet::delegate(tx[sfAccount], *delegate);
-    auto const sle = view.read(delegateKey);
-
-    if (!sle)
-        return terNO_DELEGATE_PERMISSION;
-
-    std::unordered_set granularPermissions;
-    loadGranularPermission(sle, ttACCOUNT_SET, granularPermissions);
-
-    auto const uSetFlag = tx.getFieldU32(sfSetFlag);
-    auto const uClearFlag = tx.getFieldU32(sfClearFlag);
-    // We don't support any flag based granular permission under
-    // AccountSet transaction. If any delegated account is trying to
-    // update the flag on behalf of another account, it is not
-    // authorized.
-    if (uSetFlag != 0 || uClearFlag != 0 || ((tx.getFlags() & tfUniversalMask) != 0u))
-        return terNO_DELEGATE_PERMISSION;
-
-    if (tx.isFieldPresent(sfEmailHash) && !granularPermissions.contains(AccountEmailHashSet))
-        return terNO_DELEGATE_PERMISSION;
-
-    if (tx.isFieldPresent(sfWalletLocator) || tx.isFieldPresent(sfNFTokenMinter))
-        return terNO_DELEGATE_PERMISSION;
-
-    if (tx.isFieldPresent(sfMessageKey) && !granularPermissions.contains(AccountMessageKeySet))
-        return terNO_DELEGATE_PERMISSION;
-
-    if (tx.isFieldPresent(sfDomain) && !granularPermissions.contains(AccountDomainSet))
-        return terNO_DELEGATE_PERMISSION;
-
-    if (tx.isFieldPresent(sfTransferRate) && !granularPermissions.contains(AccountTransferRateSet))
-        return terNO_DELEGATE_PERMISSION;
-
-    if (tx.isFieldPresent(sfTickSize) && !granularPermissions.contains(AccountTickSizeSet))
-        return terNO_DELEGATE_PERMISSION;
-
-    return tesSUCCESS;
-}
-
 TER
 AccountSet::preclaim(PreclaimContext const& ctx)
 {
@@ -246,30 +194,27 @@ AccountSet::preclaim(PreclaimContext const& ctx)
     //
     // Clawback
     //
-    if (ctx.view.rules().enabled(featureClawback))
+    if (uSetFlag == asfAllowTrustLineClawback)
     {
-        if (uSetFlag == asfAllowTrustLineClawback)
+        if (sle->isFlag(lsfNoFreeze))
         {
-            if (sle->isFlag(lsfNoFreeze))
-            {
-                JLOG(ctx.j.trace()) << "Can't set Clawback if NoFreeze is set";
-                return tecNO_PERMISSION;
-            }
-
-            if (!dirIsEmpty(ctx.view, keylet::ownerDir(id)))
-            {
-                JLOG(ctx.j.trace()) << "Owner directory not empty.";
-                return tecOWNERS;
-            }
+            JLOG(ctx.j.trace()) << "Can't set Clawback if NoFreeze is set";
+            return tecNO_PERMISSION;
         }
-        else if (uSetFlag == asfNoFreeze)
+
+        if (!dirIsEmpty(ctx.view, keylet::ownerDir(id)))
         {
-            // Cannot set NoFreeze if clawback is enabled
-            if (sle->isFlag(lsfAllowTrustLineClawback))
-            {
-                JLOG(ctx.j.trace()) << "Can't set NoFreeze if clawback is enabled";
-                return tecNO_PERMISSION;
-            }
+            JLOG(ctx.j.trace()) << "Owner directory not empty.";
+            return tecOWNERS;
+        }
+    }
+    else if (uSetFlag == asfNoFreeze)
+    {
+        // Cannot set NoFreeze if clawback is enabled
+        if (sle->isFlag(lsfAllowTrustLineClawback))
+        {
+            JLOG(ctx.j.trace()) << "Can't set NoFreeze if clawback is enabled";
+            return tecNO_PERMISSION;
         }
     }
 
@@ -367,7 +312,7 @@ AccountSet::doApply()
             return tecNEED_MASTER_KEY;
         }
 
-        if ((!sle->isFieldPresent(sfRegularKey)) && (!view().peek(keylet::signers(accountID_))))
+        if ((!sle->isFieldPresent(sfRegularKey)) && (!view().peek(keylet::signerList(accountID_))))
         {
             // Account has no regular key or multi-signer signer list.
             return tecNO_ALTERNATIVE_KEY;
@@ -628,7 +573,7 @@ AccountSet::doApply()
     }
 
     // Set flag for clawback
-    if (ctx_.view().rules().enabled(featureClawback) && uSetFlag == asfAllowTrustLineClawback)
+    if (uSetFlag == asfAllowTrustLineClawback)
     {
         JLOG(j_.trace()) << "set allow clawback";
         uFlagsOut |= lsfAllowTrustLineClawback;
@@ -643,10 +588,7 @@ AccountSet::doApply()
 }
 
 void
-AccountSet::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+AccountSet::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/account/SetRegularKey.cpp b/src/libxrpl/tx/transactors/account/SetRegularKey.cpp
index 66a0bef336..f408f31690 100644
--- a/src/libxrpl/tx/transactors/account/SetRegularKey.cpp
+++ b/src/libxrpl/tx/transactors/account/SetRegularKey.cpp
@@ -13,8 +13,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 XRPAmount
@@ -69,7 +67,7 @@ SetRegularKey::doApply()
     else
     {
         // Account has disabled master key and no multi-signer signer list.
-        if (sle->isFlag(lsfDisableMaster) && !view().peek(keylet::signers(accountID_)))
+        if (sle->isFlag(lsfDisableMaster) && !view().peek(keylet::signerList(accountID_)))
             return tecNO_ALTERNATIVE_KEY;
 
         sle->makeFieldAbsent(sfRegularKey);
@@ -81,10 +79,7 @@ SetRegularKey::doApply()
 }
 
 void
-SetRegularKey::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+SetRegularKey::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/account/SignerListSet.cpp b/src/libxrpl/tx/transactors/account/SignerListSet.cpp
index 7a5d2c60b0..2f95d32664 100644
--- a/src/libxrpl/tx/transactors/account/SignerListSet.cpp
+++ b/src/libxrpl/tx/transactors/account/SignerListSet.cpp
@@ -232,7 +232,7 @@ SignerListSet::removeFromLedger(
 {
     auto const accountKeylet = keylet::account(account);
     auto const ownerDirKeylet = keylet::ownerDir(account);
-    auto const signerListKeylet = keylet::signers(account);
+    auto const signerListKeylet = keylet::signerList(account);
 
     return removeSignersFromLedger(
         registry, view, accountKeylet, ownerDirKeylet, signerListKeylet, j);
@@ -302,7 +302,7 @@ SignerListSet::replaceSignerList()
 {
     auto const accountKeylet = keylet::account(accountID_);
     auto const ownerDirKeylet = keylet::ownerDir(accountID_);
-    auto const signerListKeylet = keylet::signers(accountID_);
+    auto const signerListKeylet = keylet::signerList(accountID_);
 
     // This may be either a create or a replace.  Preemptively remove any
     // old signer list.  May reduce the reserve, so this is done before
@@ -367,7 +367,7 @@ SignerListSet::destroySignerList()
         return tecNO_ALTERNATIVE_KEY;
 
     auto const ownerDirKeylet = keylet::ownerDir(accountID_);
-    auto const signerListKeylet = keylet::signers(accountID_);
+    auto const signerListKeylet = keylet::signerList(accountID_);
     return removeSignersFromLedger(
         ctx_.registry, view(), accountKeylet, ownerDirKeylet, signerListKeylet, j_);
 }
@@ -406,10 +406,7 @@ SignerListSet::writeSignersToSLE(SLE::pointer const& ledgerEntry, std::uint32_t
 }
 
 void
-SignerListSet::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+SignerListSet::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/bridge/XChainBridge.cpp b/src/libxrpl/tx/transactors/bridge/XChainBridge.cpp
index 76b274a609..9092ae4140 100644
--- a/src/libxrpl/tx/transactors/bridge/XChainBridge.cpp
+++ b/src/libxrpl/tx/transactors/bridge/XChainBridge.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -39,7 +38,9 @@
 #include 
 #include 
 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -186,7 +187,7 @@ checkAttestationPublicKey(
 
 enum class CheckDst { Check, Ignore };
 template 
-Expected, TER>
+std::expected, TER>
 claimHelper(
     XChainAttestationsBase& attestations,
     ReadView const& view,
@@ -233,7 +234,7 @@ claimHelper(
     if (weight >= quorum)
         return rewardAccounts;
 
-    return Unexpected(tecXCHAIN_CLAIM_NO_QUORUM);
+    return std::unexpected(tecXCHAIN_CLAIM_NO_QUORUM);
 }
 
 /**
@@ -301,10 +302,8 @@ onNewAttestations(
         }
 
         auto const& claimSigningAccount = att->attestationSignerAccount;
-        if (auto i = std::find_if(
-                attestations.begin(),
-                attestations.end(),
-                [&](auto const& a) { return a.keyAccount == claimSigningAccount; });
+        if (auto i = std::ranges::find_if(
+                attestations, [&](auto const& a) { return a.keyAccount == claimSigningAccount; });
             i != attestations.end())
         {
             // existing attestation
@@ -337,7 +336,7 @@ onNewAttestations(
 // Check if there is a quorum of attestations for the given amount and
 // chain. If so return the reward accounts, if not return the tec code (most
 // likely tecXCHAIN_CLAIM_NO_QUORUM)
-Expected, TER>
+std::expected, TER>
 onClaim(
     XChainClaimAttestations& attestations,
     ReadView const& view,
@@ -759,7 +758,7 @@ getSignersListAndQuorum(ReadView const& view, SLE const& sleBridge, beast::Journ
         return {r, q, tecINTERNAL};
     }
 
-    auto const sleS = view.read(keylet::signers(sleBridge[sfAccount]));
+    auto const sleS = view.read(keylet::signerList(sleBridge[sfAccount]));
     if (!sleS)
     {
         return {r, q, tecXCHAIN_NO_SIGNERS_LIST};
@@ -798,21 +797,21 @@ readOrpeekBridge(F&& getter, STXChainBridge const& bridgeSpec)
     return tryGet(STXChainBridge::ChainType::Issuing);
 }
 
-std::shared_ptr
+SLE::pointer
 peekBridge(ApplyView& v, STXChainBridge const& bridgeSpec)
 {
     return readOrpeekBridge(
-        [&v](STXChainBridge const& b, STXChainBridge::ChainType ct) -> std::shared_ptr {
+        [&v](STXChainBridge const& b, STXChainBridge::ChainType ct) -> SLE::pointer {
             return v.peek(keylet::bridge(b, ct));
         },
         bridgeSpec);
 }
 
-std::shared_ptr
+SLE::const_pointer
 readBridge(ReadView const& v, STXChainBridge const& bridgeSpec)
 {
     return readOrpeekBridge(
-        [&v](STXChainBridge const& b, STXChainBridge::ChainType ct) -> std::shared_ptr {
+        [&v](STXChainBridge const& b, STXChainBridge::ChainType ct) -> SLE::const_pointer {
             return v.read(keylet::bridge(b, ct));
         },
         bridgeSpec);
@@ -847,14 +846,14 @@ applyClaimAttestations(
         AccountID cidOwner;
     };
 
-    auto const scopeResult = [&]() -> Expected {
+    auto const scopeResult = [&]() -> std::expected {
         // This lambda is ugly - admittedly. The purpose of this lambda is to
         // limit the scope of sles so they don't overlap with
         // `finalizeClaimHelper`. Since `finalizeClaimHelper` can create child
         // views, it's important that the sle's lifetime doesn't overlap.
         auto const sleClaimID = psb.peek(claimIDKeylet);
         if (!sleClaimID)
-            return Unexpected(tecXCHAIN_NO_CLAIM_ID);
+            return std::unexpected(tecXCHAIN_NO_CLAIM_ID);
 
         // Add claims that are part of the signer's list to the "claims" vector
         std::vector atts;
@@ -868,13 +867,13 @@ applyClaimAttestations(
 
         if (atts.empty())
         {
-            return Unexpected(tecXCHAIN_PROOF_UNKNOWN_KEY);
+            return std::unexpected(tecXCHAIN_PROOF_UNKNOWN_KEY);
         }
 
         AccountID const otherChainSource = (*sleClaimID)[sfOtherChainSource];
         if (attBegin->sendingAccount != otherChainSource)
         {
-            return Unexpected(tecXCHAIN_SENDING_ACCOUNT_MISMATCH);
+            return std::unexpected(tecXCHAIN_SENDING_ACCOUNT_MISMATCH);
         }
 
         {
@@ -885,7 +884,7 @@ applyClaimAttestations(
 
             if (attDstChain != dstChain)
             {
-                return Unexpected(tecXCHAIN_WRONG_CHAIN);
+                return std::unexpected(tecXCHAIN_WRONG_CHAIN);
             }
         }
 
@@ -964,10 +963,10 @@ applyCreateAccountAttestations(
 
     PaymentSandbox psb(&view);
 
-    auto const claimCountResult = [&]() -> Expected {
+    auto const claimCountResult = [&]() -> std::expected {
         auto const sleBridge = psb.peek(bridgeK);
         if (!sleBridge)
-            return Unexpected(tecINTERNAL);
+            return std::unexpected(tecINTERNAL);
 
         return (*sleBridge)[sfXChainAccountClaimCount];
     }();
@@ -1009,7 +1008,7 @@ applyCreateAccountAttestations(
         XChainCreateAccountAttestations curAtts;
     };
 
-    auto const scopeResult = [&]() -> Expected {
+    auto const scopeResult = [&]() -> std::expected {
         // This lambda is ugly - admittedly. The purpose of this lambda is to
         // limit the scope of sles so they don't overlap with
         // `finalizeClaimHelper`. Since `finalizeClaimHelper` can create child
@@ -1025,14 +1024,14 @@ applyCreateAccountAttestations(
 
             auto const sleDoor = psb.peek(doorK);
             if (!sleDoor)
-                return Unexpected(tecINTERNAL);
+                return std::unexpected(tecINTERNAL);
 
             // Check reserve
             auto const balance = (*sleDoor)[sfBalance];
             auto const reserve = psb.fees().accountReserve((*sleDoor)[sfOwnerCount] + 1);
 
             if (balance < reserve)
-                return Unexpected(tecINSUFFICIENT_RESERVE);
+                return std::unexpected(tecINSUFFICIENT_RESERVE);
         }
 
         std::vector atts;
@@ -1045,7 +1044,7 @@ applyCreateAccountAttestations(
         }
         if (atts.empty())
         {
-            return Unexpected(tecXCHAIN_PROOF_UNKNOWN_KEY);
+            return std::unexpected(tecXCHAIN_PROOF_UNKNOWN_KEY);
         }
 
         XChainCreateAccountAttestations curAtts = [&] {
@@ -1071,7 +1070,7 @@ applyCreateAccountAttestations(
             // Modify the object before it's potentially deleted, so the meta
             // data will include the new attestations
             if (!sleClaimID)
-                return Unexpected(tecINTERNAL);
+                return std::unexpected(tecINTERNAL);
             sleClaimID->setFieldArray(sfXChainCreateAccountAttestations, curAtts.toSTArray());
             psb.update(sleClaimID);
         }
@@ -1244,7 +1243,7 @@ attestationDoApply(ApplyContext& ctx)
         Keylet bridgeK;
     };
 
-    auto const scopeResult = [&]() -> Expected {
+    auto const scopeResult = [&]() -> std::expected {
         // This lambda is ugly - admittedly. The purpose of this lambda is to
         // limit the scope of sles so they don't overlap with
         // `finalizeClaimHelper`. Since `finalizeClaimHelper` can create child
@@ -1252,7 +1251,7 @@ attestationDoApply(ApplyContext& ctx)
         auto sleBridge = readBridge(ctx.view(), bridgeSpec);
         if (!sleBridge)
         {
-            return Unexpected(tecNO_ENTRY);
+            return std::unexpected(tecNO_ENTRY);
         }
         Keylet const bridgeK{ltBRIDGE, sleBridge->key()};
         AccountID const thisDoor = (*sleBridge)[sfAccount];
@@ -1269,7 +1268,7 @@ attestationDoApply(ApplyContext& ctx)
             }
             else
             {
-                return Unexpected(tecINTERNAL);
+                return std::unexpected(tecINTERNAL);
             }
         }
         STXChainBridge::ChainType const srcChain = STXChainBridge::otherChain(dstChain);
@@ -1279,7 +1278,7 @@ attestationDoApply(ApplyContext& ctx)
             getSignersListAndQuorum(ctx.view(), *sleBridge, ctx.journal);
 
         if (!isTesSuccess(slTer))
-            return Unexpected(slTer);
+            return std::unexpected(slTer);
 
         return ScopeResult{srcChain, std::move(signersList), quorum, thisDoor, bridgeK};
     }();
@@ -1721,7 +1720,7 @@ XChainClaim::doApply()
         STAmount signatureReward;
     };
 
-    auto const scopeResult = [&]() -> Expected {
+    auto const scopeResult = [&]() -> std::expected {
         // This lambda is ugly - admittedly. The purpose of this lambda is to
         // limit the scope of sles so they don't overlap with
         // `finalizeClaimHelper`. Since `finalizeClaimHelper` can create child
@@ -1732,7 +1731,7 @@ XChainClaim::doApply()
         auto const sleClaimID = psb.peek(claimIDKeylet);
 
         if (!(sleBridge && sleClaimID && sleAcct))
-            return Unexpected(tecINTERNAL);
+            return std::unexpected(tecINTERNAL);
 
         AccountID const thisDoor = (*sleBridge)[sfAccount];
 
@@ -1748,7 +1747,7 @@ XChainClaim::doApply()
             }
             else
             {
-                return Unexpected(tecINTERNAL);
+                return std::unexpected(tecINTERNAL);
             }
         }
         STXChainBridge::ChainType const srcChain = STXChainBridge::otherChain(dstChain);
@@ -1763,7 +1762,7 @@ XChainClaim::doApply()
             getSignersListAndQuorum(ctx_.view(), *sleBridge, ctx_.journal);
 
         if (!isTesSuccess(slTer))
-            return Unexpected(slTer);
+            return std::unexpected(slTer);
 
         XChainClaimAttestations curAtts{sleClaimID->getFieldArray(sfXChainClaimAttestations)};
 
@@ -1776,7 +1775,7 @@ XChainClaim::doApply()
             signersList,
             ctx_.journal);
         if (!claimR.has_value())
-            return Unexpected(claimR.error());
+            return std::unexpected(claimR.error());
 
         return ScopeResult{
             .rewardAccounts = claimR.value(),
@@ -2225,10 +2224,7 @@ XChainCreateAccountCommit::doApply()
 }
 
 void
-XChainCreateBridge::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+XChainCreateBridge::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
@@ -2246,10 +2242,7 @@ XChainCreateBridge::finalizeInvariants(
 }
 
 void
-BridgeModify::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+BridgeModify::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
@@ -2267,10 +2260,7 @@ BridgeModify::finalizeInvariants(
 }
 
 void
-XChainClaim::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+XChainClaim::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
@@ -2283,10 +2273,7 @@ XChainClaim::finalizeInvariants(STTx const&, TER, XRPAmount, ReadView const&, be
 }
 
 void
-XChainCommit::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+XChainCommit::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
@@ -2304,10 +2291,7 @@ XChainCommit::finalizeInvariants(
 }
 
 void
-XChainCreateClaimID::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+XChainCreateClaimID::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
@@ -2325,10 +2309,7 @@ XChainCreateClaimID::finalizeInvariants(
 }
 
 void
-XChainAddClaimAttestation::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+XChainAddClaimAttestation::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
@@ -2346,10 +2327,7 @@ XChainAddClaimAttestation::finalizeInvariants(
 }
 
 void
-XChainAddAccountCreateAttestation::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+XChainAddAccountCreateAttestation::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
@@ -2367,10 +2345,7 @@ XChainAddAccountCreateAttestation::finalizeInvariants(
 }
 
 void
-XChainCreateAccountCommit::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+XChainCreateAccountCommit::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/check/CheckCancel.cpp b/src/libxrpl/tx/transactors/check/CheckCancel.cpp
index 7f1708fd81..d966772191 100644
--- a/src/libxrpl/tx/transactors/check/CheckCancel.cpp
+++ b/src/libxrpl/tx/transactors/check/CheckCancel.cpp
@@ -14,8 +14,6 @@
 #include 
 
 #include 
-#include 
-
 namespace xrpl {
 
 NotTEC
@@ -102,10 +100,7 @@ CheckCancel::doApply()
 }
 
 void
-CheckCancel::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+CheckCancel::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/check/CheckCash.cpp b/src/libxrpl/tx/transactors/check/CheckCash.cpp
index f8dbae66fc..80ef742f52 100644
--- a/src/libxrpl/tx/transactors/check/CheckCash.cpp
+++ b/src/libxrpl/tx/transactors/check/CheckCash.cpp
@@ -31,7 +31,6 @@
 
 #include 
 #include 
-#include 
 #include 
 
 namespace xrpl {
@@ -139,6 +138,11 @@ CheckCash::preclaim(PreclaimContext const& ctx)
         }(ctx.tx)};
 
         STAmount const sendMax = sleCheck->at(sfSendMax);
+        // A legacy Check may contain a non-canonical MPT sfSendMax. Universal
+        // preflight only validates the CheckCash transaction, not the stored Check.
+        if (ctx.view.rules().enabled(fixCleanup3_2_0) && !isLegalMPT(sendMax))
+            return tefBAD_LEDGER;
+
         if (!equalTokens(value.asset(), sendMax.asset()))
         {
             JLOG(ctx.j.warn()) << "Check cash does not match check currency.";
@@ -188,7 +192,7 @@ CheckCash::preclaim(PreclaimContext const& ctx)
                 [&](Issue const& issue) -> TER {
                     Currency const currency{issue.currency};
                     auto const sleTrustLine =
-                        ctx.view.read(keylet::line(dstId, issuerId, currency));
+                        ctx.view.read(keylet::trustLine(dstId, issuerId, currency));
 
                     auto const sleIssuer = ctx.view.read(keylet::account(issuerId));
                     if (!sleIssuer)
@@ -264,9 +268,10 @@ CheckCash::preclaim(PreclaimContext const& ctx)
                         return tecLOCKED;
                     }
 
-                    if (auto const err = canTrade(ctx.view, value.asset()); !isTesSuccess(err))
+                    if (auto const err = canTransfer(ctx.view, issue, srcId, dstId);
+                        !isTesSuccess(err))
                     {
-                        JLOG(ctx.j.warn()) << "MPT DEX is not allowed.";
+                        JLOG(ctx.j.warn()) << "MPT transfer is disabled.";
                         return err;
                     }
 
@@ -382,7 +387,7 @@ CheckCash::doApply()
 
             // Check reserve. Return destination account SLE if enough reserve,
             // otherwise return nullptr.
-            auto checkReserve = [&]() -> std::shared_ptr {
+            auto checkReserve = [&]() -> SLE::pointer {
                 auto sleDst = psb.peek(keylet::account(accountID_));
 
                 // Can the account cover the trust line's or MPT reserve?
@@ -406,7 +411,7 @@ CheckCash::doApply()
                     // If a trust line does not exist yet create one.
                     Issue const& trustLineIssue = issue;
                     AccountID const truster = deliverIssuer == accountID_ ? srcId : accountID_;
-                    trustLineKey = keylet::line(truster, trustLineIssue);
+                    trustLineKey = keylet::trustLine(truster, trustLineIssue);
                     destLow = deliverIssuer > accountID_;
 
                     if (!psb.exists(*trustLineKey))
@@ -586,10 +591,7 @@ CheckCash::doApply()
 }
 
 void
-CheckCash::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+CheckCash::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/check/CheckCreate.cpp b/src/libxrpl/tx/transactors/check/CheckCreate.cpp
index 6373d7e343..595bcc4ab1 100644
--- a/src/libxrpl/tx/transactors/check/CheckCreate.cpp
+++ b/src/libxrpl/tx/transactors/check/CheckCreate.cpp
@@ -111,10 +111,10 @@ CheckCreate::preclaim(PreclaimContext const& ctx)
         {
             // The currency may not be globally frozen
             AccountID const& issuerId{sendMax.getIssuer()};
-            if (isGlobalFrozen(ctx.view, sendMax.asset()))
+            if (auto const ter = checkGlobalFrozen(ctx.view, sendMax.asset()); !isTesSuccess(ter))
             {
-                JLOG(ctx.j.warn()) << "Creating a check for frozen asset";
-                return sendMax.asset().holds() ? tecLOCKED : tecFROZEN;
+                JLOG(ctx.j.warn()) << "Creating a check for frozen or locked asset";
+                return ter;
             }
             auto const err = sendMax.asset().visit(
                 [&](Issue const& issue) -> std::optional {
@@ -127,7 +127,7 @@ CheckCreate::preclaim(PreclaimContext const& ctx)
                     {
                         // Check if the issuer froze the line
                         auto const sleTrust =
-                            ctx.view.read(keylet::line(srcId, issuerId, issue.currency));
+                            ctx.view.read(keylet::trustLine(srcId, issuerId, issue.currency));
                         if (sleTrust &&
                             sleTrust->isFlag((issuerId > srcId) ? lsfHighFreeze : lsfLowFreeze))
                         {
@@ -139,7 +139,7 @@ CheckCreate::preclaim(PreclaimContext const& ctx)
                     {
                         // Check if dst froze the line.
                         auto const sleTrust =
-                            ctx.view.read(keylet::line(issuerId, dstId, issue.currency));
+                            ctx.view.read(keylet::trustLine(issuerId, dstId, issue.currency));
                         if (sleTrust &&
                             sleTrust->isFlag((dstId > issuerId) ? lsfHighFreeze : lsfLowFreeze))
                         {
@@ -153,9 +153,21 @@ CheckCreate::preclaim(PreclaimContext const& ctx)
                 },
                 [&](MPTIssue const& issue) -> std::optional {
                     if (srcId != issuerId && isFrozen(ctx.view, srcId, issue))
+                    {
+                        JLOG(ctx.j.warn()) << "Creating a check for locked MPT.";
                         return tecLOCKED;
+                    }
                     if (dstId != issuerId && isFrozen(ctx.view, dstId, issue))
+                    {
+                        JLOG(ctx.j.warn()) << "Creating a check for locked MPT.";
                         return tecLOCKED;
+                    }
+                    if (auto const ter = canTransfer(ctx.view, issue, srcId, dstId);
+                        !isTesSuccess(ter))
+                    {
+                        JLOG(ctx.j.warn()) << "MPT transfer is disabled.";
+                        return ter;
+                    }
 
                     return std::nullopt;
                 });
@@ -169,7 +181,7 @@ CheckCreate::preclaim(PreclaimContext const& ctx)
         return tecEXPIRED;
     }
 
-    return canTrade(ctx.view, ctx.tx[sfSendMax].asset());
+    return tesSUCCESS;
 }
 
 TER
@@ -246,10 +258,7 @@ CheckCreate::doApply()
 }
 
 void
-CheckCreate::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+CheckCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/credentials/CredentialAccept.cpp b/src/libxrpl/tx/transactors/credentials/CredentialAccept.cpp
index 4e9857d782..e0ebdd893a 100644
--- a/src/libxrpl/tx/transactors/credentials/CredentialAccept.cpp
+++ b/src/libxrpl/tx/transactors/credentials/CredentialAccept.cpp
@@ -20,8 +20,6 @@
 #include 
 
 #include 
-#include 
-
 namespace xrpl {
 
 using namespace credentials;
@@ -126,10 +124,7 @@ CredentialAccept::doApply()
 }
 
 void
-CredentialAccept::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+CredentialAccept::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp b/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp
index bcb0a6fefa..acca408a95 100644
--- a/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp
+++ b/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp
@@ -179,10 +179,7 @@ CredentialCreate::doApply()
 }
 
 void
-CredentialCreate::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+CredentialCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/credentials/CredentialDelete.cpp b/src/libxrpl/tx/transactors/credentials/CredentialDelete.cpp
index fcd1848cbe..6bd4ad54c5 100644
--- a/src/libxrpl/tx/transactors/credentials/CredentialDelete.cpp
+++ b/src/libxrpl/tx/transactors/credentials/CredentialDelete.cpp
@@ -16,8 +16,6 @@
 #include 
 
 #include 
-#include 
-
 namespace xrpl {
 
 using namespace credentials;
@@ -97,10 +95,7 @@ CredentialDelete::doApply()
 }
 
 void
-CredentialDelete::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+CredentialDelete::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp b/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp
index 30d703686c..32a51555b1 100644
--- a/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp
+++ b/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp
@@ -52,9 +52,13 @@ DelegateSet::preclaim(PreclaimContext const& ctx)
     if (!ctx.view.exists(keylet::account(ctx.tx[sfAccount])))
         return terNO_ACCOUNT;  // LCOV_EXCL_LINE
 
-    if (!ctx.view.exists(keylet::account(ctx.tx[sfAuthorize])))
+    auto const sleAuthorize = ctx.view.read(keylet::account(ctx.tx[sfAuthorize]));
+    if (!sleAuthorize)
         return tecNO_TARGET;
 
+    if (isPseudoAccount(sleAuthorize))
+        return tecNO_PERMISSION;
+
     // Deleting the delegate object is invalid if it doesn’t exist.
     if (ctx.tx.getFieldArray(sfPermissions).empty() &&
         !ctx.view.exists(keylet::delegate(ctx.tx[sfAccount], ctx.tx[sfAuthorize])))
@@ -132,7 +136,7 @@ DelegateSet::doApply()
 }
 
 TER
-DelegateSet::deleteDelegate(ApplyView& view, std::shared_ptr const& sle, beast::Journal j)
+DelegateSet::deleteDelegate(ApplyView& view, SLE::ref sle, beast::Journal j)
 {
     if (!sle)
         return tecINTERNAL;  // LCOV_EXCL_LINE
@@ -174,10 +178,7 @@ DelegateSet::deleteDelegate(ApplyView& view, std::shared_ptr const& sle, be
 }
 
 void
-DelegateSet::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+DelegateSet::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/delegate/DelegateUtils.cpp b/src/libxrpl/tx/transactors/delegate/DelegateUtils.cpp
index 4d3d97e443..6def542c7d 100644
--- a/src/libxrpl/tx/transactors/delegate/DelegateUtils.cpp
+++ b/src/libxrpl/tx/transactors/delegate/DelegateUtils.cpp
@@ -7,12 +7,11 @@
 #include 
 #include 
 
-#include 
 #include 
 
 namespace xrpl {
 NotTEC
-checkTxPermission(std::shared_ptr const& delegate, STTx const& tx)
+checkTxPermission(SLE::const_ref delegate, STTx const& tx)
 {
     if (!delegate)
         return terNO_DELEGATE_PERMISSION;
@@ -30,14 +29,12 @@ checkTxPermission(std::shared_ptr const& delegate, STTx const& tx)
     return terNO_DELEGATE_PERMISSION;
 }
 
-void
-loadGranularPermission(
-    std::shared_ptr const& delegate,
-    TxType const& txType,
-    std::unordered_set& granularPermissions)
+std::unordered_set
+getGranularPermission(SLE::const_ref delegate, TxType const& txType)
 {
+    std::unordered_set granularPermissions;
     if (!delegate)
-        return;
+        return granularPermissions;
 
     auto const permissionArray = delegate->getFieldArray(sfPermissions);
     for (auto const& permission : permissionArray)
@@ -48,6 +45,8 @@ loadGranularPermission(
         if (type && *type == txType)
             granularPermissions.insert(granularValue);
     }
+
+    return granularPermissions;
 }
 
 }  // namespace xrpl
diff --git a/src/libxrpl/tx/transactors/dex/AMMBid.cpp b/src/libxrpl/tx/transactors/dex/AMMBid.cpp
index b3b41fbfa2..83432fa0d0 100644
--- a/src/libxrpl/tx/transactors/dex/AMMBid.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMBid.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -27,7 +26,7 @@
 #include 
 #include 
 #include 
-#include 
+#include 
 #include 
 #include 
 #include 
@@ -267,7 +266,7 @@ applyBid(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Journa
     auto const bidMin = ctx.tx[~sfBidMin];
     auto const bidMax = ctx.tx[~sfBidMax];
 
-    auto getPayPrice = [&](Number const& computedPrice) -> Expected {
+    auto getPayPrice = [&](Number const& computedPrice) -> std::expected {
         auto const payPrice = [&]() -> std::optional {
             // Both min/max bid price are defined
             if (bidMin && bidMax)
@@ -296,11 +295,11 @@ applyBid(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Journa
         }();
         if (!payPrice)
         {
-            return Unexpected(tecAMM_FAILED);
+            return std::unexpected(tecAMM_FAILED);
         }
         if (payPrice > lpTokens)
         {
-            return Unexpected(tecAMM_INVALID_TOKENS);
+            return std::unexpected(tecAMM_INVALID_TOKENS);
         }
         return *payPrice;
     };
@@ -379,10 +378,7 @@ AMMBid::doApply()
 }
 
 void
-AMMBid::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+AMMBid::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
index 43abc6ae29..c1ef9f875e 100644
--- a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
@@ -27,7 +27,6 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 
@@ -137,7 +136,7 @@ AMMClawback::preclaim(PreclaimContext const& ctx)
                     !sleIssuer->isFlag(lsfNoFreeze);
             },
             [&](MPTIssue const& issue) {
-                auto const sleIssuance = ctx.view.read(keylet::mptIssuance(issue.getMptID()));
+                auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(issue.getMptID()));
 
                 return sleIssuance && sleIssuance->isFlag(lsfMPTCanClawback) &&
                     sleIssuance->getAccountID(sfIssuer) == ctx.tx[sfAccount];
@@ -259,6 +258,16 @@ AMMClawback::applyGuts(Sandbox& sb)
     if (!isTesSuccess(result))
         return result;  // LCOV_EXCL_LINE
 
+    if (sb.rules().enabled(fixCleanup3_3_0) && sb.rules().enabled(fixAMMv1_3))
+    {
+        if (auto const ter =
+                checkAMMPrecisionLoss(sb, ammAccount, asset, asset2, newLPTokenBalance, j_);
+            !isTesSuccess(ter))
+        {
+            return ter;
+        }
+    }
+
     auto const res =
         AMMWithdraw::deleteAMMAccountIfEmpty(sb, ammSle, newLPTokenBalance, asset, asset2, j_);
     if (!res.second)
@@ -383,10 +392,7 @@ AMMClawback::equalWithdrawMatchingOneAmount(
 }
 
 void
-AMMClawback::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+AMMClawback::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/dex/AMMCreate.cpp b/src/libxrpl/tx/transactors/dex/AMMCreate.cpp
index 3e1fe97a76..ef271ba362 100644
--- a/src/libxrpl/tx/transactors/dex/AMMCreate.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMCreate.cpp
@@ -26,7 +26,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -120,11 +119,16 @@ AMMCreate::preclaim(PreclaimContext const& ctx)
     }
 
     // Globally or individually frozen
-    if (isFrozen(ctx.view, accountID, amount.asset()) ||
-        isFrozen(ctx.view, accountID, amount2.asset()))
+    if (auto const ter = checkFrozen(ctx.view, accountID, amount.asset()); !isTesSuccess(ter))
+
     {
-        JLOG(ctx.j.debug()) << "AMM Instance: involves frozen asset.";
-        return tecFROZEN;
+        JLOG(ctx.j.debug()) << "AMM Instance: involves frozen or locked asset.";
+        return ter;
+    }
+    if (auto const ter = checkFrozen(ctx.view, accountID, amount2.asset()); !isTesSuccess(ter))
+    {
+        JLOG(ctx.j.debug()) << "AMM Instance: involves frozen or locked asset.";
+        return ter;
     }
 
     auto noDefaultRipple = [](ReadView const& view, Asset const& asset) {
@@ -191,10 +195,10 @@ AMMCreate::preclaim(PreclaimContext const& ctx)
             return terADDRESS_COLLISION;
     }
 
-    if (auto const ter = checkMPTTxAllowed(ctx.view, ttAMM_CREATE, amount.asset(), accountID);
+    if (auto const ter = canMPTTradeAndTransfer(ctx.view, amount.asset(), accountID, accountID);
         !isTesSuccess(ter))
         return ter;
-    if (auto const ter = checkMPTTxAllowed(ctx.view, ttAMM_CREATE, amount2.asset(), accountID);
+    if (auto const ter = canMPTTradeAndTransfer(ctx.view, amount2.asset(), accountID, accountID);
         !isTesSuccess(ter))
         return ter;
 
@@ -208,7 +212,7 @@ AMMCreate::preclaim(PreclaimContext const& ctx)
     auto clawbackDisabled = [&](Asset const& asset) -> TER {
         return asset.visit(
             [&](MPTIssue const& issue) -> TER {
-                auto const sle = ctx.view.read(keylet::mptIssuance(issue.getMptID()));
+                auto const sle = ctx.view.read(keylet::mptokenIssuance(issue.getMptID()));
                 if (!sle)
                     return tecINTERNAL;  // LCOV_EXCL_LINE
                 if (sle->isFlag(lsfMPTCanClawback))
@@ -256,7 +260,7 @@ applyCreate(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Jou
 
     // LP Token already exists. (should not happen)
     auto const lptIss = ammLPTIssue(amount.asset(), amount2.asset(), accountId);
-    if (sb.read(keylet::line(accountId, lptIss)))
+    if (sb.read(keylet::trustLine(accountId, lptIss)))
     {
         JLOG(j.error()) << "AMM Instance: LP Token already exists.";
         return {tecDUPLICATE, false};
@@ -301,22 +305,14 @@ applyCreate(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Jou
         // Authorize MPT
         return amount.asset().visit(
             [&](MPTIssue const& issue) -> TER {
-                // Authorize MPT
                 auto const& mptIssue = issue;
                 auto const& mptID = mptIssue.getMptID();
-                std::uint32_t flags = lsfMPTAMM;
-                if (auto const err =
-                        requireAuth(ctx.view(), mptIssue, accountId, AuthType::WeakAuth);
+                // Implicitly authorize MPT asset for AMM pseudo-account.
+                std::uint32_t const flags = lsfMPTAMM | lsfMPTAuthorized;
+                if (auto const err = requireAuth(sb, mptIssue, accountId, AuthType::WeakAuth);
                     !isTesSuccess(err))
                 {
-                    if (err == tecNO_AUTH)
-                    {
-                        flags |= lsfMPTAuthorized;
-                    }
-                    else
-                    {
-                        return err;
-                    }
+                    return err;
                 }
 
                 if (auto const err = createMPToken(sb, mptID, accountId, flags); !isTesSuccess(err))
@@ -334,7 +330,8 @@ applyCreate(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Jou
                 // Set AMM flag on AMM trustline
                 if (!isXRP(amount))
                 {
-                    SLE::pointer const sleRippleState = sb.peek(keylet::line(accountId, issue));
+                    SLE::pointer const sleRippleState =
+                        sb.peek(keylet::trustLine(accountId, issue));
                     if (!sleRippleState)
                     {
                         return tecINTERNAL;  // LCOV_EXCL_LINE
@@ -368,7 +365,7 @@ applyCreate(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Jou
                     << lpTokens << " " << amount << " " << amount2;
     auto addOrderBook = [&](Asset const& assetIn, Asset const& assetOut, std::uint64_t uRate) {
         Book const book{assetIn, assetOut, std::nullopt};
-        auto const dir = keylet::quality(keylet::kBook(book), uRate);
+        auto const dir = keylet::quality(keylet::book(book), uRate);
         if (auto const bookExisted = static_cast(sb.read(dir)); !bookExisted)
             ctx.registry.get().getOrderBookDB().addOrderBook(book);
     };
@@ -393,10 +390,7 @@ AMMCreate::doApply()
 }
 
 void
-AMMCreate::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+AMMCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/dex/AMMDelete.cpp b/src/libxrpl/tx/transactors/dex/AMMDelete.cpp
index 8a8cab1c03..e2ecec8242 100644
--- a/src/libxrpl/tx/transactors/dex/AMMDelete.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMDelete.cpp
@@ -15,8 +15,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 bool
@@ -67,10 +65,7 @@ AMMDelete::doApply()
 }
 
 void
-AMMDelete::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+AMMDelete::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp b/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp
index 86cd52300d..ff7fec019a 100644
--- a/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp
@@ -21,14 +21,12 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 
@@ -235,7 +233,7 @@ AMMDeposit::preclaim(PreclaimContext const& ctx)
             auto const lpIssue = (*ammSle)[sfLPTokenBalance].get();
             // Adjust the reserve if LP doesn't have LPToken trustline
             auto const sle =
-                ctx.view.read(keylet::line(accountID, lpIssue.account, lpIssue.currency));
+                ctx.view.read(keylet::trustLine(accountID, lpIssue.account, lpIssue.currency));
             if (xrpLiquid(ctx.view, accountID, !sle, ctx.j) >= deposit)
                 return TER(tesSUCCESS);
             if (sle)
@@ -253,7 +251,36 @@ AMMDeposit::preclaim(PreclaimContext const& ctx)
             : tecUNFUNDED_AMM;
     };
 
-    if (ctx.view.rules().enabled(featureAMMClawback))
+    auto const amount = ctx.tx[~sfAmount];
+    auto const amount2 = ctx.tx[~sfAmount2];
+    auto const ammAccountID = ammSle->getAccountID(sfAccount);
+
+    if (ctx.view.rules().enabled(fixCleanup3_3_0))
+    {
+        // Unified deposit freeze check for both pool assets.
+        // AMMDeposit is not allowed if either asset is frozen.
+        auto checkAsset = [&](Asset const& asset) -> TER {
+            if (auto const ter = requireAuth(ctx.view, asset, accountID, AuthType::WeakAuth))
+            {
+                JLOG(ctx.j.debug()) << "AMM Deposit: account is not authorized, " << asset;
+                return ter;
+            }
+            if (auto const ter = checkDepositFreeze(ctx.view, accountID, ammAccountID, asset))
+            {
+                JLOG(ctx.j.debug())
+                    << "AMM Deposit: frozen, " << to_string(accountID) << " " << to_string(asset);
+                return ter;
+            }
+            return tesSUCCESS;
+        };
+
+        if (auto const ter = checkAsset(ctx.tx[sfAsset]))
+            return ter;
+
+        if (auto const ter = checkAsset(ctx.tx[sfAsset2]))
+            return ter;
+    }
+    else if (ctx.view.rules().enabled(featureAMMClawback))
     {
         // Check if either of the assets is frozen, AMMDeposit is not allowed
         // if either asset is frozen
@@ -267,12 +294,12 @@ AMMDeposit::preclaim(PreclaimContext const& ctx)
                 return ter;
             }
 
-            if (isFrozen(ctx.view, accountID, asset))
+            if (auto const ter = checkFrozen(ctx.view, accountID, asset); !isTesSuccess(ter))
             {
-                JLOG(ctx.j.debug()) << "AMM Deposit: account or currency is frozen, "
+                JLOG(ctx.j.debug()) << "AMM Deposit: account or currency is frozen or locked, "
                                     << to_string(accountID) << " " << to_string(asset);
 
-                return tecFROZEN;
+                return ter;
             }
 
             return tesSUCCESS;
@@ -285,10 +312,6 @@ AMMDeposit::preclaim(PreclaimContext const& ctx)
             return ter;
     }
 
-    auto const amount = ctx.tx[~sfAmount];
-    auto const amount2 = ctx.tx[~sfAmount2];
-    auto const ammAccountID = ammSle->getAccountID(sfAccount);
-
     auto checkAmount = [&](std::optional const& amount, bool checkBalance) -> TER {
         if (amount)
         {
@@ -303,19 +326,26 @@ AMMDeposit::preclaim(PreclaimContext const& ctx)
                 return ter;
                 // LCOV_EXCL_STOP
             }
-            // AMM account or currency frozen
-            if (isFrozen(ctx.view, ammAccountID, amount->asset()))
+            if (!ctx.view.rules().enabled(fixCleanup3_3_0))
             {
-                JLOG(ctx.j.debug())
-                    << "AMM Deposit: AMM account or currency is frozen, " << to_string(accountID);
-                return tecFROZEN;
-            }
-            // Account frozen
-            if (isIndividualFrozen(ctx.view, accountID, amount->asset()))
-            {
-                JLOG(ctx.j.debug()) << "AMM Deposit: account is frozen, " << to_string(accountID)
-                                    << " " << to_string(amount->asset());
-                return tecFROZEN;
+                // AMM account or currency frozen
+                if (auto const ter = checkFrozen(ctx.view, ammAccountID, amount->asset());
+                    !isTesSuccess(ter))
+                {
+                    JLOG(ctx.j.debug())
+                        << "AMM Deposit: AMM account or currency is frozen or locked, "
+                        << to_string(accountID);
+                    return ter;
+                }
+                // Account frozen
+                if (auto const ter = checkIndividualFrozen(ctx.view, accountID, amount->asset());
+                    !isTesSuccess(ter))
+                {
+                    JLOG(ctx.j.debug())
+                        << "AMM Deposit: account is frozen or locked, " << to_string(accountID)
+                        << " " << to_string(amount->asset());
+                    return ter;
+                }
             }
             if (checkBalance)
             {
@@ -368,10 +398,10 @@ AMMDeposit::preclaim(PreclaimContext const& ctx)
         }
     }
 
-    if (auto const ter = checkMPTTxAllowed(ctx.view, ttAMM_DEPOSIT, ctx.tx[sfAsset], accountID);
+    if (auto const ter = canMPTTradeAndTransfer(ctx.view, ctx.tx[sfAsset], accountID, accountID);
         !isTesSuccess(ter))
         return ter;
-    if (auto const ter = checkMPTTxAllowed(ctx.view, ttAMM_DEPOSIT, ctx.tx[sfAsset2], accountID);
+    if (auto const ter = canMPTTradeAndTransfer(ctx.view, ctx.tx[sfAsset2], accountID, accountID);
         !isTesSuccess(ter))
         return ter;
 
@@ -470,6 +500,19 @@ AMMDeposit::applyGuts(Sandbox& sb)
         XRPL_ASSERT(
             newLPTokenBalance > beast::kZero,
             "xrpl::AMMDeposit::applyGuts : valid new LP token balance");
+        // Defensive check: deposit formulas with fixAMMv1_3 round LP tokens
+        // down and asset amounts up, so sqrt(pool1*pool2) >= newLPTokenBalance
+        // is guaranteed to hold. A precision loss failure is not expected.
+        if (sb.rules().enabled(fixCleanup3_3_0) && sb.rules().enabled(fixAMMv1_3))
+        {
+            if (auto const ter = checkAMMPrecisionLoss(
+                    sb, ammAccountID, ctx_.tx[sfAsset], ctx_.tx[sfAsset2], newLPTokenBalance, j_);
+                !isTesSuccess(ter))
+            {
+                UNREACHABLE("xrpl::AMMDeposit::applyGuts : AMM precision loss");
+                return {ter, false};  // LCOV_EXCL_LINE
+            }
+        }
         ammSle->setFieldAmount(sfLPTokenBalance, newLPTokenBalance);
         // LP depositing into AMM empty state gets the auction slot
         // and the voting
@@ -519,7 +562,8 @@ AMMDeposit::deposit(
         {
             auto const& lpIssue = lpTokensDeposit.get();
             // Adjust the reserve if LP doesn't have LPToken trustline
-            auto const sle = view.read(keylet::line(accountID_, lpIssue.account, lpIssue.currency));
+            auto const sle =
+                view.read(keylet::trustLine(accountID_, lpIssue.account, lpIssue.currency));
             if (xrpLiquid(view, accountID_, !sle, j_) >= depositAmount)
                 return tesSUCCESS;
         }
@@ -1013,10 +1057,7 @@ AMMDeposit::equalDepositInEmptyState(
 }
 
 void
-AMMDeposit::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+AMMDeposit::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/dex/AMMVote.cpp b/src/libxrpl/tx/transactors/dex/AMMVote.cpp
index 391f7e1ecc..23604d46cc 100644
--- a/src/libxrpl/tx/transactors/dex/AMMVote.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMVote.cpp
@@ -22,7 +22,6 @@
 
 #include 
 #include 
-#include 
 #include 
 #include 
 
@@ -249,10 +248,7 @@ AMMVote::doApply()
 }
 
 void
-AMMVote::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+AMMVote::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp
index a4fd1518f5..14ed5a4646 100644
--- a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp
@@ -7,7 +7,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -26,7 +25,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 
@@ -34,7 +32,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -93,12 +90,7 @@ AMMWithdraw::preflight(PreflightContext const& ctx)
         if (lpTokens || amount || amount2 || ePrice)
             return temMALFORMED;
     }
-    else if (ctx.tx.isFlag(tfOneAssetWithdrawAll))
-    {
-        if (!amount || lpTokens || amount2 || ePrice)
-            return temMALFORMED;
-    }
-    else if (ctx.tx.isFlag(tfSingleAsset))
+    else if (ctx.tx.isFlag(tfOneAssetWithdrawAll) || ctx.tx.isFlag(tfSingleAsset))
     {
         if (!amount || lpTokens || amount2 || ePrice)
             return temMALFORMED;
@@ -241,25 +233,37 @@ AMMWithdraw::preclaim(PreclaimContext const& ctx)
                     << "AMM Withdraw: account is not authorized, " << amount->asset();
                 return ter;
             }
-            // AMM account or currency frozen
-            if (isFrozen(ctx.view, ammAccountID, amount->asset()))
+            if (ctx.view.rules().enabled(fixCleanup3_3_0))
             {
-                JLOG(ctx.j.debug())
-                    << "AMM Withdraw: AMM account or currency is frozen, " << to_string(accountID);
-                return tecFROZEN;
+                if (auto const ret = checkWithdrawFreeze(
+                        ctx.view, ammAccountID, accountID, accountID, amount->asset()))
+                {
+                    JLOG(ctx.j.debug()) << "AMM Withdraw: frozen, " << to_string(accountID) << " "
+                                        << to_string(amount->asset());
+                    return ret;
+                }
             }
-            // Account frozen
-            if (isIndividualFrozen(ctx.view, accountID, amount->asset()))
+            else
             {
-                JLOG(ctx.j.debug()) << "AMM Withdraw: account is frozen, " << to_string(accountID)
-                                    << " " << to_string(amount->asset());
-                return tecFROZEN;
+                // AMM account or currency frozen
+                if (auto const ter = checkFrozen(ctx.view, ammAccountID, amount->asset());
+                    !isTesSuccess(ter))
+                {
+                    JLOG(ctx.j.debug())
+                        << "AMM Withdraw: AMM account or currency is frozen or locked, "
+                        << to_string(accountID);
+                    return ter;
+                }
+                // Account frozen
+                if (auto const ter = checkIndividualFrozen(ctx.view, accountID, amount->asset());
+                    !isTesSuccess(ter))
+                {
+                    JLOG(ctx.j.debug())
+                        << "AMM Withdraw: account is frozen or locked, " << to_string(accountID)
+                        << " " << to_string(amount->asset());
+                    return ter;
+                }
             }
-
-            if (auto const ter =
-                    checkMPTTxAllowed(ctx.view, ttAMM_WITHDRAW, amount->asset(), accountID);
-                !isTesSuccess(ter))
-                return ter;
         }
         return tesSUCCESS;
     };
@@ -308,6 +312,25 @@ AMMWithdraw::preclaim(PreclaimContext const& ctx)
     return tesSUCCESS;
 }
 
+FreezeHandling
+AMMWithdraw::issuerFreezeHandling() const
+{
+    // When the withdrawer is the issuer of a pool asset, the issuer can
+    // always receive their own token — even when the pool is frozen.
+    // Use IgnoreFreeze so ammHolds returns real balances instead of zero.
+    if (!ctx_.view().rules().enabled(fixCleanup3_3_0))
+        return FreezeHandling::ZeroIfFrozen;
+
+    auto const asset1 = Asset{ctx_.tx[sfAsset]};
+    auto const asset2 = Asset{ctx_.tx[sfAsset2]};
+    if (!asset1.native() && accountID_ == asset1.getIssuer())
+        return FreezeHandling::IgnoreFreeze;
+    if (!asset2.native() && accountID_ == asset2.getIssuer())
+        return FreezeHandling::IgnoreFreeze;
+
+    return FreezeHandling::ZeroIfFrozen;
+}
+
 std::pair
 AMMWithdraw::applyGuts(Sandbox& sb)
 {
@@ -335,18 +358,19 @@ AMMWithdraw::applyGuts(Sandbox& sb)
 
     auto const tfee = getTradingFee(ctx_.view(), *ammSle, accountID_);
 
+    auto const freezeHandling = issuerFreezeHandling();
+
     auto const expected = ammHolds(
         sb,
         *ammSle,
         amount ? amount->asset() : std::optional{},
         amount2 ? amount2->asset() : std::optional{},
-        FreezeHandling::ZeroIfFrozen,
+        freezeHandling,
         AuthHandling::ZeroIfUnauthorized,
         ctx_.journal);
     if (!expected)
         return {expected.error(), false};
     auto const [amountBalance, amount2Balance, lptAMMBalance] = *expected;
-
     auto const subTxType = ctx_.tx.getFlags() & tfWithdrawSubTx;
 
     auto const [result, newLPTokenBalance] = [&,
@@ -412,6 +436,16 @@ AMMWithdraw::applyGuts(Sandbox& sb)
     if (!isTesSuccess(result))
         return {result, false};
 
+    if (sb.rules().enabled(fixCleanup3_3_0) && sb.rules().enabled(fixAMMv1_3))
+    {
+        if (auto const ter = checkAMMPrecisionLoss(
+                sb, ammAccountID, ctx_.tx[sfAsset], ctx_.tx[sfAsset2], newLPTokenBalance, j_);
+            !isTesSuccess(ter))
+        {
+            return {ter, false};
+        }
+    }
+
     auto const res = deleteAMMAccountIfEmpty(
         sb, ammSle, newLPTokenBalance, ctx_.tx[sfAsset], ctx_.tx[sfAsset2], j_);
     // LCOV_EXCL_START
@@ -465,7 +499,7 @@ AMMWithdraw::withdraw(
         lpTokensAMMBalance,
         lpTokensWithdraw,
         tfee,
-        FreezeHandling::ZeroIfFrozen,
+        issuerFreezeHandling(),
         AuthHandling::ZeroIfUnauthorized,
         isWithdrawAll(ctx_.tx),
         preFeeBalance_,
@@ -611,8 +645,8 @@ AMMWithdraw::withdraw(
         bool const isIssue = asset.holds();
         bool const assetNotExists = [&] {
             if (isIssue)
-                return !view.exists(keylet::line(account, asset.get()));
-            auto const issuanceKey = keylet::mptIssuance(asset.get());
+                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;
@@ -635,10 +669,6 @@ AMMWithdraw::withdraw(
             auto const balanceAdj = isIssue ? std::max(priorBalance, balance.xrp()) : priorBalance;
             if (balanceAdj < reserve)
                 return tecINSUFFICIENT_RESERVE;
-
-            // Update owner count.
-            if (!isIssue)
-                adjustOwnerCount(view, sleAccount, 1, journal);
         }
         return tesSUCCESS;
     };
@@ -756,7 +786,7 @@ AMMWithdraw::equalWithdrawTokens(
         lpTokens,
         lpTokensWithdraw,
         tfee,
-        FreezeHandling::ZeroIfFrozen,
+        issuerFreezeHandling(),
         AuthHandling::ZeroIfUnauthorized,
         isWithdrawAll(ctx_.tx),
         preFeeBalance_,
@@ -767,7 +797,7 @@ AMMWithdraw::equalWithdrawTokens(
 std::pair
 AMMWithdraw::deleteAMMAccountIfEmpty(
     Sandbox& sb,
-    std::shared_ptr const ammSle,
+    SLE::pointer const ammSle,
     STAmount const& lpTokenBalance,
     Asset const& asset1,
     Asset const& asset2,
@@ -1101,10 +1131,13 @@ AMMWithdraw::singleWithdrawEPrice(
     // t = T*(T + A*E*(f - 2))/(T*f - A*E)
     Number const ae = amountBalance * ePrice;
     auto const f = getFee(tfee);
-    auto tokNoRoundCb = [&] {
-        return lptAMMBalance * (lptAMMBalance + ae * (f - 2)) / (lptAMMBalance * f - ae);
-    };
-    auto tokProdCb = [&] { return (lptAMMBalance + ae * (f - 2)) / (lptAMMBalance * f - ae); };
+    auto const denom = lptAMMBalance * f - ae;
+    // fixCleanup3_3_0: guard against division by zero
+    // when ePrice == lptAMMBalance*f/amountBalance
+    if (view.rules().enabled(fixCleanup3_3_0) && denom == beast::kZero)
+        return {tecAMM_FAILED, STAmount{}};
+    auto tokNoRoundCb = [&] { return lptAMMBalance * (lptAMMBalance + ae * (f - 2)) / denom; };
+    auto tokProdCb = [&] { return (lptAMMBalance + ae * (f - 2)) / denom; };
     auto const tokensAdj =
         getRoundedLPTokens(view.rules(), tokNoRoundCb, lptAMMBalance, tokProdCb, IsDeposit::No);
     if (tokensAdj <= beast::kZero)
@@ -1146,10 +1179,7 @@ AMMWithdraw::isWithdrawAll(STTx const& tx)
     return WithdrawAll::No;
 }
 void
-AMMWithdraw::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+AMMWithdraw::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/dex/OfferCancel.cpp b/src/libxrpl/tx/transactors/dex/OfferCancel.cpp
index 42692b59cc..0dea5fa967 100644
--- a/src/libxrpl/tx/transactors/dex/OfferCancel.cpp
+++ b/src/libxrpl/tx/transactors/dex/OfferCancel.cpp
@@ -10,8 +10,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 NotTEC
@@ -70,10 +68,7 @@ OfferCancel::doApply()
 }
 
 void
-OfferCancel::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+OfferCancel::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp
index 69c101644c..ed5a22e3db 100644
--- a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp
+++ b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp
@@ -94,6 +94,12 @@ OfferCreate::preflight(PreflightContext const& ctx)
     if (tx.isFlag(tfHybrid) && !tx.isFieldPresent(sfDomainID))
         return temINVALID_FLAG;
 
+    // A zero DomainID is invalid for a PermissionedDomain ledger entry because
+    // keylet::permissionedDomain(uint256) uses the DomainID as the ledger key.
+    if (auto const domainID = tx[~sfDomainID];
+        ctx.rules.enabled(fixCleanup3_2_0) && domainID && *domainID == beast::kZero)
+        return temMALFORMED;
+
     bool const bImmediateOrCancel(tx.isFlag(tfImmediateOrCancel));
     bool const bFillOrKill(tx.isFlag(tfFillOrKill));
 
@@ -181,11 +187,15 @@ OfferCreate::preclaim(PreclaimContext const& ctx)
 
     auto viewJ = ctx.registry.get().getJournal("View");
 
-    if (isGlobalFrozen(ctx.view, saTakerPays.asset()) ||
-        isGlobalFrozen(ctx.view, saTakerGets.asset()))
+    if (auto const ter = checkGlobalFrozen(ctx.view, saTakerPays.asset()); !isTesSuccess(ter))
     {
-        JLOG(ctx.j.debug()) << "Offer involves frozen asset";
-        return tecFROZEN;
+        JLOG(ctx.j.debug()) << "Offer involves frozen or locked asset";
+        return ter;
+    }
+    if (auto const ter = checkGlobalFrozen(ctx.view, saTakerGets.asset()); !isTesSuccess(ter))
+    {
+        JLOG(ctx.j.debug()) << "Offer involves frozen or locked asset";
+        return ter;
     }
 
     // Allow unfunded MPT for issuer (OutstandingAmount >= MaximumAmount)
@@ -274,7 +284,7 @@ OfferCreate::checkAcceptAsset(
             auto const& issuer = issue.getIssuer();
             if (issuerAccount->isFlag(lsfRequireAuth))
             {
-                auto const trustLine = view.read(keylet::line(id, issuer, issue.currency));
+                auto const trustLine = view.read(keylet::trustLine(id, issuer, issue.currency));
 
                 if (!trustLine)
                 {
@@ -298,7 +308,7 @@ OfferCreate::checkAcceptAsset(
                 }
             }
 
-            auto const trustLine = view.read(keylet::line(id, issue.account, issue.currency));
+            auto const trustLine = view.read(keylet::trustLine(id, issue.account, issue.currency));
 
             if (!trustLine)
             {
@@ -320,7 +330,13 @@ OfferCreate::checkAcceptAsset(
         [&](MPTIssue const& issue) -> TER {
             // WeakAuth - don't check if MPToken exists since it's created
             // if needed.
-            return requireAuth(view, issue, id, AuthType::WeakAuth);
+            if (auto const ter = requireAuth(view, issue, id, AuthType::WeakAuth);
+                !isTesSuccess(ter))
+            {
+                return ter;
+            }
+
+            return checkFrozen(view, id, issue);
         });
 }
 
@@ -543,10 +559,11 @@ OfferCreate::formatAmount(STAmount const& amount)
 TER
 OfferCreate::applyHybrid(
     Sandbox& sb,
-    std::shared_ptr sleOffer,
+    STLedgerEntry::pointer sleOffer,
     Keylet const& offerKey,
     STAmount const& saTakerPays,
     STAmount const& saTakerGets,
+    std::uint64_t openRate,
     std::function)> const& setDir)
 {
     if (!sleOffer->isFieldPresent(sfDomainID))
@@ -558,7 +575,7 @@ OfferCreate::applyHybrid(
     // if offer is hybrid, need to also place into open offer dir
     Book const book{saTakerPays.asset(), saTakerGets.asset(), std::nullopt};
 
-    auto dir = keylet::quality(keylet::kBook(book), getRate(saTakerGets, saTakerPays));
+    auto dir = keylet::quality(keylet::book(book), openRate);
     bool const bookExists = sb.exists(dir);
 
     auto const bookNode = sb.dirAppend(dir, offerKey, [&](SLE::ref sle) {
@@ -870,7 +887,7 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
     // Hybrid domain offer - BookDirectory points to domain directory,
     // and AdditionalBooks field stores one entry that points to the open
     // directory
-    auto dir = keylet::quality(keylet::kBook(book), uRate);
+    auto dir = keylet::quality(keylet::book(book), uRate);
     bool const bookExisted = static_cast(sb.peek(dir));
 
     auto setBookDir = [&](SLE::ref sle, std::optional const& maybeDomain) {
@@ -924,8 +941,16 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
     // if it's a hybrid offer, set hybrid flag, and create an open dir
     if (bHybrid)
     {
+        // Pre-fixCleanup3_2_0: the open-book directory quality was computed
+        // from post-crossing amounts, which may differ from the original rate
+        // due to rounding in rate preservation. Post-fixCleanup3_2_0: use the
+        // original placement rate so the open-book directory quality matches
+        // the domain-book directory.
+        auto const openRate = ctx_.view().rules().enabled(fixCleanup3_2_0)
+            ? uRate
+            : getRate(saTakerGets, saTakerPays);
         auto const res =
-            applyHybrid(sb, sleOffer, offerIndex, saTakerPays, saTakerGets, setBookDir);
+            applyHybrid(sb, sleOffer, offerIndex, saTakerPays, saTakerGets, openRate, setBookDir);
         if (!isTesSuccess(res))
             return {res, true};  // LCOV_EXCL_LINE
     }
@@ -965,10 +990,7 @@ OfferCreate::doApply()
 }
 
 void
-OfferCreate::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+OfferCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/did/DIDDelete.cpp b/src/libxrpl/tx/transactors/did/DIDDelete.cpp
index 4b4fb47169..90aa21d8a1 100644
--- a/src/libxrpl/tx/transactors/did/DIDDelete.cpp
+++ b/src/libxrpl/tx/transactors/did/DIDDelete.cpp
@@ -16,8 +16,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 NotTEC
@@ -37,11 +35,7 @@ DIDDelete::deleteSLE(ApplyContext& ctx, Keylet sleKeylet, AccountID const owner)
 }
 
 TER
-DIDDelete::deleteSLE(
-    ApplyView& view,
-    std::shared_ptr sle,
-    AccountID const owner,
-    beast::Journal j)
+DIDDelete::deleteSLE(ApplyView& view, SLE::pointer sle, AccountID const owner, beast::Journal j)
 {
     // Remove object from owner directory
     if (!view.dirRemove(keylet::ownerDir(owner), (*sle)[sfOwnerNode], sle->key(), true))
@@ -71,10 +65,7 @@ DIDDelete::doApply()
 }
 
 void
-DIDDelete::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+DIDDelete::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/did/DIDSet.cpp b/src/libxrpl/tx/transactors/did/DIDSet.cpp
index a930c3c754..1392581bb0 100644
--- a/src/libxrpl/tx/transactors/did/DIDSet.cpp
+++ b/src/libxrpl/tx/transactors/did/DIDSet.cpp
@@ -63,7 +63,7 @@ DIDSet::preflight(PreflightContext const& ctx)
 }
 
 static TER
-addSLE(ApplyContext& ctx, std::shared_ptr const& sle, AccountID const& owner)
+addSLE(ApplyContext& ctx, SLE::ref sle, AccountID const& owner)
 {
     auto const sleAccount = ctx.view().peek(keylet::account(owner));
     if (!sleAccount)
@@ -150,10 +150,7 @@ DIDSet::doApply()
 }
 
 void
-DIDSet::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+DIDSet::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/escrow/Escrow.cpp b/src/libxrpl/tx/transactors/escrow/Escrow.cpp
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp b/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp
index 123f83a1a6..55a9133e1e 100644
--- a/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp
+++ b/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp
@@ -23,7 +23,6 @@
 #include 
 #include 
 
-#include 
 #include 
 
 namespace xrpl {
@@ -73,7 +72,7 @@ escrowCancelPreclaimHelper(
         return tecINTERNAL;  // LCOV_EXCL_LINE
 
     // If the mpt does not exist, return tecOBJECT_NOT_FOUND
-    auto const issuanceKey = keylet::mptIssuance(amount.get().getMptID());
+    auto const issuanceKey = keylet::mptokenIssuance(amount.get().getMptID());
     auto const sleIssuance = ctx.view.read(issuanceKey);
     if (!sleIssuance)
         return tecOBJECT_NOT_FOUND;
@@ -220,10 +219,7 @@ EscrowCancel::doApply()
 }
 
 void
-EscrowCancel::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+EscrowCancel::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
index 0b1db125f6..0d83885349 100644
--- a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
+++ b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
@@ -81,6 +81,16 @@ EscrowCreate::makeTxConsequences(PreflightContext const& ctx)
     return TxConsequences{ctx.tx, isXRP(amount) ? amount.xrp() : beast::kZero};
 }
 
+bool
+EscrowCreate::checkExtraFeatures(PreflightContext const& ctx)
+{
+    // Only require featureMPTokensV1 when the escrow amount is an MPT and
+    // fixCleanup3_2_0 is active; XRP/IOU escrows are unaffected by this gate.
+    if (ctx.rules.enabled(fixCleanup3_2_0) && ctx.tx[sfAmount].holds())
+        return ctx.rules.enabled(featureMPTokensV1);
+    return true;
+}
+
 template 
 static NotTEC
 escrowCreatePreflightHelper(PreflightContext const& ctx);
@@ -103,7 +113,7 @@ template <>
 NotTEC
 escrowCreatePreflightHelper(PreflightContext const& ctx)
 {
-    if (!ctx.rules.enabled(featureMPTokensV1))
+    if (!ctx.rules.enabled(fixCleanup3_2_0) && !ctx.rules.enabled(featureMPTokensV1))
         return temDISABLED;
 
     auto const amount = ctx.tx[sfAmount];
@@ -198,7 +208,7 @@ escrowCreatePreclaimHelper(
         return tecNO_PERMISSION;
 
     // If the account does not have a trustline to the issuer, return tecNO_LINE
-    auto const sleRippleState = ctx.view.read(keylet::line(account, issuer, issue.currency));
+    auto const sleRippleState = ctx.view.read(keylet::trustLine(account, issuer, issue.currency));
     if (!sleRippleState)
         return tecNO_LINE;
 
@@ -261,7 +271,7 @@ escrowCreatePreclaimHelper(
         return tecNO_PERMISSION;
 
     // If the mpt does not exist, return tecOBJECT_NOT_FOUND
-    auto const issuanceKey = keylet::mptIssuance(amount.get().getMptID());
+    auto const issuanceKey = keylet::mptokenIssuance(amount.get().getMptID());
     auto const sleIssuance = ctx.view.read(issuanceKey);
     if (!sleIssuance)
         return tecOBJECT_NOT_FOUND;
@@ -531,10 +541,7 @@ EscrowCreate::doApply()
 }
 
 void
-EscrowCreate::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+EscrowCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp
index 13bd4b1682..7f1f9ec078 100644
--- a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp
+++ b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp
@@ -30,7 +30,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 
@@ -173,7 +172,7 @@ escrowFinishPreclaimHelper(
         return tesSUCCESS;
 
     // If the mpt does not exist, return tecOBJECT_NOT_FOUND
-    auto const issuanceKey = keylet::mptIssuance(amount.get().getMptID());
+    auto const issuanceKey = keylet::mptokenIssuance(amount.get().getMptID());
     auto const sleIssuance = ctx.view.read(issuanceKey);
     if (!sleIssuance)
         return tecOBJECT_NOT_FOUND;
@@ -401,10 +400,7 @@ EscrowFinish::doApply()
 }
 
 void
-EscrowFinish::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+EscrowFinish::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp
index ab26353a1c..9bb84e878e 100644
--- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -12,6 +11,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -27,7 +27,7 @@
 #include 
 #include 
 
-#include 
+#include 
 #include 
 #include 
 
@@ -83,7 +83,7 @@ LoanBrokerCoverClawback::preflight(PreflightContext const& ctx)
     return tesSUCCESS;
 }
 
-Expected
+std::expected
 determineBrokerID(ReadView const& view, STTx const& tx)
 {
     // If the broker ID was provided in the transaction, that's all we
@@ -96,7 +96,7 @@ determineBrokerID(ReadView const& view, STTx const& tx)
     // because that should have been rejected in preflight().
     auto const dstAmount = tx[~sfAmount];
     if (!dstAmount || !dstAmount->holds())
-        return Unexpected{tecINTERNAL};  // LCOV_EXCL_LINE
+        return std::unexpected{tecINTERNAL};  // LCOV_EXCL_LINE
 
     // Every trust line is bidirectional. Both sides are simultaneously
     // issuer and holder. For this transaction, the Account is acting as
@@ -112,7 +112,7 @@ determineBrokerID(ReadView const& view, STTx const& tx)
 
     // If the account was not found, the transaction can't go further.
     if (!sle)
-        return Unexpected{tecNO_ENTRY};
+        return std::unexpected{tecNO_ENTRY};
 
     // If the account was found, and has a LoanBrokerID (and therefore
     // is a pseudo-account), that's the
@@ -122,11 +122,11 @@ determineBrokerID(ReadView const& view, STTx const& tx)
 
     // If the account does not have a LoanBrokerID, the transaction
     // can't go further, even if it's a different type of Pseudo-account.
-    return Unexpected{tecOBJECT_NOT_FOUND};
+    return std::unexpected{tecOBJECT_NOT_FOUND};
     // Or tecWRONG_ASSET?
 }
 
-Expected
+std::expected
 determineAsset(
     ReadView const& view,
     AccountID const& account,
@@ -153,26 +153,36 @@ determineAsset(
         return Issue{amount.get().currency, account};
     }
 
-    return Unexpected(tecWRONG_ASSET);
+    return std::unexpected(tecWRONG_ASSET);
 }
 
-Expected
+std::expected
 determineClawAmount(
     SLE const& sleBroker,
     Asset const& vaultAsset,
-    std::optional const& amount)
+    std::optional const& amount,
+    SLE::const_ref vaultSle,
+    Rules const& rules)
 {
     auto const maxClawAmount = [&]() {
-        // Always round the minimum required up
-        NumberRoundModeGuard const mg1(Number::RoundingMode::Upward);
-        auto const minRequiredCover =
-            tenthBipsOfValue(sleBroker[sfDebtTotal], TenthBips32(sleBroker[sfCoverRateMinimum]));
+        auto const minRequiredCover = [&]() {
+            if (rules.enabled(fixCleanup3_2_0))
+            {
+                return minimumBrokerCover(
+                    sleBroker[sfDebtTotal], TenthBips32(sleBroker[sfCoverRateMinimum]), vaultSle);
+            }
+
+            // Always round the minimum required up
+            NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
+            return tenthBipsOfValue(
+                sleBroker[sfDebtTotal], TenthBips32(sleBroker[sfCoverRateMinimum]));
+        }();
         // The subtraction probably won't round, but round down if it does.
-        NumberRoundModeGuard const mg2(Number::RoundingMode::Downward);
+        NumberRoundModeGuard const mg(Number::RoundingMode::Downward);
         return sleBroker[sfCoverAvailable] - minRequiredCover;
     }();
     if (maxClawAmount <= beast::kZero)
-        return Unexpected(tecINSUFFICIENT_FUNDS);
+        return std::unexpected(tecINSUFFICIENT_FUNDS);
 
     // Use the vaultAsset here, because it will be the right type in all
     // circumstances. The amount may be an IOU indicating the pseudo-account's
@@ -208,7 +218,7 @@ preclaimHelper(
     SLE const& sleIssuer,
     STAmount const& clawAmount)
 {
-    auto const issuanceKey = keylet::mptIssuance(clawAmount.get().getMptID());
+    auto const issuanceKey = keylet::mptokenIssuance(clawAmount.get().getMptID());
     auto const sleIssuance = ctx.view.read(issuanceKey);
     if (!sleIssuance)
         return tecOBJECT_NOT_FOUND;
@@ -235,7 +245,7 @@ LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx)
     auto const brokerID = *findBrokerID;
     auto const amount = tx[~sfAmount];
 
-    auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID));
+    auto const sleBroker = ctx.view.read(keylet::loanBroker(brokerID));
     if (!sleBroker)
     {
         JLOG(ctx.j.warn()) << "LoanBroker does not exist.";
@@ -283,7 +293,8 @@ LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx)
         }
     }
 
-    auto const findClawAmount = determineClawAmount(*sleBroker, vaultAsset, amount);
+    auto const findClawAmount =
+        determineClawAmount(*sleBroker, vaultAsset, amount, vault, ctx.view.rules());
     if (!findClawAmount)
     {
         JLOG(ctx.j.warn()) << "LoanBroker cover is already at minimum.";
@@ -291,6 +302,10 @@ LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx)
     }
     STAmount const& clawAmount = *findClawAmount;
 
+    if (auto const ret = canApplyToBrokerCover(
+            ctx.view, sleBroker, vaultAsset, clawAmount, ctx.j, "LoanBrokerCoverClawback"))
+        return ret;
+
     // Explicitly check the balance of the trust line / MPT to make sure the
     // balance is actually there. It should always match `sfCoverAvailable`, so
     // if there isn't, this is an internal error.
@@ -329,7 +344,7 @@ LoanBrokerCoverClawback::doApply()
     auto const brokerID = *findBrokerID;
     auto const amount = tx[~sfAmount];
 
-    auto sleBroker = view().peek(keylet::loanbroker(brokerID));
+    auto sleBroker = view().peek(keylet::loanBroker(brokerID));
     if (!sleBroker)
         return tecINTERNAL;  // LCOV_EXCL_LINE
 
@@ -341,7 +356,8 @@ LoanBrokerCoverClawback::doApply()
 
     auto const vaultAsset = vault->at(sfAsset);
 
-    auto const findClawAmount = determineClawAmount(*sleBroker, vaultAsset, amount);
+    auto const findClawAmount =
+        determineClawAmount(*sleBroker, vaultAsset, amount, vault, view().rules());
     if (!findClawAmount)
         return tecINTERNAL;  // LCOV_EXCL_LINE
     STAmount const& clawAmount = *findClawAmount;
@@ -360,10 +376,7 @@ LoanBrokerCoverClawback::doApply()
 }
 
 void
-LoanBrokerCoverClawback::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+LoanBrokerCoverClawback::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverDeposit.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverDeposit.cpp
index 04905d5ea3..69b28b57af 100644
--- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverDeposit.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverDeposit.cpp
@@ -1,9 +1,11 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -14,8 +16,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 bool
@@ -43,13 +43,15 @@ LoanBrokerCoverDeposit::preflight(PreflightContext const& ctx)
 TER
 LoanBrokerCoverDeposit::preclaim(PreclaimContext const& ctx)
 {
+    auto const fix320Enabled = ctx.view.rules().enabled(fixCleanup3_2_0);
+    auto const fix330Enabled = ctx.view.rules().enabled(fixCleanup3_3_0);
     auto const& tx = ctx.tx;
 
     auto const account = tx[sfAccount];
     auto const brokerID = tx[sfLoanBrokerID];
     auto const amount = tx[sfAmount];
 
-    auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID));
+    auto const sleBroker = ctx.view.read(keylet::loanBroker(brokerID));
     if (!sleBroker)
     {
         JLOG(ctx.j.warn()) << "LoanBroker does not exist.";
@@ -77,16 +79,47 @@ LoanBrokerCoverDeposit::preclaim(PreclaimContext const& ctx)
     // Cannot transfer a non-transferable Asset
     if (auto const ret = canTransfer(ctx.view, vaultAsset, account, pseudoAccountID))
         return ret;
-    // Cannot transfer a frozen Asset
-    if (auto const ret = checkFrozen(ctx.view, account, vaultAsset))
-        return ret;
-    // Pseudo-account cannot receive if asset is deep frozen
-    if (auto const ret = checkDeepFrozen(ctx.view, pseudoAccountID, vaultAsset))
-        return ret;
+
+    if (fix330Enabled)
+    {
+        if (auto const ret = checkDepositFreeze(ctx.view, account, pseudoAccountID, vaultAsset))
+            return ret;
+    }
+    else
+    {
+        if (auto const ret = checkFrozen(ctx.view, account, vaultAsset))
+            return ret;
+
+        if (auto const ret = checkDeepFrozen(ctx.view, pseudoAccountID, vaultAsset))
+            return ret;
+    }
+
     // Cannot transfer unauthorized asset
     if (auto const ret = requireAuth(ctx.view, vaultAsset, account, AuthType::StrongAuth))
         return ret;
 
+    // Deposit must round the amount Downward to cover scale and then reuse that rounded
+    // value for the actual transfer in doApply — otherwise implicit round-to-nearest during
+    // `sfCoverAvailable  +=` could credit the broker more than the depositor paid  Computing it
+    // here in preclaim lets  us reject sub-cover-scale dust early with tecPRECISION_LOSS instead of
+    // failing only in  doApply.
+    auto const roundedAmount = [&]() -> STAmount {
+        if (!fix320Enabled)
+            return tx[sfAmount];
+
+        return roundToScale(
+            tx[sfAmount],
+            scale(sleBroker->at(sfCoverAvailable), vaultAsset),
+            Number::RoundingMode::Downward);
+    }();
+
+    if (fix320Enabled && roundedAmount == beast::kZero)
+    {
+        JLOG(ctx.j.warn()) << "LoanBrokerCoverDeposit: deposit amount: " << tx[sfAmount]
+                           << " is zero at loan broker scale";
+        return tecPRECISION_LOSS;
+    }
+
     if (accountHolds(
             ctx.view,
             account,
@@ -94,7 +127,7 @@ LoanBrokerCoverDeposit::preclaim(PreclaimContext const& ctx)
             FreezeHandling::ZeroIfFrozen,
             AuthHandling::ZeroIfUnauthorized,
             ctx.j,
-            SpendableHandling::FullBalance) < amount)
+            SpendableHandling::FullBalance) < roundedAmount)
         return tecINSUFFICIENT_FUNDS;
 
     return tesSUCCESS;
@@ -106,9 +139,7 @@ LoanBrokerCoverDeposit::doApply()
     auto const& tx = ctx_.tx;
 
     auto const brokerID = tx[sfLoanBrokerID];
-    auto const amount = tx[sfAmount];
-
-    auto broker = view().peek(keylet::loanbroker(brokerID));
+    auto broker = view().peek(keylet::loanBroker(brokerID));
     if (!broker)
         return tecINTERNAL;  // LCOV_EXCL_LINE
 
@@ -117,9 +148,32 @@ LoanBrokerCoverDeposit::doApply()
         return tecINTERNAL;  // LCOV_EXCL_LINE
 
     auto const vaultAsset = vault->at(sfAsset);
-
     auto const brokerPseudoID = broker->at(sfAccount);
 
+    // Re-round here (matches preclaim) so the same cover-scale-quantized
+    // value drives both the trustline transfer and the cover increment;
+    // see the rationale comment in preclaim.
+    bool const fix320Enabled = view().rules().enabled(fixCleanup3_2_0);
+    auto const amount = [&]() -> STAmount {
+        if (!fix320Enabled)
+            return tx[sfAmount];
+
+        return roundToScale(
+            tx[sfAmount],
+            scale(broker->at(sfCoverAvailable), vaultAsset),
+            Number::RoundingMode::Downward);
+    }();
+
+    // We validated zero-amount in preclaim, if we ended up with zero now, fail hard.
+    if (amount == beast::kZero)
+    {
+        // LCOV_EXCL_START
+        JLOG(j_.error()) << "LoanBrokerCoverDeposit: deposit amount: " << tx[sfAmount]
+                         << " is zero";
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
+
     // Transfer assets from depositor to pseudo-account.
     if (auto ter =
             accountSend(view(), accountID_, brokerPseudoID, amount, j_, WaiveTransferFee::Yes))
@@ -135,10 +189,7 @@ LoanBrokerCoverDeposit::doApply()
 }
 
 void
-LoanBrokerCoverDeposit::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+LoanBrokerCoverDeposit::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp
index 17a9683690..008857a4ad 100644
--- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp
@@ -7,6 +7,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -19,8 +20,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 bool
@@ -56,6 +55,8 @@ LoanBrokerCoverWithdraw::preflight(PreflightContext const& ctx)
 TER
 LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
 {
+    auto const fix320Enabled = ctx.view.rules().enabled(fixCleanup3_2_0);
+    auto const fix330Enabled = ctx.view.rules().enabled(fixCleanup3_3_0);
     auto const& tx = ctx.tx;
 
     auto const account = tx[sfAccount];
@@ -69,7 +70,7 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
         JLOG(ctx.j.warn()) << "Trying to withdraw into a pseudo-account.";
         return tecPSEUDO_ACCOUNT;
     }
-    auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID));
+    auto const sleBroker = ctx.view.read(keylet::loanBroker(brokerID));
     if (!sleBroker)
     {
         JLOG(ctx.j.warn()) << "LoanBroker does not exist.";
@@ -93,10 +94,19 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
     if (amount.asset() != vaultAsset)
         return tecWRONG_ASSET;
 
+    // Helper handles both IOU and MPT correctly without explicit branching.
+    if (auto const ret = canApplyToBrokerCover(
+            ctx.view, sleBroker, vaultAsset, amount, ctx.j, "LoanBrokerCoverWithdraw"))
+        return ret;
+
     // The broker's pseudo-account is the source of funds.
     auto const pseudoAccountID = sleBroker->at(sfAccount);
-    // Cannot transfer a non-transferable Asset
-    if (auto const ret = canTransfer(ctx.view, vaultAsset, pseudoAccountID, dstAcct))
+    // Post-fixCleanup3_2_0: cover withdraw is a recovery path that bypasses
+    // the lsfMPTCanTransfer flag check, so an issuer cannot trap a broker's
+    // first-loss capital. Other transferability checks (IOU NoRipple, freeze,
+    // requireAuth) still apply.
+    auto const waive = fix320Enabled ? WaiveMPTCanTransfer::Yes : WaiveMPTCanTransfer::No;
+    if (auto const ret = canTransfer(ctx.view, vaultAsset, pseudoAccountID, dstAcct, waive))
         return ret;
 
     // Withdrawal to a 3rd party destination account is essentially a transfer.
@@ -116,21 +126,35 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
     if (auto const ter = requireAuth(ctx.view, vaultAsset, dstAcct, authType))
         return ter;
 
-    // Check for freezes, unless sending directly to the issuer
-    if (dstAcct != vaultAsset.getIssuer())
+    if (fix330Enabled)
     {
-        // Cannot send a frozen Asset
-        if (auto const ret = checkFrozen(ctx.view, pseudoAccountID, vaultAsset))
-            return ret;
-        // Destination account cannot receive if asset is deep frozen
-        if (auto const ret = checkDeepFrozen(ctx.view, dstAcct, vaultAsset))
+        if (auto const ret =
+                checkWithdrawFreeze(ctx.view, pseudoAccountID, account, dstAcct, vaultAsset))
             return ret;
     }
+    else
+    {  // Check for freezes, unless sending directly to the issuer
+        if (dstAcct != vaultAsset.getIssuer())
+        {
+            // Cannot send a frozen Asset
+            if (auto const ret = checkFrozen(ctx.view, pseudoAccountID, vaultAsset))
+                return ret;
+            // Destination account cannot receive if asset is deep frozen
+            if (auto const ret = checkDeepFrozen(ctx.view, dstAcct, vaultAsset))
+                return ret;
+        }
+    }
 
     auto const coverAvail = sleBroker->at(sfCoverAvailable);
     // Cover Rate is in 1/10 bips units
     auto const currentDebtTotal = sleBroker->at(sfDebtTotal);
     auto const minimumCover = [&]() {
+        if (fix320Enabled)
+        {
+            return minimumBrokerCover(
+                currentDebtTotal, TenthBips32{sleBroker->at(sfCoverRateMinimum)}, vault);
+        }
+
         // Always round the minimum required up.
         // Applies to `tenthBipsOfValue` as well as `roundToAsset`.
         NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
@@ -144,11 +168,15 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
     if ((coverAvail - amount) < minimumCover)
         return tecINSUFFICIENT_FUNDS;
 
+    auto const freezeHandling = fix330Enabled && dstAcct == vaultAsset.getIssuer()
+        ? FreezeHandling::IgnoreFreeze
+        : FreezeHandling::ZeroIfFrozen;
+
     if (accountHolds(
             ctx.view,
             pseudoAccountID,
             vaultAsset,
-            FreezeHandling::ZeroIfFrozen,
+            freezeHandling,
             AuthHandling::ZeroIfUnauthorized,
             ctx.j) < amount)
         return tecINSUFFICIENT_FUNDS;
@@ -165,7 +193,7 @@ LoanBrokerCoverWithdraw::doApply()
     auto const amount = tx[sfAmount];
     auto const dstAcct = tx[~sfDestination].value_or(accountID_);
 
-    auto broker = view().peek(keylet::loanbroker(brokerID));
+    auto broker = view().peek(keylet::loanBroker(brokerID));
     if (!broker)
         return tecINTERNAL;  // LCOV_EXCL_LINE
 
@@ -187,10 +215,7 @@ LoanBrokerCoverWithdraw::doApply()
 }
 
 void
-LoanBrokerCoverWithdraw::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+LoanBrokerCoverWithdraw::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp
index 805a4612f2..141cc2cf56 100644
--- a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp
@@ -7,6 +7,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -17,8 +18,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 bool
@@ -44,7 +43,7 @@ LoanBrokerDelete::preclaim(PreclaimContext const& ctx)
     auto const account = tx[sfAccount];
     auto const brokerID = tx[sfLoanBrokerID];
 
-    auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID));
+    auto const sleBroker = ctx.view.read(keylet::loanBroker(brokerID));
     if (!sleBroker)
     {
         JLOG(ctx.j.warn()) << "LoanBroker does not exist.";
@@ -106,6 +105,19 @@ LoanBrokerDelete::preclaim(PreclaimContext const& ctx)
         }
     }
 
+    if (ctx.view.rules().enabled(fixCleanup3_2_0))
+    {
+        if (coverAvailable > beast::kZero)
+        {
+            auto const brokerPseudo = sleBroker->at(sfAccount);
+            if (auto const ret = checkFrozen(ctx.view, brokerPseudo, asset))
+            {
+                JLOG(ctx.j.warn()) << "Broker pseudo-account is frozen/locked.";
+                return ret;
+            }
+        }
+    }
+
     return tesSUCCESS;
 }
 
@@ -117,7 +129,7 @@ LoanBrokerDelete::doApply()
     auto const brokerID = tx[sfLoanBrokerID];
 
     // Delete the loan broker
-    auto broker = view().peek(keylet::loanbroker(brokerID));
+    auto broker = view().peek(keylet::loanBroker(brokerID));
     if (!broker)
         return tefBAD_LEDGER;  // LCOV_EXCL_LINE
     auto const vaultID = broker->at(sfVaultID);
@@ -192,10 +204,7 @@ LoanBrokerDelete::doApply()
 }
 
 void
-LoanBrokerDelete::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+LoanBrokerDelete::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
index 6e3d2ad0a8..ab1c8f22dc 100644
--- a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
@@ -114,7 +114,7 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx)
     {
         // Updating an existing Broker
 
-        auto const sleBroker = ctx.view.read(keylet::loanbroker(*brokerID));
+        auto const sleBroker = ctx.view.read(keylet::loanBroker(*brokerID));
         if (!sleBroker)
         {
             JLOG(ctx.j.warn()) << "LoanBroker does not exist.";
@@ -178,7 +178,7 @@ LoanBrokerSet::doApply()
     if (auto const brokerID = tx[~sfLoanBrokerID])
     {
         // Modify an existing LoanBroker
-        auto broker = view.peek(keylet::loanbroker(*brokerID));
+        auto broker = view.peek(keylet::loanBroker(*brokerID));
         if (!broker)
         {
             // This should be impossible
@@ -229,7 +229,7 @@ LoanBrokerSet::doApply()
             return tefBAD_LEDGER;
             // LCOV_EXCL_STOP
         }
-        auto broker = std::make_shared(keylet::loanbroker(accountID_, sequence));
+        auto broker = std::make_shared(keylet::loanBroker(accountID_, sequence));
 
         if (auto const ter = dirLink(view, accountID_, broker))
             return ter;  // LCOV_EXCL_LINE
@@ -279,10 +279,7 @@ LoanBrokerSet::doApply()
 }
 
 void
-LoanBrokerSet::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+LoanBrokerSet::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp
index 813700bd7a..3459119379 100644
--- a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp
@@ -16,8 +16,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 bool
@@ -56,7 +54,7 @@ LoanDelete::preclaim(PreclaimContext const& ctx)
     }
 
     auto const loanBrokerID = loanSle->at(sfLoanBrokerID);
-    auto const loanBrokerSle = ctx.view.read(keylet::loanbroker(loanBrokerID));
+    auto const loanBrokerSle = ctx.view.read(keylet::loanBroker(loanBrokerID));
     if (!loanBrokerSle)
     {
         // should be impossible
@@ -87,7 +85,7 @@ LoanDelete::doApply()
         return tefBAD_LEDGER;  // LCOV_EXCL_LINE
 
     auto const brokerID = loanSle->at(sfLoanBrokerID);
-    auto const brokerSle = view.peek(keylet::loanbroker(brokerID));
+    auto const brokerSle = view.peek(keylet::loanBroker(brokerID));
     if (!brokerSle)
         return tefBAD_LEDGER;  // LCOV_EXCL_LINE
     auto const brokerPseudoAccount = brokerSle->at(sfAccount);
@@ -142,10 +140,7 @@ LoanDelete::doApply()
 }
 
 void
-LoanDelete::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+LoanDelete::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp
index d3165006b5..830eb30272 100644
--- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp
@@ -26,8 +26,6 @@
 
 #include 
 #include 
-#include 
-
 namespace xrpl {
 
 bool
@@ -113,7 +111,7 @@ LoanManage::preclaim(PreclaimContext const& ctx)
     }
 
     auto const loanBrokerID = loanSle->at(sfLoanBrokerID);
-    auto const loanBrokerSle = ctx.view.read(keylet::loanbroker(loanBrokerID));
+    auto const loanBrokerSle = ctx.view.read(keylet::loanBroker(loanBrokerID));
     if (!loanBrokerSle)
     {
         // should be impossible
@@ -400,7 +398,7 @@ LoanManage::doApply()
         return tefBAD_LEDGER;  // LCOV_EXCL_LINE
 
     auto const brokerID = loanSle->at(sfLoanBrokerID);
-    auto const brokerSle = view.peek(keylet::loanbroker(brokerID));
+    auto const brokerSle = view.peek(keylet::loanBroker(brokerID));
     if (!brokerSle)
         return tefBAD_LEDGER;  // LCOV_EXCL_LINE
 
@@ -435,10 +433,7 @@ LoanManage::doApply()
 }
 
 void
-LoanManage::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+LoanManage::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp
index b8b940f3e3..fcda2a9fff 100644
--- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -29,7 +28,7 @@
 #include 
 #include 
 #include 
-#include 
+#include 
 #include 
 
 namespace xrpl {
@@ -111,7 +110,7 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx)
         return normalCost;
     }
 
-    auto const brokerSle = view.read(keylet::loanbroker(loanSle->at(sfLoanBrokerID)));
+    auto const brokerSle = view.read(keylet::loanBroker(loanSle->at(sfLoanBrokerID)));
     if (!brokerSle)
     {
         // Let preclaim worry about the error for this
@@ -214,7 +213,7 @@ LoanPay::preclaim(PreclaimContext const& ctx)
     }
 
     auto const loanBrokerID = loanSle->at(sfLoanBrokerID);
-    auto const loanBrokerSle = ctx.view.read(keylet::loanbroker(loanBrokerID));
+    auto const loanBrokerSle = ctx.view.read(keylet::loanBroker(loanBrokerID));
     if (!loanBrokerSle)
     {
         // This should be impossible
@@ -294,7 +293,7 @@ LoanPay::doApply()
     std::int32_t const loanScale = loanSle->at(sfLoanScale);
 
     auto const brokerID = loanSle->at(sfLoanBrokerID);
-    auto const brokerSle = view.peek(keylet::loanbroker(brokerID));
+    auto const brokerSle = view.peek(keylet::loanBroker(brokerID));
     if (!brokerSle)
         return tefBAD_LEDGER;  // LCOV_EXCL_LINE
     auto const brokerOwner = brokerSle->at(sfOwner);
@@ -311,6 +310,8 @@ LoanPay::doApply()
     TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
     auto debtTotalProxy = brokerSle->at(sfDebtTotal);
 
+    auto const vaultScale = getAssetsTotalScale(vaultSle);
+
     // Send the broker fee to the owner if they have sufficient cover available,
     // _and_ if the owner can receive funds
     // _and_ if the broker is authorized to hold funds. If not, so as not to
@@ -320,14 +321,22 @@ LoanPay::doApply()
     // Normally freeze status is checked in preclaim, but we do it here to
     // avoid duplicating the check. It'll claim a fee either way.
     bool const sendBrokerFeeToOwner = [&]() {
-        // Round the minimum required cover up to be conservative. This ensures
-        // CoverAvailable never drops below the theoretical minimum, protecting
-        // the broker's solvency.
-        NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
-        return coverAvailableProxy >=
-            roundToAsset(
-                   asset, tenthBipsOfValue(debtTotalProxy.value(), coverRateMinimum), loanScale) &&
-            !isDeepFrozen(view, brokerOwner, asset) &&
+        // In the fixCleanup3_2_0 path, vault-related values (for example,
+        // DebtTotal) use vaultScale. The legacy path below intentionally retains
+        // its pre-amendment loanScale behavior.
+        auto const minCover = [&]() {
+            if (view.rules().enabled(fixCleanup3_2_0))
+            {
+                return minimumBrokerCover(debtTotalProxy.value(), coverRateMinimum, vaultSle);
+            }
+            // Round the minimum required cover up to be conservative. This ensures
+            // CoverAvailable never drops below the theoretical minimum, protecting
+            // the broker's solvency.
+            NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
+            return roundToAsset(
+                asset, tenthBipsOfValue(debtTotalProxy.value(), coverRateMinimum), loanScale);
+        }();
+        return coverAvailableProxy >= minCover && !isDeepFrozen(view, brokerOwner, asset) &&
             !requireAuth(view, asset, brokerOwner, AuthType::StrongAuth);
     }();
 
@@ -371,7 +380,7 @@ LoanPay::doApply()
         return LoanPaymentType::Regular;
     }();
 
-    Expected const paymentParts =
+    std::expected const paymentParts =
         loanMakePayment(asset, view, loanSle, brokerSle, amount, paymentType, j_);
 
     if (!paymentParts)
@@ -423,10 +432,6 @@ LoanPay::doApply()
     auto assetsAvailableProxy = vaultSle->at(sfAssetsAvailable);
     auto assetsTotalProxy = vaultSle->at(sfAssetsTotal);
 
-    // The vault may be at a different scale than the loan. Reduce rounding
-    // errors during the payment by rounding some of the values to that scale.
-    auto const vaultScale = getAssetsTotalScale(vaultSle);
-
     auto const totalPaidToVaultRaw = paymentParts->principalPaid + paymentParts->interestPaid;
     auto const totalPaidToVaultRounded =
         roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::RoundingMode::Downward);
@@ -825,10 +830,7 @@ LoanPay::doApply()
 }
 
 void
-LoanPay::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+LoanPay::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp
index a3b71fd20d..903707320b 100644
--- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp
@@ -150,7 +150,7 @@ LoanSet::checkSign(PreclaimContext const& ctx)
         if (auto const c = ctx.tx.at(~sfCounterparty))
             return c;
 
-        if (auto const broker = ctx.view.read(keylet::loanbroker(ctx.tx[sfLoanBrokerID])))
+        if (auto const broker = ctx.view.read(keylet::loanBroker(ctx.tx[sfLoanBrokerID])))
             return broker->at(sfOwner);
         return std::nullopt;
     }();
@@ -268,7 +268,7 @@ LoanSet::preclaim(PreclaimContext const& ctx)
     auto const account = tx[sfAccount];
     auto const brokerID = tx[sfLoanBrokerID];
 
-    auto const brokerSle = ctx.view.read(keylet::loanbroker(brokerID));
+    auto const brokerSle = ctx.view.read(keylet::loanBroker(brokerID));
     if (!brokerSle)
     {
         // This can only be hit if there's a counterparty specified, otherwise
@@ -373,7 +373,7 @@ LoanSet::doApply()
 
     auto const brokerID = tx[sfLoanBrokerID];
 
-    auto const brokerSle = view.peek(keylet::loanbroker(brokerID));
+    auto const brokerSle = view.peek(keylet::loanBroker(brokerID));
     if (!brokerSle)
         return tefBAD_LEDGER;  // LCOV_EXCL_LINE
     auto const brokerOwner = brokerSle->at(sfOwner);
@@ -493,11 +493,19 @@ LoanSet::doApply()
     }
     TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
     {
-        // Round the minimum required cover up to be conservative. This ensures
-        // CoverAvailable never drops below the theoretical minimum, protecting
-        // the broker's solvency.
-        NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
-        if (brokerSle->at(sfCoverAvailable) < tenthBipsOfValue(newDebtTotal, coverRateMinimum))
+        auto const minCover = [&]() {
+            if (ctx_.view().rules().enabled(fixCleanup3_2_0))
+            {
+                return minimumBrokerCover(newDebtTotal, coverRateMinimum, vaultSle);
+            }
+
+            // Round the minimum required cover up to be conservative. This ensures
+            // CoverAvailable never drops below the theoretical minimum, protecting
+            // the broker's solvency.
+            NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
+            return tenthBipsOfValue(newDebtTotal, coverRateMinimum);
+        }();
+        if (brokerSle->at(sfCoverAvailable) < minCover)
         {
             JLOG(j_.warn()) << "Insufficient first-loss capital to cover the loan.";
             return tecINSUFFICIENT_FUNDS;
@@ -648,10 +656,7 @@ LoanSet::doApply()
 }
 
 void
-LoanSet::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+LoanSet::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp b/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp
index b80d282d70..14bf3646c0 100644
--- a/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp
+++ b/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp
@@ -21,7 +21,6 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 
@@ -55,13 +54,13 @@ TER
 NFTokenAcceptOffer::preclaim(PreclaimContext const& ctx)
 {
     auto const checkOffer =
-        [&ctx](std::optional id) -> std::pair, TER> {
+        [&ctx](std::optional id) -> std::pair {
         if (id)
         {
             if (id->isZero())
                 return {nullptr, tecOBJECT_NOT_FOUND};
 
-            auto offerSLE = ctx.view.read(keylet::nftoffer(*id));
+            auto offerSLE = ctx.view.read(keylet::nftokenOffer(*id));
 
             if (!offerSLE)
                 return {nullptr, tecOBJECT_NOT_FOUND};
@@ -308,7 +307,7 @@ NFTokenAcceptOffer::preclaim(PreclaimContext const& ctx)
         if (ctx.view.rules().enabled(fixEnforceNFTokenTrustline) &&
             (nft::getFlags(tokenID) & nft::kFlagCreateTrustLines) == 0 &&
             nftMinter != amount.getIssuer() &&
-            !ctx.view.read(keylet::line(nftMinter, amount.get())))
+            !ctx.view.read(keylet::trustLine(nftMinter, amount.get())))
             return tecNO_LINE;
 
         // Check that the issuer is allowed to receive IOUs.
@@ -375,35 +374,29 @@ NFTokenAcceptOffer::transferNFToken(
 
     auto const insertRet = nft::insertToken(view(), buyer, std::move(tokenAndPage->token));
 
-    // if fixNFTokenReserve is enabled, check if the buyer has sufficient
-    // reserve to own a new object, if their OwnerCount changed.
-    //
     // There was an issue where the buyer accepts a sell offer, the ledger
     // didn't check if the buyer has enough reserve, meaning that buyer can get
     // NFTs free of reserve.
-    if (view().rules().enabled(fixNFTokenReserve))
-    {
-        // To check if there is sufficient reserve, we cannot use preFeeBalance_
-        // because NFT is sold for a price. So we must use the balance after
-        // the deduction of the potential offer price. A small caveat here is
-        // that the balance has already deducted the transaction fee, meaning
-        // that the reserve requirement is a few drops higher.
-        auto const buyerBalance = sleBuyer->getFieldAmount(sfBalance);
+    // To check if there is sufficient reserve, we cannot use preFeeBalance_
+    // because NFT is sold for a price. So we must use the balance after
+    // the deduction of the potential offer price. A small caveat here is
+    // that the balance has already deducted the transaction fee, meaning
+    // that the reserve requirement is a few drops higher.
+    auto const buyerBalance = sleBuyer->getFieldAmount(sfBalance);
 
-        auto const buyerOwnerCountAfter = sleBuyer->getFieldU32(sfOwnerCount);
-        if (buyerOwnerCountAfter > buyerOwnerCountBefore)
-        {
-            if (auto const reserve = view().fees().accountReserve(buyerOwnerCountAfter);
-                buyerBalance < reserve)
-                return tecINSUFFICIENT_RESERVE;
-        }
+    auto const buyerOwnerCountAfter = sleBuyer->getFieldU32(sfOwnerCount);
+    if (buyerOwnerCountAfter > buyerOwnerCountBefore)
+    {
+        if (auto const reserve = view().fees().accountReserve(buyerOwnerCountAfter);
+            buyerBalance < reserve)
+            return tecINSUFFICIENT_RESERVE;
     }
 
     return insertRet;
 }
 
 TER
-NFTokenAcceptOffer::acceptOffer(std::shared_ptr const& offer)
+NFTokenAcceptOffer::acceptOffer(SLE::ref offer)
 {
     bool const isSell = offer->isFlag(lsfSellNFToken);
     AccountID const owner = (*offer)[sfOwner];
@@ -441,9 +434,9 @@ TER
 NFTokenAcceptOffer::doApply()
 {
     auto const loadToken = [this](std::optional const& id) {
-        std::shared_ptr sle;
+        SLE::pointer sle;
         if (id)
-            sle = view().peek(keylet::nftoffer(*id));
+            sle = view().peek(keylet::nftokenOffer(*id));
         return sle;
     };
 
@@ -456,8 +449,7 @@ NFTokenAcceptOffer::doApply()
     {
         bool foundExpired = false;
 
-        auto const deleteOfferIfExpired =
-            [this, &foundExpired](std::shared_ptr const& offer) -> TER {
+        auto const deleteOfferIfExpired = [this, &foundExpired](SLE::ref offer) -> TER {
             if (offer && hasExpired(view(), (*offer)[~sfExpiration]))
             {
                 JLOG(j_.trace()) << "Offer is expired, deleting: " << offer->key();
@@ -569,10 +561,7 @@ NFTokenAcceptOffer::doApply()
 }
 
 void
-NFTokenAcceptOffer::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+NFTokenAcceptOffer::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/nft/NFTokenBurn.cpp b/src/libxrpl/tx/transactors/nft/NFTokenBurn.cpp
index 871a8e706a..d33b1af8c2 100644
--- a/src/libxrpl/tx/transactors/nft/NFTokenBurn.cpp
+++ b/src/libxrpl/tx/transactors/nft/NFTokenBurn.cpp
@@ -13,8 +13,6 @@
 #include 
 
 #include 
-#include 
-
 namespace xrpl {
 
 NotTEC
@@ -95,10 +93,7 @@ NFTokenBurn::doApply()
 }
 
 void
-NFTokenBurn::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+NFTokenBurn::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/nft/NFTokenCancelOffer.cpp b/src/libxrpl/tx/transactors/nft/NFTokenCancelOffer.cpp
index 924dc49269..6c6f7d4e8b 100644
--- a/src/libxrpl/tx/transactors/nft/NFTokenCancelOffer.cpp
+++ b/src/libxrpl/tx/transactors/nft/NFTokenCancelOffer.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -16,15 +17,19 @@
 #include 
 
 #include 
-#include 
-
 namespace xrpl {
 
 NotTEC
 NFTokenCancelOffer::preflight(PreflightContext const& ctx)
 {
-    if (auto const& ids = ctx.tx[sfNFTokenOffers];
-        ids.empty() || (ids.size() > kMaxTokenOfferCancelCount))
+    auto const& offerIds = ctx.tx[sfNFTokenOffers];
+
+    if (offerIds.empty() || (offerIds.size() > kMaxTokenOfferCancelCount))
+        return temMALFORMED;
+
+    // Zero offer IDs cannot be passed as ledger entry keys.
+    if (ctx.rules.enabled(fixCleanup3_2_0) &&
+        std::ranges::any_of(offerIds, [](uint256 const& id) { return id.isZero(); }))
         return temMALFORMED;
 
     // In order to prevent unnecessarily overlarge transactions, we
@@ -83,7 +88,7 @@ NFTokenCancelOffer::doApply()
 {
     for (auto const& id : ctx_.tx[sfNFTokenOffers])
     {
-        if (auto offer = view().peek(keylet::nftoffer(id));
+        if (auto offer = view().peek(keylet::nftokenOffer(id));
             offer && !nft::deleteTokenOffer(view(), offer))
         {
             // LCOV_EXCL_START
@@ -98,10 +103,7 @@ NFTokenCancelOffer::doApply()
 }
 
 void
-NFTokenCancelOffer::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+NFTokenCancelOffer::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/nft/NFTokenCreateOffer.cpp b/src/libxrpl/tx/transactors/nft/NFTokenCreateOffer.cpp
index 9c7fe7d5ef..1948f3803d 100644
--- a/src/libxrpl/tx/transactors/nft/NFTokenCreateOffer.cpp
+++ b/src/libxrpl/tx/transactors/nft/NFTokenCreateOffer.cpp
@@ -14,8 +14,6 @@
 #include 
 
 #include 
-#include 
-
 namespace xrpl {
 
 std::uint32_t
@@ -91,10 +89,7 @@ NFTokenCreateOffer::doApply()
 }
 
 void
-NFTokenCreateOffer::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+NFTokenCreateOffer::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/nft/NFTokenMint.cpp b/src/libxrpl/tx/transactors/nft/NFTokenMint.cpp
index e7faa30df8..d8e5a7b235 100644
--- a/src/libxrpl/tx/transactors/nft/NFTokenMint.cpp
+++ b/src/libxrpl/tx/transactors/nft/NFTokenMint.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -26,8 +25,8 @@
 #include 
 #include 
 #include 
+#include 
 #include   // IWYU pragma: keep
-#include 
 #include 
 
 namespace xrpl {
@@ -224,12 +223,12 @@ NFTokenMint::doApply()
 {
     auto const issuer = ctx_.tx[~sfIssuer].value_or(accountID_);
 
-    auto const tokenSeq = [this, &issuer]() -> Expected {
+    auto const tokenSeq = [this, &issuer]() -> std::expected {
         auto const root = view().peek(keylet::account(issuer));
         if (root == nullptr)
         {
             // Should not happen.  Checked in preclaim.
-            return Unexpected(tecNO_ISSUER);
+            return std::unexpected(tecNO_ISSUER);
         }
 
         // If the issuer hasn't minted an NFToken before we must add a
@@ -260,7 +259,7 @@ NFTokenMint::doApply()
 
         (*root)[sfMintedNFTokens] = mintedNftCnt + 1u;
         if ((*root)[sfMintedNFTokens] == 0u)
-            return Unexpected(tecMAX_SEQUENCE_REACHED);
+            return std::unexpected(tecMAX_SEQUENCE_REACHED);
 
         // Get the unique sequence number of this token by
         // sfFirstNFTokenSequence + sfMintedNFTokens
@@ -269,7 +268,7 @@ NFTokenMint::doApply()
 
         // Check for more overflow cases
         if (tokenSeq + 1u == 0u || tokenSeq < offset)
-            return Unexpected(tecMAX_SEQUENCE_REACHED);
+            return std::unexpected(tecMAX_SEQUENCE_REACHED);
 
         ctx_.view().update(root);
         return tokenSeq;
@@ -344,10 +343,7 @@ NFTokenMint::doApply()
 }
 
 void
-NFTokenMint::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+NFTokenMint::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/nft/NFTokenModify.cpp b/src/libxrpl/tx/transactors/nft/NFTokenModify.cpp
index 7e3eeeefee..32ba362ded 100644
--- a/src/libxrpl/tx/transactors/nft/NFTokenModify.cpp
+++ b/src/libxrpl/tx/transactors/nft/NFTokenModify.cpp
@@ -14,8 +14,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 NotTEC
@@ -69,10 +67,7 @@ NFTokenModify::doApply()
 }
 
 void
-NFTokenModify::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+NFTokenModify::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/oracle/OracleDelete.cpp b/src/libxrpl/tx/transactors/oracle/OracleDelete.cpp
index 839b2b4e24..9a26816155 100644
--- a/src/libxrpl/tx/transactors/oracle/OracleDelete.cpp
+++ b/src/libxrpl/tx/transactors/oracle/OracleDelete.cpp
@@ -13,8 +13,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 NotTEC
@@ -51,7 +49,7 @@ OracleDelete::preclaim(PreclaimContext const& ctx)
 TER
 OracleDelete::deleteOracle(
     ApplyView& view,
-    std::shared_ptr const& sle,
+    SLE::ref sle,
     AccountID const& account,
     beast::Journal j)
 {
@@ -89,10 +87,7 @@ OracleDelete::doApply()
 }
 
 void
-OracleDelete::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+OracleDelete::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/oracle/OracleSet.cpp b/src/libxrpl/tx/transactors/oracle/OracleSet.cpp
index 66ce95f46c..12d826e54d 100644
--- a/src/libxrpl/tx/transactors/oracle/OracleSet.cpp
+++ b/src/libxrpl/tx/transactors/oracle/OracleSet.cpp
@@ -328,10 +328,7 @@ OracleSet::doApply()
 }
 
 void
-OracleSet::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+OracleSet::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp b/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp
index 8b4ad2f025..7e950dc743 100644
--- a/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp
+++ b/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp
@@ -298,10 +298,7 @@ DepositPreauth::removeFromLedger(ApplyView& view, uint256 const& preauthIndex, b
 }
 
 void
-DepositPreauth::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+DepositPreauth::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/payment/Payment.cpp b/src/libxrpl/tx/transactors/payment/Payment.cpp
index 1848d34786..7f1e4d8079 100644
--- a/src/libxrpl/tx/transactors/payment/Payment.cpp
+++ b/src/libxrpl/tx/transactors/payment/Payment.cpp
@@ -8,7 +8,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -29,7 +28,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -125,6 +123,12 @@ Payment::preflight(PreflightContext const& ctx)
     if (!mpTokensV2 && isDstMPT && ctx.tx.isFieldPresent(sfPaths))
         return temMALFORMED;
 
+    // A zero DomainID is invalid for a PermissionedDomain ledger entry because
+    // keylet::permissionedDomain(uint256) uses the DomainID as the ledger key.
+    if (auto const domainID = tx[~sfDomainID];
+        ctx.rules.enabled(fixCleanup3_2_0) && domainID && *domainID == beast::kZero)
+        return temMALFORMED;
+
     bool const partialPaymentAllowed = tx.isFlag(tfPartialPayment);
     bool const limitQuality = tx.isFlag(tfLimitQuality);
     bool const defaultPathsAllowed = !tx.isFlag(tfNoRippleDirect);
@@ -267,42 +271,71 @@ Payment::preflight(PreflightContext const& ctx)
 }
 
 NotTEC
-Payment::checkPermission(ReadView const& view, STTx const& tx)
+Payment::checkGranularSemantics(
+    ReadView const& view,
+    STTx const& tx,
+    std::unordered_set const& heldGranularPermissions)
 {
-    auto const delegate = tx[~sfDelegate];
-    if (!delegate)
-        return tesSUCCESS;
-
-    auto const delegateKey = keylet::delegate(tx[sfAccount], *delegate);
-    auto const sle = view.read(delegateKey);
-
-    if (!sle)
-        return terNO_DELEGATE_PERMISSION;
-
-    if (isTesSuccess(checkTxPermission(sle, tx)))
-        return tesSUCCESS;
-
-    std::unordered_set granularPermissions;
-    loadGranularPermission(sle, ttPAYMENT, granularPermissions);
-
     auto const& dstAmount = tx.getFieldAmount(sfAmount);
     auto const& amountAsset = dstAmount.asset();
 
     // Granular permissions are only valid for direct payments.
-    if ((tx.isFieldPresent(sfSendMax) && tx[sfSendMax].asset() != amountAsset) ||
-        tx.isFieldPresent(sfPaths))
+    if (tx.isFieldPresent(sfSendMax) && tx[sfSendMax].asset() != amountAsset)
         return terNO_DELEGATE_PERMISSION;
 
-    // PaymentMint and PaymentBurn apply to both IOU and MPT direct payments.
-    if (granularPermissions.contains(PaymentMint) && !isXRP(amountAsset) &&
-        amountAsset.getIssuer() == tx[sfAccount])
-        return tesSUCCESS;
+    if (isXRP(amountAsset))
+        return terNO_DELEGATE_PERMISSION;
 
-    if (granularPermissions.contains(PaymentBurn) && !isXRP(amountAsset) &&
-        amountAsset.getIssuer() == tx[sfDestination])
-        return tesSUCCESS;
+    return amountAsset.visit(
+        [&](MPTIssue const& mptIssue) -> NotTEC {
+            // For MPT payments, the MPTokenIssuanceID encodes the issuer unambiguously,
+            // unlike IOU, there is no endpoint aliasing where either side of the
+            // trustline can appear as the issuer.
+            if (heldGranularPermissions.contains(PaymentMint) &&
+                mptIssue.getIssuer() == tx[sfAccount])
+                return tesSUCCESS;
+            if (heldGranularPermissions.contains(PaymentBurn) &&
+                mptIssue.getIssuer() == tx[sfDestination])
+                return tesSUCCESS;
+            return terNO_DELEGATE_PERMISSION;
+        },
+        [&](Issue const& issue) -> NotTEC {
+            // For IOU payments, either endpoint may be encoded as the issuer in
+            // sfAmount. PaySteps normalizes those endpoint aliases, so sfAmount.issuer
+            // alone does not reliably identify whether the transaction issues or redeems
+            // IOUs. We determine PaymentMint vs PaymentBurn from the trustline balance
+            // direction instead.
+            auto const account = tx[sfAccount];
+            auto const destination = tx[sfDestination];
 
-    return terNO_DELEGATE_PERMISSION;
+            // Reject if neither endpoint is the issuer.
+            if (issue.getIssuer() != account && issue.getIssuer() != destination)
+                return terNO_DELEGATE_PERMISSION;
+
+            auto const sle = view.read(keylet::trustLine(account, destination, issue.currency));
+            if (!sle)
+                return terNO_DELEGATE_PERMISSION;
+
+            bool const accountIsLow = (account < destination);
+            auto const destLimit = sle->getFieldAmount(accountIsLow ? sfHighLimit : sfLowLimit);
+            auto const rawBalance = sle->getFieldAmount(sfBalance);
+            bool const accountIsHolder =
+                accountIsLow ? rawBalance > beast::kZero : rawBalance < beast::kZero;
+
+            // PaymentMint requires the destination to be the holder and the account to be the
+            // issuer. destLimit > 0: destination is willing to hold account's IOUs (account is the
+            // issuer). !accountIsHolder: DirectStepI will issue, not redeem.
+            if (heldGranularPermissions.contains(PaymentMint) && destLimit > beast::kZero &&
+                !accountIsHolder)
+                return tesSUCCESS;
+
+            // PaymentBurn requires the source account to be the holder and the destination to be
+            // the issuer. accountIsHolder: DirectStepI will redeem, not issue.
+            if (heldGranularPermissions.contains(PaymentBurn) && accountIsHolder)
+                return tesSUCCESS;
+
+            return terNO_DELEGATE_PERMISSION;
+        });
 }
 
 TER
@@ -673,10 +706,7 @@ Payment::doApply()
 }
 
 void
-Payment::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+Payment::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp
index cc99b8f62d..b8118bc49f 100644
--- a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp
+++ b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp
@@ -23,7 +23,6 @@
 #include 
 
 #include 
-#include 
 #include 
 
 namespace xrpl {
@@ -43,6 +42,9 @@ PaymentChannelClaim::getFlagsMask(PreflightContext const&)
 NotTEC
 PaymentChannelClaim::preflight(PreflightContext const& ctx)
 {
+    if (ctx.rules.enabled(fixCleanup3_2_0) && ctx.tx[sfChannel] == beast::kZero)
+        return temMALFORMED;
+
     auto const bal = ctx.tx[~sfBalance];
     if (bal && (!isXRP(*bal) || *bal <= beast::kZero))
         return temBAD_AMOUNT;
@@ -117,12 +119,10 @@ PaymentChannelClaim::doApply()
     AccountID const txAccount = ctx_.tx[sfAccount];
 
     auto const curExpiration = (*slep)[~sfExpiration];
+    if (isChannelExpired(ctx_.view(), (*slep)[~sfCancelAfter]) ||
+        isChannelExpired(ctx_.view(), curExpiration))
     {
-        auto const cancelAfter = (*slep)[~sfCancelAfter];
-        auto const closeTime = ctx_.view().header().parentCloseTime.time_since_epoch().count();
-        if ((cancelAfter && closeTime >= *cancelAfter) ||
-            (curExpiration && closeTime >= *curExpiration))
-            return closeChannel(slep, ctx_.view(), k.key, ctx_.registry.get().getJournal("View"));
+        return closeChannel(slep, ctx_.view(), k.key, ctx_.registry.get().getJournal("View"));
     }
 
     if (txAccount != src && txAccount != dst)
@@ -135,13 +135,19 @@ PaymentChannelClaim::doApply()
         auto const reqBalance = ctx_.tx[sfBalance].xrp();
 
         if (txAccount == dst && !ctx_.tx[~sfSignature])
-            return temBAD_SIGNATURE;
+        {
+            return ctx_.view().rules().enabled(fixCleanup3_2_0) ? TER{tecNO_PERMISSION}
+                                                                : TER{temBAD_SIGNATURE};
+        }
 
         if (ctx_.tx[~sfSignature])
         {
             PublicKey const pk((*slep)[sfPublicKey]);
             if (ctx_.tx[sfPublicKey] != pk)
-                return temBAD_SIGNER;
+            {
+                return ctx_.view().rules().enabled(fixCleanup3_2_0) ? TER{tecNO_PERMISSION}
+                                                                    : TER{temBAD_SIGNER};
+            }
         }
 
         if (reqBalance > chanFunds)
@@ -185,9 +191,10 @@ PaymentChannelClaim::doApply()
         if (dst == txAccount || (*slep)[sfBalance] == (*slep)[sfAmount])
             return closeChannel(slep, ctx_.view(), k.key, ctx_.registry.get().getJournal("View"));
 
-        auto const settleExpiration =
-            ctx_.view().header().parentCloseTime.time_since_epoch().count() +
-            (*slep)[sfSettleDelay];
+        auto const settleExpiration = saturatingAdd(
+            ctx_.view().rules(),
+            ctx_.view().header().parentCloseTime.time_since_epoch().count(),
+            (*slep)[sfSettleDelay]);
 
         if (!curExpiration || *curExpiration > settleExpiration)
         {
@@ -200,10 +207,7 @@ PaymentChannelClaim::doApply()
 }
 
 void
-PaymentChannelClaim::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+PaymentChannelClaim::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp
index 7ce25b6d15..977da56861 100644
--- a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp
+++ b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp
@@ -137,7 +137,7 @@ PaymentChannelCreate::doApply()
     //
     // Note that we use the value from the sequence or ticket as the
     // payChan sequence.  For more explanation see comments in SeqProxy.h.
-    Keylet const payChanKeylet = keylet::payChan(account, dst, ctx_.tx.getSeqValue());
+    Keylet const payChanKeylet = keylet::payChannel(account, dst, ctx_.tx.getSeqValue());
     auto const slep = std::make_shared(payChanKeylet);
 
     // Funds held in this channel
@@ -185,10 +185,7 @@ PaymentChannelCreate::doApply()
 }
 
 void
-PaymentChannelCreate::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+PaymentChannelCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelFund.cpp b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelFund.cpp
index 41906aa3da..4e3c5dd638 100644
--- a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelFund.cpp
+++ b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelFund.cpp
@@ -6,6 +6,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -18,8 +19,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 TxConsequences
@@ -31,6 +30,9 @@ PaymentChannelFund::makeTxConsequences(PreflightContext const& ctx)
 NotTEC
 PaymentChannelFund::preflight(PreflightContext const& ctx)
 {
+    if (ctx.rules.enabled(fixCleanup3_2_0) && ctx.tx[sfChannel] == beast::kZero)
+        return temMALFORMED;
+
     if (!isXRP(ctx.tx[sfAmount]) || (ctx.tx[sfAmount] <= beast::kZero))
         return temBAD_AMOUNT;
 
@@ -47,13 +49,12 @@ PaymentChannelFund::doApply()
 
     AccountID const src = (*slep)[sfAccount];
     auto const txAccount = ctx_.tx[sfAccount];
-    auto const expiration = (*slep)[~sfExpiration];
+    auto const curExpiration = (*slep)[~sfExpiration];
 
+    if (isChannelExpired(ctx_.view(), (*slep)[~sfCancelAfter]) ||
+        isChannelExpired(ctx_.view(), curExpiration))
     {
-        auto const cancelAfter = (*slep)[~sfCancelAfter];
-        auto const closeTime = ctx_.view().header().parentCloseTime.time_since_epoch().count();
-        if ((cancelAfter && closeTime >= *cancelAfter) || (expiration && closeTime >= *expiration))
-            return closeChannel(slep, ctx_.view(), k.key, ctx_.registry.get().getJournal("View"));
+        return closeChannel(slep, ctx_.view(), k.key, ctx_.registry.get().getJournal("View"));
     }
 
     if (src != txAccount)
@@ -62,16 +63,21 @@ PaymentChannelFund::doApply()
         return tecNO_PERMISSION;
     }
 
-    if (auto extend = ctx_.tx[~sfExpiration])
+    if (auto newExpiration = ctx_.tx[~sfExpiration])
     {
-        auto minExpiration = ctx_.view().header().parentCloseTime.time_since_epoch().count() +
-            (*slep)[sfSettleDelay];
-        if (expiration && *expiration < minExpiration)
-            minExpiration = *expiration;
+        auto minExpiration = saturatingAdd(
+            ctx_.view().rules(),
+            ctx_.view().header().parentCloseTime.time_since_epoch().count(),
+            (*slep)[sfSettleDelay]);
+        if (curExpiration && *curExpiration < minExpiration)
+            minExpiration = *curExpiration;
 
-        if (*extend < minExpiration)
-            return temBAD_EXPIRATION;
-        (*slep)[~sfExpiration] = *extend;
+        if (*newExpiration < minExpiration)
+        {
+            return ctx_.view().rules().enabled(fixCleanup3_2_0) ? TER{tecNO_PERMISSION}
+                                                                : TER{temBAD_EXPIRATION};
+        }
+        (*slep)[~sfExpiration] = *newExpiration;
         ctx_.view().update(slep);
     }
 
@@ -107,10 +113,7 @@ PaymentChannelFund::doApply()
 }
 
 void
-PaymentChannelFund::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+PaymentChannelFund::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainDelete.cpp b/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainDelete.cpp
index e36a0eb584..7eb3f282b9 100644
--- a/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainDelete.cpp
+++ b/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainDelete.cpp
@@ -12,8 +12,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 NotTEC
@@ -74,10 +72,7 @@ PermissionedDomainDelete::doApply()
 }
 
 void
-PermissionedDomainDelete::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+PermissionedDomainDelete::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp b/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp
index ff4019ec59..0e71ceada1 100644
--- a/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp
+++ b/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp
@@ -133,10 +133,7 @@ PermissionedDomainSet::doApply()
 }
 
 void
-PermissionedDomainSet::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+PermissionedDomainSet::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/system/Batch.cpp b/src/libxrpl/tx/transactors/system/Batch.cpp
index b9440e3273..64a62ac273 100644
--- a/src/libxrpl/tx/transactors/system/Batch.cpp
+++ b/src/libxrpl/tx/transactors/system/Batch.cpp
@@ -23,7 +23,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -518,10 +517,7 @@ Batch::doApply()
 }
 
 void
-Batch::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+Batch::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/system/Change.cpp b/src/libxrpl/tx/transactors/system/Change.cpp
index 92a06fd807..f27855a5c8 100644
--- a/src/libxrpl/tx/transactors/system/Change.cpp
+++ b/src/libxrpl/tx/transactors/system/Change.cpp
@@ -254,7 +254,7 @@ Change::applyAmendment()
 TER
 Change::applyFee()
 {
-    auto const k = keylet::fees();
+    auto const k = keylet::feeSettings();
 
     SLE::pointer feeObject = view().peek(k);
 
@@ -409,10 +409,7 @@ Change::applyUNLModify()
 }
 
 void
-Change::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+Change::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/system/LedgerStateFix.cpp b/src/libxrpl/tx/transactors/system/LedgerStateFix.cpp
index 2f30311e6f..65c32b788c 100644
--- a/src/libxrpl/tx/transactors/system/LedgerStateFix.cpp
+++ b/src/libxrpl/tx/transactors/system/LedgerStateFix.cpp
@@ -4,7 +4,9 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -12,24 +14,71 @@
 #include 
 #include 
 
-#include 
+#include 
+#include 
+#include 
 
 namespace xrpl {
 
+namespace {
+
+using FixType = LedgerStateFix::FixType;
+
+std::array, 2> const kLedgerFixFields = {{
+    {FixType::NfTokenPageLink, &sfOwner},
+    {FixType::BookExchangeRate, &sfBookDirectory},
+}};
+
+[[nodiscard]] SField const*
+fixField(FixType const fixType)
+{
+    auto const iter = std::ranges::find_if(
+        kLedgerFixFields, [fixType](auto const& entry) { return entry.first == fixType; });
+
+    if (iter == kLedgerFixFields.end())
+        return nullptr;  // LCOV_EXCL_LINE
+
+    return iter->second;
+}
+
+[[nodiscard]] bool
+hasUnexpectedFixField(STTx const& tx, SField const& expected)
+{
+    return std::ranges::any_of(kLedgerFixFields, [&tx, &expected](auto const& entry) {
+        auto const field = entry.second;
+        return field != &expected && tx.isFieldPresent(*field);
+    });
+}
+
+}  // namespace
+
 NotTEC
 LedgerStateFix::preflight(PreflightContext const& ctx)
 {
-    switch (static_cast(ctx.tx[sfLedgerFixType]))
+    auto const fixType = static_cast(ctx.tx[sfLedgerFixType]);
+
+    switch (fixType)
     {
         case FixType::NfTokenPageLink:
-            if (!ctx.tx.isFieldPresent(sfOwner))
-                return temINVALID;
+            break;
+
+        case FixType::BookExchangeRate:
+            if (!ctx.rules.enabled(fixCleanup3_2_0))
+                return temDISABLED;
             break;
 
         default:
             return tefINVALID_LEDGER_FIX_TYPE;
     }
 
+    auto const expectedField = fixField(fixType);
+    if (expectedField == nullptr)
+        return tefINVALID_LEDGER_FIX_TYPE;  // LCOV_EXCL_LINE
+
+    // Each fix type allows exactly one fix-specific field.
+    if (!ctx.tx.isFieldPresent(*expectedField) || hasUnexpectedFixField(ctx.tx, *expectedField))
+        return temINVALID;
+
     return tesSUCCESS;
 }
 
@@ -53,6 +102,24 @@ LedgerStateFix::preclaim(PreclaimContext const& ctx)
         return tesSUCCESS;
     }
 
+    if (static_cast(ctx.tx[sfLedgerFixType]) == FixType::BookExchangeRate)
+    {
+        auto const dirKey = ctx.tx.getFieldH256(sfBookDirectory);
+        auto const sle = ctx.view.read(Keylet(ltDIR_NODE, dirKey));
+        if (!sle)
+            return tecOBJECT_NOT_FOUND;
+
+        // Must be the first page of a book directory (has sfExchangeRate).
+        if (!sle->isFieldPresent(sfExchangeRate))
+            return tecNO_PERMISSION;
+
+        // ExchangeRate is already correct, nothing to fix.
+        if (getQuality(sle->key()) == sle->getFieldU64(sfExchangeRate))
+            return tecNO_PERMISSION;
+
+        return tesSUCCESS;
+    }
+
     // preflight is supposed to verify that only valid FixTypes get to preclaim.
     return tecINTERNAL;  // LCOV_EXCL_LINE
 }
@@ -68,15 +135,24 @@ LedgerStateFix::doApply()
         return tesSUCCESS;
     }
 
+    if (static_cast(ctx_.tx[sfLedgerFixType]) == FixType::BookExchangeRate)
+    {
+        auto const dirKey = ctx_.tx.getFieldH256(sfBookDirectory);
+        auto sle = view().peek(Keylet(ltDIR_NODE, dirKey));
+        if (!sle)
+            return tecINTERNAL;  // LCOV_EXCL_LINE
+
+        sle->setFieldU64(sfExchangeRate, getQuality(sle->key()));
+        view().update(sle);
+        return tesSUCCESS;
+    }
+
     // preflight is supposed to verify that only valid FixTypes get to doApply.
     return tecINTERNAL;  // LCOV_EXCL_LINE
 }
 
 void
-LedgerStateFix::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+LedgerStateFix::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/system/TicketCreate.cpp b/src/libxrpl/tx/transactors/system/TicketCreate.cpp
index f442ac223b..73a72c217d 100644
--- a/src/libxrpl/tx/transactors/system/TicketCreate.cpp
+++ b/src/libxrpl/tx/transactors/system/TicketCreate.cpp
@@ -100,7 +100,7 @@ TicketCreate::doApply()
     for (std::uint32_t i = 0; i < ticketCount; ++i)
     {
         std::uint32_t const curTicketSeq = firstTicketSeq + i;
-        Keylet const ticketKeylet = keylet::kTicket(accountID_, curTicketSeq);
+        Keylet const ticketKeylet = keylet::ticket(accountID_, curTicketSeq);
         SLE::pointer const sleTicket = std::make_shared(ticketKeylet);
 
         sleTicket->setAccountID(sfAccount, accountID_);
@@ -120,7 +120,7 @@ TicketCreate::doApply()
     }
 
     // Update the record of the number of Tickets this account owns.
-    std::uint32_t const oldTicketCount = (*(sleAccountRoot))[~sfTicketCount].valueOr(0u);
+    std::uint32_t const oldTicketCount = (*sleAccountRoot)[~sfTicketCount].valueOr(0u);
 
     sleAccountRoot->setFieldU32(sfTicketCount, oldTicketCount + ticketCount);
 
@@ -135,10 +135,7 @@ TicketCreate::doApply()
 }
 
 void
-TicketCreate::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+TicketCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/token/Clawback.cpp b/src/libxrpl/tx/transactors/token/Clawback.cpp
index 8532f97995..64457a3b5f 100644
--- a/src/libxrpl/tx/transactors/token/Clawback.cpp
+++ b/src/libxrpl/tx/transactors/token/Clawback.cpp
@@ -23,7 +23,6 @@
 #include 
 
 #include 
-#include 
 #include 
 
 namespace xrpl {
@@ -110,7 +109,7 @@ preclaimHelper(
         return tecNO_PERMISSION;
 
     auto const sleRippleState =
-        ctx.view.read(keylet::line(holder, issuer, clawAmount.get().currency));
+        ctx.view.read(keylet::trustLine(holder, issuer, clawAmount.get().currency));
     if (!sleRippleState)
         return tecNO_LINE;
 
@@ -154,7 +153,7 @@ preclaimHelper(
     AccountID const& holder,
     STAmount const& clawAmount)
 {
-    auto const issuanceKey = keylet::mptIssuance(clawAmount.get().getMptID());
+    auto const issuanceKey = keylet::mptokenIssuance(clawAmount.get().getMptID());
     auto const sleIssuance = ctx.view.read(issuanceKey);
     if (!sleIssuance)
         return tecOBJECT_NOT_FOUND;
@@ -275,10 +274,7 @@ Clawback::doApply()
 }
 
 void
-Clawback::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+Clawback::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp
new file mode 100644
index 0000000000..e0d1d28a8f
--- /dev/null
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp
@@ -0,0 +1,210 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+
+namespace xrpl {
+
+NotTEC
+ConfidentialMPTClawback::preflight(PreflightContext const& ctx)
+{
+    if (!ctx.rules.enabled(featureConfidentialTransfer))
+        return temDISABLED;
+
+    auto const account = ctx.tx[sfAccount];
+
+    // Only issuer can clawback
+    if (account != MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer())
+        return temMALFORMED;
+
+    // Cannot clawback from self
+    if (account == ctx.tx[sfHolder])
+        return temMALFORMED;
+
+    // Check invalid claw amount
+    auto const clawAmount = ctx.tx[sfMPTAmount];
+    if (clawAmount == 0 || clawAmount > kMaxMpTokenAmount)
+        return temBAD_AMOUNT;
+
+    // Verify proof length
+    if (ctx.tx[sfZKProof].length() != kEcClawbackProofLength)
+        return temMALFORMED;
+
+    return tesSUCCESS;
+}
+
+XRPAmount
+ConfidentialMPTClawback::calculateBaseFee(ReadView const& view, STTx const& tx)
+{
+    return Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier);
+}
+
+TER
+ConfidentialMPTClawback::preclaim(PreclaimContext const& ctx)
+{
+    // Check if sender account exists
+    auto const account = ctx.tx[sfAccount];
+    if (!ctx.view.exists(keylet::account(account)))
+        return terNO_ACCOUNT;
+
+    // Check if holder account exists
+    auto const holder = ctx.tx[sfHolder];
+    if (!ctx.view.exists(keylet::account(holder)))
+        return tecNO_TARGET;
+
+    // Check if MPT issuance exists
+    auto const mptIssuanceID = ctx.tx[sfMPTokenIssuanceID];
+    auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(mptIssuanceID));
+    if (!sleIssuance)
+        return tecOBJECT_NOT_FOUND;
+
+    // Sanity check: account must be the same as issuer
+    if (sleIssuance->getAccountID(sfIssuer) != account)
+        return tefINTERNAL;  // LCOV_EXCL_LINE
+
+    // Check if issuance has issuer ElGamal public key
+    if (!sleIssuance->isFieldPresent(sfIssuerEncryptionKey))
+        return tecNO_PERMISSION;
+
+    // Check if clawback is allowed
+    if (!sleIssuance->isFlag(lsfMPTCanClawback))
+        return tecNO_PERMISSION;
+
+    // Check if issuance allows confidential transfer
+    if (!sleIssuance->isFlag(lsfMPTCanHoldConfidentialBalance))
+        return tecNO_PERMISSION;
+
+    // Check holder's MPToken
+    auto const sleHolderMPToken = ctx.view.read(keylet::mptoken(mptIssuanceID, holder));
+    if (!sleHolderMPToken)
+        return tecOBJECT_NOT_FOUND;
+
+    // Check if holder has confidential balances to claw back
+    if (!sleHolderMPToken->isFieldPresent(sfIssuerEncryptedBalance))
+        return tecNO_PERMISSION;
+
+    // Check if Holder has ElGamal public Key
+    if (!sleHolderMPToken->isFieldPresent(sfHolderEncryptionKey))
+        return tecNO_PERMISSION;
+
+    // Sanity check: claw amount can not exceed confidential outstanding amount
+    // or total outstanding amount (prevents underflow in doApply)
+    auto const amount = ctx.tx[sfMPTAmount];
+    if (amount > (*sleIssuance)[~sfConfidentialOutstandingAmount].value_or(0) ||
+        amount > (*sleIssuance)[sfOutstandingAmount])
+        return tecINSUFFICIENT_FUNDS;
+
+    auto const contextHash =
+        getClawbackContextHash(account, mptIssuanceID, ctx.tx.getSeqProxy().value(), holder);
+
+    // Verify the revealed confidential amount by the issuer matches the exact
+    // confidential balance of the holder.
+    return verifyClawbackProof(
+        amount,
+        ctx.tx[sfZKProof],
+        (*sleIssuance)[sfIssuerEncryptionKey],
+        (*sleHolderMPToken)[sfIssuerEncryptedBalance],
+        contextHash);
+}
+
+TER
+ConfidentialMPTClawback::doApply()
+{
+    auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID];
+    auto const holder = ctx_.tx[sfHolder];
+
+    auto sleIssuance = view().peek(keylet::mptokenIssuance(mptIssuanceID));
+    auto sleHolderMPToken = view().peek(keylet::mptoken(mptIssuanceID, holder));
+
+    if (!sleIssuance || !sleHolderMPToken)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    auto const clawAmount = ctx_.tx[sfMPTAmount];
+
+    auto const holderPubKey = (*sleHolderMPToken)[sfHolderEncryptionKey];
+    auto const issuerPubKey = (*sleIssuance)[sfIssuerEncryptionKey];
+
+    // After clawback, the balance should be encrypted zero.
+    auto const encZeroForHolder = encryptCanonicalZeroAmount(holderPubKey, holder, mptIssuanceID);
+    if (!encZeroForHolder)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    auto encZeroForIssuer = encryptCanonicalZeroAmount(issuerPubKey, holder, mptIssuanceID);
+    if (!encZeroForIssuer)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    // Set holder's confidential balances to encrypted zero
+    (*sleHolderMPToken)[sfConfidentialBalanceInbox] = *encZeroForHolder;
+    (*sleHolderMPToken)[sfConfidentialBalanceSpending] = *encZeroForHolder;
+    (*sleHolderMPToken)[sfIssuerEncryptedBalance] = std::move(*encZeroForIssuer);
+    incrementConfidentialVersion(*sleHolderMPToken);
+
+    if (sleHolderMPToken->isFieldPresent(sfAuditorEncryptedBalance))
+    {
+        // Sanity check: the issuance must have an auditor public key if
+        // auditing is enabled.
+        if (!sleIssuance->isFieldPresent(sfAuditorEncryptionKey))
+            return tecINTERNAL;  // LCOV_EXCL_LINE
+
+        auto const auditorPubKey = (*sleIssuance)[sfAuditorEncryptionKey];
+
+        auto encZeroForAuditor = encryptCanonicalZeroAmount(auditorPubKey, holder, mptIssuanceID);
+
+        if (!encZeroForAuditor)
+            return tecINTERNAL;  // LCOV_EXCL_LINE
+
+        (*sleHolderMPToken)[sfAuditorEncryptedBalance] = std::move(*encZeroForAuditor);
+    }
+
+    // Decrease Global Confidential Outstanding Amount
+    auto const oldCOA = (*sleIssuance)[sfConfidentialOutstandingAmount];
+    if (clawAmount > oldCOA)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+    (*sleIssuance)[sfConfidentialOutstandingAmount] = oldCOA - clawAmount;
+
+    // Decrease Global Total Outstanding Amount
+    auto const oldOA = (*sleIssuance)[sfOutstandingAmount];
+    if (clawAmount > oldOA)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+    (*sleIssuance)[sfOutstandingAmount] = oldOA - clawAmount;
+
+    view().update(sleHolderMPToken);
+    view().update(sleIssuance);
+
+    return tesSUCCESS;
+}
+
+void
+ConfidentialMPTClawback::visitInvariantEntry(
+    bool,
+    std::shared_ptr const&,
+    std::shared_ptr const&)
+{
+}
+
+bool
+ConfidentialMPTClawback::finalizeInvariants(
+    STTx const&,
+    TER,
+    XRPAmount,
+    ReadView const&,
+    beast::Journal const&)
+{
+    return true;
+}
+
+}  // namespace xrpl
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp
new file mode 100644
index 0000000000..44e2596325
--- /dev/null
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp
@@ -0,0 +1,350 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+NotTEC
+ConfidentialMPTConvert::preflight(PreflightContext const& ctx)
+{
+    if (!ctx.rules.enabled(featureConfidentialTransfer))
+        return temDISABLED;
+
+    // issuer cannot convert
+    if (MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer() == ctx.tx[sfAccount])
+        return temMALFORMED;
+
+    if (ctx.tx[sfMPTAmount] > kMaxMpTokenAmount)
+        return temBAD_AMOUNT;
+
+    if (ctx.tx.isFieldPresent(sfHolderEncryptionKey))
+    {
+        if (!isValidCompressedECPoint(ctx.tx[sfHolderEncryptionKey]))
+            return temMALFORMED;
+
+        // proof of knowledge of the secret key corresponding to the provided
+        // public key is needed when holder ec public key is being set.
+        if (!ctx.tx.isFieldPresent(sfZKProof))
+            return temMALFORMED;
+
+        // verify schnorr proof length when registering holder ec public key
+        if (ctx.tx[sfZKProof].size() != kEcSchnorrProofLength)
+            return temMALFORMED;
+    }
+    else
+    {
+        // Either both sfHolderEncryptionKey and sfZKProof should be present, or both should be
+        // absent.
+        if (ctx.tx.isFieldPresent(sfZKProof))
+            return temMALFORMED;
+    }
+
+    // check encrypted amount format after the above basic checks
+    // this check is more expensive so put it at the end
+    if (auto const res = checkEncryptedAmountFormat(ctx.tx); !isTesSuccess(res))
+        return res;
+
+    return tesSUCCESS;
+}
+
+XRPAmount
+ConfidentialMPTConvert::calculateBaseFee(ReadView const& view, STTx const& tx)
+{
+    return Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier);
+}
+
+TER
+ConfidentialMPTConvert::preclaim(PreclaimContext const& ctx)
+{
+    auto const account = ctx.tx[sfAccount];
+    auto const issuanceID = ctx.tx[sfMPTokenIssuanceID];
+    auto const amount = ctx.tx[sfMPTAmount];
+
+    // ensure that issuance exists
+    auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(issuanceID));
+    if (!sleIssuance)
+        return tecOBJECT_NOT_FOUND;
+
+    if (!sleIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) ||
+        !sleIssuance->isFieldPresent(sfIssuerEncryptionKey))
+    {
+        return tecNO_PERMISSION;
+    }
+
+    // already checked in preflight, but should also check that issuer on the
+    // issuance isn't the account either
+    if (sleIssuance->getAccountID(sfIssuer) == account)
+        return tefINTERNAL;  // LCOV_EXCL_LINE
+
+    bool const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount);
+    bool const requiresAuditor = sleIssuance->isFieldPresent(sfAuditorEncryptionKey);
+
+    // tx must include auditor ciphertext if the issuance has enabled
+    // auditing, and must not include it if auditing is not enabled
+    if (requiresAuditor != hasAuditor)
+        return tecNO_PERMISSION;
+
+    auto const sleMptoken = ctx.view.read(keylet::mptoken(issuanceID, account));
+    if (!sleMptoken)
+        return tecOBJECT_NOT_FOUND;
+
+    auto const mptIssue = MPTIssue{issuanceID};
+
+    // Explicit freeze and auth checks are required because accountHolds
+    // with ZeroIfFrozen/ZeroIfUnauthorized only implicitly rejects
+    // non-zero amounts. A zero-amount convert would bypass those implicit
+    // checks, allowing frozen or unauthorized accounts to register ElGamal
+    // keys and initialize confidential balance fields.
+
+    // Check lock
+    if (auto const ter = checkFrozen(ctx.view, account, mptIssue); !isTesSuccess(ter))
+        return ter;
+
+    // Check auth
+    if (auto const ter = requireAuth(ctx.view, mptIssue, account); !isTesSuccess(ter))
+        return ter;
+
+    auto const mptAmount =
+        STAmount(MPTAmount{static_cast(amount)}, mptIssue);
+    if (accountHolds(
+            ctx.view,
+            account,
+            mptIssue,
+            FreezeHandling::ZeroIfFrozen,
+            AuthHandling::ZeroIfUnauthorized,
+            ctx.j) < mptAmount)
+    {
+        return tecINSUFFICIENT_FUNDS;
+    }
+
+    auto const hasHolderKeyOnLedger = sleMptoken->isFieldPresent(sfHolderEncryptionKey);
+    auto const hasHolderKeyInTx = ctx.tx.isFieldPresent(sfHolderEncryptionKey);
+
+    // must have pk to convert
+    if (!hasHolderKeyOnLedger && !hasHolderKeyInTx)
+        return tecNO_PERMISSION;
+
+    // can't update if there's already a pk
+    if (hasHolderKeyOnLedger && hasHolderKeyInTx)
+        return tecDUPLICATE;
+
+    // Run all verifications before returning any error to prevent timing attacks
+    // that could reveal which proof failed.
+    bool valid = true;
+
+    Slice holderPubKey;
+    if (hasHolderKeyInTx)
+    {
+        holderPubKey = ctx.tx[sfHolderEncryptionKey];
+
+        auto const contextHash =
+            getConvertContextHash(account, issuanceID, ctx.tx.getSeqProxy().value());
+
+        if (auto const ter = verifySchnorrProof(holderPubKey, ctx.tx[sfZKProof], contextHash);
+            !isTesSuccess(ter))
+        {
+            valid = false;
+        }
+    }
+    else
+    {
+        holderPubKey = (*sleMptoken)[sfHolderEncryptionKey];
+    }
+
+    std::optional auditor;
+    if (hasAuditor)
+    {
+        auditor.emplace(
+            ConfidentialRecipient{
+                .publicKey = (*sleIssuance)[sfAuditorEncryptionKey],
+                .encryptedAmount = ctx.tx[sfAuditorEncryptedAmount],
+            });
+    }
+
+    auto const blindingFactor = ctx.tx[sfBlindingFactor];
+    if (auto const ter = verifyRevealedAmount(
+            amount,
+            Slice(blindingFactor.data(), blindingFactor.size()),
+            {
+                .publicKey = holderPubKey,
+                .encryptedAmount = ctx.tx[sfHolderEncryptedAmount],
+            },
+            {
+                .publicKey = (*sleIssuance)[sfIssuerEncryptionKey],
+                .encryptedAmount = ctx.tx[sfIssuerEncryptedAmount],
+            },
+            auditor);
+        !isTesSuccess(ter))
+    {
+        valid = false;
+    }
+
+    if (!valid)
+        return tecBAD_PROOF;
+
+    return tesSUCCESS;
+}
+
+TER
+ConfidentialMPTConvert::doApply()
+{
+    auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID];
+
+    auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_));
+    if (!sleMptoken)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    auto sleIssuance = view().peek(keylet::mptokenIssuance(mptIssuanceID));
+    if (!sleIssuance)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    auto const amtToConvert = ctx_.tx[sfMPTAmount];
+    auto const amt = (*sleMptoken)[~sfMPTAmount].valueOr(0);
+
+    if (ctx_.tx.isFieldPresent(sfHolderEncryptionKey))
+        (*sleMptoken)[sfHolderEncryptionKey] = ctx_.tx[sfHolderEncryptionKey];
+
+    // Converting decreases regular balance and increases confidential outstanding.
+    // The confidential outstanding tracks total tokens in confidential form globally.
+    auto const currentCOA = (*sleIssuance)[~sfConfidentialOutstandingAmount].valueOr(0);
+    if (amtToConvert > kMaxMpTokenAmount - currentCOA)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    (*sleMptoken)[sfMPTAmount] = amt - amtToConvert;
+    (*sleIssuance)[sfConfidentialOutstandingAmount] = currentCOA + amtToConvert;
+
+    auto const holderEc = ctx_.tx[sfHolderEncryptedAmount];
+    auto const issuerEc = ctx_.tx[sfIssuerEncryptedAmount];
+    auto const auditorEc = ctx_.tx[~sfAuditorEncryptedAmount];
+
+    // Two cases for Convert:
+    // 1. Holder already has confidential balances -> homomorphically add to inbox
+    // 2. First-time convert -> initialize all confidential balance fields
+    if (sleMptoken->isFieldPresent(sfIssuerEncryptedBalance) &&
+        sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) &&
+        sleMptoken->isFieldPresent(sfConfidentialBalanceSpending))
+    {
+        // Case 1: Add to existing inbox balance (holder will merge later)
+        {
+            auto sum = homomorphicAdd(holderEc, (*sleMptoken)[sfConfidentialBalanceInbox]);
+            if (!sum)
+            {
+                // LCOV_EXCL_START
+                JLOG(ctx_.journal.error())
+                    << "ConfidentialMPTConvert failed homomorphic add for holder inbox.";
+                return tecINTERNAL;
+                // LCOV_EXCL_STOP
+            }
+
+            (*sleMptoken)[sfConfidentialBalanceInbox] = std::move(*sum);
+        }
+
+        // homomorphically add issuer's encrypted balance
+        {
+            auto sum = homomorphicAdd(issuerEc, (*sleMptoken)[sfIssuerEncryptedBalance]);
+            if (!sum)
+            {
+                // LCOV_EXCL_START
+                JLOG(ctx_.journal.error())
+                    << "ConfidentialMPTConvert failed homomorphic add for issuer balance.";
+                return tecINTERNAL;
+                // LCOV_EXCL_STOP
+            }
+
+            (*sleMptoken)[sfIssuerEncryptedBalance] = std::move(*sum);
+        }
+
+        // homomorphically add auditor's encrypted balance
+        if (auditorEc)
+        {
+            if (!sleMptoken->isFieldPresent(sfAuditorEncryptedBalance))
+                return tecINTERNAL;  // LCOV_EXCL_LINE
+
+            auto sum = homomorphicAdd(*auditorEc, (*sleMptoken)[sfAuditorEncryptedBalance]);
+            if (!sum)
+            {
+                // LCOV_EXCL_START
+                JLOG(ctx_.journal.error())
+                    << "ConfidentialMPTConvert failed homomorphic add for auditor balance.";
+                return tecINTERNAL;
+                // LCOV_EXCL_STOP
+            }
+
+            (*sleMptoken)[sfAuditorEncryptedBalance] = std::move(*sum);
+        }
+    }
+    else if (
+        !sleMptoken->isFieldPresent(sfIssuerEncryptedBalance) &&
+        !sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) &&
+        !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending) &&
+        !sleMptoken->isFieldPresent(sfAuditorEncryptedBalance))
+    {
+        // Case 2: First-time convert - initialize all confidential fields
+        (*sleMptoken)[sfConfidentialBalanceInbox] = holderEc;
+        (*sleMptoken)[sfIssuerEncryptedBalance] = issuerEc;
+        (*sleMptoken)[sfConfidentialBalanceVersion] = 0;
+
+        if (auditorEc)
+            (*sleMptoken)[sfAuditorEncryptedBalance] = *auditorEc;
+
+        // Spending balance starts at zero. Must use canonical zero encryption
+        // (deterministic ciphertext) so the ledger state is reproducible.
+        auto zeroBalance = encryptCanonicalZeroAmount(
+            (*sleMptoken)[sfHolderEncryptionKey], accountID_, mptIssuanceID);
+
+        if (!zeroBalance)
+            return tecINTERNAL;  // LCOV_EXCL_LINE
+
+        (*sleMptoken)[sfConfidentialBalanceSpending] = std::move(*zeroBalance);
+    }
+    else
+    {
+        // both sfIssuerEncryptedBalance and sfConfidentialBalanceInbox should
+        // exist together
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+    }
+
+    view().update(sleIssuance);
+    view().update(sleMptoken);
+    return tesSUCCESS;
+}
+
+void
+ConfidentialMPTConvert::visitInvariantEntry(
+    bool,
+    std::shared_ptr const&,
+    std::shared_ptr const&)
+{
+}
+
+bool
+ConfidentialMPTConvert::finalizeInvariants(
+    STTx const&,
+    TER,
+    XRPAmount,
+    ReadView const&,
+    beast::Journal const&)
+{
+    return true;
+}
+
+}  // namespace xrpl
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp
new file mode 100644
index 0000000000..d6fed78833
--- /dev/null
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp
@@ -0,0 +1,319 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+NotTEC
+ConfidentialMPTConvertBack::preflight(PreflightContext const& ctx)
+{
+    if (!ctx.rules.enabled(featureConfidentialTransfer))
+        return temDISABLED;
+
+    // issuer cannot convert back
+    if (MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer() == ctx.tx[sfAccount])
+        return temMALFORMED;
+
+    if (ctx.tx[sfMPTAmount] == 0 || ctx.tx[sfMPTAmount] > kMaxMpTokenAmount)
+        return temBAD_AMOUNT;
+
+    if (!isValidCompressedECPoint(ctx.tx[sfBalanceCommitment]))
+        return temMALFORMED;
+
+    // check encrypted amount format after the above basic checks
+    // this check is more expensive so put it at the end
+    if (auto const res = checkEncryptedAmountFormat(ctx.tx); !isTesSuccess(res))
+        return res;
+
+    // ConvertBack proof = compact sigma proof (128 bytes) + single bulletproof (688 bytes)
+    if (ctx.tx[sfZKProof].size() != kEcConvertBackProofLength)
+        return temMALFORMED;
+
+    return tesSUCCESS;
+}
+
+XRPAmount
+ConfidentialMPTConvertBack::calculateBaseFee(ReadView const& view, STTx const& tx)
+{
+    return Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier);
+}
+
+/**
+ * Verifies the cryptographic proofs for a ConvertBack transaction.
+ *
+ * This function verifies three proofs:
+ * 1. Revealed amount proof: verifies the encrypted amounts (holder, issuer,
+ *    auditor) all encrypt the same revealed amount using the blinding factor.
+ * 2. Pedersen linkage proof: verifies the balance commitment is derived from
+ *    the holder's encrypted spending balance.
+ * 3. Bulletproof (range proof): verifies the remaining balance (balance - amount)
+ *    is non-negative, preventing overdrafts.
+ *
+ * All proofs are verified before returning any error to prevent timing attacks.
+ */
+static TER
+verifyProofs(
+    STTx const& tx,
+    std::shared_ptr const& issuance,
+    std::shared_ptr const& mptoken)
+{
+    if (!mptoken->isFieldPresent(sfHolderEncryptionKey))
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    auto const mptIssuanceID = tx[sfMPTokenIssuanceID];
+    auto const account = tx[sfAccount];
+    auto const amount = tx[sfMPTAmount];
+    auto const blindingFactor = tx[sfBlindingFactor];
+    auto const holderPubKey = (*mptoken)[sfHolderEncryptionKey];
+
+    auto const contextHash = getConvertBackContextHash(
+        account,
+        mptIssuanceID,
+        tx.getSeqProxy().value(),
+        (*mptoken)[~sfConfidentialBalanceVersion].value_or(0));
+
+    // Prepare Auditor Info
+    std::optional auditor;
+    bool const hasAuditor = issuance->isFieldPresent(sfAuditorEncryptionKey);
+    if (hasAuditor)
+    {
+        auditor.emplace(
+            ConfidentialRecipient{
+                .publicKey = (*issuance)[sfAuditorEncryptionKey],
+                .encryptedAmount = tx[sfAuditorEncryptedAmount],
+            });
+    }
+
+    // Run all verifications before returning any error to prevent timing attacks
+    // that could reveal which proof failed.
+    bool valid = true;
+
+    if (auto const ter = verifyRevealedAmount(
+            amount,
+            Slice(blindingFactor.data(), blindingFactor.size()),
+            {
+                .publicKey = holderPubKey,
+                .encryptedAmount = tx[sfHolderEncryptedAmount],
+            },
+            {
+                .publicKey = (*issuance)[sfIssuerEncryptionKey],
+                .encryptedAmount = tx[sfIssuerEncryptedAmount],
+            },
+            auditor);
+        !isTesSuccess(ter))
+    {
+        valid = false;
+    }
+
+    if (auto const ter = verifyConvertBackProof(
+            tx[sfZKProof],
+            holderPubKey,
+            (*mptoken)[sfConfidentialBalanceSpending],
+            tx[sfBalanceCommitment],
+            amount,
+            contextHash);
+        !isTesSuccess(ter))
+    {
+        valid = false;
+    }
+
+    if (!valid)
+        return tecBAD_PROOF;
+
+    return tesSUCCESS;
+}
+
+TER
+ConfidentialMPTConvertBack::preclaim(PreclaimContext const& ctx)
+{
+    auto const mptIssuanceID = ctx.tx[sfMPTokenIssuanceID];
+    auto const account = ctx.tx[sfAccount];
+    auto const amount = ctx.tx[sfMPTAmount];
+
+    // ensure that issuance exists
+    auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(mptIssuanceID));
+    if (!sleIssuance)
+        return tecOBJECT_NOT_FOUND;
+
+    if (!sleIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) ||
+        !sleIssuance->isFieldPresent(sfIssuerEncryptionKey))
+        return tecNO_PERMISSION;
+
+    bool const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount);
+    bool const requiresAuditor = sleIssuance->isFieldPresent(sfAuditorEncryptionKey);
+
+    // tx must include auditor ciphertext if the issuance has enabled
+    // auditing
+    if (requiresAuditor && !hasAuditor)
+        return tecNO_PERMISSION;
+
+    // if auditing is not supported then user should not upload auditor
+    // ciphertext
+    if (!requiresAuditor && hasAuditor)
+        return tecNO_PERMISSION;
+
+    // already checked in preflight, but should also check that issuer on
+    // the issuance isn't the account either
+    if (sleIssuance->getAccountID(sfIssuer) == account)
+        return tefINTERNAL;  // LCOV_EXCL_LINE
+
+    auto const sleMptoken = ctx.view.read(keylet::mptoken(mptIssuanceID, account));
+    if (!sleMptoken)
+        return tecOBJECT_NOT_FOUND;
+
+    if (!sleMptoken->isFieldPresent(sfHolderEncryptionKey) ||
+        !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending) ||
+        !sleMptoken->isFieldPresent(sfIssuerEncryptedBalance))
+    {
+        return tecNO_PERMISSION;
+    }
+
+    // Sanity check: holder's MPToken must have auditor balance field if auditing
+    // is enabled
+    if (requiresAuditor && !sleMptoken->isFieldPresent(sfAuditorEncryptedBalance))
+        return tefINTERNAL;  // LCOV_EXCL_LINE
+
+    // if the total circulating confidential balance is smaller than what the
+    // holder is trying to convert back, we know for sure this txn should
+    // fail
+    if ((*sleIssuance)[~sfConfidentialOutstandingAmount].value_or(0) < amount)
+        return tecINSUFFICIENT_FUNDS;
+
+    // Check lock
+    MPTIssue const mptIssue(mptIssuanceID);
+    if (auto const ter = checkFrozen(ctx.view, account, mptIssue); !isTesSuccess(ter))
+        return ter;
+
+    // Check auth
+    if (auto const ter = requireAuth(ctx.view, mptIssue, account); !isTesSuccess(ter))
+        return ter;
+
+    if (auto const res = verifyProofs(ctx.tx, sleIssuance, sleMptoken); !isTesSuccess(res))
+        return res;
+
+    return tesSUCCESS;
+}
+
+TER
+ConfidentialMPTConvertBack::doApply()
+{
+    auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID];
+
+    auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_));
+    if (!sleMptoken)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    auto sleIssuance = view().peek(keylet::mptokenIssuance(mptIssuanceID));
+    if (!sleIssuance)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    auto const amtToConvertBack = ctx_.tx[sfMPTAmount];
+    auto const amt = (*sleMptoken)[~sfMPTAmount].valueOr(0);
+
+    // Converting back increases regular balance and decreases confidential
+    // outstanding. This is the inverse of Convert.
+    if (amt > kMaxMpTokenAmount - amtToConvertBack)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+    (*sleMptoken)[sfMPTAmount] = amt + amtToConvertBack;
+
+    auto const coa = (*sleIssuance)[~sfConfidentialOutstandingAmount].valueOr(0);
+    if (coa < amtToConvertBack)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+    (*sleIssuance)[sfConfidentialOutstandingAmount] = coa - amtToConvertBack;
+
+    std::optional const auditorEc = ctx_.tx[~sfAuditorEncryptedAmount];
+
+    // homomorphically subtract holder's encrypted balance
+    {
+        auto res = homomorphicSubtract(
+            (*sleMptoken)[sfConfidentialBalanceSpending], ctx_.tx[sfHolderEncryptedAmount]);
+        if (!res)
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTConvertBack failed homomorphic subtract for holder spending "
+                   "balance.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        (*sleMptoken)[sfConfidentialBalanceSpending] = std::move(*res);
+    }
+
+    // homomorphically subtract issuer's encrypted balance
+    {
+        auto res = homomorphicSubtract(
+            (*sleMptoken)[sfIssuerEncryptedBalance], ctx_.tx[sfIssuerEncryptedAmount]);
+        if (!res)
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTConvertBack failed homomorphic subtract for issuer balance.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        (*sleMptoken)[sfIssuerEncryptedBalance] = std::move(*res);
+    }
+
+    if (auditorEc)
+    {
+        auto res = homomorphicSubtract(
+            (*sleMptoken)[sfAuditorEncryptedBalance], ctx_.tx[sfAuditorEncryptedAmount]);
+        if (!res)
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTConvertBack failed homomorphic subtract for auditor balance.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        (*sleMptoken)[sfAuditorEncryptedBalance] = std::move(*res);
+    }
+
+    incrementConfidentialVersion(*sleMptoken);
+
+    view().update(sleIssuance);
+    view().update(sleMptoken);
+    return tesSUCCESS;
+}
+
+void
+ConfidentialMPTConvertBack::visitInvariantEntry(
+    bool,
+    std::shared_ptr const&,
+    std::shared_ptr const&)
+{
+}
+
+bool
+ConfidentialMPTConvertBack::finalizeInvariants(
+    STTx const&,
+    TER,
+    XRPAmount,
+    ReadView const&,
+    beast::Journal const&)
+{
+    return true;
+}
+
+}  // namespace xrpl
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp
new file mode 100644
index 0000000000..02c759c521
--- /dev/null
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp
@@ -0,0 +1,150 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+
+namespace xrpl {
+
+NotTEC
+ConfidentialMPTMergeInbox::preflight(PreflightContext const& ctx)
+{
+    if (!ctx.rules.enabled(featureConfidentialTransfer))
+        return temDISABLED;
+
+    // issuer cannot merge
+    if (MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer() == ctx.tx[sfAccount])
+        return temMALFORMED;
+
+    return tesSUCCESS;
+}
+
+XRPAmount
+ConfidentialMPTMergeInbox::calculateBaseFee(ReadView const& view, STTx const& tx)
+{
+    return Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier);
+}
+
+TER
+ConfidentialMPTMergeInbox::preclaim(PreclaimContext const& ctx)
+{
+    auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
+    if (!sleIssuance)
+        return tecOBJECT_NOT_FOUND;
+
+    if (!sleIssuance->isFlag(lsfMPTCanHoldConfidentialBalance))
+        return tecNO_PERMISSION;
+
+    // already checked in preflight, but should also check that issuer on the
+    // issuance isn't the account either
+    if (sleIssuance->getAccountID(sfIssuer) == ctx.tx[sfAccount])
+        return tefINTERNAL;  // LCOV_EXCL_LINE
+
+    auto const sleMptoken =
+        ctx.view.read(keylet::mptoken(ctx.tx[sfMPTokenIssuanceID], ctx.tx[sfAccount]));
+    if (!sleMptoken)
+        return tecOBJECT_NOT_FOUND;
+
+    if (!sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) ||
+        !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending) ||
+        !sleMptoken->isFieldPresent(sfHolderEncryptionKey))
+    {
+        return tecNO_PERMISSION;
+    }
+
+    // Check lock
+    auto const account = ctx.tx[sfAccount];
+    MPTIssue const mptIssue(ctx.tx[sfMPTokenIssuanceID]);
+    if (auto const ter = checkFrozen(ctx.view, account, mptIssue); !isTesSuccess(ter))
+        return ter;
+
+    // Check auth
+    if (auto const ter = requireAuth(ctx.view, mptIssue, account); !isTesSuccess(ter))
+        return ter;
+
+    return tesSUCCESS;
+}
+
+TER
+ConfidentialMPTMergeInbox::doApply()
+{
+    auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID];
+    auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_));
+    if (!sleMptoken)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    // sanity check
+    if (!sleMptoken->isFieldPresent(sfConfidentialBalanceSpending) ||
+        !sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) ||
+        !sleMptoken->isFieldPresent(sfHolderEncryptionKey))
+    {
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+    }
+
+    // Merge inbox into spending: spending = spending + inbox
+    // This allows holder to use received funds. Without merging, incoming
+    // transfers sit in inbox and cannot be spent or converted back.
+    auto sum = homomorphicAdd(
+        (*sleMptoken)[sfConfidentialBalanceSpending], (*sleMptoken)[sfConfidentialBalanceInbox]);
+    if (!sum)
+    {
+        // LCOV_EXCL_START
+        JLOG(ctx_.journal.error())
+            << "ConfidentialMPTMergeInbox failed homomorphic add for inbox merge.";
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
+
+    (*sleMptoken)[sfConfidentialBalanceSpending] = std::move(*sum);
+
+    // Reset inbox to encrypted zero. Must use canonical zero encryption
+    // (deterministic ciphertext) so the ledger state is reproducible.
+    auto zeroEncryption =
+        encryptCanonicalZeroAmount((*sleMptoken)[sfHolderEncryptionKey], accountID_, mptIssuanceID);
+
+    if (!zeroEncryption)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    (*sleMptoken)[sfConfidentialBalanceInbox] = std::move(*zeroEncryption);
+
+    incrementConfidentialVersion(*sleMptoken);
+
+    view().update(sleMptoken);
+    return tesSUCCESS;
+}
+
+void
+ConfidentialMPTMergeInbox::visitInvariantEntry(
+    bool,
+    std::shared_ptr const&,
+    std::shared_ptr const&)
+{
+}
+
+bool
+ConfidentialMPTMergeInbox::finalizeInvariants(
+    STTx const&,
+    TER,
+    XRPAmount,
+    ReadView const&,
+    beast::Journal const&)
+{
+    return true;
+}
+
+}  // namespace xrpl
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp
new file mode 100644
index 0000000000..302b7d239b
--- /dev/null
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp
@@ -0,0 +1,445 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+bool
+ConfidentialMPTSend::checkExtraFeatures(PreflightContext const& ctx)
+{
+    return !ctx.tx.isFieldPresent(sfCredentialIDs) || ctx.rules.enabled(featureCredentials);
+}
+
+NotTEC
+ConfidentialMPTSend::preflight(PreflightContext const& ctx)
+{
+    if (!ctx.rules.enabled(featureConfidentialTransfer))
+        return temDISABLED;
+
+    auto const account = ctx.tx[sfAccount];
+    auto const issuer = MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer();
+
+    // ConfidentialMPTSend only allows holder to holder, holder to second account,
+    // and second account to holder transfers. So issuer cannot be the sender.
+    if (account == issuer)
+        return temMALFORMED;
+
+    // Can not send to self
+    if (account == ctx.tx[sfDestination])
+        return temMALFORMED;
+
+    // Issuer cannot be the destination
+    if (ctx.tx[sfDestination] == issuer)
+        return temMALFORMED;
+
+    // Check the length of the encrypted amounts
+    if (ctx.tx[sfSenderEncryptedAmount].length() != kEcGamalEncryptedTotalLength ||
+        ctx.tx[sfDestinationEncryptedAmount].length() != kEcGamalEncryptedTotalLength ||
+        ctx.tx[sfIssuerEncryptedAmount].length() != kEcGamalEncryptedTotalLength)
+    {
+        return temBAD_CIPHERTEXT;
+    }
+
+    bool const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount);
+    if (hasAuditor && ctx.tx[sfAuditorEncryptedAmount].length() != kEcGamalEncryptedTotalLength)
+        return temBAD_CIPHERTEXT;
+
+    // Check the length of the ZKProof (fixed size regardless of recipient count)
+    if (ctx.tx[sfZKProof].length() != kEcSendProofLength)
+        return temMALFORMED;
+
+    // Check the Pedersen commitments are valid
+    if (!isValidCompressedECPoint(ctx.tx[sfBalanceCommitment]) ||
+        !isValidCompressedECPoint(ctx.tx[sfAmountCommitment]))
+    {
+        return temMALFORMED;
+    }
+
+    // Check the encrypted amount formats, this is more expensive so put it at
+    // the end
+    if (!isValidCiphertext(ctx.tx[sfSenderEncryptedAmount]) ||
+        !isValidCiphertext(ctx.tx[sfDestinationEncryptedAmount]) ||
+        !isValidCiphertext(ctx.tx[sfIssuerEncryptedAmount]))
+    {
+        return temBAD_CIPHERTEXT;
+    }
+
+    if (hasAuditor && !isValidCiphertext(ctx.tx[sfAuditorEncryptedAmount]))
+        return temBAD_CIPHERTEXT;
+
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err))
+        return err;
+
+    return tesSUCCESS;
+}
+
+XRPAmount
+ConfidentialMPTSend::calculateBaseFee(ReadView const& view, STTx const& tx)
+{
+    return Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier);
+}
+
+namespace detail {
+
+static TER
+verifySendProofs(
+    PreclaimContext const& ctx,
+    std::shared_ptr const& sleSenderMPToken,
+    std::shared_ptr const& sleDestinationMPToken,
+    std::shared_ptr const& sleIssuance)
+{
+    // Sanity check
+    if (!sleSenderMPToken || !sleDestinationMPToken || !sleIssuance)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    auto const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount);
+
+    std::optional auditor;
+    if (hasAuditor)
+    {
+        auditor.emplace(
+            ConfidentialRecipient{
+                .publicKey = (*sleIssuance)[sfAuditorEncryptionKey],
+                .encryptedAmount = ctx.tx[sfAuditorEncryptedAmount],
+            });
+    }
+
+    auto const contextHash = getSendContextHash(
+        ctx.tx[sfAccount],
+        ctx.tx[sfMPTokenIssuanceID],
+        ctx.tx.getSeqProxy().value(),
+        ctx.tx[sfDestination],
+        (*sleSenderMPToken)[~sfConfidentialBalanceVersion].value_or(0));
+
+    return verifySendProof(
+        ctx.tx[sfZKProof],
+        {
+            .publicKey = (*sleSenderMPToken)[sfHolderEncryptionKey],
+            .encryptedAmount = ctx.tx[sfSenderEncryptedAmount],
+        },
+        {
+            .publicKey = (*sleDestinationMPToken)[sfHolderEncryptionKey],
+            .encryptedAmount = ctx.tx[sfDestinationEncryptedAmount],
+        },
+        {
+            .publicKey = (*sleIssuance)[sfIssuerEncryptionKey],
+            .encryptedAmount = ctx.tx[sfIssuerEncryptedAmount],
+        },
+        auditor,
+        (*sleSenderMPToken)[sfConfidentialBalanceSpending],
+        ctx.tx[sfAmountCommitment],
+        ctx.tx[sfBalanceCommitment],
+        contextHash);
+}
+
+}  // namespace detail
+
+TER
+ConfidentialMPTSend::preclaim(PreclaimContext const& ctx)
+{
+    // Check if sender account exists
+    auto const account = ctx.tx[sfAccount];
+    if (!ctx.view.exists(keylet::account(account)))
+        return terNO_ACCOUNT;
+
+    // Check if destination account exists
+    auto const destination = ctx.tx[sfDestination];
+    auto const sleDst = ctx.view.read(keylet::account(destination));
+    if (!sleDst)
+        return tecNO_TARGET;
+
+    // Check destination tag
+    if (((sleDst->getFlags() & lsfRequireDestTag) != 0u) &&
+        !ctx.tx.isFieldPresent(sfDestinationTag))
+    {
+        return tecDST_TAG_NEEDED;
+    }
+
+    // Check if MPT issuance exists
+    auto const mptIssuanceID = ctx.tx[sfMPTokenIssuanceID];
+    auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(mptIssuanceID));
+    if (!sleIssuance)
+        return tecOBJECT_NOT_FOUND;
+
+    // Check if the issuance allows transfer
+    if (!sleIssuance->isFlag(lsfMPTCanTransfer))
+        return tecNO_AUTH;
+
+    // Check if issuance allows confidential transfer
+    if (!sleIssuance->isFlag(lsfMPTCanHoldConfidentialBalance))
+        return tecNO_PERMISSION;
+
+    // Sanity check: transfer fee must be 0 for confidential MPTs. This should
+    // be unreachable in valid ledger state because MPTokenIssuanceCreate and
+    // MPTokenIssuanceSet enforce it.
+    if ((*sleIssuance)[~sfTransferFee].value_or(0) > 0)
+        return tecNO_PERMISSION;
+
+    // Check if issuance has issuer ElGamal public key
+    if (!sleIssuance->isFieldPresent(sfIssuerEncryptionKey))
+        return tecNO_PERMISSION;
+
+    bool const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount);
+    bool const requiresAuditor = sleIssuance->isFieldPresent(sfAuditorEncryptionKey);
+
+    // Tx must include auditor ciphertext if the issuance has enabled
+    // auditing, and must not include it if auditing is not enabled
+    if (requiresAuditor != hasAuditor)
+        return tecNO_PERMISSION;
+
+    // Sanity check: issuer isn't the sender
+    if (sleIssuance->getAccountID(sfIssuer) == ctx.tx[sfAccount])
+        return tefINTERNAL;  // LCOV_EXCL_LINE
+
+    // Check sender's MPToken existence
+    auto const sleSenderMPToken = ctx.view.read(keylet::mptoken(mptIssuanceID, account));
+    if (!sleSenderMPToken)
+        return tecOBJECT_NOT_FOUND;
+
+    // Check sender's MPToken has necessary fields for confidential send
+    if (!sleSenderMPToken->isFieldPresent(sfHolderEncryptionKey) ||
+        !sleSenderMPToken->isFieldPresent(sfConfidentialBalanceSpending) ||
+        !sleSenderMPToken->isFieldPresent(sfIssuerEncryptedBalance))
+    {
+        return tecNO_PERMISSION;
+    }
+
+    // Check destination's MPToken existence
+    auto const sleDestinationMPToken = ctx.view.read(keylet::mptoken(mptIssuanceID, destination));
+    if (!sleDestinationMPToken)
+        return tecOBJECT_NOT_FOUND;
+
+    // Check destination's MPToken has necessary fields for confidential send
+    if (!sleDestinationMPToken->isFieldPresent(sfHolderEncryptionKey) ||
+        !sleDestinationMPToken->isFieldPresent(sfConfidentialBalanceInbox) ||
+        !sleDestinationMPToken->isFieldPresent(sfIssuerEncryptedBalance))
+    {
+        return tecNO_PERMISSION;
+    }
+
+    // Sanity check: Both MPTokens' auditor fields must be present if auditing
+    // is enabled
+    if (requiresAuditor &&
+        (!sleSenderMPToken->isFieldPresent(sfAuditorEncryptedBalance) ||
+         !sleDestinationMPToken->isFieldPresent(sfAuditorEncryptedBalance)))
+    {
+        return tefINTERNAL;  // LCOV_EXCL_LINE
+    }
+
+    // Check lock
+    MPTIssue const mptIssue(mptIssuanceID);
+    if (auto const ter = checkFrozen(ctx.view, account, mptIssue); !isTesSuccess(ter))
+        return ter;
+
+    if (auto const ter = checkFrozen(ctx.view, destination, mptIssue); !isTesSuccess(ter))
+        return ter;
+
+    // Check auth
+    if (auto const ter = requireAuth(ctx.view, mptIssue, account); !isTesSuccess(ter))
+        return ter;
+
+    if (auto const ter = requireAuth(ctx.view, mptIssue, destination); !isTesSuccess(ter))
+        return ter;
+
+    if (auto const err = credentials::valid(ctx.tx, ctx.view, ctx.tx[sfAccount], ctx.j);
+        !isTesSuccess(err))
+        return err;
+
+    // Check deposit preauth before the expensive ZK proof verification.
+    // Uses read-only view.
+    auto const preauthErr =
+        checkDepositPreauth(ctx.tx, ctx.view, account, destination, sleDst, ctx.j);
+    if (!isTesSuccess(preauthErr))
+        return preauthErr;
+
+    return detail::verifySendProofs(ctx, sleSenderMPToken, sleDestinationMPToken, sleIssuance);
+}
+
+TER
+ConfidentialMPTSend::doApply()
+{
+    auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID];
+    auto const destination = ctx_.tx[sfDestination];
+
+    auto sleSenderMPToken = view().peek(keylet::mptoken(mptIssuanceID, accountID_));
+    auto sleDestinationMPToken = view().peek(keylet::mptoken(mptIssuanceID, destination));
+    auto const sleIssuance = view().read(keylet::mptokenIssuance(mptIssuanceID));
+
+    auto const sleDestAcct = view().read(keylet::account(destination));
+
+    if (!sleSenderMPToken || !sleDestinationMPToken || !sleIssuance || !sleDestAcct)
+        return tecINTERNAL;  // LCOV_EXCL_LINE
+
+    // Deposit preauth authorization was already verified in preclaim.
+    // Remove any expired credentials.
+    if (auto err = cleanupExpiredCredentials(ctx_.tx, ctx_.view(), ctx_.journal);
+        !isTesSuccess(err))
+        return err;
+
+    auto const senderEc = ctx_.tx[sfSenderEncryptedAmount];
+    auto const destEc = ctx_.tx[sfDestinationEncryptedAmount];
+    auto const issuerEc = ctx_.tx[sfIssuerEncryptedAmount];
+    auto const proof = ctx_.tx[sfZKProof];
+    Slice const sendChallenge{proof.data(), kEcBlindingFactorLength};
+
+    auto const auditorEc = ctx_.tx[~sfAuditorEncryptedAmount];
+
+    // Subtract from sender's spending balance
+    {
+        auto const curSpending = (*sleSenderMPToken)[sfConfidentialBalanceSpending];
+        auto newSpending = homomorphicSubtract(curSpending, senderEc);
+        if (!newSpending)
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTSend failed homomorphic subtract for sender spending balance.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        (*sleSenderMPToken)[sfConfidentialBalanceSpending] = std::move(*newSpending);
+    }
+
+    // Subtract from issuer's balance
+    {
+        auto const curIssuerEnc = (*sleSenderMPToken)[sfIssuerEncryptedBalance];
+        auto newIssuerEnc = homomorphicSubtract(curIssuerEnc, issuerEc);
+        if (!newIssuerEnc)
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTSend failed homomorphic subtract for sender issuer balance.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        (*sleSenderMPToken)[sfIssuerEncryptedBalance] = std::move(*newIssuerEnc);
+    }
+
+    // Subtract from auditor's balance if present
+    if (auditorEc)
+    {
+        auto const curAuditorEnc = (*sleSenderMPToken)[sfAuditorEncryptedBalance];
+        auto newAuditorEnc = homomorphicSubtract(curAuditorEnc, *auditorEc);
+        if (!newAuditorEnc)
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTSend failed homomorphic subtract for sender auditor balance.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        (*sleSenderMPToken)[sfAuditorEncryptedBalance] = std::move(*newAuditorEnc);
+    }
+
+    // Add to destination's inbox balance
+    {
+        auto rerandomizedDestEc = rerandomizeCiphertext(
+            destEc, (*sleDestinationMPToken)[sfHolderEncryptionKey], sendChallenge);
+        if (!rerandomizedDestEc)
+            return tecINTERNAL;  // LCOV_EXCL_LINE
+
+        auto const curInbox = (*sleDestinationMPToken)[sfConfidentialBalanceInbox];
+        auto newInbox = homomorphicAdd(curInbox, *rerandomizedDestEc);
+        if (!newInbox)
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTSend failed homomorphic add for destination inbox.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        (*sleDestinationMPToken)[sfConfidentialBalanceInbox] = std::move(*newInbox);
+    }
+
+    // Add to issuer's balance
+    {
+        auto rerandomizedIssuerEc =
+            rerandomizeCiphertext(issuerEc, (*sleIssuance)[sfIssuerEncryptionKey], sendChallenge);
+        if (!rerandomizedIssuerEc)
+            return tecINTERNAL;  // LCOV_EXCL_LINE
+
+        auto const curIssuerEnc = (*sleDestinationMPToken)[sfIssuerEncryptedBalance];
+        auto newIssuerEnc = homomorphicAdd(curIssuerEnc, *rerandomizedIssuerEc);
+        if (!newIssuerEnc)
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTSend failed homomorphic add for destination issuer balance.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        (*sleDestinationMPToken)[sfIssuerEncryptedBalance] = std::move(*newIssuerEnc);
+    }
+
+    // Add to auditor's balance if present
+    if (auditorEc)
+    {
+        auto rerandomizedAuditorEc = rerandomizeCiphertext(
+            *auditorEc, (*sleIssuance)[sfAuditorEncryptionKey], sendChallenge);
+        if (!rerandomizedAuditorEc)
+            return tecINTERNAL;  // LCOV_EXCL_LINE
+
+        auto const curAuditorEnc = (*sleDestinationMPToken)[sfAuditorEncryptedBalance];
+        auto newAuditorEnc = homomorphicAdd(curAuditorEnc, *rerandomizedAuditorEc);
+        if (!newAuditorEnc)
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTSend failed homomorphic add for destination auditor balance.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        (*sleDestinationMPToken)[sfAuditorEncryptedBalance] = std::move(*newAuditorEnc);
+    }
+
+    // increment sender version only; receiver version is not modified by incoming sends
+    incrementConfidentialVersion(*sleSenderMPToken);
+
+    view().update(sleSenderMPToken);
+    view().update(sleDestinationMPToken);
+    return tesSUCCESS;
+}
+
+void
+ConfidentialMPTSend::visitInvariantEntry(
+    bool,
+    std::shared_ptr const&,
+    std::shared_ptr const&)
+{
+}
+
+bool
+ConfidentialMPTSend::finalizeInvariants(
+    STTx const&,
+    TER,
+    XRPAmount,
+    ReadView const&,
+    beast::Journal const&)
+{
+    return true;
+}
+
+}  // namespace xrpl
diff --git a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp
index 382ea7d40e..6db37515cb 100644
--- a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp
+++ b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp
@@ -15,8 +15,6 @@
 #include 
 
 #include 
-#include 
-
 namespace xrpl {
 
 std::uint32_t
@@ -48,7 +46,7 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
     //       `holderID` is NOT used
     if (!holderID)
     {
-        std::shared_ptr const sleMpt =
+        SLE::const_pointer const sleMpt =
             ctx.view.read(keylet::mptoken(ctx.tx[sfMPTokenIssuanceID], accountID));
 
         // There is an edge case where all holders have zero balance, issuance
@@ -66,7 +64,7 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
             if ((*sleMpt)[sfMPTAmount] != 0)
             {
                 auto const sleMptIssuance =
-                    ctx.view.read(keylet::mptIssuance(ctx.tx[sfMPTokenIssuanceID]));
+                    ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
                 if (!sleMptIssuance)
                     return tefINTERNAL;  // LCOV_EXCL_LINE
 
@@ -76,7 +74,7 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
             if ((*sleMpt)[~sfLockedAmount].value_or(0) != 0)
             {
                 auto const sleMptIssuance =
-                    ctx.view.read(keylet::mptIssuance(ctx.tx[sfMPTokenIssuanceID]));
+                    ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
                 if (!sleMptIssuance)
                     return tefINTERNAL;  // LCOV_EXCL_LINE
 
@@ -85,11 +83,31 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
             if (ctx.view.rules().enabled(featureSingleAssetVault) && sleMpt->isFlag(lsfMPTLocked))
                 return tecNO_PERMISSION;
 
+            if (ctx.view.rules().enabled(featureConfidentialTransfer))
+            {
+                auto const sleMptIssuance =
+                    ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
+
+                // if there still existing encrypted balances of MPT in
+                // circulation
+                if (sleMptIssuance &&
+                    (*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0) != 0)
+                {
+                    // this MPT still has encrypted balance, since we don't know
+                    // if it's non-zero or not, we won't allow deletion of
+                    // MPToken
+                    if (sleMpt->isFieldPresent(sfConfidentialBalanceInbox) ||
+                        sleMpt->isFieldPresent(sfConfidentialBalanceSpending))
+                        return tecHAS_OBLIGATIONS;
+                }
+            }
+
             return tesSUCCESS;
         }
 
         // Now test when the holder wants to hold/create/authorize a new MPT
-        auto const sleMptIssuance = ctx.view.read(keylet::mptIssuance(ctx.tx[sfMPTokenIssuanceID]));
+        auto const sleMptIssuance =
+            ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
 
         if (!sleMptIssuance)
             return tecOBJECT_NOT_FOUND;
@@ -108,7 +126,7 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
     if (!sleHolder)
         return tecNO_DST;
 
-    auto const sleMptIssuance = ctx.view.read(keylet::mptIssuance(ctx.tx[sfMPTokenIssuanceID]));
+    auto const sleMptIssuance = ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
     if (!sleMptIssuance)
         return tecOBJECT_NOT_FOUND;
 
@@ -133,8 +151,9 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
 
     // Can't unauthorize the pseudo-accounts because they are implicitly
     // always authorized. No need to amendment gate since Vault and LoanBroker
-    // can only be created if the Vault amendment is enabled.
-    if (isPseudoAccount(ctx.view, *holderID, {&sfVaultID, &sfLoanBrokerID}))
+    // 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}))
         return tecNO_PERMISSION;
 
     return tesSUCCESS;
@@ -155,10 +174,7 @@ MPTokenAuthorize::doApply()
 }
 
 void
-MPTokenAuthorize::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+MPTokenAuthorize::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp
index 1440b67309..e30127b688 100644
--- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp
+++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -10,6 +9,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -21,6 +21,7 @@
 #include 
 
 #include 
+#include 
 #include 
 
 namespace xrpl {
@@ -36,7 +37,15 @@ MPTokenIssuanceCreate::checkExtraFeatures(PreflightContext const& ctx)
     if (ctx.tx.isFieldPresent(sfMutableFlags) && !ctx.rules.enabled(featureDynamicMPT))
         return false;
 
-    return true;
+    if (ctx.tx.isFlag(tfMPTCanHoldConfidentialBalance) &&
+        !ctx.rules.enabled(featureConfidentialTransfer))
+        return false;
+
+    // can not set tmfMPTCannotEnableCanHoldConfidentialBalance without featureConfidentialTransfer
+    auto const mutableFlags = ctx.tx[~sfMutableFlags];
+    return !mutableFlags ||
+        ((*mutableFlags & tmfMPTCannotEnableCanHoldConfidentialBalance) == 0u) ||
+        ctx.rules.enabled(featureConfidentialTransfer);
 }
 
 std::uint32_t
@@ -49,6 +58,11 @@ MPTokenIssuanceCreate::getFlagsMask(PreflightContext const& ctx)
 NotTEC
 MPTokenIssuanceCreate::preflight(PreflightContext const& ctx)
 {
+    // sfReferenceHolding is set only internally by VaultCreate. Reject
+    // any user-submitted MPTokenIssuanceCreate that attempts to carry it.
+    if (ctx.rules.enabled(fixCleanup3_2_0) && ctx.tx.isFieldPresent(sfReferenceHolding))
+        return temMALFORMED;
+
     // If the mutable flags field is included, at least one flag must be
     // specified.
     if (auto const mutableFlags = ctx.tx[~sfMutableFlags]; mutableFlags &&
@@ -64,6 +78,10 @@ MPTokenIssuanceCreate::preflight(PreflightContext const& ctx)
         // must also be set.
         if (fee > 0u && !ctx.tx.isFlag(tfMPTCanTransfer))
             return temMALFORMED;
+
+        // Confidential amounts are encrypted so transfer rate is disallowed.
+        if (fee > 0u && ctx.tx.isFlag(tfMPTCanHoldConfidentialBalance))
+            return temBAD_TRANSFER_FEE;
     }
 
     if (auto const domain = ctx.tx[~sfDomainID])
@@ -94,19 +112,19 @@ MPTokenIssuanceCreate::preflight(PreflightContext const& ctx)
     return tesSUCCESS;
 }
 
-Expected
+std::expected
 MPTokenIssuanceCreate::create(ApplyView& view, beast::Journal journal, MPTCreateArgs const& args)
 {
     auto const acct = view.peek(keylet::account(args.account));
     if (!acct)
-        return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+        return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
 
     if (args.priorBalance &&
         *(args.priorBalance) < view.fees().accountReserve((*acct)[sfOwnerCount] + 1))
-        return Unexpected(tecINSUFFICIENT_RESERVE);
+        return std::unexpected(tecINSUFFICIENT_RESERVE);
 
     auto const mptId = makeMptID(args.sequence, args.account);
-    auto const mptIssuanceKeylet = keylet::mptIssuance(mptId);
+    auto const mptIssuanceKeylet = keylet::mptokenIssuance(mptId);
 
     // create the MPTokenIssuance
     {
@@ -114,7 +132,7 @@ MPTokenIssuanceCreate::create(ApplyView& view, beast::Journal journal, MPTCreate
             keylet::ownerDir(args.account), mptIssuanceKeylet, describeOwnerDir(args.account));
 
         if (!ownerNode)
-            return Unexpected(tecDIR_FULL);  // LCOV_EXCL_LINE
+            return std::unexpected(tecDIR_FULL);  // LCOV_EXCL_LINE
 
         auto mptIssuance = std::make_shared(mptIssuanceKeylet);
         (*mptIssuance)[sfFlags] = args.flags & ~tfUniversal;
@@ -141,6 +159,22 @@ MPTokenIssuanceCreate::create(ApplyView& view, beast::Journal journal, MPTCreate
         if (args.mutableFlags)
             (*mptIssuance)[sfMutableFlags] = *args.mutableFlags;
 
+        if (args.referenceHolding)
+        {
+            // Defensive: the holding must already exist and be of an
+            // expected type. Callers (currently only VaultCreate)
+            // populate this after the pseudo-account's MPToken /
+            // RippleState has been installed. A missing holding here
+            // would dangle the pointer and is a programmer error.
+            auto const sleHolding = view.read(keylet::unchecked(*args.referenceHolding));
+            if (!sleHolding)
+                return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+            auto const type = sleHolding->getType();
+            if (type != ltMPTOKEN && type != ltRIPPLE_STATE)
+                return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+            (*mptIssuance)[sfReferenceHolding] = *args.referenceHolding;
+        }
+
         view.insert(mptIssuance);
     }
 
@@ -173,10 +207,7 @@ MPTokenIssuanceCreate::doApply()
 }
 
 void
-MPTokenIssuanceCreate::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+MPTokenIssuanceCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceDestroy.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceDestroy.cpp
index 46811508b7..c56697767b 100644
--- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceDestroy.cpp
+++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceDestroy.cpp
@@ -9,8 +9,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 NotTEC
@@ -23,7 +21,7 @@ TER
 MPTokenIssuanceDestroy::preclaim(PreclaimContext const& ctx)
 {
     // ensure that issuance exists
-    auto const sleMPT = ctx.view.read(keylet::mptIssuance(ctx.tx[sfMPTokenIssuanceID]));
+    auto const sleMPT = ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
     if (!sleMPT)
         return tecOBJECT_NOT_FOUND;
 
@@ -44,7 +42,7 @@ MPTokenIssuanceDestroy::preclaim(PreclaimContext const& ctx)
 TER
 MPTokenIssuanceDestroy::doApply()
 {
-    auto const mpt = view().peek(keylet::mptIssuance(ctx_.tx[sfMPTokenIssuanceID]));
+    auto const mpt = view().peek(keylet::mptokenIssuance(ctx_.tx[sfMPTokenIssuanceID]));
     if (accountID_ != mpt->getAccountID(sfIssuer))
         return tecINTERNAL;  // LCOV_EXCL_LINE
 
@@ -59,10 +57,7 @@ MPTokenIssuanceDestroy::doApply()
 }
 
 void
-MPTokenIssuanceDestroy::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+MPTokenIssuanceDestroy::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp
index d8b62acf3f..d526251069 100644
--- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp
+++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp
@@ -5,7 +5,7 @@
 #include 
 #include 
 #include 
-#include 
+#include 
 #include 
 #include 
 #include 
@@ -16,15 +16,12 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 
 #include 
 #include 
 #include 
-#include 
-#include 
 
 namespace xrpl {
 
@@ -42,35 +39,34 @@ MPTokenIssuanceSet::getFlagsMask(PreflightContext const& ctx)
     return tfMPTokenIssuanceSetMask;
 }
 
-// Maps set/clear mutable flags in an MPTokenIssuanceSet transaction to the
-// corresponding ledger mutable flags that control whether the change is
-// allowed.
+// Maps each MPTokenIssuanceSet MutableFlags to the corresponding mutable
+// flag and the target ledger flag to mutate.
 struct MPTMutabilityFlags
 {
     std::uint32_t setFlag;
-    std::uint32_t clearFlag;
-    std::uint32_t canMutateFlag;
+    std::uint32_t canEnableFlag;
+    std::uint32_t ledgerFlag;
 };
 
 static constexpr std::array kMptMutabilityFlags = {
     {{.setFlag = tmfMPTSetCanLock,
-      .clearFlag = tmfMPTClearCanLock,
-      .canMutateFlag = lsmfMPTCanMutateCanLock},
+      .canEnableFlag = lsmfMPTCanEnableCanLock,
+      .ledgerFlag = lsfMPTCanLock},
      {.setFlag = tmfMPTSetRequireAuth,
-      .clearFlag = tmfMPTClearRequireAuth,
-      .canMutateFlag = lsmfMPTCanMutateRequireAuth},
+      .canEnableFlag = lsmfMPTCanEnableRequireAuth,
+      .ledgerFlag = lsfMPTRequireAuth},
      {.setFlag = tmfMPTSetCanEscrow,
-      .clearFlag = tmfMPTClearCanEscrow,
-      .canMutateFlag = lsmfMPTCanMutateCanEscrow},
+      .canEnableFlag = lsmfMPTCanEnableCanEscrow,
+      .ledgerFlag = lsfMPTCanEscrow},
      {.setFlag = tmfMPTSetCanTrade,
-      .clearFlag = tmfMPTClearCanTrade,
-      .canMutateFlag = lsmfMPTCanMutateCanTrade},
+      .canEnableFlag = lsmfMPTCanEnableCanTrade,
+      .ledgerFlag = lsfMPTCanTrade},
      {.setFlag = tmfMPTSetCanTransfer,
-      .clearFlag = tmfMPTClearCanTransfer,
-      .canMutateFlag = lsmfMPTCanMutateCanTransfer},
+      .canEnableFlag = lsmfMPTCanEnableCanTransfer,
+      .ledgerFlag = lsfMPTCanTransfer},
      {.setFlag = tmfMPTSetCanClawback,
-      .clearFlag = tmfMPTClearCanClawback,
-      .canMutateFlag = lsmfMPTCanMutateCanClawback}}};
+      .canEnableFlag = lsmfMPTCanEnableCanClawback,
+      .ledgerFlag = lsfMPTCanClawback}}};
 
 NotTEC
 MPTokenIssuanceSet::preflight(PreflightContext const& ctx)
@@ -79,11 +75,27 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx)
     auto const metadata = ctx.tx[~sfMPTokenMetadata];
     auto const transferFee = ctx.tx[~sfTransferFee];
     auto const isMutate = mutableFlags || metadata || transferFee;
+    auto const hasIssuerElGamalKey = ctx.tx.isFieldPresent(sfIssuerEncryptionKey);
+    auto const hasAuditorElGamalKey = ctx.tx.isFieldPresent(sfAuditorEncryptionKey);
+    auto const txFlags = ctx.tx.getFlags();
+
+    bool const enablePrivacy =
+        mutableFlags && (*mutableFlags & tmfMPTSetCanHoldConfidentialBalance) != 0u;
+
+    auto const hasDomain = ctx.tx.isFieldPresent(sfDomainID);
+    auto const hasHolder = ctx.tx.isFieldPresent(sfHolder);
 
     if (isMutate && !ctx.rules.enabled(featureDynamicMPT))
         return temDISABLED;
 
-    if (ctx.tx.isFieldPresent(sfDomainID) && ctx.tx.isFieldPresent(sfHolder))
+    if ((hasIssuerElGamalKey || hasAuditorElGamalKey || enablePrivacy) &&
+        !ctx.rules.enabled(featureConfidentialTransfer))
+        return temDISABLED;
+
+    if (hasDomain && hasHolder)
+        return temMALFORMED;
+
+    if (enablePrivacy && hasHolder)
         return temMALFORMED;
 
     // fails if both flags are set
@@ -95,10 +107,12 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx)
     if (holderID && accountID == holderID)
         return temMALFORMED;
 
-    if (ctx.rules.enabled(featureSingleAssetVault) || ctx.rules.enabled(featureDynamicMPT))
+    if (ctx.rules.enabled(featureSingleAssetVault) || ctx.rules.enabled(featureDynamicMPT) ||
+        ctx.rules.enabled(featureConfidentialTransfer))
     {
         // Is this transaction actually changing anything ?
-        if (ctx.tx.getFlags() == 0 && !ctx.tx.isFieldPresent(sfDomainID) && !isMutate)
+        if (txFlags == 0 && !hasDomain && !hasIssuerElGamalKey && !hasAuditorElGamalKey &&
+            !isMutate)
             return temMALFORMED;
     }
 
@@ -115,6 +129,9 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx)
         if (transferFee && *transferFee > kMaxTransferFee)
             return temBAD_TRANSFER_FEE;
 
+        if (transferFee && *transferFee > 0u && enablePrivacy)
+            return temBAD_TRANSFER_FEE;
+
         if (metadata && metadata->length() > kMaxMpTokenMetadataLength)
             return temMALFORMED;
 
@@ -122,52 +139,20 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx)
         {
             if ((*mutableFlags == 0u) || ((*mutableFlags & tmfMPTokenIssuanceSetMutableMask) != 0u))
                 return temINVALID_FLAG;
-
-            // Can not set and clear the same flag
-            if (std::ranges::any_of(kMptMutabilityFlags, [mutableFlags](auto const& f) {
-                    return (*mutableFlags & f.setFlag) && (*mutableFlags & f.clearFlag);
-                }))
-                return temINVALID_FLAG;
-
-            // Trying to set a non-zero TransferFee and clear MPTCanTransfer
-            // in the same transaction is not allowed.
-            if ((transferFee.value_or(0) != 0u) && ((*mutableFlags & tmfMPTClearCanTransfer) != 0u))
-                return temMALFORMED;
         }
     }
 
-    return tesSUCCESS;
-}
+    if (hasHolder && (hasIssuerElGamalKey || hasAuditorElGamalKey))
+        return temMALFORMED;
 
-NotTEC
-MPTokenIssuanceSet::checkPermission(ReadView const& view, STTx const& tx)
-{
-    auto const delegate = tx[~sfDelegate];
-    if (!delegate)
-        return tesSUCCESS;
+    if (hasAuditorElGamalKey && !hasIssuerElGamalKey)
+        return temMALFORMED;
 
-    auto const delegateKey = keylet::delegate(tx[sfAccount], *delegate);
-    auto const sle = view.read(delegateKey);
+    if (hasIssuerElGamalKey && !isValidCompressedECPoint(ctx.tx[sfIssuerEncryptionKey]))
+        return temMALFORMED;
 
-    if (!sle)
-        return terNO_DELEGATE_PERMISSION;
-
-    if (isTesSuccess(checkTxPermission(sle, tx)))
-        return tesSUCCESS;
-
-    // this is added in case more flags will be added for MPTokenIssuanceSet
-    // in the future. Currently unreachable.
-    if ((tx.getFlags() & tfMPTokenIssuanceSetMask) != 0u)
-        return terNO_DELEGATE_PERMISSION;  // LCOV_EXCL_LINE
-
-    std::unordered_set granularPermissions;
-    loadGranularPermission(sle, ttMPTOKEN_ISSUANCE_SET, granularPermissions);
-
-    if (tx.isFlag(tfMPTLock) && !granularPermissions.contains(MPTokenIssuanceLock))
-        return terNO_DELEGATE_PERMISSION;
-
-    if (tx.isFlag(tfMPTUnlock) && !granularPermissions.contains(MPTokenIssuanceUnlock))
-        return terNO_DELEGATE_PERMISSION;
+    if (hasAuditorElGamalKey && !isValidCompressedECPoint(ctx.tx[sfAuditorEncryptionKey]))
+        return temMALFORMED;
 
     return tesSUCCESS;
 }
@@ -176,7 +161,7 @@ TER
 MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx)
 {
     // ensure that issuance exists
-    auto const sleMptIssuance = ctx.view.read(keylet::mptIssuance(ctx.tx[sfMPTokenIssuanceID]));
+    auto const sleMptIssuance = ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
     if (!sleMptIssuance)
         return tecOBJECT_NOT_FOUND;
 
@@ -230,18 +215,19 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx)
         return currentMutableFlags & mutableFlag;
     };
 
-    if (auto const mutableFlags = ctx.tx[~sfMutableFlags])
+    auto const mutableFlags = ctx.tx[~sfMutableFlags];
+    // Whether the transaction is enabling confidential amounts.
+    bool const enablesConfidentialAmount =
+        mutableFlags && (*mutableFlags & tmfMPTSetCanHoldConfidentialBalance) != 0u;
+    if (mutableFlags)
     {
         if (std::ranges::any_of(kMptMutabilityFlags, [mutableFlags, &isMutableFlag](auto const& f) {
-                return !isMutableFlag(f.canMutateFlag) &&
-                    ((*mutableFlags & (f.setFlag | f.clearFlag)));
+                return !isMutableFlag(f.canEnableFlag) && ((*mutableFlags & f.setFlag) != 0u);
             }))
             return tecNO_PERMISSION;
 
-        // Clearing lsfMPTRequireAuth is invalid when the issuance already has
-        // a DomainID set, because a DomainID requires RequireAuth to be active.
-        if ((*mutableFlags & tmfMPTClearRequireAuth) != 0u &&
-            sleMptIssuance->isFieldPresent(sfDomainID))
+        if (enablesConfidentialAmount &&
+            isMutableFlag(lsmfMPTCannotEnableCanHoldConfidentialBalance))
             return tecNO_PERMISSION;
     }
 
@@ -257,10 +243,55 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx)
         if (fee > 0u && !sleMptIssuance->isFlag(lsfMPTCanTransfer))
             return tecNO_PERMISSION;
 
+        // Cannot set a non-zero TransferFee on an issuance that has confidential
+        // transfer enabled
+        if (fee > 0u && sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance))
+            return tecNO_PERMISSION;
+
         if (!isMutableFlag(lsmfMPTCanMutateTransferFee))
             return tecNO_PERMISSION;
     }
 
+    // cannot update issuer public key
+    if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) &&
+        sleMptIssuance->isFieldPresent(sfIssuerEncryptionKey))
+    {
+        return tecNO_PERMISSION;
+    }
+
+    // cannot update auditor public key
+    if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) &&
+        sleMptIssuance->isFieldPresent(sfAuditorEncryptionKey))
+    {
+        return tecNO_PERMISSION;  // LCOV_EXCL_LINE
+    }
+
+    if (enablesConfidentialAmount && sleMptIssuance->isFieldPresent(sfTransferFee) &&
+        (*sleMptIssuance)[sfTransferFee] > 0u)
+        return tecNO_PERMISSION;
+
+    // Encryption keys can only be set if confidential amounts are already
+    // enabled on the issuance OR if the transaction is enabling it
+    if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) &&
+        !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialAmount)
+    {
+        return tecNO_PERMISSION;
+    }
+
+    if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) &&
+        !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialAmount)
+    {
+        return tecNO_PERMISSION;
+    }
+
+    // cannot upload key if there's circulating supply of COA
+    if ((ctx.tx.isFieldPresent(sfIssuerEncryptionKey) ||
+         ctx.tx.isFieldPresent(sfAuditorEncryptionKey) || enablesConfidentialAmount) &&
+        (*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0)
+    {
+        return tecNO_PERMISSION;  // LCOV_EXCL_LINE
+    }
+
     return tesSUCCESS;
 }
 
@@ -270,7 +301,7 @@ MPTokenIssuanceSet::doApply()
     auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID];
     auto const holderID = ctx_.tx[~sfHolder];
     auto const domainID = ctx_.tx[~sfDomainID];
-    std::shared_ptr sle;
+    SLE::pointer sle;
 
     if (holderID)
     {
@@ -278,7 +309,7 @@ MPTokenIssuanceSet::doApply()
     }
     else
     {
-        sle = view().peek(keylet::mptIssuance(mptIssuanceID));
+        sle = view().peek(keylet::mptokenIssuance(mptIssuanceID));
     }
 
     if (!sle)
@@ -302,20 +333,12 @@ MPTokenIssuanceSet::doApply()
         {
             if ((mutableFlags & f.setFlag) != 0u)
             {
-                flagsOut |= f.canMutateFlag;
-            }
-            else if ((mutableFlags & f.clearFlag) != 0u)
-            {
-                flagsOut &= ~f.canMutateFlag;
+                flagsOut |= f.ledgerFlag;
             }
         }
 
-        if ((mutableFlags & tmfMPTClearCanTransfer) != 0u)
-        {
-            // If the lsfMPTCanTransfer flag is being cleared, then also clear
-            // the TransferFee field.
-            sle->makeFieldAbsent(sfTransferFee);
-        }
+        if ((mutableFlags & tmfMPTSetCanHoldConfidentialBalance) != 0u)
+            flagsOut |= lsfMPTCanHoldConfidentialBalance;
     }
 
     if (flagsIn != flagsOut)
@@ -367,16 +390,33 @@ MPTokenIssuanceSet::doApply()
         }
     }
 
+    if (auto const pubKey = ctx_.tx[~sfIssuerEncryptionKey])
+    {
+        // This is enforced in preflight.
+        XRPL_ASSERT(
+            sle->getType() == ltMPTOKEN_ISSUANCE,
+            "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance");
+
+        sle->setFieldVL(sfIssuerEncryptionKey, *pubKey);
+    }
+
+    if (auto const pubKey = ctx_.tx[~sfAuditorEncryptionKey])
+    {
+        // This is enforced in preflight.
+        XRPL_ASSERT(
+            sle->getType() == ltMPTOKEN_ISSUANCE,
+            "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance");
+
+        sle->setFieldVL(sfAuditorEncryptionKey, *pubKey);
+    }
+
     view().update(sle);
 
     return tesSUCCESS;
 }
 
 void
-MPTokenIssuanceSet::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+MPTokenIssuanceSet::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/token/TrustSet.cpp b/src/libxrpl/tx/transactors/token/TrustSet.cpp
index a8ea786347..151e82bab9 100644
--- a/src/libxrpl/tx/transactors/token/TrustSet.cpp
+++ b/src/libxrpl/tx/transactors/token/TrustSet.cpp
@@ -6,7 +6,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -21,13 +20,11 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
 
 #include 
-#include 
 #include 
 
 namespace {
@@ -125,51 +122,21 @@ TrustSet::preflight(PreflightContext const& ctx)
 }
 
 NotTEC
-TrustSet::checkPermission(ReadView const& view, STTx const& tx)
+TrustSet::checkGranularSemantics(
+    ReadView const& view,
+    STTx const& tx,
+    std::unordered_set const& heldGranularPermissions)
 {
-    auto const delegate = tx[~sfDelegate];
-    if (!delegate)
-        return tesSUCCESS;
-
-    auto const delegateKey = keylet::delegate(tx[sfAccount], *delegate);
-    auto const sle = view.read(delegateKey);
-
-    if (!sle)
-        return terNO_DELEGATE_PERMISSION;
-
-    if (isTesSuccess(checkTxPermission(sle, tx)))
-        return tesSUCCESS;
-
-    // Currently we only support TrustlineAuthorize, TrustlineFreeze and
-    // TrustlineUnfreeze granular permission. Setting other flags returns
-    // error.
-    if ((tx.getFlags() & tfTrustSetPermissionMask) != 0u)
-        return terNO_DELEGATE_PERMISSION;
-
-    if (tx.isFieldPresent(sfQualityIn) || tx.isFieldPresent(sfQualityOut))
-        return terNO_DELEGATE_PERMISSION;
-
     auto const saLimitAmount = tx.getFieldAmount(sfLimitAmount);
     auto const sleRippleState = view.read(
-        keylet::line(
+        keylet::trustLine(
             tx[sfAccount], saLimitAmount.getIssuer(), saLimitAmount.get().currency));
 
-    // if the trustline does not exist, granular permissions are
-    // not allowed to create trustline
+    // granular permissions are not allowed to create a trustline
     if (!sleRippleState)
         return terNO_DELEGATE_PERMISSION;
 
-    std::unordered_set granularPermissions;
-    loadGranularPermission(sle, ttTRUST_SET, granularPermissions);
-
-    if (tx.isFlag(tfSetfAuth) && !granularPermissions.contains(TrustlineAuthorize))
-        return terNO_DELEGATE_PERMISSION;
-    if (tx.isFlag(tfSetFreeze) && !granularPermissions.contains(TrustlineFreeze))
-        return terNO_DELEGATE_PERMISSION;
-    if (tx.isFlag(tfClearFreeze) && !granularPermissions.contains(TrustlineUnfreeze))
-        return terNO_DELEGATE_PERMISSION;
-
-    // updating LimitAmount is not allowed only with granular permissions,
+    // updating LimitAmount is not allowed with granular permissions,
     // unless there's a new granular permission for this in the future.
     auto const curLimit = tx[sfAccount] > saLimitAmount.getIssuer()
         ? sleRippleState->getFieldAmount(sfHighLimit)
@@ -225,7 +192,7 @@ TrustSet::preclaim(PreclaimContext const& ctx)
         //   o The trust line already exists
         // Then allow the TrustSet.
         if (ctx.view.rules().enabled(fixDisallowIncomingV1) &&
-            ctx.view.exists(keylet::line(id, uDstAccountID, currency)))
+            ctx.view.exists(keylet::trustLine(id, uDstAccountID, currency)))
         {
             // pass
         }
@@ -245,7 +212,7 @@ TrustSet::preclaim(PreclaimContext const& ctx)
         // TrustSet if the asset is AMM LP token and AMM is not in empty state.
         if (sleDst->isFieldPresent(sfAMMID))
         {
-            if (ctx.view.exists(keylet::line(id, uDstAccountID, currency)))
+            if (ctx.view.exists(keylet::trustLine(id, uDstAccountID, currency)))
             {
                 // pass
             }
@@ -268,7 +235,7 @@ TrustSet::preclaim(PreclaimContext const& ctx)
         }
         else if (sleDst->isFieldPresent(sfVaultID) || sleDst->isFieldPresent(sfLoanBrokerID))
         {
-            if (!ctx.view.exists(keylet::line(id, uDstAccountID, currency)))
+            if (!ctx.view.exists(keylet::trustLine(id, uDstAccountID, currency)))
                 return tecNO_PERMISSION;
             // else pass
         }
@@ -302,7 +269,7 @@ TrustSet::preclaim(PreclaimContext const& ctx)
 
         bool const bHigh = id > uDstAccountID;
         // Fetching current state of trust line
-        auto const sleRippleState = ctx.view.read(keylet::line(id, uDstAccountID, currency));
+        auto const sleRippleState = ctx.view.read(keylet::trustLine(id, uDstAccountID, currency));
         std::uint32_t uFlags = sleRippleState ? sleRippleState->getFieldU32(sfFlags) : 0u;
         // Computing expected trust line state
         uFlags = computeFreezeFlags(
@@ -394,7 +361,7 @@ TrustSet::doApply()
     saLimitAllow.get().account = accountID_;
 
     SLE::pointer const sleRippleState =
-        view().peek(keylet::line(accountID_, uDstAccountID, currency));
+        view().peek(keylet::trustLine(accountID_, uDstAccountID, currency));
 
     if (sleRippleState)
     {
@@ -642,7 +609,7 @@ TrustSet::doApply()
         // Zero balance in currency.
         STAmount const saBalance(Issue{currency, noAccount()});
 
-        auto const k = keylet::line(accountID_, uDstAccountID, currency);
+        auto const k = keylet::trustLine(accountID_, uDstAccountID, currency);
 
         JLOG(j_.trace()) << "doTrustSet: Creating ripple line: " << to_string(k.key);
 
@@ -669,10 +636,7 @@ TrustSet::doApply()
 }
 
 void
-TrustSet::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+TrustSet::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
index b5b2c1f384..541660c98f 100644
--- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -26,7 +25,7 @@
 #include 
 #include 
 
-#include 
+#include 
 #include 
 #include 
 #include 
@@ -61,7 +60,7 @@ VaultClawback::preflight(PreflightContext const& ctx)
 
 [[nodiscard]] STAmount
 clawbackAmount(
-    std::shared_ptr const& vault,
+    SLE::const_ref vault,
     std::optional const& maybeAmount,
     AccountID const& account)
 {
@@ -87,7 +86,7 @@ VaultClawback::preclaim(PreclaimContext const& ctx)
     auto const holder = ctx.tx[sfHolder];
     auto const maybeAmount = ctx.tx[~sfAmount];
     auto const mptIssuanceID = vault->at(sfShareMPTID);
-    auto const sleShareIssuance = ctx.view.read(keylet::mptIssuance(mptIssuanceID));
+    auto const sleShareIssuance = ctx.view.read(keylet::mptokenIssuance(mptIssuanceID));
     if (!sleShareIssuance)
     {
         // LCOV_EXCL_START
@@ -181,7 +180,7 @@ VaultClawback::preclaim(PreclaimContext const& ctx)
 
         return vaultAsset.visit(
             [&](MPTIssue const& issue) -> TER {
-                auto const mptIssue = ctx.view.read(keylet::mptIssuance(issue.getMptID()));
+                auto const mptIssue = ctx.view.read(keylet::mptokenIssuance(issue.getMptID()));
                 if (mptIssue == nullptr)
                     return tecOBJECT_NOT_FOUND;
 
@@ -219,10 +218,10 @@ VaultClawback::preclaim(PreclaimContext const& ctx)
     return tecWRONG_ASSET;
 }
 
-Expected, TER>
+std::expected, TER>
 VaultClawback::assetsToClawback(
-    std::shared_ptr const& vault,
-    std::shared_ptr const& sleShareIssuance,
+    SLE::ref vault,
+    SLE::const_ref sleShareIssuance,
     AccountID const& holder,
     STAmount const& clawbackAmount)
 {
@@ -231,7 +230,7 @@ VaultClawback::assetsToClawback(
         // preclaim should have blocked this , now it's an internal error
         // LCOV_EXCL_START
         JLOG(j_.error()) << "VaultClawback: asset mismatch in clawback.";
-        return Unexpected(tecINTERNAL);
+        return std::unexpected(tecINTERNAL);
         // LCOV_EXCL_STOP
     }
 
@@ -249,7 +248,7 @@ VaultClawback::assetsToClawback(
             view(), holder, share, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j_);
         auto const maybeAssets = sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
         if (!maybeAssets)
-            return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+            return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
 
         return std::make_pair(*maybeAssets, sharesDestroyed);
     }
@@ -266,7 +265,7 @@ VaultClawback::assetsToClawback(
             auto const maybeAssets =
                 sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
             if (!maybeAssets)
-                return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+                return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
 
             assetsRecovered = *maybeAssets;
         }
@@ -275,13 +274,13 @@ VaultClawback::assetsToClawback(
             auto const maybeShares =
                 assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount);
             if (!maybeShares)
-                return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+                return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
             sharesDestroyed = *maybeShares;
 
             auto const maybeAssets =
                 sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
             if (!maybeAssets)
-                return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+                return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
             assetsRecovered = *maybeAssets;
         }
         // Clamp to maximum.
@@ -295,20 +294,20 @@ VaultClawback::assetsToClawback(
                 auto const maybeShares = assetsToSharesWithdraw(
                     vault, sleShareIssuance, assetsRecovered, TruncateShares::Yes);
                 if (!maybeShares)
-                    return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+                    return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
                 sharesDestroyed = *maybeShares;
             }
 
             auto const maybeAssets =
                 sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
             if (!maybeAssets)
-                return Unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+                return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
             assetsRecovered = *maybeAssets;
             if (assetsRecovered > *assetsAvailable)
             {
                 // LCOV_EXCL_START
                 JLOG(j_.error()) << "VaultClawback: invalid rounding of shares.";
-                return Unexpected(tecINTERNAL);
+                return std::unexpected(tecINTERNAL);
                 // LCOV_EXCL_STOP
             }
         }
@@ -323,7 +322,7 @@ VaultClawback::assetsToClawback(
             << ", assetsTotal=" << vault->at(sfAssetsTotal).value()
             << ", sharesTotal=" << sleShareIssuance->at(sfOutstandingAmount)
             << ", amount=" << clawbackAmount.value();
-        return Unexpected(tecPATH_DRY);
+        return std::unexpected(tecPATH_DRY);
     }
 
     return std::make_pair(assetsRecovered, sharesDestroyed);
@@ -338,7 +337,7 @@ VaultClawback::doApply()
         return tefINTERNAL;  // LCOV_EXCL_LINE
 
     auto const mptIssuanceID = *vault->at(sfShareMPTID);
-    auto const sleIssuance = view().read(keylet::mptIssuance(mptIssuanceID));
+    auto const sleIssuance = view().read(keylet::mptokenIssuance(mptIssuanceID));
     if (!sleIssuance)
     {
         // LCOV_EXCL_START
@@ -452,10 +451,7 @@ VaultClawback::doApply()
 }
 
 void
-VaultClawback::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+VaultClawback::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp
index c6e1f28d57..d262132b6f 100644
--- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp
@@ -1,12 +1,14 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -162,9 +164,9 @@ VaultCreate::doApply()
     auto maybePseudo = createPseudoAccount(view(), vault->key(), sfVaultID);
     if (!maybePseudo)
         return maybePseudo.error();  // LCOV_EXCL_LINE
-    auto& pseudo = *maybePseudo;
-    auto pseudoId = pseudo->at(sfAccount);
-    auto asset = tx[sfAsset];
+    auto const& pseudo = *maybePseudo;
+    AccountID const pseudoId = pseudo->at(sfAccount);
+    auto const asset = tx[sfAsset];
 
     if (auto ter = addEmptyHolding(view(), pseudoId, preFeeBalance_, asset, j_); !isTesSuccess(ter))
         return ter;
@@ -182,13 +184,24 @@ VaultCreate::doApply()
     // Note, here we are **not** creating an MPToken for the assets held in
     // the vault. That MPToken or TrustLine/RippleState is created above, in
     // addEmptyHolding. Here we are creating MPTokenIssuance for the shares
-    // in the vault
-    auto maybeShare = MPTokenIssuanceCreate::create(
+    // in the vault.
+    //
+    // Post-fixCleanup3_2_0: surface the vault pseudo's holding (MPToken
+    // for MPT, RippleState for IOU) on the share via sfReferenceHolding.
+    // XRP underlyings leave it unset.
+    auto const referenceHolding = [&]() -> std::optional {
+        if (!view().rules().enabled(fixCleanup3_2_0) || asset.native())
+            return std::nullopt;
+        return asset.holds()
+            ? keylet::mptoken(asset.get().getMptID(), pseudoId).key
+            : keylet::trustLine(pseudoId, asset.get()).key;
+    }();
+    auto const maybeShare = MPTokenIssuanceCreate::create(
         view(),
         j_,
         {
             .priorBalance = std::nullopt,
-            .account = pseudoId->value(),
+            .account = pseudoId,
             .sequence = 1,
             .flags = mptFlags,
             .assetScale = scale,
@@ -196,6 +209,7 @@ VaultCreate::doApply()
             .metadata = tx[~sfMPTokenMetadata],
             .domainId = tx[~sfDomainID],
             .mutableFlags = std::nullopt,
+            .referenceHolding = referenceHolding,
         });
     if (!maybeShare)
         return maybeShare.error();  // LCOV_EXCL_LINE
@@ -249,10 +263,7 @@ VaultCreate::doApply()
 }
 
 void
-VaultCreate::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+VaultCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
index 438896e7ed..0550f08b17 100644
--- a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
@@ -17,8 +17,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 NotTEC
@@ -59,7 +57,7 @@ VaultDelete::preclaim(PreclaimContext const& ctx)
     }
 
     // Verify we can destroy MPTokenIssuance
-    auto const sleMPT = ctx.view.read(keylet::mptIssuance(vault->at(sfShareMPTID)));
+    auto const sleMPT = ctx.view.read(keylet::mptokenIssuance(vault->at(sfShareMPTID)));
 
     if (!sleMPT)
     {
@@ -112,7 +110,7 @@ VaultDelete::doApply()
     // Destroy the share issuance. Do not use MPTokenIssuanceDestroy for this,
     // no special logic needed. First run few checks, duplicated from preclaim.
     auto const shareMPTID = *vault->at(sfShareMPTID);
-    auto const mpt = view().peek(keylet::mptIssuance(shareMPTID));
+    auto const mpt = view().peek(keylet::mptokenIssuance(shareMPTID));
     if (!mpt)
     {
         // LCOV_EXCL_START
@@ -213,10 +211,7 @@ VaultDelete::doApply()
 }
 
 void
-VaultDelete::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+VaultDelete::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
index aaaf3cced8..d5eb80b84d 100644
--- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
@@ -1,6 +1,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -13,6 +14,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include   // IWYU pragma: keep
 #include 
@@ -21,11 +23,28 @@
 #include 
 #include 
 
-#include 
 #include 
 
 namespace xrpl {
 
+[[nodiscard]]
+static STAmount
+roundToVaultScale(STAmount const& amount, SLE::const_ref vault)
+{
+    XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::roundToVaultScale : valid vault sle");
+    XRPL_ASSERT(
+        amount.asset() == vault->at(sfAsset), "xrpl::roundToVaultScale : valid vault asset");
+
+    if (amount.integral())
+        return amount;
+
+    int const postScale = [&]() {
+        NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
+        return scale(vault->at(sfAssetsTotal) + amount, vault->at(sfAsset));
+    }();
+    return roundToScale(amount, postScale, Number::RoundingMode::Downward);
+}
+
 NotTEC
 VaultDeposit::preflight(PreflightContext const& ctx)
 {
@@ -44,14 +63,17 @@ VaultDeposit::preflight(PreflightContext const& ctx)
 TER
 VaultDeposit::preclaim(PreclaimContext const& ctx)
 {
+    auto const fix320Enabled = ctx.view.rules().enabled(fixCleanup3_2_0);
+    auto const fix330Enabled = ctx.view.rules().enabled(fixCleanup3_3_0);
+
     auto const vault = ctx.view.read(keylet::vault(ctx.tx[sfVaultID]));
     if (!vault)
         return tecNO_ENTRY;
 
     auto const& account = ctx.tx[sfAccount];
-    auto const assets = ctx.tx[sfAmount];
+    auto const amount = ctx.tx[sfAmount];
     auto const vaultAsset = vault->at(sfAsset);
-    if (assets.asset() != vaultAsset)
+    if (amount.asset() != vaultAsset)
         return tecWRONG_ASSET;
 
     auto const& vaultAccount = vault->at(sfAccount);
@@ -63,7 +85,7 @@ VaultDeposit::preclaim(PreclaimContext const& ctx)
 
     auto const mptIssuanceID = vault->at(sfShareMPTID);
     auto const vaultShare = MPTIssue(mptIssuanceID);
-    if (vaultShare == assets.asset())
+    if (vaultShare == amount.asset())
     {
         // LCOV_EXCL_START
         JLOG(ctx.j.error()) << "VaultDeposit: vault shares and assets cannot be same.";
@@ -71,7 +93,7 @@ VaultDeposit::preclaim(PreclaimContext const& ctx)
         // LCOV_EXCL_STOP
     }
 
-    auto const sleIssuance = ctx.view.read(keylet::mptIssuance(mptIssuanceID));
+    auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(mptIssuanceID));
     if (!sleIssuance)
     {
         // LCOV_EXCL_START
@@ -88,13 +110,21 @@ VaultDeposit::preclaim(PreclaimContext const& ctx)
         // LCOV_EXCL_STOP
     }
 
-    // Cannot deposit inside Vault an Asset frozen for the depositor
-    if (isFrozen(ctx.view, account, vaultAsset))
-        return vaultAsset.holds() ? tecFROZEN : tecLOCKED;
+    if (fix330Enabled)
+    {
+        if (auto const ret = checkDepositFreeze(ctx.view, account, vaultAccount, vaultAsset))
+            return ret;
+    }
+    else
+    {
+        // Cannot deposit inside Vault an Asset frozen for the depositor
+        if (isFrozen(ctx.view, account, vaultAsset))
+            return vaultAsset.holds() ? tecFROZEN : tecLOCKED;
 
-    // Cannot deposit if the shares of the vault are frozen
-    if (isFrozen(ctx.view, account, vaultShare))
-        return tecLOCKED;
+        // Cannot deposit if the shares of the vault are frozen
+        if (isFrozen(ctx.view, account, vaultShare))
+            return tecLOCKED;
+    }
 
     if (vault->isFlag(lsfVaultPrivate) && account != vault->at(sfOwner))
     {
@@ -122,31 +152,71 @@ VaultDeposit::preclaim(PreclaimContext const& ctx)
     if (auto const ter = requireAuth(ctx.view, vaultAsset, account); !isTesSuccess(ter))
         return ter;
 
-    if (accountHolds(
-            ctx.view,
-            account,
-            vaultAsset,
-            FreezeHandling::ZeroIfFrozen,
-            AuthHandling::ZeroIfUnauthorized,
-            ctx.j,
-            SpendableHandling::FullBalance) < assets)
+    auto const roundedAmount = fix320Enabled ? roundToVaultScale(amount, vault) : amount;
+
+    if (fix320Enabled && roundedAmount == beast::kZero)
+    {
+        JLOG(ctx.j.warn()) << "VaultDeposit: deposit amount: " << ctx.tx[sfAmount]
+                           << " is zero at vault scale";
+        return tecPRECISION_LOSS;
+    }
+
+    auto const accountBalance = accountHolds(
+        ctx.view,
+        account,
+        vaultAsset,
+        FreezeHandling::ZeroIfFrozen,
+        AuthHandling::ZeroIfUnauthorized,
+        ctx.j,
+        SpendableHandling::FullBalance);
+
+    if (accountBalance < roundedAmount)
         return tecINSUFFICIENT_FUNDS;
 
+    // IOU precision checks
+    if (fix320Enabled && !roundedAmount.integral())
+    {
+        // reject deposits that would canonicalize to a no-op at the depositor's trustline scale.
+        // Skipped for issuer-as-depositor: accountHolds returns (kMaxValue @ kMaxOffset) which
+        // would always trip the predicate.
+        if (account != amount.getIssuer() &&
+            amount.isZeroAtScale(scale(accountBalance, vaultAsset)))
+        {
+            JLOG(ctx.j.warn()) << "VaultDeposit: amount " << amount.getFullText()
+                               << " rounds to zero at counterparty trust-line scale";
+            return tecPRECISION_LOSS;
+        }
+    }
+
     return tesSUCCESS;
 }
 
 TER
 VaultDeposit::doApply()
 {
+    bool const fix320Enabled = view().rules().enabled(fixCleanup3_2_0);
     auto const vault = view().peek(keylet::vault(ctx_.tx[sfVaultID]));
     if (!vault)
         return tefINTERNAL;  // LCOV_EXCL_LINE
     auto const vaultAsset = vault->at(sfAsset);
 
-    auto const amount = ctx_.tx[sfAmount];
+    // Post-amendment IOU only: round Downward to the AssetsTotal precision so
+    // a sub-ULP tail can't be silently absorbed by one rail and not the other.
+    auto const amount =
+        fix320Enabled ? roundToVaultScale(ctx_.tx[sfAmount], vault) : ctx_.tx[sfAmount];
+
+    // We validated zero-amount in preclaim, if we ended up with zero now, fail hard.
+    if (amount == beast::kZero)
+    {
+        // LCOV_EXCL_START
+        JLOG(j_.error()) << "VaultDeposit: deposit amount: " << ctx_.tx[sfAmount] << " is zero";
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
+
     // Make sure the depositor can hold shares.
     auto const mptIssuanceID = (*vault)[sfShareMPTID];
-    auto const sleIssuance = view().read(keylet::mptIssuance(mptIssuanceID));
+    auto const sleIssuance = view().read(keylet::mptokenIssuance(mptIssuanceID));
     if (!sleIssuance)
     {
         // LCOV_EXCL_START
@@ -259,7 +329,7 @@ VaultDeposit::doApply()
     // trust line into debt the exact case preclaim authorizes via SpendableHandling::FullBalance.
     // The check thus converts a preclaim- authorized deposit into tefINTERNAL after the asset
     // transfer.
-    if (!view().rules().enabled(fixCleanup3_2_0))
+    if (!fix320Enabled)
     {
         // Sanity check
         if (accountHolds(
@@ -287,10 +357,7 @@ VaultDeposit::doApply()
 }
 
 void
-VaultDeposit::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+VaultDeposit::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/vault/VaultSet.cpp b/src/libxrpl/tx/transactors/vault/VaultSet.cpp
index f384fd3fb1..c71e84858a 100644
--- a/src/libxrpl/tx/transactors/vault/VaultSet.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultSet.cpp
@@ -15,8 +15,6 @@
 #include 
 #include 
 
-#include 
-
 namespace xrpl {
 
 bool
@@ -77,7 +75,7 @@ VaultSet::preclaim(PreclaimContext const& ctx)
     }
 
     auto const mptIssuanceID = (*vault)[sfShareMPTID];
-    auto const sleIssuance = ctx.view.read(keylet::mptIssuance(mptIssuanceID));
+    auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(mptIssuanceID));
     if (!sleIssuance)
     {
         // LCOV_EXCL_START
@@ -132,7 +130,7 @@ VaultSet::doApply()
     auto const vaultAsset = vault->at(sfAsset);
 
     auto const mptIssuanceID = (*vault)[sfShareMPTID];
-    auto const sleIssuance = view().peek(keylet::mptIssuance(mptIssuanceID));
+    auto const sleIssuance = view().peek(keylet::mptokenIssuance(mptIssuanceID));
     if (!sleIssuance)
     {
         // LCOV_EXCL_START
@@ -180,10 +178,7 @@ VaultSet::doApply()
 }
 
 void
-VaultSet::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+VaultSet::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
index 136f9e17c6..3d30005876 100644
--- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
@@ -4,12 +4,14 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -21,11 +23,22 @@
 #include 
 #include 
 
-#include 
 #include 
 
 namespace xrpl {
 
+static WaiveUnrealizedLoss
+shouldWaiveWithdrawal(ReadView const& view, AccountID const& account, SLE::const_ref issuance)
+{
+    XRPL_ASSERT(
+        issuance && issuance->getType() == ltMPTOKEN_ISSUANCE,
+        "xrpl::shouldWaiveWithdrawal : valid issuance sle");
+
+    return view.rules().enabled(fixCleanup3_2_0) && isSoleShareholder(view, account, issuance)
+        ? WaiveUnrealizedLoss::Yes
+        : WaiveUnrealizedLoss::No;
+}
+
 NotTEC
 VaultWithdraw::preflight(PreflightContext const& ctx)
 {
@@ -52,6 +65,10 @@ VaultWithdraw::preflight(PreflightContext const& ctx)
 TER
 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 vault = ctx.view.read(keylet::vault(ctx.tx[sfVaultID]));
     if (!vault)
         return tecNO_ENTRY;
@@ -65,7 +82,13 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
     auto const& vaultAccount = vault->at(sfAccount);
     auto const& account = ctx.tx[sfAccount];
     auto const& dstAcct = ctx.tx[~sfDestination].value_or(account);
-    if (auto ter = canTransfer(ctx.view, vaultAsset, vaultAccount, dstAcct); !isTesSuccess(ter))
+    // Post-fixCleanup3_2_0: withdraw is a recovery path that bypasses the
+    // lsfMPTCanTransfer flag check, so an issuer cannot trap depositor funds.
+    // Other transferability checks (IOU NoRipple, freeze, requireAuth) still
+    // apply.
+    auto const waive = fix320Enabled ? WaiveMPTCanTransfer::Yes : WaiveMPTCanTransfer::No;
+    if (auto ter = canTransfer(ctx.view, vaultAsset, vaultAccount, dstAcct, waive);
+        !isTesSuccess(ter))
     {
         JLOG(ctx.j.debug()) << "VaultWithdraw: vault assets are non-transferable.";
         return ter;
@@ -80,13 +103,13 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
         // LCOV_EXCL_STOP
     }
 
-    if (ctx.view.rules().enabled(fixCleanup3_1_3) && amount.asset() == vaultShare)
+    if (fix313Enabled && amount.asset() == vaultShare)
     {
         // Post-fixCleanup3_1_3: if the user specified shares, convert
         // to the equivalent asset amount before checking withdrawal
         // limits. Pre-amendment the limit check was skipped for
         // share-denominated withdrawals.
-        auto const sleIssuance = ctx.view.read(keylet::mptIssuance(vaultShare));
+        auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(vaultShare));
         if (!sleIssuance)
         {
             // LCOV_EXCL_START
@@ -95,9 +118,14 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
             // LCOV_EXCL_STOP
         }
 
+        // When the user is the sole shareholder they own both the available and future value.
+        // 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(ctx.view, account, sleIssuance);
         try
         {
-            auto const maybeAssets = sharesToAssetsWithdraw(vault, sleIssuance, amount);
+            auto const maybeAssets =
+                sharesToAssetsWithdraw(vault, sleIssuance, amount, waiveUnrealizedLoss);
             if (!maybeAssets)
                 return tefINTERNAL;  // LCOV_EXCL_LINE
 
@@ -135,15 +163,30 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
     if (auto const ter = requireAuth(ctx.view, vaultAsset, dstAcct, authType); !isTesSuccess(ter))
         return ter;
 
-    // Cannot withdraw from a Vault an Asset frozen for the destination account
-    if (auto const ret = checkFrozen(ctx.view, dstAcct, vaultAsset))
-        return ret;
-
-    // Cannot return shares to the vault, if the underlying asset was frozen for
-    // the submitter
-    if (auto const ret = checkFrozen(ctx.view, account, Asset{vaultShare}))
-        return ret;
+    if (fix330Enabled)
+    {
+        // checkWithdrawFreeze checks the underlying asset on the source
+        // (vault pseudo-account), the submitter, and the destination.
+        // A separate share-level freeze check is unnecessary: vault shares
+        // are issued by the vault pseudo-account, which cannot submit
+        // MPTokenIssuanceSet to individually lock a holder's MPToken.
+        // The only way shares become locked is transitively via the
+        // underlying asset, which checkWithdrawFreeze covers.
+        if (auto const ret =
+                checkWithdrawFreeze(ctx.view, vaultAccount, account, dstAcct, vaultAsset))
+            return ret;
+    }
+    else
+    {
+        // Cannot withdraw from a Vault an Asset frozen for the destination account
+        if (auto const ret = checkFrozen(ctx.view, dstAcct, vaultAsset))
+            return ret;
 
+        // Cannot return shares to the vault, if the underlying asset was frozen for
+        // the submitter
+        if (auto const ret = checkFrozen(ctx.view, account, Asset{vaultShare}))
+            return ret;
+    }
     return tesSUCCESS;
 }
 
@@ -155,7 +198,7 @@ VaultWithdraw::doApply()
         return tefINTERNAL;  // LCOV_EXCL_LINE
 
     auto const mptIssuanceID = *((*vault)[sfShareMPTID]);
-    auto const sleIssuance = view().read(keylet::mptIssuance(mptIssuanceID));
+    auto const sleIssuance = view().read(keylet::mptokenIssuance(mptIssuanceID));
     if (!sleIssuance)
     {
         // LCOV_EXCL_START
@@ -175,13 +218,19 @@ VaultWithdraw::doApply()
     MPTIssue const share{mptIssuanceID};
     STAmount sharesRedeemed = {share};
     STAmount assetsWithdrawn;
+
+    // When the user is the sole shareholder they own both the available and future value.
+    // 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);
     try
     {
         if (amount.asset() == vaultAsset)
         {
             // Fixed assets, variable shares.
             {
-                auto const maybeShares = assetsToSharesWithdraw(vault, sleIssuance, amount);
+                auto const maybeShares = assetsToSharesWithdraw(
+                    vault, sleIssuance, amount, TruncateShares::No, waiveUnrealizedLoss);
                 if (!maybeShares)
                     return tecINTERNAL;  // LCOV_EXCL_LINE
                 sharesRedeemed = *maybeShares;
@@ -189,7 +238,8 @@ VaultWithdraw::doApply()
 
             if (sharesRedeemed == beast::kZero)
                 return tecPRECISION_LOSS;
-            auto const maybeAssets = sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed);
+            auto const maybeAssets =
+                sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
             if (!maybeAssets)
                 return tecINTERNAL;  // LCOV_EXCL_LINE
             assetsWithdrawn = *maybeAssets;
@@ -198,7 +248,8 @@ VaultWithdraw::doApply()
         {
             // Fixed shares, variable assets.
             sharesRedeemed = amount;
-            auto const maybeAssets = sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed);
+            auto const maybeAssets =
+                sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
             if (!maybeAssets)
                 return tecINTERNAL;  // LCOV_EXCL_LINE
             assetsWithdrawn = *maybeAssets;
@@ -221,8 +272,14 @@ VaultWithdraw::doApply()
         return tecPATH_DRY;
     }
 
-    if (accountHolds(
-            view(), accountID_, share, FreezeHandling::ZeroIfFrozen, AuthHandling::IgnoreAuth, j_) <
+    // Post-fixCleanup3_3_0: preclaim already validated all freeze conditions
+    // (checkWithdrawFreeze), so IgnoreFreeze avoids a redundant check that
+    // would incorrectly return zero for vault pseudo-accounts whose shares
+    // are frozen via a transitively frozen underlying asset.
+    auto const freezeHandling = view().rules().enabled(fixCleanup3_3_0)
+        ? FreezeHandling::IgnoreFreeze
+        : FreezeHandling::ZeroIfFrozen;
+    if (accountHolds(view(), accountID_, share, freezeHandling, AuthHandling::IgnoreAuth, j_) <
         sharesRedeemed)
     {
         JLOG(j_.debug()) << "VaultWithdraw: account doesn't hold enough shares";
@@ -231,22 +288,64 @@ VaultWithdraw::doApply()
 
     auto assetsAvailable = vault->at(sfAssetsAvailable);
     auto assetsTotal = vault->at(sfAssetsTotal);
-    [[maybe_unused]] auto const lossUnrealized = vault->at(sfLossUnrealized);
+    auto const lossUnrealized = vault->at(sfLossUnrealized);
     XRPL_ASSERT(
         lossUnrealized <= (assetsTotal - assetsAvailable),
         "xrpl::VaultWithdraw::doApply : loss and assets do balance");
 
-    // The vault must have enough assets on hand. The vault may hold assets
-    // that it has already pledged. That is why we look at AssetAvailable
-    // instead of the pseudo-account balance.
+    // The vault must have enough assets on hand.
     if (*assetsAvailable < assetsWithdrawn)
     {
         JLOG(j_.debug()) << "VaultWithdraw: vault doesn't hold enough assets";
         return tecINSUFFICIENT_FUNDS;
     }
 
-    assetsTotal -= assetsWithdrawn;
-    assetsAvailable -= assetsWithdrawn;
+    // 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.
+    //
+    // When the rule applies, the payout is the remaining sfAssetsAvailable; in a clean vault
+    // the helper result should already equal that value, and any mismatch is a rounding artifact
+    // worth logging.
+    bool const isFinalWithdrawal =
+        sharesRedeemed == STAmount{share, sleIssuance->at(sfOutstandingAmount)};
+    if (view().rules().enabled(fixCleanup3_2_0) && isFinalWithdrawal)
+    {
+        // Unreachable: a final withdrawal with lossUnrealized > 0 has
+        // assetsWithdrawn == assetsTotal > assetsAvailable, which the
+        // insufficient-funds guard above already rejected.
+        if (*lossUnrealized != beast::kZero)
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE(
+                "xrpl::VaultWithdraw::doApply : final withdrawal with non-zero unrealized loss");
+            JLOG(j_.fatal())
+                << "VaultWithdraw: "  //
+                   "Cannot burn all outstanding shares while unrealized loss is non-zero";
+            return tefINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        STAmount const allAvailable{vaultAsset, *assetsAvailable};
+        if (assetsWithdrawn != allAvailable)
+        {
+            JLOG(j_.error())  //
+                << "VaultWithdraw: final withdrawal share-value mismatch;"
+                << " computed=" << assetsWithdrawn.getText()
+                << " assetsAvailable=" << allAvailable.getText();
+        }
+        assetsWithdrawn = allAvailable;
+
+        // Do not let dust accumulate in the Vault.
+        assetsTotal = 0;
+        assetsAvailable = 0;
+    }
+    else
+    {
+        assetsTotal -= assetsWithdrawn;
+        assetsAvailable -= assetsWithdrawn;
+    }
     view().update(vault);
 
     auto const& vaultAccount = vault->at(sfAccount);
@@ -283,19 +382,15 @@ VaultWithdraw::doApply()
         // else quietly ignore, account balance is not zero
     }
 
-    auto const dstAcct = ctx_.tx[~sfDestination].value_or(accountID_);
-
     associateAsset(*vault, vaultAsset);
 
+    auto const dstAcct = ctx_.tx[~sfDestination].value_or(accountID_);
     return doWithdraw(
         view(), ctx_.tx, accountID_, dstAcct, vaultAccount, preFeeBalance_, assetsWithdrawn, j_);
 }
 
 void
-VaultWithdraw::visitInvariantEntry(
-    bool,
-    std::shared_ptr const&,
-    std::shared_ptr const&)
+VaultWithdraw::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
 {
     // No transaction-specific invariants yet (future work).
 }
diff --git a/src/test/app/AMMClawback_test.cpp b/src/test/app/AMMClawback_test.cpp
index 9683e8ac17..ba416d8192 100644
--- a/src/test/app/AMMClawback_test.cpp
+++ b/src/test/app/AMMClawback_test.cpp
@@ -2486,8 +2486,17 @@ class AMMClawback_test : public beast::unit_test::Suite
             else if (!features[fixAMMClawbackRounding])
             {
                 // sqrt(amount * amount2) >= LPTokens and exceeds the allowed
-                // tolerance
-                env(amm::ammClawback(gw, alice, usd, eur, usd(1)), Ter(tecINVARIANT_FAILED));
+                // tolerance.
+                // With fixCleanup3_3_0 this is caught in the transaction layer;
+                // without it the invariant checker fires instead.
+                if (features[fixCleanup3_3_0])
+                {
+                    env(amm::ammClawback(gw, alice, usd, eur, usd(1)), Ter(tecPRECISION_LOSS));
+                }
+                else
+                {
+                    env(amm::ammClawback(gw, alice, usd, eur, usd(1)), Ter(tecINVARIANT_FAILED));
+                }
                 BEAST_EXPECT(amm.ammExists());
             }
             else if (features[fixAMMv1_3] && features[fixAMMClawbackRounding])
@@ -2514,6 +2523,11 @@ class AMMClawback_test : public beast::unit_test::Suite
         testFeatureDisabled(all - featureAMMClawback);
         for (auto const& features :
              {all - fixAMMv1_3 - fixAMMClawbackRounding - featureMPTokensV2,
+              // fixAMMv1_3 on, fixAMMClawbackRounding off, fixCleanup3_3_0 off:
+              // precision loss caught by invariant checker -> tecINVARIANT_FAILED
+              all - fixAMMClawbackRounding - fixCleanup3_3_0 - featureMPTokensV2,
+              // fixAMMv1_3 on, fixAMMClawbackRounding off, fixCleanup3_3_0 on:
+              // precision loss caught in transaction layer -> tecPRECISION_LOSS
               all - fixAMMClawbackRounding - featureMPTokensV2,
               all - featureMPTokensV2,
               all})
diff --git a/src/test/app/AMMExtendedMPT_test.cpp b/src/test/app/AMMExtendedMPT_test.cpp
index cf1210901f..e6dcc95733 100644
--- a/src/test/app/AMMExtendedMPT_test.cpp
+++ b/src/test/app/AMMExtendedMPT_test.cpp
@@ -3114,10 +3114,8 @@ private:
         btc.set({.holder = bob, .flags = tfMPTLock});
 
         {
-            // different from IOU. The offer is created but not crossed.
-            env(offer(bob, btc(5), XRP(25)));
+            env(offer(bob, btc(5), XRP(25)), Ter(tecLOCKED));
             env.close();
-            BEAST_EXPECT(expectOffers(env, bob, 1, {{{btc(5), XRP(25)}}}));
             BEAST_EXPECT(ammAlice.expectBalances(XRP(500), btc(105), ammAlice.tokens()));
         }
 
@@ -3220,7 +3218,7 @@ private:
             btc.set({.flags = tfMPTLock});
 
             // assets can't be bought on the market
-            AMM const ammA3(env, a3, btc(1), XRP(1), Ter(tecFROZEN));
+            AMM const ammA3(env, a3, btc(1), XRP(1), Ter(tecLOCKED));
 
             // direct issues can be sent
             env(pay(g1, a2, btc(1)));
diff --git a/src/test/app/AMMExtended_test.cpp b/src/test/app/AMMExtended_test.cpp
index 18f2d6df2f..a0a7d0fb15 100644
--- a/src/test/app/AMMExtended_test.cpp
+++ b/src/test/app/AMMExtended_test.cpp
@@ -65,10 +65,15 @@ namespace xrpl::test {
 /**
  * Tests of AMM that use offers too.
  */
-struct AMMExtended_test : public jtx::AMMTest
+class AMMExtended_test : public jtx::AMMTest
 {
     // Use small Number mantissas for the life of this test.
-    NumberMantissaScaleGuard const sg{xrpl::MantissaRange::MantissaScale::Small};
+    NumberMantissaScaleGuard const sg_{xrpl::MantissaRange::MantissaScale::Small};
+
+    // For now, just disable SAV entirely, which locks in the small Number
+    // mantissas
+    FeatureBitset const all_{
+        testableAmendments() - featureSingleAssetVault - featureLendingProtocol};
 
 private:
     void
@@ -1349,37 +1354,33 @@ private:
     testOffers()
     {
         using namespace jtx;
-        // For now, just disable SAV entirely, which locks in the small Number
-        // mantissas
-        FeatureBitset const all{
-            testableAmendments() - featureSingleAssetVault - featureLendingProtocol};
 
-        testRmFundedOffer(all);
-        testRmFundedOffer(all - fixAMMv1_1 - fixAMMv1_3);
-        testEnforceNoRipple(all);
-        testFillModes(all);
-        testOfferCrossWithXRP(all);
-        testOfferCrossWithLimitOverride(all);
-        testCurrencyConversionEntire(all);
-        testCurrencyConversionInParts(all);
-        testCrossCurrencyStartXRP(all);
-        testCrossCurrencyEndXRP(all);
-        testCrossCurrencyBridged(all);
-        testOfferFeesConsumeFunds(all);
-        testOfferCreateThenCross(all);
-        testSellFlagExceedLimit(all);
-        testGatewayCrossCurrency(all);
-        testGatewayCrossCurrency(all - fixAMMv1_1 - fixAMMv1_3);
-        testBridgedCross(all);
-        testSellWithFillOrKill(all);
-        testTransferRateOffer(all);
-        testSelfIssueOffer(all);
-        testBadPathAssert(all);
-        testSellFlagBasic(all);
-        testDirectToDirectPath(all);
-        testDirectToDirectPath(all - fixAMMv1_1 - fixAMMv1_3);
-        testRequireAuth(all);
-        testMissingAuth(all);
+        testRmFundedOffer(all_);
+        testRmFundedOffer(all_ - fixAMMv1_1 - fixAMMv1_3);
+        testEnforceNoRipple(all_);
+        testFillModes(all_);
+        testOfferCrossWithXRP(all_);
+        testOfferCrossWithLimitOverride(all_);
+        testCurrencyConversionEntire(all_);
+        testCurrencyConversionInParts(all_);
+        testCrossCurrencyStartXRP(all_);
+        testCrossCurrencyEndXRP(all_);
+        testCrossCurrencyBridged(all_);
+        testOfferFeesConsumeFunds(all_);
+        testOfferCreateThenCross(all_);
+        testSellFlagExceedLimit(all_);
+        testGatewayCrossCurrency(all_);
+        testGatewayCrossCurrency(all_ - fixAMMv1_1 - fixAMMv1_3);
+        testBridgedCross(all_);
+        testSellWithFillOrKill(all_);
+        testTransferRateOffer(all_);
+        testSelfIssueOffer(all_);
+        testBadPathAssert(all_);
+        testSellFlagBasic(all_);
+        testDirectToDirectPath(all_);
+        testDirectToDirectPath(all_ - fixAMMv1_1 - fixAMMv1_3);
+        testRequireAuth(all_);
+        testMissingAuth(all_);
     }
 
     void
@@ -3516,15 +3517,11 @@ private:
     testFlow()
     {
         using namespace jtx;
-        // For now, just disable SAV entirely, which locks in the small Number
-        // mantissas in the transaction engine
-        FeatureBitset const all{
-            testableAmendments() - featureSingleAssetVault - featureLendingProtocol};
 
-        testFalseDry(all);
-        testBookStep(all);
-        testTransferRateNoOwnerFee(all);
-        testTransferRateNoOwnerFee(all - fixAMMv1_1 - fixAMMv1_3);
+        testFalseDry(all_);
+        testBookStep(all_);
+        testTransferRateNoOwnerFee(all_);
+        testTransferRateNoOwnerFee(all_ - fixAMMv1_1 - fixAMMv1_3);
         testLimitQuality();
         testXRPPathLoop();
     }
@@ -3533,34 +3530,22 @@ private:
     testCrossingLimits()
     {
         using namespace jtx;
-        // For now, just disable SAV entirely, which locks in the small Number
-        // mantissas in the transaction engine
-        FeatureBitset const all{
-            testableAmendments() - featureSingleAssetVault - featureLendingProtocol};
-        testStepLimit(all);
-        testStepLimit(all - fixAMMv1_1 - fixAMMv1_3);
+        testStepLimit(all_);
+        testStepLimit(all_ - fixAMMv1_1 - fixAMMv1_3);
     }
 
     void
     testDeliverMin()
     {
         using namespace jtx;
-        // For now, just disable SAV entirely, which locks in the small Number
-        // mantissas in the transaction engine
-        FeatureBitset const all{
-            testableAmendments() - featureSingleAssetVault - featureLendingProtocol};
-        testConvertAllOfAnAsset(all);
-        testConvertAllOfAnAsset(all - fixAMMv1_1 - fixAMMv1_3);
+        testConvertAllOfAnAsset(all_);
+        testConvertAllOfAnAsset(all_ - fixAMMv1_1 - fixAMMv1_3);
     }
 
     void
     testDepositAuth()
     {
-        // For now, just disable SAV entirely, which locks in the small Number
-        // mantissas in the transaction engine
-        FeatureBitset const all{
-            jtx::testableAmendments() - featureSingleAssetVault - featureLendingProtocol};
-        testPayment(all);
+        testPayment(all_);
         testPayIOU();
     }
 
@@ -3568,13 +3553,9 @@ private:
     testFreeze()
     {
         using namespace test::jtx;
-        // For now, just disable SAV entirely, which locks in the small Number
-        // mantissas in the transaction engine
-        FeatureBitset const sa{
-            testableAmendments() - featureSingleAssetVault - featureLendingProtocol};
-        testRippleState(sa);
-        testGlobalFreeze(sa);
-        testOffersWhenFrozen(sa);
+        testRippleState(all_);
+        testGlobalFreeze(all_);
+        testOffersWhenFrozen(all_);
     }
 
     void
diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp
index eba388e5fd..9ff6c65c17 100644
--- a/src/test/app/AMMMPT_test.cpp
+++ b/src/test/app/AMMMPT_test.cpp
@@ -18,10 +18,12 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -46,6 +48,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -144,7 +147,7 @@ private:
                  .pay = 30'000,
                  .flags = tfMPTCanLock | kMptDexFlags});
             usd.set({.flags = tfMPTLock});
-            AMM const ammAliceFail(env, alice_, XRP(10'000), usd(10'000), Ter(tecFROZEN));
+            AMM const ammAliceFail(env, alice_, XRP(10'000), usd(10'000), Ter(tecLOCKED));
             usd.set({.flags = tfMPTUnlock});
             AMM const ammAlice(env, alice_, XRP(10'000), usd(10'000));
         }
@@ -348,7 +351,7 @@ private:
                  .pay = 30'000,
                  .flags = tfMPTCanLock | kMptDexFlags});
             btc.set({.flags = tfMPTLock});
-            AMM const ammAlice(env, alice_, USD(10'000), btc(10'000), Ter(tecFROZEN));
+            AMM const ammAlice(env, alice_, USD(10'000), btc(10'000), Ter(tecLOCKED));
             BEAST_EXPECT(!ammAlice.ammExists());
         }
 
@@ -365,7 +368,7 @@ private:
             btc.set({.holder = alice_, .flags = tfMPTLock});
 
             // alice's token is locked
-            AMM const ammAlice(env, alice_, USD(10'000), btc(10'000), Ter(tecFROZEN));
+            AMM const ammAlice(env, alice_, USD(10'000), btc(10'000), Ter(tecLOCKED));
             BEAST_EXPECT(!ammAlice.ammExists());
 
             // bob can create
@@ -693,15 +696,15 @@ private:
             btc.set({.flags = tfMPTLock});
 
             ammAlice.deposit(
-                carol_, btc(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecFROZEN));
+                carol_, btc(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecLOCKED));
 
             ammAlice.deposit(
-                carol_, USD(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecFROZEN));
+                carol_, USD(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecLOCKED));
 
-            ammAlice.deposit(carol_, 1'000, std::nullopt, std::nullopt, Ter(tecFROZEN));
+            ammAlice.deposit(carol_, 1'000, std::nullopt, std::nullopt, Ter(tecLOCKED));
 
             ammAlice.deposit(
-                carol_, USD(100), btc(100), std::nullopt, std::nullopt, Ter(tecFROZEN));
+                carol_, USD(100), btc(100), std::nullopt, std::nullopt, Ter(tecLOCKED));
         }
 
         // Individually lock MPT or freeze IOU (AMM) with IOU/MPT AMM
@@ -722,20 +725,20 @@ private:
 
             // Carol can not deposit locked mpt
             ammAlice.deposit(
-                carol_, btc(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecFROZEN));
+                carol_, btc(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecLOCKED));
 
-            ammAlice.deposit(carol_, 1'000, std::nullopt, std::nullopt, Ter(tecFROZEN));
+            ammAlice.deposit(carol_, 1'000, std::nullopt, std::nullopt, Ter(tecLOCKED));
 
-            if (!features[featureAMMClawback])
+            // Post-fixCleanup3_3_0 a locked holder cannot deposit the other
+            // (non-locked) token either, matching featureAMMClawback.
+            if (!features[featureAMMClawback] && !features[fixCleanup3_3_0])
             {
-                ammAlice.deposit(
-                    carol_, USD(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecLOCKED));
+                ammAlice.deposit(carol_, USD(100), std::nullopt, std::nullopt, std::nullopt);
             }
             else
             {
-                // Carol can not deposit non-forzen token either
                 ammAlice.deposit(
-                    carol_, USD(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecFROZEN));
+                    carol_, USD(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecLOCKED));
             }
 
             // Alice can deposit because she's not individually locked
@@ -754,8 +757,15 @@ private:
                 gw_, STAmount{Issue{gw_["USD"].currency, ammAlice.ammAccount()}, 0}, tfSetFreeze));
             env.close();
 
-            // Can deposit non-frozen token
-            ammAlice.deposit(carol_, btc(100), std::nullopt, std::nullopt, std::nullopt);
+            // Post-fixCleanup3_3_0 the deposit checks both pool assets, so the
+            // non-frozen token cannot be deposited while the AMM's USD is frozen.
+            ammAlice.deposit(
+                carol_,
+                btc(100),
+                std::nullopt,
+                std::nullopt,
+                std::nullopt,
+                features[fixCleanup3_3_0] ? Ter(tecFROZEN) : Ter(tesSUCCESS));
 
             // Cannot deposit frozen token
             ammAlice.deposit(carol_, 1'000'000, std::nullopt, std::nullopt, Ter(tecFROZEN));
@@ -774,13 +784,20 @@ private:
             // Individually lock AMM
             btc.set({.holder = ammAlice.ammAccount(), .flags = tfMPTLock});
 
-            // Can deposit non-frozen token
-            ammAlice.deposit(carol_, USD(100), std::nullopt, std::nullopt, std::nullopt);
+            // Post-fixCleanup3_3_0 the non-locked token cannot be deposited
+            // while the AMM's BTC is locked.
+            ammAlice.deposit(
+                carol_,
+                USD(100),
+                std::nullopt,
+                std::nullopt,
+                std::nullopt,
+                features[fixCleanup3_3_0] ? Ter(tecLOCKED) : Ter(tesSUCCESS));
 
             // Can not deposit locked token
-            ammAlice.deposit(carol_, 1'000, std::nullopt, std::nullopt, Ter(tecFROZEN));
+            ammAlice.deposit(carol_, 1'000, std::nullopt, std::nullopt, Ter(tecLOCKED));
             ammAlice.deposit(
-                carol_, btc(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecFROZEN));
+                carol_, btc(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecLOCKED));
 
             // Unlock AMM MPT
             btc.set({.holder = ammAlice.ammAccount(), .flags = tfMPTUnlock});
@@ -789,7 +806,9 @@ private:
             ammAlice.deposit(carol_, btc(100), std::nullopt, std::nullopt, std::nullopt);
         }
 
-        // Individually lock MPT (AMM) account with MPT/MPT AMM
+        // Individually lock MPT (AMM) account with MPT/MPT AMM.
+        // This block always runs with all amendments (incl. fixCleanup3_3_0),
+        // so the deposit checks both pool assets unconditionally.
         {
             Env env{*this};
             env.fund(XRP(10'000), gw_, alice_, carol_);
@@ -812,10 +831,10 @@ private:
             // Carol's BTC is locked
             btc.set({.holder = carol_, .flags = tfMPTLock});
             ammAlice.deposit(
-                carol_, usd(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecFROZEN));
+                carol_, usd(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecLOCKED));
 
             ammAlice.deposit(
-                carol_, btc(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecFROZEN));
+                carol_, btc(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecLOCKED));
 
             // Unlock carol's BTC
             btc.set({.holder = carol_, .flags = tfMPTUnlock});
@@ -827,13 +846,15 @@ private:
             // Individually lock MPT BTC (AMM) account
             btc.set({.holder = ammAlice.ammAccount(), .flags = tfMPTLock});
 
-            // Can deposit non-locked token USD
-            ammAlice.deposit(carol_, usd(100), std::nullopt, std::nullopt, std::nullopt);
+            // Post-fixCleanup3_3_0 the non-locked token USD cannot be deposited
+            // while the AMM's BTC is locked.
+            ammAlice.deposit(
+                carol_, usd(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecLOCKED));
 
             // Can not deposit locked token BTC
-            ammAlice.deposit(carol_, 1'000, std::nullopt, std::nullopt, Ter(tecFROZEN));
+            ammAlice.deposit(carol_, 1'000, std::nullopt, std::nullopt, Ter(tecLOCKED));
             ammAlice.deposit(
-                carol_, btc(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecFROZEN));
+                carol_, btc(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecLOCKED));
 
             // Unlock AMM MPT BTC
             btc.set({.holder = ammAlice.ammAccount(), .flags = tfMPTUnlock});
@@ -844,13 +865,15 @@ private:
             // Individually Lock MPT USD (AMM) account
             usd.set({.holder = ammAlice.ammAccount(), .flags = tfMPTLock});
 
-            // Can deposit non-locked token BTC
-            ammAlice.deposit(carol_, btc(100), std::nullopt, std::nullopt, std::nullopt);
+            // Post-fixCleanup3_3_0 the non-locked token BTC cannot be deposited
+            // while the AMM's USD is locked.
+            ammAlice.deposit(
+                carol_, btc(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecLOCKED));
 
             // Can not deposit locked token USD
-            ammAlice.deposit(carol_, 1'000, std::nullopt, std::nullopt, Ter(tecFROZEN));
+            ammAlice.deposit(carol_, 1'000, std::nullopt, std::nullopt, Ter(tecLOCKED));
             ammAlice.deposit(
-                carol_, usd(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecFROZEN));
+                carol_, usd(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecLOCKED));
 
             // Unlock AMM MPT USD
             usd.set({.holder = ammAlice.ammAccount(), .flags = tfMPTUnlock});
@@ -877,7 +900,9 @@ private:
             AMM amm(env, alice, XRP(10'000), btc(10'000));
             env.close();
 
-            if (!features[featureAMMClawback])
+            // Post-fixCleanup3_3_0 the deposit requires authorization for both
+            // pool assets, so the unauthorized MPT blocks the XRP deposit too.
+            if (!features[featureAMMClawback] && !features[fixCleanup3_3_0])
             {
                 amm.deposit(carol, XRP(10), std::nullopt, std::nullopt, std::nullopt);
             }
@@ -904,7 +929,7 @@ private:
 
             AMM amm(env, gw, XRP(10'000), btc(10'000));
 
-            amm.deposit({.account = alice, .asset1In = btc(10), .err = Ter(tecNO_PERMISSION)});
+            amm.deposit({.account = alice, .asset1In = btc(10), .err = Ter(tecNO_AUTH)});
         }
 
         // Insufficient XRP balance
@@ -2241,28 +2266,31 @@ private:
                     .err = Ter(tecNO_AUTH)});
         }
 
-        // MPTCanTransfer is not set and the account is not the issuer of MPT
+        // MPTCanTransfer is not set and the account is not the issuer of MPT.
+        // The issuer can create the AMM, and an existing LP token holder can
+        // still withdraw.
         {
             Env env{*this};
             env.fund(XRP(30'000), gw_, alice_);
             env.close();
-            auto btcm = MPTTester(
+            auto btc = MPTTester(
                 {.env = env,
                  .issuer = gw_,
                  .holders = {alice_},
                  .pay = 30'000,
                  .flags = tfMPTCanTrade,
                  .authHolder = true});
-            MPT const btc = btcm;
 
             AMM amm(env, gw_, XRP(10'000), btc(10'000));
 
+            auto const lpIssue = amm.lptIssue();
+            env.trust(STAmount{lpIssue, 20'000'000}, alice_);
+            env.close();
+            env(pay(gw_, alice_, LPToken(1'000'000).tokens(lpIssue)));
+            env.close();
+
             amm.withdraw(
-                WithdrawArg{
-                    .account = alice_,
-                    .asset1Out = btc(100),
-                    .assets = {{XRP, btc}},
-                    .err = Ter(tecNO_PERMISSION)});
+                WithdrawArg{.account = alice_, .asset1Out = btc(100), .assets = {{XRP, btc}}});
         }
 
         // Globally locked MPT
@@ -2283,8 +2311,8 @@ private:
             btc.set({.flags = tfMPTLock});
 
             ammAlice.withdraw(
-                alice_, MPT(ammAlice[1])(100), std::nullopt, std::nullopt, Ter(tecFROZEN));
-            ammAlice.withdraw(alice_, 1'000, std::nullopt, std::nullopt, Ter(tecFROZEN));
+                alice_, MPT(ammAlice[1])(100), std::nullopt, std::nullopt, Ter(tecLOCKED));
+            ammAlice.withdraw(alice_, 1'000, std::nullopt, std::nullopt, Ter(tecLOCKED));
 
             // can single withdraw the other asset
             ammAlice.withdraw({.account = alice_, .asset1Out = XRP(100)});
@@ -2312,8 +2340,8 @@ private:
 
             // Alice's BTC is locked
             btc.set({.holder = alice_, .flags = tfMPTLock});
-            ammAlice.withdraw(alice_, 1000, std::nullopt, std::nullopt, Ter(tecFROZEN));
-            ammAlice.withdraw(alice_, btc(100), std::nullopt, std::nullopt, Ter(tecFROZEN));
+            ammAlice.withdraw(alice_, 1000, std::nullopt, std::nullopt, Ter(tecLOCKED));
+            ammAlice.withdraw(alice_, btc(100), std::nullopt, std::nullopt, Ter(tecLOCKED));
 
             // can withdraw the other asset
             ammAlice.withdraw(alice_, usd(100), std::nullopt, std::nullopt);
@@ -2341,8 +2369,8 @@ private:
             // Alice's BTC is locked
             btc.set({.holder = alice_, .flags = tfMPTLock});
 
-            ammAlice.withdraw(alice_, 1'000, std::nullopt, std::nullopt, Ter(tecFROZEN));
-            ammAlice.withdraw(alice_, btc(100), std::nullopt, std::nullopt, Ter(tecFROZEN));
+            ammAlice.withdraw(alice_, 1'000, std::nullopt, std::nullopt, Ter(tecLOCKED));
+            ammAlice.withdraw(alice_, btc(100), std::nullopt, std::nullopt, Ter(tecLOCKED));
             // can still single withdraw the unlocked other asset
             ammAlice.withdraw(alice_, USD(100), std::nullopt, std::nullopt);
 
@@ -2361,8 +2389,8 @@ private:
             ammAlice.withdraw(alice_, USD(100), std::nullopt, std::nullopt);
 
             // Can not withdraw locked token BTC
-            ammAlice.withdraw(alice_, 1'000, std::nullopt, std::nullopt, Ter(tecFROZEN));
-            ammAlice.withdraw(alice_, btc(100), std::nullopt, std::nullopt, Ter(tecFROZEN));
+            ammAlice.withdraw(alice_, 1'000, std::nullopt, std::nullopt, Ter(tecLOCKED));
+            ammAlice.withdraw(alice_, btc(100), std::nullopt, std::nullopt, Ter(tecLOCKED));
 
             // Unlock AMM MPT
             btc.set({.holder = ammAlice.ammAccount(), .flags = tfMPTUnlock});
@@ -6299,9 +6327,9 @@ private:
 
         struct MPTList
         {
-            MPTTester const USD;
-            MPTTester const ETH;
-            MPTTester const CAN;
+            MPTTester const usd;
+            MPTTester const eth;
+            MPTTester const can;
         };
 
         auto prep = [&](Env& env, uint16_t gwTransferFee, uint16_t gw1TransferFee) -> MPTList {
@@ -6330,9 +6358,9 @@ private:
             env.close();
 
             return MPTList{
-                .USD = std::move(usd),
-                .ETH = std::move(eth),
-                .CAN = std::move(can),
+                .usd = std::move(usd),
+                .eth = std::move(eth),
+                .can = std::move(can),
             };
         };
 
@@ -6357,9 +6385,9 @@ private:
                 {
                     Env env(*this, features);
                     auto mpts = prep(env, rates.first, rates.second);
-                    auto usd = mpts.USD;
-                    auto eth = mpts.ETH;
-                    auto can = mpts.CAN;
+                    auto usd = mpts.usd;
+                    auto eth = mpts.eth;
+                    auto can = mpts.can;
                     std::optional amm;
 
                     if (i == 0 || i == 2)
@@ -6402,9 +6430,9 @@ private:
             {
                 Env env(*this, features);
                 auto mpts = prep(env, rates.first, rates.second);
-                auto usd = mpts.USD;
-                auto eth = mpts.ETH;
-                auto can = mpts.CAN;
+                auto usd = mpts.usd;
+                auto eth = mpts.eth;
+                auto can = mpts.can;
                 std::optional amm;
                 if (i == 0 || i == 2)
                 {
@@ -6447,9 +6475,9 @@ private:
                 {
                     Env env(*this, features);
                     auto mpts = prep(env, rates.first, rates.second);
-                    auto usd = mpts.USD;
-                    auto eth = mpts.ETH;
-                    auto can = mpts.CAN;
+                    auto usd = mpts.usd;
+                    auto eth = mpts.eth;
+                    auto can = mpts.can;
                     std::optional amm;
                     if (i == 0 || i == 2)
                     {
@@ -6513,9 +6541,9 @@ private:
             {
                 Env env(*this, features);
                 auto mpts = prep(env, rates.first, rates.second);
-                auto usd = mpts.USD;
-                auto eth = mpts.ETH;
-                auto can = mpts.CAN;
+                auto usd = mpts.usd;
+                auto eth = mpts.eth;
+                auto can = mpts.can;
                 std::optional amm;
                 if (i == 0 || i == 2)
                 {
@@ -6591,9 +6619,9 @@ private:
                 {
                     Env env(*this, features);
                     auto mpts = prep(env, rates.first, rates.second);
-                    auto usd = mpts.USD;
-                    auto eth = mpts.ETH;
-                    auto can = mpts.CAN;
+                    auto usd = mpts.usd;
+                    auto eth = mpts.eth;
+                    auto can = mpts.can;
                     std::optional amm;
 
                     if (i == 0 || i == 2)
@@ -6861,6 +6889,173 @@ private:
         BEAST_EXPECT(!amm.ammExists());
     }
 
+    void
+    testAMMWithVaultShares()
+    {
+        testcase("AMM with vault shares — underlying freeze blocks share withdrawal");
+        using namespace jtx;
+        // AMMTestBase::testableAmendments() strips featureSingleAssetVault,
+        // but vault shares require it. Use the global jtx set directly.
+        FeatureBitset const all{jtx::testableAmendments()};
+
+        // When alice's underlying asset is individually frozen:
+        //
+        // Deposit (post-fixCleanup3_3_0): checkDepositFreeze checks the AMM
+        //   pseudo-account's underlying, not alice's — deposit is allowed.
+        //   Pre-fix: featureAMMClawback calls isFrozen(alice, share) which
+        //   descends via isVaultPseudoAccountFrozen(alice,...) and finds the
+        //   frozen underlying — deposit is blocked.
+        //
+        // Withdrawal (post-fixCleanup3_3_0): checkWithdrawFreeze ends with
+        //   checkDeepFrozen(alice, share) which calls isFrozen(alice, share)
+        //   and finds the frozen underlying — withdrawal is blocked.
+        //   Pre-fix: the old path only checks the AMM account's MPToken lock,
+        //   which is unset — withdrawal succeeds.
+
+        auto runIOU = [&](FeatureBitset const& features) {
+            bool const fix330 = features[fixCleanup3_3_0];
+            Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
+
+            env.fund(XRP(100'000), gw_, alice_);
+            env(fset(gw_, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const iou = gw_["IOU"];
+            env.trust(iou(1'000'000), alice_);
+            env(pay(gw_, alice_, iou(10'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto [createTx, vaultKeylet] = vault.create({.owner = alice_, .asset = iou});
+            env(createTx);
+            env.close();
+
+            // 200 IOU → 200,000,000 vault shares (IOU vault scale = 6)
+            env(vault.deposit({.depositor = alice_, .id = vaultKeylet.key, .amount = iou(200)}));
+            env.close();
+
+            auto const shareMPTID = env.le(vaultKeylet)->at(sfShareMPTID);
+            // Use half the shares for the AMM; alice keeps the other half.
+            STAmount const shareAmt{MPTIssue{shareMPTID}, 100'000'000};
+            // Pool: XRP(100) = 1e8 drops, shares = 1e8 → LP ≈ 1e8
+            AMM amm{env, alice_, XRP(100), shareAmt};
+            env.close();
+
+            // Freeze alice's IOU trustline (individual freeze on underlying).
+            env(trust(gw_, iou(0), alice_, tfSetFreeze));
+            env.close();
+
+            // post-fix330: checkDepositFreeze checks AMM pseudo's underlying
+            //              (not alice's) → deposit is allowed
+            // pre-fix330:  featureAMMClawback path calls isFrozen(alice, share)
+            //              which descends to alice's frozen IOU → tecLOCKED
+            amm.deposit(
+                {.account = alice_,
+                 .asset1In = XRP(1),
+                 .err = Ter(fix330 ? TER(tesSUCCESS) : TER(tecLOCKED))});
+
+            // post-fix330: checkWithdrawFreeze → checkDeepFrozen(alice, share)
+            //              descends to alice's frozen IOU → tecLOCKED
+            // pre-fix330:  the AMM pseudo-account is not authorized for the
+            //              share's underlying (requireAuth recurses share→IOU
+            //              and the AMM holds no IOU trustline), so
+            //              accountHolds(ZeroIfUnauthorized) reports the pool's
+            //              share balance as 0 and the withdrawal math fails →
+            //              tecAMM_FAILED.  Vault shares deposited into an AMM are
+            //              only withdrawable once fixCleanup3_3_0 exempts the
+            //              pseudo-account from the recursive auth check.
+            amm.withdraw(
+                {.account = alice_,
+                 .tokens = 1'000,
+                 .err = Ter(fix330 ? TER(tecLOCKED) : TER(tecAMM_FAILED))});
+
+            env(trust(gw_, iou(0), alice_, tfClearFreeze));
+            env.close();
+
+            // Lifting the freeze lets the deposit through in both cases.  The
+            // withdrawal only succeeds post-fix330; pre-fix330 the share balance
+            // remains inaccessible to the unauthorized pseudo-account, so the
+            // shares stay stuck → tecAMM_FAILED.
+            amm.deposit({.account = alice_, .asset1In = XRP(1)});
+            amm.withdraw(
+                {.account = alice_,
+                 .tokens = 1'000,
+                 .err = Ter(fix330 ? TER(tesSUCCESS) : TER(tecAMM_FAILED))});
+        };
+
+        runIOU(all);
+        runIOU(all - fixCleanup3_3_0);
+
+        auto runMPT = [&](FeatureBitset const& features) {
+            bool const fix330 = features[fixCleanup3_3_0];
+            // Expected freeze failures fire invariant checks that log at Error;
+            // silence them so the test output stays clean.
+            Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
+
+            env.fund(XRP(100'000), gw_, alice_);
+            env.close();
+
+            MPTTester mptt{env, gw_, kMptInitNoFund};
+            mptt.create({.flags = kMptDexFlags | tfMPTCanLock});
+            PrettyAsset const mpt = mptt.issuanceID();
+            mptt.authorize({.account = alice_});
+            env(pay(gw_, alice_, mpt(30'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto [createTx, vaultKeylet] = vault.create({.owner = alice_, .asset = mpt});
+            env(createTx);
+            env.close();
+
+            // 20000 MPT → 20000 vault shares (MPT vault scale = 0)
+            env(vault.deposit({.depositor = alice_, .id = vaultKeylet.key, .amount = mpt(20'000)}));
+            env.close();
+
+            auto const shareMPTID = env.le(vaultKeylet)->at(sfShareMPTID);
+            // Use half the shares for the AMM; alice keeps the other half.
+            // Pool: XRP(100) = 1e8 drops, shares = 10000 → LP ≈ 1e6
+            STAmount const shareAmt{MPTIssue{shareMPTID}, 10'000};
+            AMM amm{env, alice_, XRP(100), shareAmt};
+            env.close();
+
+            // Lock alice's underlying MPT (individual lock).
+            mptt.set({.holder = alice_, .flags = tfMPTLock});
+
+            // Same pre/post-fix330 semantics as the IOU case above.
+            amm.deposit(
+                {.account = alice_,
+                 .asset1In = XRP(1),
+                 .err = Ter(fix330 ? TER(tesSUCCESS) : TER(tecLOCKED))});
+
+            // {.tokens = 1'000} → frac = 1000/1e6 = 0.001
+            // XRP out = 1e8 * 0.001 = 1e5 drops, shares out = 10000 * 0.001 = 10
+            // post-fix330: checkWithdrawFreeze sees alice's locked underlying
+            //              MPT via the share → tecLOCKED.
+            // pre-fix330:  the AMM pseudo-account is unauthorized for the
+            //              share's underlying MPT (it holds no underlying
+            //              MPToken), so accountHolds(ZeroIfUnauthorized) zeros
+            //              the pool's share balance and the math fails →
+            //              tecAMM_FAILED.
+            amm.withdraw(
+                {.account = alice_,
+                 .tokens = 1'000,
+                 .err = Ter(fix330 ? TER(tecLOCKED) : TER(tecAMM_FAILED))});
+
+            mptt.set({.holder = alice_, .flags = tfMPTUnlock});
+
+            // Unlocking lets the deposit through; the withdrawal only succeeds
+            // post-fix330 (pre-fix330 the shares remain stuck → tecAMM_FAILED).
+            amm.deposit({.account = alice_, .asset1In = XRP(1)});
+            amm.withdraw(
+                {.account = alice_,
+                 .tokens = 1'000,
+                 .err = Ter(fix330 ? TER(tesSUCCESS) : TER(tecAMM_FAILED))});
+        };
+
+        runMPT(all);
+        runMPT(all - fixCleanup3_3_0);
+    }
+
     void
     testAMMDepositWithFrozenAssets()
     {
@@ -6886,33 +7081,33 @@ private:
             cb(amm, btc);
         };
 
-        // Deposit two assets, one of which is frozen,
-        // then we should get tecFROZEN error.
+        // Deposit two assets, one of which is locked,
+        // then we should get tecLOCKED error.
         {
             Env env(*this);
             testAMMDeposit(env, [&](AMM& amm, MPTTester& btc) {
-                amm.deposit(alice_, btc(100), XRP(100), std::nullopt, tfTwoAsset, Ter(tecFROZEN));
+                amm.deposit(alice_, btc(100), XRP(100), std::nullopt, tfTwoAsset, Ter(tecLOCKED));
             });
         }
 
-        // Deposit one asset, which is the frozen token,
-        // then we should get tecFROZEN error.
+        // Deposit one asset, which is the locked token,
+        // then we should get tecLOCKED error.
         {
             Env env(*this);
             testAMMDeposit(env, [&](AMM& amm, MPTTester& btc) {
                 amm.deposit(
-                    alice_, btc(100), std::nullopt, std::nullopt, tfSingleAsset, Ter(tecFROZEN));
+                    alice_, btc(100), std::nullopt, std::nullopt, tfSingleAsset, Ter(tecLOCKED));
             });
         }
 
         // Deposit one asset which is not the frozen token,
-        // but the other asset is frozen. We should get tecFROZEN error
+        // but the other asset is frozen. We should get tecLOCKED error
         // when feature AMMClawback is enabled.
         {
             Env env(*this);
             testAMMDeposit(env, [&](AMM& amm, MPTTester& btc) {
                 amm.deposit(
-                    alice_, XRP(100), std::nullopt, std::nullopt, tfSingleAsset, Ter(tecFROZEN));
+                    alice_, XRP(100), std::nullopt, std::nullopt, tfSingleAsset, Ter(tecLOCKED));
             });
         }
     }
@@ -6929,7 +7124,7 @@ private:
             Env env(
                 *this,
                 envconfig([](std::unique_ptr cfg) {
-                    cfg->FEES.reference_fee = XRPAmount(1);
+                    cfg->fees.referenceFee = XRPAmount(1);
                     return cfg;
                 }),
                 all);
@@ -6983,7 +7178,7 @@ private:
             Env env(
                 *this,
                 envconfig([](std::unique_ptr cfg) {
-                    cfg->FEES.reference_fee = XRPAmount(1);
+                    cfg->fees.referenceFee = XRPAmount(1);
                     return cfg;
                 }),
                 all);
@@ -7018,7 +7213,7 @@ private:
             Env env(
                 *this,
                 envconfig([](std::unique_ptr cfg) {
-                    cfg->FEES.reference_fee = XRPAmount(1);
+                    cfg->fees.referenceFee = XRPAmount(1);
                     return cfg;
                 }),
                 all);
@@ -7042,7 +7237,7 @@ private:
             Env env(
                 *this,
                 envconfig([](std::unique_ptr cfg) {
-                    cfg->FEES.reference_fee = XRPAmount(1);
+                    cfg->fees.referenceFee = XRPAmount(1);
                     return cfg;
                 }),
                 all);
@@ -7083,8 +7278,8 @@ private:
         FeatureBitset const all{jtx::testableAmendments()};
         testInstanceCreate();
         testInvalidInstance();
-        testInvalidDeposit(all);
-        testInvalidDeposit(all - featureAMMClawback);
+        for (auto const& f : jtx::amendmentCombinations({fixCleanup3_3_0, featureAMMClawback}, all))
+            testInvalidDeposit(f);
         testDeposit();
         testInvalidWithdraw();
         testWithdraw();
@@ -7111,6 +7306,7 @@ private:
         testLPTokenBalance(all);
         testLPTokenBalance(all - fixAMMv1_3);
         testAMMDepositWithFrozenAssets();
+        testAMMWithVaultShares();
         testAutoDelete();
     }
 };
diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp
index 68ebdbcf60..3672c4a68a 100644
--- a/src/test/app/AMM_test.cpp
+++ b/src/test/app/AMM_test.cpp
@@ -57,6 +57,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -85,6 +86,13 @@ private:
         return jtx::testableAmendments() - featureSingleAssetVault - featureLendingProtocol;
     }
 
+    // Seed from the local testableAmendments() which strips SAV and Lending.
+    static std::vector
+    amendmentCombinations(std::initializer_list features)
+    {
+        return jtx::amendmentCombinations(features, testableAmendments());
+    }
+
     void
     testInstanceCreate()
     {
@@ -746,18 +754,19 @@ private:
         testAMM(
             [&](AMM& ammAlice, Env& env) {
                 env(fset(gw_, asfGlobalFreeze));
-                if (!features[featureAMMClawback])
+                auto const freezeBlocksAll =
+                    features[featureAMMClawback] || features[fixCleanup3_3_0];
+                if (!freezeBlocksAll)
                 {
                     // If the issuer set global freeze, the holder still can
-                    // deposit the other non-frozen token when AMMClawback is
-                    // not enabled.
+                    // deposit the other non-frozen token when neither
+                    // AMMClawback nor fixCleanup3_3_0 is enabled.
                     ammAlice.deposit(carol_, XRP(100));
                 }
                 else
                 {
                     // If the issuer set global freeze, the holder cannot
-                    // deposit the other non-frozen token when AMMClawback is
-                    // enabled.
+                    // deposit the other non-frozen token.
                     ammAlice.deposit(
                         carol_, XRP(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecFROZEN));
                 }
@@ -786,16 +795,18 @@ private:
             [&](AMM& ammAlice, Env& env) {
                 env(trust(gw_, carol_["USD"](0), tfSetFreeze));
                 env.close();
-                if (!features[featureAMMClawback])
+                auto const freezeBlocksAll =
+                    features[featureAMMClawback] || features[fixCleanup3_3_0];
+                if (!freezeBlocksAll)
                 {
-                    // Can deposit non-frozen token if AMMClawback is not
-                    // enabled
+                    // Can deposit non-frozen token if neither AMMClawback
+                    // nor fixCleanup3_3_0 is enabled
                     ammAlice.deposit(carol_, XRP(100));
                 }
                 else
                 {
                     // Cannot deposit non-frozen token if the other token is
-                    // frozen when AMMClawback is enabled
+                    // frozen
                     ammAlice.deposit(
                         carol_, XRP(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecFROZEN));
                 }
@@ -810,8 +821,18 @@ private:
                     STAmount{Issue{gw_["USD"].currency, ammAlice.ammAccount()}, 0},
                     tfSetFreeze));
                 env.close();
-                // Can deposit non-frozen token
-                ammAlice.deposit(carol_, XRP(100));
+                // Post-fixCleanup3_3_0: checkDepositFreeze checks both pool
+                // assets against the AMM account, so depositing the
+                // non-frozen token is also blocked.
+                if (!features[fixCleanup3_3_0])
+                {
+                    ammAlice.deposit(carol_, XRP(100));
+                }
+                else
+                {
+                    ammAlice.deposit(
+                        carol_, XRP(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecFROZEN));
+                }
                 ammAlice.deposit(carol_, 1'000'000, std::nullopt, std::nullopt, Ter(tecFROZEN));
                 ammAlice.deposit(
                     carol_, USD(100), std::nullopt, std::nullopt, std::nullopt, Ter(tecFROZEN));
@@ -861,11 +882,10 @@ private:
             AMM amm(env, alice_, XRP(10), gw_["USD"](10), Ter(tesSUCCESS));
             env.close();
 
-            if (features[featureAMMClawback])
+            if (features[featureAMMClawback] || features[fixCleanup3_3_0])
             {
-                // if featureAMMClawback is enabled, bob_ can not deposit XRP
-                // because he's not authorized to hold the paired token
-                // gw_["USD"].
+                // bob_ can not deposit XRP because he's not authorized to
+                // hold the paired token gw_["USD"].
                 amm.deposit(
                     bob_, XRP(10), std::nullopt, std::nullopt, std::nullopt, Ter(tecNO_AUTH));
             }
@@ -1734,36 +1754,57 @@ private:
         });
 
         // Globally frozen asset
-        testAMM([&](AMM& ammAlice, Env& env) {
-            ammAlice.deposit({.account = gw_, .asset1In = USD(1'000), .asset2In = XRP(1'000)});
-            env(fset(gw_, asfGlobalFreeze));
-            env.close();
-            // Can withdraw non-frozen token
-            for (auto const& account : {alice_, gw_})
-            {
-                ammAlice.withdraw(account, XRP(100));
-                ammAlice.withdraw(account, USD(100), std::nullopt, std::nullopt, Ter(tecFROZEN));
-                ammAlice.withdraw(account, 1'000, std::nullopt, std::nullopt, Ter(tecFROZEN));
-            }
-        });
+        testAMM(
+            [&](AMM& ammAlice, Env& env) {
+                auto const fix330 = env.current()->rules().enabled(fixCleanup3_3_0);
+                ammAlice.deposit({.account = gw_, .asset1In = USD(1'000), .asset2In = XRP(1'000)});
+                env(fset(gw_, asfGlobalFreeze));
+                env.close();
+                // Can withdraw non-frozen token
+                for (auto const& account : {alice_, gw_})
+                {
+                    ammAlice.withdraw(account, XRP(100));
+                    // Post-fixCleanup3_3_0 the issuer can withdraw their own
+                    // frozen token from the pool.
+                    auto const frozenErr =
+                        (fix330 && account == gw_) ? Ter(tesSUCCESS) : Ter(tecFROZEN);
+                    ammAlice.withdraw(account, USD(100), std::nullopt, std::nullopt, frozenErr);
+                    ammAlice.withdraw(account, 1'000, std::nullopt, std::nullopt, frozenErr);
+                }
+            },
+            std::nullopt,
+            0,
+            std::nullopt,
+            amendmentCombinations({fixCleanup3_3_0}));
 
         // Individually frozen (AMM) account
-        testAMM([&](AMM& ammAlice, Env& env) {
-            env(trust(gw_, alice_["USD"](0), tfSetFreeze));
-            env.close();
-            // Can withdraw non-frozen token
-            ammAlice.withdraw(alice_, XRP(100));
-            ammAlice.withdraw(alice_, 1'000, std::nullopt, std::nullopt, Ter(tecFROZEN));
-            ammAlice.withdraw(alice_, USD(100), std::nullopt, std::nullopt, Ter(tecFROZEN));
-            env(trust(gw_, alice_["USD"](0), tfClearFreeze));
-            // Individually frozen AMM
-            env(trust(
-                gw_, STAmount{Issue{gw_["USD"].currency, ammAlice.ammAccount()}, 0}, tfSetFreeze));
-            // Can withdraw non-frozen token
-            ammAlice.withdraw(alice_, XRP(100));
-            ammAlice.withdraw(alice_, 1'000, std::nullopt, std::nullopt, Ter(tecFROZEN));
-            ammAlice.withdraw(alice_, USD(100), std::nullopt, std::nullopt, Ter(tecFROZEN));
-        });
+        testAMM(
+            [&](AMM& ammAlice, Env& env) {
+                auto const fix330 = env.current()->rules().enabled(fixCleanup3_3_0);
+                env(trust(gw_, alice_["USD"](0), tfSetFreeze));
+                env.close();
+                // Can withdraw non-frozen token
+                ammAlice.withdraw(alice_, XRP(100));
+                // Post-fixCleanup3_3_0 regular freeze no longer blocks
+                // self-withdrawal; only deep freeze does.
+                auto const indivFreezeErr = fix330 ? Ter(tesSUCCESS) : Ter(tecFROZEN);
+                ammAlice.withdraw(alice_, 1'000, std::nullopt, std::nullopt, indivFreezeErr);
+                ammAlice.withdraw(alice_, USD(100), std::nullopt, std::nullopt, indivFreezeErr);
+                env(trust(gw_, alice_["USD"](0), tfClearFreeze));
+                // Individually frozen AMM — still blocked regardless of
+                // fixCleanup3_3_0 because the AMM account itself is frozen.
+                env(trust(
+                    gw_,
+                    STAmount{Issue{gw_["USD"].currency, ammAlice.ammAccount()}, 0},
+                    tfSetFreeze));
+                ammAlice.withdraw(alice_, XRP(100));
+                ammAlice.withdraw(alice_, 1'000, std::nullopt, std::nullopt, Ter(tecFROZEN));
+                ammAlice.withdraw(alice_, USD(100), std::nullopt, std::nullopt, Ter(tecFROZEN));
+            },
+            std::nullopt,
+            0,
+            std::nullopt,
+            amendmentCombinations({fixCleanup3_3_0}));
 
         // Carol withdraws more than she owns
         testAMM([&](AMM& ammAlice, Env&) {
@@ -1842,8 +1883,18 @@ private:
         // are rounded to all LP tokens.
         testAMM(
             [&](AMM& ammAlice, Env& env) {
-                auto const err =
-                    env.enabled(fixAMMv1_3) ? Ter(tecINVARIANT_FAILED) : Ter(tecAMM_BALANCE);
+                // Without fixAMMv1_3: sub-method returns tecAMM_BALANCE early.
+                // With fixAMMv1_3 but without fixCleanup3_3_0: sub-method succeeds
+                //   but invariant check catches the precision violation.
+                // With fixCleanup3_3_0: caught in the transaction layer before
+                //   the invariant checker runs.
+                auto const err = [&] {
+                    if (!env.enabled(fixAMMv1_3))
+                        return Ter(tecAMM_BALANCE);
+                    if (env.enabled(fixCleanup3_3_0))
+                        return Ter(tecPRECISION_LOSS);
+                    return Ter(tecINVARIANT_FAILED);
+                }();
                 ammAlice.withdraw(
                     alice_,
                     STAmount{USD, UINT64_C(9'999'999999999999), -12},
@@ -1851,7 +1902,7 @@ private:
                     std::nullopt,
                     err);
             },
-            {.features = {all, all - fixAMMv1_3}, .noLog = true});
+            {.features = {all, all - fixAMMv1_3, all - fixCleanup3_3_0}, .noLog = true});
 
         // Tiny withdraw
         testAMM([&](AMM& ammAlice, Env&) {
@@ -2229,6 +2280,31 @@ private:
             ammAlice.withdraw(alice_, XRPAmount{9'999'999'999});
             BEAST_EXPECT(ammAlice.expectBalances(XRPAmount{1}, USD(10'000), IOUAmount{100}));
         });
+
+        // singleWithdrawEPrice: crafted ePrice = lptAMMBalance*f/amountBalance
+        // makes the denominator (T*f - A*E) exactly zero.
+        // Pre-fixCleanup3_3_0: std::overflow_error escapes to the
+        // transactor backstop and is returned as tefEXCEPTION.
+        // Post-fixCleanup3_3_0: denominator check returns tecAMM_FAILED.
+        //
+        // Pool: USD(100)/EUR(100), baseFee=1000 (1%).
+        // Alice is the creator so her discounted fee is 100 (0.1%), f=0.001.
+        // ePrice = lptAMMBalance(100) * f(0.001) / amountBalance(100) = 0.001
+        testAMM(
+            [&](AMM& ammAlice, Env& env) {
+                auto const err =
+                    env.enabled(fixCleanup3_3_0) ? Ter(tecAMM_FAILED) : Ter(tefEXCEPTION);
+                ammAlice.withdraw(
+                    WithdrawArg{
+                        .account = alice_,
+                        .asset1Out = USD(0),
+                        .maxEP = IOUAmount{1, -3},  // ePrice=0.001 → denom=0
+                        .err = err});
+            },
+            {{USD(100), EUR(100)}},
+            1000,
+            std::nullopt,
+            {all - fixCleanup3_3_0, all});
     }
 
     void
@@ -2625,10 +2701,6 @@ private:
         using namespace jtx;
         using namespace std::chrono;
 
-        // For now, just disable SAV entirely, which locks in the small Number
-        // mantissas
-        features = features - featureSingleAssetVault - featureLendingProtocol;
-
         // Auction slot initially is owned by AMM creator, who pays 0 price.
 
         // Bid 110 tokens. Pay bidMin.
@@ -3337,11 +3409,6 @@ private:
         testcase("Basic Payment");
         using namespace jtx;
 
-        // For now, just disable SAV entirely, which locks in the small Number
-        // mantissas
-        features =
-            features - featureSingleAssetVault - featureLendingProtocol - featureLendingProtocol;
-
         // Payment 100USD for 100XRP.
         // Force one path with tfNoRippleDirect.
         testAMM(
@@ -4342,15 +4409,10 @@ private:
     testAmendment()
     {
         testcase("Amendment");
-        FeatureBitset const all{testableAmendments()};
-        FeatureBitset const noAMM{all - featureAMM};
-        FeatureBitset const noNumber{all - fixUniversalNumber};
-        FeatureBitset const noAMMAndNumber{all - featureAMM - fixUniversalNumber};
         using namespace jtx;
+        Env env{*this, testableAmendments() - featureAMM};
 
-        for (auto const& feature : {noAMM, noNumber, noAMMAndNumber})
         {
-            Env env{*this, feature};
             fund(env, gw_, {alice_}, {USD(1'000)}, Fund::All);
             AMM amm(env, alice_, XRP(1'000), USD(1'000), Ter(temDISABLED));
 
@@ -5093,7 +5155,7 @@ private:
             Env env(
                 *this,
                 envconfig([](std::unique_ptr cfg) {
-                    cfg->FEES.reference_fee = XRPAmount(1);
+                    cfg->fees.referenceFee = XRPAmount(1);
                     return cfg;
                 }),
                 all);
@@ -5151,7 +5213,7 @@ private:
             Env env(
                 *this,
                 envconfig([](std::unique_ptr cfg) {
-                    cfg->FEES.reference_fee = XRPAmount(1);
+                    cfg->fees.referenceFee = XRPAmount(1);
                     return cfg;
                 }),
                 all);
@@ -6638,11 +6700,11 @@ private:
             });
         }
 
-        if (features[featureAMMClawback])
+        if (features[featureAMMClawback] || features[fixCleanup3_3_0])
         {
             // Deposit one asset which is not the frozen token,
-            // but the other asset is frozen. We should get tecFROZEN error
-            // when feature AMMClawback is enabled.
+            // but the other asset is frozen. tecFROZEN when either
+            // AMMClawback or fixCleanup3_3_0 is enabled.
             Env env(*this, features);
             testAMMDeposit(env, [&](AMM& amm) {
                 amm.deposit(
@@ -6652,8 +6714,8 @@ private:
         else
         {
             // Deposit one asset which is not the frozen token,
-            // but the other asset is frozen. We will get tecSUCCESS
-            // when feature AMMClawback is not enabled.
+            // but the other asset is frozen. tesSUCCESS only when
+            // neither AMMClawback nor fixCleanup3_3_0 is enabled.
             Env env(*this, features);
             testAMMDeposit(env, [&](AMM& amm) {
                 amm.deposit(
@@ -7090,7 +7152,7 @@ private:
         Env env(
             *this,
             envconfig([](std::unique_ptr cfg) {
-                cfg->FEES.reference_fee = XRPAmount(1);
+                cfg->fees.referenceFee = XRPAmount(1);
                 return cfg;
             }),
             features);
@@ -7176,8 +7238,8 @@ private:
         FeatureBitset const all{testableAmendments()};
         testInvalidInstance();
         testInstanceCreate();
-        testInvalidDeposit(all);
-        testInvalidDeposit(all - featureAMMClawback);
+        for (auto const& f : amendmentCombinations({fixCleanup3_3_0, featureAMMClawback}))
+            testInvalidDeposit(f);
         testDeposit();
         testInvalidWithdraw();
         testWithdraw();
@@ -7227,8 +7289,8 @@ private:
         testAMMClawback(all - featureAMMClawback - featureSingleAssetVault);
         testAMMClawback(all - featureAMMClawback);
         testAMMClawback(all - fixAMMv1_1 - fixAMMv1_3 - featureAMMClawback);
-        testAMMDepositWithFrozenAssets(all);
-        testAMMDepositWithFrozenAssets(all - featureAMMClawback);
+        for (auto const& f : amendmentCombinations({fixCleanup3_3_0, featureAMMClawback}))
+            testAMMDepositWithFrozenAssets(f);
         testAMMDepositWithFrozenAssets(all - fixAMMv1_1 - featureAMMClawback);
         testAMMDepositWithFrozenAssets(all - fixAMMv1_1 - fixAMMv1_3 - featureAMMClawback);
         testFixReserveCheckOnWithdrawal(all);
diff --git a/src/test/app/AccountDelete_test.cpp b/src/test/app/AccountDelete_test.cpp
index 951f99919b..399696ec0d 100644
--- a/src/test/app/AccountDelete_test.cpp
+++ b/src/test/app/AccountDelete_test.cpp
@@ -215,8 +215,8 @@ public:
             BEAST_EXPECT(env.closed()->exists(keylet::ownerDir(carol.id())));
             BEAST_EXPECT(env.closed()->exists(keylet::depositPreauth(carol.id(), becky.id())));
             BEAST_EXPECT(env.closed()->exists(keylet::offer(carol.id(), carolOfferSeq)));
-            BEAST_EXPECT(env.closed()->exists(keylet::kTicket(carol.id(), carolTicketSeq)));
-            BEAST_EXPECT(env.closed()->exists(keylet::signers(carol.id())));
+            BEAST_EXPECT(env.closed()->exists(keylet::ticket(carol.id(), carolTicketSeq)));
+            BEAST_EXPECT(env.closed()->exists(keylet::signerList(carol.id())));
 
             // Delete carol's account even with stuff in her directory.  Show
             // that multisigning for the delete does not increase carol's fee.
@@ -229,8 +229,8 @@ public:
             BEAST_EXPECT(!env.closed()->exists(keylet::ownerDir(carol.id())));
             BEAST_EXPECT(!env.closed()->exists(keylet::depositPreauth(carol.id(), becky.id())));
             BEAST_EXPECT(!env.closed()->exists(keylet::offer(carol.id(), carolOfferSeq)));
-            BEAST_EXPECT(!env.closed()->exists(keylet::kTicket(carol.id(), carolTicketSeq)));
-            BEAST_EXPECT(!env.closed()->exists(keylet::signers(carol.id())));
+            BEAST_EXPECT(!env.closed()->exists(keylet::ticket(carol.id(), carolTicketSeq)));
+            BEAST_EXPECT(!env.closed()->exists(keylet::signerList(carol.id())));
 
             // Verify that Carol's XRP, minus the fee, was transferred to becky.
             BEAST_EXPECT(env.balance(becky) == carolOldBalance + beckyOldBalance - acctDelFee);
@@ -386,7 +386,7 @@ public:
         env(escrow::cancel(becky, alice, escrowSeq));
         env.close();
 
-        Keylet const alicePayChanKey{keylet::payChan(alice, becky, env.seq(alice))};
+        Keylet const alicePayChanKey{keylet::payChannel(alice, becky, env.seq(alice))};
 
         env(payChanCreate(alice, becky, XRP(57), 4s, env.now() + 2s, alice.pk()));
         env.close();
@@ -417,7 +417,7 @@ public:
 
         // gw creates a PayChannel with alice as the destination, this should
         // prevent alice from deleting her account.
-        Keylet const gwPayChanKey{keylet::payChan(gw, alice, env.seq(gw))};
+        Keylet const gwPayChanKey{keylet::payChannel(gw, alice, env.seq(gw))};
 
         env(payChanCreate(gw, alice, XRP(68), 4s, env.now() + 2s, alice.pk()));
         env.close();
@@ -662,7 +662,7 @@ public:
             BEAST_EXPECT(closed->exists(keylet::account(bob.id())));
             for (std::uint32_t i = 0; i < 250; ++i)
             {
-                BEAST_EXPECT(closed->exists(keylet::kTicket(bob.id(), ticketSeq + i)));
+                BEAST_EXPECT(closed->exists(keylet::ticket(bob.id(), ticketSeq + i)));
             }
         }
 
@@ -681,7 +681,7 @@ public:
             BEAST_EXPECT(!closed->exists(keylet::account(bob.id())));
             for (std::uint32_t i = 0; i < 250; ++i)
             {
-                BEAST_EXPECT(!closed->exists(keylet::kTicket(bob.id(), ticketSeq + i)));
+                BEAST_EXPECT(!closed->exists(keylet::ticket(bob.id(), ticketSeq + i)));
             }
         }
     }
@@ -813,7 +813,7 @@ public:
             env.close();
 
             // alice create DepositPreauth Object
-            env(deposit::authCredentials(alice, {{carol, credType}}));
+            env(deposit::authCredentials(alice, {{.issuer = carol, .credType = credType}}));
             env.close();
 
             // becky attempts to delete her account, but alice won't take her
diff --git a/src/test/app/AccountSet_test.cpp b/src/test/app/AccountSet_test.cpp
index 6688e3693f..362113aec2 100644
--- a/src/test/app/AccountSet_test.cpp
+++ b/src/test/app/AccountSet_test.cpp
@@ -383,6 +383,7 @@ public:
         auto const usd = gw["USD"];
 
         // Test gateway with a variety of allowed transfer rates
+        // NOLINTNEXTLINE(bugprone-float-loop-counter)
         for (double transferRate = 1.0; transferRate <= 2.0; transferRate += 0.03125)
         {
             Env env(*this);
diff --git a/src/test/app/AmendmentTable_test.cpp b/src/test/app/AmendmentTable_test.cpp
index 219aaabdda..d420990cbc 100644
--- a/src/test/app/AmendmentTable_test.cpp
+++ b/src/test/app/AmendmentTable_test.cpp
@@ -4,14 +4,14 @@
 
 #include 
 #include 
-#include 
 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -83,8 +83,8 @@ private:
     makeConfig()
     {
         auto cfg = test::jtx::envconfig();
-        cfg->section(SECTION_AMENDMENTS) = makeSection(SECTION_AMENDMENTS, enabled_);
-        cfg->section(SECTION_VETO_AMENDMENTS) = makeSection(SECTION_VETO_AMENDMENTS, vetoed_);
+        cfg->section(Sections::kAmendments) = makeSection(Sections::kAmendments, enabled_);
+        cfg->section(Sections::kVetoAmendments) = makeSection(Sections::kVetoAmendments, vetoed_);
         return cfg;
     }
 
@@ -140,7 +140,7 @@ private:
     combineArg(std::vector& dest, std::vector const& src, Args const&... args)
     {
         assert(dest.capacity() >= dest.size() + src.size());
-        std::copy(src.begin(), src.end(), std::back_inserter(dest));
+        std::ranges::copy(src, std::back_inserter(dest));
         if constexpr (sizeof...(args) > 0)
             combineArg(dest, args...);
     }
diff --git a/src/test/app/Batch_test.cpp b/src/test/app/Batch_test.cpp
index 8755fe9f9c..6aaae62155 100644
--- a/src/test/app/Batch_test.cpp
+++ b/src/test/app/Batch_test.cpp
@@ -33,6 +33,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -168,13 +170,13 @@ class Batch_test : public beast::unit_test::Suite
         std::map extraVoting = {})
     {
         auto p = test::jtx::envconfig();
-        auto& section = p->section("transaction_queue");
-        section.set("ledgers_in_queue", "2");
-        section.set("minimum_queue_size", "2");
-        section.set("min_ledgers_to_compute_size_limit", "3");
-        section.set("max_ledger_counts_to_store", "100");
-        section.set("retry_sequence_percent", "25");
-        section.set("normal_consensus_increase_percent", "0");
+        auto& section = p->section(Sections::kTransactionQueue);
+        section.set(Keys::kLedgersInQueue, "2");
+        section.set(Keys::kMinimumQueueSize, "2");
+        section.set(Keys::kMinLedgersToComputeSizeLimit, "3");
+        section.set(Keys::kMaxLedgerCountsToStore, "100");
+        section.set(Keys::kRetrySequencePercent, "25");
+        section.set(Keys::kNormalConsensusIncreasePercent, "0");
 
         for (auto const& [k, v] : extraTxQ)
             section.set(k, v);
@@ -1017,7 +1019,11 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
             {
                 std::vector const testCases = {
-                    {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
+                    {.index = 0,
+                     .txType = "Batch",
+                     .result = "tesSUCCESS",
+                     .txHash = batchID,
+                     .batchID = std::nullopt},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -1059,7 +1065,11 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
             {
                 std::vector const testCases = {
-                    {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
+                    {.index = 0,
+                     .txType = "Batch",
+                     .result = "tesSUCCESS",
+                     .txHash = batchID,
+                     .batchID = std::nullopt},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -1101,7 +1111,11 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
             {
                 std::vector const testCases = {
-                    {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
+                    {.index = 0,
+                     .txType = "Batch",
+                     .result = "tesSUCCESS",
+                     .txHash = batchID,
+                     .batchID = std::nullopt},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -1143,7 +1157,11 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
             {
                 std::vector const testCases = {
-                    {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
+                    {.index = 0,
+                     .txType = "Batch",
+                     .result = "tesSUCCESS",
+                     .txHash = batchID,
+                     .batchID = std::nullopt},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -1185,7 +1203,11 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
             {
                 std::vector const testCases = {
-                    {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
+                    {.index = 0,
+                     .txType = "Batch",
+                     .result = "tesSUCCESS",
+                     .txHash = batchID,
+                     .batchID = std::nullopt},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -1520,9 +1542,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -1552,7 +1586,11 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
             };
             validateClosedLedger(env, testCases);
 
@@ -1581,7 +1619,11 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
             };
             validateClosedLedger(env, testCases);
 
@@ -1610,7 +1652,11 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
             };
             validateClosedLedger(env, testCases);
 
@@ -1662,10 +1708,26 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tecUNFUNDED_PAYMENT", txIDs[0], batchID},
-                {2, "Payment", "tecUNFUNDED_PAYMENT", txIDs[1], batchID},
-                {3, "Payment", "tecUNFUNDED_PAYMENT", txIDs[2], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tecUNFUNDED_PAYMENT",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tecUNFUNDED_PAYMENT",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
+                {.index = 3,
+                 .txType = "Payment",
+                 .result = "tecUNFUNDED_PAYMENT",
+                 .txHash = txIDs[2],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -1695,9 +1757,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tecUNFUNDED_PAYMENT", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tecUNFUNDED_PAYMENT",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -1727,8 +1801,16 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -1758,8 +1840,16 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -1789,8 +1879,16 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -1826,11 +1924,31 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "OfferCreate", "tecKILLED", txIDs[0], batchID},
-                {2, "OfferCreate", "tecKILLED", txIDs[1], batchID},
-                {3, "OfferCreate", "tecKILLED", txIDs[2], batchID},
-                {4, "Payment", "tesSUCCESS", txIDs[3], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "OfferCreate",
+                 .result = "tecKILLED",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "OfferCreate",
+                 .result = "tecKILLED",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
+                {.index = 3,
+                 .txType = "OfferCreate",
+                 .result = "tecKILLED",
+                 .txHash = txIDs[2],
+                 .batchID = batchID},
+                {.index = 4,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[3],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -1878,8 +1996,16 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tecUNFUNDED_PAYMENT", txIDs[0], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tecUNFUNDED_PAYMENT",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -1909,11 +2035,31 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
-                {3, "Payment", "tesSUCCESS", txIDs[2], batchID},
-                {4, "Payment", "tesSUCCESS", txIDs[3], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
+                {.index = 3,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[2],
+                 .batchID = batchID},
+                {.index = 4,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[3],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -1944,10 +2090,26 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
-                {3, "Payment", "tecUNFUNDED_PAYMENT", txIDs[2], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
+                {.index = 3,
+                 .txType = "Payment",
+                 .result = "tecUNFUNDED_PAYMENT",
+                 .txHash = txIDs[2],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -1978,9 +2140,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -2011,9 +2185,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -2044,10 +2230,26 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
-                {3, "OfferCreate", "tecKILLED", txIDs[2], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
+                {.index = 3,
+                 .txType = "OfferCreate",
+                 .result = "tecKILLED",
+                 .txHash = txIDs[2],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -2095,11 +2297,31 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tecUNFUNDED_PAYMENT", txIDs[1], batchID},
-                {3, "Payment", "tecUNFUNDED_PAYMENT", txIDs[2], batchID},
-                {4, "Payment", "tesSUCCESS", txIDs[3], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tecUNFUNDED_PAYMENT",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
+                {.index = 3,
+                 .txType = "Payment",
+                 .result = "tecUNFUNDED_PAYMENT",
+                 .txHash = txIDs[2],
+                 .batchID = batchID},
+                {.index = 4,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[3],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -2130,11 +2352,31 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
-                {3, "Payment", "tecUNFUNDED_PAYMENT", txIDs[2], batchID},
-                {4, "Payment", "tesSUCCESS", txIDs[3], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
+                {.index = 3,
+                 .txType = "Payment",
+                 .result = "tecUNFUNDED_PAYMENT",
+                 .txHash = txIDs[2],
+                 .batchID = batchID},
+                {.index = 4,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[3],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -2165,10 +2407,26 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
-                {3, "Payment", "tesSUCCESS", txIDs[3], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
+                {.index = 3,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[3],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -2199,10 +2457,26 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
-                {3, "Payment", "tesSUCCESS", txIDs[3], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
+                {.index = 3,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[3],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -2232,10 +2506,26 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
-                {3, "OfferCreate", "tecKILLED", txIDs[2], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
+                {.index = 3,
+                 .txType = "OfferCreate",
+                 .result = "tecKILLED",
+                 .txHash = txIDs[2],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -2454,9 +2744,21 @@ class Batch_test : public beast::unit_test::Suite
         env.close();
 
         std::vector const testCases = {
-            {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-            {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-            {2, "AccountSet", "tesSUCCESS", txIDs[1], batchID},
+            {.index = 0,
+             .txType = "Batch",
+             .result = "tesSUCCESS",
+             .txHash = batchID,
+             .batchID = std::nullopt},
+            {.index = 1,
+             .txType = "Payment",
+             .result = "tesSUCCESS",
+             .txHash = txIDs[0],
+             .batchID = batchID},
+            {.index = 2,
+             .txType = "AccountSet",
+             .result = "tesSUCCESS",
+             .txHash = txIDs[1],
+             .batchID = batchID},
         };
         validateClosedLedger(env, testCases);
 
@@ -2503,9 +2805,21 @@ class Batch_test : public beast::unit_test::Suite
         env.close();
 
         std::vector const testCases = {
-            {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-            {1, "AccountSet", "tesSUCCESS", txIDs[0], batchID},
-            {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+            {.index = 0,
+             .txType = "Batch",
+             .result = "tesSUCCESS",
+             .txHash = batchID,
+             .batchID = std::nullopt},
+            {.index = 1,
+             .txType = "AccountSet",
+             .result = "tesSUCCESS",
+             .txHash = txIDs[0],
+             .batchID = batchID},
+            {.index = 2,
+             .txType = "Payment",
+             .result = "tesSUCCESS",
+             .txHash = txIDs[1],
+             .batchID = batchID},
         };
         validateClosedLedger(env, testCases);
 
@@ -2558,9 +2872,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "AccountDelete", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "AccountDelete",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -2601,10 +2927,26 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "AccountDelete", "tecHAS_OBLIGATIONS", txIDs[1], batchID},
-                {3, "Payment", "tesSUCCESS", txIDs[2], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "AccountDelete",
+                 .result = "tecHAS_OBLIGATIONS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
+                {.index = 3,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[2],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -2642,7 +2984,11 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
             };
             validateClosedLedger(env, testCases);
 
@@ -2694,7 +3040,7 @@ class Batch_test : public beast::unit_test::Suite
         env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit}));
         env.close();
 
-        auto const brokerKeylet = keylet::loanbroker(lender.id(), env.seq(lender));
+        auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender));
 
         {
             using namespace loanBroker;
@@ -2876,9 +3222,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "CheckCreate", "tesSUCCESS", txIDs[0], batchID},
-                {2, "CheckCash", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "CheckCreate",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "CheckCash",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -2922,9 +3280,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "CheckCreate", "tecDST_TAG_NEEDED", txIDs[0], batchID},
-                {2, "CheckCash", "tecNO_ENTRY", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "CheckCreate",
+                 .result = "tecDST_TAG_NEEDED",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "CheckCash",
+                 .result = "tecNO_ENTRY",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -2987,10 +3357,26 @@ class Batch_test : public beast::unit_test::Suite
         env.close();
 
         std::vector const testCases = {
-            {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-            {1, "TicketCreate", "tesSUCCESS", txIDs[0], batchID},
-            {2, "CheckCreate", "tesSUCCESS", txIDs[1], batchID},
-            {3, "CheckCash", "tesSUCCESS", txIDs[2], batchID},
+            {.index = 0,
+             .txType = "Batch",
+             .result = "tesSUCCESS",
+             .txHash = batchID,
+             .batchID = std::nullopt},
+            {.index = 1,
+             .txType = "TicketCreate",
+             .result = "tesSUCCESS",
+             .txHash = txIDs[0],
+             .batchID = batchID},
+            {.index = 2,
+             .txType = "CheckCreate",
+             .result = "tesSUCCESS",
+             .txHash = txIDs[1],
+             .batchID = batchID},
+            {.index = 3,
+             .txType = "CheckCash",
+             .result = "tesSUCCESS",
+             .txHash = txIDs[2],
+             .batchID = batchID},
         };
         validateClosedLedger(env, testCases);
 
@@ -3047,9 +3433,21 @@ class Batch_test : public beast::unit_test::Suite
         env.close();
 
         std::vector const testCases = {
-            {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-            {1, "CheckCreate", "tesSUCCESS", txIDs[0], batchID},
-            {2, "CheckCash", "tesSUCCESS", txIDs[1], batchID},
+            {.index = 0,
+             .txType = "Batch",
+             .result = "tesSUCCESS",
+             .txHash = batchID,
+             .batchID = std::nullopt},
+            {.index = 1,
+             .txType = "CheckCreate",
+             .result = "tesSUCCESS",
+             .txHash = txIDs[0],
+             .batchID = batchID},
+            {.index = 2,
+             .txType = "CheckCash",
+             .result = "tesSUCCESS",
+             .txHash = txIDs[1],
+             .batchID = batchID},
         };
         validateClosedLedger(env, testCases);
 
@@ -3099,9 +3497,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -3147,9 +3557,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -3196,9 +3618,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -3257,9 +3691,21 @@ class Batch_test : public beast::unit_test::Suite
 
             {
                 std::vector const testCases = {
-                    {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                    {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                    {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                    {.index = 0,
+                     .txType = "Batch",
+                     .result = "tesSUCCESS",
+                     .txHash = batchID,
+                     .batchID = std::nullopt},
+                    {.index = 1,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[0],
+                     .batchID = batchID},
+                    {.index = 2,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[1],
+                     .batchID = batchID},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -3268,7 +3714,11 @@ class Batch_test : public beast::unit_test::Suite
             {
                 // next ledger contains noop txn
                 std::vector const testCases = {
-                    {0, "AccountSet", "tesSUCCESS", noopTxnID, std::nullopt},
+                    {.index = 0,
+                     .txType = "AccountSet",
+                     .result = "tesSUCCESS",
+                     .txHash = noopTxnID,
+                     .batchID = std::nullopt},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -3301,9 +3751,21 @@ class Batch_test : public beast::unit_test::Suite
 
             {
                 std::vector const testCases = {
-                    {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                    {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                    {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                    {.index = 0,
+                     .txType = "Batch",
+                     .result = "tesSUCCESS",
+                     .txHash = batchID,
+                     .batchID = std::nullopt},
+                    {.index = 1,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[0],
+                     .batchID = batchID},
+                    {.index = 2,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[1],
+                     .batchID = batchID},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -3340,9 +3802,21 @@ class Batch_test : public beast::unit_test::Suite
 
             {
                 std::vector const testCases = {
-                    {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                    {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                    {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                    {.index = 0,
+                     .txType = "Batch",
+                     .result = "tesSUCCESS",
+                     .txHash = batchID,
+                     .batchID = std::nullopt},
+                    {.index = 1,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[0],
+                     .batchID = batchID},
+                    {.index = 2,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[1],
+                     .batchID = batchID},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -3382,10 +3856,26 @@ class Batch_test : public beast::unit_test::Suite
 
             {
                 std::vector const testCases = {
-                    {0, "AccountSet", "tesSUCCESS", noopTxnID, std::nullopt},
-                    {1, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                    {2, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                    {3, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                    {.index = 0,
+                     .txType = "AccountSet",
+                     .result = "tesSUCCESS",
+                     .txHash = noopTxnID,
+                     .batchID = std::nullopt},
+                    {.index = 1,
+                     .txType = "Batch",
+                     .result = "tesSUCCESS",
+                     .txHash = batchID,
+                     .batchID = std::nullopt},
+                    {.index = 2,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[0],
+                     .batchID = batchID},
+                    {.index = 3,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[1],
+                     .batchID = batchID},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -3442,9 +3932,21 @@ class Batch_test : public beast::unit_test::Suite
 
             {
                 std::vector const testCases = {
-                    {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                    {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                    {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                    {.index = 0,
+                     .txType = "Batch",
+                     .result = "tesSUCCESS",
+                     .txHash = batchID,
+                     .batchID = std::nullopt},
+                    {.index = 1,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[0],
+                     .batchID = batchID},
+                    {.index = 2,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[1],
+                     .batchID = batchID},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -3489,9 +3991,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
             {
                 std::vector const testCases = {
-                    {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                    {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                    {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                    {.index = 0,
+                     .txType = "Batch",
+                     .result = "tesSUCCESS",
+                     .txHash = batchID,
+                     .batchID = std::nullopt},
+                    {.index = 1,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[0],
+                     .batchID = batchID},
+                    {.index = 2,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[1],
+                     .batchID = batchID},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -3552,10 +4066,26 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
             {
                 std::vector const testCases = {
-                    {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                    {1, "CheckCreate", "tesSUCCESS", txIDs[0], batchID},
-                    {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
-                    {3, "CheckCash", "tesSUCCESS", objTxnID, std::nullopt},
+                    {.index = 0,
+                     .txType = "Batch",
+                     .result = "tesSUCCESS",
+                     .txHash = batchID,
+                     .batchID = std::nullopt},
+                    {.index = 1,
+                     .txType = "CheckCreate",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[0],
+                     .batchID = batchID},
+                    {.index = 2,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[1],
+                     .batchID = batchID},
+                    {.index = 3,
+                     .txType = "CheckCash",
+                     .result = "tesSUCCESS",
+                     .txHash = objTxnID,
+                     .batchID = std::nullopt},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -3601,10 +4131,26 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
             {
                 std::vector const testCases = {
-                    {0, "CheckCreate", "tesSUCCESS", objTxnID, std::nullopt},
-                    {1, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                    {2, "CheckCash", "tesSUCCESS", txIDs[0], batchID},
-                    {3, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                    {.index = 0,
+                     .txType = "CheckCreate",
+                     .result = "tesSUCCESS",
+                     .txHash = objTxnID,
+                     .batchID = std::nullopt},
+                    {.index = 1,
+                     .txType = "Batch",
+                     .result = "tesSUCCESS",
+                     .txHash = batchID,
+                     .batchID = std::nullopt},
+                    {.index = 2,
+                     .txType = "CheckCash",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[0],
+                     .batchID = batchID},
+                    {.index = 3,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[1],
+                     .batchID = batchID},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -3646,10 +4192,26 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
             {
                 std::vector const testCases = {
-                    {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                    {1, "CheckCreate", "tesSUCCESS", txIDs[0], batchID},
-                    {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
-                    {3, "CheckCash", "tesSUCCESS", objTxnID, std::nullopt},
+                    {.index = 0,
+                     .txType = "Batch",
+                     .result = "tesSUCCESS",
+                     .txHash = batchID,
+                     .batchID = std::nullopt},
+                    {.index = 1,
+                     .txType = "CheckCreate",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[0],
+                     .batchID = batchID},
+                    {.index = 2,
+                     .txType = "Payment",
+                     .result = "tesSUCCESS",
+                     .txHash = txIDs[1],
+                     .batchID = batchID},
+                    {.index = 3,
+                     .txType = "CheckCash",
+                     .result = "tesSUCCESS",
+                     .txHash = objTxnID,
+                     .batchID = std::nullopt},
                 };
                 validateClosedLedger(env, testCases);
             }
@@ -3742,10 +4304,26 @@ class Batch_test : public beast::unit_test::Suite
         env.close();
 
         std::vector const testCases = {
-            {0, "Payment", "tesSUCCESS", payTxn1ID, std::nullopt},
-            {1, "Batch", "tesSUCCESS", batchID, std::nullopt},
-            {2, "Payment", "tesSUCCESS", txIDs[0], batchID},
-            {3, "Payment", "tesSUCCESS", txIDs[1], batchID},
+            {.index = 0,
+             .txType = "Payment",
+             .result = "tesSUCCESS",
+             .txHash = payTxn1ID,
+             .batchID = std::nullopt},
+            {.index = 1,
+             .txType = "Batch",
+             .result = "tesSUCCESS",
+             .txHash = batchID,
+             .batchID = std::nullopt},
+            {.index = 2,
+             .txType = "Payment",
+             .result = "tesSUCCESS",
+             .txHash = txIDs[0],
+             .batchID = batchID},
+            {.index = 3,
+             .txType = "Payment",
+             .result = "tesSUCCESS",
+             .txHash = txIDs[1],
+             .batchID = batchID},
         };
         validateClosedLedger(env, testCases);
 
@@ -3753,7 +4331,11 @@ class Batch_test : public beast::unit_test::Suite
         {
             // next ledger includes the payment txn
             std::vector const testCases = {
-                {0, "Payment", "tesSUCCESS", payTxn2ID, std::nullopt},
+                {.index = 0,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = payTxn2ID,
+                 .batchID = std::nullopt},
             };
             validateClosedLedger(env, testCases);
         }
@@ -3781,7 +4363,7 @@ class Batch_test : public beast::unit_test::Suite
         {
             test::jtx::Env env{
                 *this,
-                makeSmallQueueConfig({{"minimum_txn_in_ledger_standalone", "2"}}),
+                makeSmallQueueConfig({{Keys::kMinimumTxnInLedgerStandalone, "2"}}),
                 features,
                 nullptr,
                 beast::Severity::Error};
@@ -3837,7 +4419,7 @@ class Batch_test : public beast::unit_test::Suite
         {
             test::jtx::Env env{
                 *this,
-                makeSmallQueueConfig({{"minimum_txn_in_ledger_standalone", "2"}}),
+                makeSmallQueueConfig({{Keys::kMinimumTxnInLedgerStandalone, "2"}}),
                 features,
                 nullptr,
                 beast::Severity::Error};
@@ -3965,9 +4547,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -4014,9 +4608,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "Payment", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -4064,9 +4670,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "AccountSet", "tesSUCCESS", txIDs[0], batchID},
-                {2, "Payment", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "AccountSet",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "Payment",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
 
@@ -4126,9 +4744,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "MPTokenIssuanceSet", "tesSUCCESS", txIDs[0], batchID},
-                {2, "MPTokenIssuanceSet", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "MPTokenIssuanceSet",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "MPTokenIssuanceSet",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
         }
@@ -4167,9 +4797,21 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "TrustSet", "tesSUCCESS", txIDs[0], batchID},
-                {2, "TrustSet", "tesSUCCESS", txIDs[1], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "TrustSet",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
+                {.index = 2,
+                 .txType = "TrustSet",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[1],
+                 .batchID = batchID},
             };
             validateClosedLedger(env, testCases);
         }
@@ -4207,8 +4849,16 @@ class Batch_test : public beast::unit_test::Suite
             env.close();
 
             std::vector const testCases = {
-                {0, "Batch", "tesSUCCESS", batchID, std::nullopt},
-                {1, "TrustSet", "tesSUCCESS", txIDs[0], batchID},
+                {.index = 0,
+                 .txType = "Batch",
+                 .result = "tesSUCCESS",
+                 .txHash = batchID,
+                 .batchID = std::nullopt},
+                {.index = 1,
+                 .txType = "TrustSet",
+                 .result = "tesSUCCESS",
+                 .txHash = txIDs[0],
+                 .batchID = batchID},
                 // jv2 fails with terNO_DELEGATE_PERMISSION.
             };
             validateClosedLedger(env, testCases);
diff --git a/src/test/app/CheckMPT_test.cpp b/src/test/app/CheckMPT_test.cpp
index a7fef5043c..861a115fc9 100644
--- a/src/test/app/CheckMPT_test.cpp
+++ b/src/test/app/CheckMPT_test.cpp
@@ -42,7 +42,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -54,11 +53,11 @@ namespace xrpl {
 class CheckMPT_test : public beast::unit_test::Suite
 {
     // Helper function that returns the Checks on an account.
-    static std::vector>
+    static std::vector
     checksOnAccount(test::jtx::Env& env, test::jtx::Account account)
     {
-        std::vector> result;
-        forEachItem(*env.current(), account, [&result](std::shared_ptr const& sle) {
+        std::vector result;
+        forEachItem(*env.current(), account, [&result](SLE::const_ref sle) {
             if (sle && sle->getType() == ltCHECK)
                 result.push_back(sle);
         });
@@ -1860,10 +1859,10 @@ class CheckMPT_test : public beast::unit_test::Suite
             // Use offers to automatically create MPT.
             MPT const oF4 = gw1["OF4"];
             gw1.set(oF4, tfMPTLock);
-            env(offer(gw1, XRP(92), oF4(92)), Ter(tecFROZEN));
+            env(offer(gw1, XRP(92), oF4(92)), Ter(tecLOCKED));
             env.close();
             BEAST_EXPECT(env.le(keylet::mptoken(oF4, alice)) == nullptr);
-            env(offer(alice, oF4(92), XRP(92)), Ter(tecFROZEN));
+            env(offer(alice, oF4(92), XRP(92)), Ter(tecLOCKED));
             env.close();
 
             // No one's owner count should have changed.
@@ -1951,10 +1950,10 @@ class CheckMPT_test : public beast::unit_test::Suite
             // Use offers to automatically create MPT.
             MPT const oF4 = gw1["OF4"];
             gw1.set(oF4, tfMPTLock);
-            env(offer(alice, XRP(91), oF4(91)), Ter(tecFROZEN));
+            env(offer(alice, XRP(91), oF4(91)), Ter(tecLOCKED));
             env.close();
             BEAST_EXPECT(env.le(keylet::mptoken(oF4, alice)) == nullptr);
-            env(offer(bob, oF4(91), XRP(91)), Ter(tecFROZEN));
+            env(offer(bob, oF4(91), XRP(91)), Ter(tecLOCKED));
             env.close();
 
             // No one's owner count should have changed.
diff --git a/src/test/app/Check_test.cpp b/src/test/app/Check_test.cpp
index 1d5861136c..3ad1e3fa4e 100644
--- a/src/test/app/Check_test.cpp
+++ b/src/test/app/Check_test.cpp
@@ -43,7 +43,6 @@
 
 #include 
 #include 
-#include 
 #include 
 #include 
 
@@ -58,11 +57,11 @@ class Check_test : public beast::unit_test::Suite
     }
 
     // Helper function that returns the Checks on an account.
-    static std::vector>
+    static std::vector
     checksOnAccount(test::jtx::Env& env, test::jtx::Account account)
     {
-        std::vector> result;
-        forEachItem(*env.current(), account, [&result](std::shared_ptr const& sle) {
+        std::vector result;
+        forEachItem(*env.current(), account, [&result](SLE::const_ref sle) {
             if (sle && sle->getType() == ltCHECK)
                 result.push_back(sle);
         });
@@ -1952,8 +1951,8 @@ class Check_test : public beast::unit_test::Suite
                                  Account const& acct2,
                                  IOU const& offerIou,
                                  IOU const& checkIou) {
-            auto const offerLine = env.le(keylet::line(acct1, acct2, offerIou.currency));
-            auto const checkLine = env.le(keylet::line(acct1, acct2, checkIou.currency));
+            auto const offerLine = env.le(keylet::trustLine(acct1, acct2, offerIou.currency));
+            auto const checkLine = env.le(keylet::trustLine(acct1, acct2, checkIou.currency));
             if (offerLine == nullptr || checkLine == nullptr)
             {
                 BEAST_EXPECT(offerLine == nullptr && checkLine == nullptr);
@@ -2024,7 +2023,7 @@ class Check_test : public beast::unit_test::Suite
             IOU const oF1 = gw1["OF1"];
             env(offer(gw1, XRP(98), oF1(98)));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(gw1, alice, oF1.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw1, alice, oF1.currency)) == nullptr);
             env(offer(alice, oF1(98), XRP(98)));
             ++alice.owners;
             env.close();
@@ -2042,7 +2041,7 @@ class Check_test : public beast::unit_test::Suite
             uint256 const chkId{getCheckIndex(gw1, env.seq(gw1))};
             env(check::create(gw1, alice, cK1(98)));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(gw1, alice, cK1.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw1, alice, cK1.currency)) == nullptr);
             env(check::cash(alice, chkId, cK1(98)));
             ++alice.owners;
             verifyDeliveredAmount(env, cK1(98));
@@ -2071,7 +2070,7 @@ class Check_test : public beast::unit_test::Suite
             IOU const oF1 = gw1["OF1"];
             env(offer(alice, XRP(97), oF1(97)));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(alice, bob, oF1.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(alice, bob, oF1.currency)) == nullptr);
             env(offer(bob, oF1(97), XRP(97)));
             ++bob.owners;
             env.close();
@@ -2095,12 +2094,12 @@ class Check_test : public beast::unit_test::Suite
             uint256 const chkId{getCheckIndex(alice, env.seq(alice))};
             env(check::create(alice, bob, cK1(97)));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(alice, bob, cK1.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(alice, bob, cK1.currency)) == nullptr);
             env(check::cash(bob, chkId, cK1(97)), Ter(terNO_RIPPLE));
             env.close();
 
-            BEAST_EXPECT(env.le(keylet::line(gw1, bob, oF1.currency)) != nullptr);
-            BEAST_EXPECT(env.le(keylet::line(gw1, bob, cK1.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw1, bob, oF1.currency)) != nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw1, bob, cK1.currency)) == nullptr);
 
             // Delete alice's check since it is no longer needed.
             env(check::cancel(alice, chkId));
@@ -2124,7 +2123,7 @@ class Check_test : public beast::unit_test::Suite
             IOU const oF2 = gw1["OF2"];
             env(offer(gw1, XRP(96), oF2(96)));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(gw1, alice, oF2.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw1, alice, oF2.currency)) == nullptr);
             env(offer(alice, oF2(96), XRP(96)));
             ++alice.owners;
             env.close();
@@ -2142,7 +2141,7 @@ class Check_test : public beast::unit_test::Suite
             uint256 const chkId{getCheckIndex(gw1, env.seq(gw1))};
             env(check::create(gw1, alice, cK2(96)));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(gw1, alice, cK2.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw1, alice, cK2.currency)) == nullptr);
             env(check::cash(alice, chkId, cK2(96)));
             ++alice.owners;
             verifyDeliveredAmount(env, cK2(96));
@@ -2168,7 +2167,7 @@ class Check_test : public beast::unit_test::Suite
             IOU const oF2 = gw1["OF2"];
             env(offer(alice, XRP(95), oF2(95)));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(alice, bob, oF2.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(alice, bob, oF2.currency)) == nullptr);
             env(offer(bob, oF2(95), XRP(95)));
             ++bob.owners;
             env.close();
@@ -2183,7 +2182,7 @@ class Check_test : public beast::unit_test::Suite
             uint256 const chkId{getCheckIndex(alice, env.seq(alice))};
             env(check::create(alice, bob, cK2(95)));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(alice, bob, cK2.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(alice, bob, cK2.currency)) == nullptr);
             env(check::cash(bob, chkId, cK2(95)));
             ++bob.owners;
             verifyDeliveredAmount(env, cK2(95));
@@ -2215,7 +2214,7 @@ class Check_test : public beast::unit_test::Suite
             IOU const oF3 = gw1["OF3"];
             env(offer(gw1, XRP(94), oF3(94)));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(gw1, alice, oF3.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw1, alice, oF3.currency)) == nullptr);
             env(offer(alice, oF3(94), XRP(94)));
             ++alice.owners;
             env.close();
@@ -2233,7 +2232,7 @@ class Check_test : public beast::unit_test::Suite
             uint256 const chkId{getCheckIndex(gw1, env.seq(gw1))};
             env(check::create(gw1, alice, cK3(94)));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(gw1, alice, cK3.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw1, alice, cK3.currency)) == nullptr);
             env(check::cash(alice, chkId, cK3(94)));
             ++alice.owners;
             verifyDeliveredAmount(env, cK3(94));
@@ -2259,7 +2258,7 @@ class Check_test : public beast::unit_test::Suite
             IOU const oF3 = gw1["OF3"];
             env(offer(alice, XRP(93), oF3(93)));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(alice, bob, oF3.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(alice, bob, oF3.currency)) == nullptr);
             env(offer(bob, oF3(93), XRP(93)));
             ++bob.owners;
             env.close();
@@ -2274,7 +2273,7 @@ class Check_test : public beast::unit_test::Suite
             uint256 const chkId{getCheckIndex(alice, env.seq(alice))};
             env(check::create(alice, bob, cK3(93)));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(alice, bob, cK3.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(alice, bob, cK3.currency)) == nullptr);
             env(check::cash(bob, chkId, cK3(93)));
             ++bob.owners;
             verifyDeliveredAmount(env, cK3(93));
@@ -2300,7 +2299,7 @@ class Check_test : public beast::unit_test::Suite
             IOU const oF4 = gw1["OF4"];
             env(offer(gw1, XRP(92), oF4(92)), Ter(tecFROZEN));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(gw1, alice, oF4.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw1, alice, oF4.currency)) == nullptr);
             env(offer(alice, oF4(92), XRP(92)), Ter(tecFROZEN));
             env.close();
 
@@ -2314,7 +2313,7 @@ class Check_test : public beast::unit_test::Suite
             uint256 const chkId{getCheckIndex(gw1, env.seq(gw1))};
             env(check::create(gw1, alice, cK4(92)), Ter(tecFROZEN));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(gw1, alice, cK4.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw1, alice, cK4.currency)) == nullptr);
             env(check::cash(alice, chkId, cK4(92)), Ter(tecNO_ENTRY));
             env.close();
 
@@ -2325,8 +2324,8 @@ class Check_test : public beast::unit_test::Suite
 
             // Because gw1 has set lsfGlobalFreeze, neither trust line
             // is created.
-            BEAST_EXPECT(env.le(keylet::line(gw1, alice, oF4.currency)) == nullptr);
-            BEAST_EXPECT(env.le(keylet::line(gw1, alice, cK4.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw1, alice, oF4.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw1, alice, cK4.currency)) == nullptr);
         }
         //------------ lsfGlobalFreeze, check written by non-issuer ------------
         {
@@ -2338,7 +2337,7 @@ class Check_test : public beast::unit_test::Suite
             IOU const oF4 = gw1["OF4"];
             env(offer(alice, XRP(91), oF4(91)), Ter(tecFROZEN));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(alice, bob, oF4.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(alice, bob, oF4.currency)) == nullptr);
             env(offer(bob, oF4(91), XRP(91)), Ter(tecFROZEN));
             env.close();
 
@@ -2352,7 +2351,7 @@ class Check_test : public beast::unit_test::Suite
             uint256 const chkId{getCheckIndex(alice, env.seq(alice))};
             env(check::create(alice, bob, cK4(91)), Ter(tecFROZEN));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(alice, bob, cK4.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(alice, bob, cK4.currency)) == nullptr);
             env(check::cash(bob, chkId, cK4(91)), Ter(tecNO_ENTRY));
             env.close();
 
@@ -2363,8 +2362,8 @@ class Check_test : public beast::unit_test::Suite
 
             // Because gw1 has set lsfGlobalFreeze, neither trust line
             // is created.
-            BEAST_EXPECT(env.le(keylet::line(gw1, bob, oF4.currency)) == nullptr);
-            BEAST_EXPECT(env.le(keylet::line(gw1, bob, cK4.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw1, bob, oF4.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw1, bob, cK4.currency)) == nullptr);
         }
 
         //-------------- lsfRequireAuth, check written by issuer ---------------
@@ -2388,7 +2387,7 @@ class Check_test : public beast::unit_test::Suite
             env(offer(gw2, XRP(92), oF5(92)));
             ++gw2.owners;
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(gw2, alice, oF5.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw2, alice, oF5.currency)) == nullptr);
             env(offer(alice, oF5(92), XRP(92)), Ter(tecNO_LINE));
             env.close();
 
@@ -2410,7 +2409,7 @@ class Check_test : public beast::unit_test::Suite
             env(check::create(gw2, alice, cK5(92)));
             ++gw2.owners;
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(gw2, alice, cK5.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw2, alice, cK5.currency)) == nullptr);
             env(check::cash(alice, chkId, cK5(92)), Ter(tecNO_AUTH));
             env.close();
 
@@ -2422,8 +2421,8 @@ class Check_test : public beast::unit_test::Suite
 
             // Because gw2 has set lsfRequireAuth, neither trust line
             // is created.
-            BEAST_EXPECT(env.le(keylet::line(gw2, alice, oF5.currency)) == nullptr);
-            BEAST_EXPECT(env.le(keylet::line(gw2, alice, cK5.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw2, alice, oF5.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw2, alice, cK5.currency)) == nullptr);
 
             // Since we don't need it any more, remove gw2's check.
             env(check::cancel(gw2, chkId));
@@ -2442,7 +2441,7 @@ class Check_test : public beast::unit_test::Suite
             env(offer(alice, XRP(91), oF5(91)), Ter(tecUNFUNDED_OFFER));
             env.close();
             env(offer(bob, oF5(91), XRP(91)), Ter(tecNO_LINE));
-            BEAST_EXPECT(env.le(keylet::line(gw2, bob, oF5.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw2, bob, oF5.currency)) == nullptr);
             env.close();
 
             gw2.verifyOwners(__LINE__);
@@ -2454,7 +2453,7 @@ class Check_test : public beast::unit_test::Suite
             uint256 const chkId{getCheckIndex(alice, env.seq(alice))};
             env(check::create(alice, bob, cK5(91)));
             env.close();
-            BEAST_EXPECT(env.le(keylet::line(alice, bob, cK5.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(alice, bob, cK5.currency)) == nullptr);
             env(check::cash(bob, chkId, cK5(91)), Ter(tecPATH_PARTIAL));
             env.close();
 
@@ -2469,8 +2468,8 @@ class Check_test : public beast::unit_test::Suite
 
             // Because gw2 has set lsfRequireAuth, neither trust line
             // is created.
-            BEAST_EXPECT(env.le(keylet::line(gw2, bob, oF5.currency)) == nullptr);
-            BEAST_EXPECT(env.le(keylet::line(gw2, bob, cK5.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw2, bob, oF5.currency)) == nullptr);
+            BEAST_EXPECT(env.le(keylet::trustLine(gw2, bob, cK5.currency)) == nullptr);
         }
     }
 
diff --git a/src/test/app/ClawbackMPT_test.cpp b/src/test/app/ClawbackMPT_test.cpp
new file mode 100644
index 0000000000..299e63d028
--- /dev/null
+++ b/src/test/app/ClawbackMPT_test.cpp
@@ -0,0 +1,435 @@
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+namespace xrpl {
+
+class ClawbackMPT_test : public beast::unit_test::Suite
+{
+    static std::uint32_t
+    ticketCount(test::jtx::Env const& env, test::jtx::Account const& acct)
+    {
+        std::uint32_t ret{0};
+        if (auto const sleAcct = env.le(acct))
+            ret = sleAcct->at(~sfTicketCount).value_or(0);
+        return ret;
+    }
+
+    void
+    testValidation(FeatureBitset features)
+    {
+        testcase("Validation");
+        using namespace test::jtx;
+
+        // MPT clawback fails when featureMPTokensV1 is disabled
+        {
+            Env env(*this, features - featureMPTokensV1);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(1000), alice, bob);
+            env.close();
+
+            auto const mpt = xrpl::test::jtx::MPT(alice.name(), makeMptID(env.seq(alice), alice));
+
+            env(claw(alice, mpt(5), bob), Ter(temDISABLED));
+            env.close();
+        }
+
+        // MPT clawback fails when tfMPTCanClawback is not set on the issuance
+        {
+            Env env(*this, features);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+            mptAlice.create({.ownerCount = 1, .holderCount = 0});
+            mptAlice.authorize({.account = bob});
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.claw(alice, bob, 10, tecNO_PERMISSION);
+        }
+
+        // Test preflight validation failures
+        {
+            Env env(*this, features);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(1000), alice, bob);
+            env.close();
+
+            auto const mpt = xrpl::test::jtx::MPT(alice.name(), makeMptID(env.seq(alice), alice));
+
+            // fails due to invalid flag
+            env(claw(alice, mpt(5), bob), Txflags(0x00008000), Ter(temINVALID_FLAG));
+            env.close();
+
+            // fails due to zero amount
+            env(claw(alice, mpt(0), bob), Ter(temBAD_AMOUNT));
+            env.close();
+
+            // fails due to negative amount
+            env(claw(alice, mpt(-1), bob), Ter(temBAD_AMOUNT));
+            env.close();
+
+            // fails when holder is not specified
+            env(claw(alice, mpt(5)), Ter(temMALFORMED));
+            env.close();
+
+            // fails when issuer and holder are the same account
+            env(claw(alice, mpt(5), alice), Ter(temMALFORMED));
+            env.close();
+        }
+
+        // Test preclaim failures
+        {
+            Env env(*this, features);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            auto const fakeMpt =
+                xrpl::test::jtx::MPT(alice.name(), makeMptID(env.seq(alice), alice));
+
+            // clawback fails when the issuance does not exist
+            env(claw(alice, fakeMpt(5), bob), Ter(tecOBJECT_NOT_FOUND));
+            env.close();
+
+            mptAlice.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanClawback});
+
+            // clawback fails when bob has no MPToken
+            mptAlice.claw(alice, bob, 5, tecOBJECT_NOT_FOUND);
+
+            mptAlice.authorize({.account = bob});
+
+            // clawback fails because bob's balance is 0
+            mptAlice.claw(alice, bob, 5, tecINSUFFICIENT_FUNDS);
+        }
+    }
+
+    void
+    testPermission(FeatureBitset features)
+    {
+        testcase("Permission");
+        using namespace test::jtx;
+
+        // Clawing back from a non-existent account fails
+        {
+            Env env(*this, features);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            // bob is not funded and does not exist
+            MPTTester mptAlice(env, alice, MPTInit{});
+            mptAlice.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanClawback});
+
+            mptAlice.claw(alice, bob, 5, terNO_ACCOUNT);
+        }
+
+        // A non-issuer cannot claw back MPT
+        {
+            Env env(*this, features);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const cindy{"cindy"};
+
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+            env.fund(XRP(1000), cindy);
+            env.close();
+
+            mptAlice.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanClawback});
+            mptAlice.authorize({.account = bob});
+            mptAlice.pay(alice, bob, 1000);
+
+            // cindy fails to claw because she is not the issuer
+            mptAlice.claw(cindy, bob, 200, tecNO_PERMISSION);
+        }
+    }
+
+    void
+    testEnabled(FeatureBitset features)
+    {
+        testcase("Enable clawback");
+        using namespace test::jtx;
+
+        // Test that alice is able to successfully clawback MPT from bob
+        Env env(*this, features);
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+        mptAlice.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanClawback});
+        mptAlice.authorize({.account = bob});
+        mptAlice.pay(alice, bob, 1000);
+
+        BEAST_EXPECT(mptAlice.checkMPTokenAmount(bob, 1000));
+
+        // alice claws back 200 tokens from bob
+        mptAlice.claw(alice, bob, 200);
+        BEAST_EXPECT(mptAlice.checkMPTokenAmount(bob, 800));
+
+        // alice claws back remaining 800 tokens
+        mptAlice.claw(alice, bob, 800);
+        BEAST_EXPECT(mptAlice.checkMPTokenAmount(bob, 0));
+    }
+
+    void
+    testMultiIssuance(FeatureBitset features)
+    {
+        testcase("Multi issuance");
+        using namespace test::jtx;
+
+        // Two issuers each issue their own MPT to cindy.
+        // Clawback from one does not affect the other.
+        {
+            Env env(*this, features);
+
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const cindy{"cindy"};
+
+            MPTTester mptAlice(env, alice, {.holders = {cindy}});
+            mptAlice.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanClawback});
+            mptAlice.authorize({.account = cindy});
+            mptAlice.pay(alice, cindy, 1000);
+
+            MPTTester mptBob(env, bob, MPTInit{});  // cindy already funded by mptAlice
+            mptBob.create({.ownerCount = 1, .flags = tfMPTCanClawback});
+            mptBob.authorize({.account = cindy});
+            mptBob.pay(bob, cindy, 1000);
+
+            BEAST_EXPECT(mptAlice.checkMPTokenAmount(cindy, 1000));
+            BEAST_EXPECT(mptBob.checkMPTokenAmount(cindy, 1000));
+
+            // alice claws back 200 from cindy, bob's issuance is unaffected
+            mptAlice.claw(alice, cindy, 200);
+            BEAST_EXPECT(mptAlice.checkMPTokenAmount(cindy, 800));
+            BEAST_EXPECT(mptBob.checkMPTokenAmount(cindy, 1000));
+
+            // bob claws back 600 from cindy, alice's issuance is unaffected
+            mptBob.claw(bob, cindy, 600);
+            BEAST_EXPECT(mptBob.checkMPTokenAmount(cindy, 400));
+            BEAST_EXPECT(mptAlice.checkMPTokenAmount(cindy, 800));
+        }
+
+        // One issuer issues MPT to two different holders.
+        // Clawback from one holder does not affect the other.
+        {
+            Env env(*this, features);
+
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const cindy{"cindy"};
+
+            MPTTester mptAlice(env, alice, {.holders = {bob, cindy}});
+            mptAlice.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanClawback});
+            mptAlice.authorize({.account = bob});
+            mptAlice.authorize({.account = cindy});
+            mptAlice.pay(alice, bob, 600);
+            mptAlice.pay(alice, cindy, 1000);
+
+            BEAST_EXPECT(mptAlice.checkMPTokenAmount(bob, 600));
+            BEAST_EXPECT(mptAlice.checkMPTokenAmount(cindy, 1000));
+
+            // alice claws back 500 from bob, cindy's balance is unchanged
+            mptAlice.claw(alice, bob, 500);
+            BEAST_EXPECT(mptAlice.checkMPTokenAmount(bob, 100));
+            BEAST_EXPECT(mptAlice.checkMPTokenAmount(cindy, 1000));
+
+            // alice claws back 300 from cindy, bob's balance is unchanged
+            mptAlice.claw(alice, cindy, 300);
+            BEAST_EXPECT(mptAlice.checkMPTokenAmount(cindy, 700));
+            BEAST_EXPECT(mptAlice.checkMPTokenAmount(bob, 100));
+        }
+    }
+
+    void
+    testZeroBalanceAfterClawback(FeatureBitset features)
+    {
+        testcase("Zero balance after clawback");
+        using namespace test::jtx;
+
+        // After clawback reduces balance to zero, the MPToken object
+        // still exists (unlike IOU trustlines which are deleted).
+        Env env(*this, features);
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+        mptAlice.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanClawback});
+        mptAlice.authorize({.account = bob});
+        mptAlice.pay(alice, bob, 1000);
+
+        BEAST_EXPECT(ownerCount(env, bob) == 1);
+        BEAST_EXPECT(mptAlice.checkMPTokenAmount(bob, 1000));
+
+        // alice claws back the full amount
+        mptAlice.claw(alice, bob, 1000);
+        BEAST_EXPECT(mptAlice.checkMPTokenAmount(bob, 0));
+
+        // bob still holds the MPToken object (balance 0, not deleted)
+        BEAST_EXPECT(ownerCount(env, bob) == 1);
+    }
+
+    void
+    testLockedMPT(FeatureBitset features)
+    {
+        testcase("Locked MPT");
+        using namespace test::jtx;
+
+        // Test that globally locked MPT can still be clawed back
+        {
+            Env env(*this, features);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+            mptAlice.create(
+                {.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanLock | tfMPTCanClawback});
+            mptAlice.authorize({.account = bob});
+            mptAlice.pay(alice, bob, 1000);
+
+            // globally lock the issuance
+            mptAlice.set({.account = alice, .flags = tfMPTLock});
+
+            // clawback succeeds despite global lock
+            mptAlice.claw(alice, bob, 200);
+            BEAST_EXPECT(mptAlice.checkMPTokenAmount(bob, 800));
+        }
+
+        // Test that individually locked MPT can still be clawed back
+        {
+            Env env(*this, features);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+            mptAlice.create(
+                {.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanLock | tfMPTCanClawback});
+            mptAlice.authorize({.account = bob});
+            mptAlice.pay(alice, bob, 1000);
+
+            // individually lock bob's MPToken
+            mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock});
+
+            // clawback succeeds despite individual lock
+            mptAlice.claw(alice, bob, 200);
+            BEAST_EXPECT(mptAlice.checkMPTokenAmount(bob, 800));
+        }
+    }
+
+    void
+    testAmountExceedsAvailable(FeatureBitset features)
+    {
+        testcase("Amount exceeds available");
+        using namespace test::jtx;
+
+        // When alice tries to claw back more than bob holds,
+        // only the available balance is clawed back
+        Env env(*this, features);
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+        mptAlice.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanClawback});
+        mptAlice.authorize({.account = bob});
+        mptAlice.pay(alice, bob, 1000);
+
+        BEAST_EXPECT(mptAlice.checkMPTokenAmount(bob, 1000));
+
+        // alice tries to claw back 2000, but bob only has 1000
+        mptAlice.claw(alice, bob, 2000);
+        BEAST_EXPECT(mptAlice.checkMPTokenAmount(bob, 0));
+        BEAST_EXPECT(mptAlice.checkMPTokenOutstandingAmount(0));
+
+        // MPToken object still exists with 0 balance
+        BEAST_EXPECT(ownerCount(env, bob) == 1);
+    }
+
+    void
+    testTickets(FeatureBitset features)
+    {
+        testcase("Tickets");
+        using namespace test::jtx;
+
+        // Tests MPT clawback using tickets
+        Env env(*this, features);
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+        mptAlice.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanClawback});
+        mptAlice.authorize({.account = bob});
+        mptAlice.pay(alice, bob, 100);
+
+        BEAST_EXPECT(mptAlice.checkMPTokenAmount(bob, 100));
+
+        // alice creates 10 tickets
+        std::uint32_t ticketCnt = 10;
+        std::uint32_t aliceTicketSeq{env.seq(alice) + 1};
+        env(ticket::create(alice, ticketCnt));
+        env.close();
+        std::uint32_t const aliceSeq{env.seq(alice)};
+        BEAST_EXPECT(ticketCount(env, alice) == ticketCnt);
+        BEAST_EXPECT(ownerCount(env, alice) == ticketCnt + 1);  // tickets + issuance
+
+        while (ticketCnt > 0)
+        {
+            // alice claws back 5 tokens using a ticket
+            env(claw(alice, mptAlice.mpt(5), bob), ticket::Use(aliceTicketSeq++));
+            env.close();
+
+            ticketCnt--;
+            BEAST_EXPECT(ticketCount(env, alice) == ticketCnt);
+        }
+
+        // alice clawed back 50 tokens total, 50 remain
+        BEAST_EXPECT(mptAlice.checkMPTokenAmount(bob, 50));
+
+        // account sequence numbers did not advance
+        BEAST_EXPECT(env.seq(alice) == aliceSeq);
+    }
+
+    void
+    testWithFeats(FeatureBitset features)
+    {
+        testValidation(features);
+        testPermission(features);
+        testEnabled(features);
+        testMultiIssuance(features);
+        testZeroBalanceAfterClawback(features);
+        testLockedMPT(features);
+        testAmountExceedsAvailable(features);
+        testTickets(features);
+    }
+
+public:
+    void
+    run() override
+    {
+        using namespace test::jtx;
+        FeatureBitset const all{testableAmendments()};
+        testWithFeats(all);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(ClawbackMPT, app, xrpl);
+}  // namespace xrpl
diff --git a/src/test/app/Clawback_test.cpp b/src/test/app/Clawback_test.cpp
index 5d0ecf022d..2dadd9f503 100644
--- a/src/test/app/Clawback_test.cpp
+++ b/src/test/app/Clawback_test.cpp
@@ -53,7 +53,7 @@ class Clawback_test : public beast::unit_test::Suite
         test::jtx::Account const& dst,
         Currency const& cur)
     {
-        if (auto sle = env.le(keylet::line(src, dst, cur)))
+        if (auto sle = env.le(keylet::trustLine(src, dst, cur)))
         {
             auto const useHigh = src.id() > dst.id();
             return sle->isFlag(useHigh ? lsfHighFreeze : lsfLowFreeze);
@@ -161,35 +161,6 @@ class Clawback_test : public beast::unit_test::Suite
             BEAST_EXPECT(ownerCount(env, alice) == 0);
             BEAST_EXPECT(ownerCount(env, bob) == 0);
         }
-
-        // Test that one cannot enable asfAllowTrustLineClawback when
-        // featureClawback amendment is disabled
-        {
-            Env env(*this, features - featureClawback);
-
-            Account const alice{"alice"};
-
-            env.fund(XRP(1000), alice);
-            env.close();
-
-            env.require(Nflags(alice, asfAllowTrustLineClawback));
-
-            // alice attempts to set asfAllowTrustLineClawback flag while
-            // amendment is disabled. no error is returned, but the flag remains
-            // to be unset.
-            env(fset(alice, asfAllowTrustLineClawback));
-            env.close();
-            env.require(Nflags(alice, asfAllowTrustLineClawback));
-
-            // now enable clawback amendment
-            env.enableFeature(featureClawback);
-            env.close();
-
-            // asfAllowTrustLineClawback can be set
-            env(fset(alice, asfAllowTrustLineClawback));
-            env.close();
-            env.require(Flags(alice, asfAllowTrustLineClawback));
-        }
     }
 
     void
@@ -198,46 +169,6 @@ class Clawback_test : public beast::unit_test::Suite
         testcase("Validation");
         using namespace test::jtx;
 
-        // Test that Clawback tx fails for the following:
-        // 1. when amendment is disabled
-        // 2. when asfAllowTrustLineClawback flag has not been set
-        {
-            Env env(*this, features - featureClawback);
-
-            Account const alice{"alice"};
-            Account const bob{"bob"};
-
-            env.fund(XRP(1000), alice, bob);
-            env.close();
-
-            env.require(Nflags(alice, asfAllowTrustLineClawback));
-
-            auto const usd = alice["USD"];
-
-            // alice issues 10 USD to bob
-            env.trust(usd(1000), bob);
-            env(pay(alice, bob, usd(10)));
-            env.close();
-
-            env.require(Balance(bob, alice["USD"](10)));
-            env.require(Balance(alice, bob["USD"](-10)));
-
-            // clawback fails because amendment is disabled
-            env(claw(alice, bob["USD"](5)), Ter(temDISABLED));
-            env.close();
-
-            // now enable clawback amendment
-            env.enableFeature(featureClawback);
-            env.close();
-
-            // clawback fails because asfAllowTrustLineClawback has not been set
-            env(claw(alice, bob["USD"](5)), Ter(tecNO_PERMISSION));
-            env.close();
-
-            env.require(Balance(bob, alice["USD"](10)));
-            env.require(Balance(alice, bob["USD"](-10)));
-        }
-
         // Test that Clawback tx fails for the following:
         // 1. invalid flag
         // 2. negative STAmount
diff --git a/src/test/app/ConfidentialTransferExtended_test.cpp b/src/test/app/ConfidentialTransferExtended_test.cpp
new file mode 100644
index 0000000000..0aee7516c9
--- /dev/null
+++ b/src/test/app/ConfidentialTransferExtended_test.cpp
@@ -0,0 +1,2595 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
+{
+    void
+    testSendDepositPreauth(FeatureBitset features)
+    {
+        testcase("Send deposit preauth");
+        using namespace test::jtx;
+
+        // When an account enables lsfDepositAuth (via asfDepositAuth flag),
+        // it requires explicit authorization before accepting incoming payments.
+        //
+        // There are two authorization mechanisms:
+        //
+        // 1. DIRECT ACCOUNT AUTHORIZATION (deposit::auth)
+        //    - Bob directly authorizes Carol: deposit::auth(bob, carol)
+        //    - Simple 1-to-1 trust relationship
+        //    - Carol can send to Bob without credentials
+        //
+        // 2. CREDENTIAL-BASED AUTHORIZATION (deposit::authCredentials)
+        //    - A trusted third party (dpIssuer) issues credentials
+        //    - Bob authorizes a credential TYPE from an issuer
+        //    - Anyone holding that credential can send to Bob
+        //    - Requires sender to include credential ID in transaction
+
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const dpIssuer("dpIssuer");
+        char const credType[] = "KYC_VERIFIED";
+
+        // Create and accept credential for an account
+        auto createCredential = [&](Env& env, Account const& subject) -> std::string {
+            env(credentials::create(subject, dpIssuer, credType));
+            env.close();
+            env(credentials::accept(subject, dpIssuer, credType));
+            env.close();
+            auto const jv = credentials::ledgerEntry(env, subject, dpIssuer, credType);
+            return jv[jss::result][jss::index].asString();
+        };
+
+        // TEST 1: Direct Account Authorization
+        {
+            Env env(*this, features);
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50},
+                 {.account = carol, .payAmount = 100, .convertAmount = 50}}};
+            auto& mpt = confEnv.mpt;
+            env(fset(bob, asfDepositAuth));
+            env.close();
+
+            // Carol cannot send to Bob without authorization
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .err = tecNO_PERMISSION,
+            });
+
+            // Bob directly authorizes Carol
+            env(deposit::auth(bob, carol));
+            env.close();
+
+            // Now Carol can send to Bob
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+            });
+            mpt.mergeInbox({
+                .account = bob,
+            });
+
+            // Bob revokes Carol's authorization
+            env(deposit::unauth(bob, carol));
+            env.close();
+
+            // Carol can no longer send to Bob
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // TEST 2: Credential-Based Authorization
+        {
+            Env env(*this, features);
+            env.fund(XRP(50000), dpIssuer);
+            env.close();
+
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50},
+                 {.account = carol, .payAmount = 100, .convertAmount = 50}}};
+            auto& mpt = confEnv.mpt;
+            env(fset(bob, asfDepositAuth));
+            env.close();
+
+            auto const credIdx = createCredential(env, carol);
+
+            // Carol cannot send yet - Bob hasn't authorized this credential type
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .credentials = {{credIdx}},
+                .err = tecNO_PERMISSION,
+            });
+
+            // Bob authorizes the credential type from dpIssuer
+            env(deposit::authCredentials(bob, {{.issuer = dpIssuer, .credType = credType}}));
+            env.close();
+
+            // Carol still cannot send without including credential
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .err = tecNO_PERMISSION,
+            });
+
+            // Carol CAN send when including her credential
+            mpt.send({.account = carol, .dest = bob, .amt = 10, .credentials = {{credIdx}}});
+            mpt.mergeInbox({
+                .account = bob,
+            });
+        }
+
+        // TEST 3: Direct Auth Takes Precedence Over Credentials
+        {
+            Env env(*this, features);
+            env.fund(XRP(50000), dpIssuer);
+            env.close();
+
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50},
+                 {.account = carol, .payAmount = 100, .convertAmount = 50}}};
+            auto& mpt = confEnv.mpt;
+            env(fset(bob, asfDepositAuth));
+            env.close();
+
+            auto const credIdx = createCredential(env, carol);
+
+            // Bob directly authorizes Carol (no credential needed)
+            env(deposit::auth(bob, carol));
+            env.close();
+
+            // Carol can send without credentials (direct auth)
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+            });
+            mpt.mergeInbox({
+                .account = bob,
+            });
+
+            // Carol can also send WITH credentials (still works)
+            mpt.send({.account = carol, .dest = bob, .amt = 10, .credentials = {{credIdx}}});
+            mpt.mergeInbox({
+                .account = bob,
+            });
+
+            // Bob revokes direct authorization
+            env(deposit::unauth(bob, carol));
+            env.close();
+
+            // Carol cannot send without credentials anymore
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .err = tecNO_PERMISSION,
+            });
+
+            // But credential-based auth not set up, so this also fails
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .credentials = {{credIdx}},
+                .err = tecNO_PERMISSION,
+            });
+
+            // Bob authorizes the credential type
+            env(deposit::authCredentials(bob, {{.issuer = dpIssuer, .credType = credType}}));
+            env.close();
+
+            // Now Carol can send with credentials
+            mpt.send({.account = carol, .dest = bob, .amt = 10, .credentials = {{credIdx}}});
+        }
+
+        auto const expireTime = 30;
+
+        // Lambda function that returns the credential index after creating a
+        // credential that expires shortly after the current ledger time.
+        auto createExpiringCredential = [&](Env& env, Account const& subject) -> std::string {
+            auto jv = credentials::create(subject, dpIssuer, credType);
+            auto const expiry =
+                env.current()->header().parentCloseTime.time_since_epoch().count() + expireTime;
+            jv[sfExpiration.jsonName] = expiry;
+            env(jv);
+            env.close();
+            env(credentials::accept(subject, dpIssuer, credType));
+            env.close();
+            auto const credentials = credentials::ledgerEntry(env, subject, dpIssuer, credType);
+            return credentials[jss::result][jss::index].asString();
+        };
+
+        auto credentialDeleted = [&](Env& env, Account const& subject) -> bool {
+            auto const credentials = credentials::ledgerEntry(env, subject, dpIssuer, credType);
+            return credentials[jss::result].isMember(jss::error) &&
+                credentials[jss::result][jss::error] == "entryNotFound";
+        };
+
+        // TEST 4: Expired credential with matching depositPreauth entry.
+        // checkDepositPreauth in preclaim returns tesSUCCESS (the expired
+        // credential still exists and matches the depositPreauth key), so ZK
+        // proofs run. cleanupExpiredCredentials in doApply then removes the
+        // expired credential and returns tecEXPIRED.
+        {
+            Env env(*this, features);
+            env.fund(XRP(50000), dpIssuer);
+            env.close();
+
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50},
+                 {.account = carol, .payAmount = 100, .convertAmount = 50}}};
+            auto& mpt = confEnv.mpt;
+            env(fset(bob, asfDepositAuth));
+            env.close();
+
+            auto const credIdx = createExpiringCredential(env, carol);
+
+            // Bob authorizes carol's credential type
+            env(deposit::authCredentials(bob, {{.issuer = dpIssuer, .credType = credType}}));
+            env.close();
+
+            // Advance ledger past credential expiration
+            env.close(std::chrono::seconds(expireTime));
+
+            // Send fails with tecEXPIRED; the expired credential is cleaned up
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .credentials = {{credIdx}},
+                .err = tecEXPIRED,
+            });
+            env.close();
+
+            BEAST_EXPECT(credentialDeleted(env, carol));
+        }
+
+        // TEST 5: Expired credential, destination has no depositAuth.
+        // checkDepositPreauth in preclaim returns tesSUCCESS even with expired credentials,
+        // because we want to keep the checkDepositPreauth part before the expensive proof
+        // verification. cleanupExpiredCredentials in doApply removes the expired credential and
+        // returns tecEXPIRED.
+        {
+            Env env(*this, features);
+            env.fund(XRP(50000), dpIssuer);
+            env.close();
+
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50},
+                 {.account = carol, .payAmount = 100, .convertAmount = 50}}};
+            auto& mpt = confEnv.mpt;
+
+            auto const credIdx = createExpiringCredential(env, carol);
+
+            // Advance ledger past credential expiration
+            env.close(std::chrono::seconds(expireTime));
+
+            // Send fails with tecEXPIRED; the expired credential is cleaned up
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .credentials = {{credIdx}},
+                .err = tecEXPIRED,
+            });
+            env.close();
+
+            BEAST_EXPECT(credentialDeleted(env, carol));
+        }
+
+        // TEST 6: Expired credential, depositAuth enabled but credential
+        // not authorized by bob.
+        // checkDepositPreauth in preclaim calls checkDepositPreauth which
+        // finds no match and returns tecNO_PERMISSION. doApply never runs, so
+        // the expired credential is not cleaned up by this transaction. This is
+        // a deliberate tradeoff: allowing doApply to run solely for cleanup
+        // would require bypassing the preclaim short-circuit, forcing every
+        // validator to run the expensive ZK proof verification before
+        // discovering the authorization failure. Expired credentials here will
+        // be cleaned up opportunistically by a future transaction that
+        // references them.
+        {
+            Env env(*this, features);
+            env.fund(XRP(50000), dpIssuer);
+            env.close();
+
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50},
+                 {.account = carol, .payAmount = 100, .convertAmount = 50}}};
+            auto& mpt = confEnv.mpt;
+            env(fset(bob, asfDepositAuth));
+            env.close();
+
+            auto const credIdx = createExpiringCredential(env, carol);
+
+            // Advance ledger past credential expiration
+            env.close(std::chrono::seconds(expireTime));
+
+            // Fails with tecNO_PERMISSION.
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .credentials = {{credIdx}},
+                .err = tecNO_PERMISSION,
+            });
+            env.close();
+
+            // Expired credential is not deleted
+            BEAST_EXPECT(!credentialDeleted(env, carol));
+        }
+    }
+
+    void
+    testSendCredentialValidation(FeatureBitset features)
+    {
+        testcase("Send credential validation");
+        using namespace test::jtx;
+
+        // Tests for credentials::checkFields (preflight) and
+        // credentials::valid (preclaim) validation.
+        //
+        // Preflight checks (temMALFORMED):
+        //   - Empty credentials array
+        //   - Array size exceeds maxCredentialsArraySize (8)
+        //   - Duplicate credential IDs in array
+        //
+        // Preclaim checks (tecBAD_CREDENTIALS):
+        //   - Credential doesn't exist
+        //   - Credential doesn't belong to source account
+        //   - Credential not accepted (lsfAccepted flag not set)
+
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const dpIssuer("dpIssuer");
+        char const credType[] = "KYC";
+
+        // TEST 1: Preflight - Empty Credentials Array
+        {
+            Env env(*this, features);
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50},
+                 {.account = carol, .payAmount = 100, .convertAmount = 50}}};
+            auto& mpt = confEnv.mpt;
+
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .credentials = std::vector{},
+                .err = temMALFORMED,
+            });
+        }
+
+        // TEST 2: Preflight - Credentials Array Too Large
+        {
+            Env env(*this, features);
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50},
+                 {.account = carol, .payAmount = 100, .convertAmount = 50}}};
+            auto& mpt = confEnv.mpt;
+
+            std::vector tooManyCredentials;
+            tooManyCredentials.reserve(9);
+            for (int i = 0; i < 9; ++i)
+                tooManyCredentials.push_back(to_string(uint256(i)));
+
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .credentials = tooManyCredentials,
+                .err = temMALFORMED,
+            });
+        }
+
+        // TEST 3: Preflight - Duplicate Credentials
+        {
+            Env env(*this, features);
+            env.fund(XRP(50000), dpIssuer);
+            env.close();
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50},
+                 {.account = carol, .payAmount = 100, .convertAmount = 50}}};
+            auto& mpt = confEnv.mpt;
+
+            env(credentials::create(carol, dpIssuer, credType));
+            env.close();
+            env(credentials::accept(carol, dpIssuer, credType));
+            env.close();
+
+            auto const jv = credentials::ledgerEntry(env, carol, dpIssuer, credType);
+            std::string const credIdx = jv[jss::result][jss::index].asString();
+
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .credentials = {{credIdx, credIdx}},
+                .err = temMALFORMED,
+            });
+        }
+
+        // TEST 4: Preclaim - Credential Doesn't Exist
+        {
+            Env env(*this, features);
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50},
+                 {.account = carol, .payAmount = 100, .convertAmount = 50}}};
+            auto& mpt = confEnv.mpt;
+
+            std::string const fakeCredIdx = to_string(uint256(999));
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .credentials = {{fakeCredIdx}},
+                .err = tecBAD_CREDENTIALS,
+            });
+        }
+
+        // TEST 5: Preclaim - Credential Doesn't Belong to Source Account
+        {
+            Env env(*this, features);
+            env.fund(XRP(50000), dpIssuer);
+            env.close();
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50},
+                 {.account = carol, .payAmount = 100, .convertAmount = 50}}};
+            auto& mpt = confEnv.mpt;
+
+            // Create credential for BOB (not carol)
+            env(credentials::create(bob, dpIssuer, credType));
+            env.close();
+            env(credentials::accept(bob, dpIssuer, credType));
+            env.close();
+
+            auto const jv = credentials::ledgerEntry(env, bob, dpIssuer, credType);
+            std::string const credIdx = jv[jss::result][jss::index].asString();
+
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .credentials = {{credIdx}},
+                .err = tecBAD_CREDENTIALS,
+            });
+        }
+
+        // TEST 6: Preclaim - Credential Not Accepted
+        {
+            Env env(*this, features);
+            env.fund(XRP(50000), dpIssuer);
+            env.close();
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50},
+                 {.account = carol, .payAmount = 100, .convertAmount = 50}}};
+            auto& mpt = confEnv.mpt;
+
+            // Create credential but DON'T accept it
+            env(credentials::create(carol, dpIssuer, credType));
+            env.close();
+
+            auto const jv = credentials::ledgerEntry(env, carol, dpIssuer, credType);
+            std::string const credIdx = jv[jss::result][jss::index].asString();
+
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .credentials = {{credIdx}},
+                .err = tecBAD_CREDENTIALS,
+            });
+        }
+
+        // TEST 7: Preflight - sfCredentialIDs requires featureCredentials.
+        // Even with featureConfidentialTransfer enabled, supplying
+        // CredentialIDs while featureCredentials is disabled must be
+        // rejected in preflight via checkExtraFeatures.
+        {
+            Env env(*this, features - featureCredentials);
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50},
+                 {.account = carol, .payAmount = 100, .convertAmount = 50}}};
+            auto& mpt = confEnv.mpt;
+
+            auto constexpr kCredIdx =
+                "48004829F915654A81B11C4AB8218D96FED67F209B58328A72314FB6EA288BE4";
+
+            mpt.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .credentials = {{kCredIdx}},
+                .err = temDISABLED,
+            });
+        }
+    }
+
+    // Bob creates the AMM, but Bob is not the MPT holder checked below.
+    // The AMM has its own pseudo-account (`ammHolder`) that can hold the
+    // public MPT pool balance. That pseudo-account cannot normally
+    // initialize confidential state because the confidential txn's must be
+    // signed by sfAccount, and the AMM pseudo-account has no signing key.
+    // So this is a construction/impossibility test: public AMM MPT state exists
+    // but the corresponding confidential AMM clawback flow is not normally reachable.
+    void
+    testAMMHolderCannotHaveConfidentialStateClawback(FeatureBitset features)
+    {
+        testcase("AMM holder cannot have confidential state");
+        using namespace test::jtx;
+
+        Account const alice("alice");
+        Account const bob("bob");
+
+        for (bool const enablePseudoAccount : {false, true})
+        {
+            Env env{
+                *this,
+                enablePseudoAccount ? features | featureSingleAssetVault
+                                    : features - featureSingleAssetVault};
+
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .flags = kMptDexFlags | tfMPTCanClawback | tfMPTCanHoldConfidentialBalance,
+            });
+            mptAlice.authorize({.account = bob});
+            mptAlice.pay(alice, bob, 1'000);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            AMM const amm(env, bob, XRP(100), mptAlice(100));
+            Account const ammHolder("amm", amm.ammAccount());
+            auto const ammSle = env.le(keylet::account(ammHolder.id()));
+
+            BEAST_EXPECT(ammSle && ammSle->isFieldPresent(sfAMMID));
+            BEAST_EXPECT(mptAlice.getBalance(ammHolder) == 100);
+
+            BEAST_EXPECT(!mptAlice.getEncryptedBalance(ammHolder, MPTTester::holderEncryptedInbox));
+            BEAST_EXPECT(
+                !mptAlice.getEncryptedBalance(ammHolder, MPTTester::holderEncryptedSpending));
+            BEAST_EXPECT(
+                !mptAlice.getEncryptedBalance(ammHolder, MPTTester::issuerEncryptedBalance));
+            BEAST_EXPECT(
+                !mptAlice.getEncryptedBalance(ammHolder, MPTTester::auditorEncryptedBalance));
+
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = ammHolder,
+                .amt = 100,
+                .proof = strHex(gMakeZeroBuffer(kEcClawbackProofLength)),
+                .err = tecNO_PERMISSION,
+            });
+        }
+    }
+
+    // Exercises every Confidential Transfer transaction type (MPTokenIssuanceSet,
+    // Convert, MergeInbox, Send, ConvertBack) using tickets instead of regular account
+    // sequence numbers.
+    void
+    testWithTickets(FeatureBitset features)
+    {
+        testcase("Confidential transfer with tickets");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+        });
+        mptAlice.authorize({.account = bob});
+        mptAlice.authorize({.account = carol});
+        mptAlice.pay(alice, bob, 100);
+        mptAlice.pay(alice, carol, 100);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.generateKeyPair(carol);
+
+        // MPTokenIssuanceSet with ticket, registers alice's issuer key.
+        {
+            std::uint32_t const ticketSeq = env.seq(alice) + 1;
+            env(ticket::create(alice, 1));
+            mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice), .ticketSeq = ticketSeq});
+        }
+
+        // ConfidentialMPTConvert with ticket, first convert registers bob's key.
+        {
+            std::uint32_t const ticketSeq = env.seq(bob) + 1;
+            env(ticket::create(bob, 1));
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .ticketSeq = ticketSeq,
+            });
+            env.require(MptBalance(mptAlice, bob, 50));
+        }
+
+        // ConfidentialMPTConvert with ticket
+        {
+            std::uint32_t const ticketSeq = env.seq(bob) + 1;
+            env(ticket::create(bob, 1));
+            mptAlice.convert({.account = bob, .amt = 20, .ticketSeq = ticketSeq});
+            env.require(MptBalance(mptAlice, bob, 30));
+        }
+
+        // ConfidentialMPTMergeInbox with ticket.
+        {
+            std::uint32_t const ticketSeq = env.seq(bob) + 1;
+            env(ticket::create(bob, 1));
+            mptAlice.mergeInbox({.account = bob, .ticketSeq = ticketSeq});
+        }
+
+        mptAlice.convert({.account = carol, .amt = 50, .holderPubKey = mptAlice.getPubKey(carol)});
+        mptAlice.mergeInbox({.account = carol});
+
+        // ConfidentialMPTSend with ticket.
+        {
+            std::uint32_t const ticketSeq = env.seq(bob) + 1;
+            env(ticket::create(bob, 1));
+            mptAlice.send({.account = bob, .dest = carol, .amt = 10, .ticketSeq = ticketSeq});
+        }
+
+        // Merge carol's inbox so her spending balance includes the received send.
+        mptAlice.mergeInbox({.account = carol});
+
+        // ConfidentialMPTConvertBack with ticket.
+        // The convertBack proof context hash must use the ticket sequence.
+        {
+            std::uint32_t const ticketSeq = env.seq(carol) + 1;
+            env(ticket::create(carol, 1));
+            mptAlice.convertBack({.account = carol, .amt = 10, .ticketSeq = ticketSeq});
+            // carol converted 50, received 10 from bob, then converted back 10 → public 60
+            env.require(MptBalance(mptAlice, carol, 60));
+        }
+    }
+
+    // Verifies that cryptographic proofs in Convert transactions are bound to
+    // the ticket sequence rather than the account sequence.
+    // A proof built with the ticket sequence passes.
+    void
+    testConvertTicketProofBinding(FeatureBitset features)
+    {
+        testcase("Convert proof binds to ticket sequence");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .holderCount = 0,
+            .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+        });
+        mptAlice.authorize({.account = bob});
+        mptAlice.pay(alice, bob, 100);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+        mptAlice.generateKeyPair(bob);
+
+        uint64_t const amt = 30;
+        Buffer const bf = generateBlindingFactor();
+        Buffer const holderCt = mptAlice.encryptAmount(bob, amt, bf);
+        Buffer const issuerCt = mptAlice.encryptAmount(alice, amt, bf);
+
+        std::uint32_t const ticketSeq1 = env.seq(bob) + 1;
+        env(ticket::create(bob, 1));
+
+        // Invalid: Schnorr proof built with the account seq (env.seq(bob)) rather
+        // than the ticket seq (ticketSeq1).
+        {
+            BEAST_EXPECT(env.seq(bob) != ticketSeq1);
+            uint256 const badCtxHash =
+                getConvertContextHash(bob, mptAlice.issuanceID(), env.seq(bob));
+            auto const badProof = requireOptional(
+                mptAlice.getSchnorrProof(bob, badCtxHash), "Missing Schnorr Proof.");
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = amt,
+                .proof = strHex(badProof),
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .holderEncryptedAmt = holderCt,
+                .issuerEncryptedAmt = issuerCt,
+                .blindingFactor = bf,
+                .ticketSeq = ticketSeq1,
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        std::uint32_t const ticketSeq2 = env.seq(bob) + 1;
+        env(ticket::create(bob, 1));
+
+        // Valid: proof auto-generated by convert() using ticketSeq2; context hashes match.
+        mptAlice.convert({
+            .account = bob,
+            .amt = amt,
+            .holderPubKey = mptAlice.getPubKey(bob),
+            .holderEncryptedAmt = holderCt,
+            .issuerEncryptedAmt = issuerCt,
+            .blindingFactor = bf,
+            .ticketSeq = ticketSeq2,
+        });
+        env.require(MptBalance(mptAlice, bob, 70));
+    }
+
+    // Exercises ticket-specific error codes for confidential transfer transactions:
+    void
+    testDestinationTag(FeatureBitset features)
+    {
+        testcase("test Destination Tag");
+
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}},
+            tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance};
+        auto& mptAlice = confEnv.mpt;
+
+        // Set RequireDest on carol
+        env(fset(carol, asfRequireDest));
+        env.close();
+
+        // Send without destination tag — rejected
+        mptAlice.send({
+            .account = bob,
+            .dest = carol,
+            .amt = 10,
+            .proof = getTrivialSendProofHex(),
+            .senderEncryptedAmt = getTrivialCiphertext(),
+            .destEncryptedAmt = getTrivialCiphertext(),
+            .issuerEncryptedAmt = getTrivialCiphertext(),
+            .amountCommitment = getTrivialCommitment(),
+            .balanceCommitment = getTrivialCommitment(),
+            .err = tecDST_TAG_NEEDED,
+        });
+
+        // Send with destination tag — succeeds (passes preclaim,
+        // reaches ZKP verification with the real proof)
+        mptAlice.send({.account = bob, .dest = carol, .amt = 10, .destinationTag = 42});
+
+        // Verify the destination tag is in the confirmed transaction
+        auto const tx = env.tx();
+        BEAST_EXPECT(tx);
+        BEAST_EXPECT(tx->isFieldPresent(sfDestinationTag));
+        BEAST_EXPECT((*tx)[sfDestinationTag] == 42);
+
+        env(fclear(carol, asfRequireDest));
+        env.close();
+
+        // Send without destination tag when not required — succeeds
+        mptAlice.mergeInbox({.account = carol});
+        mptAlice.send({.account = bob, .dest = carol, .amt = 10});
+    }
+
+    // terPRE_TICKET when the ticket doesn't exist yet, and tefNO_TICKET when
+    // the ticket has already been consumed or was never created.
+    void
+    testTicketErrors(FeatureBitset features)
+    {
+        testcase("Confidential transfer ticket errors");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .holderCount = 0,
+            .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+        });
+        mptAlice.authorize({.account = bob});
+        mptAlice.pay(alice, bob, 100);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+        mptAlice.generateKeyPair(bob);
+
+        // Give bob an inbox balance so MergeInbox has something to merge.
+        mptAlice.convert({.account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob)});
+
+        // Use MergeInbox as the confidential transfer transaction under test
+        // so that ticket errors are isolated from cryptographic verification.
+
+        // terPRE_TICKET: ticket sequence is far in the future and hasn't been created.
+        mptAlice.mergeInbox(
+            {.account = bob, .ticketSeq = env.seq(bob) + 100, .err = terPRE_TICKET});
+
+        // Create one ticket and use it successfully.
+        std::uint32_t const ticketSeq = env.seq(bob) + 1;
+        env(ticket::create(bob, 1));
+        mptAlice.mergeInbox({.account = bob, .ticketSeq = ticketSeq});
+
+        // tefNO_TICKET: attempt to reuse the same (already-consumed) ticket.
+        mptAlice.mergeInbox({.account = bob, .ticketSeq = ticketSeq, .err = tefNO_TICKET});
+
+        // tefNO_TICKET: ticket sequence is in the past but was never created.
+        mptAlice.mergeInbox({.account = bob, .ticketSeq = 1, .err = tefNO_TICKET});
+    }
+
+    // Bob sends 100 MPT to Carol. Carol Merge Inbox. Carol sends 50 MPT to Dave.
+    // Inner 3rd txn (Carol sends to Dave) fails because the proof is built with
+    // when Carols's spending balance is 0. (before she received funds from Bob)
+    //
+    // Also tests Bob sending to two recipients (Carol and Dave) in a single
+    // batch. Even though Bob has enough balance for both, the second send's
+    // balance-linkage proof becomes incorrect once inner 1 updates Bob's encrypted
+    // spending, so fails
+    void
+    testBatchConfidentialSend(FeatureBitset features)
+    {
+        testcase("Batch confidential send - merge inbox dependency");
+        using namespace test::jtx;
+
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            // bob = A (100 spending), carol = B (0), dave = C (0)
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
+
+            // Build the batch:
+            //   Batch Txn 1 bob -> carol 100 : valid proof, bob spending=100
+            //   Batch Txn 2 carol -> mergeInbox : valid JV
+            //   Batch Txn 3 carol->dave 50  : Invalid
+            auto const bobSeq = env.seq(bob);
+            auto const carolSeq = env.seq(carol);
+            // 3 signers, Bob, Carol, Dave
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 3);
+
+            auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 100}, bobSeq + 1);
+            auto const jv2 = mpt.mergeInboxJV({.account = carol});
+            auto const jv3 = mpt.sendJV({.account = carol, .dest = dave, .amt = 50}, carolSeq + 1);
+
+            env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
+                batch::Inner(jv1, bobSeq + 1),
+                batch::Inner(jv2, carolSeq),
+                batch::Inner(jv3, carolSeq + 1),
+                batch::Sig(carol),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // AllOrNothing: inner 3 fails
+            // bob's spending must remain 100; carol's inbox must remain 0.
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
+        }
+
+        // Bob sends to two recipients (Carol and Dave) in one batch.
+        // Bob has 150, enough for both sends individually.  However, batch txn 1
+        // changes Bob's encrypted spending on the ledger; batch txn 2 was built
+        // against the old enc(150) so its balance-linkage proof is stale.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            setupBatchEnv(mpt, alice, bob, carol, dave, 150, 0);
+
+            // tfAllOrNothing — rejects the whole batch as 2nd txn proof is incorrect
+            {
+                auto const bobSeq = env.seq(bob);
+                auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
+
+                auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 50}, bobSeq + 1);
+                auto const jv2 = mpt.sendJV({.account = bob, .dest = dave, .amt = 60}, bobSeq + 2);
+
+                env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
+                    batch::Inner(jv1, bobSeq + 1),
+                    batch::Inner(jv2, bobSeq + 2),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                // Nothing applied: bob stays 150, carol and dave inbox stay 0.
+                BEAST_EXPECT(
+                    mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 150);
+                BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
+                BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 0);
+            }
+
+            // If we change batch mode to be tfIndependent — txn 1 applies, inner 2 fails.
+            {
+                auto const bobSeq = env.seq(bob);
+                auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
+
+                auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 50}, bobSeq + 1);
+                auto const jv2 = mpt.sendJV({.account = bob, .dest = dave, .amt = 60}, bobSeq + 2);
+
+                env(batch::outer(bob, bobSeq, batchFee, tfIndependent),
+                    batch::Inner(jv1, bobSeq + 1),
+                    batch::Inner(jv2, bobSeq + 2),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                // bob 150→100, carol inbox 0→50
+                BEAST_EXPECT(
+                    mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
+                BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 50);
+                // dave gets nothing
+                BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 0);
+            }
+        }
+
+        // Now, Bob sends Confidential MPT to 2 accounts in one batch.
+        // However this time, the second txn proof is calculated using the
+        // correct encrypted(spending) proof, so it should pass.
+        {
+            // bob has exactly enough for both sends.
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            setupBatchEnv(mpt, alice, bob, carol, dave, 200, 0);
+
+            {
+                auto const bobSeq = env.seq(bob);
+                auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
+
+                // jv1 is built against the current ledger state (spending=200).
+                auto const jv1 =
+                    mpt.sendJV({.account = bob, .dest = carol, .amt = 100}, bobSeq + 1);
+
+                // Compute post-jv1 state without touching the ledger.
+                auto const chain1 = mpt.chainAfterSend(bob, 100, jv1);
+
+                // jv2 proof is built against predicted spending=100, version=N+1.
+                auto const jv2 =
+                    mpt.sendJV({.account = bob, .dest = dave, .amt = 100}, bobSeq + 2, chain1);
+
+                env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
+                    batch::Inner(jv1, bobSeq + 1),
+                    batch::Inner(jv2, bobSeq + 2),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                // Both txns applied: bob 200→0, carol inbox=100, dave inbox=100.
+                BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 0);
+                BEAST_EXPECT(
+                    mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 100);
+                BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 100);
+            }
+
+            // Now Bob has 150, but tries to send two 100 in one batch.
+            // This fails because Bob doesn't have enough MPT balance.
+            {
+                Env env2{*this, features};
+                Account const alice2("alice");
+                Account const bob2("bob");
+                Account const carol2("carol");
+                Account const dave2("dave");
+
+                MPTTester mpt2(env2, alice2, {.holders = {bob2, carol2, dave2}});
+                setupBatchEnv(mpt2, alice2, bob2, carol2, dave2, 150, 0);
+
+                auto const bobSeq = env2.seq(bob2);
+                auto const batchFee = batch::calcConfidentialBatchFee(env2, 0, 2);
+
+                auto const jv1 =
+                    mpt2.sendJV({.account = bob2, .dest = carol2, .amt = 100}, bobSeq + 1);
+                auto const chain1 = mpt2.chainAfterSend(bob2, 100, jv1);
+
+                auto const jv2 =
+                    mpt2.sendJV({.account = bob2, .dest = dave2, .amt = 100}, bobSeq + 2, chain1);
+
+                env2(
+                    batch::outer(bob2, bobSeq, batchFee, tfAllOrNothing),
+                    batch::Inner(jv1, bobSeq + 1),
+                    batch::Inner(jv2, bobSeq + 2),
+                    Ter(tesSUCCESS));
+                env2.close();
+
+                // AllOrNothing: inner 2 fails → nothing applied.
+                BEAST_EXPECT(
+                    mpt2.getDecryptedBalance(bob2, MPTTester::holderEncryptedSpending) == 150);
+                BEAST_EXPECT(
+                    mpt2.getDecryptedBalance(carol2, MPTTester::holderEncryptedInbox) == 0);
+                BEAST_EXPECT(mpt2.getDecryptedBalance(dave2, MPTTester::holderEncryptedInbox) == 0);
+            }
+        }
+    }
+    void
+    testBatchConfidentialConvertAndConvertBack(FeatureBitset features)
+    {
+        testcase("Batch confidential convert and convertBack");
+        using namespace test::jtx;
+
+        // convert + convertBack in one AllOrNothing batch, both valid.
+        //
+        // Bob has regular=50, spending=100.
+        // jv1: convert 50 regular → inbox  (Schnorr proof; does NOT touch spending/version)
+        // jv2: convertBack 30 spending → regular  (proof against spending=100, version=V)
+        //
+        // Since jv1 leaves spending and version unchanged, jv2's proof is still
+        // valid when it executes, so both inner txns succeed.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            // bob: spending=100, regular=0 after setupBatchEnv;
+            // pay 50 more to give bob regular MPT to convert in the batch.
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
+            mpt.pay(alice, bob, 50);
+
+            auto const bobSeq = env.seq(bob);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
+
+            // jv1: convert 50 regular MPT into confidential inbox
+            auto const jv1 = mpt.convertJV({.account = bob, .amt = 50}, bobSeq + 1);
+            // jv2: convert 30 spending back to regular MPT
+            auto const jv2 = mpt.convertBackJV({.account = bob, .amt = 30}, bobSeq + 2);
+
+            env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
+                batch::Inner(jv1, bobSeq + 1),
+                batch::Inner(jv2, bobSeq + 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            //   regular (mptAmount): 50 (pre) - 50 (convert) + 30 (convertBack) = 30
+            //   spending balance: 100 - 30 = 70
+            //   inbox:    0   + 50 (from convert) = 50
+            env.require(MptBalance(mpt, bob, 30));
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 70);
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedInbox) == 50);
+        }
+
+        // convert + mergeInbox + convertBack, stale convertBack proof.
+        //
+        // jv1: convert 50 regular → inbox
+        // jv2: mergeInbox (inbox 50 → spending, version V → V+1)
+        // jv3: convertBack 30 (proof built against spending=100, version=V)
+        //
+        // After jv2 applies, spending=150 and version=V+1, so jv3's
+        // proof is stale.  AllOrNothing rejects the whole batch.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
+            mpt.pay(alice, bob, 50);
+
+            auto const bobSeq = env.seq(bob);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 3);
+
+            auto const jv1 = mpt.convertJV({.account = bob, .amt = 50}, bobSeq + 1);
+            auto const jv2 = mpt.mergeInboxJV({.account = bob});
+            // jv3 proof is built against spending=100, version=V (pre-batch)
+            auto const jv3 = mpt.convertBackJV({.account = bob, .amt = 30}, bobSeq + 3);
+
+            env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
+                batch::Inner(jv1, bobSeq + 1),
+                batch::Inner(jv2, bobSeq + 2),
+                batch::Inner(jv3, bobSeq + 3),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // jv3 fails so nothing is applied.
+            env.require(MptBalance(mpt, bob, 50));
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedInbox) == 0);
+        }
+    }
+
+    // Tests a batch containing all four confidential MPT operations, Send,
+    // Convert, ConvertBack, and MergeInbox in a single AllOrNothing batch.
+    void
+    testBatchConfidentialMixTransactions(FeatureBitset features)
+    {
+        testcase("Batch confidential mixed operations");
+        using namespace test::jtx;
+
+        // send(bob→carol) + convert(carol) + convertBack(dave)
+        //  + mergeInbox(carol) in one AllOrNothing batch.
+        //
+        // Setup:
+        //   bob:   spending=100, regular=0
+        //   carol: spending=0,   regular=50
+        //   dave:  spending=50,  regular=0
+        //
+        // After the batch:
+        //   bob   spending: 100 -> 70  (sent 30 to carol)
+        //   carol inbox:    0+30(send)+50(convert)=80 -> merged -> spending=80, inbox=0
+        //   dave  spending: 50 -> 30; regular: 0 -> 20
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            // bob: spending=100. carol: key registered, spending=0.
+            // dave: key registered, spending=0 initially.
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
+            // Give carol 50 regular MPT to convert in the batch.
+            mpt.pay(alice, carol, 50);
+            // Give dave 50 regular MPT then convert to confidential spending.
+            mpt.pay(alice, dave, 50);
+            mpt.convert({.account = dave, .amt = 50});
+            mpt.mergeInbox({.account = dave});
+
+            auto const bobSeq = env.seq(bob);
+            auto const carolSeq = env.seq(carol);
+            auto const daveSeq = env.seq(dave);
+            // 2 extra signers (carol, dave), 4 inner txns
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 2, 4);
+
+            // jv1: bob sends 30 to carol
+            auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 30}, bobSeq + 1);
+            // jv2: carol converts her 50 regular MPT to confidential
+            auto const jv2 = mpt.convertJV({.account = carol, .amt = 50}, carolSeq);
+            // jv3: dave converts 20 spending back to regular MPT
+            auto const jv3 = mpt.convertBackJV({.account = dave, .amt = 20}, daveSeq);
+            // jv4: carol merges inbox into spending
+            //   (inbox = 30 from jv1 + 50 from jv2 = 80 at execution time)
+            auto const jv4 = mpt.mergeInboxJV({.account = carol});
+
+            env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
+                batch::Inner(jv1, bobSeq + 1),
+                batch::Inner(jv2, carolSeq),
+                batch::Inner(jv3, daveSeq),
+                batch::Inner(jv4, carolSeq + 1),
+                batch::Sig(carol, dave),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // All four applied:
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 70);
+            // carol's inbox was merged: spending=80, inbox=0
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 80);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
+            // dave: spending=30, regular=20
+            BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedSpending) == 30);
+            env.require(MptBalance(mpt, dave, 20));
+        }
+
+        // bob send + bob convertBack in one AllOrNothing batch.
+        //
+        // The Send applies first and increments Bob's version counter.
+        // The ConvertBack proof was built against the pre-Send (spending=100,
+        // version=V), so batch txn is rejected.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
+
+            auto const bobSeq = env.seq(bob);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
+
+            // jv1: bob sends 30 to carol (spending 100->70, version V->V+1)
+            auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 30}, bobSeq + 1);
+            // jv2: bob convertBack 40 , proof built against spending=100, version=V
+            auto const jv2 = mpt.convertBackJV({.account = bob, .amt = 40}, bobSeq + 2);
+
+            env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
+                batch::Inner(jv1, bobSeq + 1),
+                batch::Inner(jv2, bobSeq + 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // AllOrNothing: jv2 fails (stale proof) → nothing applied.
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
+        }
+    }
+
+    // Verifies that batch transactions work correctly when tickets are used instead
+    // of sequence numbers
+    void
+    testBatchAllOrNothing(FeatureBitset features)
+    {
+        testcase("Batch confidential MPT - all or nothing");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const dave("dave");
+
+        MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+        // bob=100 spending, carol=60 spending, dave=0
+        setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
+
+        // bob sends dave 10, carol sends dave 5, independent, both valid.
+        {
+            auto const bobSeq = env.seq(bob);
+            auto const carolSeq = env.seq(carol);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
+
+            auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 10}, bobSeq + 1);
+            auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq);
+
+            env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
+                batch::Inner(jv1, bobSeq + 1),
+                batch::Inner(jv2, carolSeq),
+                batch::Sig(carol),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Both txn applied: bob's balance 100→90, carol 60→55, dave inbox 0→15
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 90);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 55);
+            BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 15);
+        }
+    }
+
+    void
+    testBatchOnlyOne(FeatureBitset features)
+    {
+        testcase("Batch confidential MPT - only one");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const dave("dave");
+
+        MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+        // bob=100 spending, carol=60 spending, dave=0
+        setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
+
+        // bob sends dave 200 (invalid), carol sends dave 300 (invalid)
+        {
+            auto const bobSeq = env.seq(bob);
+            auto const carolSeq = env.seq(carol);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
+
+            // Both proofs fail range check (amount > balance)
+            auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 200}, bobSeq + 1);
+            auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 300}, carolSeq);
+
+            env(batch::outer(bob, bobSeq, batchFee, tfOnlyOne),
+                batch::Inner(jv1, bobSeq + 1),
+                batch::Inner(jv2, carolSeq),
+                batch::Sig(carol),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // No success found → nothing applied; balances unchanged
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 60);
+            BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 0);
+        }
+
+        // bob sends dave 200 (invalid), carol sends dave 5 (valid)
+        {
+            auto const bobSeq = env.seq(bob);
+            auto const carolSeq = env.seq(carol);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
+
+            auto jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 200}, bobSeq + 1);
+            auto jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq);
+
+            env(batch::outer(bob, bobSeq, batchFee, tfOnlyOne),
+                batch::Inner(jv1, bobSeq + 1),
+                batch::Inner(jv2, carolSeq),
+                batch::Sig(carol),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Only carol's send applied: carol 60→55, dave inbox 0→5, bob unchanged
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 55);
+            BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 5);
+        }
+    }
+
+    void
+    testBatchUntilFailure(FeatureBitset features)
+    {
+        testcase("Batch confidential MPT - until failure");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const dave("dave");
+
+        MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+        // bob=100 spending, carol=60 spending, dave=0
+        setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
+
+        // first fails → none applied
+        // Bob sends Dave 200 (invalid — stops immediately)
+        {
+            auto const bobSeq = env.seq(bob);
+            auto const carolSeq = env.seq(carol);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
+
+            auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 200}, bobSeq + 1);
+            auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq);
+
+            env(batch::outer(bob, bobSeq, batchFee, tfUntilFailure),
+                batch::Inner(jv1, bobSeq + 1),
+                batch::Inner(jv2, carolSeq),
+                batch::Sig(carol),
+                Ter(tesSUCCESS));
+            env.close();
+
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 60);
+        }
+
+        // Bob sends dave 10, Carol sends dave 5 — both valid and independent
+        {
+            auto const bobSeq = env.seq(bob);
+            auto const carolSeq = env.seq(carol);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
+
+            auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 10}, bobSeq + 1);
+            auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq);
+
+            env(batch::outer(bob, bobSeq, batchFee, tfUntilFailure),
+                batch::Inner(jv1, bobSeq + 1),
+                batch::Inner(jv2, carolSeq),
+                batch::Sig(carol),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Both applied: bob 100→90, carol 60→55, dave inbox 0→15
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 90);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 55);
+            BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 15);
+        }
+    }
+
+    void
+    testBatchIndependent(FeatureBitset features)
+    {
+        testcase("Batch confidential MPT - independent");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const dave("dave");
+
+        MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+        // bob=100 spending, carol=60 spending, dave=0
+        setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
+
+        // Bob sends dave 10 (valid), Carol sends dave 300
+        // (invalid), Carol sends Dave 5 (valid). Carol's
+        // balance is still 60 because the preceding send failed).
+        {
+            auto const bobSeq = env.seq(bob);
+            auto const carolSeq = env.seq(carol);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 3);
+
+            auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 10}, bobSeq + 1);
+
+            // Carol trying to send dave 300 but own balance only 60
+            auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 300}, carolSeq);
+            auto const jv3 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq + 1);
+
+            env(batch::outer(bob, bobSeq, batchFee, tfIndependent),
+                batch::Inner(jv1, bobSeq + 1),
+                batch::Inner(jv2, carolSeq),
+                batch::Inner(jv3, carolSeq + 1),
+                batch::Sig(carol),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // inner 1 (bob→dave 10) applied: bob 100→90
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 90);
+            // inner 2 failed (carol not changed), inner 3 applied: carol 60→55
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 55);
+            // dave inbox: 10 (from bob) + 5 (from carol inner 3) = 15
+            BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 15);
+        }
+    }
+
+    // Tests batching ConfidentialMPTConvert and a ConfidentialMPTConvertBack
+    // in the same batch transaction. Because Convert only modifies the inbox
+    // (never the spending balance or the version counter), a ConvertBack proof
+    // built against the pre-batch spending balance is still valid when both
+    // appear in the same batch.
+    void
+    testBatchWithTickets(FeatureBitset features)
+    {
+        testcase("Batch confidential MPT with tickets");
+        using namespace test::jtx;
+
+        // outer batch uses a ticket.
+        // The inner send proofs are still bound to regular account sequences.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
+
+            // Bob creates one ticket to use for the outer batch.
+            std::uint32_t const outerTicketSeq = env.seq(bob) + 1;
+            env(ticket::create(bob, 1));
+            env.close();
+
+            auto const bobSeq = env.seq(bob);
+            // 0 extra signers: all inner txns are from bob;
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
+
+            // When the outer uses a ticket (seq=0), inner txns start from bobSeq, bobSeq+1.
+            // jv2 must use chain state predicted after jv1 since both sends are from bob.
+            auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, bobSeq);
+            auto const chain1 = mpt.chainAfterSend(bob, 40, jv1);
+            auto const jv2 =
+                mpt.sendJV({.account = bob, .dest = dave, .amt = 20}, bobSeq + 1, chain1);
+
+            env(batch::outer(bob, 0, batchFee, tfAllOrNothing),
+                batch::Inner(jv1, bobSeq),
+                batch::Inner(jv2, bobSeq + 1),
+                ticket::Use(outerTicketSeq),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Both sends applied: bob 100→40, carol inbox=40, dave inbox=20.
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 40);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 40);
+            BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 20);
+        }
+
+        // inner transactions each consume their own ticket.
+        // The send proof context hash must be bound to the ticket sequence, not the
+        // account sequence. sendJV receives the ticket seq as its `seq` parameter.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
+
+            // Bob creates two tickets for the two inner sends.
+            std::uint32_t const ticketSeq1 = env.seq(bob) + 1;
+            std::uint32_t const ticketSeq2 = env.seq(bob) + 2;
+            env(ticket::create(bob, 2));
+            env.close();
+
+            auto const bobSeq = env.seq(bob);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
+
+            // jv1: proof bound to ticketSeq1.
+            auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, ticketSeq1);
+            // jv2: proof bound to ticketSeq2, spending state predicted after jv1.
+            auto const chain1 = mpt.chainAfterSend(bob, 40, jv1);
+            auto const jv2 =
+                mpt.sendJV({.account = bob, .dest = dave, .amt = 30}, ticketSeq2, chain1);
+
+            env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
+                batch::Inner(jv1, 0, ticketSeq1),
+                batch::Inner(jv2, 0, ticketSeq2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Both sends applied: bob 100→30, carol inbox=40, dave inbox=30.
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 30);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 40);
+            BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 30);
+        }
+
+        // inner send uses wrong sequence (account seq instead of ticket seq)
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
+
+            std::uint32_t const ticketSeq = env.seq(bob) + 1;
+            env(ticket::create(bob, 1));
+            env.close();
+
+            auto const bobSeq = env.seq(bob);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
+
+            // Proof intentionally built with account seq (bobSeq+1) instead of ticketSeq.
+            auto const badJV = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, bobSeq + 1);
+            auto const jv2 = mpt.mergeInboxJV({.account = bob});
+
+            env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
+                batch::Inner(badJV, 0, ticketSeq),
+                batch::Inner(jv2, bobSeq + 1),
+                Ter(tesSUCCESS));
+            env.close();
+
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
+        }
+    }
+
+    // Basic tests of confidential transfer through delegation. Verifies that a delegated account
+    // with the appropriate permissions can execute confidential transfer transactions
+    // on behalf of the delegator.
+    void
+    testConfidentialDelegation(FeatureBitset features)
+    {
+        testcase("Confidential transfers through delegation");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        Account const carol{"carol"};
+        Account const dave{"dave"};
+
+        MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+        env.fund(XRP(10000), dave);
+        env.close();
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback |
+                tfMPTCanHoldConfidentialBalance,
+        });
+        mptAlice.authorize({.account = bob});
+        mptAlice.authorize({.account = carol});
+        mptAlice.pay(alice, bob, 200);
+        mptAlice.pay(alice, carol, 100);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.generateKeyPair(carol);
+        mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)});
+
+        // Bob delegates Convert, MergeInbox to dave.
+        env(delegate::set(bob, dave, {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox"}));
+        env.close();
+
+        // Carol has no permission from bob to convert on his behalf.
+        mptAlice.convert({
+            .account = bob,
+            .amt = 10,
+            .holderPubKey = mptAlice.getPubKey(bob),
+            .delegate = carol,
+            .err = terNO_DELEGATE_PERMISSION,
+        });
+
+        // Dave executes Convert on behalf of bob, registering bob's key.
+        mptAlice.convert({
+            .account = bob,
+            .amt = 100,
+            .holderPubKey = mptAlice.getPubKey(bob),
+            .delegate = dave,
+        });
+        env.require(MptBalance(mptAlice, bob, 100));
+
+        // Dave executes Convert again on behalf of bob (no key registration).
+        mptAlice.convert({.account = bob, .amt = 50, .delegate = dave});
+
+        // Dave executes MergeInbox on behalf of bob.
+        mptAlice.mergeInbox({.account = bob, .delegate = dave});
+
+        // Carol converts and merge inbox.
+        mptAlice.convert({
+            .account = carol,
+            .amt = 100,
+            .holderPubKey = mptAlice.getPubKey(carol),
+        });
+        mptAlice.mergeInbox({.account = carol});
+
+        // Dave does not have permission to send on behalf of bob.
+        mptAlice.send(
+            {.account = bob,
+             .dest = carol,
+             .amt = 10,
+             .delegate = dave,
+             .err = terNO_DELEGATE_PERMISSION});
+
+        // Bob delegates ConfidentialMPTSend to dave.
+        env(delegate::set(
+            bob,
+            dave,
+            {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox", "ConfidentialMPTSend"}));
+        env.close();
+
+        // Dave executes Send on behalf of bob.
+        mptAlice.send({.account = bob, .dest = carol, .amt = 10, .delegate = dave});
+        mptAlice.mergeInbox({.account = carol});
+
+        // Dave does not have permission to convert back on behalf of bob.
+        mptAlice.convertBack(
+            {.account = bob, .amt = 10, .delegate = dave, .err = terNO_DELEGATE_PERMISSION});
+
+        // Bob delegates ConfidentialMPTConvertBack to dave.
+        env(delegate::set(
+            bob,
+            dave,
+            {"ConfidentialMPTConvert",
+             "ConfidentialMPTMergeInbox",
+             "ConfidentialMPTSend",
+             "ConfidentialMPTConvertBack"}));
+        env.close();
+
+        // Dave executes ConvertBack on behalf of bob.
+        mptAlice.convertBack({.account = bob, .amt = 10, .delegate = dave});
+
+        // Dave does not have permission to clawback on behalf of alice.
+        mptAlice.confidentialClaw(
+            {.holder = bob, .amt = 130, .delegate = dave, .err = terNO_DELEGATE_PERMISSION});
+
+        // Alice delegates ConfidentialMPTClawback to dave.
+        env(delegate::set(alice, dave, {"ConfidentialMPTClawback"}));
+        env.close();
+
+        // Dave executes Clawback on behalf of alice.
+        mptAlice.confidentialClaw({.holder = bob, .amt = 130, .delegate = dave});
+    }
+
+    // Verifies that revoking delegation prevents further delegated operations.
+    void
+    testDelegationRevocation(FeatureBitset features)
+    {
+        testcase("Confidential delegation revocation");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        Account const carol{"carol"};
+
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+        env.fund(XRP(10000), carol);
+        env.close();
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+        });
+        mptAlice.authorize({.account = bob});
+        mptAlice.pay(alice, bob, 100);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)});
+
+        // Creating the Delegate SLE consumes one owner reserve slot for bob.
+        auto const bobOwnersBefore = ownerCount(env, bob);
+        env(delegate::set(bob, carol, {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox"}));
+        env.close();
+        env.require(Owners(bob, bobOwnersBefore + 1));
+
+        // Carol converts and merge inbox on behalf of bob.
+        mptAlice.convert({
+            .account = bob,
+            .amt = 50,
+            .holderPubKey = mptAlice.getPubKey(bob),
+            .delegate = carol,
+        });
+        mptAlice.mergeInbox({.account = bob, .delegate = carol});
+
+        // Bob revokes all permissions, deletes the Delegate SLE, releasing the reserve.
+        env(delegate::set(bob, carol, std::vector{}));
+        env.close();
+        env.require(Owners(bob, bobOwnersBefore));
+
+        // Carol can no longer convert on behalf of bob.
+        mptAlice.convert({
+            .account = bob,
+            .amt = 30,
+            .delegate = carol,
+            .err = terNO_DELEGATE_PERMISSION,
+        });
+
+        // Bob can still convert by himself.
+        mptAlice.convert({.account = bob, .amt = 30});
+    }
+
+    // Verifies that a delegated confidential transfer works correctly when an
+    // auditor is configured on the issuance.
+    void
+    testDelegationWithAuditor(FeatureBitset features)
+    {
+        testcase("Confidential delegation with auditor");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        Account const carol{"carol"};
+        Account const dave{"dave"};
+        Account const auditor{"auditor"};
+
+        MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor});
+        env.fund(XRP(10000), dave);
+        env.close();
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+        });
+        mptAlice.authorize({.account = bob});
+        mptAlice.authorize({.account = carol});
+        mptAlice.pay(alice, bob, 100);
+        mptAlice.pay(alice, carol, 100);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.generateKeyPair(carol);
+        mptAlice.generateKeyPair(auditor);
+        mptAlice.set({
+            .issuerPubKey = mptAlice.getPubKey(alice),
+            .auditorPubKey = mptAlice.getPubKey(auditor),
+        });
+
+        // Bob delegates Convert and Send permissions to dave.
+        env(delegate::set(bob, dave, {"ConfidentialMPTSend", "ConfidentialMPTConvert"}));
+        env.close();
+
+        // Dave converts on behalf of bob.
+        mptAlice.convert({
+            .account = bob,
+            .amt = 50,
+            .holderPubKey = mptAlice.getPubKey(bob),
+            .delegate = dave,
+        });
+        mptAlice.mergeInbox({.account = bob});
+
+        mptAlice.convert({
+            .account = carol,
+            .amt = 50,
+            .holderPubKey = mptAlice.getPubKey(carol),
+        });
+        mptAlice.mergeInbox({.account = carol});
+
+        // Dave sends on behalf of bob.
+        mptAlice.send({.account = bob, .dest = carol, .amt = 20, .delegate = dave});
+        mptAlice.send({.account = bob, .dest = carol, .amt = 10, .delegate = dave});
+
+        // Bob delegates ConvertBack and Send permissions to auditor.
+        env(delegate::set(bob, auditor, {"ConfidentialMPTSend", "ConfidentialMPTConvertBack"}));
+        env.close();
+
+        // auditor can send and convert back on behalf of bob as well.
+        mptAlice.send({.account = bob, .dest = carol, .amt = 10, .delegate = auditor});
+        mptAlice.convertBack({.account = bob, .amt = 10, .delegate = auditor});
+    }
+
+    // Verifies that a non-issuer delegating clawback to a third party does not
+    // allow that party to execute clawback, since clawback is issuer-only.
+    void
+    testDelegationClawbackIssuerOnly(FeatureBitset features)
+    {
+        testcase("Confidential clawback delegation requires issuer");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        Account const carol{"carol"};
+        Account const dave{"dave"};
+
+        ConfidentialEnv const confEnv{
+            env,
+            alice,
+            {{.account = bob, .payAmount = 100, .convertAmount = 50},
+             {.account = carol, .payAmount = 100, .convertAmount = 100}},
+            tfMPTCanTransfer | tfMPTCanClawback | tfMPTCanHoldConfidentialBalance};
+        auto& mptAlice = confEnv.mpt;
+        env.fund(XRP(10000), dave);
+        env.close();
+
+        // Bob delegates Clawback permission to dave.
+        env(delegate::set(bob, dave, {"ConfidentialMPTClawback"}));
+        env.close();
+
+        // Dave attempts clawback on behalf of bob targetting bob, but since bob is not the issuer,
+        // the transaction should be rejected.
+        {
+            json::Value jv;
+            jv[jss::Account] = bob.human();
+            jv[jss::TransactionType] = jss::ConfidentialMPTClawback;
+            jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID());
+            jv[sfHolder] = bob.human();
+            jv[sfMPTAmount.jsonName] = "50";
+            jv[sfZKProof.jsonName] = std::string(kEcClawbackProofLength * 2, '0');
+            env(jv, delegate::As(dave), Ter(temMALFORMED));
+        }
+
+        // Dave attempts clawback on behalf of bob targeting carol, but since bob is not the issuer,
+        // the transaction should be rejected.
+        {
+            json::Value jv;
+            jv[jss::Account] = bob.human();
+            jv[jss::TransactionType] = jss::ConfidentialMPTClawback;
+            jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID());
+            jv[sfHolder] = carol.human();
+            jv[sfMPTAmount.jsonName] = "100";
+            jv[sfZKProof.jsonName] = std::string(kEcClawbackProofLength * 2, '0');
+            env(jv, delegate::As(dave), Ter(temMALFORMED));
+        }
+    }
+
+    // Batch with delegated ConfidentialMPTSend txs, covering stale and updated inner
+    // send proofs.
+    void
+    testBatchDelegatedSend(FeatureBitset features)
+    {
+        testcase("Batch ConfidentialMPTSend with delegation");
+        using namespace test::jtx;
+
+        // AllOrNothing: two delegated sends from bob via dave, second proof is
+        // stale once the first send updates bob's spending, whole batch rolls back.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
+
+            env(delegate::set(bob, dave, {"ConfidentialMPTSend"}));
+            env.close();
+
+            auto const bobSeq = env.seq(bob);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
+
+            // jv1: proof against spending balance 100
+            auto jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 60}, bobSeq + 1);
+            jv1[jss::Delegate] = dave.human();
+            // jv2: proof also against spending balance 100, which is stale once jv1 applies
+            auto jv2 = mpt.sendJV({.account = bob, .dest = dave, .amt = 60}, bobSeq + 2);
+            jv2[jss::Delegate] = dave.human();
+
+            env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
+                batch::Inner(jv1, bobSeq + 1),
+                batch::Inner(jv2, bobSeq + 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Stale proof on jv2, AllOrNothing rolls back everything.
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
+            BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 0);
+        }
+
+        // AllOrNothing: two delegated sends with correctly chained proofs both apply.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
+
+            env(delegate::set(bob, dave, {"ConfidentialMPTSend"}));
+            env.close();
+
+            auto const bobSeq = env.seq(bob);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
+
+            // jv1: proof against spending balance 100.
+            auto jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, bobSeq + 1);
+            jv1[jss::Delegate] = dave.human();
+            auto const chain1 = mpt.chainAfterSend(bob, 40, jv1);
+            // jv2: proof against predicted spending balance 60.
+            auto jv2 = mpt.sendJV({.account = bob, .dest = dave, .amt = 40}, bobSeq + 2, chain1);
+            jv2[jss::Delegate] = dave.human();
+
+            env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
+                batch::Inner(jv1, bobSeq + 1),
+                batch::Inner(jv2, bobSeq + 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Both inner tx applied
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 20);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 40);
+            BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 40);
+        }
+    }
+
+    // Test missing delegation permission inside a batch.
+    void
+    testBatchDelegationMissingPermission(FeatureBitset features)
+    {
+        testcase("Batch delegation missing permission");
+        using namespace test::jtx;
+
+        // AllOrNothing: dave has no Send permission from bob, so the delegated
+        // inner send fails. The whole batch rolls back.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
+
+            // Bob grants dave only MergeInbox, not Send.
+            env(delegate::set(bob, dave, {"ConfidentialMPTMergeInbox"}));
+            env.close();
+
+            auto const bobSeq = env.seq(bob);
+            auto const carolSeq = env.seq(carol);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
+
+            // jv1: direct send from carol (valid proof).
+            auto const jv1 = mpt.sendJV({.account = carol, .dest = dave, .amt = 30}, carolSeq);
+            // jv2: delegated send, fails because dave has no Send permission.
+            auto jv2 = mpt.sendJV({.account = bob, .dest = carol, .amt = 50}, bobSeq + 1);
+            jv2[jss::Delegate] = dave.human();
+
+            env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
+                batch::Inner(jv1, carolSeq),
+                batch::Inner(jv2, bobSeq + 1),
+                batch::Sig(carol),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // jv1 applied in the batch view, then jv2 failed, so
+            // AllOrNothing discards both inner effects.
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 60);
+            BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 0);
+        }
+
+        // Independent: the delegated confidential send is skipped because lack of permission. The
+        // send from carol still applies.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
+
+            // Bob does not grant dave any permissions.
+            auto const bobSeq = env.seq(bob);
+            auto const carolSeq = env.seq(carol);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
+
+            auto jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 50}, bobSeq + 1);
+            jv1[jss::Delegate] = dave.human();
+            auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 30}, carolSeq);
+
+            env(batch::outer(bob, bobSeq, batchFee, tfIndependent),
+                batch::Inner(jv1, bobSeq + 1),
+                batch::Inner(jv2, carolSeq),
+                batch::Sig(carol),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // jv1 failed and jv2 applied.
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 30);
+            BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 30);
+        }
+    }
+
+    // Test batch outer signer is the delegated account.
+    void
+    testBatchDelegatedSendWithDelegateAsOuterAccount(FeatureBitset features)
+    {
+        testcase("Test batch delegated send with delegate as outer account");
+        using namespace test::jtx;
+
+        // Dave has delegation permission, but the inner Account is bob.
+        // Without bob's BatchSigner, the batch is rejected.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
+
+            env(delegate::set(bob, dave, {"ConfidentialMPTSend"}));
+            env.close();
+
+            auto const daveSeq = env.seq(dave);
+            auto const bobSeq = env.seq(bob);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
+
+            auto jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, bobSeq);
+            jv1[jss::Delegate] = dave.human();
+            auto const jv2 = mpt.mergeInboxJV({.account = dave});
+
+            env(batch::outer(dave, daveSeq, batchFee, tfAllOrNothing),
+                batch::Inner(jv1, bobSeq),
+                batch::Inner(jv2, daveSeq + 1),
+                Ter(temBAD_SIGNER));
+            env.close();
+
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
+        }
+
+        // Dave submits a mixed batch: bob signs inner tx1, and
+        // dave is the Delegate account signing for inner tx2.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
+
+            env(delegate::set(bob, dave, {"ConfidentialMPTSend"}));
+            env.close();
+
+            auto const daveSeq = env.seq(dave);
+            auto const bobSeq = env.seq(bob);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
+
+            auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, bobSeq);
+            auto const chain1 = mpt.chainAfterSend(bob, 40, jv1);
+            auto jv2 = mpt.sendJV({.account = bob, .dest = carol, .amt = 30}, bobSeq + 1, chain1);
+            jv2[jss::Delegate] = dave.human();
+
+            // Dave is outer; bob signs because his account appears in inner txns.
+            env(batch::outer(dave, daveSeq, batchFee, tfAllOrNothing),
+                batch::Inner(jv1, bobSeq),
+                batch::Inner(jv2, bobSeq + 1),
+                batch::Sig(bob),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Both sends applied: bob 100→30, carol inbox=70.
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 30);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 70);
+        }
+
+        // Verify the delegator Bob's BatchSigner does not bypass the missing delegation permission.
+        // The delegated inner send fails.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+
+            MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
+            setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
+
+            // Bob does not grant dave any permissions.
+            auto const daveSeq = env.seq(dave);
+            auto const bobSeq = env.seq(bob);
+            auto const carolSeq = env.seq(carol);
+            auto const batchFee = batch::calcConfidentialBatchFee(env, 2, 2);
+
+            auto jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 50}, bobSeq);
+            jv1[jss::Delegate] = dave.human();
+            auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 30}, carolSeq);
+
+            env(batch::outer(dave, daveSeq, batchFee, tfAllOrNothing),
+                batch::Inner(jv1, bobSeq),
+                batch::Inner(jv2, carolSeq),
+                batch::Sig(bob, carol),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // jv1 fails before jv2 is attempted.
+            BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
+            BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 60);
+            BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 0);
+        }
+    }
+
+    // Mixed batch with delegated and non-delegated inner confidential MPT transactions.
+    void
+    testBatchDelegatedConfidentialMix(FeatureBitset features)
+    {
+        testcase("Batch delegated confidential multiple operations");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const dave("dave");
+        Account const erin("erin");
+        Account const frank("frank");
+
+        MPTTester mpt(env, alice, {.holders = {bob, carol, dave, frank}});
+        setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
+        mpt.pay(alice, bob, 50);
+        env.fund(XRP(10000), erin);
+        env.close();
+
+        mpt.authorize({.account = frank});
+        mpt.pay(alice, frank, 40);
+        mpt.generateKeyPair(frank);
+
+        env(delegate::set(bob, dave, {"ConfidentialMPTConvert", "ConfidentialMPTConvertBack"}));
+        env(delegate::set(carol, erin, {"ConfidentialMPTSend"}));
+        env(delegate::set(bob, erin, {"ConfidentialMPTMergeInbox"}));
+        env.close();
+
+        auto const daveSeq = env.seq(dave);
+        auto const bobSeq = env.seq(bob);
+        auto const carolSeq = env.seq(carol);
+        auto const frankSeq = env.seq(frank);
+        auto const batchFee = batch::calcConfidentialBatchFee(env, 3, 6);
+
+        // Dave submits the batch. Bob's convert and convertback use Dave as Delegate;
+        // Carol's send and Bob's mergeInbox use Erin as Delegate. Frank's
+        // convert and mergeInbox are non-delegated.
+        auto jv1 = mpt.convertBackJV({.account = bob, .amt = 30}, bobSeq);
+        jv1[jss::Delegate] = dave.human();
+        auto jv2 = mpt.convertJV({.account = bob, .amt = 20}, bobSeq + 1);
+        jv2[jss::Delegate] = dave.human();
+        auto jv3 = mpt.sendJV({.account = carol, .dest = bob, .amt = 15}, carolSeq);
+        jv3[jss::Delegate] = erin.human();
+        auto const jv4 = mpt.convertJV(
+            {.account = frank, .amt = 25, .holderPubKey = mpt.getPubKey(frank)}, frankSeq);
+        auto const jv5 = mpt.mergeInboxJV({.account = frank});
+        auto jv6 = mpt.mergeInboxJV({.account = bob});
+        jv6[jss::Delegate] = erin.human();
+
+        env(batch::outer(dave, daveSeq, batchFee, tfAllOrNothing),
+            batch::Inner(jv1, bobSeq),
+            batch::Inner(jv2, bobSeq + 1),
+            batch::Inner(jv3, carolSeq),
+            batch::Inner(jv4, frankSeq),
+            batch::Inner(jv5, frankSeq + 1),
+            batch::Inner(jv6, bobSeq + 2),
+            batch::Sig(bob, carol, frank),
+            Ter(tesSUCCESS));
+        env.close();
+
+        env.require(MptBalance(mpt, bob, 60));
+        BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 105);
+        BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedInbox) == 0);
+        env.require(MptBalance(mpt, carol, 0));
+        BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 45);
+        BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
+        env.require(MptBalance(mpt, frank, 15));
+        BEAST_EXPECT(mpt.getDecryptedBalance(frank, MPTTester::holderEncryptedSpending) == 25);
+        BEAST_EXPECT(mpt.getDecryptedBalance(frank, MPTTester::holderEncryptedInbox) == 0);
+        env.require(MptBalance(mpt, dave, 0));
+        BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedSpending) == 0);
+        BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 0);
+        auto const outstandingBalance = mpt.getIssuanceOutstandingBalance();
+        BEAST_EXPECT(outstandingBalance && *outstandingBalance == 250);
+        BEAST_EXPECT(mpt.getIssuanceConfidentialBalance() == 175);
+    }
+
+    // Test invalid scenarios for delegation with tickets.
+    void
+    testInvalidDelegationWithTickets(FeatureBitset features)
+    {
+        testcase("Invalid cases for delegation with tickets");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+        env.fund(XRP(10000), carol);
+        env.close();
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance | tfMPTCanClawback,
+        });
+        mptAlice.authorize({.account = bob});
+        mptAlice.pay(alice, bob, 200);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)});
+
+        // Bob grants carol permissions.
+        env(delegate::set(bob, carol, {"ConfidentialMPTConvert"}));
+        env.close();
+
+        uint64_t const amt = 10;
+        auto const bf = generateBlindingFactor();
+        auto const holderCt = mptAlice.encryptAmount(bob, amt, bf);
+        auto const issuerCt = mptAlice.encryptAmount(alice, amt, bf);
+
+        // Invalid: proof built with wrong ticket sequence (ticketSeq + 1).
+        {
+            auto const ticketSeq = env.seq(bob) + 1;
+            env(ticket::create(bob, 1));
+
+            auto const badCtxHash =
+                getConvertContextHash(bob, mptAlice.issuanceID(), ticketSeq + 1);
+            auto const badProof = requireOptional(
+                mptAlice.getSchnorrProof(bob, badCtxHash), "Missing Schnorr Proof.");
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = amt,
+                .proof = strHex(badProof),
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .holderEncryptedAmt = holderCt,
+                .issuerEncryptedAmt = issuerCt,
+                .blindingFactor = bf,
+                .delegate = carol,
+                .ticketSeq = ticketSeq,
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // Invalid: proof built with account sequence instead of ticket sequence.
+        {
+            auto const ticketSeq = env.seq(bob) + 1;
+            env(ticket::create(bob, 1));
+            auto const badCtxHash = getConvertContextHash(bob, mptAlice.issuanceID(), env.seq(bob));
+            auto const badProof = requireOptional(
+                mptAlice.getSchnorrProof(bob, badCtxHash), "Missing Schnorr Proof.");
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = amt,
+                .proof = strHex(badProof),
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .holderEncryptedAmt = holderCt,
+                .issuerEncryptedAmt = issuerCt,
+                .blindingFactor = bf,
+                .delegate = carol,
+                .ticketSeq = ticketSeq,
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // Invalid: ticket sequence is far in the future and hasn't been created yet.
+        {
+            mptAlice.convert({
+                .account = bob,
+                .amt = amt,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .holderEncryptedAmt = holderCt,
+                .issuerEncryptedAmt = issuerCt,
+                .blindingFactor = bf,
+                .delegate = carol,
+                .ticketSeq = env.seq(bob) + 100,
+                .err = terPRE_TICKET,
+            });
+        }
+
+        // Invalid: ticket sequence is in the past but was never created.
+        {
+            mptAlice.convert({
+                .account = bob,
+                .amt = amt,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .holderEncryptedAmt = holderCt,
+                .issuerEncryptedAmt = issuerCt,
+                .blindingFactor = bf,
+                .delegate = carol,
+                .ticketSeq = 1,
+                .err = tefNO_TICKET,
+            });
+        }
+
+        // Invalid: the delegated account, carol, creates a ticket and uses it.
+        {
+            auto const carolTicketSeq = env.seq(carol) + 1;
+            env(ticket::create(carol, 1));
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = amt,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .holderEncryptedAmt = holderCt,
+                .issuerEncryptedAmt = issuerCt,
+                .blindingFactor = bf,
+                .delegate = carol,
+                .ticketSeq = carolTicketSeq,
+                .err = tefNO_TICKET,
+            });
+        }
+
+        // Invalid: proof bound to a ticket sequence but submitted without a ticket,
+        // using account sequence.
+        {
+            auto const ticketSeq = env.seq(bob) + 1;
+            env(ticket::create(bob, 1));
+
+            // Build proof using ticketSeq.
+            auto const ctxHashForTicket =
+                getConvertContextHash(bob, mptAlice.issuanceID(), ticketSeq);
+            auto const proof = requireOptional(
+                mptAlice.getSchnorrProof(bob, ctxHashForTicket), "Missing Schnorr Proof.");
+
+            // Submit without ticket.
+            mptAlice.convert({
+                .account = bob,
+                .amt = amt,
+                .proof = strHex(proof),
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .holderEncryptedAmt = holderCt,
+                .issuerEncryptedAmt = issuerCt,
+                .blindingFactor = bf,
+                .delegate = carol,
+                .err = tecBAD_PROOF,
+            });
+        }
+    }
+
+    // Verifies that delegation works correctly when the delegating account uses
+    // tickets instead of regular sequence numbers. The proof must bind to the
+    // ticket sequence, not the account sequence.
+    void
+    testDelegationWithTickets(FeatureBitset features)
+    {
+        testcase("Confidential delegation with tickets");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const dave("dave");
+        MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+        env.fund(XRP(10000), dave);
+        env.close();
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance | tfMPTCanClawback,
+        });
+        mptAlice.authorize({.account = bob});
+        mptAlice.authorize({.account = carol});
+        mptAlice.pay(alice, bob, 200);
+        mptAlice.pay(alice, carol, 100);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.generateKeyPair(carol);
+        mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)});
+
+        // Bob grants dave permissions.
+        env(delegate::set(
+            bob,
+            dave,
+            {"ConfidentialMPTConvert",
+             "ConfidentialMPTMergeInbox",
+             "ConfidentialMPTSend",
+             "ConfidentialMPTConvertBack"}));
+        // Alice grants dave permission to clawback on her behalf.
+        env(delegate::set(alice, dave, {"ConfidentialMPTClawback"}));
+        env.close();
+
+        // Dave executes Convert on behalf of bob using ticket.
+        auto ticketSeq = env.seq(bob) + 1;
+        env(ticket::create(bob, 1));
+        BEAST_EXPECT(env.seq(bob) != ticketSeq);
+        mptAlice.convert({
+            .account = bob,
+            .amt = 100,
+            .holderPubKey = mptAlice.getPubKey(bob),
+            .delegate = dave,
+            .ticketSeq = ticketSeq,
+        });
+        env.require(MptBalance(mptAlice, bob, 100));
+
+        // MergeInbox using ticket with delegation.
+        ticketSeq = env.seq(bob) + 1;
+        env(ticket::create(bob, 1));
+        BEAST_EXPECT(env.seq(bob) != ticketSeq);
+        mptAlice.mergeInbox({.account = bob, .delegate = dave, .ticketSeq = ticketSeq});
+
+        // Carol converts and merges inbox to receive from bob.
+        mptAlice.convert({
+            .account = carol,
+            .amt = 50,
+            .holderPubKey = mptAlice.getPubKey(carol),
+        });
+        mptAlice.mergeInbox({.account = carol});
+
+        // Send using ticket with delegation.
+        ticketSeq = env.seq(bob) + 1;
+        env(ticket::create(bob, 1));
+        BEAST_EXPECT(env.seq(bob) != ticketSeq);
+        mptAlice.send({
+            .account = bob,
+            .dest = carol,
+            .amt = 20,
+            .delegate = dave,
+            .ticketSeq = ticketSeq,
+        });
+
+        // ConvertBack using ticket with delegation.
+        ticketSeq = env.seq(bob) + 1;
+        env(ticket::create(bob, 1));
+        BEAST_EXPECT(env.seq(bob) != ticketSeq);
+        mptAlice.convertBack({
+            .account = bob,
+            .amt = 10,
+            .delegate = dave,
+            .ticketSeq = ticketSeq,
+        });
+
+        // Clawback using ticket with delegation.
+        ticketSeq = env.seq(alice) + 1;
+        env(ticket::create(alice, 1));
+        BEAST_EXPECT(env.seq(alice) != ticketSeq);
+        mptAlice.confidentialClaw({
+            .holder = bob,
+            .amt = 70,
+            .delegate = dave,
+            .ticketSeq = ticketSeq,
+        });
+    }
+
+    void
+    testWithFeats(FeatureBitset features)
+    {
+        // DepositAuth, credentials, and destination tag interactions.
+        testSendDepositPreauth(features);
+        testSendCredentialValidation(features);
+        testDestinationTag(features);
+
+        // AMM/pseudo-account interaction.
+        testAMMHolderCannotHaveConfidentialStateClawback(features);
+
+        // Ticket interactions.
+        testWithTickets(features);
+        testConvertTicketProofBinding(features);
+        testTicketErrors(features);
+
+        // Batch interactions.
+        testBatchConfidentialSend(features);
+        testBatchConfidentialConvertAndConvertBack(features);
+        testBatchConfidentialMixTransactions(features);
+        testBatchAllOrNothing(features);
+        testBatchOnlyOne(features);
+        testBatchUntilFailure(features);
+        testBatchIndependent(features);
+        testBatchWithTickets(features);
+
+        // Permission delegation interactions.
+        testConfidentialDelegation(features);
+        testDelegationRevocation(features);
+        testDelegationWithAuditor(features);
+        testDelegationClawbackIssuerOnly(features);
+        testBatchDelegatedSend(features);
+        testBatchDelegationMissingPermission(features);
+        testBatchDelegatedSendWithDelegateAsOuterAccount(features);
+        testBatchDelegatedConfidentialMix(features);
+        testInvalidDelegationWithTickets(features);
+        testDelegationWithTickets(features);
+    }
+
+public:
+    void
+    run() override
+    {
+        using namespace test::jtx;
+        FeatureBitset const all{testableAmendments()};
+
+        testWithFeats(all);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(ConfidentialTransferExtended, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp
new file mode 100644
index 0000000000..e6faf0a2ae
--- /dev/null
+++ b/src/test/app/ConfidentialTransfer_test.cpp
@@ -0,0 +1,8208 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+class ConfidentialTransfer_test : public ConfidentialTransferTestBase
+{
+    void
+    testConvert(FeatureBitset features)
+    {
+        testcase("Convert");
+        using namespace test::jtx;
+
+        // Basic convert test
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 0,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 20,
+            });
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 40,
+            });
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 40,
+            });
+        }
+
+        // Edge case: minimum amount (1)
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 1);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+            mptAlice.convert({
+                .account = bob,
+                .amt = 0,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 1,
+            });
+        }
+
+        // Edge case: kMaxMpTokenAmount
+        // Using raw JSON to avoid automatic decryption checks in MPTTester
+        // which don't work for very large amounts (brute-force decryption is slow)
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, kMaxMpTokenAmount);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+
+            // First convert with amt=0 to register public key (uses MPTTester)
+            mptAlice.convert({
+                .account = bob,
+                .amt = 0,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            // Second convert with kMaxMpTokenAmount using raw JSON
+            Buffer const blindingFactor = generateBlindingFactor();
+            auto const holderCiphertext =
+                mptAlice.encryptAmount(bob, kMaxMpTokenAmount, blindingFactor);
+            auto const issuerCiphertext =
+                mptAlice.encryptAmount(alice, kMaxMpTokenAmount, blindingFactor);
+
+            json::Value jv;
+            jv[jss::Account] = bob.human();
+            jv[jss::TransactionType] = jss::ConfidentialMPTConvert;
+            jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID());
+            jv[sfMPTAmount.jsonName] = std::to_string(kMaxMpTokenAmount);
+            jv[sfHolderEncryptedAmount.jsonName] = strHex(holderCiphertext);
+            jv[sfIssuerEncryptedAmount.jsonName] = strHex(issuerCiphertext);
+            jv[sfBlindingFactor.jsonName] = strHex(blindingFactor);
+
+            env(jv, Ter(tesSUCCESS));
+
+            // Verify the public balance was reduced
+            env.require(MptBalance(mptAlice, bob, 0));
+        }
+    }
+
+    void
+    testConvertWithAuditor(FeatureBitset features)
+    {
+        testcase("Convert with auditor");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const auditor("auditor");
+        MPTTester mptAlice(
+            env,
+            alice,
+            {
+                .holders = {bob},
+                .auditor = auditor,
+            });
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+        });
+
+        mptAlice.authorize({
+            .account = bob,
+        });
+        mptAlice.pay(alice, bob, 100);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(auditor);
+
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(alice),
+            .auditorPubKey = mptAlice.getPubKey(auditor),
+        });
+
+        mptAlice.generateKeyPair(bob);
+
+        mptAlice.convert({
+            .account = bob,
+            .amt = 0,
+            .holderPubKey = mptAlice.getPubKey(bob),
+        });
+
+        mptAlice.convert({
+            .account = bob,
+            .amt = 20,
+        });
+
+        mptAlice.convert({
+            .account = bob,
+            .amt = 30,
+        });
+    }
+
+    void
+    testConvertPreflight(FeatureBitset features)
+    {
+        testcase("Convert preflight");
+        using namespace test::jtx;
+
+        // Alice (issuer) tries to convert her own tokens - should fail
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            MPTTester mptAlice(env, alice);
+
+            mptAlice.create({
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.convert({
+                .account = alice,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(alice),
+                .err = temMALFORMED,
+            });
+        }
+
+        {
+            Env env{*this, features - featureConfidentialTransfer};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .err = temDISABLED,
+            });
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = temDISABLED,
+            });
+        }
+
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convert({
+                .account = alice,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = temMALFORMED,
+            });
+
+            // Holder encrypted amount is empty (length 0)
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .holderEncryptedAmt = Buffer{},
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // Issuer encrypted amount is empty (length 0)
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .issuerEncryptedAmt = Buffer{},
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // Auditor encrypted amount has invalid length (must be 66 bytes)
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .auditorEncryptedAmt = gMakeZeroBuffer(10),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // Auditor encrypted amount has correct length but invalid data
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .auditorEncryptedAmt = getBadCiphertext(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // Amount exceeds maximum allowed MPT amount
+            mptAlice.convert({
+                .account = bob,
+                .amt = kMaxMpTokenAmount + 1,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = temBAD_AMOUNT,
+            });
+
+            // Holder encrypted amount has correct length but invalid data
+            mptAlice.convert({
+                .account = bob,
+                .amt = 1,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .holderEncryptedAmt = getBadCiphertext(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // Issuer encrypted amount has correct length but invalid data (not
+            // a valid EC point)
+            mptAlice.convert({
+                .account = bob,
+                .amt = 1,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .issuerEncryptedAmt = getBadCiphertext(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // Holder public key is invalid (empty buffer)
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = Buffer{},
+                .err = temMALFORMED,
+            });
+
+            // Holder public key has correct length but invalid EC point data
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = gMakeZeroBuffer(kEcPubKeyLength),
+                .err = temMALFORMED,
+            });
+        }
+
+        // when registering holder pub key, the transaction must include a
+        // Schnorr proof of knowledge for the corresponding secret key
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .fillSchnorrProof = false,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = temMALFORMED,
+            });
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 0,
+                .fillSchnorrProof = false,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = temMALFORMED,
+            });
+
+            // proof length is invalid
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .proof = std::string(10, 'A'),
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = temMALFORMED,
+            });
+        }
+
+        // when holder pub key already registered, Schnorr proof must not be
+        // provided
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // this will register bob's pub key,
+            // and convert 10 to confidential balance
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            // proof must not be provided after pub key was registered
+            mptAlice.convert({
+                .account = bob,
+                .amt = 20,
+                .fillSchnorrProof = true,
+                .err = temMALFORMED,
+            });
+        }
+    }
+
+    void
+    testConvertInvalidProofContextBinding(FeatureBitset features)
+    {
+        testcase("Convert proof context binding");
+        using namespace test::jtx;
+
+        auto runBadProof = [&](auto makeContextHash) {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+            mptAlice.authorize({.account = bob});
+            mptAlice.authorize({.account = carol});
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(carol);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            auto const proof =
+                mptAlice.getSchnorrProof(bob, makeContextHash(env, mptAlice, alice, bob, carol));
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .proof = strHex(requireOptional(proof, "Missing proof")),
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = tecBAD_PROOF,
+            });
+        };
+
+        // Wrong account in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const& mpt,
+                        Account const&,
+                        Account const& bob,
+                        Account const& carol) {
+            return getConvertContextHash(carol.id(), mpt.issuanceID(), env.seq(bob));
+        });
+
+        // Wrong issuance ID in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const&,
+                        Account const& alice,
+                        Account const& bob,
+                        Account const&) {
+            return getConvertContextHash(
+                bob.id(), makeMptID(env.seq(alice) + 100, alice), env.seq(bob));
+        });
+
+        // Wrong transaction sequence in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const& mpt,
+                        Account const&,
+                        Account const& bob,
+                        Account const&) {
+            return getConvertContextHash(bob.id(), mpt.issuanceID(), env.seq(bob) + 1);
+        });
+    }
+
+    void
+    testSet(FeatureBitset features)
+    {
+        testcase("Set");
+        using namespace test::jtx;
+
+        // Set keys on issuance that already has confidential amounts enabled
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const auditor("auditor");
+            MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(auditor);
+
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+        }
+
+        // Enable confidential amounts flag only (no keys)
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            MPTTester mptAlice(env, alice, {.holders = {}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock,
+            });
+
+            mptAlice.set({
+                .account = alice,
+                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+            });
+        }
+
+        // Set keys when enabling confidential amounts in the same tx
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const auditor("auditor");
+            MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock,
+            });
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(auditor);
+
+            mptAlice.set({
+                .account = alice,
+                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+
+            // Verify lsfMPTCanHoldConfidentialBalance flag is set
+            BEAST_EXPECT(mptAlice.checkFlags(
+                lsfMPTCanTransfer | lsfMPTCanLock | lsfMPTCanHoldConfidentialBalance));
+
+            // Verify keys are persisted on the issuance
+            auto const sle = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
+            BEAST_EXPECT(sle);
+            BEAST_EXPECT(sle->isFieldPresent(sfIssuerEncryptionKey));
+            BEAST_EXPECT(sle->isFieldPresent(sfAuditorEncryptionKey));
+        }
+    }
+
+    void
+    testSetPreflight(FeatureBitset features)
+    {
+        testcase("Set preflight");
+        using namespace test::jtx;
+
+        {
+            Env env{*this, features - featureConfidentialTransfer};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .err = temDISABLED,
+            });
+        }
+
+        // pub key is invalid
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            // Issuer pub key is invalid (empty)
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = Buffer{},
+                .err = temMALFORMED,
+            });
+
+            // Issuer pub key has correct length but invalid EC point data
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = gMakeZeroBuffer(kEcPubKeyLength),
+                .err = temMALFORMED,
+            });
+
+            // Auditor key is invalid length
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = gMakeZeroBuffer(10),
+                .err = temMALFORMED,
+            });
+
+            // Auditor key has correct length but invalid EC point data
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = gMakeZeroBuffer(kEcPubKeyLength),
+                .err = temMALFORMED,
+            });
+
+            // Cannot set auditor key without issuer key
+            mptAlice.set({
+                .account = alice,
+                .auditorPubKey = mptAlice.getPubKey(alice),
+                .err = temMALFORMED,
+            });
+
+            // Cannot set Holder and issuer Keys in the same transaction
+            mptAlice.set({
+                .account = alice,
+                .holder = bob,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .err = temMALFORMED,
+            });
+
+            // Cannot set Holder and auditor Keys in the same transaction
+            mptAlice.set({
+                .account = alice,
+                .holder = bob,
+                .auditorPubKey = mptAlice.getPubKey(alice),
+                .err = temMALFORMED,
+            });
+        }
+    }
+
+    void
+    testSetPreclaim(FeatureBitset features)
+    {
+        testcase("Set preclaim");
+        using namespace test::jtx;
+
+        // Cannot set issuer key if confidential amounts not enabled
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            MPTTester mptAlice(env, alice, {.holders = {}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock,
+            });
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Cannot update issuer public key once set
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            // First set issuer key - should succeed
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+            });
+
+            // Try to update issuer key - should fail
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(bob),
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Cannot update issuer and auditor public keys once set
+        // Note: trying to set only auditor key fails in preflight (temMALFORMED)
+        // so we must provide both keys, which fails on issuer key check first
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            MPTTester mptAlice(env, alice, {.holders = {bob}, .auditor = auditor});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(auditor);
+
+            // Set issuer and auditor keys - should succeed
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+
+            // Try to update both keys - fails on issuer key check first
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(bob),
+                .auditorPubKey = mptAlice.getPubKey(alice),
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Cannot set auditor key if confidential amounts not enabled
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const auditor("auditor");
+            MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock,
+            });
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(auditor);
+
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Cannot set keys when mutation of canConfidentialAmount is disallowed
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            MPTTester mptAlice(env, alice, {.holders = {}});
+
+            // Create with tmfMPTCannotEnableCanHoldConfidentialBalance
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock,
+                .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance,
+            });
+
+            mptAlice.generateKeyPair(alice);
+
+            // Trying to enable confidential amounts and set keys fails
+            // because the issuance cannot mutate canConfidentialAmount
+            mptAlice.set({
+                .account = alice,
+                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Set issuer key first, then auditor key in a separate tx
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const auditor("auditor");
+            MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(auditor);
+
+            // Set issuer key only
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+            });
+
+            // Set auditor key in a separate tx - requires issuer key in tx
+            // (preflight enforces auditor key requires issuer key)
+            // This fails because issuer key is already set on ledger
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+                .err = tecNO_PERMISSION,
+            });
+        }
+    }
+
+    void
+    testTransferFee(FeatureBitset features)
+    {
+        testcase("test transfer fee");
+        using namespace test::jtx;
+
+        // MPTokenIssuanceCreate: cannot create with both TransferFee > 0 and
+        // tfMPTCanHoldConfidentialBalance
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            MPTTester mptAlice(env, alice, {.holders = {}});
+
+            mptAlice.create({
+                .transferFee = 100,
+                .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+                .err = temBAD_TRANSFER_FEE,
+            });
+
+            // transferFee being 0 is allowed, even with tfMPTCanHoldConfidentialBalance
+            mptAlice.create({
+                .transferFee = 0,
+                .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+            });
+        }
+
+        // MPTokenIssuanceSet (preflight): cannot enable confidential amounts and
+        // set TransferFee > 0 in the same transaction
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            MPTTester mptAlice(env, alice, {.holders = {}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock,
+                .mutableFlags = tmfMPTCanMutateTransferFee,
+            });
+
+            mptAlice.set({
+                .account = alice,
+                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .transferFee = 100,
+                .err = temBAD_TRANSFER_FEE,
+            });
+        }
+
+        // MPTokenIssuanceSet (preclaim): cannot enable confidential amounts on
+        // an issuance that already has a non-zero TransferFee
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            MPTTester mptAlice(env, alice, {.holders = {}});
+
+            mptAlice.create({
+                .transferFee = 100,
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock,
+                .mutableFlags = tmfMPTCanMutateTransferFee,
+            });
+
+            mptAlice.set({
+                .account = alice,
+                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // MPTokenIssuanceSet (preclaim): cannot set TransferFee > 0 on an
+        // issuance that already has lsfMPTCanHoldConfidentialBalance
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            MPTTester mptAlice(env, alice, {.holders = {}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+                .mutableFlags = tmfMPTCanMutateTransferFee,
+            });
+
+            mptAlice.set({
+                .account = alice,
+                .transferFee = 100,
+                .err = tecNO_PERMISSION,
+            });
+
+            // Setting transfer fee to 0 is allowed, but have no effect.
+            mptAlice.set({
+                .account = alice,
+                .transferFee = 0,
+            });
+        }
+    }
+
+    void
+    testConvertPreclaim(FeatureBitset features)
+    {
+        testcase("Convert preclaim");
+        using namespace test::jtx;
+
+        // tfMPTCanHoldConfidentialBalance is not set on issuance
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // issuer has not uploaded their sfIssuerEncryptionKey
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // issuance does not exist
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.destroy();
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = tecOBJECT_NOT_FOUND,
+            });
+        }
+
+        // bob has not created MPToken
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = tecOBJECT_NOT_FOUND,
+            });
+        }
+
+        // Verification of Issuer and and holder ciphertexts
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(carol);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .holderEncryptedAmt = getTrivialCiphertext(),
+                .err = tecBAD_PROOF,
+            });
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .issuerEncryptedAmt = getTrivialCiphertext(),
+                .err = tecBAD_PROOF,
+            });
+
+            std::uint64_t const amount = 10;
+            Buffer const blindingFactor = generateBlindingFactor();
+            Buffer const holderCiphertext = mptAlice.encryptAmount(bob, amount, blindingFactor);
+
+            // Holder ciphertext is valid for the amount and
+            // blinding factor, but the issuer ciphertext is encrypted under a
+            // different public key than the registered issuer key.
+            Buffer const wrongIssuerCiphertext =
+                mptAlice.encryptAmount(carol, amount, blindingFactor);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = amount,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .holderEncryptedAmt = holderCiphertext,
+                .issuerEncryptedAmt = wrongIssuerCiphertext,
+                .blindingFactor = blindingFactor,
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // trying to convert more than what bob has
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 200,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = tecINSUFFICIENT_FUNDS,
+            });
+        }
+
+        // holder cannot upload pk again
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convert({.account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob)});
+
+            // cannot upload pk again
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = tecDUPLICATE,
+            });
+        }
+
+        // cannot convert if locked
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.set({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTLock,
+            });
+
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = tecLOCKED,
+            });
+
+            mptAlice.set({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTUnlock,
+            });
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+        }
+
+        // cannot convert if unauth
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth |
+                    tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+
+            // Unauthorize bob
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTUnauthorize,
+            });
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = tecNO_AUTH,
+            });
+
+            // auth bob
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+            });
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+        }
+
+        // frozen account cannot bypass freeze check with amount=0
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // lock bob
+            mptAlice.set({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTLock,
+            });
+
+            mptAlice.generateKeyPair(bob);
+
+            // amount=0 should still be rejected when locked
+            mptAlice.convert({
+                .account = bob,
+                .amt = 0,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = tecLOCKED,
+            });
+        }
+
+        // unauthorized account cannot bypass auth check with amount=0
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth |
+                    tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+
+            // Unauthorize bob
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTUnauthorize,
+            });
+
+            // amount=0 should still be rejected when unauthorized
+            mptAlice.convert({
+                .account = bob,
+                .amt = 0,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = tecNO_AUTH,
+            });
+        }
+
+        // cannot convert if auditor key is set, but auditor amount is not
+        // provided
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            MPTTester mptAlice(
+                env,
+                alice,
+                {
+                    .holders = {bob},
+                    .auditor = auditor,
+                });
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(auditor);
+
+            mptAlice.set(
+                {.account = alice,
+                 .issuerPubKey = mptAlice.getPubKey(alice),
+                 .auditorPubKey = mptAlice.getPubKey(auditor)});
+
+            // no auditor encrypted amt provided
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .fillAuditorEncryptedAmt = false,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // cannot convert if tx include auditor ciphertext, but does not have
+        // auditing enabled
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            // there is no auditor key set
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .auditorEncryptedAmt = getTrivialCiphertext(),
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Auditor key set successfully, auditor ciphertext mathematically
+        // correct, but contains invalid data (mismatching amount).
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            MPTTester mptAlice(
+                env,
+                alice,
+                {
+                    .holders = {bob},
+                    .auditor = auditor,
+                });
+
+            mptAlice.create({
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(auditor);
+
+            mptAlice.set(
+                {.account = alice,
+                 .issuerPubKey = mptAlice.getPubKey(alice),
+                 .auditorPubKey = mptAlice.getPubKey(auditor)});
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .auditorEncryptedAmt = getTrivialCiphertext(),
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // invalid proof when registering holder pub key
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .proof = std::string(kEcSchnorrProofLength * 2, 'A'),
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // no holder key on ledger and no key in tx
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // bob has not registered a holder key, and doesn't provide one
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // all public balance already converted, try to convert more
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+
+            // convert entire public balance
+            mptAlice.convert({
+                .account = bob,
+                .amt = 100,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            env.require(MptBalance(mptAlice, bob, 0));
+
+            // try to convert 1 more — no public balance left
+            mptAlice.convert({
+                .account = bob,
+                .amt = 1,
+                .err = tecINSUFFICIENT_FUNDS,
+            });
+        }
+    }
+
+    void
+    testMergeInbox(FeatureBitset features)
+    {
+        testcase("Merge inbox");
+        using namespace test::jtx;
+
+        // Merge with an empty inbox should succeed as a no-op.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+            mptAlice.authorize({.account = bob});
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+            });
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 40,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            mptAlice.mergeInbox({.account = bob});
+            // Inbox is empty after the first merge; the second merge is a no-op.
+            mptAlice.mergeInbox({.account = bob});
+        }
+
+        // Makes sure if merge inbox version is UINT32_MAX, the next merge wraps
+        // the version back to 0.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+            mptAlice.authorize({.account = bob});
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+            });
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 40,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            // Force the on-ledger version to UINT32_MAX, then apply a merge and
+            // confirm the version wraps around to 0.
+            auto const wrappedFrom = std::numeric_limits::max();
+            auto const jt = env.jt(mptAlice.mergeInboxJV({.account = bob}));
+            BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
+                auto const sle = std::const_pointer_cast(
+                    view.read(keylet::mptoken(mptAlice.issuanceID(), bob.id())));
+                if (!sle)
+                    return false;
+
+                (*sle)[sfConfidentialBalanceVersion] = wrappedFrom;
+                view.rawReplace(sle);
+
+                auto const result = xrpl::apply(env.app(), view, *jt.stx, TapNone, env.journal);
+                BEAST_EXPECT(result.ter == tesSUCCESS);
+                return result.applied;
+            }));
+
+            BEAST_EXPECT(mptAlice.getMPTokenVersion(bob) == 0);
+        }
+    }
+
+    void
+    testMergeInboxPreflight(FeatureBitset features)
+    {
+        testcase("Merge inbox preflight");
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+        });
+
+        mptAlice.authorize({
+            .account = bob,
+        });
+        mptAlice.pay(alice, bob, 100);
+
+        mptAlice.generateKeyPair(alice);
+
+        mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+        mptAlice.generateKeyPair(bob);
+
+        mptAlice.convert({
+            .account = bob,
+            .amt = 40,
+            .holderPubKey = mptAlice.getPubKey(bob),
+        });
+
+        mptAlice.mergeInbox({
+            .account = alice,
+            .err = temMALFORMED,
+        });
+
+        env.disableFeature(featureConfidentialTransfer);
+        env.close();
+
+        mptAlice.mergeInbox({
+            .account = bob,
+            .err = temDISABLED,
+        });
+    }
+
+    void
+    testMergeInboxPreclaim(FeatureBitset features)
+    {
+        testcase("Merge inbox preclaim");
+        using namespace test::jtx;
+
+        // issuance does not exist
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.destroy();
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.mergeInbox({
+                .account = bob,
+                .err = tecOBJECT_NOT_FOUND,
+            });
+        }
+
+        // tfMPTCanHoldConfidentialBalance is not set on issuance
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.mergeInbox({
+                .account = bob,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // no mptoken
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.mergeInbox({
+                .account = bob,
+                .err = tecOBJECT_NOT_FOUND,
+            });
+        }
+
+        // bob doesn't have encrypted balances
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.mergeInbox({
+                .account = bob,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // holder is locked
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            // lock bob
+            mptAlice.set({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTLock,
+            });
+
+            mptAlice.mergeInbox({
+                .account = bob,
+                .err = tecLOCKED,
+            });
+
+            // unlock bob
+            mptAlice.set({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTUnlock,
+            });
+
+            // should succeed now
+            mptAlice.mergeInbox({
+                .account = bob,
+            });
+        }
+
+        // holder not authorized
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance |
+                    tfMPTRequireAuth,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            // unauthorize bob
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTUnauthorize,
+            });
+
+            mptAlice.mergeInbox({
+                .account = bob,
+                .err = tecNO_AUTH,
+            });
+
+            // authorize bob again
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+            });
+
+            // should succeed now
+            mptAlice.mergeInbox({
+                .account = bob,
+            });
+        }
+    }
+
+    void
+    testSend(FeatureBitset features)
+    {
+        testcase("test confidential send");
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob, .payAmount = 100, .convertAmount = 60},
+             {.account = carol, .payAmount = 50, .convertAmount = 20}}};
+        auto& mptAlice = confEnv.mpt;
+
+        // bob sends 10 to carol
+        mptAlice.send({
+            .account = bob,
+            .dest = carol,
+            .amt = 10,
+        });
+
+        // bob sends 1 to carol again
+        mptAlice.send({
+            .account = bob,
+            .dest = carol,
+            .amt = 1,
+        });
+
+        mptAlice.mergeInbox({
+            .account = carol,
+        });
+
+        // carol sends 15 back to bob
+        mptAlice.send({
+            .account = carol,
+            .dest = bob,
+            .amt = 15,
+        });
+    }
+
+    void
+    testSendWithAuditor(FeatureBitset features)
+    {
+        testcase("test confidential send with auditor");
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const auditor("auditor");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob, .payAmount = 100, .convertAmount = 60},
+             {.account = carol, .payAmount = 50, .convertAmount = 20}},
+            tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            auditor};
+        auto& mptAlice = confEnv.mpt;
+
+        // bob sends 10 to carol
+        mptAlice.send({
+            .account = bob,
+            .dest = carol,
+            .amt = 10,
+        });
+
+        // bob sends 1 to carol again
+        mptAlice.send({
+            .account = bob,
+            .dest = carol,
+            .amt = 1,
+        });
+
+        mptAlice.mergeInbox({
+            .account = carol,
+        });
+
+        // carol sends 15 back to bob
+        mptAlice.send({
+            .account = carol,
+            .dest = bob,
+            .amt = 15,
+        });
+    }
+
+    void
+    testSendPreflight(FeatureBitset features)
+    {
+        testcase("test ConfidentialMPTSend Preflight");
+        using namespace test::jtx;
+
+        // test disabled
+        {
+            Env env{*this, features - featureConfidentialTransfer};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+
+            mptAlice.create();
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.authorize({
+                .account = carol,
+            });
+
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .senderEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength),
+                .destEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength),
+                .issuerEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength),
+                .err = temDISABLED,
+            });
+        }
+
+        // test malformed
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.authorize({
+                .account = carol,
+            });
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(carol);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+            mptAlice.pay(alice, bob, 100);
+            mptAlice.pay(alice, carol, 50);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            mptAlice.convert({
+                .account = carol,
+                .amt = 40,
+                .holderPubKey = mptAlice.getPubKey(carol),
+            });
+
+            // issuer can not be the same as sender
+            mptAlice.send({
+                .account = alice,
+                .dest = carol,
+                .amt = 10,
+                .err = temMALFORMED,
+            });
+
+            // can not send to self
+            mptAlice.send({
+                .account = bob,
+                .dest = bob,
+                .amt = 10,
+                .err = temMALFORMED,
+            });
+
+            // can not send to issuer
+            mptAlice.send({
+                .account = bob,
+                .dest = alice,
+                .amt = 10,
+                .err = temMALFORMED,
+            });
+
+            // sender encrypted amount wrong length
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .senderEncryptedAmt = gMakeZeroBuffer(10),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // dest encrypted amount wrong length
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .destEncryptedAmt = gMakeZeroBuffer(10),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // issuer encrypted amount wrong length
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .issuerEncryptedAmt = gMakeZeroBuffer(10),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // sender encrypted amount malformed
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .senderEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // dest encrypted amount malformed
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .destEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // issuer encrypted amount malformed
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .issuerEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // invalid proof length
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = std::string(10, 'A'),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temMALFORMED,
+            });
+
+            // invalid amount Pedersen commitment length
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .amountCommitment = gMakeZeroBuffer(100),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temMALFORMED,
+            });
+
+            // invalid balance Pedersen commitment length
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = gMakeZeroBuffer(100),
+                .err = temMALFORMED,
+            });
+
+            // amount Pedersen commitment has correct length but invalid EC point data
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .amountCommitment = gMakeZeroBuffer(kEcPedersenCommitmentLength),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temMALFORMED,
+            });
+
+            // balance Pedersen commitment has correct length but invalid EC point data
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = gMakeZeroBuffer(kEcPedersenCommitmentLength),
+                .err = temMALFORMED,
+            });
+        }
+
+        // test bad ciphertext
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const auditor("auditor");
+            MPTTester mptAlice(
+                env,
+                alice,
+                {
+                    .holders = {bob, carol},
+                    .auditor = auditor,
+                });
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.authorize({
+                .account = carol,
+            });
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(carol);
+            mptAlice.generateKeyPair(auditor);
+
+            mptAlice.set(
+                {.account = alice,
+                 .issuerPubKey = mptAlice.getPubKey(alice),
+                 .auditorPubKey = mptAlice.getPubKey(auditor)});
+            mptAlice.pay(alice, bob, 100);
+            mptAlice.pay(alice, carol, 50);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            mptAlice.convert({
+                .account = carol,
+                .amt = 40,
+                .holderPubKey = mptAlice.getPubKey(carol),
+            });
+
+            // auditor encrypted amount wrong length
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .auditorEncryptedAmt = gMakeZeroBuffer(10),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // auditor encrypted amount (correct length, invalid data)
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .auditorEncryptedAmt = getBadCiphertext(),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temBAD_CIPHERTEXT,
+            });
+        }
+    }
+
+    void
+    testSendPreclaim(FeatureBitset features)
+    {
+        testcase("test ConfidentialMPTSend Preclaim");
+
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const dave("dave");
+        Account const eve("eve");
+        MPTTester mptAlice(env, alice, {.holders = {bob, carol, dave, eve}});
+
+        // authorize bob, carol, dave (not eve)
+        mptAlice.create({
+            .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth |
+                tfMPTCanHoldConfidentialBalance,
+        });
+        mptAlice.authorize({
+            .account = bob,
+        });
+        mptAlice.authorize({
+            .account = alice,
+            .holder = bob,
+        });
+        mptAlice.authorize({
+            .account = carol,
+        });
+        mptAlice.authorize({
+            .account = alice,
+            .holder = carol,
+        });
+        mptAlice.authorize({
+            .account = dave,
+        });
+        mptAlice.authorize({
+            .account = alice,
+            .holder = dave,
+        });
+
+        // fund bob, carol (not dave or eve)
+        mptAlice.pay(alice, bob, 100);
+        mptAlice.pay(alice, carol, 50);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.generateKeyPair(carol);
+        mptAlice.generateKeyPair(dave);
+        mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+        // bob and carol convert some funds to confidential
+        mptAlice.convert({
+            .account = bob,
+            .amt = 60,
+            .holderPubKey = mptAlice.getPubKey(bob),
+            .err = tesSUCCESS,
+        });
+        mptAlice.convert({
+            .account = carol,
+            .amt = 20,
+            .holderPubKey = mptAlice.getPubKey(carol),
+            .err = tesSUCCESS,
+        });
+
+        // bob and carol merge inbox
+        mptAlice.mergeInbox({
+            .account = bob,
+        });
+        mptAlice.mergeInbox({
+            .account = carol,
+        });
+
+        // issuance not found
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+
+            mptAlice.create({
+                .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+            });
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.authorize({
+                .account = carol,
+            });
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // destroy the issuance
+            mptAlice.destroy();
+
+            json::Value jv;
+            jv[jss::Account] = bob.human();
+            jv[jss::Destination] = carol.human();
+            jv[jss::TransactionType] = jss::ConfidentialMPTSend;
+            jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID());
+            jv[sfSenderEncryptedAmount] = strHex(getTrivialCiphertext());
+            jv[sfDestinationEncryptedAmount] = strHex(getTrivialCiphertext());
+            jv[sfIssuerEncryptedAmount] = strHex(getTrivialCiphertext());
+            jv[sfAmountCommitment] = strHex(getTrivialCommitment());
+            jv[sfBalanceCommitment] = strHex(getTrivialCommitment());
+            jv[sfZKProof] = getTrivialSendProofHex();
+
+            env(jv, Ter(tecOBJECT_NOT_FOUND));
+        }
+
+        // destination does not exist
+        {
+            Account const unknown("unknown");
+            mptAlice.send({
+                .account = bob,
+                .dest = unknown,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .senderEncryptedAmt = getTrivialCiphertext(),
+                .destEncryptedAmt = getTrivialCiphertext(),
+                .issuerEncryptedAmt = getTrivialCiphertext(),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = tecNO_TARGET,
+            });
+        }
+
+        // destination requires destination tag but none provided
+        {
+            env(fset(carol, asfRequireDest));
+            env.close();
+
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .senderEncryptedAmt = getTrivialCiphertext(),
+                .destEncryptedAmt = getTrivialCiphertext(),
+                .issuerEncryptedAmt = getTrivialCiphertext(),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = tecDST_TAG_NEEDED,
+            });
+
+            env(fclear(carol, asfRequireDest));
+            env.close();
+        }
+
+        // dave exists, but has no confidential fields (never converted)
+        {
+            mptAlice.send({
+                .account = bob,
+                .dest = dave,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .senderEncryptedAmt = getTrivialCiphertext(),
+                .destEncryptedAmt = getTrivialCiphertext(),
+                .issuerEncryptedAmt = getTrivialCiphertext(),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = tecNO_PERMISSION,
+            });
+            mptAlice.send({
+                .account = dave,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .senderEncryptedAmt = getTrivialCiphertext(),
+                .destEncryptedAmt = getTrivialCiphertext(),
+                .issuerEncryptedAmt = getTrivialCiphertext(),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // destination exists but has no MPT object.
+        {
+            mptAlice.send({
+                .account = bob,
+                .dest = eve,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .senderEncryptedAmt = getTrivialCiphertext(),
+                .destEncryptedAmt = getTrivialCiphertext(),
+                .issuerEncryptedAmt = getTrivialCiphertext(),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = tecOBJECT_NOT_FOUND,
+            });
+        }
+
+        // issuance is locked globally
+        {
+            // lock issuance
+            mptAlice.set({
+                .account = alice,
+                .flags = tfMPTLock,
+            });
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .err = tecLOCKED,
+            });
+            // unlock issuance
+            mptAlice.set({
+                .account = alice,
+                .flags = tfMPTUnlock,
+            });
+            // now can send
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 1,
+            });
+        }
+
+        // sender is locked
+        {
+            // lock bob
+            mptAlice.set({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTLock,
+            });
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .err = tecLOCKED,
+            });
+            // unlock bob
+            mptAlice.set({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTUnlock,
+            });
+            // now can send
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 2,
+            });
+        }
+
+        // destination is locked
+        {
+            // lock carol
+            mptAlice.set({
+                .account = alice,
+                .holder = carol,
+                .flags = tfMPTLock,
+            });
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .err = tecLOCKED,
+            });
+            // unlock carol
+            mptAlice.set({
+                .account = alice,
+                .holder = carol,
+                .flags = tfMPTUnlock,
+            });
+            // now can send
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 3,
+            });
+        }
+
+        // sender not authorized
+        {
+            // unauthorize bob
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTUnauthorize,
+            });
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .err = tecNO_AUTH,
+            });
+            // authorize bob again
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+            });
+            // now can send
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 4,
+            });
+        }
+
+        // destination not authorized
+        {
+            // unauthorize carol
+            mptAlice.authorize({
+                .account = alice,
+                .holder = carol,
+                .flags = tfMPTUnauthorize,
+            });
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .err = tecNO_AUTH,
+            });
+            // authorize carol again
+            mptAlice.authorize({
+                .account = alice,
+                .holder = carol,
+            });
+            // now can send
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 5,
+            });
+        }
+
+        // cannot send when MPTCanTransfer is not set
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 60},
+                 {.account = carol, .payAmount = 50, .convertAmount = 20}},
+                tfMPTCanLock | tfMPTCanHoldConfidentialBalance};
+            auto& mptAlice = confEnv.mpt;
+
+            // bob sends 10 to carol
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,  // will be encrypted internally
+                .err = tecNO_AUTH,
+            });
+        }
+
+        // Confidential MPTs should not have a transfer fee. Force malformed
+        // ledger state to cover the defensive preclaim check.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 60},
+                 {.account = carol, .payAmount = 50, .convertAmount = 20}}};
+            auto& mptAlice = confEnv.mpt;
+
+            BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
+                auto const issuance = std::const_pointer_cast(
+                    view.read(keylet::mptokenIssuance(mptAlice.issuanceID())));
+                if (!issuance)
+                    return false;
+
+                issuance->setFieldU16(sfTransferFee, 1);
+                view.rawReplace(issuance);
+                return true;
+            }));
+
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // bad proof
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 60},
+                 {.account = carol, .payAmount = 50, .convertAmount = 20}}};
+            auto& mptAlice = confEnv.mpt;
+
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // No Auditor key set, but auditor encrypted amt provided
+        {
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .auditorEncryptedAmt = getTrivialCiphertext(),
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Auditor CipherText is Valid, but does not match the Txn Amount
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const auditor("auditor");
+            MPTTester mptAlice(
+                env,
+                alice,
+                {
+                    .holders = {bob, carol},
+                    .auditor = auditor,
+                });
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.authorize({
+                .account = carol,
+            });
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(carol);
+            mptAlice.generateKeyPair(auditor);
+
+            mptAlice.set(
+                {.account = alice,
+                 .issuerPubKey = mptAlice.getPubKey(alice),
+                 .auditorPubKey = mptAlice.getPubKey(auditor)});
+            mptAlice.pay(alice, bob, 100);
+            mptAlice.pay(alice, carol, 50);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            mptAlice.convert({
+                .account = carol,
+                .amt = 40,
+                .holderPubKey = mptAlice.getPubKey(carol),
+            });
+
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .auditorEncryptedAmt = getTrivialCiphertext(),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = tecBAD_PROOF,
+            });
+        }
+    }
+
+    void
+    testSendRangeProof(FeatureBitset features)
+    {
+        testcase("test ConfidentialMPTSend Range Proof");
+
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob, .payAmount = 1000, .convertAmount = 60},
+             {.account = carol, .payAmount = 1000, .convertAmount = 50}}};
+        auto& mptAlice = confEnv.mpt;
+
+        {
+            // Bob has 60, tries to send 70. Invalid remaining balance.
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 70,
+                .err = tecBAD_PROOF,
+            });
+
+            // Bob has 60, tries to send 61. Invalid remaining balance.
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 61,
+                .err = tecBAD_PROOF,
+            });
+
+            // Bob has 60, sends 60. Remainder is exactly 0. Valid remaining balance.
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 60,
+                .err = tesSUCCESS,
+            });
+        }
+
+        {
+            // Bob converts 100.
+            mptAlice.convert({
+                .account = bob,
+                .amt = 100,
+            });
+            mptAlice.mergeInbox({
+                .account = bob,
+            });
+
+            // Bob has 100, tries to send 2^64-1. Invalid remaining balance.
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = std::numeric_limits::max(),
+                .err = tecBAD_PROOF,
+            });
+
+            // Bob sends 1, remaining 99.
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 1,
+                .err = tesSUCCESS,
+            });
+
+            // Bob sends 100, but only has 99. Invalid remaining balance.
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 100,
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // send when spending balance is 0 (key registered, inbox merged, but nothing converted)
+        {
+            // Register keys only (amt=0) for both parties — spending stays 0.
+            Env env2{*this, features};
+            Account const alice2("alice"), bob2("bob"), carol2("carol");
+            ConfidentialEnv zeroEnv{
+                env2,
+                alice2,
+                {{.account = bob2, .payAmount = 100, .convertAmount = 0},
+                 {.account = carol2, .payAmount = 50, .convertAmount = 0}}};
+            auto& mptAlice2 = zeroEnv.mpt;
+
+            // Trying to send any amount with 0 spending balance must fail:
+            // the range proof for < 0 is invalid.
+            mptAlice2.send({
+                .account = bob2,
+                .dest = carol2,
+                .amt = 1,
+                .err = tecBAD_PROOF,
+            });
+
+            BEAST_EXPECT(
+                mptAlice2.getDecryptedBalance(bob2, MPTTester::holderEncryptedSpending) == 0);
+        }
+
+        // todo: test m exceeding range, require using scala and refactor
+    }
+
+    /* The equality proof library and range proof library do not
+     * support generating proofs for amt=0 (they require a positive witness).
+     * To test the VERIFIER without crashing the helper, we bypass normal proof
+     * generation by supplying explicit ciphertexts, commitments, and a dummy
+     * (all-zero) proof.  The preflight has no temBAD_AMOUNT guard for
+     * ConfidentialMPTSend, so all validation occurs in verifySendProofs.
+     */
+    void
+    testSendZeroAmount(FeatureBitset features)
+    {
+        testcase("Send: zero amount — equality and range proof verifier behavior");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+        });
+        mptAlice.authorize({.account = bob});
+        mptAlice.authorize({.account = carol});
+        mptAlice.pay(alice, bob, 100);
+        mptAlice.pay(alice, carol, 50);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.generateKeyPair(carol);
+
+        mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+        mptAlice.convert({.account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob)});
+        mptAlice.mergeInbox({.account = bob});
+
+        mptAlice.convert({.account = carol, .amt = 50, .holderPubKey = mptAlice.getPubKey(carol)});
+        mptAlice.mergeInbox({.account = carol});
+
+        Buffer const bf = generateBlindingFactor();
+
+        // equality proof verification for amt=0.
+        // Encrypt 0 under each participant's key.  The amount commitment is
+        // getTrivialCommitment() — a valid EC point that passes preflight's
+        // isValidCompressedECPoint check but is not the true PC for amt=0.
+        // The dummy ZKProof's equality component must be rejected by
+        // verifyMultiCiphertextEqualityProof.
+        mptAlice.send({
+            .account = bob,
+            .dest = carol,
+            .amt = 0,
+            .proof = getTrivialSendProofHex(),
+            .senderEncryptedAmt = mptAlice.encryptAmount(bob, 0, bf),
+            .destEncryptedAmt = mptAlice.encryptAmount(carol, 0, bf),
+            .issuerEncryptedAmt = mptAlice.encryptAmount(alice, 0, bf),
+            .amountCommitment = getTrivialCommitment(),
+            .balanceCommitment = getTrivialCommitment(),
+            .err = tecBAD_PROOF,
+        });
+
+        // range proof verification for amt=0.
+        // Identical construction; focuses on the bulletproof range check
+        // embedded in ZKProof.  The range proof for amount=0 with a dummy
+        // (all-zero) proof must also be rejected.
+        Buffer const bf2 = generateBlindingFactor();
+        mptAlice.send({
+            .account = bob,
+            .dest = carol,
+            .amt = 0,
+            .proof = getTrivialSendProofHex(),
+            .senderEncryptedAmt = mptAlice.encryptAmount(bob, 0, bf2),
+            .destEncryptedAmt = mptAlice.encryptAmount(carol, 0, bf2),
+            .issuerEncryptedAmt = mptAlice.encryptAmount(alice, 0, bf2),
+            .amountCommitment = getTrivialCommitment(),
+            .balanceCommitment = getTrivialCommitment(),
+            .err = tecBAD_PROOF,
+        });
+
+        // All rejected sends must leave balances unchanged.
+        BEAST_EXPECT(mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
+        BEAST_EXPECT(mptAlice.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
+    }
+
+    void
+    testDelete(FeatureBitset features)
+    {
+        testcase("Delete");
+        using namespace test::jtx;
+
+        // cannot delete mptoken where it has encrypted balance
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 100,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+                .flags = tfMPTUnauthorize,
+                .err = tecHAS_OBLIGATIONS,
+            });
+        }
+
+        // cannot delete mptoken where it has encrypted balance
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.authorize({
+                .account = carol,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(carol);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 100,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            mptAlice.convert({
+                .account = carol,
+                .amt = 0,
+                .holderPubKey = mptAlice.getPubKey(carol),
+            });
+
+            // carol cannot delete even if he has encrypted zero amount
+            mptAlice.authorize({
+                .account = carol,
+                .flags = tfMPTUnauthorize,
+                .err = tecHAS_OBLIGATIONS,
+            });
+        }
+
+        // can delete mptoken if outstanding confidential balance is zero
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 0,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+                .flags = tfMPTUnauthorize,
+            });
+        }
+
+        // can delete mptoken if issuance has been destroyed and has
+        // encrypted zero balance
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 0,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            mptAlice.destroy();
+
+            mptAlice.authorize({
+                .account = bob,
+                .flags = tfMPTUnauthorize,
+            });
+        }
+        // test with convert back and delete
+        // can delete mptoken if converted back (COA returns to zero)
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv confEnv{
+                env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 100}}};
+            auto& mptAlice = confEnv.mpt;
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 100,
+            });
+
+            mptAlice.pay(bob, alice, 100);
+
+            // Should be able to delete as Confidential Outstanding amount is 0
+            mptAlice.authorize({
+                .account = bob,
+                .flags = tfMPTUnauthorize,
+            });
+        }
+
+        // removeEmptyHolding: vault share MPToken with confidential balance
+        // fields should not be deleted on VaultWithdraw
+        {
+            Env env{*this, features | featureSingleAssetVault};
+            Account const issuer("issuer");
+            Account const owner("owner");
+            Account const depositor("depositor");
+
+            MPTTester mptt{env, issuer, {.holders = {owner, depositor}}};
+            mptt.create({
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback,
+            });
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            env(pay(issuer, depositor, asset(1000)));
+            env.close();
+
+            test::jtx::Vault const vault{env};
+            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            // Get the share MPTID from vault
+            auto const vaultSle = env.le(vaultKeylet);
+            BEAST_EXPECT(vaultSle != nullptr);
+            auto const share = vaultSle->at(sfShareMPTID);
+
+            // Depositor deposits into vault
+            tx = vault.deposit(
+                {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)});
+            env(tx);
+            env.close();
+
+            // Verify depositor has share tokens
+            auto shareMpt = env.le(keylet::mptoken(share, depositor.id()));
+            BEAST_EXPECT(shareMpt != nullptr);
+
+            // Inject confidential balance fields on the share MPToken
+            // to simulate a scenario where vault shares somehow have
+            // confidential balances
+            env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
+                // Set lsfMPTCanHoldConfidentialBalance on the share issuance
+                // so the invariant allows encrypted fields on the MPToken
+                auto issuance =
+                    std::const_pointer_cast(view.read(keylet::mptokenIssuance(share)));
+                if (!issuance)
+                    return false;
+                issuance->setFlag(lsfMPTCanHoldConfidentialBalance);
+                view.rawReplace(issuance);
+
+                auto const k = keylet::mptoken(share, depositor.id());
+                auto const sle = std::const_pointer_cast(view.read(k));
+                if (!sle)
+                    return false;
+                // Inject dummy confidential balance fields
+                Buffer dummyCiphertext(kEcGamalEncryptedTotalLength);
+                std::memset(dummyCiphertext.data(), 0, kEcGamalEncryptedTotalLength);
+                dummyCiphertext.data()[0] = kEcCompressedPrefixEvenY;
+                dummyCiphertext.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY;
+                dummyCiphertext.data()[kEcCiphertextComponentLength - 1] = 0x01;
+                dummyCiphertext.data()[kEcGamalEncryptedTotalLength - 1] = 0x01;
+                sle->setFieldVL(sfConfidentialBalanceSpending, dummyCiphertext);
+                sle->setFieldVL(sfConfidentialBalanceInbox, dummyCiphertext);
+                sle->setFieldVL(sfIssuerEncryptedBalance, dummyCiphertext);
+                view.rawReplace(sle);
+                return true;
+            });
+
+            // Withdraw everything - which should fail because of the confidential balance fields
+            tx = vault.withdraw(
+                {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)});
+            env(tx);
+
+            // The share MPToken should still exist because the
+            // withdrawal failed due to confidential balance obligations
+            shareMpt = env.le(keylet::mptoken(share, depositor.id()));
+            BEAST_EXPECT(shareMpt != nullptr);
+        }
+    }
+
+    void
+    testConvertBack(FeatureBitset features)
+    {
+        testcase("Convert back");
+        using namespace test::jtx;
+
+        // Basic convert back test
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv confEnv{
+                env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
+            auto& mptAlice = confEnv.mpt;
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 10,
+            });
+        }
+
+        // Edge case: minimum amount (1)
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv confEnv{
+                env, alice, {{.account = bob, .payAmount = 2, .convertAmount = 2}}};
+            auto& mptAlice = confEnv.mpt;
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 1,
+            });
+        }
+
+        // Edge case: kMaxMpTokenAmount
+        // Using raw JSON to avoid automatic decryption checks in MPTTester
+        // which don't work for very large amounts (brute-force decryption is slow)
+        // TODO: improve this test once there is bounded decryption or optimized decryption for
+        // large amounts
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, kMaxMpTokenAmount);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+
+            // Convert kMaxMpTokenAmount to confidential using raw JSON
+            Buffer const convertBlindingFactor = generateBlindingFactor();
+            auto const convertHolderCiphertext =
+                mptAlice.encryptAmount(bob, kMaxMpTokenAmount, convertBlindingFactor);
+            auto const convertIssuerCiphertext =
+                mptAlice.encryptAmount(alice, kMaxMpTokenAmount, convertBlindingFactor);
+            auto const convertContextHash =
+                getConvertContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob));
+            auto const schnorrProof = requireOptional(
+                mptAlice.getSchnorrProof(bob, convertContextHash), "Missing schnorr proof");
+
+            {
+                json::Value jv;
+                jv[jss::Account] = bob.human();
+                jv[jss::TransactionType] = jss::ConfidentialMPTConvert;
+                jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID());
+                jv[sfMPTAmount.jsonName] = std::to_string(kMaxMpTokenAmount);
+                jv[sfHolderEncryptionKey.jsonName] =
+                    strHex(requireOptional(mptAlice.getPubKey(bob), "Missing holder public key"));
+                jv[sfHolderEncryptedAmount.jsonName] = strHex(convertHolderCiphertext);
+                jv[sfIssuerEncryptedAmount.jsonName] = strHex(convertIssuerCiphertext);
+                jv[sfBlindingFactor.jsonName] = strHex(convertBlindingFactor);
+                jv[sfZKProof.jsonName] = strHex(schnorrProof);
+
+                env(jv, Ter(tesSUCCESS));
+            }
+
+            // Merge inbox using raw JSON - moves funds from inbox to spending balance
+            {
+                json::Value jv;
+                jv[jss::Account] = bob.human();
+                jv[jss::TransactionType] = jss::ConfidentialMPTMergeInbox;
+                jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID());
+
+                env(jv, Ter(tesSUCCESS));
+            }
+
+            // ConvertBack kMaxMpTokenAmount - 1 using raw JSON
+            // After convert + merge, spending balance = kMaxMpTokenAmount
+            // We convert back kMaxMpTokenAmount - 1 to leave remainder of 1
+            std::uint64_t const convertBackAmt = kMaxMpTokenAmount - 1;
+
+            Buffer const convertBackBlindingFactor = generateBlindingFactor();
+            auto const convertBackHolderCiphertext =
+                mptAlice.encryptAmount(bob, convertBackAmt, convertBackBlindingFactor);
+            auto const convertBackIssuerCiphertext =
+                mptAlice.encryptAmount(alice, convertBackAmt, convertBackBlindingFactor);
+
+            // Get the encrypted spending balance from ledger (no decryption needed)
+            auto const encryptedSpendingBalance = requireOptional(
+                mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
+                "Missing encrypted spending balance");
+
+            // Generate pedersen commitment for the known spending balance
+            Buffer const pcBlindingFactor = generateBlindingFactor();
+            Buffer const pedersenCommitment =
+                mptAlice.getPedersenCommitment(kMaxMpTokenAmount, pcBlindingFactor);
+
+            // Generate the proof using known spending balance value
+            auto const version = mptAlice.getMPTokenVersion(bob);
+            uint256 const convertBackContextHash =
+                getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version);
+
+            Buffer const proof = mptAlice.getConvertBackProof(
+                bob,
+                convertBackAmt,
+                convertBackContextHash,
+                {
+                    .pedersenCommitment = pedersenCommitment,
+                    .amt = kMaxMpTokenAmount,
+                    .encryptedAmt = encryptedSpendingBalance,
+                    .blindingFactor = pcBlindingFactor,
+                });
+
+            {
+                json::Value jv;
+                jv[jss::Account] = bob.human();
+                jv[jss::TransactionType] = jss::ConfidentialMPTConvertBack;
+                jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID());
+                jv[sfMPTAmount.jsonName] = std::to_string(convertBackAmt);
+                jv[sfHolderEncryptedAmount.jsonName] = strHex(convertBackHolderCiphertext);
+                jv[sfIssuerEncryptedAmount.jsonName] = strHex(convertBackIssuerCiphertext);
+                jv[sfBlindingFactor.jsonName] = strHex(convertBackBlindingFactor);
+                jv[sfBalanceCommitment.jsonName] = strHex(pedersenCommitment);
+                jv[sfZKProof.jsonName] = strHex(proof);
+
+                env(jv, Ter(tesSUCCESS));
+            }
+
+            // Verify the public balance was restored (minus 1 remaining in confidential)
+            env.require(MptBalance(mptAlice, bob, convertBackAmt));
+        }
+    }
+
+    void
+    testConvertBackWithAuditor(FeatureBitset features)
+    {
+        testcase("Convert back with auditor");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const auditor("auditor");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob, .payAmount = 100, .convertAmount = 40}},
+            tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            auditor};
+        auto& mptAlice = confEnv.mpt;
+
+        mptAlice.convertBack({
+            .account = bob,
+            .amt = 30,
+        });
+    }
+
+    void
+    testConvertBackPreflight(FeatureBitset features)
+    {
+        testcase("Convert back preflight");
+        using namespace test::jtx;
+
+        {
+            Env env{*this, features - featureConfidentialTransfer};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+                .err = temDISABLED,
+            });
+        }
+
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv confEnv{
+                env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
+            auto& mptAlice = confEnv.mpt;
+
+            mptAlice.convertBack({
+                .account = alice,
+                .amt = 30,
+                .err = temMALFORMED,
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 0,
+                .err = temBAD_AMOUNT,
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = kMaxMpTokenAmount + 1,
+                .err = temBAD_AMOUNT,
+            });
+
+            // Balance commitment has correct length but invalid EC point data
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+                .pedersenCommitment = gMakeZeroBuffer(kEcPedersenCommitmentLength),
+                .err = temMALFORMED,
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+                .holderEncryptedAmt = Buffer{},
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+                .issuerEncryptedAmt = Buffer{},
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+                .holderEncryptedAmt = getBadCiphertext(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+                .issuerEncryptedAmt = getBadCiphertext(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+                .auditorEncryptedAmt = gMakeZeroBuffer(10),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+                .auditorEncryptedAmt = getBadCiphertext(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // invalid proof length
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+                .proof = Buffer{},
+                .err = temMALFORMED,
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+                .proof = gMakeZeroBuffer(100),
+                .err = temMALFORMED,
+            });
+        }
+    }
+
+    void
+    testConvertBackPreclaim(FeatureBitset features)
+    {
+        testcase("Convert back preclaim");
+        using namespace test::jtx;
+
+        // issuance does not exist
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.destroy();
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+                .err = tecOBJECT_NOT_FOUND,
+            });
+        }
+
+        // tfMPTCanHoldConfidentialBalance is not set on issuance
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // no mptoken
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+                .err = tecOBJECT_NOT_FOUND,
+            });
+        }
+
+        // mptoken exists but lacks confidential fields
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+
+            mptAlice.pay(alice, bob, 100);
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // Bob's MPToken lacks the confidential fields
+            auto const sleBobMpt = env.le(keylet::mptoken(mptAlice.issuanceID(), bob.id()));
+            BEAST_EXPECT(sleBobMpt);
+            BEAST_EXPECT(!sleBobMpt->isFieldPresent(sfHolderEncryptionKey));
+            BEAST_EXPECT(!sleBobMpt->isFieldPresent(sfConfidentialBalanceSpending));
+            BEAST_EXPECT(!sleBobMpt->isFieldPresent(sfIssuerEncryptedBalance));
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 30,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // bob tries to convert back more than COA
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.authorize({
+                .account = carol,
+            });
+            mptAlice.pay(alice, bob, 100);
+            mptAlice.pay(alice, carol, 100);
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(carol);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 40,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            mptAlice.mergeInbox({
+                .account = bob,
+            });
+
+            mptAlice.convert({
+                .account = carol,
+                .amt = 40,
+                .holderPubKey = mptAlice.getPubKey(carol),
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 300,
+                .err = tecINSUFFICIENT_FUNDS,
+            });
+        }
+
+        // cannot convert if locked or unauth
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth |
+                    tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 40,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            mptAlice.mergeInbox({
+                .account = bob,
+            });
+
+            mptAlice.set({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTLock,
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 10,
+                .err = tecLOCKED,
+            });
+
+            mptAlice.set({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTUnlock,
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 10,
+            });
+
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTUnauthorize,
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 10,
+                .err = tecNO_AUTH,
+            });
+
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+            });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 10,
+            });
+        }
+
+        // Verification of holder and issuer ciphertexts during convertBack
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv confEnv{
+                env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}};
+            auto& mptAlice = confEnv.mpt;
+
+            // Holder encrypted amount is valid format but mathematically incorrect for this
+            // convertBack
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 10,
+                .holderEncryptedAmt = getTrivialCiphertext(),
+                .err = tecBAD_PROOF,
+            });
+
+            // Issuer encrypted amount is valid format but mathematically incorrect for this
+            // convertBack
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 10,
+                .issuerEncryptedAmt = getTrivialCiphertext(),
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // Alice has NOT set an auditor key, but Bob provides
+        // auditorEncryptedAmt
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv confEnv{
+                env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}};
+            auto& mptAlice = confEnv.mpt;
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 10,
+                // Provide valid ciphertext to pass preflight
+                .auditorEncryptedAmt = getTrivialCiphertext(),
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // we set the auditor key, but convertBack omits auditorEncryptedAmt
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50}},
+                tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+                auditor};
+            auto& mptAlice = confEnv.mpt;
+
+            // ConvertBack WITHOUT auditorEncryptedAmt
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 10,
+                .fillAuditorEncryptedAmt = false,
+                .err = tecNO_PERMISSION,
+            });
+
+            // ConvertBack where auditor ciphertext mathematically
+            // correct, but contains invalid data (mismatching amount).
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 10,
+                .auditorEncryptedAmt = getTrivialCiphertext(),
+                .err = tecBAD_PROOF,
+            });
+        }
+    }
+
+    void
+    testClawback(FeatureBitset features)
+    {
+        testcase("test ConfidentialMPTClawback");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const dave("dave");
+        MPTTester mptAlice(env, alice, {.holders = {bob, carol, dave}});
+
+        mptAlice.create({
+            .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback |
+                tfMPTCanHoldConfidentialBalance,
+        });
+        mptAlice.authorize({
+            .account = bob,
+        });
+        mptAlice.pay(alice, bob, 100);
+        mptAlice.authorize({
+            .account = carol,
+        });
+        mptAlice.pay(alice, carol, 200);
+        mptAlice.authorize({
+            .account = dave,
+        });
+        mptAlice.pay(alice, dave, 300);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.generateKeyPair(carol);
+        mptAlice.generateKeyPair(dave);
+        mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+        // setup bob.
+        // after setup, bob's spending balance is 60, inbox balance is 0.
+        {
+            // bob converts 60 to confidential
+            mptAlice.convert({.account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob)});
+
+            // bob merge inbox
+            mptAlice.mergeInbox({
+                .account = bob,
+            });
+        }
+
+        // setup carol.
+        // after setup, carol's spending balance is 120, inbox balance is 0.
+        {
+            // carol converts 120 to confidential
+            mptAlice.convert(
+                {.account = carol, .amt = 120, .holderPubKey = mptAlice.getPubKey(carol)});
+
+            // carol merge inbox
+            mptAlice.mergeInbox({
+                .account = carol,
+            });
+        }
+
+        // setup dave.
+        // dave will not merge inbox.
+        // after setup, dave's inbox balance is 200, spending balance is 0.
+        mptAlice.convert({.account = dave, .amt = 200, .holderPubKey = mptAlice.getPubKey(dave)});
+
+        // setup: carol confidential send 50 to bob.
+        // after send, bob's inbox balance is 50, spending balance
+        // remains 60. carol's inbox balance remains 0, spending balance
+        // drops to 70.
+        mptAlice.send({
+            .account = carol,
+            .dest = bob,
+            .amt = 50,
+        });
+
+        // Confidential clawback is burn/reduce outstanding amount.
+        // The holder public balance is unchanged, and OA/COA decrease.
+        auto const preBobPublicBalance = mptAlice.getBalance(bob);
+        auto const preOutstandingAmount = mptAlice.getIssuanceOutstandingBalance();
+        auto const preConfidentialOutstandingAmount = mptAlice.getIssuanceConfidentialBalance();
+        BEAST_EXPECT(!env.le(keylet::mptoken(mptAlice.issuanceID(), alice.id())));
+
+        // alice clawback all confidential balance from bob, 110 in total.
+        // bob has balance in both inbox and spending. These balances should
+        // become zero after clawback, which is verified in the
+        // confidentialClaw function.
+        mptAlice.confidentialClaw({
+            .account = alice,
+            .holder = bob,
+            .amt = 110,
+        });
+        BEAST_EXPECT(mptAlice.getBalance(bob) == preBobPublicBalance);
+        auto const postOutstandingAmount = mptAlice.getIssuanceOutstandingBalance();
+        BEAST_EXPECT(
+            preOutstandingAmount && postOutstandingAmount &&
+            *postOutstandingAmount == *preOutstandingAmount - 110);
+        BEAST_EXPECT(
+            mptAlice.getIssuanceConfidentialBalance() == preConfidentialOutstandingAmount - 110);
+        BEAST_EXPECT(!env.le(keylet::mptoken(mptAlice.issuanceID(), alice.id())));
+
+        // alice clawback all confidential balance from carol, which is 70.
+        // carol only has balance in spending.
+        mptAlice.confidentialClaw({
+            .account = alice,
+            .holder = carol,
+            .amt = 70,
+        });
+
+        // alice clawback all confidential balance from dave, which is 200.
+        // dave only has balance in inbox.
+        mptAlice.confidentialClaw({
+            .account = alice,
+            .holder = dave,
+            .amt = 200,
+        });
+    }
+
+    void
+    testClawbackWithAuditor(FeatureBitset features)
+    {
+        testcase("test ConfidentialMPTClawback with auditor");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const dave("dave");
+        Account const auditor("auditor");
+        MPTTester mptAlice(
+            env,
+            alice,
+            {
+                .holders = {bob, carol, dave},
+                .auditor = auditor,
+            });
+
+        mptAlice.create({
+            .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback |
+                tfMPTCanHoldConfidentialBalance,
+        });
+        mptAlice.authorize({
+            .account = bob,
+        });
+        mptAlice.pay(alice, bob, 100);
+        mptAlice.authorize({
+            .account = carol,
+        });
+        mptAlice.pay(alice, carol, 200);
+        mptAlice.authorize({
+            .account = dave,
+        });
+        mptAlice.pay(alice, dave, 300);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.generateKeyPair(carol);
+        mptAlice.generateKeyPair(dave);
+        mptAlice.generateKeyPair(auditor);
+        mptAlice.set(
+            {.account = alice,
+             .issuerPubKey = mptAlice.getPubKey(alice),
+             .auditorPubKey = mptAlice.getPubKey(auditor)});
+
+        // setup bob.
+        // after setup, bob's spending balance is 60, inbox balance is 0.
+        {
+            // bob converts 60 to confidential
+            mptAlice.convert({.account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob)});
+
+            // bob merge inbox
+            mptAlice.mergeInbox({
+                .account = bob,
+            });
+        }
+
+        // setup carol.
+        // after setup, carol's spending balance is 120, inbox balance is 0.
+        {
+            // carol converts 120 to confidential
+            mptAlice.convert(
+                {.account = carol, .amt = 120, .holderPubKey = mptAlice.getPubKey(carol)});
+
+            // carol merge inbox
+            mptAlice.mergeInbox({
+                .account = carol,
+            });
+        }
+
+        // setup dave.
+        // dave will not merge inbox.
+        // after setup, dave's inbox balance is 200, spending balance is 0.
+        mptAlice.convert({.account = dave, .amt = 200, .holderPubKey = mptAlice.getPubKey(dave)});
+
+        // setup: carol confidential send 50 to bob.
+        // after send, bob's inbox balance is 50, spending balance
+        // remains 60. carol's inbox balance remains 0, spending balance
+        // drops to 70.
+        mptAlice.send({
+            .account = carol,
+            .dest = bob,
+            .amt = 50,
+        });
+
+        // alice clawback all confidential balance from bob, 110 in total.
+        // bob has balance in both inbox and spending. These balances should
+        // become zero after clawback, which is verified in the
+        // confidentialClaw function.
+        mptAlice.confidentialClaw({
+            .account = alice,
+            .holder = bob,
+            .amt = 110,
+        });
+
+        // alice clawback all confidential balance from carol, which is 70.
+        // carol only has balance in spending.
+        mptAlice.confidentialClaw({
+            .account = alice,
+            .holder = carol,
+            .amt = 70,
+        });
+
+        // alice clawback all confidential balance from dave, which is 200.
+        // dave only has balance in inbox.
+        mptAlice.confidentialClaw({
+            .account = alice,
+            .holder = dave,
+            .amt = 200,
+        });
+    }
+
+    void
+    testClawbackInvalidProofContextBinding(FeatureBitset features)
+    {
+        testcase("ConfidentialMPTClawback context binding");
+        using namespace test::jtx;
+
+        auto runBadProof = [&](auto makeContextHash) {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 60}},
+                tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback |
+                    tfMPTCanHoldConfidentialBalance};
+            auto& mptAlice = confEnv.mpt;
+
+            auto const privKey = mptAlice.getPrivKey(alice);
+            if (!BEAST_EXPECT(privKey.has_value()))
+                return;
+
+            auto const proof = mptAlice.getClawbackProof(
+                bob,
+                60,
+                requireOptionalRef(privKey, "Missing private key"),
+                makeContextHash(env, mptAlice, alice, bob, carol));
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 60,
+                .proof = strHex(requireOptional(proof, "Missing proof")),
+                .err = tecBAD_PROOF,
+            });
+        };
+
+        // Wrong account (issuer) in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const& mpt,
+                        Account const& alice,
+                        Account const& bob,
+                        Account const& carol) {
+            return getClawbackContextHash(carol.id(), mpt.issuanceID(), env.seq(alice), bob.id());
+        });
+
+        // Wrong issuance ID in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const&,
+                        Account const& alice,
+                        Account const& bob,
+                        Account const&) {
+            return getClawbackContextHash(
+                alice.id(), makeMptID(env.seq(alice) + 100, alice), env.seq(alice), bob.id());
+        });
+
+        // Wrong transaction sequence in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const& mpt,
+                        Account const& alice,
+                        Account const& bob,
+                        Account const&) {
+            return getClawbackContextHash(
+                alice.id(), mpt.issuanceID(), env.seq(alice) + 1, bob.id());
+        });
+
+        // Wrong holder in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const& mpt,
+                        Account const& alice,
+                        Account const&,
+                        Account const& carol) {
+            return getClawbackContextHash(alice.id(), mpt.issuanceID(), env.seq(alice), carol.id());
+        });
+    }
+
+    // Bob creates the AMM, but Bob is not the MPT holder checked below.
+    // The AMM has its own pseudo-account (`ammHolder`) that can hold the
+    // public MPT pool balance. That pseudo-account cannot normally
+    // initialize confidential state because the confidential txn's must be
+    // signed by sfAccount, and the AMM pseudo-account has no signing key.
+    // So this is a construction/impossibility test: public AMM MPT state exists
+    // but the corresponding confidential AMM clawback flow is not normally reachable.
+    void
+    testClawbackPreflight(FeatureBitset features)
+    {
+        testcase("test ConfidentialMPTClawback Preflight");
+        using namespace test::jtx;
+
+        // test feature disabled
+        {
+            Env env{*this, features - featureConfidentialTransfer};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create();
+            mptAlice.authorize({
+                .account = bob,
+            });
+
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 10,
+                .proof = "123",
+                .err = temDISABLED,
+            });
+        }
+
+        // test malformed
+        {
+            // set up
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.authorize({
+                .account = carol,
+            });
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(carol);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+            mptAlice.pay(alice, bob, 100);
+            mptAlice.pay(alice, carol, 50);
+
+            // only issuer can clawback
+            mptAlice.confidentialClaw({
+                .account = carol,
+                .holder = bob,
+                .amt = 10,
+                .err = temMALFORMED,
+            });
+
+            // invalid issuance ID, whose issuer is not alice
+            {
+                json::Value jv;
+                jv[jss::Account] = alice.human();
+                jv[sfHolder] = bob.human();
+                jv[jss::TransactionType] = jss::ConfidentialMPTClawback;
+                jv[sfMPTAmount] = std::to_string(10);
+                jv[sfZKProof] = "123";
+
+                // wrong issuance ID
+                jv[sfMPTokenIssuanceID] = "00000004AE123A8556F3CF91154711376AFB0F894F832B3E";
+
+                env(jv, Ter(temMALFORMED));
+            }
+
+            // issuer cannot clawback from self
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = alice,
+                .amt = 10,
+                .err = temMALFORMED,
+            });
+
+            // invalid amount
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 0,
+                .err = temBAD_AMOUNT,
+            });
+
+            // invalid proof length
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 10,
+                .proof = "123",
+                .err = temMALFORMED,
+            });
+        }
+    }
+
+    void
+    testClawbackPreclaim(FeatureBitset features)
+    {
+        testcase("Clawback Preclaim Errors");
+        using namespace test::jtx;
+
+        {
+            // set up, alice is the issuer, bob and carol are authorized
+            // holders. dave is not authorized. bob has confidential
+            // balance, carol does not.
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const dave("dave");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol, dave}});
+
+            mptAlice.create({
+                .flags = tfMPTCanTransfer | tfMPTCanClawback | tfMPTRequireAuth |
+                    tfMPTCanHoldConfidentialBalance,
+            });
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+            });
+            mptAlice.authorize({
+                .account = carol,
+            });
+            mptAlice.authorize({
+                .account = alice,
+                .holder = carol,
+            });
+
+            mptAlice.pay(alice, bob, 100);
+            mptAlice.pay(alice, carol, 50);
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(carol);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 60,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+            mptAlice.mergeInbox({
+                .account = bob,
+            });
+
+            // holder does not exist
+            {
+                Account const unknown("unknown");
+                mptAlice.confidentialClaw({
+                    .account = alice,
+                    .holder = unknown,
+                    .amt = 10,
+                    .err = tecNO_TARGET,
+                });
+            }
+
+            // dave does not hold mpt at all, no MPT object
+            {
+                mptAlice.confidentialClaw({
+                    .account = alice,
+                    .holder = dave,
+                    .amt = 10,
+                    .err = tecOBJECT_NOT_FOUND,
+                });
+            }
+
+            // carol has no confidential balance
+            {
+                mptAlice.confidentialClaw({
+                    .account = alice,
+                    .holder = carol,
+                    .amt = 10,
+                    .err = tecNO_PERMISSION,
+                });
+            }
+        }
+
+        // lsfMPTCanClawback not set
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+            });
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 10,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // no issuer key
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+            mptAlice.create({
+                .flags = tfMPTCanClawback | tfMPTCanHoldConfidentialBalance,
+            });
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.generateKeyPair(alice);
+
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 10,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // issuance not found
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+            mptAlice.create({
+                .flags = tfMPTCanClawback | tfMPTCanHoldConfidentialBalance,
+            });
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // destroy the issuance
+            mptAlice.destroy();
+
+            json::Value jv;
+            jv[jss::Account] = alice.human();
+            jv[sfHolder] = bob.human();
+            jv[jss::TransactionType] = jss::ConfidentialMPTClawback;
+            jv[sfMPTAmount] = std::to_string(10);
+            std::string const dummyProof(kEcClawbackProofLength * 2, '0');
+            jv[sfZKProof] = dummyProof;
+            jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID());
+
+            env(jv, Ter(tecOBJECT_NOT_FOUND));
+        }
+
+        // After setup, bob has confidential balance 60 in spending.
+        std::uint32_t const setupFlags = tfMPTCanTransfer | tfMPTCanClawback | tfMPTRequireAuth |
+            tfMPTCanLock | tfMPTCanHoldConfidentialBalance;
+        std::string const dummyClawbackProof(kEcClawbackProofLength * 2, '0');
+
+        auto removeMPTokenField =
+            [&](Env& env, MPTTester const& mpt, Account const& holder, SField const& field) {
+                BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
+                    auto const sle = std::const_pointer_cast(
+                        view.read(keylet::mptoken(mpt.issuanceID(), holder.id())));
+                    if (!sle)
+                        return false;
+
+                    sle->makeFieldAbsent(field);
+                    view.rawReplace(sle);
+                    return true;
+                }));
+            };
+
+        // After global COA is drained to zero, a further confidential clawback
+        // fails because the amount exceeds the remaining confidential
+        // outstanding amount.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv confEnv{
+                env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags};
+            auto& mptAlice = confEnv.mpt;
+
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 60,
+            });
+
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 1,
+                .proof = dummyClawbackProof,
+                .err = tecINSUFFICIENT_FUNDS,
+            });
+        }
+
+        // Missing issuer encrypted balance should fail before proof
+        // verification.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv confEnv{
+                env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags};
+            auto& mptAlice = confEnv.mpt;
+
+            removeMPTokenField(env, mptAlice, bob, sfIssuerEncryptedBalance);
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 60,
+                .proof = dummyClawbackProof,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Missing holder encryption key should fail before proof verification.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv confEnv{
+                env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags};
+            auto& mptAlice = confEnv.mpt;
+
+            removeMPTokenField(env, mptAlice, bob, sfHolderEncryptionKey);
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 60,
+                .proof = dummyClawbackProof,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // lock should not block clawback. lock bob individually
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv confEnv{
+                env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags};
+            auto& mptAlice = confEnv.mpt;
+            mptAlice.set({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTLock,
+            });
+
+            // clawback should still work
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 60,
+            });
+        }
+
+        // lock globally
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv confEnv{
+                env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags};
+            auto& mptAlice = confEnv.mpt;
+            mptAlice.set({
+                .account = alice,
+                .flags = tfMPTLock,
+            });
+
+            // clawback should still work
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 60,
+            });
+        }
+
+        // unauthorize should not block clawback
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv confEnv{
+                env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags};
+            auto& mptAlice = confEnv.mpt;
+
+            // unauthorize bob
+            mptAlice.authorize({
+                .account = alice,
+                .holder = bob,
+                .flags = tfMPTUnauthorize,
+            });
+            // clawback should still work
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 60,
+            });
+        }
+
+        // insufficient funds, clawback amount exceeding confidential
+        // outstanding amount
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv confEnv{
+                env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags};
+            auto& mptAlice = confEnv.mpt;
+
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 10000,
+                .err = tecINSUFFICIENT_FUNDS,
+            });
+        }
+    }
+
+    void
+    testClawbackProof(FeatureBitset features)
+    {
+        testcase("ConfidentialMPTClawback Proof");
+        using namespace test::jtx;
+
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+
+        // lambda function to set up MPT with alice as issuer, bob and carol
+        // as authorized holders, and fund 1000 mpt to bob and 2000 mpt to
+        // carol.
+        auto setupEnv = [&](Env& env) -> MPTTester {
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+
+            mptAlice.create({
+                .flags = tfMPTCanTransfer | tfMPTCanClawback | tfMPTCanHoldConfidentialBalance,
+            });
+
+            for (auto const& [acct, amt] : {std::pair{bob, 1000}, {carol, 2000}})
+            {
+                mptAlice.authorize({
+                    .account = acct,
+                });
+                mptAlice.pay(alice, acct, amt);
+                mptAlice.generateKeyPair(acct);
+            }
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            return mptAlice;
+        };
+
+        // lambda function to test a set of bad clawback amounts that should
+        // return tecBAD_PROOF
+        auto checkBadProofs =
+            [&](MPTTester& mpt, Account const& holder, std::initializer_list amts) {
+                for (auto const badAmt : amts)
+                {
+                    mpt.confidentialClaw({
+                        .account = alice,
+                        .holder = holder,
+                        .amt = badAmt,
+                        .err = tecBAD_PROOF,
+                    });
+                }
+            };
+
+        // SCENARIO 1: clawback from inbox only or spending only balances.
+        // bob converts 500 and merge inbox,
+        // carol converts 1000, but not merge inbox.
+        // after setup, bob has 500 in spending, carol has 1000 in inbox.
+        {
+            Env env{*this, features};
+            auto mptAlice = setupEnv(env);
+
+            // bob converts and merges
+            mptAlice.convert({.account = bob, .amt = 500, .holderPubKey = mptAlice.getPubKey(bob)});
+            mptAlice.mergeInbox({
+                .account = bob,
+            });
+            // carol converts without merge
+            mptAlice.convert(
+                {.account = carol, .amt = 1000, .holderPubKey = mptAlice.getPubKey(carol)});
+
+            // verify proof fails with invalid clawback amount
+            // bob: 500 in Spending, 0 in Inbox
+            checkBadProofs(
+                mptAlice,
+                bob,
+                {
+                    1,
+                    10,
+                    70,
+                    100,
+                    110,
+                    200,
+                    499,
+                    501,
+                    600,
+                });
+
+            // carol: 1000 in Inbox, 0 in Spending
+            checkBadProofs(
+                mptAlice,
+                carol,
+                {
+                    1,
+                    10,
+                    50,
+                    500,
+                    777,
+                    850,
+                    999,
+                    1001,
+                    1200,
+                });
+
+            // clawback with correct amount that passes proof verification
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 500,
+            });
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = carol,
+                .amt = 1000,
+            });
+        }
+
+        // SCENARIO 2: clawback from mixed inbox and spending balances.
+        // bob converts 300 to confidential and merge inbox,
+        // carol converts 400 to confidential and merge inbox,
+        // bob sends 100 to carol, carol sends 100 to bob.
+        // After setup, bob has 100 in inbox and 200 in spending;
+        // carol has 100 in inbox and 300 in spending.
+        {
+            Env env{*this, features};
+            auto mptAlice = setupEnv(env);
+
+            mptAlice.convert({.account = bob, .amt = 300, .holderPubKey = mptAlice.getPubKey(bob)});
+            mptAlice.mergeInbox({
+                .account = bob,
+            });
+            mptAlice.convert(
+                {.account = carol, .amt = 400, .holderPubKey = mptAlice.getPubKey(carol)});
+            mptAlice.mergeInbox({
+                .account = carol,
+            });
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 100,
+            });
+            mptAlice.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 100,
+            });
+
+            // verify proof fails with invalid clawback amount
+            // bob: 100 in inbox, 200 in spending
+            checkBadProofs(
+                mptAlice,
+                bob,
+                {
+                    1,
+                    10,
+                    50,
+                    100,
+                    200,
+                    299,
+                    301,
+                    400,
+                });
+
+            // proof failure for incorrect amount when clawbacking from
+            // carol carol: 100 in inbox, 300 in spending
+            checkBadProofs(
+                mptAlice,
+                carol,
+                {
+                    1,
+                    10,
+                    50,
+                    100,
+                    300,
+                    399,
+                    401,
+                    501,
+                });
+
+            // clawback with correct amount that passes proof verification
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 300,
+            });
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = carol,
+                .amt = 400,
+            });
+        }
+
+        // SCENARIO 3: the clawback proof omits the holder's confidential
+        // balance version. A proof generated before the version advances is
+        // still accepted, because getClawbackContextHash has no version
+        // component.
+        {
+            Env env{*this, features};
+            auto mptAlice = setupEnv(env);
+
+            mptAlice.convert({.account = bob, .amt = 500, .holderPubKey = mptAlice.getPubKey(bob)});
+            mptAlice.mergeInbox({
+                .account = bob,
+            });
+
+            auto const privKey = mptAlice.getPrivKey(alice);
+            if (!BEAST_EXPECT(privKey.has_value()))
+                return;
+
+            auto const proof = mptAlice.getClawbackProof(
+                bob,
+                500,
+                requireOptionalRef(privKey, "Missing private key"),
+                getClawbackContextHash(
+                    alice.id(), mptAlice.issuanceID(), env.seq(alice), bob.id()));
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            // Advance bob's balance version after the proof is generated. An
+            // empty-inbox merge leaves the balance unchanged but still bumps
+            // sfConfidentialBalanceVersion.
+            auto const versionBefore = mptAlice.getMPTokenVersion(bob);
+            mptAlice.mergeInbox({.account = bob});
+            BEAST_EXPECT(mptAlice.getMPTokenVersion(bob) != versionBefore);
+
+            // The stale-version proof is still accepted.
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 500,
+                .proof = strHex(requireOptional(proof, "Missing proof")),
+            });
+        }
+    }
+
+    void
+    testPublicTransfersAfterClearingConfidentialFlag(FeatureBitset features)
+    {
+        testcase("Public transfers after clearing Confidential Flag");
+        using namespace test::jtx;
+
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+
+        // After clearing the confidential flag, all four public MPT operations
+        // must succeed regardless of which confidential path left encrypted-zero
+        // fields on bob's MPToken.
+        auto runPublicPayments = [&](MPTTester& mpt) {
+            mpt.pay(bob, carol, 10);
+            mpt.pay(carol, bob, 5);
+            mpt.pay(alice, bob, 1);
+            mpt.pay(carol, alice, 5);
+        };
+
+        auto drainAndDeleteBobMPToken = [&](Env& env, MPTTester& mpt) {
+            auto const bobBalance = mpt.getBalance(bob);
+            BEAST_EXPECT(bobBalance > 0);
+
+            mpt.pay(bob, alice, bobBalance);
+            BEAST_EXPECT(mpt.getBalance(bob) == 0);
+
+            mpt.authorize({.account = bob, .flags = tfMPTUnauthorize});
+            BEAST_EXPECT(!env.le(keylet::mptoken(mpt.issuanceID(), bob.id())));
+        };
+
+        // Alice pays Bob 100 public, Bob converts 50 confidential
+        // Bob converts 50 back to public, and make sure can receive public payments
+        {
+            Env env{*this, features};
+            ConfidentialEnv ct{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50}},
+                tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance};
+
+            env.fund(XRP(1'000), carol);
+            ct.mpt.authorize({.account = carol});
+            ct.mpt.pay(alice, carol, 50);
+
+            ct.mpt.convertBack({.account = bob, .amt = 50});
+
+            runPublicPayments(ct.mpt);
+            drainAndDeleteBobMPToken(env, ct.mpt);
+        }
+
+        // Same path as above but with Auditor
+        {
+            Env env{*this, features};
+            Account const auditor("auditor");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({.account = bob});
+            mptAlice.authorize({.account = carol});
+            mptAlice.pay(alice, bob, 100);
+            mptAlice.pay(alice, carol, 50);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(auditor);
+            mptAlice.set(
+                {.account = alice,
+                 .issuerPubKey = mptAlice.getPubKey(alice),
+                 .auditorPubKey = mptAlice.getPubKey(auditor)});
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+            mptAlice.mergeInbox({.account = bob});
+            mptAlice.convertBack({.account = bob, .amt = 50});
+
+            runPublicPayments(mptAlice);
+            drainAndDeleteBobMPToken(env, mptAlice);
+        }
+
+        // Confidential clawback leaves encrypted-zero fields;
+        // the public balance remaining after the clawback must stay usable.
+        {
+            Env env{*this, features};
+            ConfidentialEnv ct{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 50}},
+                tfMPTCanTransfer | tfMPTCanClawback | tfMPTCanHoldConfidentialBalance};
+
+            env.fund(XRP(1'000), carol);
+            ct.mpt.authorize({.account = carol});
+            ct.mpt.pay(alice, carol, 50);
+
+            ct.mpt.confidentialClaw({.account = alice, .holder = bob, .amt = 50});
+
+            runPublicPayments(ct.mpt);
+            drainAndDeleteBobMPToken(env, ct.mpt);
+        }
+    }
+
+    void
+    testMutatePrivacy(FeatureBitset features)
+    {
+        testcase("mutate lsfMPTCanHoldConfidentialBalance");
+        using namespace test::jtx;
+
+        // can not create mpt issuance with tmfMPTCannotEnableCanHoldConfidentialBalance
+        // when featureDynamicMPT is disabled
+        {
+            Env env{*this, features - featureDynamicMPT};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 0,
+                .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance,
+                .err = temDISABLED,
+            });
+        }
+
+        // can not create mpt issuance with tmfMPTCannotEnableCanHoldConfidentialBalance when
+        // featureConfidentialTransfer is disabled
+        {
+            Env env{*this, features - featureConfidentialTransfer};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 0,
+                .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance,
+                .err = temDISABLED,
+            });
+        }
+
+        // if lsmfMPTCannotEnableCanHoldConfidentialBalance is set, can not set/clear
+        // lsfMPTCanHoldConfidentialBalance
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer,
+                .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance,
+            });
+
+            mptAlice.set({
+                .account = alice,
+                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Toggle lsfMPTCanHoldConfidentialBalance
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+                .mutableFlags = tmfMPTCanEnableCanLock,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            auto holderPubKeySet = false;
+            auto verifyToggle = [&](TER expectedResult, uint64_t amt) {
+                if (!holderPubKeySet)
+                {
+                    mptAlice.convert({
+                        .account = bob,
+                        .amt = amt,
+                        .holderPubKey = mptAlice.getPubKey(bob),
+                        .err = expectedResult,
+                    });
+                }
+                else
+                {
+                    mptAlice.convert({
+                        .account = bob,
+                        .amt = amt,
+                        .err = expectedResult,
+                    });
+                }
+
+                if (expectedResult == tesSUCCESS)
+                {
+                    holderPubKeySet = true;
+                    mptAlice.mergeInbox({
+                        .account = bob,
+                    });
+
+                    // make sure there's no confidential outstanding balance
+                    // for the next toggle test
+                    mptAlice.convertBack({
+                        .account = bob,
+                        .amt = amt,
+                    });
+                }
+            };
+
+            // set lsfMPTCanHoldConfidentialBalance, but no effect because
+            // lsfMPTCanHoldConfidentialBalance was already set
+            mptAlice.set({
+                .account = alice,
+                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+            });
+            verifyToggle(tesSUCCESS, 10);
+
+            // set tmfMPTSetCanHoldConfidentialBalance again
+            mptAlice.set({
+                .account = alice,
+                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+            });
+            verifyToggle(tesSUCCESS, 30);
+        }
+
+        // can not mutate lsfPrivacy when there's confidential
+        // outstanding amount
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            // lsmfMPTCannotEnableCanHoldConfidentialBalance is false by default,
+            // so that lsfMPTCanHoldConfidentialBalance can be mutated
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({
+                .account = bob,
+            });
+            mptAlice.pay(alice, bob, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // bob convert 50 to confidential
+            mptAlice.convert({.account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob)});
+
+            // set lsfMPTCanHoldConfidentialBalance should fail because of
+            // confidential outstanding balance
+            mptAlice.set({
+                .account = alice,
+                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .err = tecNO_PERMISSION,
+            });
+        }
+    }
+
+    void
+    testConvertBackPedersenProof(FeatureBitset features)
+    {
+        testcase("Convert back pedersen proof");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        ConfidentialEnv confEnv{
+            env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
+        auto& mptAlice = confEnv.mpt;
+
+        // for ease of understanding, generate all the fields here instead of
+        // autofilling
+        uint64_t const amt = 10;
+        Buffer const blindingFactor = generateBlindingFactor();
+        Buffer const pcBlindingFactor = generateBlindingFactor();
+
+        auto const spendingBalance = requireOptional(
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing spending balance");
+        auto const encryptedSpendingBalance = requireOptional(
+            mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing encrypted spending balance");
+        BEAST_EXPECT(!encryptedSpendingBalance.empty());
+
+        Buffer const pedersenCommitment =
+            mptAlice.getPedersenCommitment(spendingBalance, pcBlindingFactor);
+        Buffer const issuerCiphertext = mptAlice.encryptAmount(alice, amt, blindingFactor);
+        Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor);
+        auto const version = mptAlice.getMPTokenVersion(bob);
+
+        // These tests verify that the compact ConvertBack proof validation
+        // correctly rejects proofs generated with incorrect parameters.
+        // The compact proof simultaneously verifies balance ownership,
+        // commitment linkage, and that remaining balance is non-negative.
+
+        // Test 1: Proof generated with wrong pedersen commitment value.
+        // The proof uses PC(1, rho) but the transaction submits PC(balance, rho).
+        // Verification fails because the proof doesn't match the submitted commitment.
+        {
+            uint256 const contextHash =
+                getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
+            Buffer const badPedersenCommitment =
+                mptAlice.getPedersenCommitment(1, pcBlindingFactor);
+            Buffer const proof = mptAlice.getConvertBackProof(
+                bob,
+                amt,
+                contextHash,
+                {
+                    .pedersenCommitment = badPedersenCommitment,  // wrong pedersen commitment
+                    .amt = spendingBalance,
+                    .encryptedAmt = encryptedSpendingBalance,
+                    .blindingFactor = pcBlindingFactor,
+                });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = amt,
+                .proof = proof,
+                .holderEncryptedAmt = bobCiphertext,
+                .issuerEncryptedAmt = issuerCiphertext,
+                .blindingFactor = blindingFactor,
+                .pedersenCommitment = pedersenCommitment,
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // Test 2: Proof generated with wrong blinding factor (rho).
+        // The pedersen commitment PC = balance*G + rho*H requires the same rho
+        // used in proof generation. Using a different rho breaks the linkage.
+        {
+            uint256 const contextHash =
+                getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
+
+            Buffer const proof = mptAlice.getConvertBackProof(
+                bob,
+                amt,
+                contextHash,
+                {
+                    .pedersenCommitment = pedersenCommitment,
+                    .amt = spendingBalance,
+                    .encryptedAmt = encryptedSpendingBalance,
+                    .blindingFactor = generateBlindingFactor(),  // wrong blinding factor
+                });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = amt,
+                .proof = proof,
+                .holderEncryptedAmt = bobCiphertext,
+                .issuerEncryptedAmt = issuerCiphertext,
+                .blindingFactor = blindingFactor,
+                .pedersenCommitment = pedersenCommitment,
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // Test 3: Proof generated with wrong balance value.
+        // The proof claims balance=1 but the encrypted spending balance contains
+        // the actual balance. Verification fails because the values don't match.
+        {
+            uint256 const contextHash =
+                getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
+
+            Buffer const proof = mptAlice.getConvertBackProof(
+                bob,
+                amt,
+                contextHash,
+                {
+                    .pedersenCommitment = pedersenCommitment,
+                    .amt = 1,  // wrong balance
+                    .encryptedAmt = encryptedSpendingBalance,
+                    .blindingFactor = pcBlindingFactor,
+                });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = amt,
+                .proof = proof,
+                .holderEncryptedAmt = bobCiphertext,
+                .issuerEncryptedAmt = issuerCiphertext,
+                .blindingFactor = blindingFactor,
+                .pedersenCommitment = pedersenCommitment,
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // Test 4: Correct proof but wrong pedersen commitment in transaction.
+        // The proof is generated correctly, but the transaction submits a
+        // different pedersen commitment. Verification fails because the
+        // submitted commitment doesn't match what the proof was generated for.
+        {
+            uint256 const contextHash =
+                getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
+            Buffer const badPedersenCommitment =
+                mptAlice.getPedersenCommitment(1, pcBlindingFactor);
+            Buffer const proof = mptAlice.getConvertBackProof(
+                bob,
+                amt,
+                contextHash,
+                {
+                    .pedersenCommitment = pedersenCommitment,
+                    .amt = spendingBalance,
+                    .encryptedAmt = encryptedSpendingBalance,
+                    .blindingFactor = pcBlindingFactor,
+                });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = amt,
+                .proof = proof,
+                .holderEncryptedAmt = bobCiphertext,
+                .issuerEncryptedAmt = issuerCiphertext,
+                .blindingFactor = blindingFactor,
+                .pedersenCommitment = badPedersenCommitment,  // wrong pedersen commitment
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // Test 5: Proof generated with wrong context hash.
+        // The context hash binds the proof to a specific transaction (account,
+        // sequence, issuanceID, amount, version). Using a different context hash
+        // makes the proof invalid for this transaction, preventing replay attacks.
+        {
+            uint256 const badContextHash{1};
+
+            Buffer const proof = mptAlice.getConvertBackProof(
+                bob,
+                amt,
+                badContextHash,  // wrong context hash
+                {
+                    .pedersenCommitment = pedersenCommitment,
+                    .amt = spendingBalance,
+                    .encryptedAmt = encryptedSpendingBalance,
+                    .blindingFactor = pcBlindingFactor,
+                });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = amt,
+                .proof = proof,
+                .holderEncryptedAmt = bobCiphertext,
+                .issuerEncryptedAmt = issuerCiphertext,
+                .blindingFactor = blindingFactor,
+                .pedersenCommitment = pedersenCommitment,
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // Test 6: Correct proof to verify the test setup is valid.
+        // All parameters are correct, so the transaction should succeed.
+        {
+            uint256 const contextHash =
+                getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
+
+            Buffer const proof = mptAlice.getConvertBackProof(
+                bob,
+                amt,
+                contextHash,
+                {
+                    .pedersenCommitment = pedersenCommitment,
+                    .amt = spendingBalance,
+                    .encryptedAmt = encryptedSpendingBalance,
+                    .blindingFactor = pcBlindingFactor,
+                });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = amt,
+                .proof = proof,
+                .holderEncryptedAmt = bobCiphertext,
+                .issuerEncryptedAmt = issuerCiphertext,
+                .blindingFactor = blindingFactor,
+                .pedersenCommitment = pedersenCommitment,
+            });
+        }
+    }
+
+    void
+    testConvertBackBulletproof(FeatureBitset features)
+    {
+        testcase("Convert back bulletproof");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        ConfidentialEnv confEnv{
+            env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
+        auto& mptAlice = confEnv.mpt;
+
+        // for ease of understanding, generate all the fields here instead of
+        // autofilling
+        uint64_t const amt = 10;
+        Buffer const blindingFactor = generateBlindingFactor();
+        Buffer const pcBlindingFactor = generateBlindingFactor();
+
+        auto const spendingBalance = requireOptional(
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing spending balance");
+        auto const encryptedSpendingBalance = requireOptional(
+            mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing encrypted spending balance");
+        BEAST_EXPECT(!encryptedSpendingBalance.empty());
+
+        Buffer const pedersenCommitment =
+            mptAlice.getPedersenCommitment(spendingBalance, pcBlindingFactor);
+        Buffer const issuerCiphertext = mptAlice.encryptAmount(alice, amt, blindingFactor);
+        Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor);
+        auto const version = mptAlice.getMPTokenVersion(bob);
+
+        // These tests verify that the compact ConvertBack proof (sigma + bulletproof)
+        // correctly rejects proofs generated with incorrect parameters.
+        // The compact proof simultaneously verifies balance ownership, commitment
+        // linkage, and that the remaining balance is non-negative.
+
+        // Test 1: Proof generated with wrong balance value.
+        // The sigma proof claims balance=1 but the spending balance contains the
+        // actual balance. The compact proof's balance-linkage check fails.
+        {
+            uint256 const contextHash =
+                getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
+
+            Buffer const proof = mptAlice.getConvertBackProof(
+                bob,
+                amt,
+                contextHash,
+                {
+                    .pedersenCommitment = pedersenCommitment,
+                    .amt = 1,  // wrong balance (actual balance is ~40)
+                    .encryptedAmt = encryptedSpendingBalance,
+                    .blindingFactor = pcBlindingFactor,
+                });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = amt,
+                .proof = proof,
+                .holderEncryptedAmt = bobCiphertext,
+                .issuerEncryptedAmt = issuerCiphertext,
+                .blindingFactor = blindingFactor,
+                .pedersenCommitment = pedersenCommitment,
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // Test 2: Proof generated with wrong blinding factor (rho).
+        // The compact sigma proof must use the same blinding factor (rho) as the
+        // Pedersen commitment PC = balance*G + rho*H. Using a different rho
+        // creates an inconsistency the verifier detects.
+        {
+            uint256 const contextHash =
+                getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
+
+            Buffer const proof = mptAlice.getConvertBackProof(
+                bob,
+                amt,
+                contextHash,
+                {
+                    .pedersenCommitment = pedersenCommitment,
+                    .amt = spendingBalance,
+                    .encryptedAmt = encryptedSpendingBalance,
+                    .blindingFactor = generateBlindingFactor(),  // wrong blinding factor
+                });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = amt,
+                .proof = proof,
+                .holderEncryptedAmt = bobCiphertext,
+                .issuerEncryptedAmt = issuerCiphertext,
+                .blindingFactor = blindingFactor,
+                .pedersenCommitment = pedersenCommitment,
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // Test 3: Proof generated with wrong context hash.
+        // The context hash binds the proof to a specific transaction (account,
+        // sequence, issuanceID, amount, version). Using a different context hash
+        // makes the proof invalid for this transaction, preventing replay attacks.
+        {
+            uint256 const badContextHash{1};
+            Buffer const proof = mptAlice.getConvertBackProof(
+                bob,
+                amt,
+                badContextHash,  // wrong context hash
+                {
+                    .pedersenCommitment = pedersenCommitment,
+                    .amt = spendingBalance,
+                    .encryptedAmt = encryptedSpendingBalance,
+                    .blindingFactor = pcBlindingFactor,
+                });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = amt,
+                .proof = proof,
+                .holderEncryptedAmt = bobCiphertext,
+                .issuerEncryptedAmt = issuerCiphertext,
+                .blindingFactor = blindingFactor,
+                .pedersenCommitment = pedersenCommitment,
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // Test 4: Correct proof to verify the test setup is valid.
+        // All parameters are correct, so the transaction should succeed.
+        {
+            uint256 const contextHash =
+                getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
+
+            Buffer const proof = mptAlice.getConvertBackProof(
+                bob,
+                amt,
+                contextHash,
+                {
+                    .pedersenCommitment = pedersenCommitment,
+                    .amt = spendingBalance,
+                    .encryptedAmt = encryptedSpendingBalance,
+                    .blindingFactor = pcBlindingFactor,
+                });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = amt,
+                .proof = proof,
+                .holderEncryptedAmt = bobCiphertext,
+                .issuerEncryptedAmt = issuerCiphertext,
+                .blindingFactor = blindingFactor,
+                .pedersenCommitment = pedersenCommitment,
+            });
+        }
+    }
+
+    // A convert-back proof is bound to (account, issuance, sequence, version) via
+    // the Fiat-Shamir context hash. Crafting a proof against any single wrong
+    // variable and submitting it with the real parameters must be rejected
+    // with tecBAD_PROOF
+    void
+    testConvertBackInvalidProofContextBinding(FeatureBitset features)
+    {
+        testcase("ConvertBack proof context binding");
+        using namespace test::jtx;
+
+        auto runBadProof = [&](auto makeContextHash) {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            ConfidentialEnv confEnv{
+                env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
+            auto& mptAlice = confEnv.mpt;
+
+            std::uint64_t const amt = 10;
+            Buffer const blindingFactor = generateBlindingFactor();
+            Buffer const pcBlindingFactor = generateBlindingFactor();
+
+            auto const spendingBalance =
+                mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
+            auto const encryptedSpendingBalance =
+                mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending);
+            if (!BEAST_EXPECT(spendingBalance && encryptedSpendingBalance))
+                return;
+
+            Buffer const pedersenCommitment = mptAlice.getPedersenCommitment(
+                requireOptional(spendingBalance, "Missing spending balance"), pcBlindingFactor);
+            Buffer const issuerCiphertext = mptAlice.encryptAmount(alice, amt, blindingFactor);
+            Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor);
+            auto const version = mptAlice.getMPTokenVersion(bob);
+
+            Buffer const proof = mptAlice.getConvertBackProof(
+                bob,
+                amt,
+                makeContextHash(env, mptAlice, alice, bob, carol, version),
+                {
+                    .pedersenCommitment = pedersenCommitment,
+                    .amt = requireOptional(spendingBalance, "Missing spending balance"),
+                    .encryptedAmt = requireOptionalRef(
+                        encryptedSpendingBalance, "Missing encrypted spending balance"),
+                    .blindingFactor = pcBlindingFactor,
+                });
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = amt,
+                .proof = proof,
+                .holderEncryptedAmt = bobCiphertext,
+                .issuerEncryptedAmt = issuerCiphertext,
+                .blindingFactor = blindingFactor,
+                .pedersenCommitment = pedersenCommitment,
+                .err = tecBAD_PROOF,
+            });
+        };
+
+        // Wrong account in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const& mpt,
+                        Account const&,
+                        Account const& bob,
+                        Account const& carol,
+                        std::uint32_t version) {
+            return getConvertBackContextHash(carol.id(), mpt.issuanceID(), env.seq(bob), version);
+        });
+
+        // Wrong issuance ID in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const&,
+                        Account const& alice,
+                        Account const& bob,
+                        Account const&,
+                        std::uint32_t version) {
+            return getConvertBackContextHash(
+                bob.id(), makeMptID(env.seq(alice) + 100, alice), env.seq(bob), version);
+        });
+
+        // Wrong transaction sequence in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const& mpt,
+                        Account const&,
+                        Account const& bob,
+                        Account const&,
+                        std::uint32_t version) {
+            return getConvertBackContextHash(bob.id(), mpt.issuanceID(), env.seq(bob) + 1, version);
+        });
+
+        // Wrong balance version in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const& mpt,
+                        Account const&,
+                        Account const& bob,
+                        Account const&,
+                        std::uint32_t version) {
+            return getConvertBackContextHash(bob.id(), mpt.issuanceID(), env.seq(bob), version + 1);
+        });
+    }
+
+    // This test simulates a valid proof π extracted from a transaction
+    // for amount m1 is reused in a new transaction for a different
+    // amount m2 with different ciphertexts. It confirms the context hash
+    // recomputation fails due to the ciphertext binding mismatch, resulting
+    // in tecBAD_PROOF.
+    void
+    testConvertBackProofCiphertextBinding(FeatureBitset features)
+    {
+        testcase("ConvertBack: proof ciphertext binding");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob");
+        ConfidentialEnv confEnv{
+            env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}};
+        auto& mptAlice = confEnv.mpt;
+
+        auto const spendingBalance = requireOptional(
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing spending balance");
+        auto const encryptedSpendingBalance = requireOptional(
+            mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing encrypted spending balance");
+        auto const version = mptAlice.getMPTokenVersion(bob);
+        Buffer const pcBlindingFactor = generateBlindingFactor();
+        Buffer const pedersenCommitment =
+            mptAlice.getPedersenCommitment(spendingBalance, pcBlindingFactor);
+
+        // Generate a valid proof pi for Amount m1 = 10
+        uint64_t const amtA = 10;
+        uint32_t const currentSeq = env.seq(bob);
+        uint256 const contextHashA =
+            getConvertBackContextHash(bob, mptAlice.issuanceID(), currentSeq, version);
+
+        Buffer const proofA = mptAlice.getConvertBackProof(
+            bob,
+            amtA,
+            contextHashA,
+            {
+                .pedersenCommitment = pedersenCommitment,
+                .amt = spendingBalance,
+                .encryptedAmt = encryptedSpendingBalance,
+                .blindingFactor = pcBlindingFactor,
+            });
+
+        // Construct Transaction B with Amount m2 = 20 and attach Proof pi
+        uint64_t const amtB = 20;
+        Buffer const blindingFactorB = generateBlindingFactor();
+        Buffer const bobCiphertextB = mptAlice.encryptAmount(bob, amtB, blindingFactorB);
+        Buffer const issuerCiphertextB = mptAlice.encryptAmount(alice, amtB, blindingFactorB);
+
+        // We attempt to verify the proof pi (for amt 10) against the new ciphertexts (for amt 20).
+        mptAlice.convertBack({
+            .account = bob,
+            .amt = amtB,
+            .proof = proofA,  // Extracted/Reused proof from Transaction A
+            .holderEncryptedAmt = bobCiphertextB,
+            .issuerEncryptedAmt = issuerCiphertextB,
+            .blindingFactor = blindingFactorB,
+            .pedersenCommitment = pedersenCommitment,
+            .err = tecBAD_PROOF,  // Expected failure
+        });
+    }
+
+    // This test simulates a valid proof π and ciphertext are
+    // tied to version v, but are reused after an inbox merge has incremented
+    // the CBS version to v+1. It confirms the validator rejects the transaction
+    // before acceptance due to the ContextID mismatch.
+    void
+    testConvertBackProofVersionMismatch(FeatureBitset features)
+    {
+        testcase("ConvertBack: proof version mismatch");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob");
+        ConfidentialEnv confEnv{
+            env, alice, {{.account = bob, .payAmount = 1000, .convertAmount = 100}}};
+        auto& mptAlice = confEnv.mpt;
+
+        auto const versionV = mptAlice.getMPTokenVersion(bob);
+        auto const spendingBalanceV = requireOptional(
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing spending balance");
+        auto const encryptedSpendingBalanceV = requireOptional(
+            mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing encrypted spending balance");
+
+        // Parameters for the intended ConvertBack transaction
+        uint64_t const amt = 10;
+        Buffer const blindingFactor = generateBlindingFactor();
+        Buffer const pcBlindingFactor = generateBlindingFactor();
+        Buffer const pedersenCommitment =
+            mptAlice.getPedersenCommitment(spendingBalanceV, pcBlindingFactor);
+        Buffer const issuerCiphertext = mptAlice.encryptAmount(alice, amt, blindingFactor);
+        Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor);
+
+        // State Change: Increment version to v+1
+        // Converting more funds and merging increments the sfConfidentialBalanceVersion
+        mptAlice.convert({
+            .account = bob,
+            .amt = 50,
+        });
+        mptAlice.mergeInbox({
+            .account = bob,
+        });
+
+        BEAST_EXPECT(mptAlice.getMPTokenVersion(bob) > versionV);
+
+        // Attack: Attempt to reuse proof tied to Version v at ledger Version v+1
+        uint32_t const currentSeq = env.seq(bob);
+        // Proof is explicitly generated using the outdated Version v
+        uint256 const oldContextHash =
+            getConvertBackContextHash(bob, mptAlice.issuanceID(), currentSeq, versionV);
+
+        Buffer const oldProof = mptAlice.getConvertBackProof(
+            bob,
+            amt,
+            oldContextHash,
+            {
+                .pedersenCommitment = pedersenCommitment,
+                .amt = spendingBalanceV,
+                .encryptedAmt = encryptedSpendingBalanceV,
+                .blindingFactor = pcBlindingFactor,
+            });
+
+        // Submit and verify failure
+        mptAlice.convertBack({
+            .account = bob,
+            .amt = amt,
+            .proof = oldProof,
+            .holderEncryptedAmt = bobCiphertext,
+            .issuerEncryptedAmt = issuerCiphertext,
+            .blindingFactor = blindingFactor,
+            .pedersenCommitment = pedersenCommitment,
+            .err = tecBAD_PROOF,  // Fails because TransactionContextID differs
+        });
+    }
+
+    /* This test simulates an attack where the holder ciphertext is modified
+     * via homomorphic addition (adding Encrypted_amt(1)) while leaving the issuer
+     * ciphertext unchanged. It confirms that the validator detects the
+     * mismatch between the re-computed ciphertexts and the submitted ones,
+     * resulting in tecBAD_PROOF.   */
+    void
+    testConvertBackHomomorphicCiphertextModification(FeatureBitset features)
+    {
+        testcase("ConvertBack: homomorphic ciphertext modification");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob");
+        ConfidentialEnv confEnv{
+            env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}};
+        auto& mptAlice = confEnv.mpt;
+
+        // Prepare valid parameters for a ConvertBack of 10
+        uint64_t const amt = 10;
+        Buffer const bf = generateBlindingFactor();
+
+        auto const holderCipherText = mptAlice.encryptAmount(bob, amt, bf);
+        auto const issuerCipherText = mptAlice.encryptAmount(alice, amt, bf);
+
+        // Generate a "Delta" ciphertext (Encrypting 1)
+        // We use Bob's key because we are tampering with Bob's (Holder's) field
+        Buffer const deltaBf = generateBlindingFactor();
+        auto const deltaCipherText = mptAlice.encryptAmount(bob, 1, deltaBf);
+
+        // Homomorphically add Delta to HolderCipherText: Tampered = Enc(10) + Enc(1) = Enc(11)
+        Buffer tamperedHolderCipherText = requireOptional(
+            homomorphicAdd(holderCipherText, deltaCipherText), "Missing tampered ciphertext");
+
+        // Generate a valid proof for the ORIGINAL amount (10)
+        auto const spendingBal = requireOptional(
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing spending balance");
+        auto const spendingBalEnc = requireOptional(
+            mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing encrypted spending balance");
+        Buffer const pcBf = generateBlindingFactor();
+        auto const pedersenCommitment = mptAlice.getPedersenCommitment(spendingBal, pcBf);
+
+        auto const currentVersion = mptAlice.getMPTokenVersion(bob);
+        // Uses the new signature: Account, IssuanceID, Sequence, Version
+        uint256 const contextHash =
+            getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), currentVersion);
+
+        Buffer const proof = mptAlice.getConvertBackProof(
+            bob,
+            amt,
+            contextHash,
+            {
+                .pedersenCommitment = pedersenCommitment,
+                .amt = spendingBal,
+                .encryptedAmt = spendingBalEnc,
+                .blindingFactor = pcBf,
+            });
+
+        // Submit transaction with Divergent Ciphertexts
+        // Holder Ciphertext encrypts 11. Issuer Ciphertext encrypts 10.
+        // The consistency check (re-encryption of `amt` with `bf`) will match Issuer but FAIL for
+        // Holder.
+        mptAlice.convertBack({
+            .account = bob,
+            .amt = amt,
+            .proof = proof,
+            .holderEncryptedAmt = tamperedHolderCipherText,  // Tampered (11)
+            .issuerEncryptedAmt = issuerCipherText,          // Original (10)
+            .blindingFactor = bf,
+            .pedersenCommitment = pedersenCommitment,
+            .err = tecBAD_PROOF,
+        });
+    }
+
+    /* This test verifies that xrpld correctly rejects attempts to
+     * overflow the maximum allowable token amount via homomorphic manipulation.
+     * It simulates an attack where an individual takes a valid ciphertext encrypting
+     * the maximum amount (kMaxMpTokenAmount) and homomorphically adds an encryption of
+     * 1 to it, producing a ciphertext for MAX+1. The test confirms that the Bulletproof
+     * range proof or inner-product constraints detect this overflow and invalidate the
+     * transaction, preserving the supply invariant. */
+    void
+    testSendHomomorphicOverflow(FeatureBitset features)
+    {
+        testcase("Send: homomorphic overflow attack via Enc(MAX) + Enc(1)");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob, .payAmount = 100, .convertAmount = 100},
+             {.account = carol, .payAmount = 50, .convertAmount = 50}}};
+        auto& mptAlice = confEnv.mpt;
+
+        // Bob sends 10 to carol.  The send amount (10) and Bob's remaining balance
+        // (90) are both within [0, kMaxMpTokenAmount].  Range proof passes.
+        mptAlice.send({.account = bob, .dest = carol, .amt = 10});
+
+        // Bob's spending balance is 90 after the baseline send.
+        auto const bobSpendingBefore =
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
+        BEAST_EXPECT(bobSpendingBefore == 90);
+
+        // Construct Enc(kMaxMpTokenAmount) with Bob's public key.
+        Buffer const bf1 = generateBlindingFactor();
+        Buffer const encMax = mptAlice.encryptAmount(bob, kMaxMpTokenAmount, bf1);
+
+        // Construct Enc(1) with a separate blinding factor.
+        Buffer const bf2 = generateBlindingFactor();
+        Buffer const encOne = mptAlice.encryptAmount(bob, 1, bf2);
+
+        // Homomorphically add to produce CB_S_holder' = Enc(MAX) + Enc(1)
+        Buffer overflowedCt =
+            requireOptional(homomorphicAdd(encMax, encOne), "Missing overflowed ciphertext");
+
+        // Submit the send transaction with the tampered ciphertext.
+        // Setting amt = kMaxMpTokenAmount + 1 drives proof generation for the
+        // overflowed value.  The bulletproof range check [0, kMaxMpTokenAmount]
+        // rejects MAX+1; the validator must return tecBAD_PROOF.
+        mptAlice.send({
+            .account = bob,
+            .dest = carol,
+            .amt = kMaxMpTokenAmount + 1,
+            .senderEncryptedAmt = overflowedCt,
+            .err = tecBAD_PROOF,
+        });
+
+        auto const bobSpendingAfter =
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
+        BEAST_EXPECT(bobSpendingBefore == bobSpendingAfter);
+    }
+
+    /* This test ensures that the system prevents underflow attacks where a user
+     * attempts to create a negative balance through homomorphic subtraction. It
+     * simulates a scenario where an attacker takes a ciphertext encrypting zero
+     * and subtracts an encryption of 1, resulting in a value of -1.
+     * The test asserts that the range proof verification fails because the resulting
+     * value falls outside the valid non-negative range [0, kMaxMpTokenAmount],
+     * causing the validator to reject the transaction with tecBAD_PROOF. */
+    void
+    testConvertBackHomomorphicUnderflow(FeatureBitset features)
+    {
+        testcase("ConvertBack: homomorphic underflow attack via Enc(0) - Enc(1)");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob");
+        ConfidentialEnv confEnv{
+            env, alice, {{.account = bob, .payAmount = 10, .convertAmount = 10}}};
+        auto& mptAlice = confEnv.mpt;
+
+        // Converting back 1 from 10 leaves remaining balance = 9 (non-negative).
+        // Range proof [0, kMaxMpTokenAmount] passes.
+        mptAlice.convertBack({.account = bob, .amt = 1});
+
+        // Bob's spending balance is now 9; public balance is 1.
+        auto const bobSpendingBefore =
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
+        BEAST_EXPECT(bobSpendingBefore == 9);
+        auto const bobPublicBefore = mptAlice.getBalance(bob);
+        BEAST_EXPECT(bobPublicBefore == 1);
+
+        // Construct Enc(0) — the zero encrypted balance using Bob's key.
+        Buffer const bf1 = generateBlindingFactor();
+        Buffer const encZero = mptAlice.encryptAmount(bob, 0, bf1);
+
+        // Construct Enc(1) with a separate blinding factor.
+        Buffer const bf2 = generateBlindingFactor();
+        Buffer const encOne = mptAlice.encryptAmount(bob, 1, bf2);
+
+        // Homomorphically subtract to produce CB_S_holder' = Enc(0) − Enc(1)
+        // = Enc(−1), which lies below [0, kMaxMpTokenAmount].
+        Buffer underflowedCt =
+            requireOptional(homomorphicSubtract(encZero, encOne), "Missing underflowed ciphertext");
+
+        // The underflowed value as uint64_t: 0 - 1 wraps to 0xFFFFFFFFFFFFFFFF.
+        // Generate a real proof using this wrapped value. The validator must still reject it
+        // because 0xFFFFFFFFFFFFFFFE (remaining balance) is outside [0, kMaxMpTokenAmount].
+        constexpr std::uint64_t kUnderflowedAmt =
+            static_cast(0) - static_cast(1);
+
+        Buffer const pcBf = generateBlindingFactor();
+        Buffer const pedersenCommitment = mptAlice.getPedersenCommitment(kUnderflowedAmt, pcBf);
+
+        auto const currentVersion = mptAlice.getMPTokenVersion(bob);
+        uint256 const contextHash =
+            getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), currentVersion);
+
+        Buffer const proof = mptAlice.getConvertBackProof(
+            bob,
+            1,
+            contextHash,
+            {
+                .pedersenCommitment = pedersenCommitment,
+                .amt = kUnderflowedAmt,
+                .encryptedAmt = underflowedCt,
+                .blindingFactor = pcBf,
+            });
+
+        mptAlice.convertBack({
+            .account = bob,
+            .amt = 1,
+            .proof = proof,
+            .holderEncryptedAmt = underflowedCt,
+            .pedersenCommitment = pedersenCommitment,
+            .err = tecBAD_PROOF,
+        });
+
+        // Supply invariant: both public and confidential balances must be unchanged
+        // after the rejected attack.
+        BEAST_EXPECT(mptAlice.getBalance(bob) == bobPublicBefore);
+        auto const bobSpendingAfter =
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
+        BEAST_EXPECT(bobSpendingBefore == bobSpendingAfter);
+    }
+
+    // Confidential sends carry encrypted amounts and a zero-knowledge proof.
+    // Both are built from elliptic-curve math, so every coordinate in the
+    // transaction must be a real point on the secp256k1 curve. These three
+    // variants confirm the validator rejects garbage coordinates at the right
+    // stage before any expensive cryptographic verification runs.
+    void
+    testSendInvalidCurvePoints(FeatureBitset features)
+    {
+        testcase("Send: off-curve EC points");
+        using namespace test::jtx;
+
+        // Variant A: garbage coordinate in ciphertext / commitment fields
+        // getBadCiphertext() looks structurally valid (correct length, right
+        // prefix byte 0x02) but its x-coordinate is 0xFF...FF, which does not
+        // lie on secp256k1. Preflight must reject before any ledger access.
+        {
+            Account const alice("alice"), bob("bob"), carol("carol");
+            Env env{*this, features};
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 60},
+                 {.account = carol, .payAmount = 50, .convertAmount = 30}}};
+            auto& mptAlice = confEnv.mpt;
+
+            // sender's encrypted amount has an invalid coordinate
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .senderEncryptedAmt = getBadCiphertext(),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // recipient's encrypted amount has an invalid coordinate
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .destEncryptedAmt = getBadCiphertext(),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // issuer's encrypted amount has an invalid coordinate
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .issuerEncryptedAmt = getBadCiphertext(),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // The amount and balance commitments are single curve coordinates
+            // used to tie the proof to the transfer amount and sender balance.
+            // A commitment with a valid-looking prefix but an impossible
+            // x-coordinate must also be rejected.
+            Buffer badCommitment(kEcPedersenCommitmentLength);
+            std::memset(badCommitment.data(), 0xFF, kEcPedersenCommitmentLength);
+            badCommitment.data()[0] = kEcCompressedPrefixEvenY;
+
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .amountCommitment = badCommitment,
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temMALFORMED,
+            });
+
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = badCommitment,
+                .err = temMALFORMED,
+            });
+        }
+
+        // Variant B: garbage coordinates inside the ZKP proof blob
+        // The proof blob has the right total byte length (so it passes the
+        // length check at preflight), but every embedded coordinate is
+        // 0xFF...FF — impossible on secp256k1. The proof verifier must detect
+        // this and return tecBAD_PROOF without crashing.
+        {
+            Account const alice("alice"), bob("bob"), carol("carol");
+            Env env{*this, features};
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 60},
+                 {.account = carol, .payAmount = 50, .convertAmount = 30}}};
+            auto& mptAlice = confEnv.mpt;
+
+            Buffer badProof(kEcSendProofLength);
+            std::memset(badProof.data(), 0xFF, kEcSendProofLength);
+            badProof.data()[0] = kEcCompressedPrefixEvenY;
+
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = strHex(badProof),
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // Variant C: only one of the two ciphertext coordinates is bad
+        // Each encrypted amount is two coordinates back-to-back: C1 then C2.
+        // Both must be valid. These tests corrupt only one at a time to
+        // confirm both are checked independently.
+        {
+            Account const alice("alice"), bob("bob"), carol("carol");
+            Env env{*this, features};
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 60},
+                 {.account = carol, .payAmount = 50, .convertAmount = 30}}};
+            auto& mptAlice = confEnv.mpt;
+
+            // getTrivialCiphertext() has both C1 and C2 as valid (but trivial)
+            // curve coordinates.  We replace one half at a time with 0xFF...FF.
+            auto const& tc = getTrivialCiphertext();
+
+            // C1 = bad (0xFF...FF), C2 = valid trivial point
+            Buffer badC1goodC2(kEcGamalEncryptedTotalLength);
+            std::memset(badC1goodC2.data(), 0xFF, kEcGamalEncryptedTotalLength);
+            badC1goodC2.data()[0] = kEcCompressedPrefixEvenY;
+            std::memcpy(
+                badC1goodC2.data() + kEcCiphertextComponentLength,
+                tc.data() + kEcCiphertextComponentLength,
+                kEcCiphertextComponentLength);
+
+            // C1 = valid trivial point, C2 = bad (0xFF...FF)
+            Buffer goodC1badC2(kEcGamalEncryptedTotalLength);
+            std::memset(goodC1badC2.data(), 0xFF, kEcGamalEncryptedTotalLength);
+            std::memcpy(goodC1badC2.data(), tc.data(), kEcCiphertextComponentLength);
+            goodC1badC2.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY;
+
+            // sender's encrypted amount — bad C1
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .senderEncryptedAmt = badC1goodC2,
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // sender's encrypted amount — bad C2
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .senderEncryptedAmt = goodC1badC2,
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // recipient's encrypted amount — bad C1
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .destEncryptedAmt = badC1goodC2,
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temBAD_CIPHERTEXT,
+            });
+
+            // recipient's encrypted amount — bad C2
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .proof = getTrivialSendProofHex(),
+                .destEncryptedAmt = goodC1badC2,
+                .amountCommitment = getTrivialCommitment(),
+                .balanceCommitment = getTrivialCommitment(),
+                .err = temBAD_CIPHERTEXT,
+            });
+        }
+    }
+
+    // Reject points from the wrong elliptic curve (wrong-group injection).
+    //
+    // An attacker might submit coordinates that come from a completely
+    // different elliptic curve, for example, the one used in TLS
+    // certificates (NIST P-256). If those coordinates happen to also be
+    // valid points on secp256k1 (which is possible since both curves use
+    // 256-bit fields), the format check at preflight will pass. However,
+    // the zero-knowledge proof is built specifically for secp256k1: the
+    // math inside the proof only holds for the right curve, so any
+    // transaction carrying cross-curve data will still be rejected at
+    // proof verification (tecBAD_PROOF).
+    void
+    testSendWrongGroupPointInjection(FeatureBitset features)
+    {
+        testcase("Send: wrong-group point injection rejected");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob, .payAmount = 100, .convertAmount = 60},
+             {.account = carol, .payAmount = 50, .convertAmount = 30}}};
+        auto& mptAlice = confEnv.mpt;
+
+        // The x-coordinate of the NIST P-256 generator point — a real,
+        // well-known value from a different elliptic curve (used in TLS
+        // and certificates).  This x-coordinate is also a valid secp256k1
+        // point, so it passes preflight.  Rejection happens at proof
+        // verification because the ZKP is secp256k1-specific.
+        //
+        //   P-256 generator x:
+        //     6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296
+        static constexpr std::uint8_t kP256GeneratorX[32] = {
+            0x6B, 0x17, 0xD1, 0xF2, 0xE1, 0x2C, 0x42, 0x47, 0xF8, 0xBC, 0xE6,
+            0xE5, 0x63, 0xA4, 0x40, 0xF2, 0x77, 0x03, 0x7D, 0x81, 0x2D, 0xEB,
+            0x33, 0xA0, 0xF4, 0xA1, 0x39, 0x45, 0xD8, 0x98, 0xC2, 0x96,
+        };
+
+        // A 66-byte encrypted amount using the P-256 x-coordinate for both halves.
+        Buffer wrongGroupCt(kEcGamalEncryptedTotalLength);
+        wrongGroupCt.data()[0] = kEcCompressedPrefixEvenY;
+        std::memcpy(wrongGroupCt.data() + 1, kP256GeneratorX, 32);
+        wrongGroupCt.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY;
+        std::memcpy(wrongGroupCt.data() + kEcCiphertextComponentLength + 1, kP256GeneratorX, 32);
+
+        // A 33-byte commitment using the same wrong-curve x-coordinate.
+        Buffer wrongGroupCommitment(kEcPedersenCommitmentLength);
+        wrongGroupCommitment.data()[0] = kEcCompressedPrefixEvenY;
+        std::memcpy(wrongGroupCommitment.data() + 1, kP256GeneratorX, 32);
+
+        // sender's encrypted amount uses a coordinate from the wrong curve
+        mptAlice.send({
+            .account = bob,
+            .dest = carol,
+            .amt = 10,
+            .proof = getTrivialSendProofHex(),
+            .senderEncryptedAmt = wrongGroupCt,
+            .amountCommitment = getTrivialCommitment(),
+            .balanceCommitment = getTrivialCommitment(),
+            .err = tecBAD_PROOF,
+        });
+
+        // recipient's encrypted amount uses a coordinate from the wrong curve
+        mptAlice.send({
+            .account = bob,
+            .dest = carol,
+            .amt = 10,
+            .proof = getTrivialSendProofHex(),
+            .destEncryptedAmt = wrongGroupCt,
+            .amountCommitment = getTrivialCommitment(),
+            .balanceCommitment = getTrivialCommitment(),
+            .err = tecBAD_PROOF,
+        });
+
+        // issuer's encrypted amount uses a coordinate from the wrong curve
+        mptAlice.send({
+            .account = bob,
+            .dest = carol,
+            .amt = 10,
+            .proof = getTrivialSendProofHex(),
+            .issuerEncryptedAmt = wrongGroupCt,
+            .amountCommitment = getTrivialCommitment(),
+            .balanceCommitment = getTrivialCommitment(),
+            .err = tecBAD_PROOF,
+        });
+
+        // amount commitment uses a coordinate from the wrong curve
+        mptAlice.send({
+            .account = bob,
+            .dest = carol,
+            .amt = 10,
+            .proof = getTrivialSendProofHex(),
+            .amountCommitment = wrongGroupCommitment,
+            .balanceCommitment = getTrivialCommitment(),
+            .err = tecBAD_PROOF,
+        });
+
+        // balance commitment uses a coordinate from the wrong curve
+        mptAlice.send({
+            .account = bob,
+            .dest = carol,
+            .amt = 10,
+            .proof = getTrivialSendProofHex(),
+            .amountCommitment = getTrivialCommitment(),
+            .balanceCommitment = wrongGroupCommitment,
+            .err = tecBAD_PROOF,
+        });
+    }
+
+    // Reject an all-zero "null" public key.
+    //
+    // Every account in a confidential transfer needs a real public key —
+    // a specific point on the secp256k1 curve derived from a secret number
+    // only that account knows. An all-zero key (33 bytes of 0x00) is not
+    // a real key. It has no secret behind it, and encrypting data to it
+    // would not actually hide anything. The validator must reject it at
+    // preflight so no account can ever register a broken key.
+    void
+    testConvertIdentityElementRejection(FeatureBitset features)
+    {
+        testcase("Convert: all-zero public key rejected");
+        using namespace test::jtx;
+
+        // 33 zero bytes — not a real public key; no valid secret maps to this.
+        Buffer const nullKey = gMakeZeroBuffer(kEcPubKeyLength);
+
+        // Recipient (holder) tries to register an all-zero key.
+        // Must be rejected so no account ends up with an unprotected balance.
+        {
+            Env env{*this, features};
+            Account const alice("alice"), bob("bob"), carol("carol");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+            mptAlice.authorize({.account = bob});
+            mptAlice.authorize({.account = carol});
+            mptAlice.pay(alice, bob, 100);
+            mptAlice.pay(alice, carol, 50);
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(carol);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // recipient (carol) tries to register an all-zero key
+            mptAlice.convert({
+                .account = carol,
+                .amt = 10,
+                .holderPubKey = nullKey,
+                .err = temMALFORMED,
+            });
+
+            // sender (bob) tries to register an all-zero key
+            mptAlice.convert({
+                .account = bob,
+                .amt = 10,
+                .holderPubKey = nullKey,
+                .err = temMALFORMED,
+            });
+        }
+
+        // Issuer tries to register an all-zero key.
+        // The issuer's key is used to encrypt the issuer's copy of every
+        // transfer amount.
+        {
+            Env env{*this, features};
+            Account const alice("alice"), bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+            mptAlice.authorize({.account = bob});
+            mptAlice.pay(alice, bob, 100);
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = nullKey,
+                .err = temMALFORMED,
+            });
+        }
+    }
+
+    /* This test ensures that when sending confidential tokens, the encrypted
+     * amounts are securely locked to the correct accounts' official public keys.
+     *
+     * Attack scenario — Encrypting the issuer's copy with the wrong key:
+     * A sender correctly encrypts the hidden transfer amount for themselves
+     * and the receiver. However, they intentionally encrypt the issuer's
+     * copy of the data using the wrong public key (for example, using the
+     * receiver's key instead of the official issuer's key). */
+    void
+    testSendWrongIssuerPublicKey(FeatureBitset features)
+    {
+        testcase("Send: issuer ciphertext encrypted under wrong public key");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob, .payAmount = 100, .convertAmount = 100},
+             {.account = carol, .payAmount = 50, .convertAmount = 50}}};
+        auto& mptAlice = confEnv.mpt;
+
+        auto const bobSpendingBefore =
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
+
+        // issuer ciphertext encrypted under carol's holder key
+        // (should be under alice's registered issuer key).
+        {
+            Buffer const bf = generateBlindingFactor();
+            Buffer const wrongIssuerCt = mptAlice.encryptAmount(carol, 10, bf);
+
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .issuerEncryptedAmt = wrongIssuerCt,
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // issuer ciphertext encrypted under bob's holder key
+        // (the sender's own key — still not the registered issuer key).
+        {
+            Buffer const bf = generateBlindingFactor();
+            Buffer const wrongIssuerCt = mptAlice.encryptAmount(bob, 10, bf);
+
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .issuerEncryptedAmt = wrongIssuerCt,
+                .err = tecBAD_PROOF,
+            });
+        }
+
+        // all balances unchanged
+        BEAST_EXPECT(
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) ==
+            bobSpendingBefore);
+        BEAST_EXPECT(mptAlice.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
+    }
+
+    // This test verifies that the compact AND-composed Send sigma proof
+    // enforces the shared-randomness invariant across participants.
+    void
+    testSendSharedRandomnessViolation(FeatureBitset features)
+    {
+        testcase("divergent C1 across participants in ConfidentialMPTSend");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const auditor("auditor");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob, .payAmount = 100, .convertAmount = 50},
+             {.account = carol, .payAmount = 50, .convertAmount = 50}},
+            tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
+            auditor};
+        auto& mptAlice = confEnv.mpt;
+
+        // Send amount is 10.
+        uint64_t const amt = 10;
+
+        enum class Participant { Sender, Dest, Issuer, Auditor };
+
+        // This lambda submits a send transaction where one of the four ciphertexts
+        // is encrypted with different randomness than the one used to build the proof.
+        // Note: When divergent is nullopt, all participants
+        // will use the same randomness and expected to succeed, this is the
+        // control case that confirms the test setup itself is sound, the bad proof
+        // is actually from divergent randomness, not other causes.
+        auto submitWithDivergentC1 = [&](std::optional divergent) {
+            ConfidentialSendSetup setup(mptAlice, bob, carol, alice, amt, std::cref(auditor));
+
+            auto const proofOpt =
+                requireOptional(setup.generateProof(mptAlice, env, bob, carol), "Missing proof");
+
+            // Re-encrypt one participant's ciphertext with divergent randomness.
+            Buffer senderCt = setup.senderAmt;
+            Buffer destCt = setup.destAmt;
+            Buffer issuerCt = setup.issuerAmt;
+            Buffer auditorCt =
+                requireOptionalRef(setup.auditorAmt, "Missing auditor encrypted amount");
+            if (divergent)
+            {
+                Buffer const bfDivergent = generateBlindingFactor();
+                switch (*divergent)
+                {
+                    case Participant::Sender:
+                        senderCt = mptAlice.encryptAmount(bob, amt, bfDivergent);
+                        break;
+                    case Participant::Dest:
+                        destCt = mptAlice.encryptAmount(carol, amt, bfDivergent);
+                        break;
+                    case Participant::Issuer:
+                        issuerCt = mptAlice.encryptAmount(alice, amt, bfDivergent);
+                        break;
+                    case Participant::Auditor:
+                        auditorCt = mptAlice.encryptAmount(auditor, amt, bfDivergent);
+                        break;
+                }
+            }
+
+            TER const expectedErr = divergent ? TER{tecBAD_PROOF} : TER{tesSUCCESS};
+
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = amt,
+                .proof = strHex(proofOpt),
+                .senderEncryptedAmt = senderCt,
+                .destEncryptedAmt = destCt,
+                .issuerEncryptedAmt = issuerCt,
+                .auditorEncryptedAmt = auditorCt,
+                .blindingFactor = setup.blindingFactor,
+                .amountCommitment = setup.amountCommitment,
+                .balanceCommitment = setup.balanceCommitment,
+                .err = expectedErr,
+            });
+
+            // Verify balances.
+            auto const spendingAfter =
+                mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
+            if (divergent)
+            {
+                BEAST_EXPECT(spendingAfter == setup.prevSpending);
+            }
+            else
+            {
+                BEAST_EXPECT(spendingAfter == setup.prevSpending - amt);
+            }
+        };
+
+        // This confirms the test setup is sound, if any of the divergent cases below
+        // fail, it is due to the C1 mismatch and not a setup bug.
+        submitWithDivergentC1(std::nullopt);
+
+        // Divergent C1 for different participants should all fail with tecBAD_PROOF:
+        submitWithDivergentC1(Participant::Sender);
+        submitWithDivergentC1(Participant::Dest);
+        submitWithDivergentC1(Participant::Issuer);
+        submitWithDivergentC1(Participant::Auditor);
+    }
+
+    void
+    testConfidentialMPTBaseFee(FeatureBitset features)
+    {
+        testcase("test confidential transactions fee");
+        using namespace test::jtx;
+
+        auto setup =
+            [&](MPTTester& mpt, Account const& alice, Account const& bob, Account const& carol) {
+                mpt.create({
+                    .ownerCount = 1,
+                    .flags = tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer |
+                        tfMPTCanClawback,
+                });
+                mpt.authorize({.account = bob});
+                mpt.authorize({.account = carol});
+                mpt.pay(alice, bob, 100);
+                mpt.pay(alice, carol, 50);
+                mpt.generateKeyPair(alice);
+                mpt.generateKeyPair(bob);
+                mpt.generateKeyPair(carol);
+                mpt.set({.account = alice, .issuerPubKey = mpt.getPubKey(alice)});
+            };
+
+        // test expected base fee for confidential transactions
+        {
+            Env env{*this, features};
+            Account const alice("alice"), bob("bob"), carol("carol");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+            setup(mptAlice, alice, bob, carol);
+
+            auto const baseFee = env.current()->fees().base;
+            auto const expectedFee = baseFee * (kConfidentialFeeMultiplier + 1);
+
+            // lambda function to submit confidential transaction and check fee charged to the
+            // account
+            auto checkFee = [&](Account const& acct, auto&& submitFn) {
+                auto const before = env.balance(acct);
+                submitFn();
+                auto const after = env.balance(acct);
+                BEAST_EXPECT(before - after == expectedFee);
+            };
+
+            checkFee(bob, [&]() {
+                mptAlice.convert(
+                    {.account = bob,
+                     .amt = 50,
+                     .holderPubKey = mptAlice.getPubKey(bob),
+                     .fee = expectedFee});
+            });
+            checkFee(carol, [&]() {
+                mptAlice.convert(
+                    {.account = carol,
+                     .amt = 10,
+                     .holderPubKey = mptAlice.getPubKey(carol),
+                     .fee = expectedFee});
+            });
+            checkFee(bob, [&]() { mptAlice.mergeInbox({.account = bob, .fee = expectedFee}); });
+            checkFee(carol, [&]() { mptAlice.mergeInbox({.account = carol, .fee = expectedFee}); });
+            checkFee(bob, [&]() {
+                mptAlice.send({.account = bob, .dest = carol, .amt = 5, .fee = expectedFee});
+            });
+            checkFee(bob, [&]() {
+                mptAlice.convertBack({.account = bob, .amt = 5, .fee = expectedFee});
+            });
+            checkFee(alice, [&]() {
+                mptAlice.confidentialClaw(
+                    {.account = alice, .holder = carol, .amt = 15, .fee = expectedFee});
+            });
+        }
+
+        // test insufficient fee for confidential transactions
+        {
+            Env env{*this, features};
+            Account const alice("alice"), bob("bob"), carol("carol");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+            setup(mptAlice, alice, bob, carol);
+            auto const baseFee = env.current()->fees().base;
+            auto const expectedFee = baseFee * (kConfidentialFeeMultiplier + 1);
+
+            mptAlice.convert(
+                {.account = bob,
+                 .amt = 1,
+                 .holderPubKey = mptAlice.getPubKey(bob),
+                 .fee = expectedFee - 1,
+                 .err = telINSUF_FEE_P});
+            mptAlice.mergeInbox({.account = bob, .fee = baseFee, .err = telINSUF_FEE_P});
+            mptAlice.send(
+                {.account = bob,
+                 .dest = carol,
+                 .amt = 1,
+                 .fee = baseFee * kConfidentialFeeMultiplier,
+                 .err = telINSUF_FEE_P});
+            mptAlice.convertBack({.account = bob, .amt = 1, .fee = baseFee, .err = telINSUF_FEE_P});
+            mptAlice.confidentialClaw(
+                {.account = alice,
+                 .holder = carol,
+                 .amt = 1,
+                 .fee = baseFee,
+                 .err = telINSUF_FEE_P});
+        }
+
+        // test excessive fee for confidential transactions
+        {
+            Env env{*this, features};
+            Account const alice("alice"), bob("bob"), carol("carol");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+            setup(mptAlice, alice, bob, carol);
+
+            auto const baseFee = env.current()->fees().base;
+            auto const highFee = baseFee * (kConfidentialFeeMultiplier + 1) * 2;
+            auto const bobBefore = env.balance(bob);
+            mptAlice.convert(
+                {.account = bob,
+                 .amt = 1,
+                 .holderPubKey = mptAlice.getPubKey(bob),
+                 .fee = highFee});
+            BEAST_EXPECT(env.balance(bob) == bobBefore - highFee);
+        }
+    }
+
+    void
+    testSendForgedEqualityProof(FeatureBitset features)
+    {
+        testcase("Send: forged equality proof");
+
+        // Test that modifying a ciphertext after proof generation causes
+        // verification to fail. The Fiat-Shamir challenge binds ciphertexts
+        // to the proof, so any modification invalidates the proof.
+
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}};
+        auto& mptAlice = confEnv.mpt;
+
+        ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10);
+
+        // Forge destination ciphertext (Enc(20) instead of Enc(10))
+        {
+            auto const proof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            Buffer const forgedBlindingFactor = generateBlindingFactor();
+            auto const forgedDestAmt = mptAlice.encryptAmount(carol, 20, forgedBlindingFactor);
+
+            auto args = setup.sendArgs(
+                bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF);
+            args.destEncryptedAmt = forgedDestAmt;
+            mptAlice.send(args);
+        }
+
+        // Forge sender's ciphertext (Enc(5) instead of Enc(10))
+        {
+            auto const proof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            Buffer const forgedBlindingFactor = generateBlindingFactor();
+            auto const forgedSenderAmt = mptAlice.encryptAmount(bob, 5, forgedBlindingFactor);
+
+            auto args = setup.sendArgs(
+                bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF);
+            args.senderEncryptedAmt = forgedSenderAmt;
+            mptAlice.send(args);
+        }
+
+        // Forge issuer's ciphertext (Enc(100) instead of Enc(10))
+        {
+            auto const proof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            Buffer const forgedBlindingFactor = generateBlindingFactor();
+            auto const forgedIssuerAmt = mptAlice.encryptAmount(alice, 100, forgedBlindingFactor);
+
+            auto args = setup.sendArgs(
+                bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF);
+            args.issuerEncryptedAmt = forgedIssuerAmt;
+            mptAlice.send(args);
+        }
+    }
+
+    void
+    testSendForgedRangeProof(FeatureBitset features)
+    {
+        testcase("Send: forged range proof");
+
+        // Attack: send uint64_max tokens using Enc(uint64_max) ciphertexts
+        // and a corrupted bulletproof. Verifier rejects due to inner-product
+        // mismatch and Fiat-Shamir transcript divergence. Supply invariant
+        // is preserved.
+
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}};
+        auto& mptAlice = confEnv.mpt;
+
+        uint64_t const badAmount = std::numeric_limits::max();
+        Buffer const blindingFactor = generateBlindingFactor();
+
+        // Construct Enc(uint64_max) ciphertexts and commitment.
+        auto const senderAmt = mptAlice.encryptAmount(bob, badAmount, blindingFactor);
+        auto const destAmt = mptAlice.encryptAmount(carol, badAmount, blindingFactor);
+        auto const issuerAmt = mptAlice.encryptAmount(alice, badAmount, blindingFactor);
+        auto const amountCommitment = mptAlice.getPedersenCommitment(badAmount, blindingFactor);
+
+        // Balance commitment for Bob's actual balance.
+        auto const prevSpending = requireOptional(
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing previous spending balance");
+        auto const balanceBlindingFactor = generateBlindingFactor();
+        auto const balanceCommitment =
+            mptAlice.getPedersenCommitment(prevSpending, balanceBlindingFactor);
+
+        // Generate a valid proof for a legitimate amount, then corrupt
+        // the bulletproof segment to simulate a forged range proof.
+        ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10);
+        auto const validProof = setup.generateProof(mptAlice, env, bob, carol);
+        if (!BEAST_EXPECT(validProof.has_value()))
+            return;
+
+        // Corrupt bulletproof bytes.
+        Buffer forgedProof = requireOptional(validProof, "Missing valid proof");
+        for (size_t i = kBulletproofOffset; i < forgedProof.size(); i += 7)
+            forgedProof.data()[i] ^= 0xFF;
+
+        // Submit — rejected due to commitment mismatch.
+        mptAlice.send(
+            {.account = bob,
+             .dest = carol,
+             .amt = badAmount,
+             .proof = strHex(forgedProof),
+             .senderEncryptedAmt = senderAmt,
+             .destEncryptedAmt = destAmt,
+             .issuerEncryptedAmt = issuerAmt,
+             .amountCommitment = amountCommitment,
+             .balanceCommitment = balanceCommitment,
+             .err = tecBAD_PROOF});
+
+        // Supply invariant: Bob's balance unchanged.
+        auto const postSpending = requireOptional(
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing post spending balance");
+        BEAST_EXPECT(postSpending == prevSpending);
+    }
+
+    void
+    testSendNegativeValueMalleability(FeatureBitset features)
+    {
+        testcase("Send: negative value malleability");
+
+        // Attack: forge a bulletproof claiming remaining = (uint64_t)(-10).
+        // Bob has 10 tokens, sends 10. Honest remaining is 0, but the
+        // forged proof claims 0xFFFFFFFFFFFFFFF6. Rejected because
+        // PC(0) != PC(0xFFFFFFFFFFFFFFF6).
+
+        using namespace test::jtx;
+        // Bob converts exactly 10 tokens, leaving honest remaining = 0.
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob, .payAmount = 1000, .convertAmount = 10},
+             {.account = carol, .payAmount = 1000, .convertAmount = 50}}};
+        auto& mptAlice = confEnv.mpt;
+
+        uint64_t const sendAmount = 10;
+        uint64_t const negativeRemaining = static_cast(-10);  // 0xFFFFFFFFFFFFFFF6
+
+        ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
+
+        auto const ctxHash = getSendContextHash(
+            bob.id(), mptAlice.issuanceID(), env.seq(bob), carol.id(), setup.version);
+
+        auto const validProof = setup.generateProof(mptAlice, env, bob, carol);
+        if (!BEAST_EXPECT(validProof.has_value()))
+            return;
+
+        // Forge bulletproof for {10, 0xFFFFFFFFFFFFFFF6} and splice it in.
+        auto const forgedBulletproof = getForgedBulletproof(
+            {sendAmount, negativeRemaining},
+            {setup.amountBlindingFactor, setup.balanceBlindingFactor},
+            ctxHash);
+
+        Buffer forgedProof(requireOptionalRef(validProof, "Missing valid proof").size());
+        std::memcpy(
+            forgedProof.data(),
+            requireOptionalRef(validProof, "Missing valid proof").data(),
+            kBulletproofOffset);
+        std::memcpy(
+            forgedProof.data() + kBulletproofOffset,
+            forgedBulletproof.data(),
+            kEcDoubleBulletproofLength);
+
+        mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF));
+
+        // Supply invariant: Bob's balance unchanged.
+        auto const postSpending = requireOptional(
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing post spending balance");
+        BEAST_EXPECT(postSpending == setup.prevSpending);
+    }
+
+    void
+    testSendInvalidProofContextBinding(FeatureBitset features)
+    {
+        testcase("Send proof context binding");
+        using namespace test::jtx;
+
+        auto runBadProof = [&](auto makeContextHash) {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            ConfidentialEnv confEnv{
+                env,
+                alice,
+                {{.account = bob, .payAmount = 100, .convertAmount = 40}, {.account = carol}}};
+            auto& mptAlice = confEnv.mpt;
+
+            ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10);
+
+            auto const proof = mptAlice.getConfidentialSendProof(
+                bob,
+                setup.sendAmount,
+                setup.recipients,
+                setup.blindingFactor,
+                makeContextHash(env, mptAlice, alice, bob, carol, setup.version),
+                {
+                    .pedersenCommitment = setup.amountCommitment,
+                    .amt = setup.sendAmount,
+                    .encryptedAmt = setup.senderAmt,
+                    .blindingFactor = setup.amountBlindingFactor,
+                },
+                {
+                    .pedersenCommitment = setup.balanceCommitment,
+                    .amt = setup.prevSpending,
+                    .encryptedAmt = setup.prevEncryptedSpending,
+                    .blindingFactor = setup.balanceBlindingFactor,
+                });
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            mptAlice.send(setup.sendArgs(
+                bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF));
+        };
+
+        // Wrong sender account in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const& mpt,
+                        Account const&,
+                        Account const& bob,
+                        Account const& carol,
+                        std::uint32_t version) {
+            return getSendContextHash(
+                carol.id(), mpt.issuanceID(), env.seq(bob), carol.id(), version);
+        });
+
+        // Wrong issuance ID in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const&,
+                        Account const& alice,
+                        Account const& bob,
+                        Account const& carol,
+                        std::uint32_t version) {
+            return getSendContextHash(
+                bob.id(),
+                makeMptID(env.seq(alice) + 100, alice),
+                env.seq(bob),
+                carol.id(),
+                version);
+        });
+
+        // Wrong transaction sequence in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const& mpt,
+                        Account const&,
+                        Account const& bob,
+                        Account const& carol,
+                        std::uint32_t version) {
+            return getSendContextHash(
+                bob.id(), mpt.issuanceID(), env.seq(bob) + 1, carol.id(), version);
+        });
+
+        // Wrong destination in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const& mpt,
+                        Account const&,
+                        Account const& bob,
+                        Account const&,
+                        std::uint32_t version) {
+            return getSendContextHash(bob.id(), mpt.issuanceID(), env.seq(bob), bob.id(), version);
+        });
+
+        // Wrong balance version in the proof context.
+        runBadProof([&](Env& env,
+                        MPTTester const& mpt,
+                        Account const&,
+                        Account const& bob,
+                        Account const& carol,
+                        std::uint32_t version) {
+            return getSendContextHash(
+                bob.id(), mpt.issuanceID(), env.seq(bob), carol.id(), version + 1);
+        });
+    }
+
+    void
+    testSendFiatShamirBinding(FeatureBitset features)
+    {
+        testcase("Send: Fiat-Shamir Binding");
+
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}};
+        auto& mptAlice = confEnv.mpt;
+
+        ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10);
+
+        // Variant A: forged amount commitment.
+        {
+            auto const proof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            auto const forgedBlindingFactor = generateBlindingFactor();
+            auto const forgedCommitment =
+                mptAlice.getPedersenCommitment(setup.sendAmount + 5, forgedBlindingFactor);
+
+            auto args = setup.sendArgs(
+                bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF);
+            args.amountCommitment = forgedCommitment;
+            mptAlice.send(args);
+        }
+
+        // Variant B: proof replay at a different sequence.
+        {
+            auto const proof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            mptAlice.pay(bob, carol, 1);
+            env.close();
+
+            mptAlice.send(setup.sendArgs(
+                bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF));
+        }
+
+        // Variant C: tampered response scalars.
+        {
+            auto const proof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            auto const& proofRef = requireOptionalRef(proof, "Missing proof");
+            Buffer tamperedProof(proofRef.size());
+            std::memcpy(tamperedProof.data(), proofRef.data(), proofRef.size());
+            size_t const tamperOffset = tamperedProof.size() / 2;
+            tamperedProof.data()[tamperOffset] ^= 0xFF;
+
+            mptAlice.send(setup.sendArgs(bob, carol, tamperedProof, tecBAD_PROOF));
+        }
+    }
+
+    void
+    testSendProofComponentReuse(FeatureBitset features)
+    {
+        testcase("Send: Proof Component Reuse");
+
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol"), dan("dan");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob},
+             {.account = carol, .payAmount = 1000, .convertAmount = 50},
+             {.account = dan, .payAmount = 1000, .convertAmount = 50}}};
+        auto& mptAlice = confEnv.mpt;
+
+        uint64_t const sendAmount = 10;
+
+        // Variant A: replay proof to same destination after sequence changes.
+        {
+            ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
+
+            auto const proof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            mptAlice.send(setup.sendArgs(bob, carol, requireOptionalRef(proof, "Missing proof")));
+            mptAlice.mergeInbox({.account = carol});
+
+            mptAlice.send(setup.sendArgs(
+                bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF));
+        }
+
+        // Variant B: replay proof to a different destination.
+        {
+            ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
+
+            auto const proof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            mptAlice.send(setup.sendArgs(bob, carol, requireOptionalRef(proof, "Missing proof")));
+            mptAlice.mergeInbox({.account = carol});
+
+            auto const destAmtDan = mptAlice.encryptAmount(dan, sendAmount, setup.blindingFactor);
+            auto const issuerAmtDan =
+                mptAlice.encryptAmount(alice, sendAmount, setup.blindingFactor);
+
+            auto args =
+                setup.sendArgs(bob, dan, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF);
+            args.destEncryptedAmt = destAmtDan;
+            args.issuerEncryptedAmt = issuerAmtDan;
+            mptAlice.send(args);
+        }
+    }
+
+    void
+    testSendSpecialWitnessValues(FeatureBitset features)
+    {
+        testcase("Send: special witness values");
+
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}};
+        auto& mptAlice = confEnv.mpt;
+
+        ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10);
+
+        // Variant A: zero-valued response scalars.
+        {
+            auto const proof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            Buffer forgedProof = requireOptionalRef(proof, "Missing proof");
+
+            static constexpr size_t kSigmaScalarSize = 32;
+            static constexpr size_t kChallengeOffset = 0;
+            static constexpr size_t kResponseOffset = kChallengeOffset + kSigmaScalarSize;
+            static constexpr size_t kResponseSize = 5 * kSigmaScalarSize;  // z_m..z_sk
+            std::memset(forgedProof.data() + kResponseOffset, 0, kResponseSize);
+
+            mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF));
+        }
+
+        // Variant B: identity element in ciphertext.
+        {
+            auto const proof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            Buffer invalidCiphertext(kEcGamalEncryptedTotalLength);
+            std::memset(invalidCiphertext.data(), 0, kEcGamalEncryptedTotalLength);
+
+            auto args = setup.sendArgs(
+                bob, carol, requireOptionalRef(proof, "Missing proof"), temBAD_CIPHERTEXT);
+            args.senderEncryptedAmt = invalidCiphertext;
+            mptAlice.send(args);
+        }
+
+        // Variant B2: identity element in commitment.
+        {
+            auto const proof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            Buffer invalidCommitment(kEcPedersenCommitmentLength);
+            std::memset(invalidCommitment.data(), 0, kEcPedersenCommitmentLength);
+
+            auto args = setup.sendArgs(
+                bob, carol, requireOptionalRef(proof, "Missing proof"), temMALFORMED);
+            args.amountCommitment = invalidCommitment;
+            mptAlice.send(args);
+        }
+
+        // Variant C: boundary scalar (curve order).
+        {
+            auto const proof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            Buffer forgedProof = requireOptionalRef(proof, "Missing proof");
+
+            static constexpr unsigned char kCurveOrder[32] = {
+                0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,  //
+                0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE,  //
+                0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B,  //
+                0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x41   //
+            };
+
+            std::memcpy(forgedProof.data() + 32, kCurveOrder, 32);
+
+            mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF));
+        }
+
+        // Variant C2: overflow scalar (curve order + 1).
+        {
+            auto const proof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
+
+            Buffer forgedProof = requireOptionalRef(proof, "Missing proof");
+
+            static constexpr unsigned char kOverflowScalar[32] = {
+                0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,  //
+                0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE,  //
+                0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B,  //
+                0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x42   //
+            };
+
+            std::memcpy(forgedProof.data() + 32, kOverflowScalar, 32);
+
+            mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF));
+        }
+    }
+
+    void
+    testSendCrossStatementProofSubstitution(FeatureBitset features)
+    {
+        testcase("Send: cross-statement proof substitution");
+
+        // This test verifies that proofs generated for one protocol component
+        // cannot be used in place of another, and that proofs bound to
+        // different public parameters are rejected.
+
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}},
+            tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer | tfMPTCanClawback};
+        auto& mptAlice = confEnv.mpt;
+
+        uint64_t const sendAmount = 10;
+
+        // Variant A: Swap proof type (cross-statement substitution)
+        // -----------------------------------------------------------------
+        // Attack: Generate a valid convertBack proof (compact sigma +
+        // single bulletproof) and attempt to use it as the ZK proof in a
+        // ConfidentialMPTSend transaction.
+        //
+        // Expected: The send proof has a different structure
+        // (equality + 2×pedersen + double bulletproof). Even if sized to
+        // match, the domain-separated Fiat-Shamir transcript differs,
+        // so verification equations fail.
+        {
+            ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
+
+            // Generate a valid convertBack proof for bob
+            auto const spendingBalance = requireOptional(
+                mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
+                "Missing spending balance");
+            auto const encryptedSpending = requireOptional(
+                mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
+                "Missing encrypted spending balance");
+
+            Buffer const pcBlindingFactor = generateBlindingFactor();
+            Buffer const pedersenCommitment =
+                mptAlice.getPedersenCommitment(spendingBalance, pcBlindingFactor);
+
+            auto const version = mptAlice.getMPTokenVersion(bob);
+            uint256 const convertBackCtxHash =
+                getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version);
+
+            Buffer const convertBackProof = mptAlice.getConvertBackProof(
+                bob,
+                sendAmount,
+                convertBackCtxHash,
+                {
+                    .pedersenCommitment = pedersenCommitment,
+                    .amt = spendingBalance,
+                    .encryptedAmt = encryptedSpending,
+                    .blindingFactor = pcBlindingFactor,
+                });
+
+            // Resize the convertBack proof to match the expected send proof
+            // size so it passes preflight's size check and reaches the actual
+            // ZK verification in doApply.
+            auto const expectedSendSize = kEcSendProofLength;
+            Buffer resizedProof(expectedSendSize);
+            auto const copyLen = std::min(convertBackProof.size(), expectedSendSize);
+            std::memcpy(resizedProof.data(), convertBackProof.data(), copyLen);
+            // Zero-pad the rest (if convertBack proof is shorter)
+            if (copyLen < expectedSendSize)
+                std::memset(resizedProof.data() + copyLen, 0, expectedSendSize - copyLen);
+
+            mptAlice.send(setup.sendArgs(bob, carol, resizedProof, tecBAD_PROOF));
+        }
+
+        // Variant B: Valid proof bound to wrong public parameters
+        // -----------------------------------------------------------------
+        // Attack: Generate a valid send proof using a wrong context hash
+        // (computed with a different issuanceID). The proof is
+        // mathematically valid for the wrong statement, but when the
+        // verifier recomputes the Fiat-Shamir challenge using the correct
+        // issuanceID, the challenge differs and verification fails.
+        {
+            ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
+
+            // Compute context hash with a fabricated (wrong) issuanceID
+            uint192 const fakeIssuanceID{1};
+            auto const wrongCtxHash = getSendContextHash(
+                bob.id(), fakeIssuanceID, env.seq(bob), carol.id(), setup.version);
+
+            // Generate a proof that is valid for the wrong issuanceID
+            auto const wrongProof = mptAlice.getConfidentialSendProof(
+                bob,
+                sendAmount,
+                setup.recipients,
+                setup.blindingFactor,
+                wrongCtxHash,
+                {
+                    .pedersenCommitment = setup.amountCommitment,
+                    .amt = sendAmount,
+                    .encryptedAmt = setup.senderAmt,
+                    .blindingFactor = setup.amountBlindingFactor,
+                },
+                {
+                    .pedersenCommitment = setup.balanceCommitment,
+                    .amt = setup.prevSpending,
+                    .encryptedAmt = setup.prevEncryptedSpending,
+                    .blindingFactor = setup.balanceBlindingFactor,
+                });
+
+            if (!BEAST_EXPECT(wrongProof.has_value()))
+                return;
+
+            // Submit with the correct issuanceID — verifier recomputes
+            // the challenge using the real issuanceID, which differs from
+            // the one baked into the proof.
+            mptAlice.send(setup.sendArgs(
+                bob, carol, requireOptionalRef(wrongProof, "Missing wrong proof"), tecBAD_PROOF));
+        }
+    }
+
+    void
+    testSendCiphertextMalleability(FeatureBitset features)
+    {
+        testcase("Send: ciphertext malleability");
+
+        // Attack: replace ElGamal ciphertext Enc(m) with Enc(2m) to inflate
+        // the amount credited to the recipient. ElGamal is homomorphic, so
+        // scalar multiplication (C1, C2) → (k*C1, k*C2) decrypts to k*m.
+
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}},
+            tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance};
+        auto& mptAlice = confEnv.mpt;
+
+        uint64_t const sendAmount = 10;
+
+        // Variant A: Post-signature tampering.
+        // Build a valid signed transaction, then replace the destination
+        // ciphertext with Enc(2m) in the serialized blob. The original
+        // signature no longer covers the modified data.
+        {
+            auto const seq = env.seq(bob);
+            auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = sendAmount}, seq);
+            auto jtx = env.jt(jv);
+            BEAST_EXPECT(jtx.stx);
+
+            // Serialize signed tx, deserialize into mutable STObject
+            Serializer s;
+            jtx.stx->add(s);
+            SerialIter sit(s.slice());
+            STObject obj(sit, sfTransaction);
+
+            // Replace dest ciphertext with Enc(2m) — a valid EC point
+            // encrypting an inflated amount under carol's key
+            Buffer const bf = generateBlindingFactor();
+            auto const inflatedCiphertext = mptAlice.encryptAmount(carol, sendAmount * 2, bf);
+            obj.setFieldVL(sfDestinationEncryptedAmount, inflatedCiphertext);
+
+            // Re-serialize with the original (now-stale) signature
+            Serializer tampered;
+            obj.add(tampered);
+
+            // Signature verification fails — rejected before ZKP check
+            auto const jr = env.rpc("submit", strHex(tampered.slice()));
+            BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction");
+        }
+
+        // Variant B: Re-signed with inflated ciphertext.
+        // Generate a valid proof for amount m, then replace the destination
+        // ciphertext with Enc(2m) and re-sign. Signature passes, but the
+        // compact sigma proof fails: the proof binds Enc(m) to the Pedersen
+        // commitment PC(m, r), so substituting Enc(2m) breaks the linkage.
+        {
+            ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
+
+            auto const ctxHash = getSendContextHash(
+                bob.id(), mptAlice.issuanceID(), env.seq(bob), carol.id(), setup.version);
+
+            auto const validProof = mptAlice.getConfidentialSendProof(
+                bob,
+                sendAmount,
+                setup.recipients,
+                setup.blindingFactor,
+                ctxHash,
+                {
+                    .pedersenCommitment = setup.amountCommitment,
+                    .amt = sendAmount,
+                    .encryptedAmt = setup.senderAmt,
+                    .blindingFactor = setup.amountBlindingFactor,
+                },
+                {
+                    .pedersenCommitment = setup.balanceCommitment,
+                    .amt = setup.prevSpending,
+                    .encryptedAmt = setup.prevEncryptedSpending,
+                    .blindingFactor = setup.balanceBlindingFactor,
+                });
+
+            if (!BEAST_EXPECT(validProof.has_value()))
+                return;
+
+            // Replace dest ciphertext with Enc(2m) using the same blinding
+            // factor — even with matching randomness the proof rejects
+            // because the committed plaintext differs
+            auto const inflatedDestAmt =
+                mptAlice.encryptAmount(carol, sendAmount * 2, setup.blindingFactor);
+
+            auto args = setup.sendArgs(
+                bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF);
+            args.destEncryptedAmt = inflatedDestAmt;
+            mptAlice.send(args);
+        }
+    }
+
+    void
+    testSendCiphertextNegation(FeatureBitset features)
+    {
+        testcase("Send: ciphertext negation");
+
+        // Attack: negate ciphertext -Enc(m) = (-C1, -C2) to reverse the
+        // transaction direction. Negation decrypts to the group-level
+        // additive inverse of m*G, effectively turning a credit into a debit.
+
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}},
+            tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance};
+        auto& mptAlice = confEnv.mpt;
+
+        uint64_t const sendAmount = 10;
+
+        // Negate an ElGamal ciphertext by flipping the y-coordinate parity
+        // of both compressed EC points. For secp256k1 compressed form,
+        // prefix 0x02 means even-y and 0x03 means odd-y; negation
+        // swaps them: -P has the same x but opposite y.
+        auto negateCiphertext = [](Buffer const& ct) -> Buffer {
+            Buffer neg = ct;
+            neg.data()[0] ^= 0x01;                             // negate C1
+            neg.data()[kEcCiphertextComponentLength] ^= 0x01;  // negate C2
+            return neg;
+        };
+
+        // Variant A: Post-signature negation.
+        // Negate the destination ciphertext in the signed blob.
+        // Signature no longer covers the modified field.
+        {
+            auto const seq = env.seq(bob);
+            auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = sendAmount}, seq);
+            auto jtx = env.jt(jv);
+            BEAST_EXPECT(jtx.stx);
+
+            Serializer s;
+            jtx.stx->add(s);
+
+            SerialIter sit(s.slice());
+            STObject obj(sit, sfTransaction);
+
+            auto const origDestAmt = obj.getFieldVL(sfDestinationEncryptedAmount);
+            Buffer const origBuf(origDestAmt.data(), origDestAmt.size());
+            auto const negDestAmt = negateCiphertext(origBuf);
+            obj.setFieldVL(
+                sfDestinationEncryptedAmount, Slice(negDestAmt.data(), negDestAmt.size()));
+
+            Serializer tampered;
+            obj.add(tampered);
+
+            auto const jr = env.rpc("submit", strHex(tampered.slice()));
+            BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction");
+        }
+
+        // Variant B: Re-signed with all negated ciphertexts.
+        // Signature passes, but the compact sigma proof fails — the proof
+        // was generated for Enc(m), not Enc(-m).
+        {
+            ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
+
+            auto const validProof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(validProof.has_value()))
+                return;
+
+            // Negate all three ciphertexts: Enc(m) -> Enc(-m)
+            auto const negSenderAmt = negateCiphertext(setup.senderAmt);
+            auto const negDestAmt = negateCiphertext(setup.destAmt);
+            auto const negIssuerAmt = negateCiphertext(setup.issuerAmt);
+
+            auto args = setup.sendArgs(
+                bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF);
+            args.senderEncryptedAmt = negSenderAmt;
+            args.destEncryptedAmt = negDestAmt;
+            args.issuerEncryptedAmt = negIssuerAmt;
+            mptAlice.send(args);
+        }
+
+        // Variant C: Negate only the sender ciphertext.
+        // The verifier uses the sender ciphertext to derive the remainder
+        // commitment: Enc(b) - Enc(m) becomes Enc(b) - (-Enc(m)) = Enc(b+m).
+        // The bulletproof was generated for (b - m), not (b + m), so the
+        // aggregated range proof fails.
+        {
+            ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
+
+            auto const validProof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(validProof.has_value()))
+                return;
+
+            auto const negSenderAmt = negateCiphertext(setup.senderAmt);
+
+            auto args = setup.sendArgs(
+                bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF);
+            args.senderEncryptedAmt = negSenderAmt;
+            mptAlice.send(args);
+        }
+    }
+
+    void
+    testSendCiphertextCombination(FeatureBitset features)
+    {
+        testcase("Send: ciphertext combination");
+
+        // Attack: exploit ElGamal homomorphism to combine ciphertexts
+        // Enc(m1) + Enc(m2) = Enc(m1+m2), inflating the credited amount
+        // without knowing the private keys.
+
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob, .payAmount = 1000, .convertAmount = 200},
+             {.account = carol, .payAmount = 1000, .convertAmount = 100}},
+            tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance};
+        auto& mptAlice = confEnv.mpt;
+
+        uint64_t const m1 = 10;
+        uint64_t const m2 = 5;
+
+        // Variant A: Post-signature combination.
+        // Add Enc(m2) to the signed destination ciphertext Enc(m1).
+        // The original signature doesn't cover the combined ciphertext.
+        {
+            auto const seq = env.seq(bob);
+            auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = m1}, seq);
+            auto jtx = env.jt(jv);
+            BEAST_EXPECT(jtx.stx);
+
+            Serializer s;
+            jtx.stx->add(s);
+
+            SerialIter sit(s.slice());
+            STObject obj(sit, sfTransaction);
+
+            auto const origDestCt = obj.getFieldVL(sfDestinationEncryptedAmount);
+
+            // Homomorphically add Enc(m2) to the original Enc(m1)
+            Buffer const bf2 = generateBlindingFactor();
+            auto const encM2 = mptAlice.encryptAmount(carol, m2, bf2);
+            auto const combined = requireOptional(
+                homomorphicAdd(
+                    Slice(origDestCt.data(), origDestCt.size()), Slice(encM2.data(), encM2.size())),
+                "Missing combined ciphertext");
+
+            obj.setFieldVL(sfDestinationEncryptedAmount, combined);
+
+            Serializer tampered;
+            obj.add(tampered);
+
+            auto const jr = env.rpc("submit", strHex(tampered.slice()));
+            BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction");
+        }
+
+        // Variant B: Re-signed with combined ciphertext.
+        // Generate a valid proof for m1, then replace dest ciphertext with
+        // Enc(m1) + Enc(m2). Sigma proof fails because the proof was
+        // generated for Enc(m1) only — the combined ciphertext has
+        // different randomness.
+        {
+            ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, m1);
+
+            auto const validProof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(validProof.has_value()))
+                return;
+
+            // Homomorphically add Enc(m2) to the valid dest ciphertext
+            Buffer const bf2 = generateBlindingFactor();
+            auto const encM2 = mptAlice.encryptAmount(carol, m2, bf2);
+            auto const combinedDest = homomorphicAdd(setup.destAmt, encM2);
+            BEAST_EXPECT(combinedDest.has_value());
+
+            auto args = setup.sendArgs(
+                bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF);
+            args.destEncryptedAmt = combinedDest;
+            mptAlice.send(args);
+        }
+
+        // Variant C: Cross-transaction ciphertext reuse.
+        // Execute a valid send of m1, then build a new send for m2 using
+        // a combined ciphertext oldEnc(m1) + newEnc(m2) = Enc(m1+m2),
+        // where oldEnc(m1) is the actual ciphertext from the previous tx.
+        // The proof was generated for the new transaction's context, but
+        // the ciphertext includes stale randomness from the old Enc(m1).
+        {
+            // Execute a valid send of m1, capturing the actual ciphertext used
+            ConfidentialSendSetup const setup1(mptAlice, bob, carol, alice, m1);
+            auto const proof1 = setup1.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof1.has_value()))
+                return;
+            mptAlice.send(setup1.sendArgs(bob, carol, requireOptionalRef(proof1, "Missing proof")));
+
+            ConfidentialSendSetup const setup2(mptAlice, bob, carol, alice, m2);
+
+            auto const proof2 = setup2.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof2.has_value()))
+                return;
+
+            // Combine the actual prior-tx Enc(m1) with the new Enc(m2)
+            auto const crossCombined = homomorphicAdd(setup1.destAmt, setup2.destAmt);
+            BEAST_EXPECT(crossCombined.has_value());
+
+            auto args = setup2.sendArgs(
+                bob, carol, requireOptionalRef(proof2, "Missing proof"), tecBAD_PROOF);
+            args.destEncryptedAmt = crossCombined;
+            mptAlice.send(args);
+        }
+    }
+
+    void
+    testSendCiphertextRerandomization(FeatureBitset features)
+    {
+        testcase("Send: ciphertext rerandomization");
+
+        // Attack: substitute the randomness component C1 of an ElGamal
+        // ciphertext (C1, C2) while keeping the message component C2
+        // unchanged. This "rerandomizes" the ciphertext to break
+        // linkability or forge fresh-looking ciphertexts.
+        //
+        // The compact sigma proof binds C1 to the shared randomness used
+        // across all recipients, so any C1 substitution breaks the proof.
+
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}},
+            tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance};
+        auto& mptAlice = confEnv.mpt;
+
+        uint64_t const sendAmount = 10;
+
+        // Helper: replace C1 in a ciphertext with C1 from another
+        // ciphertext, keeping C2 unchanged. Returns a rerandomized
+        // ciphertext (C1', C2).
+        auto substituteC1 = [](Buffer const& target, Buffer const& source) -> Buffer {
+            Buffer result = target;
+            // Copy C1 (the first ciphertext component) from source.
+            std::memcpy(result.data(), source.data(), kEcCiphertextComponentLength);
+            return result;
+        };
+
+        // Variant A: Post-signature C1 substitution.
+        // Replace C1 in the dest ciphertext after signing.
+        // Signature no longer covers the modified ciphertext.
+        {
+            auto const seq = env.seq(bob);
+            auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = sendAmount}, seq);
+            auto jtx = env.jt(jv);
+            BEAST_EXPECT(jtx.stx);
+
+            Serializer s;
+            jtx.stx->add(s);
+            SerialIter sit(s.slice());
+            STObject obj(sit, sfTransaction);
+
+            // Generate a random C1' by encrypting a different amount
+            Buffer const bf2 = generateBlindingFactor();
+            auto const otherCt = mptAlice.encryptAmount(carol, 99, bf2);
+
+            // Replace C1 in the dest ciphertext
+            auto const origDestAmt = obj.getFieldVL(sfDestinationEncryptedAmount);
+            Buffer const origBuf(origDestAmt.data(), origDestAmt.size());
+            auto const rerandomized = substituteC1(origBuf, otherCt);
+            obj.setFieldVL(
+                sfDestinationEncryptedAmount, Slice(rerandomized.data(), rerandomized.size()));
+
+            Serializer tampered;
+            obj.add(tampered);
+
+            // Signature verification fails
+            auto const jr = env.rpc("submit", strHex(tampered.slice()));
+            BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction");
+        }
+
+        // Variant B: Re-signed C1 substitution.
+        // Replace C1 in the dest ciphertext with a fresh random point
+        // and re-sign. Sigma proof fails because the shared-randomness
+        // binding no longer holds — C1' wasn't generated with the same r
+        // used in the proof.
+        {
+            ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
+
+            auto const validProof = setup.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(validProof.has_value()))
+                return;
+
+            // Create a ciphertext with different randomness to get C1'
+            Buffer const bf2 = generateBlindingFactor();
+            auto const otherCt = mptAlice.encryptAmount(carol, sendAmount, bf2);
+
+            // Replace C1 in dest ciphertext, keep C2
+            auto const rerandomizedDest = substituteC1(setup.destAmt, otherCt);
+
+            auto args = setup.sendArgs(
+                bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF);
+            args.destEncryptedAmt = rerandomizedDest;
+            mptAlice.send(args);
+        }
+    }
+
+    void
+    testSendZeroRandomnessCiphertext(FeatureBitset features)
+    {
+        testcase("Send: zero randomness ciphertext");
+
+        // Setting r = 0 in ElGamal yields C1 = O (identity), C2 = mG —
+        // a deterministic ciphertext that reveals the plaintext.
+
+        using namespace test::jtx;
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}},
+            tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance};
+        auto& mptAlice = confEnv.mpt;
+
+        uint64_t const sendAmount = 10;
+
+        // -----------------------------------------------------------------
+        // Variant A: Post-signature zero-randomness substitution
+        // -----------------------------------------------------------------
+        // Construct a valid ConfidentialMPTSend transaction with proper
+        // ciphertexts and ZKPs, sign it, then replace the sender ciphertext
+        // with a deterministic form (C1 = 0x00...00, C2 = arbitrary).
+        // Since the identity element has no valid compressed encoding,
+        // the modified blob fails deserialization / signature check.
+        {
+            auto const seq = env.seq(bob);
+            auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = sendAmount}, seq);
+            auto jtx = env.jt(jv);
+            BEAST_EXPECT(jtx.stx);
+
+            // Serialize the signed transaction
+            Serializer s;
+            jtx.stx->add(s);
+            SerialIter sit(s.slice());
+            STObject obj(sit, sfTransaction);
+
+            // Replace sender ciphertext with zero-randomness form:
+            // C1 = all zeros (identity element — invalid encoding)
+            // C2 = valid trivial point (simulating mG)
+            Buffer zeroCiphertext(kEcGamalEncryptedTotalLength);
+            std::memset(zeroCiphertext.data(), 0, kEcGamalEncryptedTotalLength);
+            // C2 half: use a valid point so only C1 is the problem
+            auto const& tc = getTrivialCiphertext();
+            std::memcpy(
+                zeroCiphertext.data() + kEcCiphertextComponentLength,
+                tc.data() + kEcCiphertextComponentLength,
+                kEcCiphertextComponentLength);
+            obj.setFieldVL(sfSenderEncryptedAmount, zeroCiphertext);
+
+            // Re-serialize with the original (now-stale) signature
+            Serializer tampered;
+            obj.add(tampered);
+
+            // Signature verification fails because ciphertext fields are
+            // signed — transaction rejected before ZKP verification.
+            auto const jr = env.rpc("submit", strHex(tampered.slice()));
+            BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction");
+        }
+
+        // -----------------------------------------------------------------
+        // Variant B: Re-signed zero-randomness ciphertext
+        // -----------------------------------------------------------------
+        // Same zero-randomness ciphertext as Variant A (C1 = 0, C2 = mG),
+        // but submitted normally via send() which re-signs the transaction.
+        // Signature verification passes, but preflight's isValidCiphertext
+        // rejects it: the identity element has no valid compressed encoding
+        // on secp256k1, so secp256k1_ec_pubkey_parse fails on C1 = 0.
+        {
+            // Build zero-randomness ciphertext: C1 = all zeros (identity),
+            // C2 = valid trivial point (simulating mG)
+            Buffer zeroCiphertext(kEcGamalEncryptedTotalLength);
+            std::memset(zeroCiphertext.data(), 0, kEcGamalEncryptedTotalLength);
+            auto const& tc = getTrivialCiphertext();
+            std::memcpy(
+                zeroCiphertext.data() + kEcCiphertextComponentLength,
+                tc.data() + kEcCiphertextComponentLength,
+                kEcCiphertextComponentLength);
+
+            mptAlice.send(
+                {.account = bob,
+                 .dest = carol,
+                 .amt = sendAmount,
+                 .senderEncryptedAmt = zeroCiphertext,
+                 .err = temBAD_CIPHERTEXT});
+        }
+
+        // -----------------------------------------------------------------
+        // Variant C: Deterministic ciphertext reuse across transactions
+        // -----------------------------------------------------------------
+        // Construct two transactions using identical deterministic
+        // ciphertexts (same fixed blinding factor).  Even if a valid
+        // proof could be generated for one, it cannot be reused because
+        // the TransactionContextID (which includes account sequence)
+        // differs between transactions.
+        {
+            // First transaction: generate valid proof for sendAmount
+            ConfidentialSendSetup const setup1(mptAlice, bob, carol, alice, sendAmount);
+
+            auto const proof1 = setup1.generateProof(mptAlice, env, bob, carol);
+            if (!BEAST_EXPECT(proof1.has_value()))
+                return;
+
+            // Submit first transaction successfully
+            mptAlice.send(setup1.sendArgs(bob, carol, requireOptionalRef(proof1, "Missing proof")));
+
+            mptAlice.mergeInbox({.account = carol});
+
+            // Second transaction: reuse the same proof from tx1.
+            // The context hash includes the new account sequence, so the
+            // proof generated for the old sequence is invalid.
+            ConfidentialSendSetup const setup2(mptAlice, bob, carol, alice, sendAmount);
+
+            mptAlice.send(setup2.sendArgs(
+                bob, carol, requireOptionalRef(proof1, "Missing proof"), tecBAD_PROOF));
+        }
+    }
+
+    void
+    testSendRerandomizesRecipientInboxAgainstMergeCancellation(FeatureBitset features)
+    {
+        testcase("Send: recipient inbox rerandomization prevents merge cancellation");
+
+        using namespace test::jtx;
+
+        // Derive the deterministic canonical-zero randomness r0 used for
+        // Bob's first spending balance.
+        auto getCanonicalZeroBlindingFactor = [](AccountID const& account, MPTID const& mptID) {
+            Buffer scalar(kEcBlindingFactorLength);
+            std::array hashInput{};
+            std::memcpy(hashInput.data(), "EncZero", 7);
+            std::memcpy(hashInput.data() + 7, account.data(), account.size());
+            std::memcpy(hashInput.data() + 27, mptID.data(), mptID.size());
+
+            for (;;)
+            {
+                unsigned int mdLen = kEcBlindingFactorLength;
+                if (EVP_Digest(
+                        hashInput.data(),
+                        hashInput.size(),
+                        scalar.data(),
+                        &mdLen,
+                        EVP_sha256(),
+                        nullptr) != 1)
+                {
+                    Throw("Failed to derive canonical zero blinding factor");
+                }
+
+                if (secp256k1_ec_seckey_verify(mpt_secp256k1_context(), scalar.data()))
+                    return scalar;
+
+                std::memcpy(hashInput.data(), scalar.data(), scalar.size());
+            }
+        };
+
+        // Pick randomness that would cancel Bob's MergeInbox C1 to infinity
+        // without receiver-side re-randomization.
+        auto negateScalarSum = [](Buffer const& lhs, Buffer const& rhs) {
+            Buffer sum(kEcBlindingFactorLength);
+            Buffer negated(kEcBlindingFactorLength);
+            secp256k1_mpt_scalar_add(sum.data(), lhs.data(), rhs.data());
+            secp256k1_mpt_scalar_negate(negated.data(), sum.data());
+            return negated;
+        };
+
+        // Without an auditor, target Bob's holder inbox. The crafted send
+        // randomness would make MergeInbox hit the point at infinity unless
+        // ConfidentialMPTSend re-randomizes the recipient ciphertext.
+        {
+            Env env{*this, features};
+            Account const alice("alice"), bob("bob"), carol("carol");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({.account = bob});
+            mptAlice.authorize({.account = carol});
+            mptAlice.pay(alice, bob, 100);
+            mptAlice.pay(alice, carol, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(carol);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.convert({
+                .account = carol,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(carol),
+            });
+            mptAlice.mergeInbox({.account = carol});
+
+            Buffer const convertBlindingFactor = generateBlindingFactor();
+            mptAlice.convert({
+                .account = bob,
+                .amt = 20,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .blindingFactor = convertBlindingFactor,
+            });
+
+            Buffer const canonicalZeroBlindingFactor =
+                getCanonicalZeroBlindingFactor(bob.id(), mptAlice.issuanceID());
+
+            // Holder inbox cancellation happens later in MergeInbox, when
+            // Bob's spending Enc(0; r0) is added to inbox Enc(25; -r0).
+            Buffer const maliciousSendBlindingFactor =
+                negateScalarSum(canonicalZeroBlindingFactor, convertBlindingFactor);
+
+            mptAlice.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 5,
+                .blindingFactor = maliciousSendBlindingFactor,
+            });
+
+            mptAlice.mergeInbox({.account = bob});
+
+            auto const bobSpending =
+                mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
+            BEAST_EXPECT(bobSpending && *bobSpending == 25);
+        }
+
+        // With an auditor, verify the destination auditor balance is also
+        // re-randomized. Auditor balance is updated during send, and this crafted
+        // randomness would otherwise make that homomorphic sum hit infinity without
+        // re-randomization.
+        {
+            Env env{*this, features};
+            Account const alice("alice"), bob("bob"), carol("carol"), auditor("auditor");
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor});
+
+            mptAlice.create({
+                .ownerCount = 1,
+                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+            });
+
+            mptAlice.authorize({.account = bob});
+            mptAlice.authorize({.account = carol});
+            mptAlice.pay(alice, bob, 100);
+            mptAlice.pay(alice, carol, 100);
+
+            mptAlice.generateKeyPair(alice);
+            mptAlice.generateKeyPair(bob);
+            mptAlice.generateKeyPair(carol);
+            mptAlice.generateKeyPair(auditor);
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+
+            mptAlice.convert({
+                .account = carol,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(carol),
+            });
+            mptAlice.mergeInbox({.account = carol});
+
+            Buffer const convertBlindingFactor = generateBlindingFactor();
+            mptAlice.convert({
+                .account = bob,
+                .amt = 20,
+                .holderPubKey = mptAlice.getPubKey(bob),
+                .blindingFactor = convertBlindingFactor,
+            });
+
+            // This would make the homomorphic sum hit infinity.
+            Buffer const maliciousSendBlindingFactor =
+                negateScalarSum(gMakeZeroBuffer(kEcBlindingFactorLength), convertBlindingFactor);
+
+            mptAlice.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 5,
+                .blindingFactor = maliciousSendBlindingFactor,
+            });
+
+            auto const bobAuditor =
+                mptAlice.getDecryptedBalance(bob, MPTTester::auditorEncryptedBalance);
+            BEAST_EXPECT(bobAuditor && *bobAuditor == 25);
+        }
+    }
+
+    void
+    testWithFeats(FeatureBitset features)
+    {
+        // ConfidentialMPTConvert
+        testConvert(features);
+        testConvertPreflight(features);
+        testConvertInvalidProofContextBinding(features);
+        testConvertPreclaim(features);
+        testConvertWithAuditor(features);
+
+        // ConfidentialMPTMergeInbox
+        testMergeInbox(features);
+        testMergeInboxPreflight(features);
+        testMergeInboxPreclaim(features);
+
+        testSet(features);
+        testSetPreflight(features);
+        testSetPreclaim(features);
+
+        // ConfidentialMPTSend
+        testSend(features);
+        testSendPreflight(features);
+        testSendPreclaim(features);
+        testSendRangeProof(features);
+
+        testSendZeroAmount(features);
+        testSendWithAuditor(features);
+
+        // ConfidentialMPTClawback
+        testClawback(features);
+        testClawbackPreflight(features);
+        testClawbackPreclaim(features);
+        testClawbackProof(features);
+        testClawbackWithAuditor(features);
+        testClawbackInvalidProofContextBinding(features);
+
+        testDelete(features);
+
+        // ConfidentialMPTConvertBack
+        testConvertBack(features);
+        testConvertBackPreflight(features);
+        testConvertBackPreclaim(features);
+        testConvertBackWithAuditor(features);
+        testConvertBackPedersenProof(features);
+        testConvertBackBulletproof(features);
+
+        // Homomorphic operation tests
+        testSendHomomorphicOverflow(features);
+        testConvertBackHomomorphicCiphertextModification(features);
+        testConvertBackHomomorphicUnderflow(features);
+
+        // Invalid curve points
+        testSendInvalidCurvePoints(features);
+        testSendWrongGroupPointInjection(features);
+        testConvertIdentityElementRejection(features);
+        testSendWrongIssuerPublicKey(features);
+
+        // public and private txns
+        testPublicTransfersAfterClearingConfidentialFlag(features);
+
+        // Replay tests
+        testMutatePrivacy(features);
+        testConvertBackInvalidProofContextBinding(features);
+        testConvertBackProofCiphertextBinding(features);
+        testConvertBackProofVersionMismatch(features);
+
+        // Crafted-proof Tests
+        testSendSharedRandomnessViolation(features);
+
+        // Transaction Fee Tests
+        testConfidentialMPTBaseFee(features);
+
+        // TransferFee (transfer rate) Tests
+        testTransferFee(features);
+
+        // Zero knowledge proof tests
+        testSendInvalidProofContextBinding(features);
+        testSendForgedEqualityProof(features);
+        testSendForgedRangeProof(features);
+        testSendNegativeValueMalleability(features);
+        testSendFiatShamirBinding(features);
+        testSendProofComponentReuse(features);
+        testSendSpecialWitnessValues(features);
+        testSendCrossStatementProofSubstitution(features);
+
+        // Ciphertext malleability tests
+        testSendCiphertextMalleability(features);
+        testSendCiphertextNegation(features);
+        testSendCiphertextCombination(features);
+        testSendCiphertextRerandomization(features);
+        testSendZeroRandomnessCiphertext(features);
+        testSendRerandomizesRecipientInboxAgainstMergeCancellation(features);
+    }
+
+public:
+    void
+    run() override
+    {
+        using namespace test::jtx;
+        FeatureBitset const all{testableAmendments()};
+
+        testWithFeats(all);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(ConfidentialTransfer, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/Credentials_test.cpp b/src/test/app/Credentials_test.cpp
index 9416ca222f..456a53bc01 100644
--- a/src/test/app/Credentials_test.cpp
+++ b/src/test/app/Credentials_test.cpp
@@ -1078,7 +1078,7 @@ struct Credentials_test : public beast::unit_test::Suite
         }
 
         // Create DepositPreauth
-        env(deposit::authCredentials(becky, {{subject, credType}}));
+        env(deposit::authCredentials(becky, {{.issuer = subject, .credType = credType}}));
         env.close();
         // env();
         auto jtx = env.jt(pay(subject, becky, XRP(100)), credentials::Ids({credIdx}));
@@ -1087,7 +1087,7 @@ struct Credentials_test : public beast::unit_test::Suite
         auto const stx = std::make_shared(*jtx.stx);
 
         // Create PermissionedDomain
-        env(pdomain::setTx(becky, {{issuer, credType}}));
+        env(pdomain::setTx(becky, {{.issuer = issuer, .credType = credType}}));
         env.close();
         auto const objects = pdomain::getObjects(becky, env);
         if (!BEAST_EXPECT(!objects.empty()))
diff --git a/src/test/app/CrossingLimitsMPT_test.cpp b/src/test/app/CrossingLimitsMPT_test.cpp
index 4a016f31dc..8bd0c767f7 100644
--- a/src/test/app/CrossingLimitsMPT_test.cpp
+++ b/src/test/app/CrossingLimitsMPT_test.cpp
@@ -270,7 +270,7 @@ public:
 
                 env.require(Balance(alice, usd(2'503)));
                 env.require(Balance(alice, eur(1'100)));
-                auto const numAOffers = 2'000 + 100 + 1'000 + 1 - (2 * 100 + 2 * 199 + 1 + 1);
+                auto const numAOffers = 2'000 + 100 + 1'000 + 1 - ((2 * 100) + (2 * 199) + 1 + 1);
                 env.require(offers(alice, numAOffers));
                 env.require(Owners(alice, numAOffers + 2));
 
@@ -358,7 +358,7 @@ public:
                 env.require(Balance(alice, usd(2'494)));
                 env.require(Balance(alice, eur(1'100)));
                 auto const numAOffers =
-                    1 + 2'000 + 100 + 1'000 + 1 - (1 + 2 * 100 + 2 * 199 + 1 + 1);
+                    1 + 2'000 + 100 + 1'000 + 1 - (1 + (2 * 100) + (2 * 199) + 1 + 1);
                 env.require(offers(alice, numAOffers));
                 env.require(Owners(alice, numAOffers + 2));
 
diff --git a/src/test/app/CrossingLimits_test.cpp b/src/test/app/CrossingLimits_test.cpp
index 3cf8f50990..c48892f04e 100644
--- a/src/test/app/CrossingLimits_test.cpp
+++ b/src/test/app/CrossingLimits_test.cpp
@@ -258,7 +258,7 @@ public:
 
             env.require(Balance(alice, usd(2503)));
             env.require(Balance(alice, eur(1100)));
-            auto const numAOffers = 2000 + 100 + 1000 + 1 - (2 * 100 + 2 * 199 + 1 + 1);
+            auto const numAOffers = 2000 + 100 + 1000 + 1 - ((2 * 100) + (2 * 199) + 1 + 1);
             env.require(offers(alice, numAOffers));
             env.require(Owners(alice, numAOffers + 2));
 
@@ -329,7 +329,7 @@ public:
 
             env.require(Balance(alice, usd(2494)));
             env.require(Balance(alice, eur(1100)));
-            auto const numAOffers = 1 + 2000 + 100 + 1000 + 1 - (1 + 2 * 100 + 2 * 199 + 1 + 1);
+            auto const numAOffers = 1 + 2000 + 100 + 1000 + 1 - (1 + (2 * 100) + (2 * 199) + 1 + 1);
             env.require(offers(alice, numAOffers));
             env.require(Owners(alice, numAOffers + 2));
 
diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp
index 06884e9931..02ee0750d5 100644
--- a/src/test/app/Delegate_test.cpp
+++ b/src/test/app/Delegate_test.cpp
@@ -5,8 +5,11 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -22,16 +25,20 @@
 #include 
 #include 
 #include 
+#include 
 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -40,11 +47,13 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -227,6 +236,19 @@ class Delegate_test : public beast::unit_test::Suite
             env(delegate::set(gw, Account("unknown"), {"Payment"}), Ter(tecNO_TARGET));
         }
 
+        // Delegating to a pseudo-account is not allowed, should return tecNO_PERMISSION
+        {
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = gw, .asset = xrpIssue()});
+            env(tx);
+            env.close();
+
+            auto const sleVault = env.le(keylet);
+            BEAST_EXPECT(sleVault);
+            Account const vaultPseudo{"vault", sleVault->at(sfAccount)};
+            env(delegate::set(gw, vaultPseudo, {"Payment"}), Ter(tecNO_PERMISSION));
+        }
+
         // non-delegable transaction
         {
             env(delegate::set(gw, alice, {"SetRegularKey"}), Ter(temMALFORMED));
@@ -1062,6 +1084,126 @@ class Delegate_test : public beast::unit_test::Suite
             }
         }
 
+        // PaymentMint/PaymentBurn with sfSendMax of the same asset is allowed,
+        // same-asset SendMax is still a direct payment, not cross-currency.
+        {
+            Env env(*this, features);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const gw{"gw"};
+            auto const usd = gw["USD"];
+            env.fund(XRP(10000), alice, bob, gw);
+            env.trust(usd(200), alice);
+            env.close();
+
+            env(delegate::set(gw, bob, {"PaymentMint"}));
+            env.close();
+
+            // sfSendMax with same asset as sfAmount, still a direct payment
+            env(pay(gw, alice, usd(50)), Sendmax(usd(50)), delegate::As(bob));
+            env.require(Balance(alice, usd(50)));
+
+            env(delegate::set(alice, bob, {"PaymentBurn"}));
+            env.close();
+
+            env(pay(alice, gw, usd(30)), delegate::As(bob));
+            env.require(Balance(alice, usd(20)));
+        }
+
+        // PaymentBurn is authorized by balance direction, not trust limit.
+        // holder is allowed to burn even if trust limit is 0.
+        {
+            Env env(*this);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const gw{"gateway"};
+            auto const gwUSD = gw["USD"];
+            auto const aliceUSD = alice["USD"];
+
+            env.fund(XRP(10000), alice, bob, gw);
+            env.trust(gwUSD(200), alice);
+            env.close();
+
+            env(pay(gw, alice, gwUSD(50)));
+            env.require(Balance(alice, gwUSD(50)));
+            env.close();
+
+            env(delegate::set(alice, bob, {"PaymentBurn"}));
+            env.close();
+
+            env.trust(gwUSD(0), alice);
+            env.close();
+            BEAST_EXPECT(env.limit(alice, gwUSD.issue()) == gwUSD(0));
+
+            env(trust(gw, aliceUSD(200)));
+            env.close();
+
+            env(pay(alice, gw, gwUSD(30)), delegate::As(bob));
+            env.require(Balance(alice, gwUSD(20)));
+            env.require(Balance(gw, aliceUSD(-20)));
+        }
+
+        // Test invalid fields or flags not allowed in granular permission template
+        {
+            Env env(*this, features);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const gw{"gw"};
+            auto const usd = gw["USD"];
+            env.fund(XRP(10000), alice, bob, gw);
+            env.trust(usd(200), alice);
+            env.close();
+
+            env(delegate::set(gw, bob, {"PaymentMint"}));
+            env(delegate::set(alice, bob, {"PaymentBurn"}));
+            env.close();
+
+            // sfDeliverMin (with tfPartialPayment) is not in the PaymentMint
+            // or PaymentBurn template.
+            env(pay(gw, alice, usd(100)),
+                DeliverMin(usd(50)),
+                Txflags(tfPartialPayment),
+                delegate::As(bob),
+                Ter(terNO_DELEGATE_PERMISSION));
+            env(pay(alice, gw, usd(50)),
+                DeliverMin(usd(25)),
+                Txflags(tfPartialPayment),
+                delegate::As(bob),
+                Ter(terNO_DELEGATE_PERMISSION));
+
+            // sfDomainID is not in the PaymentMint or PaymentBurn template.
+            env(pay(gw, alice, usd(100)),
+                Domain(uint256{1}),
+                delegate::As(bob),
+                Ter(terNO_DELEGATE_PERMISSION));
+            env(pay(alice, gw, usd(50)),
+                Domain(uint256{1}),
+                delegate::As(bob),
+                Ter(terNO_DELEGATE_PERMISSION));
+        }
+
+        // Delegate account holds no granular permissions for the tx type:
+        // getGranularPermission returns empty set.
+        {
+            Env env(*this, features);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const gw{"gw"};
+            auto const usd = gw["USD"];
+            env.fund(XRP(10000), alice, bob, gw);
+            env.trust(usd(200), alice);
+            env.close();
+
+            // Bob holds only an AccountSet granular permission.
+            env(delegate::set(alice, bob, {"AccountDomainSet"}));
+            env.close();
+
+            // Payment has granular permissions defined in permissions.macro,
+            // but bob only holds AccountSet's granular permission,
+            // getGranularPermission returns empty.
+            env(pay(alice, gw, usd(50)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
+        }
+
         // PaymentMint and PaymentBurn for MPT
         {
             std::string logs;
@@ -1076,22 +1218,22 @@ class Delegate_test : public beast::unit_test::Suite
             mpt.authorize({.account = alice});
             mpt.authorize({.account = bob});
 
-            auto const MPT = mpt["MPT"];  // NOLINT(readability-identifier-naming)
-            env(pay(gw, alice, MPT(500)));
-            env(pay(gw, bob, MPT(500)));
+            auto const gwMPT = mpt["MPT"];
+            env(pay(gw, alice, gwMPT(500)));
+            env(pay(gw, bob, gwMPT(500)));
             env.close();
-            auto aliceMPT = env.balance(alice, MPT);
-            auto bobMPT = env.balance(bob, MPT);
+            auto aliceMPT = env.balance(alice, gwMPT);
+            auto bobMPT = env.balance(bob, gwMPT);
 
             // PaymentMint
             {
                 env(delegate::set(gw, bob, {"PaymentMint"}));
                 env.close();
 
-                env(pay(gw, alice, MPT(50)), delegate::As(bob));
-                BEAST_EXPECT(env.balance(alice, MPT) == aliceMPT + MPT(50));
-                BEAST_EXPECT(env.balance(bob, MPT) == bobMPT);
-                aliceMPT = env.balance(alice, MPT);
+                env(pay(gw, alice, gwMPT(50)), delegate::As(bob));
+                BEAST_EXPECT(env.balance(alice, gwMPT) == aliceMPT + gwMPT(50));
+                BEAST_EXPECT(env.balance(bob, gwMPT) == bobMPT);
+                aliceMPT = env.balance(alice, gwMPT);
             }
 
             // PaymentBurn
@@ -1099,25 +1241,268 @@ class Delegate_test : public beast::unit_test::Suite
                 env(delegate::set(alice, bob, {"PaymentBurn"}));
                 env.close();
 
-                env(pay(alice, gw, MPT(50)), delegate::As(bob));
-                BEAST_EXPECT(env.balance(alice, MPT) == aliceMPT - MPT(50));
-                BEAST_EXPECT(env.balance(bob, MPT) == bobMPT);
-                aliceMPT = env.balance(alice, MPT);
+                env(pay(alice, gw, gwMPT(50)), delegate::As(bob));
+                BEAST_EXPECT(env.balance(alice, gwMPT) == aliceMPT - gwMPT(50));
+                BEAST_EXPECT(env.balance(bob, gwMPT) == bobMPT);
+                aliceMPT = env.balance(alice, gwMPT);
             }
 
             // Grant both granular permissions and tx level permission.
             {
                 env(delegate::set(alice, bob, {"PaymentBurn", "PaymentMint", "Payment"}));
                 env.close();
-                env(pay(alice, gw, MPT(50)), delegate::As(bob));
-                BEAST_EXPECT(env.balance(alice, MPT) == aliceMPT - MPT(50));
-                BEAST_EXPECT(env.balance(bob, MPT) == bobMPT);
-                aliceMPT = env.balance(alice, MPT);
-                env(pay(alice, bob, MPT(100)), delegate::As(bob));
-                BEAST_EXPECT(env.balance(alice, MPT) == aliceMPT - MPT(100));
-                BEAST_EXPECT(env.balance(bob, MPT) == bobMPT + MPT(100));
+                env(pay(alice, gw, gwMPT(50)), delegate::As(bob));
+                BEAST_EXPECT(env.balance(alice, gwMPT) == aliceMPT - gwMPT(50));
+                BEAST_EXPECT(env.balance(bob, gwMPT) == bobMPT);
+                aliceMPT = env.balance(alice, gwMPT);
+                env(pay(alice, bob, gwMPT(100)), delegate::As(bob));
+                BEAST_EXPECT(env.balance(alice, gwMPT) == aliceMPT - gwMPT(100));
+                BEAST_EXPECT(env.balance(bob, gwMPT) == bobMPT + gwMPT(100));
             }
         }
+
+        // PaymentMint/PaymentBurn must not trust IOU issuer aliases.
+        // In a direct IOU payment, sfAmount.issuer may be encoded as either
+        // endpoint, and PaySteps normalizes those aliases to the same execution.
+        // These cases ensure a delegate cannot flip the encoded issuer to turn a
+        // mint into an apparent burn, or a burn into an apparent mint.
+        {
+            Env env(*this);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const gw{"gateway"};
+            auto const gwUSD = gw["USD"];
+            auto const aliceUSD = alice["USD"];
+
+            env.fund(XRP(10000), alice, bob, gw);
+            env.trust(gwUSD(200), alice);
+            env.close();
+
+            // Alice holds 100 USD issued by gw.
+            env(pay(gw, alice, gwUSD(100)));
+            env.close();
+            env.require(Balance(alice, gwUSD(100)));
+
+            // Delegate with only PaymentBurn tries to mint by encoding
+            // Amount.issuer as the destination alias, alice. The actual issuer
+            // is gw, so this requires PaymentMint and must be rejected.
+            {
+                env(delegate::set(gw, bob, {"PaymentBurn"}));
+                env.close();
+
+                // Amount.issuer = alice (destination), rejected because gw is
+                // the actual issuer and PaymentMint is required.
+                env(pay(gw, alice, aliceUSD(50)),
+                    delegate::As(bob),
+                    Ter(terNO_DELEGATE_PERMISSION));
+                env.require(Balance(alice, gwUSD(100)));
+
+                // Fails because bob holds PaymentBurn, not PaymentMint.
+                env(pay(gw, alice, gwUSD(50)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
+                env.require(Balance(alice, gwUSD(100)));
+            }
+
+            // Delegate with only PaymentMint tries to burn by encoding
+            // Amount.issuer as the source alias, alice. The actual issuer is
+            // gw, so this requires PaymentBurn and must be rejected.
+            {
+                env(delegate::set(alice, bob, {"PaymentMint"}));
+                env.close();
+
+                // Amount.issuer = alice (account), rejected because gw is the
+                // actual issuer and PaymentBurn is required.
+                env(pay(alice, gw, aliceUSD(50)),
+                    delegate::As(bob),
+                    Ter(terNO_DELEGATE_PERMISSION));
+                env.require(Balance(alice, gwUSD(100)));
+
+                // Fails because bob holds PaymentMint, not PaymentBurn.
+                env(pay(alice, gw, gwUSD(50)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
+                env.require(Balance(alice, gwUSD(100)));
+            }
+        }
+
+        // Neither account nor destination is issuer.
+        // PaymentMint and PaymentBurn do not authorize these payments.
+        {
+            // IOU
+            {
+                Env env(*this);
+                Account const alice{"alice"};
+                Account const bob{"bob"};
+                Account const gw{"gateway"};
+                Account const gw2{"gateway2"};
+                auto const gwUSD = gw["USD"];
+
+                env.fund(XRP(10000), alice, bob, gw, gw2);
+                env.close();
+
+                env.trust(gwUSD(200), alice);
+                env.close();
+
+                env(pay(gw, alice, gwUSD(100)));
+                env.close();
+
+                env(delegate::set(alice, bob, {"PaymentMint", "PaymentBurn"}));
+                env.close();
+
+                env(pay(alice, gw, gw2["USD"](50)),
+                    delegate::As(bob),
+                    Ter(terNO_DELEGATE_PERMISSION));
+            }
+
+            // MPT
+            {
+                Env env(*this, features);
+                Account const alice{"alice"};
+                Account const bob{"bob"};
+                Account const gw{"gateway"};
+                Account const gw2{"gateway2"};
+
+                env.fund(XRP(10000), gw2);
+                env.close();
+
+                MPTTester mpt(env, gw, {.holders = {alice, bob}});
+                mpt.create({.ownerCount = 1, .flags = tfMPTCanTransfer});
+
+                mpt.authorize({.account = alice});
+                mpt.authorize({.account = bob});
+
+                auto const gwMPT = mpt["MPT"];
+                env(pay(gw, alice, gwMPT(500)));
+                env(pay(gw, bob, gwMPT(500)));
+                env.close();
+
+                env(delegate::set(alice, bob, {"PaymentMint", "PaymentBurn"}));
+                env.close();
+
+                env(pay(alice, gw2, gwMPT(50)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
+            }
+        }
+
+        // IOU issuer is an endpoint, but no trustline exists.
+        {
+            Env env(*this);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const gw{"gateway"};
+
+            env.fund(XRP(10000), alice, bob, gw);
+            env.close();
+
+            env(delegate::set(alice, bob, {"PaymentMint", "PaymentBurn"}));
+            env.close();
+
+            env(pay(alice, gw, alice["USD"](50)),
+                delegate::As(bob),
+                Ter(terNO_DELEGATE_PERMISSION));
+        }
+
+        // Both trust limits (who is the designated issuer) and balance direction
+        // (which way DirectStepI executes) must be checked. Neither alone is sufficient.
+        {
+            Env env(*this);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const gw{"gateway"};
+            auto const gwUSD = gw["USD"];
+            auto const aliceUSD = alice["USD"];
+
+            env.fund(XRP(10000), alice, bob, gw);
+
+            // Alice trusts gw but holds zero gw-issued USD. With balance == 0,
+            // DirectStepI would issue rather than redeem, so PaymentBurn must
+            // be rejected even though Alice's trust limit to gw is positive.
+            {
+                env.trust(gwUSD(200), alice);
+                env.close();
+
+                // Alice has nothing to burn.
+                env(delegate::set(alice, bob, {"PaymentBurn"}));
+                env.close();
+
+                env(pay(alice, gw, gwUSD(50)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
+                env(pay(alice, gw, aliceUSD(50)),
+                    delegate::As(bob),
+                    Ter(terNO_DELEGATE_PERMISSION));
+            }
+
+            // Set up a trust line where gw holds alice-issued USD. DirectStepI
+            // would redeem rather than issue, so PaymentMint must be rejected
+            // even though the endpoint identity matches.
+            {
+                // Gw sets trust to accept alice-issued USD.
+                env(trust(gw, aliceUSD(200)));
+                env.close();
+
+                // Alice issues her own USD to gw; now gw holds alice's IOUs.
+                env(pay(alice, gw, aliceUSD(100)));
+                env.close();
+
+                // In gw's view, accountHolds(gw, USD, alice) > 0, so DirectStepI redeems.
+                // PaymentMint must be rejected because the step would redeem, not issue.
+                env(delegate::set(gw, bob, {"PaymentMint"}));
+                env.close();
+
+                env(pay(gw, alice, gwUSD(50)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
+                env(pay(gw, alice, aliceUSD(50)),
+                    delegate::As(bob),
+                    Ter(terNO_DELEGATE_PERMISSION));
+            }
+        }
+
+        // Alice trusts gw but gw is not willing to hold alice's IOU (destLimit == 0).
+        {
+            Env env(*this);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const gw{"gateway"};
+            env.fund(XRP(10000), alice, bob, gw);
+            env.trust(gw["USD"](200), alice);
+            env.close();
+
+            env(delegate::set(alice, bob, {"PaymentMint"}));
+            env.close();
+
+            env(pay(alice, gw, gw["USD"](50)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
+            env(pay(alice, gw, alice["USD"](50)),
+                delegate::As(bob),
+                Ter(terNO_DELEGATE_PERMISSION));
+        }
+
+        // Verify granular permissions of different tx types in the same SLE are scoped
+        // correctly. AccountSet permissions don't apply to Payment and vice versa
+        {
+            Env env(*this);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const gw{"gw"};
+            auto const usd = gw["USD"];
+            env.fund(XRP(10000), alice, bob, gw);
+            env.trust(usd(200), alice);
+            env.close();
+
+            // Alice granted bob with both AccountDomainSet and PaymentMint.
+            env(delegate::set(alice, bob, {"AccountDomainSet", "PaymentMint"}));
+            env.close();
+
+            // PaymentMint fails at granular semantic check because alice is not the issuer.
+            env(pay(alice, gw, usd(50)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
+
+            // AccountDomainSet applies correctly to AccountSet
+            std::string const domain = "example.com";
+            auto jt = noop(alice);
+            jt[sfDomain] = strHex(domain);
+            jt[sfDelegate] = bob.human();
+            env(jt);
+            BEAST_EXPECT((*env.le(alice))[sfDomain] == makeSlice(domain));
+
+            // gw gives bob PaymentMint and bob can mint on gw's behalf
+            env(delegate::set(gw, bob, {"PaymentMint"}));
+            env.close();
+            env(pay(gw, alice, usd(50)), delegate::As(bob));
+            env.require(Balance(alice, usd(50)));
+        }
     }
 
     void
@@ -1300,6 +1685,34 @@ class Delegate_test : public beast::unit_test::Suite
             env(trust(gw, gw["USD"](0), alice, tfSetfAuth | tfFullyCanonicalSig),
                 delegate::As(bob));
         }
+
+        {
+            Env env(*this);
+            Account const gw{"gw"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            env.fund(XRP(10000), gw, alice, bob);
+
+            env(fset(gw, asfRequireAuth));
+            env.close();
+            env(trust(alice, gw["USD"](50)));
+            env.close();
+            env(delegate::set(gw, bob, {"TrustlineAuthorize"}));
+            env.close();
+
+            env(trust(gw, gw["USD"](0), alice, tfSetfAuth), delegate::As(bob));
+            env.close();
+
+            // sfQualityOut is a valid TrustSet field, but not permitted in granular template
+            json::Value txJson = trust(gw, gw["USD"](0), alice, tfSetfAuth);
+            txJson[sfQualityOut.jsonName] = 100;
+            env(txJson, delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
+
+            // tfSetNoRipple is a valid flag for TrustSet, but not permitted in granular template
+            env(trust(gw, gw["USD"](0), alice, tfSetfAuth | tfSetNoRipple),
+                delegate::As(bob),
+                Ter(terNO_DELEGATE_PERMISSION));
+        }
     }
 
     void
@@ -1455,7 +1868,9 @@ class Delegate_test : public beast::unit_test::Suite
             env(jv2, Ter(terNO_DELEGATE_PERMISSION));
         }
 
-        // can not set AccountSet flags on behalf of other account
+        // can not set AccountSet flags on behalf of other account,
+        // in permissions.macro, the template for AccountSet does
+        // not allow any flag set or clear.
         {
             Env env(*this);
             auto const alice = Account{"alice"};
@@ -1551,6 +1966,71 @@ class Delegate_test : public beast::unit_test::Suite
             env(jt);
             BEAST_EXPECT((*env.le(alice))[sfDomain] == makeSlice(domain));
         }
+
+        // setting invalid field not in permissions.macro template will be rejected.
+        {
+            Env env(*this);
+            auto const alice = Account{"alice"};
+            auto const bob = Account{"bob"};
+            env.fund(XRP(10000), alice, bob);
+            env.close();
+
+            // Alice gives Bob permission to set her Domain
+            env(delegate::set(alice, bob, {"AccountDomainSet"}));
+            env.close();
+
+            std::string const domain = "example.com";
+            auto txJson = noop(alice);
+            txJson[sfDomain] = strHex(domain);
+            txJson[sfDelegate] = bob.human();
+
+            // sfNFTokenMinter is a valid field in AccountSet tx, but
+            // it is not permitted for granular template
+            txJson[sfNFTokenMinter] = bob.human();
+
+            env(txJson, Ter(terNO_DELEGATE_PERMISSION));
+        }
+
+        // Delegated AccountSet with no fields and no flags is allowed,
+        // because it is allowed in the non-delegated case as well.
+        {
+            Env env(*this);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            env.fund(XRP(10000), alice, bob);
+            env.close();
+
+            env(delegate::set(alice, bob, {"AccountDomainSet"}));
+            env.close();
+
+            auto jt = noop(alice);
+            jt[sfDelegate] = bob.human();
+            env(jt);
+        }
+
+        // Revoking all permissions deletes the SLE and subsequent attempts are rejected.
+        {
+            Env env(*this);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            env.fund(XRP(10000), alice, bob);
+            env.close();
+
+            env(delegate::set(alice, bob, {"AccountDomainSet"}));
+            env.close();
+
+            std::string const domain = "example.com";
+            auto jt = noop(alice);
+            jt[sfDomain] = strHex(domain);
+            jt[sfDelegate] = bob.human();
+            env(jt);
+
+            // empty DelegateSet deletes the SLE
+            env(delegate::set(alice, bob, {}));
+            env.close();
+
+            env(jt, Ter(terNO_DELEGATE_PERMISSION));
+        }
     }
 
     void
@@ -1671,6 +2151,37 @@ class Delegate_test : public beast::unit_test::Suite
             env.close();
             mpt.set({.account = alice, .flags = tfMPTLock | tfFullyCanonicalSig, .delegate = bob});
         }
+
+        // field not permitted to exist in granular delegation
+        {
+            Env env(*this);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            env.fund(XRP(100000), alice, bob);
+
+            MPTTester mpt(env, alice, {.fund = false});
+            mpt.create({.flags = tfMPTCanLock});
+            env.close();
+
+            // alice gives granular permission to bob for MPTokenIssuanceLock
+            env(delegate::set(alice, bob, {"MPTokenIssuanceLock"}));
+            env.close();
+
+            // Field is not permitted, permitted fields for delegation is defined in
+            // permissions.macro.
+            mpt.set(
+                {.account = alice,
+                 .mutableFlags = 2,
+                 .delegate = bob,
+                 .err = terNO_DELEGATE_PERMISSION});
+
+            // Notice: flags not defined in permissions.macro are not permitted for delegation.
+            // Since preflight will check invalid flag for the tx, it is not reachable.
+            // If any new flag is defined into the transaction in the future,
+            // but is not allowed for delegation, the transaction will be rejected with
+            // terNO_DELEGATE_PERMISSION. The set of permitted flags for delegation is defined in
+            // permissions.macro.
+        }
     }
 
     void
@@ -1879,6 +2390,149 @@ class Delegate_test : public beast::unit_test::Suite
         BEAST_EXPECT(env.balance(edward) == edwardBalance);
     }
 
+    void
+    testMultiSignDelegatorAsSigner()
+    {
+        // checkMultiSign disallows the owner of the account to
+        // be part of the multisigner list. When it is a delegated transaction,
+        // the delegate account should not be part of the multisigner list.
+        testcase("test delegator as multisigner in delegate's signer list");
+        using namespace jtx;
+
+        Env env(*this);
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        Account const carol{"carol"};
+        Account const daria{"daria"};
+        env.fund(XRP(100000), alice, bob, carol, daria);
+        env.close();
+
+        env(delegate::set(alice, bob, {"Payment"}));
+        env.close();
+
+        // bob's signer list includes the delegator alice and daria
+        env(signers(bob, 2, {{alice, 1}, {daria, 1}}));
+        env.close();
+
+        auto aliceBalance = env.balance(alice);
+        auto bobBalance = env.balance(bob);
+        auto carolBalance = env.balance(carol);
+        auto const amt = 100;
+
+        // alice can sign as a multisigner for bob
+        env(pay(alice, carol, XRP(100)), Fee(XRP(10)), delegate::As(bob), Msig(alice, daria));
+        env.close();
+
+        BEAST_EXPECT(env.balance(alice) == aliceBalance - XRP(amt));
+        BEAST_EXPECT(env.balance(bob) == bobBalance - XRP(10));
+        BEAST_EXPECT(env.balance(carol) == carolBalance + XRP(amt));
+
+        // alice can not sign as a multisigner if she sent the transaction by herself.
+        env(pay(alice, carol, XRP(100)), Fee(XRP(10)), Msig(alice, daria), Ter(telENV_RPC_FAILED));
+        env.close();
+
+        // Get new balances
+        aliceBalance = env.balance(alice);
+        bobBalance = env.balance(bob);
+        carolBalance = env.balance(carol);
+
+        // bob (the delegate) should not appear as a multisigner in his transaction sent on behalf
+        // of alice. STTx::checkMultiSign catches this at the local-check stage, so the jtx
+        // framework returns telENV_RPC_FAILED.
+        env(pay(alice, carol, XRP(50)),
+            Fee(XRP(10)),
+            delegate::As(bob),
+            Msig(alice, bob),
+            Ter(telENV_RPC_FAILED));
+        env.close();
+        BEAST_EXPECT(env.balance(alice) == aliceBalance);
+        BEAST_EXPECT(env.balance(bob) == bobBalance);
+        BEAST_EXPECT(env.balance(carol) == carolBalance);
+    }
+
+    void
+    testSignForDelegated()
+    {
+        // In sortAndValidateSigners, if it is a delegated transaction, the delegate account is
+        // the forbidden account from appearing in its own Signers array.
+        testcase("test sign_for with delegated transaction");
+        using namespace jtx;
+
+        Env env(*this);
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        Account const carol{"carol"};
+        Account const daria{"daria"};
+        env.fund(XRP(100000), alice, bob, carol, daria);
+        env.close();
+
+        env(delegate::set(alice, bob, {"Payment"}));
+        env.close();
+
+        // bob's signer list includes the delegator alice and daria
+        env(signers(bob, 2, {{alice, 1}, {daria, 1}}));
+        env.close();
+
+        auto const baseFee = env.current()->fees().base;
+
+        auto const sendAmt = 1'000'000;
+        auto makeDelegateTx = [&]() -> json::Value {
+            json::Value jv;
+            jv[jss::tx_json][jss::Account] = alice.human();
+            jv[jss::tx_json][sfDelegate.jsonName] = bob.human();
+            jv[jss::tx_json][jss::TransactionType] = jss::Payment;
+            jv[jss::tx_json][jss::Destination] = carol.human();
+            jv[jss::tx_json][jss::Amount] = sendAmt;
+            jv[jss::tx_json][jss::Fee] = std::to_string((10 * baseFee).drops());
+            jv[jss::tx_json][jss::Sequence] = env.seq(alice);
+            jv[jss::tx_json][jss::SigningPubKey] = "";
+            return jv;
+        };
+
+        // The delegator alice and daria both sign via sign_for, which is valid
+        {
+            auto const aliceBalance = env.balance(alice);
+            auto const bobBalance = env.balance(bob);
+            auto const dariaBalance = env.balance(daria);
+            auto const carolBalance = env.balance(carol);
+
+            json::Value jv = makeDelegateTx();
+            jv[jss::account] = alice.human();
+            jv[jss::secret] = alice.name();
+            auto jrr = env.rpc("json", "sign_for", to_string(jv))[jss::result];
+            BEAST_EXPECT(jrr[jss::status] == "success");
+
+            json::Value jv2;
+            jv2[jss::tx_json] = jrr[jss::tx_json];
+            jv2[jss::account] = daria.human();
+            jv2[jss::secret] = daria.name();
+            jrr = env.rpc("json", "sign_for", to_string(jv2))[jss::result];
+            BEAST_EXPECT(jrr[jss::status] == "success");
+
+            json::Value jvSubmit;
+            jvSubmit[jss::tx_json] = jrr[jss::tx_json];
+            jrr = env.rpc("json", "submit_multisigned", to_string(jvSubmit))[jss::result];
+            BEAST_EXPECT(jrr[jss::status] == "success");
+            env.close();
+            BEAST_EXPECT(env.balance(alice) == aliceBalance - XRPAmount(sendAmt));
+            BEAST_EXPECT(env.balance(bob) == bobBalance - (10 * baseFee));
+            BEAST_EXPECT(env.balance(daria) == dariaBalance);
+            BEAST_EXPECT(env.balance(carol) == carolBalance + XRPAmount(sendAmt));
+        }
+
+        // The delegated account bob attempts sign_for, will be rejected.
+        {
+            json::Value jv = makeDelegateTx();
+            jv[jss::account] = bob.human();
+            jv[jss::secret] = bob.name();
+            auto jrr = env.rpc("json", "sign_for", to_string(jv))[jss::result];
+            BEAST_EXPECT(jrr[jss::status] == "error");
+            BEAST_EXPECT(
+                jrr[jss::error_message].asString().contains(
+                    "A Signer may not be the transaction's Account"));
+        }
+    }
+
     void
     testPermissionValue(FeatureBitset features)
     {
@@ -1929,7 +2583,6 @@ class Delegate_test : public beast::unit_test::Suite
         // NFTokenMint, NFTokenBurn, NFTokenCreateOffer, NFTokenCancelOffer,
         // NFTokenAcceptOffer are not included, they are tested separately.
         std::unordered_map txRequiredFeatures{
-            {"Clawback", featureClawback},
             {"AMMClawback", featureAMMClawback},
             {"AMMCreate", featureAMM},
             {"AMMDeposit", featureAMM},
@@ -1997,6 +2650,62 @@ class Delegate_test : public beast::unit_test::Suite
             for (auto const& tx : txRequiredFeatures)
                 txAmendmentEnabled(tx.first);
         }
+
+        // Granular permissions also require the amendment for their underlying
+        // transaction type.
+        {
+            for (auto const permission : {"MPTokenIssuanceLock", "MPTokenIssuanceUnlock"})
+            {
+                Env env(*this, features - featureMPTokensV1);
+
+                Account const alice{"alice"};
+                Account const bob{"bob"};
+                env.fund(XRP(100000), alice, bob);
+                env.close();
+
+                env(delegate::set(alice, bob, {permission}), Ter(temMALFORMED));
+            }
+        }
+    }
+
+    void
+    testGranularSandboxCheckOrder()
+    {
+        testcase("Make sure GranularSandbox is checked after transaction-level permission");
+
+        using namespace jtx;
+
+        Env env(*this);
+        Account const gw{"gw"};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        env.fund(XRP(10000), gw, alice, bob);
+
+        env(fset(gw, asfRequireAuth));
+        env.close();
+        env(trust(alice, gw["USD"](50)));
+        env.close();
+        env(delegate::set(gw, bob, {"TrustlineAuthorize"}));
+        env.close();
+
+        env(trust(gw, gw["USD"](0), alice, tfSetfAuth), delegate::As(bob));
+        env.close();
+
+        // sfQualityOut is a valid TrustSet field, but not permitted in granular template
+        json::Value txJson = trust(gw, gw["USD"](0), alice, tfSetfAuth);
+        txJson[sfQualityOut.jsonName] = 100;
+        env(txJson, delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
+
+        // Now Alice grants Bob with transaction level permission
+        env(delegate::set(gw, bob, {"TrustlineAuthorize", "TrustSet"}));
+        env.close();
+
+        // NOTE: This case is to ensure that if a delegate possesses a
+        // transaction-level permission (e.g., TrustSet), the granular sandbox must not incorrectly
+        // block the transaction. The function checkGranularSandbox MUST be called after the
+        // transaction-level permission check. This test case is to avoid future refactor mistakes,
+        // modifying the order will fail here.
+        env(txJson, delegate::As(bob));
     }
 
     void
@@ -2038,7 +2747,9 @@ class Delegate_test : public beast::unit_test::Suite
         // DO NOT modify expectedDelegableCount unless all scenarios, including
         // edge cases, have been fully tested and verified.
         // ====================================================================
-        std::size_t const expectedDelegableCount = 51;
+        // Includes the five confidential MPT transaction types, which are
+        // explicitly marked Delegable in transactions.macro.
+        std::size_t const expectedDelegableCount = 56;
 
         BEAST_EXPECTS(
             delegableCount == expectedDelegableCount,
@@ -2049,6 +2760,94 @@ class Delegate_test : public beast::unit_test::Suite
                 "\n  Action: Verify security requirements to interact with Delegation feature");
     }
 
+    void
+    testNonDelegableTxWithDelegate(FeatureBitset features)
+    {
+        testcase("non-delegable tx with sfDelegate is rejected at preflight");
+        using namespace jtx;
+
+        Env env(*this, features);
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        env.fund(XRP(10000), alice, bob);
+        env.close();
+
+        // Transactions that are notDelegable and have no granular permissions
+        // will be rejected with temINVALID at preflight.
+        // Note: pseudo-transactions (EnableAmendment, SetFee and UNLModify) are also
+        // notDelegable but are excluded here — passesLocalChecks() blocks them
+        // before preflight1 is ever reached.
+        {
+            // SetRegularKey, SignerListSet, AccountDelete, DelegateSet.
+            env(regkey(alice, bob), delegate::As(bob), Ter(temINVALID));
+            env(signers(alice, 1, {{bob, 1}}), delegate::As(bob), Ter(temINVALID));
+            env(acctdelete(alice, bob), delegate::As(bob), Ter(temINVALID));
+            env(delegate::set(alice, bob, {"Payment"}), delegate::As(bob), Ter(temINVALID));
+
+            // SAV transactions.
+            {
+                Vault const vault{env};
+                auto [createTx, keylet] = vault.create({.owner = alice, .asset = xrpIssue()});
+                env(createTx, delegate::As(bob), Ter(temINVALID));
+
+                env(vault.set({.owner = alice, .id = keylet.key}),
+                    delegate::As(bob),
+                    Ter(temINVALID));
+                env(vault.del({.owner = alice, .id = keylet.key}),
+                    delegate::As(bob),
+                    Ter(temINVALID));
+                env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(1)}),
+                    delegate::As(bob),
+                    Ter(temINVALID));
+                env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(1)}),
+                    delegate::As(bob),
+                    Ter(temINVALID));
+                env(vault.clawback({.issuer = alice, .id = keylet.key, .holder = bob}),
+                    delegate::As(bob),
+                    Ter(temINVALID));
+            }
+
+            // Batch transaction: the outer Batch itself is non-delegable.
+            {
+                auto const seq = env.seq(alice);
+                auto const batchFee = batch::calcBatchFee(env, 0, 1);
+                env(batch::outer(alice, seq, batchFee, tfAllOrNothing),
+                    batch::Inner(pay(alice, bob, XRP(1)), seq + 1),
+                    delegate::As(bob),
+                    Ter(temINVALID));
+            }
+
+            // Lending protocol transactions
+            {
+                Vault const vault{env};
+                auto [createTx, keylet] = vault.create({.owner = alice, .asset = xrpIssue()});
+                env(createTx);
+
+                env(loanBroker::set(alice, keylet.key), delegate::As(bob), Ter(temINVALID));
+                env(loanBroker::del(alice, keylet.key), delegate::As(bob), Ter(temINVALID));
+                env(loanBroker::coverDeposit(alice, keylet.key, XRP(1)),
+                    delegate::As(bob),
+                    Ter(temINVALID));
+                env(loanBroker::coverWithdraw(alice, keylet.key, XRP(1)),
+                    delegate::As(bob),
+                    Ter(temINVALID));
+                env(loanBroker::coverClawback(alice), delegate::As(bob), Ter(temINVALID));
+
+                env(loan::set(alice, keylet.key, Number(100)), delegate::As(bob), Ter(temINVALID));
+                env(loan::manage(alice, keylet.key, 0), delegate::As(bob), Ter(temINVALID));
+                env(loan::del(alice, keylet.key), delegate::As(bob), Ter(temINVALID));
+                env(loan::pay(alice, keylet.key, XRP(1)), delegate::As(bob), Ter(temINVALID));
+            }
+        }
+
+        // AccountSet is notDelegable at tx level but has granular permissions,
+        // so sfDelegate passes preflight and is rejected at invokeCheckPermission with
+        // terNO_DELEGATE_PERMISSION.
+        {
+            env(fset(alice, asfDefaultRipple), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
+        }
+    }
+
     void
     testDelegateUtilsNullptrCheck()
     {
@@ -2058,12 +2857,43 @@ class Delegate_test : public beast::unit_test::Suite
         STTx const tx{ttPAYMENT, [](STObject&) {}};
         BEAST_EXPECT(checkTxPermission(nullptr, tx) == terNO_DELEGATE_PERMISSION);
 
-        // loadGranularPermission nullptr check
-        std::unordered_set granularPermissions;
-        loadGranularPermission(nullptr, ttPAYMENT, granularPermissions);
+        // getGranularPermission nullptr check
+        auto const granularPermissions = getGranularPermission(nullptr, ttPAYMENT);
         BEAST_EXPECT(granularPermissions.empty());
     }
 
+    void
+    testPermissionToTxType()
+    {
+        testcase("test Permission to Tx type");
+
+        // 0 is not a valid permission value
+        BEAST_EXPECT(!Permission::permissionToTxType(0));
+
+        // 1 maps to Payment transaction
+        BEAST_EXPECT(Permission::permissionToTxType(1) == ttPAYMENT);
+
+        // UINT16_MAX+1 is the maximum possible tx-level permission value
+        constexpr uint32_t maxTxPermission = std::numeric_limits::max() + 1u;
+        BEAST_EXPECT(Permission::permissionToTxType(maxTxPermission).has_value());
+
+        // exceeding maximum value should return nullopt
+        BEAST_EXPECT(!Permission::permissionToTxType(maxTxPermission + 1));
+
+        // All granular permission values should return nullopt since they do not map to a TxType.
+        for (auto const gp : {
+#pragma push_macro("GRANULAR_PERMISSION")
+#undef GRANULAR_PERMISSION
+#define GRANULAR_PERMISSION(type, txType, value, ...) GranularPermissionType::type,
+#include 
+#undef GRANULAR_PERMISSION
+#pragma pop_macro("GRANULAR_PERMISSION")
+             })
+        {
+            BEAST_EXPECT(!Permission::permissionToTxType(static_cast(gp)));
+        }
+    }
+
     void
     run() override
     {
@@ -2086,10 +2916,15 @@ class Delegate_test : public beast::unit_test::Suite
         testSingleSignBadSecret();
         testMultiSign();
         testMultiSignQuorumNotMet();
+        testMultiSignDelegatorAsSigner();
+        testSignForDelegated();
         testPermissionValue(all);
         testTxRequireFeatures(all);
+        testGranularSandboxCheckOrder();
         testTxDelegableCount();
+        testNonDelegableTxWithDelegate(all);
         testDelegateUtilsNullptrCheck();
+        testPermissionToTxType();
     }
 };
 BEAST_DEFINE_TESTSUITE(Delegate, app, xrpl);
diff --git a/src/test/app/DepositAuth_test.cpp b/src/test/app/DepositAuth_test.cpp
index 98597a175f..3496e67b54 100644
--- a/src/test/app/DepositAuth_test.cpp
+++ b/src/test/app/DepositAuth_test.cpp
@@ -614,7 +614,8 @@ struct DepositPreauth_test : public beast::unit_test::Suite
 
                 TER const expectTer(!supportsCredentials ? TER(temDISABLED) : TER(tesSUCCESS));
 
-                env(deposit::authCredentials(becky, {{carol, credType}}), Ter(expectTer));
+                env(deposit::authCredentials(becky, {{.issuer = carol, .credType = credType}}),
+                    Ter(expectTer));
                 env.close();
 
                 // gw accept credentials
@@ -744,7 +745,8 @@ struct DepositPreauth_test : public beast::unit_test::Suite
             env.close();
 
             // Setup DepositPreauth object failed - amendent is not supported
-            env(deposit::authCredentials(bob, {{issuer, credType}}), Ter(temDISABLED));
+            env(deposit::authCredentials(bob, {{.issuer = issuer, .credType = credType}}),
+                Ter(temDISABLED));
             env.close();
 
             // But can create old DepositPreauth
@@ -782,10 +784,11 @@ struct DepositPreauth_test : public beast::unit_test::Suite
 
             // Bob will accept payments from accounts with credentials signed
             // by 'issuer'
-            env(deposit::authCredentials(bob, {{issuer, credType}}));
+            env(deposit::authCredentials(bob, {{.issuer = issuer, .credType = credType}}));
             env.close();
 
-            auto const jDP = ledgerEntryDepositPreauth(env, bob, {{issuer, credType}});
+            auto const jDP =
+                ledgerEntryDepositPreauth(env, bob, {{.issuer = issuer, .credType = credType}});
             BEAST_EXPECT(
                 jDP.isObject() && jDP.isMember(jss::result) &&
                 !jDP[jss::result].isMember(jss::error) && jDP[jss::result].isMember(jss::node) &&
@@ -858,11 +861,14 @@ struct DepositPreauth_test : public beast::unit_test::Suite
             }
 
             // Bob setup DepositPreauth object, duplicates is not allowed
-            env(deposit::authCredentials(bob, {{issuer, credType}, {issuer, credType}}),
+            env(deposit::authCredentials(
+                    bob,
+                    {{.issuer = issuer, .credType = credType},
+                     {.issuer = issuer, .credType = credType}}),
                 Ter(temMALFORMED));
 
             // Bob setup DepositPreauth object
-            env(deposit::authCredentials(bob, {{issuer, credType}}));
+            env(deposit::authCredentials(bob, {{.issuer = issuer, .credType = credType}}));
             env.close();
 
             {
@@ -928,35 +934,37 @@ struct DepositPreauth_test : public beast::unit_test::Suite
 
             {
                 // both included [AuthorizeCredentials UnauthorizeCredentials]
-                auto jv = deposit::authCredentials(bob, {{issuer, credType}});
+                auto jv = deposit::authCredentials(bob, {{.issuer = issuer, .credType = credType}});
                 jv[sfUnauthorizeCredentials.jsonName] = json::ValueType::Array;
                 env(jv, Ter(temMALFORMED));
             }
 
             {
                 // both included [Unauthorize, AuthorizeCredentials]
-                auto jv = deposit::authCredentials(bob, {{issuer, credType}});
+                auto jv = deposit::authCredentials(bob, {{.issuer = issuer, .credType = credType}});
                 jv[sfUnauthorize.jsonName] = issuer.human();
                 env(jv, Ter(temMALFORMED));
             }
 
             {
                 // both included [Authorize, AuthorizeCredentials]
-                auto jv = deposit::authCredentials(bob, {{issuer, credType}});
+                auto jv = deposit::authCredentials(bob, {{.issuer = issuer, .credType = credType}});
                 jv[sfAuthorize.jsonName] = issuer.human();
                 env(jv, Ter(temMALFORMED));
             }
 
             {
                 // both included [Unauthorize, UnauthorizeCredentials]
-                auto jv = deposit::unauthCredentials(bob, {{issuer, credType}});
+                auto jv =
+                    deposit::unauthCredentials(bob, {{.issuer = issuer, .credType = credType}});
                 jv[sfUnauthorize.jsonName] = issuer.human();
                 env(jv, Ter(temMALFORMED));
             }
 
             {
                 // both included [Authorize, UnauthorizeCredentials]
-                auto jv = deposit::unauthCredentials(bob, {{issuer, credType}});
+                auto jv =
+                    deposit::unauthCredentials(bob, {{.issuer = issuer, .credType = credType}});
                 jv[sfAuthorize.jsonName] = issuer.human();
                 env(jv, Ter(temMALFORMED));
             }
@@ -983,7 +991,7 @@ struct DepositPreauth_test : public beast::unit_test::Suite
 
             {
                 // empty credential type
-                auto jv = deposit::authCredentials(bob, {{issuer, {}}});
+                auto jv = deposit::authCredentials(bob, {{.issuer = issuer, .credType = {}}});
                 env(jv, Ter(temMALFORMED));
             }
 
@@ -993,14 +1001,23 @@ struct DepositPreauth_test : public beast::unit_test::Suite
                     i("i");
                 auto const& z = credType;
                 auto jv = deposit::authCredentials(
-                    bob, {{a, z}, {b, z}, {c, z}, {d, z}, {e, z}, {f, z}, {g, z}, {h, z}, {i, z}});
+                    bob,
+                    {{.issuer = a, .credType = z},
+                     {.issuer = b, .credType = z},
+                     {.issuer = c, .credType = z},
+                     {.issuer = d, .credType = z},
+                     {.issuer = e, .credType = z},
+                     {.issuer = f, .credType = z},
+                     {.issuer = g, .credType = z},
+                     {.issuer = h, .credType = z},
+                     {.issuer = i, .credType = z}});
                 env(jv, Ter(temARRAY_TOO_LARGE));
             }
 
             {
                 // Can't create with non-existing issuer
                 Account const rick{"rick"};
-                auto jv = deposit::authCredentials(bob, {{rick, credType}});
+                auto jv = deposit::authCredentials(bob, {{.issuer = rick, .credType = credType}});
                 env(jv, Ter(tecNO_ISSUER));
                 env.close();
             }
@@ -1010,21 +1027,24 @@ struct DepositPreauth_test : public beast::unit_test::Suite
                 Account const john{"john"};
                 env.fund(env.current()->fees().accountReserve(0), john);
                 env.close();
-                auto jv = deposit::authCredentials(john, {{issuer, credType}});
+                auto jv =
+                    deposit::authCredentials(john, {{.issuer = issuer, .credType = credType}});
                 env(jv, Ter(tecINSUFFICIENT_RESERVE));
             }
 
             {
                 // NO deposit object exists
-                env(deposit::unauthCredentials(bob, {{issuer, credType}}), Ter(tecNO_ENTRY));
+                env(deposit::unauthCredentials(bob, {{.issuer = issuer, .credType = credType}}),
+                    Ter(tecNO_ENTRY));
             }
 
             // Create DepositPreauth object
             {
-                env(deposit::authCredentials(bob, {{issuer, credType}}));
+                env(deposit::authCredentials(bob, {{.issuer = issuer, .credType = credType}}));
                 env.close();
 
-                auto const jDP = ledgerEntryDepositPreauth(env, bob, {{issuer, credType}});
+                auto const jDP =
+                    ledgerEntryDepositPreauth(env, bob, {{.issuer = issuer, .credType = credType}});
                 BEAST_EXPECT(
                     jDP.isObject() && jDP.isMember(jss::result) &&
                     !jDP[jss::result].isMember(jss::error) &&
@@ -1045,14 +1065,16 @@ struct DepositPreauth_test : public beast::unit_test::Suite
                 }
 
                 // can't create duplicate
-                env(deposit::authCredentials(bob, {{issuer, credType}}), Ter(tecDUPLICATE));
+                env(deposit::authCredentials(bob, {{.issuer = issuer, .credType = credType}}),
+                    Ter(tecDUPLICATE));
             }
 
             // Delete DepositPreauth object
             {
-                env(deposit::unauthCredentials(bob, {{issuer, credType}}));
+                env(deposit::unauthCredentials(bob, {{.issuer = issuer, .credType = credType}}));
                 env.close();
-                auto const jDP = ledgerEntryDepositPreauth(env, bob, {{issuer, credType}});
+                auto const jDP =
+                    ledgerEntryDepositPreauth(env, bob, {{.issuer = issuer, .credType = credType}});
                 BEAST_EXPECT(
                     jDP.isObject() && jDP.isMember(jss::result) &&
                     jDP[jss::result].isMember(jss::error) &&
@@ -1119,7 +1141,10 @@ struct DepositPreauth_test : public beast::unit_test::Suite
             env(fset(bob, asfDepositAuth));
             env.close();
             // Bob setup DepositPreauth object
-            env(deposit::authCredentials(bob, {{issuer, credType}, {issuer, credType2}}));
+            env(deposit::authCredentials(
+                bob,
+                {{.issuer = issuer, .credType = credType},
+                 {.issuer = issuer, .credType = credType2}}));
             env.close();
 
             {
@@ -1228,7 +1253,7 @@ struct DepositPreauth_test : public beast::unit_test::Suite
             env(fset(bob, asfDepositAuth));
             env.close();
             // Bob setup DepositPreauth object
-            env(deposit::authCredentials(bob, {{issuer, credType}}));
+            env(deposit::authCredentials(bob, {{.issuer = issuer, .credType = credType}}));
             env.close();
 
             auto const seq = env.seq(alice);
@@ -1286,14 +1311,14 @@ struct DepositPreauth_test : public beast::unit_test::Suite
         env.fund(XRP(5000), stock, alice, bob);
 
         std::vector credentials = {
-            {"a", "a"},
-            {"b", "b"},
-            {"c", "c"},
-            {"d", "d"},
-            {"e", "e"},
-            {"f", "f"},
-            {"g", "g"},
-            {"h", "h"}};
+            {.issuer = "a", .credType = "a"},
+            {.issuer = "b", .credType = "b"},
+            {.issuer = "c", .credType = "c"},
+            {.issuer = "d", .credType = "d"},
+            {.issuer = "e", .credType = "e"},
+            {.issuer = "f", .credType = "f"},
+            {.issuer = "g", .credType = "g"},
+            {.issuer = "h", .credType = "h"}};
 
         for (auto const& c : credentials)
             env.fund(XRP(5000), c.issuer);
diff --git a/src/test/app/EscrowToken_test.cpp b/src/test/app/EscrowToken_test.cpp
index 5bb1303dba..666ee498bd 100644
--- a/src/test/app/EscrowToken_test.cpp
+++ b/src/test/app/EscrowToken_test.cpp
@@ -57,7 +57,7 @@ struct EscrowToken_test : public beast::unit_test::Suite
     static uint64_t
     issuerMPTEscrowed(jtx::Env const& env, jtx::MPT const& mpt)
     {
-        auto const sle = env.le(keylet::mptIssuance(mpt.mpt()));
+        auto const sle = env.le(keylet::mptokenIssuance(mpt.mpt()));
         if (sle && sle->isFieldPresent(sfLockedAmount))
             return (*sle)[sfLockedAmount];
         return 0;
@@ -929,7 +929,7 @@ struct EscrowToken_test : public beast::unit_test::Suite
             env(trust(alice, usd(0)));
             env.close();
 
-            auto const trustLineKey = keylet::line(alice.id(), gw.id(), usd.currency);
+            auto const trustLineKey = keylet::trustLine(alice.id(), gw.id(), usd.currency);
             BEAST_EXPECT(!env.current()->exists(trustLineKey));
 
             env.close();
@@ -3177,7 +3177,8 @@ struct EscrowToken_test : public beast::unit_test::Suite
             BEAST_EXPECT(mptEscrowed(env, bob, mpt) == 0);
             BEAST_EXPECT(env.balance(gw, mpt) == outstandingMPT);
             BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 0);
-            BEAST_EXPECT(!env.le(keylet::mptIssuance(mpt.mpt()))->isFieldPresent(sfLockedAmount));
+            BEAST_EXPECT(
+                !env.le(keylet::mptokenIssuance(mpt.mpt()))->isFieldPresent(sfLockedAmount));
         }
 
         // Max MPT Amount Issued (Escrow Max MPT)
diff --git a/src/test/app/Escrow_test.cpp b/src/test/app/Escrow_test.cpp
index 3e76524cf1..5623bc4443 100644
--- a/src/test/app/Escrow_test.cpp
+++ b/src/test/app/Escrow_test.cpp
@@ -1544,7 +1544,7 @@ struct Escrow_test : public beast::unit_test::Suite
                 credentials::Ids({credIdx}),
                 Ter(tecNO_PERMISSION));
 
-            env(deposit::authCredentials(bob, {{zelda, credType}}));
+            env(deposit::authCredentials(bob, {{.issuer = zelda, .credType = credType}}));
             env.close();
 
             // Success
@@ -1601,7 +1601,7 @@ struct Escrow_test : public beast::unit_test::Suite
                 // Bob require pre-authorization
                 env(fset(bob, asfDepositAuth));
                 env.close();
-                env(deposit::authCredentials(bob, {{zelda, credType}}));
+                env(deposit::authCredentials(bob, {{.issuer = zelda, .credType = credType}}));
                 env.close();
 
                 // Use any valid credentials if account == dst
diff --git a/src/test/app/FeeVote_test.cpp b/src/test/app/FeeVote_test.cpp
index 4fb97a0b74..666dec1249 100644
--- a/src/test/app/FeeVote_test.cpp
+++ b/src/test/app/FeeVote_test.cpp
@@ -4,9 +4,9 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -144,7 +144,7 @@ verifyFeeObject(
     Rules const& rules,
     FeeSettingsFields const& expected)
 {
-    auto const feeObject = ledger->read(keylet::fees());
+    auto const feeObject = ledger->read(keylet::feeSettings());
     if (!feeObject)
         return false;
 
@@ -213,18 +213,18 @@ class FeeVote_test : public beast::unit_test::Suite
             // defaults
             Section const config;
             auto setup = setupFeeVote(config);
-            BEAST_EXPECT(setup.reference_fee == defaultSetup.reference_fee);
-            BEAST_EXPECT(setup.account_reserve == defaultSetup.account_reserve);
-            BEAST_EXPECT(setup.owner_reserve == defaultSetup.owner_reserve);
+            BEAST_EXPECT(setup.referenceFee == defaultSetup.referenceFee);
+            BEAST_EXPECT(setup.accountReserve == defaultSetup.accountReserve);
+            BEAST_EXPECT(setup.ownerReserve == defaultSetup.ownerReserve);
         }
         {
             Section config;
             config.append(
                 {"reference_fee = 50", "account_reserve = 1234567", "owner_reserve = 1234"});
             auto setup = setupFeeVote(config);
-            BEAST_EXPECT(setup.reference_fee == 50);
-            BEAST_EXPECT(setup.account_reserve == 1234567);
-            BEAST_EXPECT(setup.owner_reserve == 1234);
+            BEAST_EXPECT(setup.referenceFee == 50);
+            BEAST_EXPECT(setup.accountReserve == 1234567);
+            BEAST_EXPECT(setup.ownerReserve == 1234);
         }
         {
             Section config;
@@ -232,9 +232,9 @@ class FeeVote_test : public beast::unit_test::Suite
                 {"reference_fee = blah", "account_reserve = yada", "owner_reserve = foo"});
             // Illegal values are ignored, and the defaults left unchanged
             auto setup = setupFeeVote(config);
-            BEAST_EXPECT(setup.reference_fee == defaultSetup.reference_fee);
-            BEAST_EXPECT(setup.account_reserve == defaultSetup.account_reserve);
-            BEAST_EXPECT(setup.owner_reserve == defaultSetup.owner_reserve);
+            BEAST_EXPECT(setup.referenceFee == defaultSetup.referenceFee);
+            BEAST_EXPECT(setup.accountReserve == defaultSetup.accountReserve);
+            BEAST_EXPECT(setup.ownerReserve == defaultSetup.ownerReserve);
         }
         {
             Section config;
@@ -242,9 +242,9 @@ class FeeVote_test : public beast::unit_test::Suite
                 {"reference_fee = -50", "account_reserve = -1234567", "owner_reserve = -1234"});
             // Illegal values are ignored, and the defaults left unchanged
             auto setup = setupFeeVote(config);
-            BEAST_EXPECT(setup.reference_fee == defaultSetup.reference_fee);
-            BEAST_EXPECT(setup.account_reserve == static_cast(-1234567));
-            BEAST_EXPECT(setup.owner_reserve == static_cast(-1234));
+            BEAST_EXPECT(setup.referenceFee == defaultSetup.referenceFee);
+            BEAST_EXPECT(setup.accountReserve == static_cast(-1234567));
+            BEAST_EXPECT(setup.ownerReserve == static_cast(-1234));
         }
         {
             auto const big64 = std::to_string(
@@ -256,9 +256,9 @@ class FeeVote_test : public beast::unit_test::Suite
                  "owner_reserve = " + big64});
             // Illegal values are ignored, and the defaults left unchanged
             auto setup = setupFeeVote(config);
-            BEAST_EXPECT(setup.reference_fee == defaultSetup.reference_fee);
-            BEAST_EXPECT(setup.account_reserve == defaultSetup.account_reserve);
-            BEAST_EXPECT(setup.owner_reserve == defaultSetup.owner_reserve);
+            BEAST_EXPECT(setup.referenceFee == defaultSetup.referenceFee);
+            BEAST_EXPECT(setup.accountReserve == defaultSetup.accountReserve);
+            BEAST_EXPECT(setup.ownerReserve == defaultSetup.ownerReserve);
         }
     }
 
@@ -273,7 +273,7 @@ class FeeVote_test : public beast::unit_test::Suite
             auto ledger = std::make_shared(
                 kCreateGenesis,
                 Rules{env.app().config().features},
-                env.app().config().FEES.toFees(),
+                env.app().config().fees.toFees(),
                 std::vector{},
                 env.app().getNodeFamily());
 
@@ -303,7 +303,7 @@ class FeeVote_test : public beast::unit_test::Suite
             auto ledger = std::make_shared(
                 kCreateGenesis,
                 Rules{env.app().config().features},
-                env.app().config().FEES.toFees(),
+                env.app().config().fees.toFees(),
                 std::vector{},
                 env.app().getNodeFamily());
 
@@ -336,7 +336,7 @@ class FeeVote_test : public beast::unit_test::Suite
             auto ledger = std::make_shared(
                 kCreateGenesis,
                 Rules{env.app().config().features},
-                env.app().config().FEES.toFees(),
+                env.app().config().fees.toFees(),
                 std::vector{},
                 env.app().getNodeFamily());
 
@@ -358,7 +358,7 @@ class FeeVote_test : public beast::unit_test::Suite
             auto ledger = std::make_shared(
                 kCreateGenesis,
                 Rules{env.app().config().features},
-                env.app().config().FEES.toFees(),
+                env.app().config().fees.toFees(),
                 std::vector{},
                 env.app().getNodeFamily());
 
@@ -385,7 +385,7 @@ class FeeVote_test : public beast::unit_test::Suite
         auto ledger = std::make_shared(
             kCreateGenesis,
             Rules{env.app().config().features},
-            env.app().config().FEES.toFees(),
+            env.app().config().fees.toFees(),
             std::vector{},
             env.app().getNodeFamily());
 
@@ -424,7 +424,7 @@ class FeeVote_test : public beast::unit_test::Suite
         auto ledger = std::make_shared(
             kCreateGenesis,
             Rules{env.app().config().features},
-            env.app().config().FEES.toFees(),
+            env.app().config().fees.toFees(),
             std::vector{},
             env.app().getNodeFamily());
 
@@ -472,7 +472,7 @@ class FeeVote_test : public beast::unit_test::Suite
         auto ledger = std::make_shared(
             kCreateGenesis,
             Rules{env.app().config().features},
-            env.app().config().FEES.toFees(),
+            env.app().config().fees.toFees(),
             std::vector{},
             env.app().getNodeFamily());
 
@@ -503,7 +503,7 @@ class FeeVote_test : public beast::unit_test::Suite
         auto ledger = std::make_shared(
             kCreateGenesis,
             Rules{env.app().config().features},
-            env.app().config().FEES.toFees(),
+            env.app().config().fees.toFees(),
             std::vector{},
             env.app().getNodeFamily());
 
@@ -549,7 +549,7 @@ class FeeVote_test : public beast::unit_test::Suite
         auto ledger = std::make_shared(
             kCreateGenesis,
             Rules{env.app().config().features},
-            env.app().config().FEES.toFees(),
+            env.app().config().fees.toFees(),
             std::vector{},
             env.app().getNodeFamily());
 
@@ -578,9 +578,9 @@ class FeeVote_test : public beast::unit_test::Suite
         using namespace jtx;
 
         FeeSetup setup;
-        setup.reference_fee = 42;
-        setup.account_reserve = 1234567;
-        setup.owner_reserve = 7654321;
+        setup.referenceFee = 42;
+        setup.accountReserve = 1234567;
+        setup.ownerReserve = 7654321;
 
         // Test with XRPFees enabled
         {
@@ -590,7 +590,7 @@ class FeeVote_test : public beast::unit_test::Suite
             auto ledger = std::make_shared(
                 kCreateGenesis,
                 Rules{env.app().config().features},
-                env.app().config().FEES.toFees(),
+                env.app().config().fees.toFees(),
                 std::vector{},
                 env.app().getNodeFamily());
 
@@ -609,7 +609,7 @@ class FeeVote_test : public beast::unit_test::Suite
             feeVote->doValidation(currentFees, ledger->rules(), *val);
 
             BEAST_EXPECT(val->isFieldPresent(sfBaseFeeDrops));
-            BEAST_EXPECT(val->getFieldAmount(sfBaseFeeDrops) == XRPAmount(setup.reference_fee));
+            BEAST_EXPECT(val->getFieldAmount(sfBaseFeeDrops) == XRPAmount(setup.referenceFee));
         }
 
         // Test with XRPFees disabled (legacy format)
@@ -620,7 +620,7 @@ class FeeVote_test : public beast::unit_test::Suite
             auto ledger = std::make_shared(
                 kCreateGenesis,
                 Rules{env.app().config().features},
-                env.app().config().FEES.toFees(),
+                env.app().config().fees.toFees(),
                 std::vector{},
                 env.app().getNodeFamily());
 
@@ -638,7 +638,7 @@ class FeeVote_test : public beast::unit_test::Suite
 
             // In legacy mode, should vote using legacy fields
             BEAST_EXPECT(val->isFieldPresent(sfBaseFee));
-            BEAST_EXPECT(val->getFieldU64(sfBaseFee) == setup.reference_fee);
+            BEAST_EXPECT(val->getFieldU64(sfBaseFee) == setup.referenceFee);
         }
     }
 
@@ -650,9 +650,9 @@ class FeeVote_test : public beast::unit_test::Suite
         using namespace jtx;
 
         FeeSetup setup;
-        setup.reference_fee = 42;
-        setup.account_reserve = 1234567;
-        setup.owner_reserve = 7654321;
+        setup.referenceFee = 42;
+        setup.accountReserve = 1234567;
+        setup.ownerReserve = 7654321;
 
         Env env(*this, testableAmendments() | featureXRPFees);
 
@@ -665,7 +665,7 @@ class FeeVote_test : public beast::unit_test::Suite
         auto ledger = std::make_shared(
             kCreateGenesis,
             Rules{env.app().config().features},
-            env.app().config().FEES.toFees(),
+            env.app().config().fees.toFees(),
             std::vector{},
             env.app().getNodeFamily());
 
@@ -689,9 +689,9 @@ class FeeVote_test : public beast::unit_test::Suite
                 env.app().getTimeKeeper().now(), pub, sec, calcNodeID(pub), [&](STValidation& v) {
                     v.setFieldU32(sfLedgerSequence, ledger->seq());
                     // Vote for different fees than current
-                    v.setFieldAmount(sfBaseFeeDrops, XRPAmount{setup.reference_fee});
-                    v.setFieldAmount(sfReserveBaseDrops, XRPAmount{setup.account_reserve});
-                    v.setFieldAmount(sfReserveIncrementDrops, XRPAmount{setup.owner_reserve});
+                    v.setFieldAmount(sfBaseFeeDrops, XRPAmount{setup.referenceFee});
+                    v.setFieldAmount(sfReserveBaseDrops, XRPAmount{setup.accountReserve});
+                    v.setFieldAmount(sfReserveIncrementDrops, XRPAmount{setup.ownerReserve});
                 });
             if ((i % 2) != 0)
                 val->setTrusted();
@@ -723,10 +723,10 @@ class FeeVote_test : public beast::unit_test::Suite
         BEAST_EXPECT(!feeTx.isFieldPresent(sfReferenceFeeUnits));
 
         // Check the values
-        BEAST_EXPECT(feeTx.getFieldAmount(sfBaseFeeDrops) == XRPAmount{setup.reference_fee});
-        BEAST_EXPECT(feeTx.getFieldAmount(sfReserveBaseDrops) == XRPAmount{setup.account_reserve});
+        BEAST_EXPECT(feeTx.getFieldAmount(sfBaseFeeDrops) == XRPAmount{setup.referenceFee});
+        BEAST_EXPECT(feeTx.getFieldAmount(sfReserveBaseDrops) == XRPAmount{setup.accountReserve});
         BEAST_EXPECT(
-            feeTx.getFieldAmount(sfReserveIncrementDrops) == XRPAmount{setup.owner_reserve});
+            feeTx.getFieldAmount(sfReserveIncrementDrops) == XRPAmount{setup.ownerReserve});
     }
 
     void
diff --git a/src/test/app/FixNFTokenPageLinks_test.cpp b/src/test/app/FixNFTokenPageLinks_test.cpp
index f77d924c94..7b13fc060b 100644
--- a/src/test/app/FixNFTokenPageLinks_test.cpp
+++ b/src/test/app/FixNFTokenPageLinks_test.cpp
@@ -173,6 +173,13 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
             tx.removeMember(sfOwner.jsonName);
             env(tx, Fee(linkFixFee), Ter(temINVALID));
         }
+        {
+            // NFTokenPageLink fixes require sfOwner and reject fields that
+            // belong to other LedgerStateFix types.
+            json::Value tx = ledgerStateFix::nftPageLinks(alice, alice);
+            tx[sfBookDirectory.jsonName] = to_string(uint256{1});
+            env(tx, Fee(linkFixFee), Ter(temINVALID));
+        }
         {
             // Invalid LedgerFixType codes.
             json::Value tx = ledgerStateFix::nftPageLinks(alice, alice);
@@ -260,7 +267,7 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
 
         // Get the index of the middle page.
         uint256 const aliceMiddleNFTokenPageIndex = [&env, &alice]() {
-            auto lastNFTokenPage = env.le(keylet::nftpageMax(alice));
+            auto lastNFTokenPage = env.le(keylet::nftokenPageMax(alice));
             return lastNFTokenPage->at(sfPreviousPageMin);
         }();
 
@@ -283,12 +290,12 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         // Removing the last token from the last page deletes the last
         // page.  This is a bug.  The contents of the next-to-last page
         // should have been moved into the last page.
-        BEAST_EXPECT(!env.le(keylet::nftpageMax(alice)));
+        BEAST_EXPECT(!env.le(keylet::nftokenPageMax(alice)));
 
         // alice's "middle" page is still present, but has no links.
         {
-            auto aliceMiddleNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(alice), aliceMiddleNFTokenPageIndex));
+            auto aliceMiddleNFTokenPage = env.le(
+                keylet::nftokenPage(keylet::nftokenPageMin(alice), aliceMiddleNFTokenPageIndex));
             if (!BEAST_EXPECT(aliceMiddleNFTokenPage))
                 return;
 
@@ -308,7 +315,7 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
 
         // Get the index of the middle page.
         uint256 const bobMiddleNFTokenPageIndex = [&env, &bob]() {
-            auto lastNFTokenPage = env.le(keylet::nftpageMax(bob));
+            auto lastNFTokenPage = env.le(keylet::nftokenPageMax(bob));
             return lastNFTokenPage->at(sfPreviousPageMin);
         }();
 
@@ -325,13 +332,13 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         // Removing the last token from the last page deletes the last
         // page.  This is a bug.  The contents of the next-to-last page
         // should have been moved into the last page.
-        BEAST_EXPECT(!env.le(keylet::nftpageMax(bob)));
+        BEAST_EXPECT(!env.le(keylet::nftokenPageMax(bob)));
 
         // bob's "middle" page is still present, but has lost the
         // NextPageMin field.
         {
             auto bobMiddleNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(bob), bobMiddleNFTokenPageIndex));
+                env.le(keylet::nftokenPage(keylet::nftokenPageMin(bob), bobMiddleNFTokenPageIndex));
             if (!BEAST_EXPECT(bobMiddleNFTokenPage))
                 return;
 
@@ -351,7 +358,7 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
 
         // Get the index of the middle page.
         uint256 const carolMiddleNFTokenPageIndex = [&env, &carol]() {
-            auto lastNFTokenPage = env.le(keylet::nftpageMax(carol));
+            auto lastNFTokenPage = env.le(keylet::nftokenPageMax(carol));
             return lastNFTokenPage->at(sfPreviousPageMin);
         }();
 
@@ -360,7 +367,7 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         dariaNFTs.reserve(32);
         for (int i = 0; i < 32; ++i)
         {
-            uint256 const offerIndex = keylet::nftoffer(carol, env.seq(carol)).key;
+            uint256 const offerIndex = keylet::nftokenOffer(carol, env.seq(carol)).key;
             env(token::createOffer(carol, carolNFTs.back(), XRP(0)), Txflags(tfSellNFToken));
             env.close();
 
@@ -376,12 +383,12 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         // Removing the last token from the last page deletes the last
         // page.  This is a bug.  The contents of the next-to-last page
         // should have been moved into the last page.
-        BEAST_EXPECT(!env.le(keylet::nftpageMax(carol)));
+        BEAST_EXPECT(!env.le(keylet::nftokenPageMax(carol)));
 
         // carol's "middle" page is still present, but has lost the
         // NextPageMin field.
         auto carolMiddleNFTokenPage =
-            env.le(keylet::nftpage(keylet::nftpageMin(carol), carolMiddleNFTokenPageIndex));
+            env.le(keylet::nftokenPage(keylet::nftokenPageMin(carol), carolMiddleNFTokenPageIndex));
         if (!BEAST_EXPECT(carolMiddleNFTokenPage))
             return;
 
@@ -394,7 +401,7 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         // back from daria.
         for (uint256 const& nft : dariaNFTs)
         {
-            uint256 const offerIndex = keylet::nftoffer(carol, env.seq(carol)).key;
+            uint256 const offerIndex = keylet::nftokenOffer(carol, env.seq(carol)).key;
             env(token::createOffer(carol, nft, drops(1)), token::Owner(daria));
             env.close();
 
@@ -411,8 +418,8 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
 
         // carol's "middle" page is present and still has no NextPageMin field.
         {
-            auto carolMiddleNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(carol), carolMiddleNFTokenPageIndex));
+            auto carolMiddleNFTokenPage = env.le(
+                keylet::nftokenPage(keylet::nftokenPageMin(carol), carolMiddleNFTokenPageIndex));
             if (!BEAST_EXPECT(carolMiddleNFTokenPage))
                 return;
 
@@ -421,7 +428,7 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         }
         // carol has a "last" page again, but it has no PreviousPageMin field.
         {
-            auto carolLastNFTokenPage = env.le(keylet::nftpageMax(carol));
+            auto carolLastNFTokenPage = env.le(keylet::nftokenPageMax(carol));
 
             BEAST_EXPECT(!carolLastNFTokenPage->isFieldPresent(sfPreviousPageMin));
             BEAST_EXPECT(!carolLastNFTokenPage->isFieldPresent(sfNextPageMin));
@@ -449,12 +456,12 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         // Verify that alice's NFToken directory is still damaged.
 
         // alice's last page should still be missing.
-        BEAST_EXPECT(!env.le(keylet::nftpageMax(alice)));
+        BEAST_EXPECT(!env.le(keylet::nftokenPageMax(alice)));
 
         // alice's "middle" page is still present and has no links.
         {
-            auto aliceMiddleNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(alice), aliceMiddleNFTokenPageIndex));
+            auto aliceMiddleNFTokenPage = env.le(
+                keylet::nftokenPage(keylet::nftokenPageMin(alice), aliceMiddleNFTokenPageIndex));
             if (!BEAST_EXPECT(aliceMiddleNFTokenPage))
                 return;
 
@@ -473,7 +480,7 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
 
         // alice's last page should now be present and include no links.
         {
-            auto aliceLastNFTokenPage = env.le(keylet::nftpageMax(alice));
+            auto aliceLastNFTokenPage = env.le(keylet::nftokenPageMax(alice));
             if (!BEAST_EXPECT(aliceLastNFTokenPage))
                 return;
 
@@ -482,8 +489,8 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         }
 
         // alice's middle page should be gone.
-        BEAST_EXPECT(
-            !env.le(keylet::nftpage(keylet::nftpageMin(alice), aliceMiddleNFTokenPageIndex)));
+        BEAST_EXPECT(!env.le(
+            keylet::nftokenPage(keylet::nftokenPageMin(alice), aliceMiddleNFTokenPageIndex)));
 
         BEAST_EXPECT(nftCount(env, alice) == 32);
         BEAST_EXPECT(ownerCount(env, alice) == 1);
@@ -495,12 +502,12 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         // Verify that bob's NFToken directory is still damaged.
 
         // bob's last page should still be missing.
-        BEAST_EXPECT(!env.le(keylet::nftpageMax(bob)));
+        BEAST_EXPECT(!env.le(keylet::nftokenPageMax(bob)));
 
         // bob's "middle" page is still present and missing NextPageMin.
         {
             auto bobMiddleNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(bob), bobMiddleNFTokenPageIndex));
+                env.le(keylet::nftokenPage(keylet::nftokenPageMin(bob), bobMiddleNFTokenPageIndex));
             if (!BEAST_EXPECT(bobMiddleNFTokenPage))
                 return;
 
@@ -515,7 +522,7 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         // bob's last page should now be present and include a previous
         // link but no next link.
         {
-            auto const lastPageKeylet = keylet::nftpageMax(bob);
+            auto const lastPageKeylet = keylet::nftokenPageMax(bob);
             auto const bobLastNFTokenPage = env.le(lastPageKeylet);
             if (!BEAST_EXPECT(bobLastNFTokenPage))
                 return;
@@ -525,8 +532,8 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
             BEAST_EXPECT(!bobLastNFTokenPage->isFieldPresent(sfNextPageMin));
 
             auto const bobNewFirstNFTokenPage = env.le(
-                keylet::nftpage(
-                    keylet::nftpageMin(bob), bobLastNFTokenPage->at(sfPreviousPageMin)));
+                keylet::nftokenPage(
+                    keylet::nftokenPageMin(bob), bobLastNFTokenPage->at(sfPreviousPageMin)));
             if (!BEAST_EXPECT(bobNewFirstNFTokenPage))
                 return;
 
@@ -537,7 +544,8 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         }
 
         // bob's middle page should be gone.
-        BEAST_EXPECT(!env.le(keylet::nftpage(keylet::nftpageMin(bob), bobMiddleNFTokenPageIndex)));
+        BEAST_EXPECT(
+            !env.le(keylet::nftokenPage(keylet::nftokenPageMin(bob), bobMiddleNFTokenPageIndex)));
 
         BEAST_EXPECT(nftCount(env, bob) == 64);
         BEAST_EXPECT(ownerCount(env, bob) == 2);
@@ -550,17 +558,16 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
 
         // carol's "middle" page is present and has no NextPageMin field.
         {
-            auto carolMiddleNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(carol), carolMiddleNFTokenPageIndex));
+            auto carolMiddleNFTokenPage = env.le(
+                keylet::nftokenPage(keylet::nftokenPageMin(carol), carolMiddleNFTokenPageIndex));
             if (!BEAST_EXPECT(carolMiddleNFTokenPage))
                 return;
-
             BEAST_EXPECT(carolMiddleNFTokenPage->isFieldPresent(sfPreviousPageMin));
             BEAST_EXPECT(!carolMiddleNFTokenPage->isFieldPresent(sfNextPageMin));
         }
         // carol has a "last" page, but it has no PreviousPageMin field.
         {
-            auto carolLastNFTokenPage = env.le(keylet::nftpageMax(carol));
+            auto carolLastNFTokenPage = env.le(keylet::nftokenPageMax(carol));
 
             BEAST_EXPECT(!carolLastNFTokenPage->isFieldPresent(sfPreviousPageMin));
             BEAST_EXPECT(!carolLastNFTokenPage->isFieldPresent(sfNextPageMin));
@@ -572,9 +579,9 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
 
         {
             // carol's "middle" page is present and now has a NextPageMin field.
-            auto const lastPageKeylet = keylet::nftpageMax(carol);
-            auto carolMiddleNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(carol), carolMiddleNFTokenPageIndex));
+            auto const lastPageKeylet = keylet::nftokenPageMax(carol);
+            auto carolMiddleNFTokenPage = env.le(
+                keylet::nftokenPage(keylet::nftokenPageMin(carol), carolMiddleNFTokenPageIndex));
             if (!BEAST_EXPECT(carolMiddleNFTokenPage))
                 return;
 
@@ -595,8 +602,8 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
 
             // carol also has a "first" page that includes a NextPageMin field.
             auto carolFirstNFTokenPage = env.le(
-                keylet::nftpage(
-                    keylet::nftpageMin(carol), carolMiddleNFTokenPage->at(sfPreviousPageMin)));
+                keylet::nftokenPage(
+                    keylet::nftokenPageMin(carol), carolMiddleNFTokenPage->at(sfPreviousPageMin)));
             if (!BEAST_EXPECT(carolFirstNFTokenPage))
                 return;
 
diff --git a/src/test/app/FlowMPT_test.cpp b/src/test/app/FlowMPT_test.cpp
index e1d6f512d2..2e224a7eb1 100644
--- a/src/test/app/FlowMPT_test.cpp
+++ b/src/test/app/FlowMPT_test.cpp
@@ -37,7 +37,6 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -433,7 +432,7 @@ struct FlowMPT_test : public beast::unit_test::Suite
                     env.require(Balance(bob, eur(999)));
 
                     // Show that bob's USD offer is now a blocker.
-                    std::shared_ptr const usdOffer = env.le(bobUsdOffer);
+                    SLE::const_pointer const usdOffer = env.le(bobUsdOffer);
                     if (BEAST_EXPECT(usdOffer))
                     {
                         std::uint64_t const bookRate = [&usdOffer]() {
@@ -730,11 +729,11 @@ struct FlowMPT_test : public beast::unit_test::Suite
     }
 
     // Helper function that returns the Offers on an account.
-    static std::vector>
+    static std::vector
     offersOnAccount(jtx::Env& env, jtx::Account account)
     {
-        std::vector> result;
-        forEachItem(*env.current(), account, [&result](std::shared_ptr const& sle) {
+        std::vector result;
+        forEachItem(*env.current(), account, [&result](SLE::const_ref sle) {
             if (sle->getType() == ltOFFER)
                 result.push_back(sle);
         });
@@ -1792,7 +1791,7 @@ struct FlowMPT_test : public beast::unit_test::Suite
                 // but OutstandingAmount is 300USD because gw's sell offer is balanced out by
                 // gw's buy offer.
                 //*maxAmt sendMax limitEUR expectEUR outstandingUSD edBuy danBuy bobSell gwXRP offersGw lastGw
-                {  400,   400,    400,     400,      300,           100,  100,   100,    1100, 0,       false},
+                {  .maxAmt=400,   .sendMax=400,    .dstTrustLimit=400,     .dstExpectEUR=400,      .outstandingUSD=300,           .expEdBuyUSD=100,  .expDanBuyUSD=100,   .expBobSellUSD=100,    .expGwXRP=1100, .expOffersGw=0,       .lastGwBuyUSD=false},
                 // Sell USD: alice, carol, bob, gw are consumed.
                 // Buy USD: john, gw, dan, ed (partially) are consumed.
                 // gw's sell USD is partially consumed because there is available balance (50USD).
@@ -1801,32 +1800,32 @@ struct FlowMPT_test : public beast::unit_test::Suite
                 // gw's offer is removed from the order book because it's partially consumed and
                 // the remaining offer is unfunded.
                 //*maxAmt sendMax limitEUR expectEUR outstandingUSD edBuy danBuy bobSell gwXRP offersGw lastGw
-                {  350,   400,    400,     350,      250,           50,   100,   100,    1050, 0,       false},
+                {  .maxAmt=350,   .sendMax=400,    .dstTrustLimit=400,     .dstExpectEUR=350,      .outstandingUSD=250,           .expEdBuyUSD=50,   .expDanBuyUSD=100,   .expBobSellUSD=100,    .expGwXRP=1050, .expOffersGw=0,       .lastGwBuyUSD=false},
                 // Sell USD: alice, carol, bob are consumed; gw's is unfunded
                 //   since OutstandingAmount is initially at MaximumAmount.
                 // Buy USD: john, gw, dan are consumed; ed's remains on the order
                 //   book since 300USD is the sell limit.
                 //*maxAmt sendMax limitEUR expectEUR outstandingUSD edBuy danBuy bobSell gwXRP offersGw lastGw
-                {  300,   400,    400,     300,      200,           0,    100,   100,    1000, 0,       false},
+                {  .maxAmt=300,   .sendMax=400,    .dstTrustLimit=400,     .dstExpectEUR=300,      .outstandingUSD=200,           .expEdBuyUSD=0,    .expDanBuyUSD=100,   .expBobSellUSD=100,    .expGwXRP=1000, .expOffersGw=0,       .lastGwBuyUSD=false},
                 // Same as above. bill's trustline limit sets the output to 300USD.
                 //*maxAmt sendMax limitEUR expectEUR outstandingUSD edBuy danBuy bobSell gwXRP offersGw lastGw
-                {  300,   400,    300,     300,      200,           0,    100,   100,    1000, 0,       false},
+                {  .maxAmt=300,   .sendMax=400,    .dstTrustLimit=300,     .dstExpectEUR=300,      .outstandingUSD=200,           .expEdBuyUSD=0,    .expDanBuyUSD=100,   .expBobSellUSD=100,    .expGwXRP=1000, .expOffersGw=0,       .lastGwBuyUSD=false},
                 // Sell USD: alice, carol, bob are consumed; gw's removed from
                 //   the order book since it's unfunded.
                 // Buy USD: john, gw, dan are consumed; ed's  remains on the order
                 //   book since 300USD is the limit.
                 //*maxAmt sendMax limitEUR expectEUR outstandingUSD edBuy danBuy bobSell gwXRP offersGw lastGw
-                {  300,   400,    300,     300,      200,           0,    100,   100,    1000, 0,       true},
+                {  .maxAmt=300,   .sendMax=400,    .dstTrustLimit=300,     .dstExpectEUR=300,      .outstandingUSD=200,           .expEdBuyUSD=0,    .expDanBuyUSD=100,   .expBobSellUSD=100,    .expGwXRP=1000, .expOffersGw=0,       .lastGwBuyUSD=true},
                 // Sell USD: alice, carol are consumed; gw's removed from
                 //   the order book in rev pass since it's unfunded; bob's
                 //   remains on the order book.
                 // Buy USD: john, gw; ed's, dan's  remains on the order
                 //   book since 300USD is the limit.
                 //*maxAmt sendMax limitEUR expectEUR outstandingUSD edBuy danBuy bobSell gwXRP offersGw lastGw
-                {  300,   200,    300,     200,      200,           0,    0,     0,      1000, 0,       false},
+                {  .maxAmt=300,   .sendMax=200,    .dstTrustLimit=300,     .dstExpectEUR=200,      .outstandingUSD=200,           .expEdBuyUSD=0,    .expDanBuyUSD=0,     .expBobSellUSD=0,      .expGwXRP=1000, .expOffersGw=0,       .lastGwBuyUSD=false},
                 // Same as three tests above since limited by buy 300USD (gw offer is unfunded)
                 //*maxAmt sendMax limitEUR expectEUR outstandingUSD edBuy danBuy bobSell gwXRP offersGw lastGw
-                {  300,   380,    400,     300,      200,           0,    100,   100,    1000, 0,       false},
+                {  .maxAmt=300,   .sendMax=380,    .dstTrustLimit=400,     .dstExpectEUR=300,      .outstandingUSD=200,           .expEdBuyUSD=0,    .expDanBuyUSD=100,   .expBobSellUSD=100,    .expGwXRP=1000, .expOffersGw=0,       .lastGwBuyUSD=false},
             };
             // clang-format on
             for (auto const& t : tests)
@@ -1912,26 +1911,26 @@ struct FlowMPT_test : public beast::unit_test::Suite
                 // Gw gets 300USD from alice; carol and bob buy 200USD,
                 // therefore OutstandingAmount is 200.
                 //*maxAmt sendMax gwOffer dstXRP outstandingUSD bobBuy gwXRP offersGw lastGw
-                { 300,    300,    100,    1300,  200,           100,   900,  0,       false},
+                { .maxAmt=300,    .sendMax=300,    .gwOffer=100,    .dstExpectXRP=1300,  .outstandingUSD=200,           .expBobBuyUSD=100,   .expGwXRP=900,  .expOffersGw=0,       .lastGwBuyUSD=false},
                 // Same as above. Gw offer location in the order book doesn't matter
                 //*maxAmt sendMax gwOffer dstXRP outstandingUSD bobBuy gwXRP offersGw lastGw
-                { 300,    300,    100,    1300,  200,           100,   900,  0,       true},
+                { .maxAmt=300,    .sendMax=300,    .gwOffer=100,    .dstExpectXRP=1300,  .outstandingUSD=200,           .expBobBuyUSD=100,   .expGwXRP=900,  .expOffersGw=0,       .lastGwBuyUSD=true},
                 // Buy USD: carol, gw are consumed. bob's offer remains on the order book.
                 // Gw gets 300USD from alice; carol buys 100USD,
                 // therefore OutstandingAmount is 100.
                 //*maxAmt sendMax gwOffer dstXRP outstandingUSD bobBuy gwXRP offersGw lastGw
-                { 300,    300,    200,    1300,  100,           0,     800,  0,       false},
+                { .maxAmt=300,    .sendMax=300,    .gwOffer=200,    .dstExpectXRP=1300,  .outstandingUSD=100,           .expBobBuyUSD=0,     .expGwXRP=800,  .expOffersGw=0,       .lastGwBuyUSD=false},
                 // Buy USD: carol, bob are consumed; gw's is partially consumed (100/100) since it's last.
                 // Gw gets 300USD from alice; carol and bob buy 200USD,
                 // therefore OutstandingAmount is 200.
                 //*maxAmt sendMax gwOffer dstXRP outstandingUSD bobBuy gwXRP offersGw lastGw
-                { 300,    300,    200,    1300,  200,           100,   900,  1,       true},
+                { .maxAmt=300,    .sendMax=300,    .gwOffer=200,    .dstExpectXRP=1300,  .outstandingUSD=200,           .expBobBuyUSD=100,   .expGwXRP=900,  .expOffersGw=1,       .lastGwBuyUSD=true},
                 // Buy USD: carol, bob are consumed; gw's is partially consumed (50/50) since it's last
                 // and sendMax limits the output.
                 // Gw gets 250USD from alice; carol and bob buy 200USD, alice has 50USD left,
                 // therefore OutstandingAmount is 200.
                 //*maxAmt sendMax gwOffer dstXRP outstandingUSD bobBuy gwXRP offersGw lastGw
-                { 300,    250,    200,    1250,  250,           100,   950,  1,       true},
+                { .maxAmt=300,    .sendMax=250,    .gwOffer=200,    .dstExpectXRP=1250,  .outstandingUSD=250,           .expBobBuyUSD=100,   .expGwXRP=950,  .expOffersGw=1,       .lastGwBuyUSD=true},
             };
             // clang-format on
             for (auto const& t : tests)
@@ -2024,10 +2023,10 @@ struct FlowMPT_test : public beast::unit_test::Suite
                 // Sell USD: carol, gw, bob are consumed.
                 // ed buys 300USD from carol, gw, bob therefore OutstandingAmount is 300.
                 //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw
-                { 300,    300,    0,      100,    300,   300,           700,     100,    1100, 0,       false},
+                { .maxAmt=300,    .sendMax=300,    .initDst=0,      .gwOffer=100,    .dstExpectUSD=300,   .outstandingUSD=300,           .expAliceXRP=700,     .expBobSellUSD=100,    .expGwXRP=1100, .expOffersGw=0,       .lastGwBuyUSD=false},
                 // Same as above. Gw offer location in the order book doesn't matter
                 //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw
-                { 300,    300,    0,      100,    300,   300,           700,     100,    1100, 0,       true},
+                { .maxAmt=300,    .sendMax=300,    .initDst=0,      .gwOffer=100,    .dstExpectUSD=300,   .outstandingUSD=300,           .expAliceXRP=700,     .expBobSellUSD=100,    .expGwXRP=1100, .expOffersGw=0,       .lastGwBuyUSD=true},
                 // Sell USD: carol, bob are consumed, gw is partially consumed.
                 // ed buys 200 from carol and bob and 50 from gw because gw can only issue 50
                 // (300(max) - 200(carol+bob) - 50(ed)). ed buys 250 from carol, gw, bob and has 50 initially,
@@ -2035,33 +2034,33 @@ struct FlowMPT_test : public beast::unit_test::Suite
                 // gw's offer is removed from the order book because it's partially consumed and the remaining
                 // offer is unfunded.
                 //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw
-                { 300,    300,    50,     100,    300,   300,           750,     100,    1050, 0,       false},
+                { .maxAmt=300,    .sendMax=300,    .initDst=50,     .gwOffer=100,    .dstExpectUSD=300,   .outstandingUSD=300,           .expAliceXRP=750,     .expBobSellUSD=100,    .expGwXRP=1050, .expOffersGw=0,       .lastGwBuyUSD=false},
                 // Same as above. Gw offer location in the order book doesn't matter.
                 //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw
-                { 300,    300,    50,     100,    300,   300,           750,     100,    1050, 0,       true},
+                { .maxAmt=300,    .sendMax=300,    .initDst=50,     .gwOffer=100,    .dstExpectUSD=300,   .outstandingUSD=300,           .expAliceXRP=750,     .expBobSellUSD=100,    .expGwXRP=1050, .expOffersGw=0,       .lastGwBuyUSD=true},
                 // Same as above. Gw offer size doesn't matter.
                 //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw
-                { 300,    300,    50,     200,    300,   300,           750,     100,    1050, 0,       true},
+                { .maxAmt=300,    .sendMax=300,    .initDst=50,     .gwOffer=200,    .dstExpectUSD=300,   .outstandingUSD=300,           .expAliceXRP=750,     .expBobSellUSD=100,    .expGwXRP=1050, .expOffersGw=0,       .lastGwBuyUSD=true},
                 // Sell USD: carol, gw are consumed, bob is partially consumed.
                 // ed buys 200 from carol and gw and 50 form bob because of sendMax limit. bob keeps 50,
                 // therefore OutstandingAmount is 300.
                 //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw
-                { 300,    250,    0,      100,    250,   300,           750,     50,     1100, 0,       false},
+                { .maxAmt=300,    .sendMax=250,    .initDst=0,      .gwOffer=100,    .dstExpectUSD=250,   .outstandingUSD=300,           .expAliceXRP=750,     .expBobSellUSD=50,     .expGwXRP=1100, .expOffersGw=0,       .lastGwBuyUSD=false},
                 // Sell USD: carol, bob are consumed, gw is partially consumed because of sendMax limit.
                 // ed buys 200 from carol and bob and 50 from gw. Therefore, OutstandingAmount is 250.
                 // gw's offer remains on the order book because it's partially consumed and has more funds.
                 //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw
-                { 300,    250,    0,      100,    250,   250,           750,     100,    1050, 1,       true},
+                { .maxAmt=300,    .sendMax=250,    .initDst=0,      .gwOffer=100,    .dstExpectUSD=250,   .outstandingUSD=250,           .expAliceXRP=750,     .expBobSellUSD=100,    .expGwXRP=1050, .expOffersGw=1,       .lastGwBuyUSD=true},
                 // Sell USD: carol, bob are consumed, gw is partially consumed because of sendMax limit, also
                 // there is only 50 available to issue. ed buys 200 from carol and bob and 50 from gw, plus
                 // he has initially 50, therefore OutstandingAmount is 300.
                 //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw
-                { 300,    250,    50,     100,    300,   300,           750,     100,    1050, 0,       true},
+                { .maxAmt=300,    .sendMax=250,    .initDst=50,     .gwOffer=100,    .dstExpectUSD=300,   .outstandingUSD=300,           .expAliceXRP=750,     .expBobSellUSD=100,    .expGwXRP=1050, .expOffersGw=0,       .lastGwBuyUSD=true},
                 // Sell USD: carol, bob are consumed, gw is not consumed because there is not available funds
                 // to issue. ed buys 200 from carol and bob and, plus he has initially 100,
                 // therefore OutstandingAmount is 300. gw offer is removed because it's unfunded.
                 //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw
-                { 300,    250,    100,    100,    300,   300,           800,     100,    1000, 0,       true},
+                { .maxAmt=300,    .sendMax=250,    .initDst=100,    .gwOffer=100,    .dstExpectUSD=300,   .outstandingUSD=300,           .expAliceXRP=800,     .expBobSellUSD=100,    .expGwXRP=1000, .expOffersGw=0,       .lastGwBuyUSD=true},
             };
             // clang-format on
             for (auto const& t : tests)
diff --git a/src/test/app/Flow_test.cpp b/src/test/app/Flow_test.cpp
index c6f4cf59d1..72e39ab890 100644
--- a/src/test/app/Flow_test.cpp
+++ b/src/test/app/Flow_test.cpp
@@ -43,7 +43,6 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -58,7 +57,7 @@ getNoRippleFlag(
     jtx::Account const& dst,
     Currency const& cur)
 {
-    if (auto sle = env.le(keylet::line(src, dst, cur)))
+    if (auto sle = env.le(keylet::trustLine(src, dst, cur)))
     {
         auto const flag = (src.id() > dst.id()) ? lsfHighNoRipple : lsfLowNoRipple;
         return sle->isFlag(flag);
@@ -572,7 +571,7 @@ struct Flow_test : public beast::unit_test::Suite
                 env.require(Balance(bob, eur(999)));
 
                 // Show that bob's USD offer is now a blocker.
-                std::shared_ptr const usdOffer = env.le(bobUsdOffer);
+                SLE::const_pointer const usdOffer = env.le(bobUsdOffer);
                 if (BEAST_EXPECT(usdOffer))
                 {
                     std::uint64_t const bookRate = [&usdOffer]() {
@@ -711,11 +710,11 @@ struct Flow_test : public beast::unit_test::Suite
     }
 
     // Helper function that returns the Offers on an account.
-    static std::vector>
+    static std::vector
     offersOnAccount(jtx::Env& env, jtx::Account account)
     {
-        std::vector> result;
-        forEachItem(*env.current(), account, [&result](std::shared_ptr const& sle) {
+        std::vector result;
+        forEachItem(*env.current(), account, [&result](SLE::const_ref sle) {
             if (sle->getType() == ltOFFER)
                 result.push_back(sle);
         });
diff --git a/src/test/app/Freeze_test.cpp b/src/test/app/Freeze_test.cpp
index 2b5ca831da..786f5b4680 100644
--- a/src/test/app/Freeze_test.cpp
+++ b/src/test/app/Freeze_test.cpp
@@ -1788,7 +1788,7 @@ class Freeze_test : public beast::unit_test::Suite
             env(token::mint(a2, 0), Txflags(tfTransferable));
             env.close();
 
-            auto const buyIdx = keylet::nftoffer(a1, env.seq(a1)).key;
+            auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
             env(token::createOffer(a1, nftID, usd(10)), token::Owner(a2));
             env.close();
 
@@ -1874,10 +1874,10 @@ class Freeze_test : public beast::unit_test::Suite
             env(token::mint(a2, 0), Txflags(tfTransferable));
             env.close();
 
-            uint256 const sellIdx = keylet::nftoffer(a2, env.seq(a2)).key;
+            uint256 const sellIdx = keylet::nftokenOffer(a2, env.seq(a2)).key;
             env(token::createOffer(a2, nftID, usd(10)), Txflags(tfSellNFToken));
             env.close();
-            auto const buyIdx = keylet::nftoffer(a1, env.seq(a1)).key;
+            auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
             env(token::createOffer(a1, nftID, usd(11)), token::Owner(a2));
             env.close();
 
@@ -1900,13 +1900,13 @@ class Freeze_test : public beast::unit_test::Suite
             env(token::mint(minter, 0), token::XferFee(1u), Txflags(tfTransferable));
             env.close();
 
-            uint256 const minterSellIdx = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const minterSellIdx = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftID, drops(1)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(a2, minterSellIdx));
             env.close();
 
-            uint256 const sellIdx = keylet::nftoffer(a2, env.seq(a2)).key;
+            uint256 const sellIdx = keylet::nftokenOffer(a2, env.seq(a2)).key;
             env(token::createOffer(a2, nftID, usd(100)), Txflags(tfSellNFToken));
             env.close();
             env(trust(g1, minter["USD"](1000), tfSetFreeze | tfSetDeepFreeze));
@@ -1960,7 +1960,7 @@ class Freeze_test : public beast::unit_test::Suite
         env(token::mint(account, 0), Txflags(tfTransferable));
         env.close();
 
-        uint256 const sellOfferIndex = keylet::nftoffer(account, env.seq(account)).key;
+        uint256 const sellOfferIndex = keylet::nftokenOffer(account, env.seq(account)).key;
         env(token::createOffer(account, nftID, currency), Txflags(tfSellNFToken));
         env.close();
 
diff --git a/src/test/app/GRPCServerTLS_test.cpp b/src/test/app/GRPCServerTLS_test.cpp
index c7156fb3a2..a48986d004 100644
--- a/src/test/app/GRPCServerTLS_test.cpp
+++ b/src/test/app/GRPCServerTLS_test.cpp
@@ -1,9 +1,8 @@
 #include 
 #include 
 
-#include 
-
 #include 
+#include 
 #include 
 #include 
 
@@ -368,7 +367,8 @@ public:
         Env env(*this, std::move(cfg));
 
         // Verify the server actually started by checking the port
-        auto const grpcPort = env.app().config()[SECTION_PORT_GRPC].get("port");
+        auto const grpcPort =
+            env.app().config()[Sections::kPortGrpc].get(Keys::kPort);
         BEAST_EXPECT(grpcPort.has_value());
         // NOLINTBEGIN(bugprone-unchecked-optional-access) grpcPort.has_value() checked above
         BEAST_EXPECT(*grpcPort > 0);
@@ -394,7 +394,8 @@ public:
         Env env(*this, std::move(cfg));
 
         // Verify the server actually started by checking the port
-        auto const grpcPort = env.app().config()[SECTION_PORT_GRPC].get("port");
+        auto const grpcPort =
+            env.app().config()[Sections::kPortGrpc].get(Keys::kPort);
         BEAST_EXPECT(grpcPort.has_value());
         // NOLINTBEGIN(bugprone-unchecked-optional-access) grpcPort.has_value() checked above
         BEAST_EXPECT(*grpcPort > 0);
@@ -431,7 +432,8 @@ public:
         Env env(*this, std::move(cfg));
 
         // Verify the server actually started by checking the port
-        auto const grpcPort = env.app().config()[SECTION_PORT_GRPC].get("port");
+        auto const grpcPort =
+            env.app().config()[Sections::kPortGrpc].get(Keys::kPort);
         BEAST_EXPECT(grpcPort.has_value());
         // NOLINTBEGIN(bugprone-unchecked-optional-access) grpcPort.has_value() checked above
         BEAST_EXPECT(*grpcPort > 0);
@@ -465,9 +467,9 @@ public:
 
         // Create config with only cert (missing key)
         auto cfg = envconfig();
-        (*cfg)[SECTION_PORT_GRPC].set("ip", "127.0.0.1");
-        (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_cert", getServerCertPath().string());
+        (*cfg)[Sections::kPortGrpc].set(Keys::kIp, "127.0.0.1");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslCert, getServerCertPath().string());
         // Intentionally omit ssl_key
 
         try
@@ -477,8 +479,7 @@ public:
         }
         catch (std::runtime_error const& e)
         {
-            BEAST_EXPECT(
-                std::string(e.what()).find("Incomplete TLS configuration") != std::string::npos);
+            BEAST_EXPECT(std::string(e.what()).contains("Incomplete TLS configuration"));
         }
     }
 
@@ -491,9 +492,9 @@ public:
 
         // Create config with only key (missing cert)
         auto cfg = envconfig();
-        (*cfg)[SECTION_PORT_GRPC].set("ip", "127.0.0.1");
-        (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_key", getServerKeyPath().string());
+        (*cfg)[Sections::kPortGrpc].set(Keys::kIp, "127.0.0.1");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslKey, getServerKeyPath().string());
         // Intentionally omit ssl_cert
 
         try
@@ -503,8 +504,7 @@ public:
         }
         catch (std::runtime_error const& e)
         {
-            BEAST_EXPECT(
-                std::string(e.what()).find("Incomplete TLS configuration") != std::string::npos);
+            BEAST_EXPECT(std::string(e.what()).contains("Incomplete TLS configuration"));
         }
     }
 
@@ -518,9 +518,9 @@ public:
         // Test 1: ssl_client_ca specified without any TLS config
         {
             auto cfg = envconfig();
-            (*cfg)[SECTION_PORT_GRPC].set("ip", "127.0.0.1");
-            (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-            (*cfg)[SECTION_PORT_GRPC].set("ssl_client_ca", getCACertPath().string());
+            (*cfg)[Sections::kPortGrpc].set(Keys::kIp, "127.0.0.1");
+            (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+            (*cfg)[Sections::kPortGrpc].set(Keys::kSslClientCa, getCACertPath().string());
             // Intentionally omit both ssl_cert and ssl_key
 
             try
@@ -531,18 +531,18 @@ public:
             catch (std::runtime_error const& e)
             {
                 BEAST_EXPECT(
-                    std::string(e.what()).find(
-                        "ssl_client_ca requires both ssl_cert and ssl_key") != std::string::npos);
+                    std::string(e.what()).contains(
+                        "ssl_client_ca requires both ssl_cert and ssl_key"));
             }
         }
 
         // Test 2: ssl_client_ca with only ssl_cert (missing ssl_key)
         {
             auto cfg = envconfig();
-            (*cfg)[SECTION_PORT_GRPC].set("ip", "127.0.0.1");
-            (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-            (*cfg)[SECTION_PORT_GRPC].set("ssl_cert", getServerCertPath().string());
-            (*cfg)[SECTION_PORT_GRPC].set("ssl_client_ca", getCACertPath().string());
+            (*cfg)[Sections::kPortGrpc].set(Keys::kIp, "127.0.0.1");
+            (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+            (*cfg)[Sections::kPortGrpc].set(Keys::kSslCert, getServerCertPath().string());
+            (*cfg)[Sections::kPortGrpc].set(Keys::kSslClientCa, getCACertPath().string());
             // Intentionally omit ssl_key
 
             try
@@ -554,19 +554,17 @@ public:
             {
                 // This should fail with "Incomplete TLS configuration" first
                 // because ssl_cert is specified without ssl_key
-                BEAST_EXPECT(
-                    std::string(e.what()).find("Incomplete TLS configuration") !=
-                    std::string::npos);
+                BEAST_EXPECT(std::string(e.what()).contains("Incomplete TLS configuration"));
             }
         }
 
         // Test 3: ssl_client_ca with only ssl_key (missing ssl_cert)
         {
             auto cfg = envconfig();
-            (*cfg)[SECTION_PORT_GRPC].set("ip", "127.0.0.1");
-            (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-            (*cfg)[SECTION_PORT_GRPC].set("ssl_key", getServerKeyPath().string());
-            (*cfg)[SECTION_PORT_GRPC].set("ssl_client_ca", getCACertPath().string());
+            (*cfg)[Sections::kPortGrpc].set(Keys::kIp, "127.0.0.1");
+            (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+            (*cfg)[Sections::kPortGrpc].set(Keys::kSslKey, getServerKeyPath().string());
+            (*cfg)[Sections::kPortGrpc].set(Keys::kSslClientCa, getCACertPath().string());
             // Intentionally omit ssl_cert
 
             try
@@ -578,9 +576,7 @@ public:
             {
                 // This should fail with "Incomplete TLS configuration" first
                 // because ssl_key is specified without ssl_cert
-                BEAST_EXPECT(
-                    std::string(e.what()).find("Incomplete TLS configuration") !=
-                    std::string::npos);
+                BEAST_EXPECT(std::string(e.what()).contains("Incomplete TLS configuration"));
             }
         }
     }
@@ -595,9 +591,9 @@ public:
         // Test 1: ssl_cert_chain specified without any TLS config
         {
             auto cfg = envconfig();
-            (*cfg)[SECTION_PORT_GRPC].set("ip", "127.0.0.1");
-            (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-            (*cfg)[SECTION_PORT_GRPC].set("ssl_cert_chain", getCACertPath().string());
+            (*cfg)[Sections::kPortGrpc].set(Keys::kIp, "127.0.0.1");
+            (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+            (*cfg)[Sections::kPortGrpc].set(Keys::kSslCertChain, getCACertPath().string());
             // Intentionally omit both ssl_cert and ssl_key
 
             try
@@ -608,18 +604,18 @@ public:
             catch (std::runtime_error const& e)
             {
                 BEAST_EXPECT(
-                    std::string(e.what()).find(
-                        "ssl_cert_chain requires both ssl_cert and ssl_key") != std::string::npos);
+                    std::string(e.what()).contains(
+                        "ssl_cert_chain requires both ssl_cert and ssl_key"));
             }
         }
 
         // Test 2: ssl_cert_chain with only ssl_cert (missing ssl_key)
         {
             auto cfg = envconfig();
-            (*cfg)[SECTION_PORT_GRPC].set("ip", "127.0.0.1");
-            (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-            (*cfg)[SECTION_PORT_GRPC].set("ssl_cert", getServerCertPath().string());
-            (*cfg)[SECTION_PORT_GRPC].set("ssl_cert_chain", getCACertPath().string());
+            (*cfg)[Sections::kPortGrpc].set(Keys::kIp, "127.0.0.1");
+            (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+            (*cfg)[Sections::kPortGrpc].set(Keys::kSslCert, getServerCertPath().string());
+            (*cfg)[Sections::kPortGrpc].set(Keys::kSslCertChain, getCACertPath().string());
             // Intentionally omit ssl_key
 
             try
@@ -631,9 +627,7 @@ public:
             {
                 // This should fail with "Incomplete TLS configuration" first
                 // because ssl_cert is specified without ssl_key
-                BEAST_EXPECT(
-                    std::string(e.what()).find("Incomplete TLS configuration") !=
-                    std::string::npos);
+                BEAST_EXPECT(std::string(e.what()).contains("Incomplete TLS configuration"));
             }
         }
     }
@@ -655,7 +649,8 @@ public:
         Env env(*this, std::move(cfg));
 
         // Verify the server actually started by checking the port
-        auto const grpcPort = env.app().config()[SECTION_PORT_GRPC].get("port");
+        auto const grpcPort =
+            env.app().config()[Sections::kPortGrpc].get(Keys::kPort);
         BEAST_EXPECT(grpcPort.has_value());
         // NOLINTBEGIN(bugprone-unchecked-optional-access) grpcPort.has_value() checked above
         BEAST_EXPECT(*grpcPort > 0);
@@ -684,15 +679,16 @@ public:
         using namespace jtx;
 
         auto cfg = envconfig();
-        (*cfg)[SECTION_PORT_GRPC].set("ip", "127.0.0.1");
-        (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_cert", "/nonexistent/path/to/cert.pem");
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_key", getServerKeyPath().string());
+        (*cfg)[Sections::kPortGrpc].set(Keys::kIp, "127.0.0.1");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslCert, "/nonexistent/path/to/cert.pem");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslKey, getServerKeyPath().string());
 
         Env env(*this, std::move(cfg));
 
         // Server should fail to start - verify port is 0
-        auto const grpcPort = env.app().config()[SECTION_PORT_GRPC].get("port");
+        auto const grpcPort =
+            env.app().config()[Sections::kPortGrpc].get(Keys::kPort);
         BEAST_EXPECT(grpcPort.has_value());
         BEAST_EXPECT(*grpcPort == 0);  // NOLINT(bugprone-unchecked-optional-access)
     }
@@ -705,15 +701,16 @@ public:
         using namespace jtx;
 
         auto cfg = envconfig();
-        (*cfg)[SECTION_PORT_GRPC].set("ip", "127.0.0.1");
-        (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_cert", getServerCertPath().string());
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_key", "/nonexistent/path/to/key.pem");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kIp, "127.0.0.1");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslCert, getServerCertPath().string());
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslKey, "/nonexistent/path/to/key.pem");
 
         Env env(*this, std::move(cfg));
 
         // Server should fail to start - verify port is 0
-        auto const grpcPort = env.app().config()[SECTION_PORT_GRPC].get("port");
+        auto const grpcPort =
+            env.app().config()[Sections::kPortGrpc].get(Keys::kPort);
         BEAST_EXPECT(grpcPort.has_value());
         BEAST_EXPECT(*grpcPort == 0);  // NOLINT(bugprone-unchecked-optional-access)
     }
@@ -726,16 +723,17 @@ public:
         using namespace jtx;
 
         auto cfg = envconfig();
-        (*cfg)[SECTION_PORT_GRPC].set("ip", "127.0.0.1");
-        (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_cert", getServerCertPath().string());
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_key", getServerKeyPath().string());
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_cert_chain", "/nonexistent/path/to/chain.pem");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kIp, "127.0.0.1");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslCert, getServerCertPath().string());
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslKey, getServerKeyPath().string());
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslCertChain, "/nonexistent/path/to/chain.pem");
 
         Env env(*this, std::move(cfg));
 
         // Server should fail to start - verify port is 0
-        auto const grpcPort = env.app().config()[SECTION_PORT_GRPC].get("port");
+        auto const grpcPort =
+            env.app().config()[Sections::kPortGrpc].get(Keys::kPort);
         BEAST_EXPECT(grpcPort.has_value());
         BEAST_EXPECT(*grpcPort == 0);  // NOLINT(bugprone-unchecked-optional-access)
     }
@@ -748,16 +746,17 @@ public:
         using namespace jtx;
 
         auto cfg = envconfig();
-        (*cfg)[SECTION_PORT_GRPC].set("ip", "127.0.0.1");
-        (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_cert", getServerCertPath().string());
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_key", getServerKeyPath().string());
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_client_ca", "/nonexistent/path/to/ca.pem");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kIp, "127.0.0.1");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslCert, getServerCertPath().string());
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslKey, getServerKeyPath().string());
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslClientCa, "/nonexistent/path/to/ca.pem");
 
         Env env(*this, std::move(cfg));
 
         // Server should fail to start - verify port is 0
-        auto const grpcPort = env.app().config()[SECTION_PORT_GRPC].get("port");
+        auto const grpcPort =
+            env.app().config()[Sections::kPortGrpc].get(Keys::kPort);
         BEAST_EXPECT(grpcPort.has_value());
         BEAST_EXPECT(*grpcPort == 0);  // NOLINT(bugprone-unchecked-optional-access)
     }
@@ -775,16 +774,17 @@ public:
         emptyFile.close();
 
         auto cfg = envconfig();
-        (*cfg)[SECTION_PORT_GRPC].set("ip", "127.0.0.1");
-        (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_cert", getServerCertPath().string());
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_key", getServerKeyPath().string());
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_client_ca", emptyCAPath.string());
+        (*cfg)[Sections::kPortGrpc].set(Keys::kIp, "127.0.0.1");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslCert, getServerCertPath().string());
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslKey, getServerKeyPath().string());
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslClientCa, emptyCAPath.string());
 
         Env env(*this, std::move(cfg));
 
         // Server should fail to start due to empty CA file
-        auto const grpcPort = env.app().config()[SECTION_PORT_GRPC].get("port");
+        auto const grpcPort =
+            env.app().config()[Sections::kPortGrpc].get(Keys::kPort);
         BEAST_EXPECT(grpcPort.has_value());
         BEAST_EXPECT(*grpcPort == 0);  // NOLINT(bugprone-unchecked-optional-access)
     }
@@ -798,18 +798,19 @@ public:
 
         // Test with all TLS features enabled: cert, key, cert_chain, and client_ca
         auto cfg = envconfig();
-        (*cfg)[SECTION_PORT_GRPC].set("ip", getEnvLocalhostAddr());
-        (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_cert", getServerCertPath().string());
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_key", getServerKeyPath().string());
-        (*cfg)[SECTION_PORT_GRPC].set(
-            "ssl_cert_chain", getCACertPath().string());  // Using CA as intermediate
-        (*cfg)[SECTION_PORT_GRPC].set("ssl_client_ca", getCACertPath().string());
+        (*cfg)[Sections::kPortGrpc].set(Keys::kIp, getEnvLocalhostAddr());
+        (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslCert, getServerCertPath().string());
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslKey, getServerKeyPath().string());
+        (*cfg)[Sections::kPortGrpc].set(
+            Keys::kSslCertChain, getCACertPath().string());  // Using CA as intermediate
+        (*cfg)[Sections::kPortGrpc].set(Keys::kSslClientCa, getCACertPath().string());
 
         Env env(*this, std::move(cfg));
 
         // Verify the server started successfully
-        auto const grpcPort = env.app().config()[SECTION_PORT_GRPC].get("port");
+        auto const grpcPort =
+            env.app().config()[Sections::kPortGrpc].get(Keys::kPort);
         BEAST_EXPECT(grpcPort.has_value());
         // NOLINTBEGIN(bugprone-unchecked-optional-access) grpcPort.has_value() checked above
         BEAST_EXPECT(*grpcPort > 0);
diff --git a/src/test/app/HashRouter_test.cpp b/src/test/app/HashRouter_test.cpp
index 8f9cae351e..0266da2bc0 100644
--- a/src/test/app/HashRouter_test.cpp
+++ b/src/test/app/HashRouter_test.cpp
@@ -3,6 +3,7 @@
 
 #include 
 #include 
+#include 
 #include 
 
 #include 
@@ -274,9 +275,9 @@ class HashRouter_test : public beast::unit_test::Suite
         {
             Config cfg;
             // non-default
-            auto& h = cfg.section("hashrouter");
-            h.set("hold_time", "600");
-            h.set("relay_time", "15");
+            auto& h = cfg.section(Sections::kHashrouter);
+            h.set(Keys::kHoldTime, "600");
+            h.set(Keys::kRelayTime, "15");
             auto const setup = setupHashRouter(cfg);
             BEAST_EXPECT(setup.holdTime == 600s);
             BEAST_EXPECT(setup.relayTime == 15s);
@@ -284,9 +285,9 @@ class HashRouter_test : public beast::unit_test::Suite
         {
             Config cfg;
             // equal
-            auto& h = cfg.section("hashrouter");
-            h.set("hold_time", "400");
-            h.set("relay_time", "400");
+            auto& h = cfg.section(Sections::kHashrouter);
+            h.set(Keys::kHoldTime, "400");
+            h.set(Keys::kRelayTime, "400");
             auto const setup = setupHashRouter(cfg);
             BEAST_EXPECT(setup.holdTime == 400s);
             BEAST_EXPECT(setup.relayTime == 400s);
@@ -294,9 +295,9 @@ class HashRouter_test : public beast::unit_test::Suite
         {
             Config cfg;
             // wrong order
-            auto& h = cfg.section("hashrouter");
-            h.set("hold_time", "60");
-            h.set("relay_time", "120");
+            auto& h = cfg.section(Sections::kHashrouter);
+            h.set(Keys::kHoldTime, "60");
+            h.set(Keys::kRelayTime, "120");
             try
             {
                 setupHashRouter(cfg);
@@ -313,9 +314,9 @@ class HashRouter_test : public beast::unit_test::Suite
         {
             Config cfg;
             // too small hold
-            auto& h = cfg.section("hashrouter");
-            h.set("hold_time", "10");
-            h.set("relay_time", "120");
+            auto& h = cfg.section(Sections::kHashrouter);
+            h.set(Keys::kHoldTime, "10");
+            h.set(Keys::kRelayTime, "120");
             try
             {
                 setupHashRouter(cfg);
@@ -333,9 +334,9 @@ class HashRouter_test : public beast::unit_test::Suite
         {
             Config cfg;
             // too small relay
-            auto& h = cfg.section("hashrouter");
-            h.set("hold_time", "500");
-            h.set("relay_time", "6");
+            auto& h = cfg.section(Sections::kHashrouter);
+            h.set(Keys::kHoldTime, "500");
+            h.set(Keys::kRelayTime, "6");
             try
             {
                 setupHashRouter(cfg);
@@ -352,9 +353,9 @@ class HashRouter_test : public beast::unit_test::Suite
         {
             Config cfg;
             // garbage
-            auto& h = cfg.section("hashrouter");
-            h.set("hold_time", "alice");
-            h.set("relay_time", "bob");
+            auto& h = cfg.section(Sections::kHashrouter);
+            h.set(Keys::kHoldTime, "alice");
+            h.set(Keys::kRelayTime, "bob");
             auto const setup = setupHashRouter(cfg);
             // The set function ignores values that don't convert, so the
             // defaults are left unchanged
diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp
index 775e18656b..9fde52ecb2 100644
--- a/src/test/app/Invariants_test.cpp
+++ b/src/test/app/Invariants_test.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -24,6 +25,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -50,6 +52,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 
 #include 
@@ -214,7 +218,7 @@ class Invariants_test : public beast::unit_test::Suite
             // std::cerr << messages << '\n';
             for (auto const& m : expectLogs)
             {
-                BEAST_EXPECTS(messages.find(m) != std::string::npos, m);
+                BEAST_EXPECTS(messages.contains(m), m);
             }
         }
     }
@@ -459,7 +463,7 @@ class Invariants_test : public beast::unit_test::Suite
                 BEAST_EXPECT(sle->at(~sfAMMID) == ammKey);
 
                 for (auto const& trustKeylet :
-                     {keylet::line(ammAcctID, a1["USD"]), keylet::line(a1, ammIssue)})
+                     {keylet::trustLine(ammAcctID, a1["USD"]), keylet::trustLine(a1, ammIssue)})
                 {
                     auto const line = ac.view().peek(trustKeylet);
                     if (!line)
@@ -560,7 +564,7 @@ class Invariants_test : public beast::unit_test::Suite
             [](Account const& a1, Account const& a2, ApplyContext& ac) {
                 // create simple trust SLE with xrp currency
                 auto const sleNew =
-                    std::make_shared(keylet::line(a1, a2, xrpIssue().currency));
+                    std::make_shared(keylet::trustLine(a1, a2, xrpIssue().currency));
                 ac.view().insert(sleNew);
                 return true;
             });
@@ -576,7 +580,8 @@ class Invariants_test : public beast::unit_test::Suite
             {{"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::line(a1, a2, a1["USD"].currency));
+                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));
 
@@ -591,7 +596,8 @@ class Invariants_test : public beast::unit_test::Suite
             {{"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::line(a1, a2, a1["USD"].currency));
+                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;
@@ -605,7 +611,8 @@ class Invariants_test : public beast::unit_test::Suite
             {{"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::line(a1, a2, a1["USD"].currency));
+                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;
@@ -619,7 +626,8 @@ class Invariants_test : public beast::unit_test::Suite
             {{"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::line(a1, a2, a1["USD"].currency));
+                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;
@@ -633,7 +641,8 @@ class Invariants_test : public beast::unit_test::Suite
             {{"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::line(a1, a2, a1["USD"].currency));
+                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;
@@ -688,8 +697,8 @@ class Invariants_test : public beast::unit_test::Suite
                                         ApplyContext& ac,
                                         int a1Balance,
                                         int a2Balance) {
-            auto const sleA1 = ac.view().peek(keylet::line(a1, g1["USD"]));
-            auto const sleA2 = ac.view().peek(keylet::line(a2, g1["USD"]));
+            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));
@@ -958,7 +967,7 @@ class Invariants_test : public beast::unit_test::Suite
                     return false;
 
                 MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptIssuance(mpt.getMptID()));
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
                 sleNew->setFieldU64(sfOutstandingAmount, -1);
                 ac.view().insert(sleNew);
                 return true;
@@ -974,7 +983,7 @@ class Invariants_test : public beast::unit_test::Suite
                     return false;
 
                 MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptIssuance(mpt.getMptID()));
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
                 sleNew->setFieldU64(sfLockedAmount, -1);
                 ac.view().insert(sleNew);
                 return true;
@@ -990,7 +999,7 @@ class Invariants_test : public beast::unit_test::Suite
                     return false;
 
                 MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptIssuance(mpt.getMptID()));
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
                 sleNew->setFieldU64(sfOutstandingAmount, 1);
                 sleNew->setFieldU64(sfLockedAmount, 10);
                 ac.view().insert(sleNew);
@@ -1173,7 +1182,7 @@ class Invariants_test : public beast::unit_test::Suite
         doInvariantCheck(
             {{"NFT page has invalid size"}},
             [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftpageMax(a1));
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
                 nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(0));
 
                 ac.view().insert(nftPage);
@@ -1183,7 +1192,7 @@ class Invariants_test : public beast::unit_test::Suite
         doInvariantCheck(
             {{"NFT page has invalid size"}},
             [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftpageMax(a1));
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
                 nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(33));
 
                 ac.view().insert(nftPage);
@@ -1196,7 +1205,7 @@ class Invariants_test : public beast::unit_test::Suite
                 STArray nfTokens = makeNFTokenIDs(2);
                 std::iter_swap(nfTokens.begin(), nfTokens.begin() + 1);
 
-                auto nftPage = std::make_shared(keylet::nftpageMax(a1));
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
                 nftPage->setFieldArray(sfNFTokens, nfTokens);
 
                 ac.view().insert(nftPage);
@@ -1209,7 +1218,7 @@ class Invariants_test : public beast::unit_test::Suite
                 STArray nfTokens = makeNFTokenIDs(1);
                 nfTokens[0].setFieldVL(sfURI, Blob{});
 
-                auto nftPage = std::make_shared(keylet::nftpageMax(a1));
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
                 nftPage->setFieldArray(sfNFTokens, nfTokens);
 
                 ac.view().insert(nftPage);
@@ -1219,9 +1228,9 @@ class Invariants_test : public beast::unit_test::Suite
         doInvariantCheck(
             {{"NFT page is improperly linked"}},
             [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftpageMax(a1));
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
                 nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
-                nftPage->setFieldH256(sfPreviousPageMin, keylet::nftpageMax(a1).key);
+                nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMax(a1).key);
 
                 ac.view().insert(nftPage);
                 return true;
@@ -1230,9 +1239,9 @@ class Invariants_test : public beast::unit_test::Suite
         doInvariantCheck(
             {{"NFT page is improperly linked"}},
             [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftpageMax(a1));
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
                 nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
-                nftPage->setFieldH256(sfPreviousPageMin, keylet::nftpageMin(a2).key);
+                nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMin(a2).key);
 
                 ac.view().insert(nftPage);
                 return true;
@@ -1241,7 +1250,7 @@ class Invariants_test : public beast::unit_test::Suite
         doInvariantCheck(
             {{"NFT page is improperly linked"}},
             [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftpageMax(a1));
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
                 nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
                 nftPage->setFieldH256(sfNextPageMin, nftPage->key());
 
@@ -1253,10 +1262,10 @@ class Invariants_test : public beast::unit_test::Suite
             {{"NFT page is improperly linked"}},
             [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) {
                 STArray nfTokens = makeNFTokenIDs(1);
-                auto nftPage = std::make_shared(keylet::nftpage(
-                    keylet::nftpageMax(a1), ++(nfTokens[0].getFieldH256(sfNFTokenID))));
+                auto nftPage = std::make_shared(keylet::nftokenPage(
+                    keylet::nftokenPageMax(a1), ++(nfTokens[0].getFieldH256(sfNFTokenID))));
                 nftPage->setFieldArray(sfNFTokens, nfTokens);
-                nftPage->setFieldH256(sfNextPageMin, keylet::nftpageMax(a2).key);
+                nftPage->setFieldH256(sfNextPageMin, keylet::nftokenPageMax(a2).key);
 
                 ac.view().insert(nftPage);
                 return true;
@@ -1266,8 +1275,8 @@ class Invariants_test : public beast::unit_test::Suite
             {{"NFT found in incorrect page"}},
             [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
                 STArray nfTokens = makeNFTokenIDs(2);
-                auto nftPage = std::make_shared(keylet::nftpage(
-                    keylet::nftpageMax(a1), (nfTokens[1].getFieldH256(sfNFTokenID))));
+                auto nftPage = std::make_shared(keylet::nftokenPage(
+                    keylet::nftokenPageMax(a1), (nfTokens[1].getFieldH256(sfNFTokenID))));
                 nftPage->setFieldArray(sfNFTokens, nfTokens);
 
                 ac.view().insert(nftPage);
@@ -1275,7 +1284,88 @@ class Invariants_test : public beast::unit_test::Suite
             });
     }
 
-    static std::shared_ptr
+    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,
@@ -1783,7 +1873,7 @@ class Invariants_test : public beast::unit_test::Suite
         for (std::size_t n = 0; n < numCreds; ++n)
         {
             auto credType = "cred_type" + std::to_string(n);
-            credentials.push_back({a2, credType});
+            credentials.push_back({.issuer = a2, .credType = credType});
         }
 
         std::uint32_t const seq = env.seq(a1);
@@ -2037,6 +2127,169 @@ class Invariants_test : public beast::unit_test::Suite
         }
     }
 
+    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)
     {
@@ -2054,7 +2307,7 @@ class Invariants_test : public beast::unit_test::Suite
         // Create Loan Broker
         using namespace loanBroker;
 
-        auto const loanBrokerKeylet = keylet::loanbroker(a.id(), env.seq(a));
+        auto const loanBrokerKeylet = keylet::loanBroker(a.id(), env.seq(a));
         // Create a Loan Broker with all default values.
         env(set(a, vaultID), Fee(kIncrement));
 
@@ -2433,7 +2686,7 @@ class Invariants_test : public beast::unit_test::Suite
                 return false;
 
             auto const mptIssuanceID = (*sleVault)[sfShareMPTID];
-            auto sleShares = ac.peek(keylet::mptIssuance(mptIssuanceID));
+            auto sleShares = ac.peek(keylet::mptokenIssuance(mptIssuanceID));
             if (!sleShares)
                 return false;
 
@@ -2735,7 +2988,7 @@ class Invariants_test : public beast::unit_test::Suite
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
-                auto sleShares = ac.view().peek(keylet::mptIssuance((*sleVault)[sfShareMPTID]));
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
                 if (!sleShares)
                     return false;
                 ac.view().erase(sleVault);
@@ -2760,7 +3013,7 @@ class Invariants_test : public beast::unit_test::Suite
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
-                auto sleShares = ac.view().peek(keylet::mptIssuance((*sleVault)[sfShareMPTID]));
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
                 if (!sleShares)
                     return false;
                 // Note, such an "orphaned" update of MPT issuance attached to a
@@ -2850,7 +3103,7 @@ class Invariants_test : public beast::unit_test::Suite
                 (*sleVault)[sfAssetsMaximum] = 200;
                 ac.view().update(sleVault);
 
-                auto sleShares = ac.view().peek(keylet::mptIssuance((*sleVault)[sfShareMPTID]));
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
                 if (!sleShares)
                     return false;
                 ac.view().erase(sleShares);
@@ -3046,7 +3299,7 @@ class Invariants_test : public beast::unit_test::Suite
                 if (!sleVault)
                     return false;
                 ac.view().update(sleVault);
-                auto sleShares = ac.view().peek(keylet::mptIssuance((*sleVault)[sfShareMPTID]));
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
                 if (!sleShares)
                     return false;
                 (*sleShares)[sfOutstandingAmount] = 0;
@@ -3066,7 +3319,7 @@ class Invariants_test : public beast::unit_test::Suite
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
-                auto sleShares = ac.view().peek(keylet::mptIssuance((*sleVault)[sfShareMPTID]));
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
                 if (!sleShares)
                     return false;
                 (*sleShares)[sfMaximumAmount] = 10;
@@ -3089,7 +3342,7 @@ class Invariants_test : public beast::unit_test::Suite
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
-                auto sleShares = ac.view().peek(keylet::mptIssuance((*sleVault)[sfShareMPTID]));
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
                 if (!sleShares)
                     return false;
                 (*sleShares)[sfOutstandingAmount] = kMaxMpTokenAmount + 1;
@@ -3191,7 +3444,7 @@ class Invariants_test : public beast::unit_test::Suite
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
-                auto sleShares = ac.view().peek(keylet::mptIssuance((*sleVault)[sfShareMPTID]));
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
                 if (!sleShares)
                     return false;
                 ac.view().update(sleVault);
@@ -3243,7 +3496,7 @@ class Invariants_test : public beast::unit_test::Suite
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
-                auto sleShares = ac.view().peek(keylet::mptIssuance((*sleVault)[sfShareMPTID]));
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
                 if (!sleShares)
                     return false;
                 ac.view().update(sleVault);
@@ -3290,7 +3543,7 @@ class Invariants_test : public beast::unit_test::Suite
                 ac.view().insert(sleAccount);
 
                 auto const sharesMptId = makeMptID(sequence, pseudoId);
-                auto const sharesKeylet = keylet::mptIssuance(sharesMptId);
+                auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
                 auto sleShares = std::make_shared(sharesKeylet);
                 auto const sharesPage = ac.view().dirInsert(
                     keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
@@ -3348,7 +3601,7 @@ class Invariants_test : public beast::unit_test::Suite
                 ac.view().insert(sleAccount);
 
                 auto const sharesMptId = makeMptID(sequence, pseudoId);
-                auto const sharesKeylet = keylet::mptIssuance(sharesMptId);
+                auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
                 auto sleShares = std::make_shared(sharesKeylet);
                 auto const sharesPage = ac.view().dirInsert(
                     keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
@@ -3390,7 +3643,7 @@ class Invariants_test : public beast::unit_test::Suite
                 sleVault->setFieldU64(sfOwnerNode, *vaultPage);
 
                 auto const sharesMptId = makeMptID(sequence, a2.id());
-                auto const sharesKeylet = keylet::mptIssuance(sharesMptId);
+                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()));
@@ -3963,6 +4216,63 @@ class Invariants_test : public beast::unit_test::Suite
         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(
+            Env{*this, defaultAmendments() - fixCleanup3_2_0},
+            {},
+            [](Account const&, Account const&, ApplyContext&) { return true; },
+            XRPAmount{},
+            nonCanonicalMPTPayment(),
+            {tesSUCCESS, tesSUCCESS});
+
+        doInvariantCheck(
+            {{"ledger entry contains non-canonical MPT or XRP amount"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                auto sleNew = std::make_shared(keylet::check(a1.id(), (*sle)[sfSequence]));
+                sleNew->setAccountID(sfAccount, a1.id());
+                sleNew->setAccountID(sfDestination, a2.id());
+                sleNew->setFieldAmount(sfSendMax, nonCanonicalMPTAmount(sfSendMax));
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"ledger entry contains non-canonical MPT or XRP amount"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                auto sleNew = std::make_shared(keylet::check(a1.id(), (*sle)[sfSequence]));
+                sleNew->setAccountID(sfAccount, a1.id());
+                sleNew->setAccountID(sfDestination, a2.id());
+                sleNew->setFieldAmount(sfSendMax, negativeMPTAmount(sfSendMax));
+                ac.view().insert(sleNew);
+                return true;
+            });
+
         // MPT OutstandingAmount > MaximumAmount
         doInvariantCheck(
             {{"OutstandingAmount overflow"}},
@@ -3973,7 +4283,7 @@ class Invariants_test : public beast::unit_test::Suite
                     return false;
 
                 MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)};
-                auto sleNew = std::make_shared(keylet::mptIssuance(mpt.getMptID()));
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
                 sleNew->setFieldU64(sfOutstandingAmount, 110);
                 sleNew->setFieldU64(sfMaximumAmount, 100);
                 ac.view().insert(sleNew);
@@ -3990,7 +4300,7 @@ class Invariants_test : public beast::unit_test::Suite
                     return false;
 
                 MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)};
-                auto sleNew = std::make_shared(keylet::mptIssuance(mpt.getMptID()));
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
                 sleNew->setFieldU64(sfOutstandingAmount, 100);
                 sleNew->setFieldU64(sfMaximumAmount, 100);
                 ac.view().insert(sleNew);
@@ -4034,7 +4344,7 @@ class Invariants_test : public beast::unit_test::Suite
             });
         testPayment(
             "OutstandingAmount overflow", [&](MPTID const& id, ApplyContext& ac, Account const&) {
-                auto sle = ac.view().peek(keylet::mptIssuance(id));
+                auto sle = ac.view().peek(keylet::mptokenIssuance(id));
                 if (!sle)
                     return false;
                 sle->setFieldU64(sfOutstandingAmount, 101);
@@ -4061,7 +4371,8 @@ class Invariants_test : public beast::unit_test::Suite
                     for (int i = 0; i < nTokens; ++i)
                     {
                         MPTIssue const mpt{makeMptID(seq + i, a1)};
-                        auto sleNew = std::make_shared(keylet::mptIssuance(mpt.getMptID()));
+                        auto sleNew =
+                            std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
                         ac.view().insert(sleNew);
 
                         sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2));
@@ -4103,6 +4414,501 @@ class Invariants_test : public beast::unit_test::Suite
                     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 transfer: ValidMPTTransfer gates isVaultPseudoAccountFrozen
+        // on fixCleanup3_3_0.  Pre-amendment, vault-share transfers are allowed
+        // even when the underlying asset is individually frozen for the sender;
+        // post-amendment they are blocked.
+        {
+            Account const gw{"gw"};
+            MPTID shareID{};
+
+            auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool {
+                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();
+
+                PrettyAsset const iou = gw["IOU"];
+                Vault const vault{env};
+                auto [createTx, vaultKeylet] = vault.create({.owner = a1, .asset = iou});
+                env(createTx);
+                env.close();
+                // Both a1 and a2 deposit IOU, each receiving vault shares.
+                env(vault.deposit({.depositor = a1, .id = vaultKeylet.key, .amount = iou(100)}));
+                env(vault.deposit({.depositor = a2, .id = vaultKeylet.key, .amount = iou(100)}));
+                env.close();
+
+                shareID = env.le(vaultKeylet)->at(sfShareMPTID);
+
+                // Freeze a2's IOU trustline from the issuer side.
+                // a2 is the receiver in the simulated AMM withdraw; the
+                // distinction under test is that pre-fix330 the invariant
+                // does not apply the transitive vault freeze to receivers.
+                env(trust(gw, gw["IOU"](0), a2, tfSetFreeze));
+                env.close();
+                return true;
+            };
+
+            // 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;
+            };
+
+            // post-fixCleanup3_3_0: full isFrozen() applies to all holders;
+            // isVaultPseudoAccountFrozen finds a2's underlying IOU frozen →
+            // invalidTransfer → invariant fires.
+            doInvariantCheck(
+                Env{*this, defaultAmendments()},
+                {{"invalid MPToken transfer between holders"}},
+                precheck,
+                XRPAmount{},
+                STTx{ttAMM_WITHDRAW, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                preclose);
+
+            // pre-fixCleanup3_3_0: legacy AMM withdraw only checked
+            // checkIndividualFrozen on the destination, not the transitive
+            // vault freeze; a2 as receiver is exempt → invariant passes.
+            doInvariantCheck(
+                Env{*this, defaultAmendments() - fixCleanup3_3_0},
+                {},
+                precheck,
+                XRPAmount{},
+                STTx{ttAMM_WITHDRAW, [](STObject&) {}},
+                {tesSUCCESS, tesSUCCESS},
+                preclose);
+        }
+
+        // Side-specific vault-share AMM_WITHDRAW invariant tests.
+        // Both cases use a real vault (IOU underlying) and a real AMM whose
+        // pool includes vault shares.  precheck simulates an AMM_WITHDRAW by
+        // transferring 10 vault shares from the AMM pseudo-account to a2.
+        {
+            MPTID shareID{};
+            AccountID ammAcctID{};
+            AccountID vaultPseudoID{};
+            Account const gw{"gw"};
+
+            // Simulate AMM_WITHDRAW: AMM pseudo-account sends 10 vault shares
+            // to a2.  The AMM pseudo is the sender (decreasing balance);
+            // a2 is the receiver (increasing balance).
+            auto const precheck2 =
+                [&](Account const& /*a1*/, Account const& a2, ApplyContext& ac) -> bool {
+                auto sleAMM = ac.view().peek(keylet::mptoken(shareID, ammAcctID));
+                auto sle2 = ac.view().peek(keylet::mptoken(shareID, a2.id()));
+                if (!sleAMM || !sle2)
+                    return false;
+                (*sleAMM)[sfMPTAmount] -= 10;
+                (*sle2)[sfMPTAmount] += 10;
+                ac.view().update(sleAMM);
+                ac.view().update(sle2);
+                return true;
+            };
+
+            // Shared vault + AMM setup: a1 deposits 500 IOU into a vault and
+            // creates an AMM with XRP + 100 vault shares, giving the AMM
+            // pseudo-account a vault-share MPToken balance.
+            auto const setupVaultAMM = [&](Account const& a1, Account const& a2, Env& env) -> bool {
+                env.fund(XRP(1'000), gw);
+                env(fset(gw, asfDefaultRipple));
+                env.close();
+
+                env.trust(gw["IOU"](10'000), a1);
+                env.trust(gw["IOU"](10'000), a2);
+                env.close();
+                env(pay(gw, a1, gw["IOU"](1'000)));
+                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"](500)}));
+                env(vault.deposit(
+                    {.depositor = a2, .id = vaultKeylet.key, .amount = gw["IOU"](200)}));
+                env.close();
+
+                shareID = env.le(vaultKeylet)->at(sfShareMPTID);
+                vaultPseudoID = env.le(vaultKeylet)->at(sfAccount);
+
+                // a1 creates AMM with XRP + 100 vault shares; the AMM
+                // pseudo-account receives an MPToken record for shareID.
+                AMM const amm(env, a1, XRP(100), STAmount{MPTIssue{shareID}, 100});
+                ammAcctID = amm.ammAccount();
+                return true;
+            };
+
+            // Case 1: freeze the vault pseudo-account's IOU trustline.
+            // isVaultPseudoAccountFrozen(ammAcct) calls isAnyFrozen({vaultPseudo,
+            // ammAcct}, IOU); since vaultPseudo is frozen it returns true.  The
+            // AMM sender has a decreasing balance (not a receiver) so it is
+            // never exempt from the check — invariant fires both pre- and
+            // post-fixCleanup3_3_0.
+            auto const preclose3 = [&](Account const& a1, Account const& a2, Env& env) -> bool {
+                if (!setupVaultAMM(a1, a2, env))
+                    return false;
+                env(trust(gw, gw["IOU"](0), Account{"vaultPseudo", vaultPseudoID}, tfSetFreeze));
+                env.close();
+                return true;
+            };
+
+            doInvariantCheck(
+                Env{*this, defaultAmendments()},
+                {{"invalid MPToken transfer between holders"}},
+                precheck2,
+                XRPAmount{},
+                STTx{ttAMM_WITHDRAW, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                preclose3);
+
+            doInvariantCheck(
+                Env{*this, defaultAmendments() - fixCleanup3_3_0},
+                {{"invalid MPToken transfer between holders"}},
+                precheck2,
+                XRPAmount{},
+                STTx{ttAMM_WITHDRAW, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                preclose3);
+
+            // Case 2: freeze a2's (receiver's) IOU trustline.
+            // isVaultPseudoAccountFrozen(a2) → isAnyFrozen({vaultPseudo, a2},
+            // IOU) → true.  The AMM sender's check passes (vaultPseudo and
+            // ammAcct are not frozen).  Pre-fix330: receiver is exempt from
+            // isVaultPseudoAccountFrozen in ttAMM_WITHDRAW → passes.
+            // Post-fix330: full isFrozen() applied to a2 → fires.
+            auto const preclose4 = [&](Account const& a1, Account const& a2, Env& env) -> bool {
+                if (!setupVaultAMM(a1, a2, env))
+                    return false;
+                env(trust(gw, gw["IOU"](0), a2, tfSetFreeze));
+                env.close();
+                return true;
+            };
+
+            doInvariantCheck(
+                Env{*this, defaultAmendments()},
+                {{"invalid MPToken transfer between holders"}},
+                precheck2,
+                XRPAmount{},
+                STTx{ttAMM_WITHDRAW, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                preclose4);
+
+            doInvariantCheck(
+                Env{*this, defaultAmendments() - fixCleanup3_3_0},
+                {},
+                precheck2,
+                XRPAmount{},
+                STTx{ttAMM_WITHDRAW, [](STObject&) {}},
+                {tesSUCCESS, tesSUCCESS},
+                preclose4);
+        }
+    }
+
+    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
@@ -4129,8 +4935,8 @@ class Invariants_test : public beast::unit_test::Suite
                                                    auto const& goodConfig) {
             char const* const c1 = "USD";
             char const* const c2 = "EUR";
-            auto const k1 = keylet::line(a1, a2, a1[c1].currency);
-            auto const k2 = keylet::line(a1, a3, a1[c2].currency);
+            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;
@@ -4225,7 +5031,7 @@ class Invariants_test : public beast::unit_test::Suite
                     return false;
 
                 MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptIssuance(mpt.getMptID()));
+                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_
@@ -4253,113 +5059,371 @@ class Invariants_test : public beast::unit_test::Suite
             std::vector values;
         };
 
-        NumberMantissaScaleGuard const g{MantissaRange::MantissaScale::Large};
-
-        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)
+        for (auto const mantissaScale : {
+                 MantissaRange::MantissaScale::LargeLegacy,
+                 MantissaRange::MantissaScale::Large,
+             })
         {
-            testcase("vault computeCoarsestScale: " + tc.name);
+            NumberMantissaScaleGuard const g{mantissaScale};
 
-            auto const actualScale = ValidVault::computeCoarsestScale(tc.values);
+            auto makeDelta = [&vaultAsset](Number const& n) -> ValidVault::DeltaInfo {
+                return {.delta = n, .scale = scale(n, vaultAsset.raw())};
+            };
 
-            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)
+            auto const testCases = std::vector{
                 {
-                    first = num.delta;
-                    firstRounded = roundToAsset(vaultAsset, num.delta, actualScale);
-                    continue;
-                }
-                auto const numRounded = roundToAsset(vaultAsset, num.delta, actualScale);
+                    .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(
-                    numRounded != firstRounded,
-                    "at a scale of " + std::to_string(actualScale) + " " + to_string(num.delta) +
-                        " == " + to_string(*first));
+                    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
+    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
@@ -4377,18 +5441,23 @@ public:
         testNoZeroEscrow();
         testValidNewAccountRoot();
         testNFTokenPageInvariants();
+        testAMMDeleteInvariants(defaultAmendments());
+        testAMMDeleteInvariants(defaultAmendments() - fixCleanup3_3_0);
         testPermissionedDomainInvariants(defaultAmendments() | fixCleanup3_1_3);
         testPermissionedDomainInvariants(defaultAmendments() - fixCleanup3_1_3);
         testPermissionedDEX(defaultAmendments() | fixCleanup3_1_3);
         testPermissionedDEX(defaultAmendments() - fixCleanup3_1_3);
+        testBookDirectoryExchangeRate();
         testNoModifiedUnmodifiableFields();
         testValidPseudoAccounts();
         testValidLoanBroker();
         testVault();
+        testConfidentialMPTTransfer();
         testMPT();
         testInvariantOverwrite(defaultAmendments());
         testInvariantOverwrite(defaultAmendments() - fixCleanup3_1_3);
         testVaultComputeCoarsestScale();
+        testAMM();
     }
 };
 
diff --git a/src/test/app/LPTokenTransfer_test.cpp b/src/test/app/LPTokenTransfer_test.cpp
index 4bf2db9515..2947b3a3ce 100644
--- a/src/test/app/LPTokenTransfer_test.cpp
+++ b/src/test/app/LPTokenTransfer_test.cpp
@@ -359,7 +359,7 @@ class LPTokenTransfer_test : public jtx::AMMTest
         env.close();
 
         // bob_ creates a sell offer for lptoken
-        uint256 const sellOfferIndex = keylet::nftoffer(bob_, env.seq(bob_)).key;
+        uint256 const sellOfferIndex = keylet::nftokenOffer(bob_, env.seq(bob_)).key;
         env(token::createOffer(bob_, nftID, STAmount{lpIssue, 10}), Txflags(tfSellNFToken));
         env.close();
 
@@ -420,7 +420,7 @@ class LPTokenTransfer_test : public jtx::AMMTest
             env.close();
 
             // bob_ creates a buy offer with lptoken despite bob_'s USD is frozen
-            uint256 const buyOfferIndex = keylet::nftoffer(bob_, env.seq(bob_)).key;
+            uint256 const buyOfferIndex = keylet::nftokenOffer(bob_, env.seq(bob_)).key;
             env(token::createOffer(bob_, nftID, STAmount{lpIssue, 10}), token::Owner(carol_));
             env.close();
 
diff --git a/src/test/app/LedgerHistory_test.cpp b/src/test/app/LedgerHistory_test.cpp
index e464962258..3d0e546678 100644
--- a/src/test/app/LedgerHistory_test.cpp
+++ b/src/test/app/LedgerHistory_test.cpp
@@ -50,7 +50,7 @@ public:
             return std::make_shared(
                 kCreateGenesis,
                 Rules{env.app().config().features},
-                env.app().config().FEES.toFees(),
+                env.app().config().fees.toFees(),
                 std::vector{},
                 env.app().getNodeFamily());
         }
diff --git a/src/test/app/LedgerLoad_test.cpp b/src/test/app/LedgerLoad_test.cpp
index fa2c8f9047..ee3bfe5192 100644
--- a/src/test/app/LedgerLoad_test.cpp
+++ b/src/test/app/LedgerLoad_test.cpp
@@ -40,9 +40,9 @@ class LedgerLoad_test : public beast::unit_test::Suite
         StartUpType type,
         std::optional trapTxHash)
     {
-        cfg->START_LEDGER = ledger;
-        cfg->START_UP = type;
-        cfg->TRAP_TX_HASH = trapTxHash;
+        cfg->startLedger = ledger;
+        cfg->startUp = type;
+        cfg->trapTxHash = trapTxHash;
         assert(!dbPath.empty());
         cfg->legacy("database_path", dbPath);
         return cfg;
diff --git a/src/test/app/LedgerMaster_test.cpp b/src/test/app/LedgerMaster_test.cpp
index ee14a93d07..3cf9b3a9d9 100644
--- a/src/test/app/LedgerMaster_test.cpp
+++ b/src/test/app/LedgerMaster_test.cpp
@@ -27,9 +27,9 @@ class LedgerMaster_test : public beast::unit_test::Suite
     {
         using namespace jtx;
         return envconfig([&](std::unique_ptr cfg) {
-            cfg->NETWORK_ID = networkID;
+            cfg->networkId = networkID;
             // This test relies on ledger hash so must lock it to fee 10.
-            cfg->FEES.reference_fee = 10;
+            cfg->fees.referenceFee = 10;
             return cfg;
         });
     }
diff --git a/src/test/app/LedgerReplay_test.cpp b/src/test/app/LedgerReplay_test.cpp
index 4b1e4510d6..2422db05de 100644
--- a/src/test/app/LedgerReplay_test.cpp
+++ b/src/test/app/LedgerReplay_test.cpp
@@ -549,7 +549,7 @@ struct LedgerServer
             while (senders.contains(fromIdx))
                 fromIdx = (fromIdx + 1) % fundedAccounts;
             senders.insert(fromIdx);
-            toIdx = (toIdx + r * 2) % fundedAccounts;
+            toIdx = (toIdx + (r * 2)) % fundedAccounts;
             if (toIdx == fromIdx)
                 toIdx = (toIdx + 1) % fundedAccounts;
         };
@@ -1062,7 +1062,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
         testcase("config test");
         {
             Config const c;
-            BEAST_EXPECT(c.LEDGER_REPLAY == false);
+            BEAST_EXPECT(c.ledgerReplay == false);
         }
 
         {
@@ -1072,17 +1072,17 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
 1
 )xrpldConfig");
             c.loadFromString(toLoad);
-            BEAST_EXPECT(c.LEDGER_REPLAY == true);
+            BEAST_EXPECT(c.ledgerReplay == true);
         }
 
         {
             Config c;
-            std::string const toLoad = (R"xrpldConfig(
+            std::string const toLoad = R"xrpldConfig(
 [ledger_replay]
 0
-)xrpldConfig");
+)xrpldConfig";
             c.loadFromString(toLoad);
-            BEAST_EXPECT(c.LEDGER_REPLAY == false);
+            BEAST_EXPECT(c.ledgerReplay == false);
         }
     }
 
@@ -1101,7 +1101,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
 
             beast::IP::Address const addr = boost::asio::ip::make_address("172.1.1.100");
             jtx::Env serverEnv(*this);
-            serverEnv.app().config().LEDGER_REPLAY = server;
+            serverEnv.app().config().ledgerReplay = server;
             auto httpResp = xrpl::makeResponse(
                 true, httpRequest, addr, addr, uint256{1}, 1, {1, 0}, serverEnv.app());
             auto const clientResult = peerFeatureEnabled(httpResp, kFeatureLedgerReplay, client);
diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp
index 96d0722732..ac8e0764fc 100644
--- a/src/test/app/LendingHelpers_test.cpp
+++ b/src/test/app/LendingHelpers_test.cpp
@@ -8,9 +8,16 @@
 #include 
 #include 
 #include 
+#include 
+#include 
+#include 
+#include 
+#include 
 #include 
 
 #include 
+#include 
+#include 
 #include 
 #include 
 
@@ -287,9 +294,9 @@ class LendingHelpers_test : public beast::unit_test::Suite
                 std::uint32_t n;
             };
             auto const cases = std::vector{
-                {"r=5%, n=3", Number{5, -2}, 3},
-                {"r=0.1%, n=1000", Number{1, -3}, 1'000},
-                {"r=1e-7, n=100 (above threshold by 10x)", Number{1, -7}, 100},
+                {.name = "r=5%, n=3", .r = Number{5, -2}, .n = 3},
+                {.name = "r=0.1%, n=1000", .r = Number{1, -3}, .n = 1'000},
+                {.name = "r=1e-7, n=100 (above threshold by 10x)", .r = Number{1, -7}, .n = 100},
             };
             for (auto const& tc : cases)
             {
@@ -318,8 +325,10 @@ class LendingHelpers_test : public beast::unit_test::Suite
             auto const cases = std::vector{
                 // bug regime: r = 1 TenthBips32 over 600s payment interval
                 // → r ≈ 1.9e-10, r*n ≈ 3.8e-10 < 1e-9.
-                {"bug regime: r~1.9e-10, n=2", loanPeriodicRate(TenthBips32{1}, 600), 2},
-                {"r=1e-12, n=100", Number{1, -12}, 100},
+                {.name = "bug regime: r~1.9e-10, n=2",
+                 .r = loanPeriodicRate(TenthBips32{1}, 600),
+                 .n = 2},
+                {.name = "r=1e-12, n=100", .r = Number{1, -12}, .n = 100},
             };
             for (auto const& tc : cases)
             {
@@ -356,8 +365,8 @@ class LendingHelpers_test : public beast::unit_test::Suite
                 std::uint32_t n;
             };
             auto const cases = std::vector{
-                {"r=1e-9, n=1", Number{1, -9}, 1},
-                {"r=1e-12, n=1000", Number{1, -12}, 1'000},
+                {.name = "r=1e-9, n=1", .r = Number{1, -9}, .n = 1},
+                {.name = "r=1e-12, n=1000", .r = Number{1, -12}, .n = 1'000},
             };
 
             for (auto const& tc : cases)
@@ -505,7 +514,9 @@ class LendingHelpers_test : public beast::unit_test::Suite
         auto const expectedOverpaymentManagementFee = Number{10};   // 10% of 100
         auto const expectedPrincipalPortion = Number{400};          // 1,000 - 100 - 500
 
+        Env const env{*this};
         auto const components = xrpl::detail::computeOverpaymentComponents(
+            env.current()->rules(),
             iou,
             loanScale,
             overpayment,
@@ -846,7 +857,13 @@ class LendingHelpers_test : public beast::unit_test::Suite
         Number const overpaymentAmount{50};
 
         auto const overpaymentComponents = computeOverpaymentComponents(
-            asset, loanScale, overpaymentAmount, TenthBips32(0), TenthBips32(0), managementFeeRate);
+            env.current()->rules(),
+            asset,
+            loanScale,
+            overpaymentAmount,
+            TenthBips32(0),
+            TenthBips32(0),
+            managementFeeRate);
 
         auto const loanProperties = computeLoanProperties(
             env.current()->rules(),
@@ -934,6 +951,7 @@ class LendingHelpers_test : public beast::unit_test::Suite
         auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
 
         auto const overpaymentComponents = computeOverpaymentComponents(
+            env.current()->rules(),
             asset,
             loanScale,
             Number{50, 0},
@@ -1029,6 +1047,7 @@ class LendingHelpers_test : public beast::unit_test::Suite
         auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
 
         auto const overpaymentComponents = computeOverpaymentComponents(
+            env.current()->rules(),
             asset,
             loanScale,
             Number{50, 0},
@@ -1130,6 +1149,7 @@ class LendingHelpers_test : public beast::unit_test::Suite
         auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
 
         auto const overpaymentComponents = computeOverpaymentComponents(
+            env.current()->rules(),
             asset,
             loanScale,
             Number{50, 0},
@@ -1239,6 +1259,7 @@ class LendingHelpers_test : public beast::unit_test::Suite
         auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
 
         auto const overpaymentComponents = computeOverpaymentComponents(
+            env.current()->rules(),
             asset,
             loanScale,
             Number{50, 0},
@@ -1336,7 +1357,6 @@ class LendingHelpers_test : public beast::unit_test::Suite
         using namespace jtx;
         using namespace xrpl::detail;
 
-        Env const env{*this};
         Account const issuer{"issuer"};
         PrettyAsset const asset = issuer["USD"];
         std::int32_t const loanScale = -5;
@@ -1347,7 +1367,9 @@ class LendingHelpers_test : public beast::unit_test::Suite
         std::uint32_t const paymentsRemaining = 10;
         auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
 
+        Env const env{*this};
         auto const overpaymentComponents = computeOverpaymentComponents(
+            env.current()->rules(),
             asset,
             loanScale,
             Number{50, 0},
@@ -1355,90 +1377,178 @@ class LendingHelpers_test : public beast::unit_test::Suite
             TenthBips32(10'000),  // 10% overpayment fee
             managementFeeRate);
 
-        auto const loanProperties = computeLoanProperties(
-            env.current()->rules(),
-            asset,
-            loanPrincipal,
-            loanInterestRate,
-            paymentInterval,
-            paymentsRemaining,
-            managementFeeRate,
-            loanScale);
+        struct Outcome
+        {
+            LoanPaymentParts parts;
+            LoanState oldState;
+            LoanState newState;
+        };
 
-        auto const ret = tryOverpayment(
-            env.current()->rules(),
-            asset,
-            loanScale,
-            overpaymentComponents,
-            loanProperties.loanState,
-            loanProperties.periodicPayment,
-            periodicRate,
-            paymentsRemaining,
-            managementFeeRate,
-            env.journal);
+        // Run tryOverpayment under a given amendment set. At this (non-near-zero)
+        // rate computeLoanProperties is amendment-independent, so the loan state
+        // is identical across the amendment; only tryOverpayment's fixCleanup3_2_0
+        // behaviour (the exact-principal pin and the management-fee re-derivation
+        // from that principal) differs.
+        auto run = [&](FeatureBitset features) -> std::optional {
+            Env const env{*this, features};
+            auto const loanProperties = computeLoanProperties(
+                env.current()->rules(),
+                asset,
+                loanPrincipal,
+                loanInterestRate,
+                paymentInterval,
+                paymentsRemaining,
+                managementFeeRate,
+                loanScale);
+            auto const ret = tryOverpayment(
+                env.current()->rules(),
+                asset,
+                loanScale,
+                overpaymentComponents,
+                loanProperties.loanState,
+                loanProperties.periodicPayment,
+                periodicRate,
+                paymentsRemaining,
+                managementFeeRate,
+                env.journal);
+            if (!BEAST_EXPECT(ret))
+                return std::nullopt;
+            return Outcome{
+                .parts = ret->first,
+                .oldState = loanProperties.loanState,
+                .newState = ret->second.loanState};
+        };
 
-        BEAST_EXPECT(ret);
+        auto const fixedOpt = run(testableAmendments());
+        auto const legacyOpt = run(testableAmendments() - fixCleanup3_2_0);
+        if (!fixedOpt || !legacyOpt)
+        {
+            BEAST_EXPECT(fixedOpt.has_value());
+            BEAST_EXPECT(legacyOpt.has_value());
+            return;
+        }
+        Outcome const& fixed = *fixedOpt;
+        Outcome const& legacy = *legacyOpt;
 
-        auto const& [actualPaymentParts, newLoanProperties] = *ret;
-        auto const& newState = newLoanProperties.loanState;
+        // Components that the amendment does not change. The management fee is
+        // charged against the overpayment interest portion first, so interest
+        // paid stays 4.5 and fee paid 5.5; the principal repaid is 40 in both.
+        auto checkCommon = [&](Outcome const& o, char const* tag) {
+            BEAST_EXPECTS(
+                (o.parts.interestPaid == Number{45, -1}),
+                std::string(tag) + " interestPaid " + to_string(o.parts.interestPaid));
+            BEAST_EXPECTS(
+                (o.parts.feePaid == Number{55, -1}),
+                std::string(tag) + " feePaid " + to_string(o.parts.feePaid));
+            BEAST_EXPECTS(
+                o.parts.principalPaid == 40,
+                std::string(tag) + " principalPaid " + to_string(o.parts.principalPaid));
+            BEAST_EXPECT(
+                o.parts.principalPaid ==
+                o.oldState.principalOutstanding - o.newState.principalOutstanding);
+            // v = p + i + m identity: the non-interest part of valueChange equals
+            // the interest-due change.
+            BEAST_EXPECT(
+                o.parts.valueChange - o.parts.interestPaid ==
+                o.newState.interestDue - o.oldState.interestDue);
+        };
+        checkCommon(fixed, "fixed");
+        checkCommon(legacy, "legacy");
 
-        // =========== VALIDATE PAYMENT PARTS ===========
-
-        // Since there is loan management fee, the fee is charged against
-        // overpayment interest portion first, so interest paid remains 4.5
-        BEAST_EXPECTS(
-            (actualPaymentParts.interestPaid == Number{45, -1}),
-            " interestPaid mismatch: expected 4.5, got " +
-                to_string(actualPaymentParts.interestPaid));
-
-        // With overpayment interest portion, value change should equal the
-        // interest decrease plus overpayment interest portion
-        BEAST_EXPECTS(
-            (actualPaymentParts.valueChange ==
-             Number{-164737, -5} + actualPaymentParts.interestPaid),
-            " valueChange mismatch: expected " +
-                to_string(Number{-164737, -5} + actualPaymentParts.interestPaid) + ", got " +
-                to_string(actualPaymentParts.valueChange));
-
-        // While there is no overpayment fee, fee paid should equal the
-        // management fee charged against the overpayment interest portion
-        BEAST_EXPECTS(
-            (actualPaymentParts.feePaid == Number{55, -1}),
-            " feePaid mismatch: expected 5.5, got " + to_string(actualPaymentParts.feePaid));
-
-        BEAST_EXPECTS(
-            actualPaymentParts.principalPaid == 40,
-            " principalPaid mismatch: expected 40, got `" +
-                to_string(actualPaymentParts.principalPaid));
-
-        // =========== VALIDATE STATE CHANGES ===========
-
-        BEAST_EXPECTS(
-            actualPaymentParts.principalPaid ==
-                loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
-            " principalPaid mismatch: expected " +
-                to_string(
-                    loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
-                ", got " + to_string(actualPaymentParts.principalPaid));
-
-        // Note that the management fee value change is not captured, as this
-        // value is not needed to correctly update the Vault state.
-        BEAST_EXPECTS(
-            (newState.managementFeeDue - loanProperties.loanState.managementFeeDue ==
-             Number{-18304, -5}),
-            " management fee change mismatch: expected " + to_string(Number{-18304, -5}) +
-                ", got " +
-                to_string(newState.managementFeeDue - loanProperties.loanState.managementFeeDue));
-
-        BEAST_EXPECTS(
-            actualPaymentParts.valueChange - actualPaymentParts.interestPaid ==
-                newState.interestDue - loanProperties.loanState.interestDue,
-            " valueChange mismatch: expected " +
-                to_string(newState.interestDue - loanProperties.loanState.interestDue) + ", got " +
-                to_string(actualPaymentParts.valueChange - actualPaymentParts.interestPaid));
+        // With fixCleanup3_2_0 the management fee is re-derived from the exact
+        // principal; without it, from the one-scale-unit-high round-trip
+        // principal. So the management fee outstanding (and hence the value
+        // change, via v = p + i + m) differ by exactly one scale-unit (1e-5 at
+        // loanScale -5) between the two paths.
+        BEAST_EXPECT((fixed.parts.valueChange == Number{-164738, -5} + fixed.parts.interestPaid));
+        BEAST_EXPECT(
+            (fixed.newState.managementFeeDue - fixed.oldState.managementFeeDue ==
+             Number{-18303, -5}));
+        BEAST_EXPECT((legacy.parts.valueChange == Number{-164737, -5} + legacy.parts.interestPaid));
+        BEAST_EXPECT(
+            (legacy.newState.managementFeeDue - legacy.oldState.managementFeeDue ==
+             Number{-18304, -5}));
     }
 
 public:
+    void
+    testCanApplyToBrokerCover()
+    {
+        using namespace jtx;
+
+        Account const issuer{"issuer"};
+        PrettyAsset const iou = issuer["IOU"];
+
+        // sfCoverAvailable = Number{10} on an IOU → STAmount exponent = -14,
+        // so coverScale = -14.  The ULP boundary is 5e-15; anything below
+        // that rounds to zero at cover scale.  Number{1,-16} = 1e-16 is our
+        // representative sub-ULP probe.
+        struct TestCase
+        {
+            std::string name;
+            Number coverAvailable;
+            STAmount amount;
+            TER expected;
+        };
+
+        auto const testCases = std::vector{
+            {
+                .name = "Zero amount",
+                .coverAvailable = Number{10},
+                .amount = STAmount{iou, Number{0}},
+                .expected = tecPRECISION_LOSS,
+            },
+            {
+                .name = "Rounds to zero at cover scale",
+                .coverAvailable = Number{10},
+                .amount = STAmount{iou, Number{1, -16}},
+                .expected = tecPRECISION_LOSS,
+            },
+            {
+                .name = "Zero coverAvailable, whole-unit amount",
+                // coverScale = 0 (zero STAmount exponent); 1 IOU is not
+                // zero at integer scale → tesSUCCESS.
+                .coverAvailable = Number{0},
+                .amount = STAmount{iou, Number{1}},
+                .expected = tesSUCCESS,
+            },
+            {
+                .name = "Supra-ULP amount",
+                .coverAvailable = Number{10},
+                .amount = STAmount{iou, Number{1, -13}},
+                .expected = tesSUCCESS,
+            },
+        };
+
+        Env const env{*this};
+
+        for (auto const& tc : testCases)
+        {
+            testcase("canApplyToBrokerCover: " + tc.name);
+            auto sle = std::make_shared(ltLOAN_BROKER, uint256{1u});
+            sle->at(sfCoverAvailable) = tc.coverAvailable;
+            BEAST_EXPECT(
+                canApplyToBrokerCover(*env.current(), sle, iou, tc.amount, env.journal, "test") ==
+                tc.expected);
+        }
+
+        // Amendment off → guard is bypassed regardless of amount.
+        {
+            testcase("canApplyToBrokerCover: amendment disabled");
+            Env const envOff{*this, testableAmendments() - fixCleanup3_2_0};
+            auto sle = std::make_shared(ltLOAN_BROKER, uint256{1u});
+            sle->at(sfCoverAvailable) = Number{10};
+            BEAST_EXPECT(
+                canApplyToBrokerCover(
+                    *envOff.current(),
+                    sle,
+                    iou,
+                    STAmount{iou, Number{0}},
+                    envOff.journal,
+                    "test") == tesSUCCESS);
+        }
+    }
+
     void
     run() override
     {
@@ -1462,6 +1572,7 @@ public:
         testComputePaymentFactorNearZeroRate();
         testComputeOverpaymentComponents();
         testComputeInterestAndFeeParts();
+        testCanApplyToBrokerCover();
     }
 };
 
diff --git a/src/test/app/LoadFeeTrack_test.cpp b/src/test/app/LoadFeeTrack_test.cpp
index 528e90321b..aba0b35062 100644
--- a/src/test/app/LoadFeeTrack_test.cpp
+++ b/src/test/app/LoadFeeTrack_test.cpp
@@ -17,7 +17,7 @@ public:
         {
             Fees const fees = [&]() {
                 Fees f;
-                f.base = d.FEES.reference_fee;
+                f.base = d.fees.referenceFee;
                 f.reserve = 200 * kDropsPerXrp;
                 f.increment = 50 * kDropsPerXrp;
                 return f;
@@ -30,7 +30,7 @@ public:
         {
             Fees const fees = [&]() {
                 Fees f;
-                f.base = d.FEES.reference_fee * 10;
+                f.base = d.fees.referenceFee * 10;
                 f.reserve = 200 * kDropsPerXrp;
                 f.increment = 50 * kDropsPerXrp;
                 return f;
@@ -43,7 +43,7 @@ public:
         {
             Fees const fees = [&]() {
                 Fees f;
-                f.base = d.FEES.reference_fee;
+                f.base = d.fees.referenceFee;
                 f.reserve = 200 * kDropsPerXrp;
                 f.increment = 50 * kDropsPerXrp;
                 return f;
diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/LoanBroker_test.cpp
index a7e036a621..2a4de18a9c 100644
--- a/src/test/app/LoanBroker_test.cpp
+++ b/src/test/app/LoanBroker_test.cpp
@@ -1,5 +1,6 @@
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -52,9 +53,11 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -91,7 +94,7 @@ class LoanBroker_test : public beast::unit_test::Suite
             using namespace loanBroker;
             // Can't create a loan broker regardless of whether the vault exists
             env(set(alice, keylet.key), Ter(temDISABLED));
-            auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
+            auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
             // Other LoanBroker transactions are disabled, too.
             // 1. LoanBrokerCoverDeposit
             env(coverDeposit(alice, brokerKeylet.key, asset(1000)), Ter(temDISABLED));
@@ -177,7 +180,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         static PrettyAsset const kGhostIouAsset = kNonExistent["GST"];
         PrettyAsset const vaultPseudoIouAsset = vault.pseudoAccount["PSD"];
 
-        auto const badKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
+        auto const badKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
         env(set(alice, badVault.vaultID));
         env.close();
         auto const badBrokerPseudo = [&]() {
@@ -190,7 +193,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         }();
         PrettyAsset const badBrokerPseudoIouAsset = badBrokerPseudo["WAT"];
 
-        auto const keylet = keylet::loanbroker(alice.id(), env.seq(alice));
+        auto const keylet = keylet::loanBroker(alice.id(), env.seq(alice));
         {
             // Start with default values
             auto jtx = env.jt(set(alice, vault.vaultID));
@@ -280,7 +283,8 @@ class LoanBroker_test : public beast::unit_test::Suite
                 [&env, &vault, &pseudoAccount, &broker, &keylet, this](auto n) {
                     using namespace jtx;
 
-                    if (BEAST_EXPECT(broker = env.le(keylet)))
+                    broker = env.le(keylet);
+                    if (BEAST_EXPECT(broker))
                     {
                         auto const amount = vault.asset(n);
                         BEAST_EXPECT(broker->at(sfCoverAvailable) == amount.number());
@@ -470,7 +474,8 @@ class LoanBroker_test : public beast::unit_test::Suite
             env.close();
 
             // Check the results of modifications
-            if (BEAST_EXPECT(broker = env.le(keylet)) && checkChangedBroker)
+            broker = env.le(keylet);
+            if (BEAST_EXPECT(broker) && checkChangedBroker)
                 checkChangedBroker(broker);
 
             // Verify that fields get removed when set to default values
@@ -483,7 +488,8 @@ class LoanBroker_test : public beast::unit_test::Suite
             env.close();
 
             // Check the updated fields
-            if (BEAST_EXPECT(broker = env.le(keylet)))
+            broker = env.le(keylet);
+            if (BEAST_EXPECT(broker))
             {
                 BEAST_EXPECT(!broker->isFieldPresent(sfDebtMaximum));
                 BEAST_EXPECT(!broker->isFieldPresent(sfData));
@@ -733,7 +739,7 @@ class LoanBroker_test : public beast::unit_test::Suite
                     // Modifications
 
                     // Update the fields
-                    auto const nextKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
+                    auto const nextKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
 
                     // fields that can't be changed
                     // LoanBrokerID
@@ -889,7 +895,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)}));
         env.close();
 
-        auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
+        auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
         env(set(alice, vaultInfo.vaultID));
         env.close();
 
@@ -933,11 +939,7 @@ class LoanBroker_test : public beast::unit_test::Suite
             env.close();
             env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)),
                 Ter(tecINSUFFICIENT_FUNDS));
-
-            // preclaim: tecFROZEN
-            env(fset(issuer, asfGlobalFreeze));
-            env.close();
-            env(coverDeposit(alice, brokerKeylet.key, vaultInfo.asset(10)), Ter(tecFROZEN));
+            // Freeze/lock tests are in testCoverDepositFreezes/testCoverWithdrawFreezes
         }
         else
         {
@@ -963,35 +965,20 @@ class LoanBroker_test : public beast::unit_test::Suite
             // preclaim: tecDST_TAG_NEEDED
             Account const dest{"dest"};
             env.fund(XRP(1'000), dest);
+
             env(fset(dest, asfRequireDest));
-            env.close();
             env(coverWithdraw(alice, brokerKeylet.key, asset(10)),
                 kDestination(dest),
                 Ter(tecDST_TAG_NEEDED));
+            env(fclear(dest, asfRequireDest));
 
             // preclaim: tecNO_PERMISSION
-            env(fclear(dest, asfRequireDest));
             env(fset(dest, asfDepositAuth));
-            env.close();
             env(coverWithdraw(alice, brokerKeylet.key, asset(10)),
                 kDestination(dest),
                 Ter(tecNO_PERMISSION));
-
-            // preclaim: tecFROZEN
-            env(trust(dest, asset(1'000)));
             env(fclear(dest, asfDepositAuth));
-            env(fset(issuer, asfGlobalFreeze));
-            env.close();
-            env(coverWithdraw(alice, brokerKeylet.key, asset(10)),
-                kDestination(dest),
-                Ter(tecFROZEN));
-
-            // preclaim:: tecFROZEN (deep frozen)
-            env(fclear(issuer, asfGlobalFreeze));
-            env(trust(issuer, asset(1'000), dest, tfSetFreeze | tfSetDeepFreeze));
-            env(coverWithdraw(alice, brokerKeylet.key, asset(10)),
-                kDestination(dest),
-                Ter(tecFROZEN));
+            // Freeze/lock tests are in testCoverDepositFreezes/testCoverWithdrawFreezes
 
             // preclaim: tecPSEUDO_ACCOUNT
             env(coverWithdraw(alice, brokerKeylet.key, asset(10)),
@@ -1228,7 +1215,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         env.close();
 
         // Predict LoanBroker key using alice's current sequence BEFORE submit
-        auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
+        auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
 
         // Create LoanBroker pointing to the vault
         env(loanBroker::set(alice, vaultKeylet.key));
@@ -1247,7 +1234,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         beast::Journal const jlog{sink};
         ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
 
-        if (auto sleBroker = ac.view().peek(keylet::loanbroker(brokerKeylet.key)))
+        if (auto sleBroker = ac.view().peek(keylet::loanBroker(brokerKeylet.key)))
         {
             auto const vaultID = (*sleBroker)[sfVaultID];
             if (auto sleVault = ac.view().peek(keylet::vault(vaultID)))
@@ -1334,7 +1321,7 @@ class LoanBroker_test : public beast::unit_test::Suite
                 err);
         });
 
-        auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
+        auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
         // Can create LoanBroker if the vault owner is not authorized
         forUnauthAuth([&](auto) { env(set(alice, vaultInfo.vaultID)); });
 
@@ -1412,7 +1399,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)}));
         env.close();
 
-        auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
+        auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
         env(set(alice, vaultInfo.vaultID));
         env.close();
 
@@ -1500,7 +1487,7 @@ class LoanBroker_test : public beast::unit_test::Suite
                 Ter(err));
             env.close();
 
-            auto const brokerKeylet = keylet::loanbroker(broker, env.seq(broker));
+            auto const brokerKeylet = keylet::loanBroker(broker, env.seq(broker));
 
             env(loanBroker::set(broker, keylet.key));
             env.close();
@@ -1576,6 +1563,649 @@ class LoanBroker_test : public beast::unit_test::Suite
         env(loanBroker::set(lender, vaultKeylet.key), Ter(tecFROZEN));
     }
 
+    void
+    testLoanBrokerDeleteLockedMPT(FeatureBitset features)
+    {
+        testcase << "LoanBrokerDelete - locked broker pseudo-account MPT";
+        using namespace jtx;
+        using namespace loanBroker;
+
+        Account const issuer("issuer");
+        Account const alice("alice");
+
+        auto const withFix = features[fixCleanup3_2_0];
+        Env env(*this, features);
+        env.fund(XRP(100'000), issuer, alice);
+        env.close();
+
+        // Create MPT with locking enabled
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+
+        PrettyAsset const mpt{mptt.issuanceID()};
+
+        // Fund alice
+        mptt.authorize({.account = alice});
+        env.close();
+        env(pay(issuer, alice, mpt(100'000)));
+        env.close();
+
+        // Create vault
+        Vault const vault{env};
+        auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = mpt});
+        env(tx);
+        env.close();
+
+        // Deposit into vault
+        env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = mpt(10'000)}));
+        env.close();
+
+        // Create loan broker
+        auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+        env(set(alice, vaultKeylet.key));
+        env.close();
+
+        // Deposit cover
+        env(coverDeposit(alice, brokerKeylet.key, mpt(5'000).value()));
+        env.close();
+
+        // Verify cover is deposited
+        auto const broker = env.le(brokerKeylet);
+        if (!BEAST_EXPECT(broker))
+            return;
+        BEAST_EXPECT(broker->at(sfCoverAvailable) > 0);
+
+        // Get the broker pseudo-account ID
+        auto const brokerPseudoID = broker->at(sfAccount);
+
+        // Verify the broker pseudo-account has an MPToken
+        auto const pseudoMptKey = keylet::mptoken(mptt.issuanceID(), brokerPseudoID);
+        auto const pseudoMpt = env.le(pseudoMptKey);
+        if (!BEAST_EXPECT(pseudoMpt))
+            return;
+
+        // Issuer locks the broker pseudo-account's individual MPToken
+        {
+            json::Value jv;
+            jv[jss::Account] = issuer.human();
+            jv[sfMPTokenIssuanceID] = to_string(mptt.issuanceID());
+            jv[jss::Holder] = toBase58(brokerPseudoID);
+            jv[jss::TransactionType] = jss::MPTokenIssuanceSet;
+            jv[jss::Flags] = tfMPTLock;
+            env(jv);
+            env.close();
+        }
+
+        // Verify the pseudo-account's MPToken is now locked
+        {
+            auto const sle = env.le(pseudoMptKey);
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(sle->isFlag(lsfMPTLocked));
+        }
+
+        // Record alice's balance before deletion
+        auto const aliceBalanceBefore = env.balance(alice, mpt);
+
+        // With fixCleanup3_2_0, preclaim() checks the broker pseudo-account's
+        // freeze/lock state via checkFrozen(), so deletion is blocked.
+        // Without the fix, the check is missing and the locked cover is
+        // returned to the owner.
+        if (withFix)
+        {
+            env(del(alice, brokerKeylet.key), Ter(tecLOCKED));
+            env.close();
+
+            // Verify the broker is not deleted
+            BEAST_EXPECT(env.le(brokerKeylet) != nullptr);
+
+            // Verify alice did not receive the cover despite the lock
+            auto const aliceBalanceAfter = env.balance(alice, mpt);
+            BEAST_EXPECT(aliceBalanceAfter == aliceBalanceBefore);
+
+            // Verify the locked MPToken was not deleted
+            BEAST_EXPECT(env.le(pseudoMptKey) != nullptr);
+        }
+        else
+        {
+            env(del(alice, brokerKeylet.key), Ter(tesSUCCESS));
+            env.close();
+
+            // Verify the broker is deleted
+            BEAST_EXPECT(env.le(brokerKeylet) == nullptr);
+
+            // Verify alice received the cover despite the lock
+            auto const aliceBalanceAfter = env.balance(alice, mpt);
+            BEAST_EXPECT(aliceBalanceAfter > aliceBalanceBefore);
+
+            // Verify the locked MPToken was deleted
+            BEAST_EXPECT(env.le(pseudoMptKey) == nullptr);
+        }
+    }
+
+    void
+    testLoanBrokerDeleteFrozenIOU(FeatureBitset features)
+    {
+        testcase << "LoanBrokerDelete - frozen broker pseudo-account IOU";
+        using namespace jtx;
+        using namespace loanBroker;
+
+        Account const issuer("issuer");
+        Account const alice("alice");
+
+        auto const withFix = features[fixCleanup3_2_0];
+        std::string logs;
+        Env env(*this, features, std::make_unique(&logs));
+        env.fund(XRP(100'000), issuer, alice);
+        env.close();
+
+        auto const iou = issuer["IOU"];
+
+        // Set up trust lines and fund alice
+        env(trust(alice, iou(1'000'000)));
+        env.close();
+        env(pay(issuer, alice, iou(100'000)));
+        env.close();
+
+        // Create vault
+        Vault const vault{env};
+        auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = iou.asset()});
+        env(tx);
+        env.close();
+
+        // Deposit into vault
+        env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = iou(10'000)}));
+        env.close();
+
+        // Create loan broker
+        auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+        env(set(alice, vaultKeylet.key));
+        env.close();
+
+        // Deposit cover
+        env(coverDeposit(alice, brokerKeylet.key, iou(5'000)));
+        env.close();
+
+        // Verify cover is deposited
+        auto const broker = env.le(brokerKeylet);
+        if (!BEAST_EXPECT(broker))
+            return;
+        BEAST_EXPECT(broker->at(sfCoverAvailable) > 0);
+
+        // Get the broker pseudo-account
+        auto const brokerPseudoID = broker->at(sfAccount);
+        auto const brokerPseudo = Account("BrokerPseudo", brokerPseudoID);
+
+        // Issuer freezes the broker pseudo-account's trust line
+        env(trust(issuer, brokerPseudo["IOU"](0), tfSetFreeze));
+        env.close();
+
+        // Record alice's balance before deletion attempt
+        auto const aliceBalanceBefore = env.balance(alice, iou);
+
+        // With fixCleanup3_2_0, preclaim() checks the broker
+        // pseudo-account's freeze state via checkFrozen(), so
+        // deletion is blocked early with tecFROZEN.
+        // Without the fix, preclaim() does not check the pseudo-account,
+        // but the TransfersNotFrozen invariant catches the frozen transfer
+        // in doApply() and fails with tecINVARIANT_FAILED.
+        // Either way, the broker survives and alice's balance is unchanged.
+        if (withFix)
+        {
+            env(del(alice, brokerKeylet.key), Ter(tecFROZEN));
+        }
+        else
+        {
+            env(del(alice, brokerKeylet.key), Ter(tecINVARIANT_FAILED));
+        }
+        env.close();
+
+        // Broker still exists
+        BEAST_EXPECT(env.le(brokerKeylet) != nullptr);
+
+        // Alice's balance unchanged
+        auto const aliceBalanceAfter = env.balance(alice, iou);
+        BEAST_EXPECT(aliceBalanceAfter == aliceBalanceBefore);
+    }
+
+    void
+    testCoverDepositFreezes()
+    {
+        using namespace jtx;
+        using namespace loanBroker;
+
+        Account const issuer{"issuer"};
+        Account const alice{"alice"};
+
+        // === IOU ===
+        {
+            testcase("LoanBrokerCoverDeposit IOU freeze checks");
+            Env env(*this);
+            Vault const vault{env};
+
+            env.fund(XRP(100'000), issuer, alice);
+            env(trust(alice, issuer["IOU"](1'000'000)));
+            env.close();
+            PrettyAsset const asset(issuer["IOU"]);
+            env(pay(issuer, alice, asset(100'000)));
+            env.close();
+
+            auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = asset});
+            env(tx);
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)}));
+            env.close();
+
+            auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+            env(set(alice, vaultKeylet.key));
+            env.close();
+
+            auto const broker = env.le(brokerKeylet);
+            if (!BEAST_EXPECT(broker))
+                return;
+            Account const brokerPseudo("pseudo", broker->at(sfAccount));
+
+            env(coverDeposit(alice, brokerKeylet.key, asset(10)));
+            env.close();
+
+            auto runTests = [&]() {
+                auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
+
+                // Global freeze
+                env(fset(issuer, asfGlobalFreeze));
+                env(coverDeposit(alice, brokerKeylet.key, asset(1)), Ter(tecFROZEN));
+                env(fclear(issuer, asfGlobalFreeze));
+
+                // Source regular freeze
+                env(trust(issuer, asset(0), alice, tfSetFreeze));
+                env(coverDeposit(alice, brokerKeylet.key, asset(1)), Ter(tecFROZEN));
+                env(trust(issuer, asset(0), alice, tfClearFreeze));
+
+                // Source deep freeze
+                env(trust(issuer, asset(0), alice, tfSetFreeze | tfSetDeepFreeze));
+                env(coverDeposit(alice, brokerKeylet.key, asset(1)), Ter(tecFROZEN));
+                env(trust(issuer, asset(0), alice, tfClearFreeze | tfClearDeepFreeze));
+
+                // Pseudo regular freeze — post-fix blocks, pre-fix allows (BUG)
+                TER const pseudoTer = fix330Enabled ? TER(tecFROZEN) : TER(tesSUCCESS);
+                env(trust(issuer, asset(0), brokerPseudo, tfSetFreeze));
+                env(coverDeposit(alice, brokerKeylet.key, asset(1)), Ter(pseudoTer));
+                env(trust(issuer, asset(0), brokerPseudo, tfClearFreeze));
+
+                // Pseudo deep freeze
+                env(trust(issuer, asset(0), brokerPseudo, tfSetFreeze | tfSetDeepFreeze));
+                env(coverDeposit(alice, brokerKeylet.key, asset(1)), Ter(tecFROZEN));
+                env(trust(issuer, asset(0), brokerPseudo, tfClearFreeze | tfClearDeepFreeze));
+            };
+
+            runTests();
+            env.disableFeature(fixCleanup3_3_0);
+            runTests();
+            env.enableFeature(fixCleanup3_3_0);
+        }
+
+        // === MPT ===
+        {
+            testcase("LoanBrokerCoverDeposit MPT lock checks");
+            Env env(*this);
+            Vault const vault{env};
+
+            env.fund(XRP(100'000), issuer, alice);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const mpt{mptt.issuanceID()};
+
+            mptt.authorize({.account = alice});
+            env(pay(issuer, alice, mpt(100'000)));
+            env.close();
+
+            auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = mpt});
+            env(tx);
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = mpt(50)}));
+            env.close();
+
+            auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+            env(set(alice, vaultKeylet.key));
+            env.close();
+
+            auto const broker = env.le(brokerKeylet);
+            if (!BEAST_EXPECT(broker))
+                return;
+            Account const brokerPseudo("pseudo", broker->at(sfAccount));
+
+            env(coverDeposit(alice, brokerKeylet.key, mpt(10)));
+            env.close();
+
+            // For MPT isDeepFrozen == isFrozen, so all locks block in
+            // both pre- and post-fix. No behavioral difference.
+            auto runTests = [&]() {
+                // Global lock
+                mptt.set({.flags = tfMPTLock});
+                env.close();
+                env(coverDeposit(alice, brokerKeylet.key, mpt(1)), Ter(tecLOCKED));
+                mptt.set({.flags = tfMPTUnlock});
+                env.close();
+
+                // Source (alice) individual lock
+                mptt.set({.holder = alice, .flags = tfMPTLock});
+                env.close();
+                env(coverDeposit(alice, brokerKeylet.key, mpt(1)), Ter(tecLOCKED));
+                mptt.set({.holder = alice, .flags = tfMPTUnlock});
+                env.close();
+
+                // Pseudo individual lock
+                mptt.set({.holder = brokerPseudo, .flags = tfMPTLock});
+                env.close();
+                env(coverDeposit(alice, brokerKeylet.key, mpt(1)), Ter(tecLOCKED));
+                mptt.set({.holder = brokerPseudo, .flags = tfMPTUnlock});
+                env.close();
+            };
+
+            runTests();
+            env.disableFeature(fixCleanup3_3_0);
+            runTests();
+            env.enableFeature(fixCleanup3_3_0);
+        }
+    }
+
+    // Focused demonstration: a cover-withdraw submitter under a regular
+    // individual IOU freeze can still withdraw to themselves (self-withdrawal).
+    //
+    // Pre-fixCleanup3_3_0: the old code only checked the pseudo-account source
+    // and the destination for deep-freeze; it did not check the submitter's
+    // individual freeze at all. Self-withdrawal therefore always succeeded.
+    // Post-fixCleanup3_3_0: checkWithdrawFreeze explicitly skips the submitter
+    // freeze check when submitter == destination, preserving the same result.
+    void
+    testCoverWithdrawSelfWhileFrozen()
+    {
+        testcase("LoanBrokerCoverWithdraw IOU self-withdrawal while individually frozen");
+
+        using namespace jtx;
+        using namespace loanBroker;
+
+        Account const issuer{"issuer"};
+        Account const alice{"alice"};
+        Account const dest{"dest"};
+        Env env{*this};
+        Vault const vault{env};
+
+        env.fund(XRP(100'000), issuer, alice, dest);
+        env(trust(alice, issuer["IOU"](1'000'000)));
+        env(trust(dest, issuer["IOU"](1'000'000)));
+        env.close();
+
+        PrettyAsset const asset(issuer["IOU"]);
+        env(pay(issuer, alice, asset(100'000)));
+        env.close();
+
+        auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = asset});
+        env(vaultTx);
+        env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)}));
+        env.close();
+
+        auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+        env(set(alice, vaultKeylet.key));
+        env.close();
+
+        env(coverDeposit(alice, brokerKeylet.key, asset(10)));
+        env.close();
+
+        auto runTests = [&]() {
+            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
+
+            // Set a regular individual freeze on alice's IOU trustline.
+            env(trust(issuer, asset(0), alice, tfSetFreeze));
+            env.close();
+
+            // Self-withdrawal: submitter == destination (no sfDestination in tx).
+            // Both pre- and post-fixCleanup3_3_0 this succeeds:
+            //   pre-fix:  old code never checked the submitter's freeze.
+            //   post-fix: checkWithdrawFreeze skips submitter when submitter==dst.
+            env(coverWithdraw(alice, brokerKeylet.key, asset(1)), Ter(tesSUCCESS));
+
+            // Withdrawal to a third party is blocked by the submitter freeze
+            // under fixCleanup3_3_0; pre-fix it was not checked.
+            env(coverWithdraw(alice, brokerKeylet.key, asset(1)),
+                kDestination(dest),
+                Ter(fix330Enabled ? TER(tecFROZEN) : TER(tesSUCCESS)));
+
+            env(trust(issuer, asset(0), alice, tfClearFreeze));
+            env.close();
+        };
+
+        runTests();
+        env.disableFeature(fixCleanup3_3_0);
+        runTests();
+        env.enableFeature(fixCleanup3_3_0);
+    }
+
+    void
+    testCoverWithdrawFreezes()
+    {
+        using namespace jtx;
+        using namespace loanBroker;
+
+        Account const issuer{"issuer"};
+        Account const alice{"alice"};
+
+        // === IOU ===
+        {
+            testcase("LoanBrokerCoverWithdraw IOU freeze checks");
+            Env env(*this);
+            Vault const vault{env};
+
+            env.fund(XRP(100'000), issuer, alice);
+            env(trust(alice, issuer["IOU"](1'000'000)));
+            env.close();
+            PrettyAsset const asset(issuer["IOU"]);
+            env(pay(issuer, alice, asset(100'000)));
+            env.close();
+
+            auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = asset});
+            env(tx);
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)}));
+            env.close();
+
+            auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+            env(set(alice, vaultKeylet.key));
+            env.close();
+
+            auto const broker = env.le(brokerKeylet);
+            if (!BEAST_EXPECT(broker))
+                return;
+            Account const brokerPseudo("pseudo", broker->at(sfAccount));
+
+            env(coverDeposit(alice, brokerKeylet.key, asset(10)));
+            env.close();
+
+            Account const dest{"dest"};
+            env.fund(XRP(1'000), dest);
+            env(trust(dest, asset(1'000)));
+
+            auto runTests = [&]() {
+                auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
+                TER const expectedTec = fix330Enabled ? TER(tecFROZEN) : TER(tesSUCCESS);
+
+                // Global freeze
+                env(fset(issuer, asfGlobalFreeze));
+                env(coverWithdraw(alice, brokerKeylet.key, asset(1)),
+                    kDestination(dest),
+                    Ter(tecFROZEN));
+                env(fclear(issuer, asfGlobalFreeze));
+
+                // Source (pseudo) regular freeze
+                env(trust(issuer, asset(0), brokerPseudo, tfSetFreeze));
+                env(coverWithdraw(alice, brokerKeylet.key, asset(1)),
+                    kDestination(dest),
+                    Ter(tecFROZEN));
+                env(trust(issuer, asset(0), brokerPseudo, tfClearFreeze));
+
+                // Source (pseudo) deep freeze
+                env(trust(issuer, asset(0), brokerPseudo, tfSetFreeze | tfSetDeepFreeze));
+                env(coverWithdraw(alice, brokerKeylet.key, asset(1)),
+                    kDestination(dest),
+                    Ter(tecFROZEN));
+                env(trust(issuer, asset(0), brokerPseudo, tfClearFreeze | tfClearDeepFreeze));
+
+                // Submitter regular freeze → dest
+                env(trust(issuer, asset(0), alice, tfSetFreeze));
+                env(coverWithdraw(alice, brokerKeylet.key, asset(1)),
+                    kDestination(dest),
+                    Ter(expectedTec));
+                // Submitter regular freeze → self: always allowed
+                env(coverWithdraw(alice, brokerKeylet.key, asset(1)), Ter(tesSUCCESS));
+                env(trust(issuer, asset(0), alice, tfClearFreeze));
+                env(coverDeposit(
+                    alice, brokerKeylet.key, asset(isTesSuccess(expectedTec) ? 2 : 1)));
+
+                // Submitter deep freeze → dest
+                env(trust(issuer, asset(0), alice, tfSetFreeze | tfSetDeepFreeze));
+                env(coverWithdraw(alice, brokerKeylet.key, asset(1)),
+                    kDestination(dest),
+                    Ter(expectedTec));
+                // Submitter deep freeze → self: blocked (checkDeepFrozen)
+                env(coverWithdraw(alice, brokerKeylet.key, asset(1)), Ter(tecFROZEN));
+                env(trust(issuer, asset(0), alice, tfClearFreeze | tfClearDeepFreeze));
+                if (isTesSuccess(expectedTec))
+                    env(coverDeposit(alice, brokerKeylet.key, asset(1)));
+
+                // Destination regular freeze: only deep freeze blocks
+                env(trust(issuer, asset(0), dest, tfSetFreeze));
+                env(coverWithdraw(alice, brokerKeylet.key, asset(1)),
+                    kDestination(dest),
+                    Ter(tesSUCCESS));
+                env(trust(issuer, asset(0), dest, tfClearFreeze));
+                env(coverDeposit(alice, brokerKeylet.key, asset(1)));
+
+                // Destination deep freeze
+                env(trust(issuer, asset(0), dest, tfSetFreeze | tfSetDeepFreeze));
+                env(coverWithdraw(alice, brokerKeylet.key, asset(1)),
+                    kDestination(dest),
+                    Ter(tecFROZEN));
+                env(trust(issuer, asset(0), dest, tfClearFreeze | tfClearDeepFreeze));
+
+                // Submitter frozen → issuer: bypasses all freeze checks
+                env(trust(issuer, asset(0), alice, tfSetFreeze));
+                env(coverWithdraw(alice, brokerKeylet.key, asset(1)),
+                    kDestination(issuer),
+                    Ter(tesSUCCESS));
+                env(trust(issuer, asset(0), alice, tfClearFreeze));
+                env(coverDeposit(alice, brokerKeylet.key, asset(1)));
+            };
+
+            runTests();
+            env.disableFeature(fixCleanup3_3_0);
+            runTests();
+            env.enableFeature(fixCleanup3_3_0);
+        }
+
+        // === MPT ===
+        {
+            testcase("LoanBrokerCoverWithdraw MPT lock checks");
+            Env env(*this);
+            Vault const vault{env};
+
+            env.fund(XRP(100'000), issuer, alice);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const mpt{mptt.issuanceID()};
+
+            mptt.authorize({.account = alice});
+            env(pay(issuer, alice, mpt(100'000)));
+            env.close();
+
+            auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = mpt});
+            env(tx);
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = mpt(50)}));
+            env.close();
+
+            auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+            env(set(alice, vaultKeylet.key));
+            env.close();
+
+            auto const broker = env.le(brokerKeylet);
+            if (!BEAST_EXPECT(broker))
+                return;
+            Account const brokerPseudo("pseudo", broker->at(sfAccount));
+
+            env(coverDeposit(alice, brokerKeylet.key, mpt(10)));
+            env.close();
+
+            Account const dest{"dest"};
+            env.fund(XRP(1'000), dest);
+            mptt.authorize({.account = dest});
+            env.close();
+
+            auto runTests = [&]() {
+                auto const withFix = env.current()->rules().enabled(fixCleanup3_3_0);
+                // Only submitter-to-dest differs: post-fix blocks, pre-fix
+                // doesn't (BUG). All other locks block in both because for
+                // MPT isDeepFrozen == isFrozen.
+                TER const submitterToDest = withFix ? TER(tecLOCKED) : TER(tesSUCCESS);
+
+                // Global lock
+                mptt.set({.flags = tfMPTLock});
+                env.close();
+                env(coverWithdraw(alice, brokerKeylet.key, mpt(1)),
+                    kDestination(dest),
+                    Ter(tecLOCKED));
+                mptt.set({.flags = tfMPTUnlock});
+                env.close();
+
+                // Source (pseudo) individual lock
+                mptt.set({.holder = brokerPseudo, .flags = tfMPTLock});
+                env.close();
+                env(coverWithdraw(alice, brokerKeylet.key, mpt(1)),
+                    kDestination(dest),
+                    Ter(tecLOCKED));
+                mptt.set({.holder = brokerPseudo, .flags = tfMPTUnlock});
+                env.close();
+
+                // Submitter individual lock → dest
+                mptt.set({.holder = alice, .flags = tfMPTLock});
+                env.close();
+                env(coverWithdraw(alice, brokerKeylet.key, mpt(1)),
+                    kDestination(dest),
+                    Ter(submitterToDest));
+                // Submitter individual lock → self: blocked
+                env(coverWithdraw(alice, brokerKeylet.key, mpt(1)), Ter(tecLOCKED));
+                mptt.set({.holder = alice, .flags = tfMPTUnlock});
+                env.close();
+                if (isTesSuccess(submitterToDest))
+                    env(coverDeposit(alice, brokerKeylet.key, mpt(1)));
+                env.close();
+
+                // Dest individual lock: blocked
+                mptt.set({.holder = dest, .flags = tfMPTLock});
+                env.close();
+                env(coverWithdraw(alice, brokerKeylet.key, mpt(1)),
+                    kDestination(dest),
+                    Ter(tecLOCKED));
+                mptt.set({.holder = dest, .flags = tfMPTUnlock});
+                env.close();
+
+                // Submitter locked → issuer: bypasses all freeze checks
+                mptt.set({.holder = alice, .flags = tfMPTLock});
+                env.close();
+                env(coverWithdraw(alice, brokerKeylet.key, mpt(1)),
+                    kDestination(issuer),
+                    Ter(tesSUCCESS));
+                mptt.set({.holder = alice, .flags = tfMPTUnlock});
+                env(coverDeposit(alice, brokerKeylet.key, mpt(1)));
+                env.close();
+            };
+
+            runTests();
+            env.disableFeature(fixCleanup3_3_0);
+            runTests();
+            env.enableFeature(fixCleanup3_3_0);
+        }
+    }
+
     void
     testRIPD4274IOU()
     {
@@ -1668,7 +2298,7 @@ class LoanBroker_test : public beast::unit_test::Suite
             env(vault.withdraw({.depositor = broker, .id = keylet.key, .amount = token(1'000)}));
 
             // Test LoanBroker withdraw
-            auto const brokerKeylet = keylet::loanbroker(broker, env.seq(broker));
+            auto const brokerKeylet = keylet::loanBroker(broker, env.seq(broker));
 
             env(loanBroker::set(broker, keylet.key));
             env.close();
@@ -1796,7 +2426,7 @@ class LoanBroker_test : public beast::unit_test::Suite
             }
 
             // Test LoanBroker withdraw
-            auto const brokerKeylet = keylet::loanbroker(broker, env.seq(broker));
+            auto const brokerKeylet = keylet::loanBroker(broker, env.seq(broker));
 
             env(loanBroker::set(broker, keylet.key));
             env.close();
@@ -1823,18 +2453,233 @@ class LoanBroker_test : public beast::unit_test::Suite
         testRIPD4274MPT();
     }
 
+    // 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,
+    // and must silently succeed without changing sfCoverAvailable when off.
+    void
+    testCoverPrecisionGuard()
+    {
+        using namespace jtx;
+        using namespace loanBroker;
+
+        Account const issuer{"issuer"};
+        Account const alice{"alice"};
+
+        // sfCoverAvailable = 10 IOU → STAmount exponent = -14.
+        // Anything < 5e-15 rounds to zero at that scale.
+        // 1e-16 is the representative sub-ULP probe amount.
+
+        // Shared setup: funds accounts, creates a vault + broker with 10 IOU
+        // cover, and returns {brokerKeylet, iou}.
+        auto const setup = [&](Env& env) -> std::pair {
+            Vault const vault{env};
+
+            env.fund(XRP(100'000), issuer, alice);
+            env.close();
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const iou = issuer["IOU"];
+            env(trust(alice, iou(1'000'000)));
+            env.close();
+            env(pay(issuer, alice, iou(1'000)));
+            env.close();
+
+            auto [createTx, vaultKeylet] = vault.create({.owner = alice, .asset = iou});
+            env(createTx);
+            env.close();
+
+            auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+            env(set(alice, vaultKeylet.key));
+            env.close();
+
+            env(coverDeposit(alice, brokerKeylet.key, iou(10)));
+            env.close();
+
+            return {brokerKeylet, iou};
+        };
+
+        auto runTestCases = [&](FeatureBitset features) {
+            TER const expected =
+                features[fixCleanup3_2_0] ? TER{tecPRECISION_LOSS} : TER{tesSUCCESS};
+
+            {
+                testcase("Cover precision guard: Deposit zero-at-scale");
+                Env env{*this, features};
+                auto const [brokerKeylet, iou] = setup(env);
+                PrettyAmount const subUlpAmt = iou(Number{1, -16});
+                auto const coverBefore = env.le(brokerKeylet)->at(sfCoverAvailable);
+                env(coverDeposit(alice, brokerKeylet.key, subUlpAmt), Ter(expected));
+                env.close();
+                if (expected == tesSUCCESS)
+                {
+                    if (auto const broker = env.le(brokerKeylet); BEAST_EXPECT(broker))
+                        BEAST_EXPECT(broker->at(sfCoverAvailable) == coverBefore);
+                }
+            }
+
+            {
+                testcase("Cover precision guard: Deposit rounds down");
+                // Both cases succeed; post-fix the amount is rounded DOWN to
+                // cover scale first, so the delta differs from pre-fix
+                // Input: 1.8e-14 IOU (sub-scale at cover scale -14)
+                //   Pre-fix:  10 + 1.8e-14 → round-to-nearest →
+                //             10.00000000000002 → delta 2e-14
+                //   Post-fix: roundToScale(1.8e-14, -14, Downward) = 1e-14;
+                //             10 + 1e-14 = 10.00000000000001 → delta 1e-14
+                Env env{*this, features};
+                auto const [brokerKeylet, iou] = setup(env);
+                PrettyAmount const subUlpAmt = iou(Number{18, -15});
+                auto const coverBefore = env.le(brokerKeylet)->at(sfCoverAvailable);
+                env(coverDeposit(alice, brokerKeylet.key, subUlpAmt), Ter(tesSUCCESS));
+                env.close();
+                auto const brokerAfter = env.le(brokerKeylet);
+                if (!BEAST_EXPECT(brokerAfter))
+                    return;
+
+                Number const delta = features[fixCleanup3_2_0] ? Number{1, -14} : Number{2, -14};
+                BEAST_EXPECT(brokerAfter->at(sfCoverAvailable) - coverBefore == delta);
+            }
+
+            // Property: post-fix, when the user deposits `x` and cover
+            // gains `x'`, we always have 0 <= x - x' < 1 ULP at cover
+            // scale (cover holds 10 IOU → ULP = 1e-14). Pre-fix uses
+            // STAmount's default round-to-nearest during `+=`, which can
+            // over-deposit (x' > x), so the property only holds with
+            // fixCleanup3_2_0 enabled.
+            if (features[fixCleanup3_2_0])
+            {
+                testcase("Cover precision guard: Deposit rounding bound");
+                Env env{*this, features};
+                auto const [brokerKeylet, iou] = setup(env);
+                Number const oneUlp{1, -14};
+                // Each requested amount lies strictly between 1·ULP and
+                // 2·ULP at cover scale; post-fix `roundDown` credits
+                // exactly `oneUlp` and leaves a strictly-positive,
+                // strictly-sub-ULP residual.
+                for (Number const requested : {Number{11, -15}, Number{15, -15}, Number{19, -15}})
+                {
+                    auto const broker = env.le(brokerKeylet);
+                    if (!BEAST_EXPECT(broker))
+                        return;
+                    Number const coverBefore = broker->at(sfCoverAvailable);
+                    env(coverDeposit(alice, brokerKeylet.key, iou(requested)), Ter(tesSUCCESS));
+                    env.close();
+                    auto const brokerAfter = env.le(brokerKeylet);
+                    if (!BEAST_EXPECT(brokerAfter))
+                        return;
+                    Number const coverAfter = brokerAfter->at(sfCoverAvailable);
+                    Number const actual = coverAfter - coverBefore;
+                    Number const lost = requested - actual;
+                    BEAST_EXPECT(lost >= Number{0});
+                    BEAST_EXPECT(lost < oneUlp);
+                }
+            }
+
+            {
+                testcase("Cover precision guard: Withdraw");
+                Env env{*this, features};
+                auto const [brokerKeylet, iou] = setup(env);
+                PrettyAmount const subUlpAmt = iou(Number{1, -16});
+                auto const coverBefore = env.le(brokerKeylet)->at(sfCoverAvailable);
+                auto const aliceBalanceBefore = env.balance(alice, iou);
+                env(coverWithdraw(alice, brokerKeylet.key, subUlpAmt), Ter(expected));
+                env.close();
+                if (expected == tesSUCCESS)
+                {
+                    if (auto const broker = env.le(brokerKeylet); BEAST_EXPECT(broker))
+                        BEAST_EXPECT(broker->at(sfCoverAvailable) == coverBefore);
+                    BEAST_EXPECT(env.balance(alice, iou) == aliceBalanceBefore);
+                }
+            }
+
+            {
+                testcase("Cover precision guard: Clawback");
+                Env env{*this, features};
+                auto const [brokerKeylet, iou] = setup(env);
+                PrettyAmount const subUlpAmt = iou(Number{1, -16});
+                auto const coverBefore = env.le(brokerKeylet)->at(sfCoverAvailable);
+                env(coverClawback(issuer),
+                    kLoanBrokerId(brokerKeylet.key),
+                    kAmount(subUlpAmt),
+                    Ter(expected));
+                env.close();
+                if (expected == tesSUCCESS)
+                {
+                    if (auto const broker = env.le(brokerKeylet); BEAST_EXPECT(broker))
+                        BEAST_EXPECT(broker->at(sfCoverAvailable) == coverBefore);
+                }
+            }
+
+            // MPT amounts are integers; scale is 0; the guard never rejects a
+            // positive integer amount. Verify all three callsites pass with amendment on.
+            {
+                testcase("Cover precision guard: MPT min amount passes");
+                Env env{*this, all_};
+
+                env.fund(XRP(100'000), issuer, alice);
+                env.close();
+
+                MPTTester mptt{env, issuer, kMptInitNoFund};
+                mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+                env.close();
+
+                PrettyAsset const mptAsset = mptt["MPT"];
+                mptt.authorize({.account = alice});
+                env.close();
+
+                env(pay(issuer, alice, mptAsset(100)));
+                env.close();
+
+                Vault const vault{env};
+                auto [createTx, vaultKeylet] = vault.create({.owner = alice, .asset = mptAsset});
+                env(createTx);
+                env.close();
+
+                auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+                env(set(alice, vaultKeylet.key));
+                env.close();
+
+                env(coverDeposit(alice, brokerKeylet.key, mptAsset(10)));
+                env.close();
+
+                env(coverDeposit(alice, brokerKeylet.key, mptAsset(1)), Ter(tesSUCCESS));
+                env.close();
+
+                env(coverWithdraw(alice, brokerKeylet.key, mptAsset(1)), Ter(tesSUCCESS));
+                env.close();
+
+                env(coverClawback(issuer),
+                    kLoanBrokerId(brokerKeylet.key),
+                    kAmount(mptAsset(1)),
+                    Ter(tesSUCCESS));
+                env.close();
+            }
+        };
+
+        runTestCases(all_);
+        runTestCases(all_ - fixCleanup3_2_0);
+    }
+
 public:
     void
     run() override
     {
+        testInvalidLoanBrokerCoverClawback();
+        testInvalidLoanBrokerCoverDeposit();
+        testInvalidLoanBrokerCoverWithdraw();
+        testCoverDepositFreezes();
+        testCoverWithdrawFreezes();
+        testCoverWithdrawSelfWhileFrozen();
+
+        testCoverPrecisionGuard();
+
         testLoanBrokerSetDebtMaximum();
         testLoanBrokerCoverDepositNullVault();
 
         testDisabled();
         testLifecycle();
-        testInvalidLoanBrokerCoverClawback();
-        testInvalidLoanBrokerCoverDeposit();
-        testInvalidLoanBrokerCoverWithdraw();
         testInvalidLoanBrokerDelete();
         testInvalidLoanBrokerSet();
         testRequireAuth();
@@ -1844,6 +2689,11 @@ public:
 
         testRIPD4274();
 
+        testLoanBrokerDeleteLockedMPT(all_);
+        testLoanBrokerDeleteLockedMPT(all_ - fixCleanup3_2_0);
+
+        testLoanBrokerDeleteFrozenIOU(all_);
+        testLoanBrokerDeleteFrozenIOU(all_ - fixCleanup3_2_0);
         // TODO: Write clawback failure tests with an issuer / MPT that doesn't
         // have the right flags set.
     }
diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp
index a8132887fc..56a7cab47a 100644
--- a/src/test/app/Loan_test.cpp
+++ b/src/test/app/Loan_test.cpp
@@ -4,7 +4,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -14,6 +13,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -68,6 +68,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -89,8 +90,8 @@ class Loan_test : public beast::unit_test::Suite
 protected:
     // Ensure that all the features needed for Lending Protocol are included,
     // even if they are set to unsupported.
-    FeatureBitset const all_{jtx::testableAmendments()};
 
+    FeatureBitset const all_{jtx::testableAmendments()};
     std::string const iouCurrency_{"IOU"};
 
     void
@@ -109,7 +110,7 @@ protected:
             Account const bob{"bob"};
             env.fund(XRP(10000), alice, bob);
 
-            auto const keylet = keylet::loanbroker(alice, env.seq(alice));
+            auto const keylet = keylet::loanBroker(alice, env.seq(alice));
 
             using namespace std::chrono_literals;
             using namespace loan;
@@ -150,6 +151,10 @@ protected:
         TenthBips32 coverRateLiquidation = percentageToTenthBips(25);
         std::string data = {};  // NOLINT(readability-redundant-member-init)
         std::uint32_t flags = 0;
+        // If set, the vault is created with this sfScale value. Useful for
+        // tests that need finer loanScale to exercise rounding edge cases.
+        std::optional vaultScale =
+            std::nullopt;  // NOLINT(readability-redundant-member-init)
 
         [[nodiscard]] Number
         maxCoveredLoanValue(Number const& currentDebt) const
@@ -192,7 +197,7 @@ protected:
         [[nodiscard]] Keylet
         brokerKeylet() const
         {
-            return keylet::loanbroker(brokerID);
+            return keylet::loanBroker(brokerID);
         }
         [[nodiscard]] Keylet
         vaultKeylet() const
@@ -349,7 +354,7 @@ protected:
             std::uint32_t ownerCount) const
         {
             using namespace jtx;
-            if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+            if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID));
                 env.test.BEAST_EXPECT(brokerSle))
             {
                 TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)};
@@ -447,7 +452,7 @@ protected:
                     paymentRemaining,
                     1);
 
-                if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+                if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID));
                     env.test.BEAST_EXPECT(brokerSle))
                 {
                     if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID)));
@@ -503,6 +508,8 @@ protected:
         auto const coverRateMinValue = params.coverRateMin;
 
         auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
+        if (params.vaultScale)
+            tx[sfScale] = *params.vaultScale;
         env(tx);
         env.close();
         BEAST_EXPECT(env.le(vaultKeylet));
@@ -514,7 +521,7 @@ protected:
             BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value());
         }
 
-        auto const keylet = keylet::loanbroker(lender.id(), env.seq(lender));
+        auto const keylet = keylet::loanBroker(lender.id(), env.seq(lender));
 
         using namespace loanBroker;
         env(set(lender, vaultKeylet.key, params.flags),
@@ -607,7 +614,7 @@ protected:
     bool
     canImpairLoan(jtx::Env const& env, BrokerInfo const& broker, LoanState const& state)
     {
-        if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+        if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
             BEAST_EXPECT(brokerSle))
         {
             if (auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID)));
@@ -778,7 +785,7 @@ protected:
         BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams);
 
         auto const pseudoAcctOpt = [&]() -> std::optional {
-            auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
             if (!BEAST_EXPECT(brokerSle))
                 return std::nullopt;
             auto const brokerPseudo = brokerSle->at(sfAccount);
@@ -789,7 +796,7 @@ protected:
         Account const& pseudoAcct = *pseudoAcctOpt;
 
         auto const loanKeyletOpt = [&]() -> std::optional {
-            auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
             if (!BEAST_EXPECT(brokerSle))
                 return std::nullopt;
 
@@ -1200,7 +1207,8 @@ protected:
     runLoan(
         AssetType assetType,
         BrokerParameters const& brokerParams,
-        LoanParameters const& loanParams)
+        LoanParameters const& loanParams,
+        FeatureBitset features)
     {
         using namespace jtx;
 
@@ -1208,7 +1216,7 @@ protected:
         Account const lender("lender");
         Account const borrower("borrower");
 
-        Env env(*this, all_);
+        Env env(*this, features);
 
         auto loanResult =
             createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
@@ -1262,7 +1270,7 @@ protected:
             toEndOfLife)
     {
         auto const [keylet, loanSequence] = [&]() {
-            auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
             if (!BEAST_EXPECT(brokerSle))
             {
                 // will be invalid
@@ -1351,7 +1359,7 @@ protected:
 
         auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count();
 
-        if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+        if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
             BEAST_EXPECT(brokerSle))
         {
             BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1);
@@ -1532,7 +1540,7 @@ protected:
             borrowerStartingBalance.value() - adjustment);
         BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount);
 
-        if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+        if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
             BEAST_EXPECT(brokerSle))
         {
             BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
@@ -1608,7 +1616,7 @@ protected:
         auto const loanSetFee = Fee(env.current()->fees().base * 2);
 
         auto const pseudoAcct = [&]() {
-            auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
             if (!BEAST_EXPECT(brokerSle))
                 return Account{lender};
             auto const brokerPseudo = brokerSle->at(sfAccount);
@@ -1871,7 +1879,7 @@ protected:
         // XRP can not be frozen, but run through the loop anyway to test
         // the tecLIMIT_EXCEEDED case
         {
-            auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
             if (!BEAST_EXPECT(brokerSle))
                 return;
 
@@ -1995,7 +2003,7 @@ protected:
         // Finally! Create a loan
 
         auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) {
-            if (auto const brokerSle = env.le(keylet::loanbroker(brokerID));
+            if (auto const brokerSle = env.le(keylet::loanBroker(brokerID));
                 BEAST_EXPECT(brokerSle))
             {
                 auto const available = brokerSle->at(sfCoverAvailable);
@@ -2005,7 +2013,7 @@ protected:
             return Number{};
         };
         auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) {
-            if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+            if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
                 BEAST_EXPECT(brokerSle))
             {
                 BEAST_EXPECT(
@@ -2137,21 +2145,23 @@ protected:
                 // If the loan does not allow overpayments, send a payment that
                 // tries to make an overpayment. Do not include `txFlags`, so we
                 // don't end up duplicating the next test transaction.
-                env(pay(borrower,
-                        loanKeylet.key,
-                        STAmount{broker.asset, state.periodicPayment * Number{15, -1}},
-                        tfLoanOverpayment),
-                    Fee(XRPAmount{baseFee * (Number{15, -1} / kLoanPaymentsPerFeeIncrement + 1)}),
-                    Ter(tecNO_PERMISSION));
+                //
+                // fixCleanup3_1_3 gates tfLoanOverpayment as a valid flag:
+                // with fix on → preflight passes, apply returns tecNO_PERMISSION;
+                // with fix off → preflight rejects the flag, returns temINVALID_FLAG.
+                bool const hasFix313 = env.current()->rules().enabled(fixCleanup3_1_3);
+                STAmount const overpayAmount{broker.asset, state.periodicPayment * Number{15, -1}};
+                XRPAmount const overpayFee{
+                    baseFee * (Number{15, -1} / kLoanPaymentsPerFeeIncrement + 1)};
+                env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment),
+                    Fee(overpayFee),
+                    Ter(hasFix313 ? TER{tecNO_PERMISSION} : TER{temINVALID_FLAG}));
 
+                if (hasFix313)
                 {
                     env.disableFeature(fixCleanup3_1_3);
-                    env(pay(borrower,
-                            loanKeylet.key,
-                            STAmount{broker.asset, state.periodicPayment * Number{15, -1}},
-                            tfLoanOverpayment),
-                        Fee(XRPAmount{
-                            baseFee * (Number{15, -1} / kLoanPaymentsPerFeeIncrement + 1)}),
+                    env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment),
+                        Fee(overpayFee),
                         Ter(temINVALID_FLAG));
                     env.enableFeature(fixCleanup3_1_3);
                 }
@@ -2895,7 +2905,7 @@ protected:
     }
 
     void
-    testLoanSet()
+    testLoanSet(FeatureBitset features)
     {
         using namespace jtx;
 
@@ -2914,7 +2924,7 @@ protected:
                                   std::function mptTest,
                                   std::function iouTest,
                                   CaseArgs args = {}) {
-            Env env(*this, all_);
+            Env env(*this, features);
             env.fund(XRP(args.initialXRP), issuer, lender, borrower);
             env.close();
             if (args.requireAuth)
@@ -2979,9 +2989,9 @@ protected:
             }
 
             if (mptTest)
-                (mptTest)(env, brokers[0], mptt);
+                mptTest(env, brokers[0], mptt);
             if (iouTest)
-                (iouTest)(env, brokers[1]);
+                iouTest(env, brokers[1]);
         };
 
         testCase(
@@ -3122,7 +3132,7 @@ protected:
 
                 env(pay(borrower, issuer, broker.asset(10'000)));
                 env.close();
-                auto const trustline = keylet::line(borrower, broker.asset.raw().get());
+                auto const trustline = keylet::trustLine(borrower, broker.asset.raw().get());
                 auto const sleLine1 = env.le(trustline);
                 BEAST_EXPECT(sleLine1 == nullptr);
 
@@ -3215,7 +3225,7 @@ protected:
                 env.trust(broker.asset(0), lender);
                 env.close();
 
-                auto const trustline = keylet::line(lender, broker.asset.raw().get());
+                auto const trustline = keylet::trustLine(lender, broker.asset.raw().get());
                 auto const sleLine1 = env.le(trustline);
                 BEAST_EXPECT(sleLine1 != nullptr);
 
@@ -3452,14 +3462,14 @@ protected:
     }
 
     void
-    testLifecycle()
+    testLifecycle(FeatureBitset features)
     {
         testcase("Lifecycle");
         using namespace jtx;
 
         // Create 3 loan brokers: one for XRP, one for an IOU, and one for
         // an MPT. That'll require three corresponding SAVs.
-        Env env(*this, all_);
+        Env env(*this, features);
 
         Account const issuer{"issuer"};
         // For simplicity, lender will be the sole actor for the vault &
@@ -3525,7 +3535,7 @@ protected:
                 }
             }
 
-            if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+            if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID));
                 BEAST_EXPECT(brokerSle))
             {
                 BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
@@ -3536,7 +3546,7 @@ protected:
                     lender, broker.brokerID, STAmount(broker.asset, coverAvailable)));
                 env.close();
 
-                brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+                brokerSle = env.le(keylet::loanBroker(broker.brokerID));
                 BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0);
             }
             // Verify we can delete the loan broker
@@ -3546,7 +3556,7 @@ protected:
     }
 
     void
-    testSelfLoan()
+    testSelfLoan(FeatureBitset features)
     {
         testcase << "Self Loan";
 
@@ -3554,7 +3564,7 @@ protected:
         using namespace std::chrono_literals;
         // Create 3 loan brokers: one for XRP, one for an IOU, and one for
         // an MPT. That'll require three corresponding SAVs.
-        Env env(*this, all_);
+        Env env(*this, features);
 
         Account const issuer{"issuer"};
         // For simplicity, lender will be the sole actor for the vault &
@@ -3681,7 +3691,7 @@ protected:
     }
 
     void
-    testBatchBypassCounterparty()
+    testBatchBypassCounterparty(FeatureBitset features)
     {
         // From FIND-001
         testcase << "Batch Bypass Counterparty";
@@ -3692,7 +3702,7 @@ protected:
 
         using namespace jtx;
         using namespace std::chrono_literals;
-        Env env(*this, all_);
+        Env env(*this, features);
 
         Account const lender{"lender"};
         Account const borrower{"borrower"};
@@ -3748,14 +3758,14 @@ protected:
     }
 
     void
-    testWrongMaxDebtBehavior()
+    testWrongMaxDebtBehavior(FeatureBitset features)
     {
         // From FIND-003
         testcase << "Wrong Max Debt Behavior";
 
         using namespace jtx;
         using namespace std::chrono_literals;
-        Env env(*this, all_);
+        Env env(*this, features);
 
         Account const issuer{"issuer"};
         Account const lender{"lender"};
@@ -3768,7 +3778,7 @@ protected:
 
         BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
 
-        if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+        if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
             BEAST_EXPECT(brokerSle))
         {
             BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0);
@@ -3794,7 +3804,7 @@ protected:
     }
 
     void
-    testLoanPayComputePeriodicPaymentValidRateInvariant()
+    testLoanPayComputePeriodicPaymentValidRateInvariant(FeatureBitset features)
     {
         // From FIND-012
         testcase << "LoanPay xrpl::detail::computePeriodicPayment : "
@@ -3802,7 +3812,7 @@ protected:
 
         using namespace jtx;
         using namespace std::chrono_literals;
-        Env env(*this, all_);
+        Env env(*this, features);
 
         Account const issuer{"issuer"};
         Account const lender{"lender"};
@@ -3838,7 +3848,7 @@ protected:
         createJson["OverpaymentInterestRate"] = 1360;
         createJson["PaymentInterval"] = 727;
 
-        auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
+        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
         auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
         auto const keylet = keylet::loan(broker.brokerID, loanSequence);
 
@@ -3862,7 +3872,7 @@ protected:
     }
 
     void
-    testRPC()
+    testRPC(FeatureBitset features)
     {
         // This will expand as more test cases are added. Some functionality
         // is tested in other test functions.
@@ -3870,7 +3880,7 @@ protected:
 
         using namespace jtx;
 
-        Env env(*this, all_);
+        Env env(*this, features);
 
         auto lowerFee = [&]() {
             // Run the local fee back down.
@@ -4181,11 +4191,11 @@ protected:
             Env env(*this);
 
             auto getCoverBalance = [&](BrokerInfo const& brokerInfo, auto const& accountField) {
-                if (auto const le = env.le(keylet::loanbroker(brokerInfo.brokerID));
+                if (auto const le = env.le(keylet::loanBroker(brokerInfo.brokerID));
                     BEAST_EXPECT(le))
                 {
                     auto const account = le->at(accountField);
-                    if (auto const sleLine = env.le(keylet::line(account, iou));
+                    if (auto const sleLine = env.le(keylet::trustLine(account, iou));
                         BEAST_EXPECT(sleLine))
                     {
                         STAmount balance = sleLine->at(sfBalance);
@@ -4363,7 +4373,7 @@ protected:
         env.close();
 
         auto const pseudoBroker = [&]() -> std::optional {
-            if (auto brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID));
+            if (auto brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
                 BEAST_EXPECT(brokerSle))
             {
                 return Account{"pseudo", brokerSle->at(sfAccount)};
@@ -4541,7 +4551,7 @@ protected:
     }
 
     void
-    testAccountSendMptMinAmountInvariant()
+    testAccountSendMptMinAmountInvariant(FeatureBitset features)
     {
         // (From FIND-006)
         testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount "
@@ -4549,7 +4559,7 @@ protected:
 
         using namespace jtx;
         using namespace std::chrono_literals;
-        Env env(*this, all_);
+        Env env(*this, features);
 
         Account const issuer{"issuer"};
         Account const lender{"lender"};
@@ -4593,7 +4603,7 @@ protected:
         createJson["PaymentTotal"] = "2891743748";
         createJson["PrincipalRequested"] = "8516.98";
 
-        auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
+        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
 
         createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
         env(createJson, Ter(temINVALID));
@@ -4601,7 +4611,7 @@ protected:
     }
 
     void
-    testLoanPayDebtDecreaseInvariant()
+    testLoanPayDebtDecreaseInvariant(FeatureBitset features)
     {
         // From FIND-007
         testcase << "LoanPay xrpl::LoanPay::doApply : debtDecrease "
@@ -4610,7 +4620,7 @@ protected:
         using namespace jtx;
         using namespace std::chrono_literals;
         using namespace Lending;
-        Env env(*this, all_);
+        Env env(*this, features);
 
         Account const issuer{"issuer"};
         Account const lender{"lender"};
@@ -4654,7 +4664,7 @@ protected:
         createJson["PaymentTotal"] = 5678;
         createJson["PrincipalRequested"] = "9924.81";
 
-        auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
+        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
         auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
         auto const keylet = keylet::loan(broker.brokerID, loanSequence);
 
@@ -4663,7 +4673,7 @@ protected:
         env.close();
 
         auto const pseudoAcct = [&]() {
-            auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
             if (!BEAST_EXPECT(brokerSle))
                 return Account{lender};
             auto const brokerPseudo = brokerSle->at(sfAccount);
@@ -4694,14 +4704,14 @@ protected:
     }
 
     void
-    testLoanPayComputePeriodicPaymentValidTotalInterestInvariant()
+    testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(FeatureBitset features)
     {
         // From FIND-010
         testcase << "xrpl::loanComputePaymentParts : valid total interest";
 
         using namespace jtx;
         using namespace std::chrono_literals;
-        Env env(*this, all_);
+        Env env(*this, features);
 
         Account const issuer{"issuer"};
         Account const lender{"lender"};
@@ -4744,7 +4754,7 @@ protected:
         createJson["PaymentTotal"] = 1;
         createJson["PrincipalRequested"] = "0.000763058";
 
-        auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
+        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
         auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
         auto const keylet = keylet::loan(broker.brokerID, loanSequence);
 
@@ -4813,7 +4823,7 @@ protected:
         // There are enough payments due on this loan that it only needs to be
         // created once, and can be paid on multiple times. Just don't create a
         // gazillion test cases.
-        auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
+        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
         auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
         auto const keylet = keylet::loan(broker.brokerID, loanSequence);
 
@@ -4901,7 +4911,7 @@ protected:
     }
 
     void
-    testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant()
+    testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(FeatureBitset features)
     {
         // From FIND-009
         testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid "
@@ -4910,7 +4920,7 @@ protected:
         using namespace jtx;
         using namespace std::chrono_literals;
         using namespace Lending;
-        Env env(*this, all_);
+        Env env(*this, features);
 
         Account const issuer{"issuer"};
         Account const lender{"lender"};
@@ -4952,7 +4962,7 @@ protected:
         createJson["PaymentTotal"] = 5678;
         createJson["PrincipalRequested"] = "9924.81";
 
-        auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
+        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
         auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
         auto const keylet = keylet::loan(broker.brokerID, loanSequence);
 
@@ -5003,7 +5013,7 @@ protected:
     }
 
     void
-    testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant()
+    testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(FeatureBitset features)
     {
         // From FIND-008
         testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded";
@@ -5011,7 +5021,7 @@ protected:
         using namespace jtx;
         using namespace std::chrono_literals;
         using namespace Lending;
-        Env env(*this, all_);
+        Env env(*this, features);
 
         Account const issuer{"issuer"};
         Account const lender{"lender"};
@@ -5058,7 +5068,7 @@ protected:
         createJson["PaymentTotal"] = 5678;
         createJson["PrincipalRequested"] = "9924.81";
 
-        auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
+        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
         auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
         auto const keylet = keylet::loan(broker.brokerID, loanSequence);
 
@@ -5088,7 +5098,7 @@ protected:
     }
 
     void
-    testLoanNextPaymentDueDateOverflow()
+    testLoanNextPaymentDueDateOverflow(FeatureBitset features)
     {
         // For FIND-013
         testcase << "Prevent nextPaymentDueDate overflow";
@@ -5096,7 +5106,7 @@ protected:
         using namespace jtx;
         using namespace std::chrono_literals;
         using namespace Lending;
-        Env env(*this, all_);
+        Env env{*this, features};
 
         Account const issuer{"issuer"};
         Account const lender{"lender"};
@@ -5223,7 +5233,7 @@ protected:
         }
         {
             // Start date when the ledger is closed will be larger
-            auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
+            auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
             auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
             auto const keylet = keylet::loan(broker.brokerID, loanSequence);
 
@@ -5250,11 +5260,11 @@ protected:
         }
         {
             // Start date when the ledger is closed will be larger
-            auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
+            auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
             auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
             auto const keylet = keylet::loan(broker.brokerID, loanSequence);
 
-            auto const closeStartDate = (parentCloseTime() / 10 + 1) * 10;
+            auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10;
             auto const grace = 5'000;
             auto const interval = kMaxTime - closeStartDate - grace;
             auto const total = 1;
@@ -5283,7 +5293,7 @@ protected:
             env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2})));
 
             // Start date when the ledger is closed will be larger
-            auto const closeStartDate = (parentCloseTime() / 10 + 1) * 10;
+            auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10;
             auto const grace = 5'000;
             auto const maxLoanTime = kMaxTime - closeStartDate - grace;
             auto const total = [&]() {
@@ -5298,7 +5308,7 @@ protected:
             if (!BEAST_EXPECT(total != 0))
                 return;
 
-            auto const brokerState = env.le(keylet::loanbroker(broker.brokerID));
+            auto const brokerState = env.le(keylet::loanBroker(broker.brokerID));
             // Intentionally shadow the outer values
             auto const loanSequence = brokerState->at(sfLoanSequence);
             auto const keylet = keylet::loan(broker.brokerID, loanSequence);
@@ -5395,114 +5405,79 @@ protected:
     }
 
     void
-    testCoverDepositWithdrawNonTransferableMPT(FeatureBitset feature)
+    testLendingCanTradeDisabledNoImpact()
     {
-        testcase("CoverDeposit and CoverWithdraw reject MPT without CanTransfer");
+        testcase("Lending: CanTrade disabled has no impact");
         using namespace jtx;
+        using namespace loan;
         using namespace loanBroker;
 
-        Env env(*this, feature);
+        Env env(*this, all_);
 
         Account const issuer{"issuer"};
-        Account const alice{"alice"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
 
-        env.fund(XRP(100'000), issuer, alice);
+        env.fund(XRP(1'000'000), issuer, lender, borrower);
         env.close();
 
-        MPTTester mpt{env, issuer, kMptInitNoFund};
-
-        mpt.create({.flags = tfMPTCanTransfer, .mutableFlags = tmfMPTCanMutateCanTransfer});
-
+        MPTTester mpt(
+            {.env = env,
+             .issuer = issuer,
+             .holders = {lender, borrower},
+             .flags = tfMPTCanTransfer | tfMPTCanLock,
+             .mutableFlags = tmfMPTCanEnableCanTrade});
+        PrettyAsset const asset = mpt.issuanceID();
+        env(pay(issuer, lender, asset(10'000'000)));
+        env(pay(issuer, borrower, asset(100'000)));
         env.close();
 
-        PrettyAsset const asset = mpt["MPT"];
-        mpt.authorize({.account = alice});
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        // CanTrade is not set
+        env(offer(lender, XRP(1), asset(10)), Ter{tecNO_PERMISSION});
         env.close();
 
-        // Issuer can fund the holder even if CanTransfer is not set.
-        env(pay(issuer, alice, asset(100)));
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+
+        // New cover deposits still work.
+        env(coverDeposit(lender, broker.brokerID, asset(100)));
         env.close();
 
-        Vault const vault{env};
-        auto const [createTx, vaultKeylet] = vault.create({.owner = alice, .asset = asset});
-        env(createTx);
+        // New loan issuance still works.
+        env(loan::set(borrower, broker.brokerID, 1'000),
+            Sig(sfCounterpartySignature, lender),
+            loanSetFee);
+        env.close();
+        auto const loanKeylet = keylet::loan(broker.brokerID, 1);
+        BEAST_EXPECT(env.le(loanKeylet));
+
+        // Repayment still works.
+        env(pay(borrower, loanKeylet.key, asset(1'000)));
         env.close();
 
-        auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
-        env(set(alice, vaultKeylet.key));
+        // Cover withdrawal still works.
+        env(coverWithdraw(lender, broker.brokerID, asset(100)));
         env.close();
 
-        auto const brokerSle = env.le(brokerKeylet);
-        if (!BEAST_EXPECT(brokerSle))
-            return;
-
-        Account const pseudoAccount{"Loan Broker pseudo-account", brokerSle->at(sfAccount)};
-
-        // Remove CanTransfer after the broker is set up.
-        mpt.set({.mutableFlags = tmfMPTClearCanTransfer});
+        // Enable CanTrade and verify the DEX path is restored.
+        mpt.set({.mutableFlags = tmfMPTSetCanTrade});
         env.close();
 
-        // Standard Payment path should forbid third-party transfers.
-        auto const err = feature[featureMPTokensV2] ? tecNO_PERMISSION : tecNO_AUTH;
-        env(pay(alice, pseudoAccount, asset(1)), Ter(err));
+        env(offer(lender, XRP(1), asset(10)));
         env.close();
-
-        // Cover cannot be transferred to broker account
-        auto const depositAmount = asset(1);
-        env(coverDeposit(alice, brokerKeylet.key, depositAmount), Ter{tecNO_AUTH});
-        env.close();
-
-        if (auto const refreshed = env.le(brokerKeylet); BEAST_EXPECT(refreshed))
-        {
-            BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 0);
-            env.require(Balance(pseudoAccount, asset(0)));
-        }
-
-        // Set CanTransfer again and transfer some deposit
-        mpt.set({.mutableFlags = tmfMPTSetCanTransfer});
-        env.close();
-
-        env(coverDeposit(alice, brokerKeylet.key, depositAmount));
-        env.close();
-
-        if (auto const refreshed = env.le(brokerKeylet); BEAST_EXPECT(refreshed))
-        {
-            BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 1);
-            env.require(Balance(pseudoAccount, depositAmount));
-        }
-
-        // Remove CanTransfer after the deposit
-        mpt.set({.mutableFlags = tmfMPTClearCanTransfer});
-        env.close();
-
-        // Cover cannot be transferred from broker account
-        env(coverWithdraw(alice, brokerKeylet.key, depositAmount), Ter{tecNO_AUTH});
-        env.close();
-
-        // Set CanTransfer again and withdraw
-        mpt.set({.mutableFlags = tmfMPTSetCanTransfer});
-        env.close();
-
-        env(coverWithdraw(alice, brokerKeylet.key, depositAmount));
-        env.close();
-
-        if (auto const refreshed = env.le(brokerKeylet); BEAST_EXPECT(refreshed))
-        {
-            BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 0);
-            env.require(Balance(pseudoAccount, asset(0)));
-        }
     }
 
 #if LOAN_TODO
     void
-    testLoanPayLateFullPaymentBypassesPenalties()
+    testLoanPayLateFullPaymentBypassesPenalties(FeatureBitset features)
     {
         testcase("LoanPay full payment skips late penalties");
         using namespace jtx;
         using namespace loan;
         using namespace std::chrono_literals;
 
-        Env env(*this, all);
+        Env env(*this, features);
 
         Account const issuer{"issuer"};
         Account const lender{"lender"};
@@ -5522,7 +5497,7 @@ protected:
 
         auto const loanSetFee = Fee(env.current()->fees().base * 2);
 
-        auto const brokerPreLoan = env.le(keylet::loanbroker(broker.brokerID));
+        auto const brokerPreLoan = env.le(keylet::loanBroker(broker.brokerID));
         if (BEAST_EXPECT(brokerPreLoan); !brokerPreLoan.has_value())
             return;
 
@@ -5557,7 +5532,7 @@ protected:
         auto const overdueClose = tp{d{state1.nextPaymentDate + state1.paymentInterval}};
         env.close(overdueClose);
 
-        auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
         auto const loanSle = env.le(loanKeylet);
         if (!BEAST_EXPECT(brokerSle && loanSle))
             return;
@@ -5648,7 +5623,7 @@ protected:
     }
 
     void
-    testLoanCoverMinimumRoundingExploit()
+    testLoanCoverMinimumRoundingExploit(FeatureBitset features)
     {
         auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) {
             testcase << "LoanBrokerCoverClawback drains cover via rounding"
@@ -5658,7 +5633,7 @@ protected:
             using namespace loan;
             using namespace loanBroker;
 
-            Env env(*this, all);
+            Env env(*this, features);
 
             Account const issuer{"issuer"};
             Account const lender{"lender"};
@@ -5692,7 +5667,7 @@ protected:
             env(createTx);
             env.close();
 
-            auto const brokerBefore = env.le(keylet::loanbroker(broker.brokerID));
+            auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID));
             BEAST_EXPECT(brokerBefore);
             if (!brokerBefore)
                 return;
@@ -5709,7 +5684,7 @@ protected:
             env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID));
             env.close();
 
-            auto const brokerAfter = env.le(keylet::loanbroker(broker.brokerID));
+            auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID));
             BEAST_EXPECT(brokerAfter);
             if (!brokerAfter)
                 return;
@@ -5734,7 +5709,7 @@ protected:
 #endif
 
     void
-    testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic()
+    testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features)
     {
         // --- PoC Summary ----------------------------------------------------
         // Scenario: Borrower makes one periodic payment early (before next due)
@@ -5756,7 +5731,7 @@ protected:
         using namespace loan;
         using namespace std::chrono_literals;
 
-        Env env(*this, all_);
+        Env env{*this, features};
 
         Account const lender{"poc_lender4"};
         Account const borrower{"poc_borrower4"};
@@ -5801,7 +5776,7 @@ protected:
             kGracePeriod(grace),
             Fee(loanSetFee));
 
-        auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
         BEAST_EXPECT(brokerSle);
         auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
         auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
@@ -5833,7 +5808,7 @@ protected:
         auto after = getCurrentState(env, broker, loanKeylet);
         auto const loanSle = env.le(loanKeylet);
         BEAST_EXPECT(loanSle);
-        auto const brokerSle2 = env.le(keylet::loanbroker(broker.brokerID));
+        auto const brokerSle2 = env.le(keylet::loanBroker(broker.brokerID));
         BEAST_EXPECT(brokerSle2);
 
         auto const closePaymentFee = loanSle ? loanSle->at(sfClosePaymentFee) : Number{};
@@ -5959,13 +5934,13 @@ protected:
     }
 
     void
-    testDustManipulation()
+    testDustManipulation(FeatureBitset features)
     {
         testcase("Dust manipulation");
 
         using namespace jtx;
         using namespace std::chrono_literals;
-        Env env(*this, all_);
+        Env env{*this, features};
 
         // Setup: Create accounts
         Account const issuer{"issuer"};
@@ -5995,7 +5970,7 @@ protected:
         auto broker = createVaultAndBroker(env, asset, lender, brokerParams);
 
         auto const loanKeyletOpt = [&]() -> std::optional {
-            auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
             if (!BEAST_EXPECT(brokerSle))
                 return std::nullopt;
 
@@ -6099,7 +6074,7 @@ protected:
     }
 
     void
-    testRIPD3831()
+    testRIPD3831(FeatureBitset features)
     {
         using namespace jtx;
 
@@ -6126,7 +6101,7 @@ protected:
 
         auto const assetType = AssetType::XRP;
 
-        Env env(*this, all_);
+        Env env{*this, features};
 
         auto loanResult =
             createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
@@ -6172,7 +6147,7 @@ protected:
     }
 
     void
-    testRIPD3459()
+    testRIPD3459(FeatureBitset features)
     {
         testcase("RIPD-3459 - LoanBroker incorrect debt total");
 
@@ -6197,7 +6172,7 @@ protected:
 
         auto const assetType = AssetType::MPT;
 
-        Env env(*this, all_);
+        Env env{*this, features};
 
         auto loanResult =
             createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
@@ -6266,7 +6241,7 @@ protected:
             txFee);
         env.close();
 
-        auto const brokerKeyLet = keylet::loanbroker(lender.id(), env.seq(lender));
+        auto const brokerKeyLet = keylet::loanBroker(lender.id(), env.seq(lender));
 
         env(loanBroker::set(lender, vaultKeyLet.key), txFee);
         env.close();
@@ -6297,14 +6272,14 @@ protected:
     }
 
     void
-    testRoundingAllowsUndercoverage()
+    testRoundingAllowsUndercoverage(FeatureBitset features)
     {
         testcase("Minimum cover rounding allows undercoverage (XRP)");
 
         using namespace jtx;
         using namespace loanBroker;
 
-        Env env(*this, all_);
+        Env env{*this, features};
 
         Account const lender{"lender"};
         Account const borrower{"borrower"};
@@ -6340,7 +6315,7 @@ protected:
         env.close();
 
         // Verify DebtTotal is exactly 804
-        if (auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID));
+        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
             BEAST_EXPECT(brokerSle))
         {
             log << *brokerSle << std::endl;
@@ -6361,7 +6336,7 @@ protected:
         env.close();
 
         // Validate CoverAvailable == 80 XRP and DebtTotal remains 804
-        if (auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID));
+        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
             BEAST_EXPECT(brokerSle))
         {
             log << *brokerSle << std::endl;
@@ -6376,7 +6351,7 @@ protected:
     }
 
     void
-    testRIPD3902()
+    testRIPD3902(FeatureBitset features)
     {
         testcase("RIPD-3902 - 1 IOU loan payments");
 
@@ -6403,7 +6378,7 @@ protected:
 
         auto const assetType = AssetType::IOU;
 
-        Env env(*this, all_);
+        Env env{*this, features};
 
         auto loanResult =
             createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
@@ -6471,7 +6446,7 @@ protected:
                 txFee);
             env.close();
 
-            auto const brokerKeylet = keylet::loanbroker(broker.id(), env.seq(broker));
+            auto const brokerKeylet = keylet::loanBroker(broker.id(), env.seq(broker));
 
             env(loanBroker::set(broker, vaultKeylet.key), txFee);
             env.close();
@@ -6547,7 +6522,7 @@ protected:
     }
 
     void
-    testIssuerIsBorrower()
+    testIssuerIsBorrower(FeatureBitset features)
     {
         testcase("RIPD-4096 - Issuer as borrower");
 
@@ -6567,7 +6542,7 @@ protected:
 
         auto const assetType = AssetType::IOU;
 
-        Env env(*this, all_);
+        Env env{*this, features};
 
         auto loanResult =
             createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer);
@@ -6664,14 +6639,14 @@ protected:
     }
 
     void
-    testOverpaymentManagementFee()
+    testOverpaymentManagementFee(FeatureBitset features)
     {
         testcase("testOverpaymentManagementFee");
 
         using namespace jtx;
         using namespace loan;
 
-        Env env(*this, all_);
+        Env env{*this, features};
 
         Account const lender{"lender"}, borrower{"borrower"};
 
@@ -6717,7 +6692,7 @@ protected:
     }
 
     void
-    testLoanPayBrokerOwnerMissingTrustline()
+    testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features)
     {
         testcase << "LoanPay Broker Owner Missing Trustline (PoC)";
         using namespace jtx;
@@ -6726,7 +6701,7 @@ protected:
         Account const borrower("borrower");
         Account const broker("broker");
         auto const iou = issuer["IOU"];
-        Env env(*this, all_);
+        Env env(*this, features);
         env.fund(XRP(20'000), issuer, broker, borrower);
         env.close();
         // Set up trustlines and fund accounts
@@ -6755,7 +6730,7 @@ protected:
         env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{iou, additionalCover}));
         env.close();
         // Verify broker owner has a trustline
-        auto const brokerTrustline = keylet::line(broker, iou);
+        auto const brokerTrustline = keylet::trustLine(broker, iou);
         BEAST_EXPECT(env.le(brokerTrustline) != nullptr);
         // Broker owner deletes their trustline
         // First, pay any positive balance to issuer to zero it out
@@ -6774,7 +6749,7 @@ protected:
         // Verify trustline is still deleted
         BEAST_EXPECT(env.le(brokerTrustline) == nullptr);
         // Verify the service fee went to the broker pseudo-account
-        if (auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID));
+        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
             BEAST_EXPECT(brokerSle))
         {
             Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
@@ -6785,7 +6760,7 @@ protected:
     }
 
     void
-    testLoanPayBrokerOwnerUnauthorizedMPT()
+    testLoanPayBrokerOwnerUnauthorizedMPT(FeatureBitset features)
     {
         testcase << "LoanPay Broker Owner MPT unauthorized";
         using namespace jtx;
@@ -6795,7 +6770,7 @@ protected:
         Account const borrower("borrower");
         Account const broker("broker");
 
-        Env env(*this, all_);
+        Env env{*this, features};
         env.fund(XRP(20'000), issuer, broker, borrower);
         env.close();
 
@@ -6855,7 +6830,7 @@ protected:
         // Verify the MPT is still unauthorized.
         BEAST_EXPECT(env.le(brokerMpt) == nullptr);
         // Verify the service fee went to the broker pseudo-account
-        if (auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID));
+        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
             BEAST_EXPECT(brokerSle))
         {
             Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
@@ -6866,7 +6841,7 @@ protected:
     }
 
     void
-    testLoanPayBrokerOwnerNoPermissionedDomainMPT()
+    testLoanPayBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features)
     {
         testcase << "LoanPay Broker Owner without permissioned domain of the MPT";
         using namespace jtx;
@@ -6876,13 +6851,13 @@ protected:
         Account const borrower("borrower");
         Account const broker("broker");
 
-        Env env(*this, all_);
+        Env env{*this, features};
         env.fund(XRP(20'000), issuer, broker, borrower);
         env.close();
 
         auto credType = "credential1";
 
-        pdomain::Credentials const credentials1{{issuer, credType}};
+        pdomain::Credentials const credentials1 = {{.issuer = issuer, .credType = credType}};
         env(pdomain::setTx(issuer, credentials1));
         env.close();
 
@@ -6958,7 +6933,7 @@ protected:
         // Verify broker is still not authorized
         env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH));
         // Verify the service fee went to the broker pseudo-account
-        if (auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID));
+        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
             BEAST_EXPECT(brokerSle))
         {
             Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
@@ -6969,7 +6944,7 @@ protected:
     }
 
     void
-    testLoanSetBrokerOwnerNoPermissionedDomainMPT()
+    testLoanSetBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features)
     {
         testcase << "LoanSet Broker Owner without permissioned domain of the MPT";
         using namespace jtx;
@@ -6979,13 +6954,13 @@ protected:
         Account const borrower("borrower");
         Account const broker("broker");
 
-        Env env(*this, all_);
+        Env env{*this, features};
         env.fund(XRP(20'000), issuer, broker, borrower);
         env.close();
 
         auto credType = "credential1";
 
-        pdomain::Credentials const credentials1{{issuer, credType}};
+        pdomain::Credentials const credentials1{{.issuer = issuer, .credType = credType}};
         env(pdomain::setTx(issuer, credentials1));
         env.close();
 
@@ -7041,7 +7016,7 @@ protected:
     }
 
     void
-    testSequentialFLCDepletion()
+    testSequentialFLCDepletion(FeatureBitset features)
     {
         testcase << "First-Loss Capital Depletion on Sequential Defaults";
 
@@ -7049,7 +7024,7 @@ protected:
         using namespace loan;
         using namespace loanBroker;
 
-        Env env(*this, all_);
+        Env env{*this, features};
 
         Account const issuer{"issuer"};
         Account const lender{"lender"};
@@ -7194,7 +7169,7 @@ protected:
                 .coverDeposit = 500'000,
             });
         auto const [currentSeq, vaultKeylet] = [&]() {
-            auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID));
+            auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
             if (!BEAST_EXPECT(brokerSle))
                 return std::make_tuple(0u, keylet::unchecked(beast::kZero));
             auto const currentSeq = brokerSle->at(sfLoanSequence);
@@ -7301,7 +7276,7 @@ protected:
     // loss from an impaired loan, ensuring the invariant check properly
     // accounts for the loss.
     void
-    testWithdrawReflectsUnrealizedLoss()
+    testWithdrawReflectsUnrealizedLoss(FeatureBitset features)
     {
         using namespace jtx;
         using namespace loan;
@@ -7320,7 +7295,7 @@ protected:
         static constexpr std::uint32_t kLocalPaymentInterval = 600;
         static constexpr std::uint32_t kLocalPaymentTotal = 2;
 
-        Env env(*this, all_);
+        Env env{*this, features};
 
         // Setup accounts
         Account const issuer{"issuer"};
@@ -7366,7 +7341,7 @@ protected:
         env.close();
 
         // Create a loan
-        auto const sleBroker = env.le(keylet::loanbroker(broker.brokerID));
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
         if (!BEAST_EXPECT(sleBroker))
             return;
 
@@ -7427,6 +7402,538 @@ protected:
         attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS);
     }
 
+    // A residual overpayment can reduce the stored principal by one scale-unit
+    // *less* than computeOverpaymentComponents predicts, firing the
+    // "principal change agrees" XRPL_ASSERT_PARTS in doOverpayment:
+    //
+    //   trackedPrincipalDelta == principalOutstanding - newPrincipalOutstanding
+    //
+    // tryOverpayment re-amortizes the loan at the reduced principal, then
+    // re-derives the theoretical principal from the new periodic payment via
+    // (P * paymentFactor) / paymentFactor. That round-trip is not exact in
+    // Number's 19-digit arithmetic; a positive residual pushes the recomputed
+    // principal a hair above the exact grid point `oldPrincipal - delta`, and
+    // the Upward rounding in tryOverpayment then bumps it a full scale-unit
+    // higher. The principal therefore drops by `delta - 1 unit`, not `delta`.
+    //
+    // Concrete case (isolated, at the tryOverpayment level):
+    // A 100 USD loan at the minimum non-zero rate, 3 payments, loanScale -10.
+    // After one regular payment (principalOutstanding 66.6666666674) a residual overpayment of
+    // 0.049999998 yields trackedPrincipalDelta 0.048999998 but only reduces the principal by
+    // 0.0489999979 (newPrincipal 66.6176666695) — short by 1e-10.
+    //
+    // With fixCleanup3_2_0, tryOverpayment pins the new principal to the exact,
+    // on-grid reduction (oldPrincipal - trackedPrincipalDelta) instead of the
+    // lossy (P*factor)/factor round-trip, so the assertion holds and the
+    // overpayment applies cleanly. The three "principal change agrees" /
+    // "interest paid agrees" / "principal payment matches" assertions are
+    // gated behind the same amendment, so without it they are disabled (the
+    // server does not abort) and the loan keeps the pre-amendment computation.
+    //
+    // The test runs the same scenario under both amendment settings and checks
+    // the stored principal against a ground-truth value derived independently of
+    // the loan-state computation under test.
+    void
+    testBugOverpaymentPrincipalChange()
+    {
+        testcase("bug: doOverpayment asserts 'principal change agrees'");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace xrpl::detail;
+
+        struct Params
+        {
+            TenthBips32 interestRate;
+            TenthBips16 managementFeeRate;
+            std::uint32_t paymentTotal;
+            std::uint32_t paymentInterval;
+            std::int64_t principal;
+            Number overpayment;
+            TenthBips32 overpaymentInterestRate;
+            TenthBips32 overpaymentFeeRate;
+            std::optional vaultScale;
+        };
+
+        struct Result
+        {
+            Number principalOutstanding;  // stored principal after the LoanPay
+            Number expectedNewPrincipal;  // ground truth, independent of the fix
+            Number managementFeeChange;   // managementFeeOutstanding after - before
+            Number unit;                  // one scale-unit at the loan scale
+        };
+
+        auto runScenario = [this](FeatureBitset features, Params const& p) -> Result {
+            Env env(*this, features);
+
+            Account const issuer{"issuer"};
+            Account const lender{"vaultOwner"};
+            Account const borrower{"borrower"};
+
+            env.fund(XRP(1'000'000), issuer, lender, borrower);
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const iouAsset = issuer["USD"];
+            Asset const asset = iouAsset.raw();
+            STAmount const iouLimit{asset, Number{9'999'999'999'999'999LL}};
+            env(trust(lender, iouLimit));
+            env(trust(borrower, iouLimit));
+            env(pay(issuer, lender, iouAsset(1'000'000)));
+            env(pay(issuer, borrower, iouAsset(1'000'000)));
+            env.close();
+
+            auto const broker = createVaultAndBroker(
+                env,
+                iouAsset,
+                lender,
+                {.vaultDeposit = 900'000,
+                 .debtMax = 0,
+                 .managementFeeRate = p.managementFeeRate,
+                 .vaultScale = p.vaultScale});
+
+            auto const brokerSle = env.le(broker.brokerKeylet());
+            BEAST_EXPECT(brokerSle);
+            auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
+            auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
+
+            env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment),
+                Sig(sfCounterpartySignature, lender),
+                kInterestRate(p.interestRate),
+                kPaymentTotal(p.paymentTotal),
+                kPaymentInterval(p.paymentInterval),
+                kGracePeriod(p.paymentInterval),
+                kOverpaymentFee(p.overpaymentFeeRate),
+                kOverpaymentInterestRate(p.overpaymentInterestRate),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // The single LoanPay below makes one regular payment (the overpayment
+            // is smaller than one period) and leaves the residual as an
+            // overpayment.
+            auto const s = getCurrentState(env, broker, loanKeylet);
+            auto const periodicRate = loanPeriodicRate(s.interestRate, s.paymentInterval);
+            auto const onePeriod = computePaymentComponents(
+                env.current()->rules(),
+                asset,
+                s.loanScale,
+                s.totalValue,
+                s.principalOutstanding,
+                s.managementFeeOutstanding,
+                s.periodicPayment,
+                periodicRate,
+                s.paymentRemaining,
+                p.managementFeeRate);
+
+            // Ground truth: the stored principal must drop by exactly the regular
+            // payment's principal portion plus the overpayment's principal
+            // portion. computeOverpaymentComponents depends only on the
+            // overpayment amount and rates (not on the loan-state computation
+            // under test), so it is an independent oracle. Both components are
+            // computed under the same rules as the env so the payment factor
+            // matches.
+            auto const overpaymentComponents = computeOverpaymentComponents(
+                env.current()->rules(),
+                asset,
+                s.loanScale,
+                p.overpayment,
+                p.overpaymentInterestRate,
+                p.overpaymentFeeRate,
+                p.managementFeeRate);
+            Number const expectedNewPrincipal = s.principalOutstanding -
+                onePeriod.trackedPrincipalDelta - overpaymentComponents.trackedPrincipalDelta;
+
+            Number const managementFeeBefore = s.managementFeeOutstanding;
+
+            STAmount const payAmount{asset, onePeriod.trackedValueDelta + p.overpayment};
+            env(pay(borrower, loanKeylet.key, payAmount),
+                Txflags(tfLoanOverpayment),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const loanSle = env.le(loanKeylet);
+            BEAST_EXPECT(loanSle);
+
+            return Result{
+                .principalOutstanding = loanSle ? Number{loanSle->at(sfPrincipalOutstanding)} : 0,
+                .expectedNewPrincipal = expectedNewPrincipal,
+                .managementFeeChange =
+                    (loanSle ? Number{loanSle->at(sfManagementFeeOutstanding)} : Number{0}) -
+                    managementFeeBefore,
+                .unit = Number{1, s.loanScale}};
+        };
+
+        // Scenario 1: the original near-zero-rate principal reproduction
+        // (loanScale -10, no management fee). 0.049999998 is smaller than one
+        // period, so it stays a residual overpayment.
+        Params const principalCase{
+            .interestRate = TenthBips32{1},
+            .managementFeeRate = TenthBips16{0},
+            .paymentTotal = 3,
+            .paymentInterval = 60,
+            .principal = 100,
+            .overpayment = Number{49999998, -9},
+            .overpaymentInterestRate = TenthBips32{1000},
+            .overpaymentFeeRate = TenthBips32{1000},
+            .vaultScale = 1};
+
+        // With fixCleanup3_2_0 the stored principal lands exactly on the
+        // ground-truth grid point: it is reduced by exactly the overpayment's
+        // principal portion. This is the key correctness check: if the principal
+        // pin were removed (even with the assertions still gated off), the lossy
+        // (P * factor) / factor round-trip would leave the principal one
+        // scale-unit high and this would fail.
+        Result const fixed = runScenario(all_, principalCase);
+        BEAST_EXPECTS(
+            fixed.principalOutstanding == fixed.expectedNewPrincipal,
+            "fixed principal " + to_string(fixed.principalOutstanding) + " != expected " +
+                to_string(fixed.expectedNewPrincipal));
+
+        // Without the amendment the loan amortizes with the catastrophically
+        // cancelling near-zero payment factor, so its schedule (and ground truth)
+        // differ from the fixed case; the gated assertions keep the server from
+        // aborting and the overpayment still lands exactly on that schedule.
+        Result const legacy = runScenario(all_ - fixCleanup3_2_0, principalCase);
+        BEAST_EXPECTS(
+            legacy.principalOutstanding == legacy.expectedNewPrincipal,
+            "legacy principal " + to_string(legacy.principalOutstanding) + " != expected " +
+                to_string(legacy.expectedNewPrincipal));
+
+        // Scenario 2: a normal-rate loan with a 10% management fee. At a normal
+        // rate the payment factor is identical across the amendment, so toggling
+        // fixCleanup3_2_0 isolates the fix. This overpayment (found by search)
+        // lands on a state where both the principal and the management fee differ
+        // by one scale-unit between the fixed and legacy paths.
+        Params const feeCase{
+            .interestRate = TenthBips32{10000},
+            .managementFeeRate = TenthBips16{10000},
+            .paymentTotal = 6,
+            .paymentInterval = 30u * 24 * 60 * 60,
+            .principal = 1000,
+            .overpayment = Number{214367363, -10},
+            .overpaymentInterestRate = TenthBips32{0},
+            .overpaymentFeeRate = TenthBips32{0},
+            .vaultScale = std::nullopt};
+
+        Result const feeFixed = runScenario(all_, feeCase);
+        Result const feeLegacy = runScenario(all_ - fixCleanup3_2_0, feeCase);
+
+        // With the fix the principal is the exact reduction; without it the lossy
+        // (P * factor) / factor round-trip leaves it one scale-unit high.
+        BEAST_EXPECTS(
+            feeFixed.principalOutstanding == feeFixed.expectedNewPrincipal,
+            "fee-case fixed principal " + to_string(feeFixed.principalOutstanding) +
+                " != expected " + to_string(feeFixed.expectedNewPrincipal));
+        BEAST_EXPECTS(
+            feeLegacy.principalOutstanding == feeLegacy.expectedNewPrincipal + feeLegacy.unit,
+            "fee-case legacy principal " + to_string(feeLegacy.principalOutstanding) +
+                " != expected " + to_string(feeLegacy.expectedNewPrincipal + feeLegacy.unit));
+
+        // Management fee: the overpayment re-amortizes a fee-bearing loan, so the management fee
+        // outstanding drops.
+        //
+        // Unlike the principal that is already at the correct precision, the re-amortized
+        // management fee  is tenthBipsOfValue of the new schedule's gross interest, which depends
+        // on the recomputed periodic payment. So the expected change below is a pinned constant
+        // captured from a passing run a magic value only because there is nothing simpler to
+        // compare against.
+        //
+        // At the integration level, toggling the amendment also changes the regular payment's
+        // rounding so a fixed-vs-legacy comparison cannot isolate the overpayment management-fee
+        // fix.
+        BEAST_EXPECT(feeFixed.managementFeeChange == feeLegacy.managementFeeChange);
+        BEAST_EXPECTS(
+            (feeFixed.managementFeeChange == Number{-8219709543, -10}),
+            "fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange));
+    }
+
+    // A LoanSet with InterestRate = 1 (0.001% annualized, the minimum non-zero
+    // rate). At such a near-zero rate the closed-form payment factor
+    // (1 + r)^n - 1 cancels catastrophically.
+    //
+    // Without fixCleanup3_2_0 the resulting amortization is degenerate and the
+    // LoanSet is rejected with tecPRECISION_LOSS (no loan created). With the
+    // amendment, computePowerMinusOneHybrid uses a numerically-stable series
+    // expansion, so the loan is created and the scheduled payments
+    // (2 * periodicPayment) cover the principal — no economic underpayment
+    // (yield theft).
+    //
+    // The test runs the same LoanSet under both amendment settings and pins the
+    // exact outcome for each.
+    void
+    testLoanSetNearZeroInterestRateSucceeds()
+    {
+        testcase("LoanSet near-zero interest rate covers principal");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Number const principalRequested{1000};
+
+        struct Result
+        {
+            TER ter = tesSUCCESS;
+            bool created = false;
+            std::int32_t loanScale = 0;
+            Number principal;
+            Number totalValue;
+            Number managementFee;
+            Number periodicPayment;
+        };
+
+        auto runScenario = [&](FeatureBitset features, TER expectedTer) -> Result {
+            Env env(*this, features);
+
+            Account const issuer{"issuer"};
+            Account const lender{"vaultOwner"};
+            Account const borrower{"borrower"};
+
+            env.fund(XRP(1'000'000), issuer, lender, borrower);
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const iouAsset = issuer["USD"];
+            STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}};
+            env(trust(lender, iouLimit));
+            env(trust(borrower, iouLimit));
+            env(pay(issuer, lender, iouAsset(1'000'000)));
+            env(pay(issuer, borrower, iouAsset(1'000'000)));
+            env.close();
+
+            auto const broker = createVaultAndBroker(
+                env,
+                iouAsset,
+                lender,
+                {.vaultDeposit = 100'000, .debtMax = 0, .managementFeeRate = TenthBips16{0}});
+
+            auto const brokerSle = env.le(broker.brokerKeylet());
+            BEAST_EXPECT(brokerSle);
+            auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
+            auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
+
+            env(set(borrower, broker.brokerID, principalRequested),
+                Sig(sfCounterpartySignature, lender),
+                kInterestRate(TenthBips32{1}),
+                kPaymentTotal(2),
+                kPaymentInterval(400),
+                Fee(env.current()->fees().base * 2),
+                Ter(expectedTer));
+            env.close();
+
+            Result r;
+            r.ter = env.ter();
+            if (auto const loanSle = env.le(loanKeylet))
+            {
+                r.created = true;
+                r.loanScale = loanSle->at(sfLoanScale);
+                r.principal = loanSle->at(sfPrincipalOutstanding);
+                r.totalValue = loanSle->at(sfTotalValueOutstanding);
+                r.managementFee = loanSle->at(sfManagementFeeOutstanding);
+                r.periodicPayment = loanSle->at(sfPeriodicPayment);
+            }
+            return r;
+        };
+
+        Result const fixed = runScenario(all_, tesSUCCESS);
+        Result const legacy = runScenario(all_ - fixCleanup3_2_0, tecPRECISION_LOSS);
+
+        // Without the amendment, the catastrophically-cancelling closed-form
+        // payment factor produces a degenerate amortization that fails
+        // checkLoanGuards: the LoanSet is rejected with tecPRECISION_LOSS and no
+        // loan is created.
+        BEAST_EXPECT(legacy.ter == tecPRECISION_LOSS);
+        BEAST_EXPECT(!legacy.created);
+
+        // With the amendment the stable series expansion produces a valid loan
+        // at loanScale -10.
+        BEAST_EXPECT(fixed.ter == tesSUCCESS);
+        BEAST_EXPECT(fixed.created);
+        BEAST_EXPECT(fixed.loanScale == -10);
+        BEAST_EXPECT(fixed.principal == principalRequested);
+        BEAST_EXPECT((fixed.totalValue == Number{10000000001903, -10}));
+        BEAST_EXPECT(fixed.managementFee == beast::kZero);
+
+        // Periodic payment from the numerically-stable series expansion, and the
+        // scheduled total (2 * periodicPayment) which exceeds the 1000 principal
+        // — no economic underpayment / yield theft.
+        BEAST_EXPECT((fixed.periodicPayment == Number{5000000000951293762, -16}));
+        BEAST_EXPECT((fixed.periodicPayment * 2 == Number{1000000000190258752, -15}));
+        BEAST_EXPECT(fixed.periodicPayment * 2 > principalRequested);
+    }
+
+    // An overpayment whose residual amount has more precision than loanScale
+    // fires the isRounded(asset, overpayment, loanScale) assertion in
+    // computeOverpaymentComponents (and a downstream "interest paid agrees"
+    // assertion in doOverpayment). fixCleanup3_2_0 rounds the residual down
+    // to loanScale before passing it in. The pre-amendment path can't be
+    // tested here because the assertion fires in Debug builds and aborts
+    // the test process — see the PR description for context.
+    void
+    testBugOverpayUnroundedAmount()
+    {
+        testcase("bug: computeOverpaymentComponents isRounded assertion");
+
+        using namespace jtx;
+        using namespace loan;
+        Env env(*this, all_);
+
+        Account const issuer{"issuer"};
+        Account const lender{"vaultOwner"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(1'000'000), issuer, lender, borrower);
+        env(fset(issuer, asfDefaultRipple));
+        env.close();
+
+        PrettyAsset const iouAsset = issuer["USD"];
+        STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}};
+        env(trust(lender, iouLimit));
+        env(trust(borrower, iouLimit));
+        env(pay(issuer, lender, iouAsset(1'000'000)));
+        env(pay(issuer, borrower, iouAsset(1'000'000)));
+        env.close();
+
+        auto const broker = createVaultAndBroker(
+            env,
+            iouAsset,
+            lender,
+            {.vaultDeposit = 100'000,
+             .debtMax = 5000,
+             .managementFeeRate = TenthBips16{1000},
+             .vaultScale = 1});
+
+        auto const sleBroker = env.le(broker.brokerKeylet());
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanSequence = sleBroker->at(sfLoanSequence);
+        auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
+
+        using namespace loan;
+        env(set(borrower, broker.brokerID, Number{1000}, tfLoanOverpayment),
+            Sig(sfCounterpartySignature, lender),
+            kInterestRate(TenthBips32{10000}),
+            kPaymentTotal(12),
+            kPaymentInterval(60),
+            kGracePeriod(60),
+            kOverpaymentFee(TenthBips32{1000}),
+            kOverpaymentInterestRate(TenthBips32{1000}),
+            Fee(env.current()->fees().base * 2),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // periodic * 1.5 at 15-sig-digit precision: 125.000154585042. This
+        // has too many digits to round cleanly to loanScale=-10, so the
+        // overpayment residual fails the isRounded check.
+        STAmount const payAmount{iouAsset.raw(), Number{125'000'154'585'042LL, -12}};
+        env(pay(borrower, loanKeylet.key, payAmount), Txflags(tfLoanOverpayment), Ter(tesSUCCESS));
+        env.close();
+    }
+
+    // Regression for the dual-rounding fix at coarse (integer-MPT) scale.
+    //
+    // Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The
+    // amortization schedule produces a fractional principal
+    // (~0.47) which under round-to-nearest collapses to 0 in a single
+    // step, causing `doPayment`'s strict `>` assertion on principal to
+    // fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded
+    // upward (sticks at 1 across the first two periods) and only clears
+    // in the final payment.
+    //
+    // The test pays one period at a time across three LoanPay
+    // transactions and verifies the loan completes (paymentRemaining=0)
+    // with totals matching the loan's economics (1 principal + 2 interest).
+    void
+    testIntegerScalePrincipalSticks(FeatureBitset features)
+    {
+        // Without fixCleanup3_2_0, this behavior will abort the server, so
+        // don't run without it.
+        if (!features[fixCleanup3_2_0])
+            return;
+
+        testcase("edge: integer MPT principal stuck mid-loan completes via final");
+
+        using namespace jtx;
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000), issuer, lender, borrower);
+        env.close();
+
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer});
+        PrettyAsset const asset{mptt.issuanceID()};
+
+        mptt.authorize({.account = lender});
+        mptt.authorize({.account = borrower});
+
+        env(pay(issuer, lender, asset(10'000)));
+        env(pay(issuer, borrower, asset(10'000)));
+        env.close();
+
+        Vault const vault{env};
+        auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
+        env(vaultTx);
+        env.close();
+
+        env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)}));
+        env.close();
+
+        auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender));
+        env(loanBroker::set(lender, vaultKeylet.key),
+            loanBroker::kDebtMaximum(Number{100}),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const brokerStateBefore = env.le(brokerKeylet);
+        if (!BEAST_EXPECT(brokerStateBefore))
+            return;
+        auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
+        auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence);
+
+        env(loan::set(borrower, brokerKeylet.key, Number{1}),
+            Sig(sfCounterpartySignature, lender),
+            loan::kInterestRate(TenthBips32{50'000}),
+            loan::kPaymentTotal(3),
+            loan::kPaymentInterval(31'536'000),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const borrowerStart = env.balance(borrower, asset).value();
+
+        // Three separate periodic payments of 1 each. Expected per-period
+        // evolution at integer MPT scale (TVO = PO + interestDue +
+        // managementFeeDue):
+        //   start:        PO=1, TVO=3, paymentRemaining=3
+        //   after pay #1: PO=1, TVO=2, paymentRemaining=2  (principal sticks)
+        //   after pay #2: PO=1, TVO=1, paymentRemaining=1  (principal sticks)
+        //   after pay #3: PO=0, TVO=0, paymentRemaining=0  (final clears)
+        std::array const expectedPO{Number{1}, Number{1}, Number{0}};
+        std::array const expectedTVO{Number{2}, Number{1}, Number{0}};
+        std::array const expectedRemaining{2, 1, 0};
+
+        for (int i = 0; i < 3; ++i)
+        {
+            env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS));
+            env.close();
+
+            auto const sle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]);
+            BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]);
+            BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]);
+        }
+
+        // Borrower paid 3 total regardless of fee split (1 principal + 2
+        // interest+fee, matching loan economics).
+        auto const borrowerEnd = env.balance(borrower, asset).value();
+        BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value());
+    }
+
     // A near-zero interest rate on a 100 USD loan
     // produces total interest of ~6 units at loanScale -9. Numerical error
     // in the amortization formula pushes the theoretical principal above
@@ -7482,7 +7989,7 @@ protected:
         createJson["PaymentTotal"] = 3;
         createJson["PaymentInterval"] = 600;
 
-        auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
+        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
         auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
         auto const keylet = keylet::loan(broker.brokerID, loanSequence);
 
@@ -7565,7 +8072,7 @@ protected:
         createJson["PaymentTotal"] = 3;
         createJson["PaymentInterval"] = 600;
 
-        auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
+        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
         auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
         auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
         createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
@@ -7609,71 +8116,500 @@ protected:
                 to_string(tolerance));
     }
 
+    // Verify that LoanPay, LoanBrokerCoverWithdraw, and LoanSet all use the
+    // same vault-scale minimum cover when fixCleanup3_2_0 is enabled.
+    // Before the amendment, each transactor computed its minimum cover at a
+    // different precision (loanScale, debtScale, or the raw unrounded
+    // tenthBipsOfValue), which could lead to inconsistent decisions for the
+    // same broker state.  After the amendment all three use
+    // minimumBrokerCover at vaultScale.
+    void
+    testMinimumBrokerCoverConsistency(FeatureBitset features)
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace loanBroker;
+
+        bool const withAmendment = features[fixCleanup3_2_0];
+
+        struct Ctx
+        {
+            jtx::Account issuer;
+            jtx::Account lender;
+            jtx::Account borrower;
+            jtx::PrettyAsset iou;
+            BrokerInfo broker;
+            BrokerParameters brokerParams;
+        };
+
+        // Shared setup, parametrized by vaultDeposit (the only varying setup
+        // field across the three scenarios).  Each call runs in its own Env
+        // so multiple invocations within one scenario cannot interfere.
+        // The caller is responsible for invoking testcase(...) before the
+        // first runTest call of each scenario.
+        auto runTest = [&](Number vaultDeposit, auto&& body) {
+            Env env(*this, features);
+
+            Account const issuer{"issuer"};
+            Account const lender{"lender"};
+            Account const borrower{"borrower"};
+
+            env.fund(XRP(1'000'000'000), issuer, lender, borrower);
+            env.close();
+
+            // Enable clawback on the issuer *before* any trust lines exist
+            // (asfAllowTrustLineClawback requires an empty owner directory).
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const iou = issuer[iouCurrency_];
+            env(trust(lender, iou(1'000'000'000)));
+            env(trust(borrower, iou(1'000'000'000)));
+            env.close();
+            env(pay(issuer, lender, iou(100'000'000)));
+            env(pay(issuer, borrower, iou(100'000'000)));
+            env.close();
+
+            // 13.37% — non-round rate produces a messier minimum.
+            BrokerParameters const brokerParams{
+                .vaultDeposit = vaultDeposit,
+                .debtMax = 0,
+                .coverRateMin = TenthBips32{13'370},
+                .coverDeposit = 5'000,
+                .managementFeeRate = TenthBips16{500}};
+
+            BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams);
+
+            body(
+                env,
+                Ctx{.issuer = issuer,
+                    .lender = lender,
+                    .borrower = borrower,
+                    .iou = iou,
+                    .broker = broker,
+                    .brokerParams = brokerParams});
+        };
+
+        // Scenario 1 — LoanPay
+        //
+        // Verify that LoanPay's minimum cover check uses vault scale (not
+        // loan scale).  Before the amendment, different loans could produce
+        // different fee routing decisions for the same broker-level state.
+        // Small vault deposit => vaultScale = -12.
+        testcase("LoanPay minimum cover scale consistency");
+        {
+            struct LoanKeylets
+            {
+                Keylet tiny;
+                Keylet big;
+            };
+
+            // Create the tiny + big loans and reduce cover via clawback so
+            // that subsequent LoanPay calls hit the minimum-cover boundary.
+            // Used by the two pay-and-check sub-tests below so each can run
+            // in its own Env.
+            auto setupLoansAndClawback = [&](Env& env, Ctx const& c) -> std::optional {
+                Asset const asset{c.iou};
+
+                // Create the TINY loan first (while vaultScale is still
+                // small).  principal 0.01, 0% interest, 1 payment =>
+                // loanScale = vaultScale.
+                auto const brokerSle1 = env.le(keylet::loanBroker(c.broker.brokerID));
+                if (!BEAST_EXPECT(brokerSle1))
+                    return std::nullopt;
+                auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence);
+                auto const tinyLoanKeylet = keylet::loan(c.broker.brokerID, tinyLoanSeq);
+
+                env(set(c.borrower, c.broker.brokerID, Number{1, -2}),
+                    Sig(sfCounterpartySignature, c.lender),
+                    kInterestRate(TenthBips32{0}),
+                    kPaymentTotal(1),
+                    kPaymentInterval(86400 * 365),
+                    Fee(XRP(10)));
+                env.close();
+
+                // Create the BIG loan second.  100% annual interest over 20
+                // payments pushes totalValueOutstanding high enough that
+                // loanScale > vaultScale.
+                auto const brokerSle2 = env.le(keylet::loanBroker(c.broker.brokerID));
+                if (!BEAST_EXPECT(brokerSle2))
+                    return std::nullopt;
+                auto const bigLoanSeq = brokerSle2->at(sfLoanSequence);
+                auto const bigLoanKeylet = keylet::loan(c.broker.brokerID, bigLoanSeq);
+
+                env(set(c.borrower, c.broker.brokerID, Number{500}),
+                    Sig(sfCounterpartySignature, c.lender),
+                    kInterestRate(TenthBips32{100'000}),
+                    kPaymentTotal(20),
+                    kPaymentInterval(86400 * 365),
+                    Fee(XRP(10)));
+                env.close();
+
+                // The tiny loan's scale is frozen at the vault's pre-big-loan
+                // scale, so it is strictly smaller than the big loan's.
+                // After the big loan is created the vault absorbs its value,
+                // pushing vaultScale up to match bigLoanScale.
+                auto const tinyLoanSle = env.le(tinyLoanKeylet);
+                auto const bigLoanSle = env.le(bigLoanKeylet);
+                auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
+                if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(bigLoanSle) ||
+                    !BEAST_EXPECT(vaultSle))
+                    return std::nullopt;
+                if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) ||
+                    !BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) ||
+                    !BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11))
+                    return std::nullopt;
+
+                // Use issuer clawback to reduce cover to the minimum the
+                // clawback transactor allows.  Compute the amount as
+                // initialCover - expectedCoverAfter so we exercise the exact
+                // clawback rather than relying on the transactor to clip
+                // down.
+                //
+                // Before the amendment the clawback minimum is the
+                // *unrounded* tenthBipsOfValue — strictly less than the
+                // rounded-at-vaultScale minimum LoanPay uses for the big
+                // loan.  After the amendment both clawback and LoanPay use
+                // the same rounded minimum (via minimumBrokerCover), so
+                // cover lands exactly at that threshold.
+                Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15}
+                                                                : Number{1330651855688458000, -15};
+                Number const clawbackAmount =
+                    Number{c.brokerParams.coverDeposit} - expectedCoverAfter;
+
+                env(coverClawback(c.issuer),
+                    kLoanBrokerId(c.broker.brokerID),
+                    kAmount(STAmount{asset, clawbackAmount}));
+                env.close();
+
+                auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID));
+                if (!BEAST_EXPECT(brokerSle) ||
+                    !BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedCoverAfter))
+                    return std::nullopt;
+
+                return LoanKeylets{.tiny = tinyLoanKeylet, .big = bigLoanKeylet};
+            };
+
+            // Pay one loan and report whether the fee went to the broker's
+            // pseudo account (the fallback when cover < minimum) rather
+            // than to the owner.
+            auto feeGoesToPseudo = [&](Env& env, Ctx const& c, Keylet const& loanKeylet) -> bool {
+                Asset const asset{c.iou};
+                auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID));
+                if (!BEAST_EXPECT(brokerSle))
+                    return false;
+                auto const pseudoAcct = Account("pseudo", brokerSle->at(sfAccount));
+                auto const pseudoBefore = env.balance(pseudoAcct, c.iou);
+
+                auto const payLoan = env.le(loanKeylet);
+                if (!BEAST_EXPECT(payLoan))
+                    return false;
+                auto const periodicPayment = payLoan->at(sfPeriodicPayment);
+                auto const serviceFee = payLoan->at(sfLoanServiceFee);
+                std::int32_t const loanScale = payLoan->at(sfLoanScale);
+
+                auto const payment = roundPeriodicPayment(asset, periodicPayment, loanScale);
+                auto const payAmt = STAmount{asset, payment + serviceFee};
+
+                env(loan::pay(c.borrower, loanKeylet.key, payAmt), Fee(XRP(10)));
+                env.close();
+
+                auto const pseudoAfter = env.balance(pseudoAcct, c.iou);
+                return pseudoAfter.number() > pseudoBefore.number();
+            };
+
+            // Pay the BIG loan in its own Env so its outcome cannot affect
+            // the TINY-loan check.  With the fix, LoanPay and clawback use
+            // the same vaultScale minimum (cover == minAtVaultScale =>
+            // fee to owner).  Without the fix, LoanPay uses bigLoanScale=-11,
+            // rounds up to a larger minimum than what clawback used =>
+            // cover < min => fee to pseudo.
+            runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
+                auto const loans = setupLoansAndClawback(env, c);
+                if (!loans)
+                    return;
+                BEAST_EXPECT(feeGoesToPseudo(env, c, loans->big) == !withAmendment);
+            });
+
+            // Pay the TINY loan in its own Env.  Fee goes to the owner
+            // either way:
+            //  - With the fix: LoanPay uses vaultScale=-11 (same as
+            //    clawback) => owner.
+            //  - Without the fix: LoanPay uses tinyLoanScale=-12, rounds
+            //    up at -12 (a no-op) => min == cover => owner.
+            runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
+                auto const loans = setupLoansAndClawback(env, c);
+                if (!loans)
+                    return;
+                BEAST_EXPECT(!feeGoesToPseudo(env, c, loans->tiny));
+            });
+        }
+
+        // Scenario 2 — LoanBrokerCoverWithdraw
+        //
+        // Verify that CoverWithdraw's minimum cover check uses vault scale
+        // (not scale(debtTotal, asset)).  Before the amendment, CoverWithdraw
+        // used:
+        //   roundToAsset(asset, tenthBipsOfValue(debt, rate), scale(debt, asset))
+        // which could disagree with LoanPay's minimum (which used loanScale).
+        //
+        // Use a large vault deposit so that vaultScale (from AssetsTotal) is
+        // strictly larger than debtScale (from DebtTotal).  With
+        // vaultDeposit = 100,000: after the big loan
+        //   AssetsTotal ≈ 109,500 → vaultScale = -10
+        //   DebtTotal   ≈  10,000 → debtScale  = -11
+        // The one-order-of-magnitude gap makes roundToAsset at -10 truncate
+        // more aggressively than at -11, exposing the bug.
+        testcase("CoverWithdraw minimum cover scale consistency");
+        runTest(
+            /*vaultDeposit=*/100'000, [&](Env& env, Ctx const& c) {
+                Asset const asset{c.iou};
+
+                // Create only the big loan to push DebtTotal up to ~10,000
+                // while AssetsTotal stays around 109,500 (dominated by the
+                // large vault deposit).
+                env(set(c.borrower, c.broker.brokerID, Number{500}),
+                    Sig(sfCounterpartySignature, c.lender),
+                    kInterestRate(TenthBips32{100'000}),
+                    kPaymentTotal(20),
+                    kPaymentInterval(86400 * 365),
+                    Fee(XRP(10)));
+                env.close();
+
+                // Read broker state and compute both old and new minimums.
+                auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID));
+                auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
+                if (!BEAST_EXPECT(brokerSle) || !BEAST_EXPECT(vaultSle))
+                    return;
+
+                auto const coverAvail = brokerSle->at(sfCoverAvailable);
+                auto const debtTotal = brokerSle->at(sfDebtTotal);
+                auto const vaultScale = getAssetsTotalScale(vaultSle);
+                auto const debtScale = scale(debtTotal, asset);
+
+                // Sanity: debt scale differs from vault scale for this setup.
+                BEAST_EXPECT(debtScale < vaultScale);
+
+                auto const oldMin = [&]() {
+                    NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
+                    return roundToAsset(
+                        asset,
+                        tenthBipsOfValue(debtTotal, TenthBips32{c.brokerParams.coverRateMin}),
+                        debtScale);
+                }();
+                auto const newMin = minimumBrokerCover(
+                    debtTotal, TenthBips32{c.brokerParams.coverRateMin}, vaultSle);
+
+                // The new (vaultScale) minimum must be strictly larger than
+                // the old (debtScale) minimum — that is the gap the amendment
+                // closes.
+                Number const expectedNewMin{1330650518688500000, -15};
+                Number const expectedOldMin{1330650518688472000, -15};
+                BEAST_EXPECT(newMin == expectedNewMin);
+                BEAST_EXPECT(oldMin == expectedOldMin);
+
+                // Try to withdraw so that remaining cover lands between the
+                // two minimums:  oldMin < target < newMin.
+                auto const target = oldMin + (newMin - oldMin) / 2;
+                auto const withdrawAmount = STAmount{asset, coverAvail - target};
+
+                if (withAmendment)
+                {
+                    // CoverWithdraw now uses vaultScale: target < newMin
+                    // => FAILS.
+                    env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount),
+                        Ter(tecINSUFFICIENT_FUNDS));
+                }
+                else
+                {
+                    // Old CoverWithdraw uses debtScale: target > oldMin
+                    // => SUCCEEDS.
+                    env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount));
+                }
+                env.close();
+            });
+
+        // Scenario 3 — LoanSet
+        //
+        // Verify that LoanSet's minimum cover check uses vault scale (not the
+        // raw unrounded tenthBipsOfValue).  Before the amendment, LoanSet
+        // used tenthBipsOfValue(newDebtTotal, coverRateMinimum) (no
+        // roundToAsset), while clawback/withdraw used different formulas.
+        // After the amendment all use minimumBrokerCover at vaultScale, and
+        // rounding at a coarser scale can absorb a tiny debt increase —
+        // allowing a loan that would otherwise be rejected.
+        testcase("LoanSet minimum cover scale consistency");
+        runTest(
+            /*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
+                // Create the tiny loan (scale -12) AND the big loan (scale
+                // -11).  Both loans are needed so that DebtTotal has a full
+                // 16-digit mantissa — a "messy" value where roundToAsset at
+                // vaultScale actually truncates digits and produces a
+                // different result from the raw tenthBipsOfValue.  With only
+                // the big loan, DebtTotal has ~4 significant digits and
+                // rounding at scale -11 is a no-op, masking the amendment's
+                // effect.
+                env(set(c.borrower, c.broker.brokerID, Number{1, -2}),
+                    Sig(sfCounterpartySignature, c.lender),
+                    kInterestRate(TenthBips32{0}),
+                    kPaymentTotal(1),
+                    kPaymentInterval(86400 * 365),
+                    Fee(XRP(10)));
+                env.close();
+
+                env(set(c.borrower, c.broker.brokerID, Number{500}),
+                    Sig(sfCounterpartySignature, c.lender),
+                    kInterestRate(TenthBips32{100'000}),
+                    kPaymentTotal(20),
+                    kPaymentInterval(86400 * 365),
+                    Fee(XRP(10)));
+                env.close();
+
+                // Clawback to reduce cover to the clawback transactor's
+                // minimum.  Pass the exact amount rather than relying on the
+                // transactor to clip down; the setup matches Scenario 1 so
+                // the same residual-cover values apply.
+                Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15}
+                                                                : Number{1330651855688458000, -15};
+                Number const clawbackAmount =
+                    Number{c.brokerParams.coverDeposit} - expectedCoverAfter;
+                env(coverClawback(c.issuer),
+                    kLoanBrokerId(c.broker.brokerID),
+                    kAmount(c.iou(clawbackAmount)));
+                env.close();
+
+                // Verify scales.
+                auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                auto const vaultScale = getAssetsTotalScale(vaultSle);
+                BEAST_EXPECT(vaultScale == -11);
+
+                // Now try to create a tiny additional loan.  Principal is
+                // 1e-11 (the smallest value that survives the precision
+                // check at loanScale = vaultScale = -11), with 0% interest
+                // and 1 payment.
+                //
+                // The tiny debt increase adds ~1.337e-12 to the unrounded
+                // minimum.
+                // - Without the amendment: the old LoanSet formula rounds
+                //   up during tenthBipsOfValue (16-digit Number
+                //   normalisation), pushing the minimum past the cover left
+                //   by clawback => tecINSUFFICIENT_FUNDS.
+                // - With the amendment: minimumBrokerCover rounds at
+                //   vaultScale=-11, which absorbs the tiny increase — the
+                //   rounded minimum stays the same => tesSUCCESS.
+                auto const tinyPrincipal = Number{1, -11};
+
+                if (withAmendment)
+                {
+                    env(set(c.borrower, c.broker.brokerID, tinyPrincipal),
+                        Sig(sfCounterpartySignature, c.lender),
+                        kInterestRate(TenthBips32{0}),
+                        kPaymentTotal(1),
+                        kPaymentInterval(86400 * 365),
+                        Fee(XRP(10)));
+                }
+                else
+                {
+                    env(set(c.borrower, c.broker.brokerID, tinyPrincipal),
+                        Sig(sfCounterpartySignature, c.lender),
+                        kInterestRate(TenthBips32{0}),
+                        kPaymentTotal(1),
+                        kPaymentInterval(86400 * 365),
+                        Fee(XRP(10)),
+                        Ter(tecINSUFFICIENT_FUNDS));
+                }
+                env.close();
+            });
+    }
+
+    void
+    runAmendmentIndependent()
+    {
+        testDisabled();
+        testInvalidLoanSet();
+        testInvalidLoanDelete();
+        testInvalidLoanManage();
+        testInvalidLoanPay();
+        testIssuerLoan();
+        testServiceFeeOnBrokerDeepFreeze();
+        testRequireAuth();
+        testRIPD3901();
+        testBorrowerIsBroker();
+        testLimitExceeded();
+        testLendingCanTradeDisabledNoImpact();
+        testBugOverpaymentPrincipalChange();
+        testBugOverpayUnroundedAmount();
+
+        for (auto const flags : {0u, tfLoanOverpayment})
+            testYieldTheftRounding(flags);
+        testBugInterestDueDeltaCrash();
+        testFullLifecycleVaultPnLNearZeroRate();
+        testLoanSetNearZeroInterestRateSucceeds();
+    }
+
+    // Tests run under each entry in amendmentCombinations().
+    void
+    runAmendmentSensitive(FeatureBitset features)
+    {
+#if LOAN_TODO
+        testLoanPayLateFullPaymentBypassesPenalties(features);
+        testLoanCoverMinimumRoundingExploit(features);
+#endif
+        // Lifecycle
+        testLifecycle(features);
+        testLoanSet(features);
+        testDosLoanPay(features);
+        testSelfLoan(features);
+
+        // Payment paths
+        testWithdrawReflectsUnrealizedLoss(features);
+        testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(features);
+        testBatchBypassCounterparty(features);
+        testLoanNextPaymentDueDateOverflow(features);
+        testSequentialFLCDepletion(features);
+
+        // Invariants
+        testLoanPayComputePeriodicPaymentValidRateInvariant(features);
+        testAccountSendMptMinAmountInvariant(features);
+        testLoanPayDebtDecreaseInvariant(features);
+        testWrongMaxDebtBehavior(features);
+        testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(features);
+        testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(features);
+        testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(features);
+
+        // RPC
+        testRPC(features);
+
+        // Edge / rounding
+        testDustManipulation(features);
+        testRoundingAllowsUndercoverage(features);
+        testOverpaymentManagementFee(features);
+        testIssuerIsBorrower(features);
+        testIntegerScalePrincipalSticks(features);
+        testMinimumBrokerCoverConsistency(features);
+
+        // RIPD regressions
+        testRIPD3831(features);
+        testRIPD3459(features);
+        testRIPD3902(features);
+
+        // Broker-owner permissions
+        testLoanPayBrokerOwnerMissingTrustline(features);
+        testLoanPayBrokerOwnerUnauthorizedMPT(features);
+        testLoanPayBrokerOwnerNoPermissionedDomainMPT(features);
+        testLoanSetBrokerOwnerNoPermissionedDomainMPT(features);
+    }
+
 public:
     void
     run() override
     {
-#if LOAN_TODO
-        testLoanPayLateFullPaymentBypassesPenalties();
-        testLoanCoverMinimumRoundingExploit();
-#endif
-        for (auto const flags : {0u, tfLoanOverpayment})
-        {
-            testYieldTheftRounding(flags);
-        }
-
-        testBugInterestDueDeltaCrash();
-        testFullLifecycleVaultPnLNearZeroRate();
-
-        testWithdrawReflectsUnrealizedLoss();
-        testInvalidLoanSet();
-
-        auto const all = jtx::testableAmendments();
-        testCoverDepositWithdrawNonTransferableMPT(all);
-        testCoverDepositWithdrawNonTransferableMPT(all - featureMPTokensV2);
-        testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic();
-
-        testDisabled();
-        testSelfLoan();
-        testIssuerLoan();
-        testLoanSet();
-        testLifecycle();
-        testServiceFeeOnBrokerDeepFreeze();
-
-        testRPC();
-        testInvalidLoanDelete();
-        testInvalidLoanManage();
-        testInvalidLoanPay();
-
-        testBatchBypassCounterparty();
-        testLoanPayComputePeriodicPaymentValidRateInvariant();
-        testAccountSendMptMinAmountInvariant();
-        testLoanPayDebtDecreaseInvariant();
-        testWrongMaxDebtBehavior();
-        testLoanPayComputePeriodicPaymentValidTotalInterestInvariant();
-        testDosLoanPay(all | fixCleanup3_1_3);
-        testDosLoanPay(all - fixCleanup3_1_3);
-        testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant();
-        testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant();
-        testLoanNextPaymentDueDateOverflow();
-
-        testRequireAuth();
-        testDustManipulation();
-
-        testRIPD3831();
-        testRIPD3459();
-        testRIPD3901();
-        testRIPD3902();
-        testRoundingAllowsUndercoverage();
-        testBorrowerIsBroker();
-        testIssuerIsBorrower();
-        testLimitExceeded();
-        testOverpaymentManagementFee();
-        testLoanPayBrokerOwnerMissingTrustline();
-        testLoanPayBrokerOwnerUnauthorizedMPT();
-        testLoanPayBrokerOwnerNoPermissionedDomainMPT();
-        testLoanSetBrokerOwnerNoPermissionedDomainMPT();
-        testSequentialFLCDepletion();
+        runAmendmentIndependent();
+        for (auto const& features : jtx::amendmentCombinations(
+                 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
+            runAmendmentSensitive(features);
     }
 };
 
@@ -7738,7 +8674,7 @@ protected:
             .payInterval = payInterval,
         };
 
-        runLoan(assetType, brokerParams, loanParams);
+        runLoan(assetType, brokerParams, loanParams, all_);
     }
 
 public:
@@ -7797,7 +8733,7 @@ class LoanArbitrary_test : public LoanBatch_test
             .payTotal = 2,
             .payInterval = 200};
 
-        runLoan(AssetType::XRP, brokerParams, loanParams);
+        runLoan(AssetType::XRP, brokerParams, loanParams, all_);
     }
 };
 
diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp
index 1e7763ac62..bbf1c54ab1 100644
--- a/src/test/app/MPToken_test.cpp
+++ b/src/test/app/MPToken_test.cpp
@@ -25,14 +25,18 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -47,6 +51,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -55,13 +60,17 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -102,20 +111,9 @@ class MPToken_test : public beast::unit_test::Suite
                  .metadata = "test",
                  .err = temMALFORMED});
 
-            if (!features[featureSingleAssetVault])
+            if (!features[featureSingleAssetVault] || !features[featurePermissionedDomains])
             {
-                // tries to set DomainID when SAV is disabled
-                mptAlice.create(
-                    {.maxAmt = 100,
-                     .assetScale = 0,
-                     .metadata = "test",
-                     .flags = tfMPTRequireAuth,
-                     .domainID = uint256(42),
-                     .err = temDISABLED});
-            }
-            else if (!features[featurePermissionedDomains])
-            {
-                // tries to set DomainID when PD is disabled
+                // tries to set DomainID when SAV or PD is disabled
                 mptAlice.create(
                     {.maxAmt = 100,
                      .assetScale = 0,
@@ -191,6 +189,22 @@ class MPToken_test : public beast::unit_test::Suite
                  .metadata = "test",
                  .err = temMALFORMED});
         }
+
+        // sfReferenceHolding is populated internally only by VaultCreate.
+        // A user-submitted MPTokenIssuanceCreate carrying the field must be
+        // rejected at preflight under fixCleanup3_2_0.
+        if (features[fixCleanup3_2_0])
+        {
+            Env env{*this, features};
+            env.fund(XRP(1'000), alice);
+            env.close();
+
+            json::Value jv;
+            jv[sfAccount] = alice.human();
+            jv[sfTransactionType] = jss::MPTokenIssuanceCreate;
+            jv[sfReferenceHolding] = to_string(uint256{1});
+            env(jv, Ter(temMALFORMED));
+        }
     }
 
     void
@@ -592,7 +606,8 @@ class MPToken_test : public beast::unit_test::Suite
             // (2)
             mptAlice.set({.account = alice, .flags = 0x00000008, .err = temINVALID_FLAG});
 
-            if (!features[featureSingleAssetVault] && !features[featureDynamicMPT])
+            if (!features[featureSingleAssetVault] && !features[featureDynamicMPT] &&
+                !features[featureConfidentialTransfer])
             {
                 // test invalid flags - nothing is being changed
                 mptAlice.set({.account = alice, .flags = 0x00000000, .err = tecNO_PERMISSION});
@@ -1777,7 +1792,7 @@ class MPToken_test : public beast::unit_test::Suite
             env.close();
 
             // Bob authorize credentials
-            env(deposit::authCredentials(bob, {{dpIssuer, credType}}));
+            env(deposit::authCredentials(bob, {{.issuer = dpIssuer, .credType = credType}}));
             env.close();
 
             // alice try to send 100 MPT to bob, not authorized
@@ -2097,6 +2112,864 @@ class MPToken_test : public beast::unit_test::Suite
         BEAST_EXPECT(txWithAmounts.empty());
     }
 
+    void
+    testNonCanonicalMPTAmountCleanup(FeatureBitset features)
+    {
+        using namespace test::jtx;
+        using namespace std::literals;
+        FeatureBitset const withoutFix = features - fixCleanup3_2_0;
+        FeatureBitset const withFix = features | fixCleanup3_2_0;
+        FeatureBitset const withoutFixAndV2 = withoutFix - featureMPTokensV2;
+        FeatureBitset const withFixAndWithoutV2 = withFix - featureMPTokensV2;
+
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        Account const gw{"gw"};
+
+        using MPTValue = MPTAmount::value_type;
+        MPTValue const mptMin = std::numeric_limits::min();
+        MPTValue const mptMax = std::numeric_limits::max();
+        std::uint64_t const u64Max = std::numeric_limits::max();
+        std::uint64_t const firstInvalidMPTMantissa = static_cast(mptMax) + 1;
+        MPTValue const alice0 = 10'000;
+        MPTValue const gw0 = -20'000;
+        TER const success = tesSUCCESS;
+        TER const invariantFailed = tecINVARIANT_FAILED;
+        TER const pathPartial = tecPATH_PARTIAL;
+        TER const badAmountTer = temBAD_AMOUNT;
+
+        struct BadMPTAmount
+        {
+            std::string_view name;
+            std::uint64_t mantissa;
+            bool negative;
+            MPTValue mptValue;
+            TER issuerToHolderPreFixTer;
+            TER holderSourcePreFixTer;
+            MPTValue issuerToHolderAliceAfterPreFix;
+            MPTValue issuerToHolderIssuerAfterPreFix;
+            MPTValue issuerToHolderAliceAfterPostFix;
+            MPTValue issuerToHolderIssuerAfterPostFix;
+        };
+        // clang-format off
+        std::array const badMPTAmounts = {{
+            {   .name="INT64_MAX + 1",        .mantissa=firstInvalidMPTMantissa,     .negative=false, .mptValue=mptMin,           .issuerToHolderPreFixTer=invariantFailed, .holderSourcePreFixTer=pathPartial,  .issuerToHolderAliceAfterPreFix=alice0,         .issuerToHolderIssuerAfterPreFix=gw0,           .issuerToHolderAliceAfterPostFix=alice0 - 1,      .issuerToHolderIssuerAfterPostFix=gw0 + 1},
+            {   .name="INT64_MAX + 10",       .mantissa=firstInvalidMPTMantissa + 9, .negative=false, .mptValue=mptMin + 9,       .issuerToHolderPreFixTer=invariantFailed, .holderSourcePreFixTer=pathPartial,  .issuerToHolderAliceAfterPreFix=alice0,         .issuerToHolderIssuerAfterPreFix=gw0,           .issuerToHolderAliceAfterPostFix=alice0 - 1,      .issuerToHolderIssuerAfterPostFix=gw0 + 1},
+            {   .name="UINT64_MAX - 9998",    .mantissa=u64Max - 9'998,              .negative=false, .mptValue=MPTValue{-9'999}, .issuerToHolderPreFixTer=success,         .holderSourcePreFixTer=pathPartial,  .issuerToHolderAliceAfterPreFix=alice0 - 9'999, .issuerToHolderIssuerAfterPreFix=gw0 + 9'999,   .issuerToHolderAliceAfterPostFix=alice0 - 10'000, .issuerToHolderIssuerAfterPostFix=gw0 + 10'000},
+            {   .name="UINT64_MAX - 9",       .mantissa=u64Max - 9,                  .negative=false, .mptValue=MPTValue{-10},    .issuerToHolderPreFixTer=success,         .holderSourcePreFixTer=pathPartial,  .issuerToHolderAliceAfterPreFix=alice0 - 10,    .issuerToHolderIssuerAfterPreFix=gw0 + 10,      .issuerToHolderAliceAfterPostFix=alice0 - 11,     .issuerToHolderIssuerAfterPostFix=gw0 + 11},
+            {   .name="UINT64_MAX - 1",       .mantissa=u64Max - 1,                  .negative=false, .mptValue=MPTValue{-2},     .issuerToHolderPreFixTer=success,         .holderSourcePreFixTer=pathPartial,  .issuerToHolderAliceAfterPreFix=alice0 - 2,     .issuerToHolderIssuerAfterPreFix=gw0 + 2,       .issuerToHolderAliceAfterPostFix=alice0 - 3,      .issuerToHolderIssuerAfterPostFix=gw0 + 3},
+            {   .name="UINT64_MAX",           .mantissa=u64Max,                      .negative=false, .mptValue=MPTValue{-1},     .issuerToHolderPreFixTer=success,         .holderSourcePreFixTer=pathPartial,  .issuerToHolderAliceAfterPreFix=alice0 - 1,     .issuerToHolderIssuerAfterPreFix=gw0 + 1,       .issuerToHolderAliceAfterPostFix=alice0 - 2,      .issuerToHolderIssuerAfterPostFix=gw0 + 2},
+            {   .name="-2",                   .mantissa=std::uint64_t{2},            .negative=true,  .mptValue=MPTValue{-2},     .issuerToHolderPreFixTer=badAmountTer,    .holderSourcePreFixTer=badAmountTer, .issuerToHolderAliceAfterPreFix=alice0,         .issuerToHolderIssuerAfterPreFix=gw0,           .issuerToHolderAliceAfterPostFix=alice0 - 1,      .issuerToHolderIssuerAfterPostFix=gw0 + 1}
+        }};
+        // clang-format on
+        auto const badMPTAmount = [&](MPTIssue const& issue, BadMPTAmount const& bad) {
+            return STAmount{issue, bad.mantissa, 0, bad.negative, STAmount::Unchecked{}};
+        };
+        auto const makeIssue = [&](Env& env) {
+            MPTTester const mpt{
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, bob},
+                 .pay = 10'000,
+                 .flags = tfMPTCanTransfer | tfMPTCanTrade | tfMPTCanEscrow | tfMPTCanClawback}};
+            return MPTIssue{mpt.issuanceID()};
+        };
+        auto const withNonCanonicalMPTAmount =
+            [](JTx jt, SField const& field, STAmount const& amount, Account const& signer) {
+                STTx tx{*jt.stx};
+                tx.setFieldAmount(field, amount);
+                tx.sign(signer.pk(), signer.sk());
+                jt.stx = std::make_shared(tx);
+                return jt;
+            };
+        auto const roundTrip = [](STTx const& tx) {
+            Serializer s;
+            tx.add(s);
+            SerialIter sit{s.slice()};
+            return STTx{sit};
+        };
+        auto const expectRoundTripBadMPT =
+            [&](JTx const& jt, SField const& field, BadMPTAmount const& bad) {
+                auto const roundTripped = roundTrip(*jt.stx);
+                auto const persisted = roundTripped.getFieldAmount(field);
+                BEAST_EXPECT(persisted.holds());
+                BEAST_EXPECT(persisted.mantissa() == bad.mantissa);
+                BEAST_EXPECT(persisted.exponent() == 0);
+                BEAST_EXPECT(persisted.negative() == bad.negative);
+                BEAST_EXPECT(persisted.mpt().value() == bad.mptValue);
+                if (!bad.negative)
+                    BEAST_EXPECT(persisted.mantissa() > kMaxMpTokenAmount);
+            };
+
+        for (auto const& bad : badMPTAmounts)
+        {
+            testcase("fixCleanup3_2_0 rejects non-canonical MPT Payment amounts");
+            {
+                Env env{*this, withoutFixAndV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto malformedHolderToHolder = withNonCanonicalMPTAmount(
+                    env.jt(pay(alice, bob, STAmount{issue, std::uint64_t{1}})),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                expectRoundTripBadMPT(malformedHolderToHolder, sfAmount, bad);
+                malformedHolderToHolder.ter = bad.holderSourcePreFixTer;
+                env.submit(malformedHolderToHolder);
+                env.close();
+                BEAST_EXPECT(
+                    (env.balance(alice, issue).value() == STAmount{MPTAmount{10'000}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(bob, issue).value() == STAmount{MPTAmount{10'000}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(gw, issue).value() == STAmount{MPTAmount{-20'000}, issue}));
+
+                env.enableFeature(fixCleanup3_2_0);
+                env.close();
+                env(env.jt(pay(bob, alice, STAmount{issue, std::uint64_t{1}})), Ter{tesSUCCESS});
+                env.close();
+                BEAST_EXPECT(
+                    (env.balance(alice, issue).value() == STAmount{MPTAmount{10'001}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(bob, issue).value() == STAmount{MPTAmount{9'999}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(gw, issue).value() == STAmount{MPTAmount{-20'000}, issue}));
+            }
+            {
+                Env env{*this, envconfig(), withoutFixAndV2, nullptr, beast::Severity::Disabled};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto malformedIssuerToHolder = withNonCanonicalMPTAmount(
+                    env.jt(pay(gw, alice, STAmount{issue, std::uint64_t{1}})),
+                    sfAmount,
+                    badAmount,
+                    gw);
+                expectRoundTripBadMPT(malformedIssuerToHolder, sfAmount, bad);
+                malformedIssuerToHolder.ter = bad.issuerToHolderPreFixTer;
+                env.submit(malformedIssuerToHolder);
+                env.close();
+                BEAST_EXPECT(
+                    (env.balance(alice, issue).value() ==
+                     STAmount{MPTAmount{bad.issuerToHolderAliceAfterPreFix}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(bob, issue).value() == STAmount{MPTAmount{10'000}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(gw, issue).value() ==
+                     STAmount{MPTAmount{bad.issuerToHolderIssuerAfterPreFix}, issue}));
+
+                env.enableFeature(fixCleanup3_2_0);
+                env.close();
+                env(env.jt(pay(alice, gw, STAmount{issue, std::uint64_t{1}})), Ter{tesSUCCESS});
+                env.close();
+                BEAST_EXPECT(
+                    (env.balance(alice, issue).value() ==
+                     STAmount{MPTAmount{bad.issuerToHolderAliceAfterPostFix}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(bob, issue).value() == STAmount{MPTAmount{10'000}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(gw, issue).value() ==
+                     STAmount{MPTAmount{bad.issuerToHolderIssuerAfterPostFix}, issue}));
+            }
+            {
+                Env env{*this, withoutFixAndV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto malformedHolderToIssuer = withNonCanonicalMPTAmount(
+                    env.jt(pay(alice, gw, STAmount{issue, std::uint64_t{1}})),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                expectRoundTripBadMPT(malformedHolderToIssuer, sfAmount, bad);
+                malformedHolderToIssuer.ter = bad.holderSourcePreFixTer;
+                env.submit(malformedHolderToIssuer);
+                env.close();
+                BEAST_EXPECT(
+                    (env.balance(alice, issue).value() == STAmount{MPTAmount{10'000}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(bob, issue).value() == STAmount{MPTAmount{10'000}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(gw, issue).value() == STAmount{MPTAmount{-20'000}, issue}));
+
+                env.enableFeature(fixCleanup3_2_0);
+                env.close();
+                env(env.jt(pay(gw, alice, STAmount{issue, std::uint64_t{1}})), Ter{tesSUCCESS});
+                env.close();
+                BEAST_EXPECT(
+                    (env.balance(alice, issue).value() == STAmount{MPTAmount{10'001}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(bob, issue).value() == STAmount{MPTAmount{10'000}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(gw, issue).value() == STAmount{MPTAmount{-20'001}, issue}));
+            }
+            {
+                Env env{*this, withFixAndWithoutV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(pay(alice, bob, STAmount{issue, std::uint64_t{1}})),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                tx.ter = temBAD_AMOUNT;
+                env.submit(tx);
+            }
+            {
+                Env env{*this, withFixAndWithoutV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(pay(gw, alice, STAmount{issue, std::uint64_t{1}})),
+                    sfAmount,
+                    badAmount,
+                    gw);
+                tx.ter = temBAD_AMOUNT;
+                env.submit(tx);
+            }
+            {
+                Env env{*this, withFixAndWithoutV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(pay(alice, gw, STAmount{issue, std::uint64_t{1}})),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                tx.ter = temBAD_AMOUNT;
+                env.submit(tx);
+            }
+
+            testcase("fixCleanup3_2_0 rejects non-canonical MPT Check amounts");
+            {
+                Env env{*this, envconfig(), withoutFix, nullptr, beast::Severity::Disabled};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badSendMax = badMPTAmount(issue, bad);
+                auto const checkSeq = env.seq(alice);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(check::create(alice, bob, STAmount{issue, std::uint64_t{10}})),
+                    sfSendMax,
+                    badSendMax,
+                    alice);
+                tx.ter = bad.negative ? TER{temBAD_AMOUNT} : TER{tesSUCCESS};
+                env.submit(tx);
+                env.close();
+
+                auto const checkKeylet = keylet::check(alice.id(), checkSeq);
+                auto const sleCheck = env.le(checkKeylet);
+                BEAST_EXPECT((sleCheck != nullptr) == !bad.negative);
+                if (sleCheck && !bad.negative)
+                {
+                    auto const persisted = sleCheck->getFieldAmount(sfSendMax);
+                    BEAST_EXPECT(persisted.holds());
+                    BEAST_EXPECT(persisted.mantissa() == bad.mantissa);
+                    BEAST_EXPECT(persisted.negative() == bad.negative);
+                }
+            }
+            {
+                Env env{*this, envconfig(), withoutFix, nullptr, beast::Severity::Disabled};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badSendMax = badMPTAmount(issue, bad);
+                auto const checkSeq = env.seq(alice);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(check::create(alice, bob, STAmount{issue, std::uint64_t{10}})),
+                    sfSendMax,
+                    badSendMax,
+                    alice);
+                tx.ter = bad.negative ? TER{temBAD_AMOUNT} : TER{tesSUCCESS};
+                env.submit(tx);
+                env.close();
+
+                auto const checkKeylet = keylet::check(alice.id(), checkSeq);
+                BEAST_EXPECT((env.le(checkKeylet) != nullptr) == !bad.negative);
+                if (!bad.negative)
+                {
+                    // CheckCancel has no amount fields, but it must be able to
+                    // remove a malformed legacy Check while the fix is disabled.
+                    env(env.jt(check::cancel(alice, checkKeylet.key)), Ter{tesSUCCESS});
+                    env.close();
+                    BEAST_EXPECT(env.le(checkKeylet) == nullptr);
+                }
+            }
+            {
+                Env env{*this, envconfig(), withoutFix, nullptr, beast::Severity::Disabled};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badSendMax = badMPTAmount(issue, bad);
+                auto const checkSeq = env.seq(alice);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(check::create(alice, bob, STAmount{issue, std::uint64_t{10}})),
+                    sfSendMax,
+                    badSendMax,
+                    alice);
+                tx.ter = bad.negative ? TER{temBAD_AMOUNT} : TER{tesSUCCESS};
+                env.submit(tx);
+                env.close();
+
+                auto const checkKeylet = keylet::check(alice.id(), checkSeq);
+                BEAST_EXPECT((env.le(checkKeylet) != nullptr) == !bad.negative);
+                if (!bad.negative)
+                {
+                    env.enableFeature(fixCleanup3_2_0);
+                    env.close();
+
+                    // Once the fix is enabled, CheckCancel should still remove
+                    // a legacy Check because it does not consume the bad amount.
+                    env(env.jt(check::cancel(alice, checkKeylet.key)), Ter{tesSUCCESS});
+                    env.close();
+                    BEAST_EXPECT(env.le(checkKeylet) == nullptr);
+                }
+            }
+            {
+                Env env{*this, envconfig(), withoutFix, nullptr, beast::Severity::Disabled};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badSendMax = badMPTAmount(issue, bad);
+                auto const checkSeq = env.seq(alice);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(check::create(alice, bob, STAmount{issue, std::uint64_t{10}})),
+                    sfSendMax,
+                    badSendMax,
+                    alice);
+                tx.ter = bad.negative ? TER{temBAD_AMOUNT} : TER{tesSUCCESS};
+                env.submit(tx);
+                env.close();
+
+                auto const checkKeylet = keylet::check(alice.id(), checkSeq);
+                BEAST_EXPECT((env.le(checkKeylet) != nullptr) == !bad.negative);
+                if (!bad.negative)
+                {
+                    env.enableFeature(fixCleanup3_2_0);
+                    env.close();
+
+                    auto const cashAmount = STAmount{sfAmount, issue, std::uint64_t{1}, 0, false};
+                    env(env.jt(check::cash(bob, checkKeylet.key, cashAmount)), Ter{tefBAD_LEDGER});
+                    env.close();
+                    BEAST_EXPECT(env.le(checkKeylet) != nullptr);
+                }
+            }
+            {
+                Env env{*this, withoutFix};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const sendMax = STAmount{sfSendMax, issue, std::uint64_t{10}, 0, false};
+                auto const checkSeq = env.seq(alice);
+                env(env.jt(check::create(alice, bob, sendMax)), Ter{tesSUCCESS});
+                env.close();
+
+                auto const badCashAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        check::cash(
+                            bob,
+                            keylet::check(alice.id(), checkSeq).key,
+                            STAmount{issue, std::uint64_t{1}})),
+                    sfAmount,
+                    badCashAmount,
+                    bob);
+                expectRoundTripBadMPT(tx, sfAmount, bad);
+                tx.ter = bad.holderSourcePreFixTer;
+                env.submit(tx);
+                env.close();
+                BEAST_EXPECT(env.le(keylet::check(alice.id(), checkSeq)) != nullptr);
+                BEAST_EXPECT(
+                    (env.balance(alice, issue).value() == STAmount{MPTAmount{10'000}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(bob, issue).value() == STAmount{MPTAmount{10'000}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(gw, issue).value() == STAmount{MPTAmount{-20'000}, issue}));
+            }
+            {
+                Env env{*this, withFix};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const sendMax = STAmount{sfSendMax, issue, std::uint64_t{10}, 0, false};
+                auto const checkSeq = env.seq(alice);
+                env(env.jt(check::create(alice, bob, sendMax)), Ter{tesSUCCESS});
+                env.close();
+
+                auto const badCashAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        check::cash(
+                            bob,
+                            keylet::check(alice.id(), checkSeq).key,
+                            STAmount{issue, std::uint64_t{1}})),
+                    sfAmount,
+                    badCashAmount,
+                    bob);
+                tx.ter = temBAD_AMOUNT;
+                env.submit(tx);
+            }
+
+            testcase("fixCleanup3_2_0 rejects non-canonical MPT Escrow amounts");
+            {
+                Env env{*this, withoutFix};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const escrowSeq = env.seq(alice);
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        escrow::create(alice, bob, STAmount{issue, std::uint64_t{1}}),
+                        escrow::kFinishTime(env.now() + 1s)),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                tx.ter = bad.negative ? TER{temBAD_AMOUNT} : TER{tecINSUFFICIENT_FUNDS};
+                env.submit(tx);
+                env.close();
+                BEAST_EXPECT(env.le(keylet::escrow(alice.id(), escrowSeq)) == nullptr);
+            }
+            {
+                Env env{*this, withFix};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        escrow::create(alice, bob, STAmount{issue, std::uint64_t{1}}),
+                        escrow::kFinishTime(env.now() + 1s)),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                tx.ter = temBAD_AMOUNT;
+                env.submit(tx);
+            }
+
+            testcase("fixCleanup3_2_0 rejects non-canonical MPT Clawback amounts");
+            {
+                Env env{*this, withoutFix};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(claw(gw, STAmount{issue, std::uint64_t{1}}, bob)),
+                    sfAmount,
+                    badAmount,
+                    gw);
+                expectRoundTripBadMPT(tx, sfAmount, bad);
+                tx.ter = bad.negative ? TER{temBAD_AMOUNT} : TER{tesSUCCESS};
+                env.submit(tx);
+                env.close();
+
+                MPTValue const bobAfter = bad.negative ? MPTValue{10'000} : MPTValue{0};
+                MPTValue const gwAfter = bad.negative ? MPTValue{-20'000} : MPTValue{-10'000};
+                BEAST_EXPECT(
+                    (env.balance(alice, issue).value() == STAmount{MPTAmount{10'000}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(bob, issue).value() == STAmount{MPTAmount{bobAfter}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(gw, issue).value() == STAmount{MPTAmount{gwAfter}, issue}));
+            }
+            {
+                Env env{*this, withFix};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(claw(gw, STAmount{issue, std::uint64_t{1}}, bob)),
+                    sfAmount,
+                    badAmount,
+                    gw);
+                tx.ter = temBAD_AMOUNT;
+                env.submit(tx);
+                env.close();
+
+                BEAST_EXPECT(
+                    (env.balance(alice, issue).value() == STAmount{MPTAmount{10'000}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(bob, issue).value() == STAmount{MPTAmount{10'000}, issue}));
+                BEAST_EXPECT(
+                    (env.balance(gw, issue).value() == STAmount{MPTAmount{-20'000}, issue}));
+            }
+
+            testcase("featureMPTokensV2 disabled rejects MPT OfferCreate amounts");
+            {
+                Env env{*this, withoutFixAndV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badTakerPays = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(offer(alice, STAmount{issue, std::uint64_t{1}}, XRP(10))),
+                    sfTakerPays,
+                    badTakerPays,
+                    alice);
+                expectRoundTripBadMPT(tx, sfTakerPays, bad);
+                tx.ter = temDISABLED;
+                env.submit(tx);
+            }
+            {
+                Env env{*this, withFixAndWithoutV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badTakerPays = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(offer(alice, STAmount{issue, std::uint64_t{1}}, XRP(10))),
+                    sfTakerPays,
+                    badTakerPays,
+                    alice);
+                tx.ter = temDISABLED;
+                env.submit(tx);
+            }
+            {
+                // sfTakerPays is MPT: both amendments active. Negative offers
+                // fail in OfferCreate::preflight() before the universal check;
+                // positive non-canonical amounts fail in the universal check.
+                Env env{*this, withFix};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badTakerPays = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(offer(alice, STAmount{issue, std::uint64_t{1}}, XRP(10))),
+                    sfTakerPays,
+                    badTakerPays,
+                    alice);
+                tx.ter = TER{temBAD_AMOUNT};
+                env.submit(tx);
+            }
+            {
+                // sfTakerGets is MPT: both amendments active. Negative offers
+                // fail in OfferCreate::preflight() before the universal check;
+                // positive non-canonical amounts fail in the universal check.
+                Env env{*this, withFix};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badTakerGets = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(offer(alice, XRP(10), STAmount{issue, std::uint64_t{1}})),
+                    sfTakerGets,
+                    badTakerGets,
+                    alice);
+                tx.ter = TER{temBAD_AMOUNT};
+                env.submit(tx);
+            }
+
+            testcase("featureMPTokensV2 disabled rejects MPT AMMCreate amounts");
+            {
+                Env env{*this, withoutFixAndV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        AMM::createJv(alice.id(), STAmount{issue, std::uint64_t{1}}, XRP(1), 0),
+                        Fee(static_cast(env.current()->fees().increment.drops()))),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                expectRoundTripBadMPT(tx, sfAmount, bad);
+                tx.ter = temDISABLED;
+                env.submit(tx);
+            }
+            {
+                Env env{*this, withFixAndWithoutV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        AMM::createJv(alice.id(), STAmount{issue, std::uint64_t{1}}, XRP(1), 0),
+                        Fee(static_cast(env.current()->fees().increment.drops()))),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                tx.ter = temDISABLED;
+                env.submit(tx);
+            }
+            {
+                // sfAmount is MPT: both amendments active, expect temBAD_AMOUNT
+                Env env{*this, withFix};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        AMM::createJv(alice.id(), STAmount{issue, std::uint64_t{1}}, XRP(1), 0),
+                        Fee(static_cast(env.current()->fees().increment.drops()))),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                tx.ter = temBAD_AMOUNT;
+                env.submit(tx);
+            }
+
+            testcase("featureMPTokensV2 disabled rejects MPT AMMDeposit amounts");
+            {
+                Env env{*this, withoutFixAndV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        AMM::depositJv(
+                            {.account = alice,
+                             .asset1In = STAmount{issue, std::uint64_t{1}},
+                             .assets = std::make_pair(Asset{issue}, Asset{xrpIssue()})}),
+                        Fee(static_cast(env.current()->fees().increment.drops()))),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                expectRoundTripBadMPT(tx, sfAmount, bad);
+                tx.ter = temDISABLED;
+                env.submit(tx);
+            }
+            {
+                Env env{*this, withFixAndWithoutV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        AMM::depositJv(
+                            {.account = alice,
+                             .asset1In = STAmount{issue, std::uint64_t{1}},
+                             .assets = std::make_pair(Asset{issue}, Asset{xrpIssue()})}),
+                        Fee(static_cast(env.current()->fees().increment.drops()))),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                tx.ter = temDISABLED;
+                env.submit(tx);
+            }
+            {
+                // sfAmount is MPT: both amendments active, expect temBAD_AMOUNT
+                Env env{*this, withFix};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        AMM::depositJv(
+                            {.account = alice,
+                             .asset1In = STAmount{issue, std::uint64_t{1}},
+                             .assets = std::make_pair(Asset{issue}, Asset{xrpIssue()})}),
+                        Fee(static_cast(env.current()->fees().increment.drops()))),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                tx.ter = temBAD_AMOUNT;
+                env.submit(tx);
+            }
+
+            testcase("featureMPTokensV2 disabled rejects MPT AMMWithdraw amounts");
+            {
+                Env env{*this, withoutFixAndV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        AMM::withdrawJv(
+                            {.account = alice,
+                             .asset1Out = STAmount{issue, std::uint64_t{1}},
+                             .assets = std::make_pair(Asset{issue}, Asset{xrpIssue()})}),
+                        Fee(static_cast(env.current()->fees().increment.drops()))),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                expectRoundTripBadMPT(tx, sfAmount, bad);
+                tx.ter = temDISABLED;
+                env.submit(tx);
+            }
+            {
+                Env env{*this, withFixAndWithoutV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        AMM::withdrawJv(
+                            {.account = alice,
+                             .asset1Out = STAmount{issue, std::uint64_t{1}},
+                             .assets = std::make_pair(Asset{issue}, Asset{xrpIssue()})}),
+                        Fee(static_cast(env.current()->fees().increment.drops()))),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                tx.ter = temDISABLED;
+                env.submit(tx);
+            }
+            {
+                // sfAmount is MPT: both amendments active, expect temBAD_AMOUNT
+                Env env{*this, withFix};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        AMM::withdrawJv(
+                            {.account = alice,
+                             .asset1Out = STAmount{issue, std::uint64_t{1}},
+                             .assets = std::make_pair(Asset{issue}, Asset{xrpIssue()})}),
+                        Fee(static_cast(env.current()->fees().increment.drops()))),
+                    sfAmount,
+                    badAmount,
+                    alice);
+                tx.ter = temBAD_AMOUNT;
+                env.submit(tx);
+            }
+
+            testcase("featureMPTokensV2 disabled rejects MPT AMMClawback amounts");
+            {
+                Env env{*this, withoutFixAndV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        amm::ammClawback(
+                            gw,
+                            alice,
+                            Asset{issue},
+                            Asset{xrpIssue()},
+                            std::make_optional(STAmount{issue, std::uint64_t{1}})),
+                        Fee(static_cast(env.current()->fees().increment.drops()))),
+                    sfAmount,
+                    badAmount,
+                    gw);
+                expectRoundTripBadMPT(tx, sfAmount, bad);
+                tx.ter = temDISABLED;
+                env.submit(tx);
+            }
+            {
+                Env env{*this, withFixAndWithoutV2};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        amm::ammClawback(
+                            gw,
+                            alice,
+                            Asset{issue},
+                            Asset{xrpIssue()},
+                            std::make_optional(STAmount{issue, std::uint64_t{1}})),
+                        Fee(static_cast(env.current()->fees().increment.drops()))),
+                    sfAmount,
+                    badAmount,
+                    gw);
+                tx.ter = temDISABLED;
+                env.submit(tx);
+            }
+            {
+                // sfAmount is MPT: both amendments active, expect temBAD_AMOUNT
+                Env env{*this, withFix};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        amm::ammClawback(
+                            gw,
+                            alice,
+                            Asset{issue},
+                            Asset{xrpIssue()},
+                            std::make_optional(STAmount{issue, std::uint64_t{1}})),
+                        Fee(static_cast(env.current()->fees().increment.drops()))),
+                    sfAmount,
+                    badAmount,
+                    gw);
+                tx.ter = temBAD_AMOUNT;
+                env.submit(tx);
+            }
+
+            testcase("fixCleanup3_2_0 rejects non-canonical MPT VaultClawback amounts");
+            {
+                Env env{*this, withFix};
+                env.fund(XRP(100'000), alice, bob, gw);
+                env.close();
+                auto const issue = makeIssue(env);
+
+                auto const badAmount = badMPTAmount(issue, bad);
+                uint256 const fakeVaultId = keylet::vault(gw.id(), 1).key;
+                auto tx = withNonCanonicalMPTAmount(
+                    env.jt(
+                        Vault::clawback(
+                            {.issuer = gw,
+                             .id = fakeVaultId,
+                             .holder = alice,
+                             .amount = STAmount{issue, std::uint64_t{1}}}),
+                        Fee(static_cast(env.current()->fees().increment.drops()))),
+                    sfAmount,
+                    badAmount,
+                    gw);
+                tx.ter = temBAD_AMOUNT;
+                env.submit(tx);
+            }
+        }
+    }
+
     void
     testTxJsonMetaFields(FeatureBitset features)
     {
@@ -2108,7 +2981,7 @@ class MPToken_test : public beast::unit_test::Suite
         Account const alice{"alice"};
 
         auto cfg = envconfig();
-        cfg->FEES.reference_fee = 10;
+        cfg->fees.referenceFee = 10;
         Env env{*this, std::move(cfg), features};
         MPTTester mptAlice(env, alice);
 
@@ -2602,7 +3475,7 @@ class MPToken_test : public beast::unit_test::Suite
             MPTTester mptAlice(env, alice, {.holders = {bob}});
             mptAlice.create(
                 {.ownerCount = 1,
-                 .mutableFlags = tmfMPTCanMutateMetadata | tmfMPTCanMutateCanLock |
+                 .mutableFlags = tmfMPTCanMutateMetadata | tmfMPTCanEnableCanLock |
                      tmfMPTCanMutateTransferFee});
 
             // Setting flags is not allowed when MutableFlags is present
@@ -2650,33 +3523,6 @@ class MPToken_test : public beast::unit_test::Suite
             }
         }
 
-        // Can not set and clear the same mutable flag
-        {
-            Env env{*this, features};
-            MPTTester mptAlice(env, alice, {.holders = {bob}});
-            auto const mptID = makeMptID(env.seq(alice), alice);
-
-            auto const flagCombinations = {
-                tmfMPTSetCanLock | tmfMPTClearCanLock,
-                tmfMPTSetRequireAuth | tmfMPTClearRequireAuth,
-                tmfMPTSetCanEscrow | tmfMPTClearCanEscrow,
-                tmfMPTSetCanTrade | tmfMPTClearCanTrade,
-                tmfMPTSetCanTransfer | tmfMPTClearCanTransfer,
-                tmfMPTSetCanClawback | tmfMPTClearCanClawback,
-                tmfMPTSetCanLock | tmfMPTClearCanLock | tmfMPTClearCanTrade,
-                tmfMPTSetCanTransfer | tmfMPTClearCanTransfer | tmfMPTSetCanEscrow |
-                    tmfMPTClearCanClawback};
-
-            for (auto const& mutableFlags : flagCombinations)
-            {
-                mptAlice.set(
-                    {.account = alice,
-                     .id = mptID,
-                     .mutableFlags = mutableFlags,
-                     .err = temINVALID_FLAG});
-            }
-        }
-
         // Can not mutate flag which is not mutable
         {
             Env env{*this, features};
@@ -2686,17 +3532,11 @@ class MPToken_test : public beast::unit_test::Suite
 
             auto const mutableFlags = {
                 tmfMPTSetCanLock,
-                tmfMPTClearCanLock,
                 tmfMPTSetRequireAuth,
-                tmfMPTClearRequireAuth,
                 tmfMPTSetCanEscrow,
-                tmfMPTClearCanEscrow,
                 tmfMPTSetCanTrade,
-                tmfMPTClearCanTrade,
                 tmfMPTSetCanTransfer,
-                tmfMPTClearCanTransfer,
-                tmfMPTSetCanClawback,
-                tmfMPTClearCanClawback};
+                tmfMPTSetCanClawback};
 
             for (auto const& mutableFlag : mutableFlags)
             {
@@ -2740,34 +3580,6 @@ class MPToken_test : public beast::unit_test::Suite
                  .err = temBAD_TRANSFER_FEE});
         }
 
-        // Test setting non-zero transfer fee and clearing MPTCanTransfer at the
-        // same time
-        {
-            Env env{*this, features};
-            MPTTester mptAlice(env, alice, {.holders = {bob}});
-
-            mptAlice.create(
-                {.transferFee = 100,
-                 .ownerCount = 1,
-                 .flags = tfMPTCanTransfer,
-                 .mutableFlags = tmfMPTCanMutateTransferFee | tmfMPTCanMutateCanTransfer});
-
-            // Can not set non-zero transfer fee and clear MPTCanTransfer at the
-            // same time
-            mptAlice.set(
-                {.account = alice,
-                 .mutableFlags = tmfMPTClearCanTransfer,
-                 .transferFee = 1,
-                 .err = temMALFORMED});
-
-            // Can set transfer fee to zero and clear MPTCanTransfer at the same
-            // time. tfMPTCanTransfer will be cleared and TransferFee field will
-            // be removed.
-            mptAlice.set(
-                {.account = alice, .mutableFlags = tmfMPTClearCanTransfer, .transferFee = 0});
-            BEAST_EXPECT(!mptAlice.isTransferFeePresent());
-        }
-
         // Can not set non-zero transfer fee when MPTCanTransfer is not set
         {
             Env env{*this, features};
@@ -2775,7 +3587,7 @@ class MPToken_test : public beast::unit_test::Suite
 
             mptAlice.create(
                 {.ownerCount = 1,
-                 .mutableFlags = tmfMPTCanMutateTransferFee | tmfMPTCanMutateCanTransfer});
+                 .mutableFlags = tmfMPTCanMutateTransferFee | tmfMPTCanEnableCanTransfer});
 
             mptAlice.set({.account = alice, .transferFee = 100, .err = tecNO_PERMISSION});
 
@@ -2808,21 +3620,14 @@ class MPToken_test : public beast::unit_test::Suite
 
             mptAlice.create(
                 {.ownerCount = 1,
-                 .mutableFlags = tmfMPTCanMutateCanTrade | tmfMPTCanMutateCanTransfer |
+                 .mutableFlags = tmfMPTCanEnableCanTrade | tmfMPTCanEnableCanTransfer |
                      tmfMPTCanMutateMetadata});
 
             // Can not mutate transfer fee
             mptAlice.set({.account = alice, .transferFee = 100, .err = tecNO_PERMISSION});
 
             auto const invalidFlags = {
-                tmfMPTSetCanLock,
-                tmfMPTClearCanLock,
-                tmfMPTSetRequireAuth,
-                tmfMPTClearRequireAuth,
-                tmfMPTSetCanEscrow,
-                tmfMPTClearCanEscrow,
-                tmfMPTSetCanClawback,
-                tmfMPTClearCanClawback};
+                tmfMPTSetCanLock, tmfMPTSetRequireAuth, tmfMPTSetCanEscrow, tmfMPTSetCanClawback};
 
             // Can not mutate flags which are not mutable
             for (auto const& mutableFlag : invalidFlags)
@@ -2833,11 +3638,9 @@ class MPToken_test : public beast::unit_test::Suite
 
             // Can mutate MPTCanTrade
             mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTrade});
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTClearCanTrade});
 
             // Can mutate MPTCanTransfer
             mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTransfer});
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTClearCanTransfer});
 
             // Can mutate metadata
             mptAlice.set({.account = alice, .metadata = "test"});
@@ -2906,37 +3709,26 @@ class MPToken_test : public beast::unit_test::Suite
             BEAST_EXPECT(mptAlice.checkTransferFee(10));
         }
 
-        // Test flag toggling
+        // Test mutable flag enablement
         {
-            auto testFlagToggle = [&](std::uint32_t createFlags,
-                                      std::uint32_t setFlags,
-                                      std::uint32_t clearFlags) {
+            auto testFlagSet = [&](std::uint32_t createFlags, std::uint32_t setFlags) {
                 Env env{*this, features};
                 MPTTester mptAlice(env, alice);
 
                 // Create the MPT object with the specified initial flags
                 mptAlice.create({.metadata = "test", .ownerCount = 1, .mutableFlags = createFlags});
 
-                // Set and clear the flag multiple times
-                mptAlice.set({.account = alice, .mutableFlags = setFlags});
-                mptAlice.set({.account = alice, .mutableFlags = clearFlags});
-                mptAlice.set({.account = alice, .mutableFlags = clearFlags});
+                // Setting the same mutable capability more than once is harmless.
                 mptAlice.set({.account = alice, .mutableFlags = setFlags});
                 mptAlice.set({.account = alice, .mutableFlags = setFlags});
-                mptAlice.set({.account = alice, .mutableFlags = clearFlags});
-                mptAlice.set({.account = alice, .mutableFlags = setFlags});
-                mptAlice.set({.account = alice, .mutableFlags = clearFlags});
             };
 
-            testFlagToggle(tmfMPTCanMutateCanLock, tfMPTCanLock, tmfMPTClearCanLock);
-            testFlagToggle(
-                tmfMPTCanMutateRequireAuth, tmfMPTSetRequireAuth, tmfMPTClearRequireAuth);
-            testFlagToggle(tmfMPTCanMutateCanEscrow, tmfMPTSetCanEscrow, tmfMPTClearCanEscrow);
-            testFlagToggle(tmfMPTCanMutateCanTrade, tmfMPTSetCanTrade, tmfMPTClearCanTrade);
-            testFlagToggle(
-                tmfMPTCanMutateCanTransfer, tmfMPTSetCanTransfer, tmfMPTClearCanTransfer);
-            testFlagToggle(
-                tmfMPTCanMutateCanClawback, tmfMPTSetCanClawback, tmfMPTClearCanClawback);
+            testFlagSet(tmfMPTCanEnableCanLock, tmfMPTSetCanLock);
+            testFlagSet(tmfMPTCanEnableRequireAuth, tmfMPTSetRequireAuth);
+            testFlagSet(tmfMPTCanEnableCanEscrow, tmfMPTSetCanEscrow);
+            testFlagSet(tmfMPTCanEnableCanTrade, tmfMPTSetCanTrade);
+            testFlagSet(tmfMPTCanEnableCanTransfer, tmfMPTSetCanTransfer);
+            testFlagSet(tmfMPTCanEnableCanClawback, tmfMPTSetCanClawback);
         }
     }
 
@@ -2957,7 +3749,7 @@ class MPToken_test : public beast::unit_test::Suite
                 {.ownerCount = 1,
                  .holderCount = 0,
                  .flags = tfMPTCanLock | tfMPTCanTransfer,
-                 .mutableFlags = tmfMPTCanMutateCanLock | tmfMPTCanMutateCanTrade |
+                 .mutableFlags = tmfMPTCanEnableCanLock | tmfMPTCanEnableCanTrade |
                      tmfMPTCanMutateTransferFee});
             mptAlice.authorize({.account = bob, .holderCount = 1});
 
@@ -2965,11 +3757,8 @@ class MPToken_test : public beast::unit_test::Suite
             mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock});
 
             // Can mutate the mutable flags and fields
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTClearCanLock});
             mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanLock});
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTClearCanLock});
             mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTrade});
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTClearCanTrade});
             mptAlice.set({.account = alice, .transferFee = 200});
         }
 
@@ -2981,7 +3770,7 @@ class MPToken_test : public beast::unit_test::Suite
                 {.ownerCount = 1,
                  .holderCount = 0,
                  .flags = tfMPTCanLock,
-                 .mutableFlags = tmfMPTCanMutateCanLock | tmfMPTCanMutateCanClawback |
+                 .mutableFlags = tmfMPTCanEnableCanLock | tmfMPTCanEnableCanClawback |
                      tmfMPTCanMutateMetadata});
             mptAlice.authorize({.account = bob, .holderCount = 1});
 
@@ -2989,36 +3778,23 @@ class MPToken_test : public beast::unit_test::Suite
             mptAlice.set({.account = alice, .flags = tfMPTLock});
 
             // Can mutate the mutable flags and fields
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTClearCanLock});
             mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanLock});
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTClearCanLock});
             mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanClawback});
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTClearCanClawback});
             mptAlice.set({.account = alice, .metadata = "mutate"});
         }
 
-        // Test lock and unlock after mutating MPTCanLock
+        // Test lock and unlock after enabling MPTCanLock
         {
             Env env{*this, features};
             MPTTester mptAlice(env, alice, {.holders = {bob}});
             mptAlice.create(
                 {.ownerCount = 1,
                  .holderCount = 0,
-                 .flags = tfMPTCanLock,
-                 .mutableFlags = tmfMPTCanMutateCanLock | tmfMPTCanMutateCanClawback |
+                 .mutableFlags = tmfMPTCanEnableCanLock | tmfMPTCanEnableCanClawback |
                      tmfMPTCanMutateMetadata});
             mptAlice.authorize({.account = bob, .holderCount = 1});
 
-            // Can lock and unlock
-            mptAlice.set({.account = alice, .flags = tfMPTLock});
-            mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock});
-            mptAlice.set({.account = alice, .flags = tfMPTUnlock});
-            mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTUnlock});
-
-            // Clear lsfMPTCanLock
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTClearCanLock});
-
-            // Can not lock or unlock
+            // Can not lock or unlock before MPTCanLock is enabled
             mptAlice.set({.account = alice, .flags = tfMPTLock, .err = tecNO_PERMISSION});
             mptAlice.set({.account = alice, .flags = tfMPTUnlock, .err = tecNO_PERMISSION});
             mptAlice.set(
@@ -3026,10 +3802,10 @@ class MPToken_test : public beast::unit_test::Suite
             mptAlice.set(
                 {.account = alice, .holder = bob, .flags = tfMPTUnlock, .err = tecNO_PERMISSION});
 
-            // Set MPTCanLock again
+            // Set MPTCanLock
             mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanLock});
 
-            // Can lock and unlock again
+            // Can lock and unlock
             mptAlice.set({.account = alice, .flags = tfMPTLock});
             mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock});
             mptAlice.set({.account = alice, .flags = tfMPTUnlock});
@@ -3043,83 +3819,30 @@ class MPToken_test : public beast::unit_test::Suite
         testcase("Mutate MPTRequireAuth");
         using namespace test::jtx;
 
-        // test mutating RequireAuth flag on the issuance and its effect on payment authorization
-        {
-            Env env{*this, features};
-            Account const alice("alice");
-            Account const bob("bob");
+        // test enabling RequireAuth flag on the issuance and its effect on payment
+        // authorization
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
 
-            MPTTester mptAlice(env, alice, {.holders = {bob}});
-            mptAlice.create(
-                {.ownerCount = 1,
-                 .flags = tfMPTRequireAuth,
-                 .mutableFlags = tmfMPTCanMutateRequireAuth});
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+        mptAlice.create(
+            {.ownerCount = 1,
+             .flags = tfMPTCanTransfer,
+             .mutableFlags = tmfMPTCanEnableRequireAuth});
 
-            mptAlice.authorize({.account = bob});
-            mptAlice.authorize({.account = alice, .holder = bob});
+        mptAlice.authorize({.account = bob});
+        mptAlice.pay(alice, bob, 1000);
 
-            // Pay to bob
-            mptAlice.pay(alice, bob, 1000);
+        // Set RequireAuth because it is mutable.
+        mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetRequireAuth});
 
-            // Unauthorize bob
-            mptAlice.authorize({.account = alice, .holder = bob, .flags = tfMPTUnauthorize});
+        // This should fail because bob is not authorized yet.
+        mptAlice.pay(alice, bob, 1000, tecNO_AUTH);
 
-            // Can not pay to bob
-            mptAlice.pay(bob, alice, 100, tecNO_AUTH);
-
-            // Clear RequireAuth
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTClearRequireAuth});
-
-            // Can pay to bob
-            mptAlice.pay(alice, bob, 1000);
-
-            // Set RequireAuth again
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetRequireAuth});
-
-            // Can not pay to bob since he is not authorized
-            mptAlice.pay(bob, alice, 100, tecNO_AUTH);
-
-            // Authorize bob again
-            mptAlice.authorize({.account = alice, .holder = bob});
-
-            // Can pay to bob again
-            mptAlice.pay(alice, bob, 100);
-        }
-
-        // Cannot clear RequireAuth when a DomainID is set on the issuance
-        {
-            Account const alice{"alice"};
-            Account const bob{"bob"};
-            Account const credIssuer{"credIssuer"};
-            pdomain::Credentials const credentials{
-                {.issuer = credIssuer, .credType = "credential"}};
-
-            Env env{*this, features};
-            env.fund(XRP(1000), credIssuer);
-            env.close();
-
-            env(pdomain::setTx(credIssuer, credentials));
-            env.close();
-            auto const domainId = pdomain::getNewDomain(env.meta());
-
-            MPTTester mptAlice(env, alice, {.holders = {bob}});
-            mptAlice.create({
-                .ownerCount = 1,
-                .flags = tfMPTRequireAuth,
-                .mutableFlags = tmfMPTCanMutateRequireAuth,
-                .domainID = domainId,
-            });
-
-            // Clearing RequireAuth while a DomainID is present must be rejected,
-            mptAlice.set({
-                .account = alice,
-                .mutableFlags = tmfMPTClearRequireAuth,
-                .err = tecNO_PERMISSION,
-            });
-
-            // Setting RequireAuth (already set) is still allowed, though it has no effect.
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetRequireAuth});
-        }
+        // Issuer authorizes bob and pay should succeed.
+        mptAlice.authorize({.account = alice, .holder = bob});
+        mptAlice.pay(alice, bob, 1000);
     }
 
     void
@@ -3140,7 +3863,7 @@ class MPToken_test : public beast::unit_test::Suite
             {.ownerCount = 1,
              .holderCount = 0,
              .flags = tfMPTCanTransfer,
-             .mutableFlags = tmfMPTCanMutateCanEscrow});
+             .mutableFlags = tmfMPTCanEnableCanEscrow});
         mptAlice.authorize({.account = carol});
         mptAlice.authorize({.account = bob});
 
@@ -3162,14 +3885,6 @@ class MPToken_test : public beast::unit_test::Suite
             escrow::kCondition(escrow::kCb1),
             escrow::kFinishTime(env.now() + 1s),
             Fee(baseFee * 150));
-
-        // Clear MPTCanEscrow
-        mptAlice.set({.account = alice, .mutableFlags = tmfMPTClearCanEscrow});
-        env(escrow::create(carol, bob, mpt(3)),
-            escrow::kCondition(escrow::kCb1),
-            escrow::kFinishTime(env.now() + 1s),
-            Fee(baseFee * 150),
-            Ter(tecNO_PERMISSION));
     }
 
     void
@@ -3188,7 +3903,7 @@ class MPToken_test : public beast::unit_test::Suite
             MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
             mptAlice.create(
                 {.ownerCount = 1,
-                 .mutableFlags = tmfMPTCanMutateCanTransfer | tmfMPTCanMutateTransferFee});
+                 .mutableFlags = tmfMPTCanEnableCanTransfer | tmfMPTCanMutateTransferFee});
 
             mptAlice.authorize({.account = bob});
             mptAlice.authorize({.account = carol});
@@ -3232,19 +3947,9 @@ class MPToken_test : public beast::unit_test::Suite
                 env(pay(bob, carol, mptAlice(50)), Txflags(tfPartialPayment));
                 BEAST_EXPECT(env.balance(carol, mptc) == mptc(49));
             }
-
-            // Alice clears MPTCanTransfer
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTClearCanTransfer});
-
-            // TransferFee field is removed when MPTCanTransfer is cleared
-            BEAST_EXPECT(!mptAlice.isTransferFeePresent());
-
-            // Bob can not pay
-            mptAlice.pay(bob, carol, 50, tecNO_AUTH);
         }
 
-        // Can set transfer fee to zero when MPTCanTransfer is not set, but
-        // tmfMPTCanMutateTransferFee is set.
+        // Can set transfer fee to zero when tmfMPTCanMutateTransferFee is set.
         {
             Env env{*this, features};
 
@@ -3253,18 +3958,12 @@ class MPToken_test : public beast::unit_test::Suite
                 {.transferFee = 100,
                  .ownerCount = 1,
                  .flags = tfMPTCanTransfer,
-                 .mutableFlags = tmfMPTCanMutateTransferFee | tmfMPTCanMutateCanTransfer});
+                 .mutableFlags = tmfMPTCanMutateTransferFee});
 
             BEAST_EXPECT(mptAlice.checkTransferFee(100));
 
-            // Clear MPTCanTransfer and transfer fee is removed
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTClearCanTransfer});
-            BEAST_EXPECT(!mptAlice.isTransferFeePresent());
-
-            // Can still set transfer fee to zero, although it is already zero
+            // Setting transfer fee to zero removes the field.
             mptAlice.set({.account = alice, .transferFee = 0});
-
-            // TransferFee field is still not present
             BEAST_EXPECT(!mptAlice.isTransferFeePresent());
         }
     }
@@ -3282,7 +3981,7 @@ class MPToken_test : public beast::unit_test::Suite
         MPTTester mptAlice(env, alice, {.holders = {bob}});
 
         mptAlice.create(
-            {.ownerCount = 1, .holderCount = 0, .mutableFlags = tmfMPTCanMutateCanClawback});
+            {.ownerCount = 1, .holderCount = 0, .mutableFlags = tmfMPTCanEnableCanClawback});
 
         // Bob creates an MPToken
         mptAlice.authorize({.account = bob});
@@ -3298,12 +3997,6 @@ class MPToken_test : public beast::unit_test::Suite
 
         // Can clawback now
         mptAlice.claw(alice, bob, 1);
-
-        // Clear MPTCanClawback
-        mptAlice.set({.account = alice, .mutableFlags = tmfMPTClearCanClawback});
-
-        // Can not clawback
-        mptAlice.claw(alice, bob, 1, tecNO_PERMISSION);
     }
 
     void
@@ -3346,7 +4039,7 @@ class MPToken_test : public beast::unit_test::Suite
             // view may contain partial state and must be discarded.
             if (expectedOutstanding)
             {
-                auto const sle = av.peek(keylet::mptIssuance(mptTester.issuanceID()));
+                auto const sle = av.peek(keylet::mptokenIssuance(mptTester.issuanceID()));
                 if (!BEAST_EXPECT(sle))
                     return;
                 BEAST_EXPECTS(sle->getFieldU64(sfOutstandingAmount) == *expectedOutstanding, label);
@@ -3481,10 +4174,10 @@ class MPToken_test : public beast::unit_test::Suite
                     auto const [errBuy, errSell] = [&]() -> std::pair {
                         // Global lock
                         if (lockMPTIssue)
-                            return std::make_pair(tecFROZEN, tecFROZEN);
+                            return std::make_pair(tecLOCKED, tecLOCKED);
                         // Local lock
                         if (lockMPToken)
-                            return std::make_pair(tesSUCCESS, error(tecUNFUNDED_OFFER));
+                            return std::make_pair(error(tecLOCKED), error(tecUNFUNDED_OFFER));
                         // MPToken doesn't exist
                         if (requireAuth)
                             return std::make_pair(error(tecNO_AUTH), error(tecUNFUNDED_OFFER));
@@ -3547,51 +4240,77 @@ class MPToken_test : public beast::unit_test::Suite
             env(offer(alice, btc(10), eth(10)), Ter(tecUNFUNDED_OFFER));
         }
 
-        // MPTLock flag is set and the account is not the issuer of MPT
+        // MPTLock flag is set: MPT/MPT offer crossing with independent issuers.
+        // gw2 issues BTC and gw issues ETH so each asset can be frozen independently.
+        // Passive setup: bob sells BTC (offer(bob, ETH, BTC)); dan sells ETH (offer(dan, BTC,
+        // ETH)).
         {
+            Account const gw2 = Account("gw2");
             Account const bob = Account("bob");
             Account const dan = Account("dan");
             Env env(*this);
-            env.fund(XRP(1'000), gw, alice, carol, bob, dan);
+            env.fund(XRP(1'000), gw, gw2, alice, carol, bob, dan);
             MPTTester btc(
                 {.env = env,
-                 .issuer = gw,
-                 .holders = {alice, carol, bob, dan},
+                 .issuer = gw2,
+                 .holders = {alice, carol, bob, dan, gw},
                  .pay = 100,
                  .flags = tfMPTCanLock | kMptDexFlags});
             MPTTester eth(
                 {.env = env,
                  .issuer = gw,
-                 .holders = {alice, carol, bob, dan},
+                 .holders = {alice, carol, bob, dan, gw2},
                  .pay = 100,
                  .flags = tfMPTCanLock | kMptDexFlags});
 
+            // bob sells BTC (takerGets=BTC); dan sells ETH (takerGets=ETH)
             env(offer(bob, eth(10), btc(10)), Txflags(tfPassive));
             env(offer(dan, btc(10), eth(10)), Txflags(tfPassive));
+            env.close();
 
-            auto test = [&](auto const& flag, bool gwOwner = false) {
-                btc.set({.holder = carol, .flags = flag});
-                btc.set({.holder = alice, .flags = flag});
+            // --- Individual lock on BTC ---
+            // alice sells locked BTC (takerGets): balance zeroed, offer unfunded
+            btc.set({.holder = alice, .flags = tfMPTLock});
+            env(offer(alice, eth(1), btc(1)), Ter(tecUNFUNDED_OFFER));
+            btc.set({.holder = alice, .flags = tfMPTUnlock});
+            // carol buys locked BTC (takerPays): locked MPToken cannot receive
+            btc.set({.holder = carol, .flags = tfMPTLock});
+            env(offer(carol, btc(1), eth(1)), Ter(tecLOCKED));
+            btc.set({.holder = carol, .flags = tfMPTUnlock});
+            // gw2 is BTC issuer: individual lock on holders does not affect issuer
+            env(offer(gw2, eth(1), btc(1)));
 
-                if (gwOwner)
-                {
-                    // Succeeds if the account is the issuer
-                    env(offer(gw, eth(1), btc(1)));
-                    env(offer(gw, btc(1), eth(1)));
-                }
-                else
-                {
-                    auto const err = flag == tfMPTLock ? Ter(tecUNFUNDED_OFFER) : Ter(tesSUCCESS);
-                    env(offer(alice, eth(1), btc(1)), err);
-                    // Offer created by not crossed
-                    env(offer(carol, btc(1), eth(1)));
-                    BEAST_EXPECT(expectOffers(env, carol, 1, {{btc(1), eth(1)}}));
-                }
-            };
+            // --- Individual lock on ETH ---
+            // alice sells locked ETH (takerGets): balance zeroed, offer unfunded
+            eth.set({.holder = alice, .flags = tfMPTLock});
+            env(offer(alice, btc(1), eth(1)), Ter(tecUNFUNDED_OFFER));
+            eth.set({.holder = alice, .flags = tfMPTUnlock});
+            // carol buys locked ETH (takerPays): locked MPToken cannot receive
+            eth.set({.holder = carol, .flags = tfMPTLock});
+            env(offer(carol, eth(1), btc(1)), Ter(tecLOCKED));
+            eth.set({.holder = carol, .flags = tfMPTUnlock});
+            // gw is ETH issuer: individual lock on holders does not affect issuer
+            env(offer(gw, btc(1), eth(1)));
 
-            test(tfMPTLock);
-            test(tfMPTLock, true);
-            test(tfMPTUnlock);
+            // --- Global lock on BTC ---
+            // All accounts fail regardless of role: global lock is checked in OfferCreate
+            // before offer crossing, so it applies even to the issuer.
+            btc.set({.flags = tfMPTLock});
+            env(offer(alice, eth(1), btc(1)), Ter(tecLOCKED));  // alice sells BTC
+            env(offer(alice, btc(1), eth(1)), Ter(tecLOCKED));  // alice buys BTC
+            env(offer(gw2, eth(1), btc(1)), Ter(tecLOCKED));    // gw2 is BTC issuer, still fails
+            btc.set({.flags = tfMPTUnlock});
+
+            // --- Global lock on ETH ---
+            eth.set({.flags = tfMPTLock});
+            env(offer(alice, btc(1), eth(1)), Ter(tecLOCKED));  // alice sells ETH
+            env(offer(alice, eth(1), btc(1)), Ter(tecLOCKED));  // alice buys ETH
+            env(offer(gw, btc(1), eth(1)), Ter(tecLOCKED));     // gw is ETH issuer, still fails
+            eth.set({.flags = tfMPTUnlock});
+
+            // --- After all locks cleared: normal crossing succeeds ---
+            env(offer(alice, eth(1), btc(1)));
+            env(offer(carol, btc(1), eth(1)));
         }
 
         // MPTRequireAuth flag is set and the account is not authorized
@@ -3627,27 +4346,22 @@ class MPToken_test : public beast::unit_test::Suite
         {
             Env env(*this);
             env.fund(XRP(1'000), gw, alice, carol);
-            MPTTester btc(
+            MPTTester const btc(
                 {.env = env,
                  .issuer = gw,
                  .holders = {alice, carol},
                  .pay = 100,
-                 .flags = tfMPTCanTrade,
-                 .mutableFlags = tmfMPTCanMutateCanTransfer});
-            MPTTester eth(
+                 .flags = tfMPTCanTrade | tfMPTCanTransfer});
+            MPTTester const eth(
                 {.env = env,
                  .issuer = gw,
                  .holders = {alice, carol},
                  .pay = 100,
-                 .flags = tfMPTCanTrade | tfMPTCanTransfer,
-                 .mutableFlags = tmfMPTCanMutateCanTransfer});
+                 .flags = tfMPTCanTrade});
 
             // Can create
             env(offer(alice, eth(10), btc(10)), Txflags(tfPassive));
-            btc.set({.mutableFlags = tmfMPTSetCanTransfer});
-            eth.set({.mutableFlags = tmfMPTClearCanTransfer});
-            env(offer(alice, eth(10), btc(10)), Txflags(tfPassive));
-            BEAST_EXPECT(getAccountOffers(env, alice)[jss::offers].size() == 2);
+            BEAST_EXPECT(getAccountOffers(env, alice)[jss::offers].size() == 1);
 
             // issuer can create
             env(offer(gw, eth(10), btc(10)), Txflags(tfPassive));
@@ -3675,14 +4389,14 @@ class MPToken_test : public beast::unit_test::Suite
                  .holders = {alice, carol},
                  .pay = 100,
                  .flags = tfMPTCanTransfer,
-                 .mutableFlags = tmfMPTCanMutateCanTrade});
+                 .mutableFlags = tmfMPTCanEnableCanTrade});
             MPTTester const eth(
                 {.env = env,
                  .issuer = gw,
                  .holders = {alice, carol},
                  .pay = 100,
                  .flags = tfMPTCanTrade,
-                 .mutableFlags = tmfMPTCanMutateCanTrade});
+                 .mutableFlags = tmfMPTCanEnableCanTrade});
 
             // Can't create
             env(offer(gw, eth(10), btc(10)), Ter(tecNO_PERMISSION));
@@ -3806,6 +4520,7 @@ class MPToken_test : public beast::unit_test::Suite
         testcase("Cross Asset Payment");
         using namespace test::jtx;
         Account const gw = Account("gw");
+        Account const gw2 = Account("gw2");
         Account const alice = Account("alice");
         Account const carol = Account("carol");
         Account const bob = Account("bob");
@@ -3918,29 +4633,29 @@ class MPToken_test : public beast::unit_test::Suite
                  .holders = {alice, carol, bob},
                  .pay = 1'000,
                  .flags = tfMPTCanLock | kMptDexFlags,
-                 .mutableFlags = tmfMPTCanMutateRequireAuth | tmfMPTCanMutateCanTrade |
-                     tmfMPTCanMutateCanTransfer});
+                 .mutableFlags = tmfMPTCanEnableRequireAuth | tmfMPTCanEnableCanTrade |
+                     tmfMPTCanEnableCanTransfer});
             MPTTester eth(
                 {.env = env,
                  .issuer = gw,
                  .holders = {alice, carol, bob},
                  .pay = 1'000,
                  .flags = tfMPTCanLock | kMptDexFlags,
-                 .mutableFlags = tmfMPTCanMutateCanTransfer});
+                 .mutableFlags = tmfMPTCanEnableCanTransfer});
             MPTTester const usd(
                 {.env = env,
                  .issuer = gw,
                  .holders = {alice, carol, bob},
                  .pay = 1'000,
                  .flags = kMptDexFlags | tfMPTCanLock,
-                 .mutableFlags = tmfMPTCanMutateCanTransfer});
+                 .mutableFlags = tmfMPTCanEnableCanTransfer});
             MPTTester const cad(
                 {.env = env,
                  .issuer = gw,
                  .holders = {alice, carol, bob},
                  .pay = 1'000,
                  .flags = kMptDexFlags | tfMPTCanLock,
-                 .mutableFlags = tmfMPTCanMutateCanTransfer});
+                 .mutableFlags = tmfMPTCanEnableCanTransfer});
 
             env(offer(bob, eth(1'000), btc(1'000)), Txflags(tfPassive));
             env.close();
@@ -3982,27 +4697,46 @@ class MPToken_test : public beast::unit_test::Suite
             env(pay(ed, gw, btc(10)), Path(~btc), Sendmax(eth(10)));
             // BTC is transferred from issuer to bob
             env(pay(gw, ed, eth(10)), Path(~eth), Sendmax(btc(10)));
+            //
             // BTC is transferred from ed to bob, ed is not authorized
             env(pay(ed, gw, eth(10)), Path(~eth), Sendmax(btc(10)), Ter(tecNO_AUTH));
             env.close();
-            btc.set({.mutableFlags = tmfMPTClearRequireAuth});
+        }
 
-            // MPTCanTransfer is not set
+        // MPTCanTransfer is not set.
+        {
+            auto const ed = Account{"ed"};
+            Env env{*this, features};
+            env.fund(XRP(1'000), gw, alice, carol, bob, ed);
+            MPTTester const btc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, carol, bob, ed},
+                 .pay = 1'000,
+                 .flags = tfMPTCanTrade});
+            MPTTester const eth(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, carol, bob, ed},
+                 .pay = 1'000,
+                 .flags = kMptDexFlags});
+
+            env(offer(bob, eth(1'000), btc(1'000)), Txflags(tfPassive));
+            env.close();
+            env(offer(bob, btc(1'000), eth(1'000)), Txflags(tfPassive));
+            env.close();
 
             // Fail regardless if source/destination is the issuer or
             // not since the offer is owned by a holder.
-            btc.set({.mutableFlags = tmfMPTClearCanTransfer});
             env(pay(ed, carol, btc(10)), Path(~btc), Sendmax(eth(10)), Ter(tecPATH_PARTIAL));
             env(pay(carol, ed, btc(10)), Path(~btc), Sendmax(eth(10)), Ter(tecPATH_PARTIAL));
             env(pay(ed, carol, eth(10)), Path(~eth), Sendmax(btc(10)), Ter(tecPATH_PARTIAL));
             env(pay(carol, ed, eth(10)), Path(~eth), Sendmax(btc(10)), Ter(tecPATH_PARTIAL));
-            // Fail because BTC, which has CanTransfer disabled, is sent to
-            // bob
+            // Fail because BTC, which has CanTransfer disabled, is sent to bob
             env(pay(ed, gw, eth(10)), Path(~eth), Sendmax(btc(10)), Ter(tecPATH_PARTIAL));
             env(pay(ed, gw, btc(10)), Path(~btc), Sendmax(eth(10)), Ter(tesSUCCESS));
             env(pay(gw, ed, eth(10)), Path(~eth), Sendmax(btc(10)), Ter(tesSUCCESS));
-            // Fail because BTC, which has CanTransfer disabled, is sent to
-            // ed
+            // Fail because BTC, which has CanTransfer disabled, is sent to ed
             env(pay(gw, ed, btc(10)), Path(~btc), Sendmax(eth(10)), Ter(tecPATH_PARTIAL));
             env.close();
             env(offer(gw, eth(100), btc(100)), Txflags(tfPassive));
@@ -4017,124 +4751,166 @@ class MPToken_test : public beast::unit_test::Suite
             env(pay(ed, gw, btc(10)), Path(~btc), Sendmax(eth(10)));
             env.close();
         }
-        // Multiple steps: CAD/USD, USD/BTC, BTC/ETH
+
+        // Multiple steps: CAD/USD, USD/BTC, BTC/ETH.
+        // takerGets can transfer if:
+        //  - CanTransfer is set
+        //  - The offer's owner is the issuer
+        //  - BookStep is the last step, which means strand's destination is
+        //    the issuer
+        // takerPays can transfer if
+        //  - BookStep is the first step, which means strand's source is
+        //    the issuer
+        //  - The offer's owner is the issuer
+        //  - Previous step is BookStep, which transfers per above
+        //  - CanTransfer is set
         {
-            auto const ed = Account{"ed"};
-            Env env{*this, features};
-            env.fund(XRP(1'000), gw, alice, carol, bob, ed);
-            env.close();
-            MPTTester btc(
-                {.env = env,
-                 .issuer = gw,
-                 .holders = {alice, carol, bob},
-                 .pay = 1'000,
-                 .flags = tfMPTCanLock | kMptDexFlags,
-                 .mutableFlags = tmfMPTCanMutateCanTransfer});
-            MPTTester eth(
-                {.env = env,
-                 .issuer = gw,
-                 .holders = {alice, carol, bob},
-                 .pay = 1'000,
-                 .flags = tfMPTCanLock | kMptDexFlags,
-                 .mutableFlags = tmfMPTCanMutateCanTransfer});
-            MPTTester usd(
-                {.env = env,
-                 .issuer = gw,
-                 .holders = {alice, carol, bob},
-                 .pay = 1'000,
-                 .flags = kMptDexFlags | tfMPTCanLock,
-                 .mutableFlags = tmfMPTCanMutateCanTransfer});
-            MPTTester cad(
-                {.env = env,
-                 .issuer = gw,
-                 .holders = {alice, carol, bob},
-                 .pay = 1'000,
-                 .flags = kMptDexFlags | tfMPTCanLock,
-                 .mutableFlags = tmfMPTCanMutateCanTransfer});
-            // takerGets can transfer if:
-            //  - CanTransfer is set
-            //  - The offer's owner is the issuer
-            //  - BookStep is the last step, which means strand's destination is
-            //    the issuer
-            // takerPays can transfer if
-            //  - BookStep is the first step, which means strand's source is
-            //    the issuer
-            //  - The offer's owner is the issuer
-            //  - Previous step is BookStep, which transfers per above
-            //  - CanTransfer is set
-            env(offer(bob, cad(100), usd(100)), Txflags(tfPassive));
-            env(offer(bob, usd(100), btc(100)), Txflags(tfPassive));
-            env(offer(bob, btc(100), eth(100)), Txflags(tfPassive));
-            env.close();
-            BEAST_EXPECT(expectOffers(env, bob, 3));
-            btc.set({.mutableFlags = tmfMPTSetCanTransfer});
-            usd.set({.mutableFlags = tmfMPTClearCanTransfer});
-            // TakerGets
-            // fail - CAD/USD is owned by bob
-            env(pay(alice, carol, eth(1)),
-                Path(~usd, ~btc, ~eth),
-                Sendmax(cad(1)),
-                Ter(tecPATH_PARTIAL));
-            auto seq(env.seq(gw));
-            env(offer(gw, usd(1), btc(1)), Txflags(tfPassive));
-            env.close();
-            // fail - CAD/USD is owned by bob
-            env(pay(alice, carol, eth(1)),
-                Path(~usd, ~btc, ~eth),
-                Sendmax(cad(1)),
-                Ter(tecPATH_PARTIAL));
-            env.close();
-            env(offerCancel(gw, seq));
-            env(offer(gw, cad(1), usd(1)), Txflags(tfPassive));
-            env.close();
-            BEAST_EXPECT(expectOffers(env, bob, 3));
-            // succeed - CAD/USD is owned by issuer
-            env(pay(alice, carol, eth(1)), Path(~usd, ~btc, ~eth), Sendmax(cad(1)));
-            env.close();
-            // bob's CAD/USD is deleted
-            BEAST_EXPECT(expectOffers(env, bob, 2));
-            env(offer(bob, cad(100), usd(100)), Txflags(tfPassive));
-            BEAST_EXPECT(expectOffers(env, gw, 0));
-            usd.set({.mutableFlags = tmfMPTSetCanTransfer});
-            eth.set({.mutableFlags = tmfMPTClearCanTransfer});
-            // fail - BTC/ETH is owned by bob, destination is carol
-            env(pay(alice, carol, eth(1)),
-                Path(~usd, ~btc, ~eth),
-                Sendmax(cad(1)),
-                Ter(tecPATH_PARTIAL));
-            env.close();
-            BEAST_EXPECT(expectOffers(env, bob, 3));
-            // succeed - destination is an issuer
-            env(pay(alice, gw, eth(1)), Path(~usd, ~btc, ~eth), Sendmax(cad(1)));
-            env.close();
-            BEAST_EXPECT(expectOffers(env, bob, 3));
-            // TakerPays
-            eth.set({.mutableFlags = tmfMPTSetCanTransfer});
-            cad.set({.mutableFlags = tmfMPTClearCanTransfer});
-            // fail - CAD/USD is owned by bob, source is alice
-            env(pay(alice, carol, eth(1)),
-                Path(~usd, ~btc, ~eth),
-                Sendmax(cad(1)),
-                Ter(tecPATH_PARTIAL));
-            // succeed - source is the issuer
-            env(pay(gw, carol, eth(1)), Path(~usd, ~btc, ~eth), Sendmax(cad(1)));
-            env.close();
-            env(offer(gw, cad(1), usd(1)), Txflags(tfPassive));
-            env.close();
-            // succeed - CAD/USD is owned by issuer
-            env(pay(alice, carol, eth(1)), Path(~usd, ~btc, ~eth), Sendmax(cad(1)));
-            env.close();
-            BEAST_EXPECT(expectOffers(env, gw, 0));
-            BEAST_EXPECT(expectOffers(env, bob, 2));
-            cad.set({.mutableFlags = tmfMPTSetCanTransfer});
-            btc.set({.mutableFlags = tmfMPTClearCanTransfer});
-            env(offer(bob, cad(1), usd(1)), Txflags(tfPassive));
-            env(offer(gw, usd(1), btc(1)), Txflags(tfPassive));
-            env.close();
-            // succeed - USD/BTC is owned by issuer
-            env(pay(alice, carol, eth(1)), Path(~usd, ~btc, ~eth), Sendmax(cad(1)));
-            env.close();
-            BEAST_EXPECT(expectOffers(env, gw, 0));
+            // enum to indicate which MPT doesn't set CanTransfer flag.
+            enum class NoTransferMPT { BTC, ETH, USD, CAD };
+
+            // Lambda to test multi-step payment with one of the MPTs not setting CanTransfer flag.
+            auto const testMultiStepMPTCanTransfer = [&](NoTransferMPT const noTransferMPT,
+                                                         auto const& test) {
+                auto const getFlags = [&](NoTransferMPT const mpt) {
+                    return mpt == noTransferMPT ? tfMPTCanTrade : kMptDexFlags;
+                };
+
+                Env env{*this, features};
+                env.fund(XRP(1'000), gw, alice, carol, bob);
+                env.close();
+                MPTTester const btc(
+                    {.env = env,
+                     .issuer = gw,
+                     .holders = {alice, carol, bob},
+                     .pay = 1'000,
+                     .flags = getFlags(NoTransferMPT::BTC)});
+                MPTTester const eth(
+                    {.env = env,
+                     .issuer = gw,
+                     .holders = {alice, carol, bob},
+                     .pay = 1'000,
+                     .flags = getFlags(NoTransferMPT::ETH)});
+                MPTTester const usd(
+                    {.env = env,
+                     .issuer = gw,
+                     .holders = {alice, carol, bob},
+                     .pay = 1'000,
+                     .flags = getFlags(NoTransferMPT::USD)});
+                MPTTester const cad(
+                    {.env = env,
+                     .issuer = gw,
+                     .holders = {alice, carol, bob},
+                     .pay = 1'000,
+                     .flags = getFlags(NoTransferMPT::CAD)});
+
+                env(offer(bob, cad(100), usd(100)), Txflags(tfPassive));
+                env(offer(bob, usd(100), btc(100)), Txflags(tfPassive));
+                env(offer(bob, btc(100), eth(100)), Txflags(tfPassive));
+                env.close();
+
+                test(env, btc, eth, usd, cad);
+            };
+
+            // USD starts without MPTCanTransfer.
+            testMultiStepMPTCanTransfer(
+                NoTransferMPT::USD,
+                [&](Env& env,
+                    MPTTester const& btc,
+                    MPTTester const& eth,
+                    MPTTester const& usd,
+                    MPTTester const& cad) {
+                    BEAST_EXPECT(expectOffers(env, bob, 3));
+
+                    // fail - CAD/USD is owned by bob
+                    env(pay(alice, carol, eth(1)),
+                        Path(~usd, ~btc, ~eth),
+                        Sendmax(cad(1)),
+                        Ter(tecPATH_PARTIAL));
+
+                    auto seq(env.seq(gw));
+                    env(offer(gw, usd(1), btc(1)), Txflags(tfPassive));
+                    env.close();
+                    // fail - CAD/USD is owned by bob
+                    env(pay(alice, carol, eth(1)),
+                        Path(~usd, ~btc, ~eth),
+                        Sendmax(cad(1)),
+                        Ter(tecPATH_PARTIAL));
+                    env.close();
+                    env(offerCancel(gw, seq));
+                    env(offer(gw, cad(1), usd(1)), Txflags(tfPassive));
+                    env.close();
+                    BEAST_EXPECT(expectOffers(env, bob, 3));
+                    // succeed - CAD/USD is owned by issuer
+                    env(pay(alice, carol, eth(1)), Path(~usd, ~btc, ~eth), Sendmax(cad(1)));
+                    env.close();
+                    // bob's CAD/USD is deleted.
+                    BEAST_EXPECT(expectOffers(env, bob, 2));
+                    env(offer(bob, cad(100), usd(100)), Txflags(tfPassive));
+                    BEAST_EXPECT(expectOffers(env, gw, 0));
+                });
+
+            // ETH starts without MPTCanTransfer.
+            testMultiStepMPTCanTransfer(
+                NoTransferMPT::ETH,
+                [&](Env& env,
+                    MPTTester const& btc,
+                    MPTTester const& eth,
+                    MPTTester const& usd,
+                    MPTTester const& cad) {
+                    // fail - BTC/ETH is owned by bob, destination is carol
+                    env(pay(alice, carol, eth(1)),
+                        Path(~usd, ~btc, ~eth),
+                        Sendmax(cad(1)),
+                        Ter(tecPATH_PARTIAL));
+                    env.close();
+                    BEAST_EXPECT(expectOffers(env, bob, 3));
+
+                    // succeed - destination is an issuer
+                    env(pay(alice, gw, eth(1)), Path(~usd, ~btc, ~eth), Sendmax(cad(1)));
+                    env.close();
+                    BEAST_EXPECT(expectOffers(env, bob, 3));
+                });
+
+            // CAD starts without MPTCanTransfer.
+            testMultiStepMPTCanTransfer(
+                NoTransferMPT::CAD,
+                [&](Env& env,
+                    MPTTester const& btc,
+                    MPTTester const& eth,
+                    MPTTester const& usd,
+                    MPTTester const& cad) {
+                    // fail - CAD/USD is owned by bob, source is alice
+                    env(pay(alice, carol, eth(1)),
+                        Path(~usd, ~btc, ~eth),
+                        Sendmax(cad(1)),
+                        Ter(tecPATH_PARTIAL));
+                    // succeed - source is the issuer
+                    env(pay(gw, carol, eth(1)), Path(~usd, ~btc, ~eth), Sendmax(cad(1)));
+                    env.close();
+                    env(offer(gw, cad(1), usd(1)), Txflags(tfPassive));
+                    env.close();
+                    // succeed - CAD/USD is owned by issuer
+                    env(pay(alice, carol, eth(1)), Path(~usd, ~btc, ~eth), Sendmax(cad(1)));
+                    env.close();
+                    BEAST_EXPECT(expectOffers(env, gw, 0));
+                    BEAST_EXPECT(expectOffers(env, bob, 2));
+                });
+
+            // BTC starts without MPTCanTransfer.
+            testMultiStepMPTCanTransfer(
+                NoTransferMPT::BTC,
+                [&](Env& env,
+                    MPTTester const& btc,
+                    MPTTester const& eth,
+                    MPTTester const& usd,
+                    MPTTester const& cad) {
+                    env(offer(gw, usd(1), btc(1)), Txflags(tfPassive));
+                    env.close();
+                    // succeed - USD/BTC is owned by issuer
+                    env(pay(alice, carol, eth(1)), Path(~usd, ~btc, ~eth), Sendmax(cad(1)));
+                    env.close();
+                    BEAST_EXPECT(expectOffers(env, gw, 0));
+                });
         }
 
         // MPTCanTrade is not set
@@ -4148,38 +4924,38 @@ class MPToken_test : public beast::unit_test::Suite
                  .holders = {alice, carol, bob},
                  .pay = 1'000,
                  .flags = tfMPTCanTransfer,
-                 .mutableFlags = tmfMPTCanMutateCanTrade});
+                 .mutableFlags = tmfMPTCanEnableCanTrade});
             MPTTester const eth(
                 {.env = env,
                  .issuer = gw,
                  .holders = {alice, carol, bob},
                  .pay = 1'000,
-                 .flags = tfMPTCanTransfer | tfMPTCanTrade,
-                 .mutableFlags = tmfMPTCanMutateCanTrade});
+                 .flags = kMptDexFlags});
             MPTTester const usd(
                 {.env = env,
                  .issuer = gw,
                  .holders = {alice, carol, bob},
                  .pay = 1'000,
-                 .flags = tfMPTCanTransfer | tfMPTCanTrade,
-                 .mutableFlags = tmfMPTCanMutateCanTrade});
+                 .flags = kMptDexFlags});
 
             env(pay(alice, carol, eth(1)), Path(~eth), Sendmax(btc(1)), Ter(tecNO_PERMISSION));
             env(pay(alice, carol, btc(1)), Path(~btc), Sendmax(eth(1)), Ter(tecNO_PERMISSION));
             env.close();
 
+            // Enable MPTCanTrade so BTC can be crossed through offers.
             btc.set({.mutableFlags = tmfMPTSetCanTrade});
             env(offer(bob, XRP(1), btc(1)));
             env(offer(bob, btc(1), eth(1)));
             env(offer(bob, eth(1), usd(1)));
             env.close();
-            btc.set({.mutableFlags = tmfMPTClearCanTrade});
+            BEAST_EXPECT(expectOffers(env, bob, 3));
+
             env(pay(gw, carol, usd(1)),
                 Path(~btc, ~eth, ~usd),
                 Sendmax(XRP(1)),
-                Ter(tecPATH_PARTIAL));
+                Txflags(tfPartialPayment | tfNoRippleDirect));
             env.close();
-            BEAST_EXPECT(expectOffers(env, bob, 3));
+            BEAST_EXPECT(expectOffers(env, bob, 0));
         }
 
         // Holders are locked
@@ -4212,31 +4988,36 @@ class MPToken_test : public beast::unit_test::Suite
             auto getMPT = [&](Env& env) {
                 MPTTester const btc(
                     {.env = env,
-                     .issuer = gw,
-                     .holders = {alice, carol, bob},
+                     .issuer = gw2,
+                     .holders = {alice, carol, bob, gw},
                      .pay = 100,
                      .flags = tfMPTCanLock | kMptDexFlags});
                 MPTTester const eth(
                     {.env = env,
                      .issuer = gw,
-                     .holders = {alice, carol, bob},
+                     .holders = {alice, carol, bob, gw2},
                      .pay = 100,
                      .flags = tfMPTCanLock | kMptDexFlags});
                 return std::make_pair(btc, eth);
             };
             auto getIOU = [&](Env& env) {
-                for (auto const& iou : {gw["BTC"], gw["ETH"]})
+                for (auto const& a : {alice, carol, bob})
                 {
-                    for (auto const& a : {alice, carol, bob})
-                    {
-                        env(fset(a, asfDefaultRipple));
-                        env.close();
-                        env(trust(a, iou(200)));
-                        env(pay(gw, a, iou(100)));
-                        env.close();
-                    }
+                    env(fset(a, asfDefaultRipple));
+                    env.close();
+                    env(trust(a, gw["ETH"](200)));
+                    env(pay(gw, a, gw["ETH"](100)));
+                    env(trust(a, gw2["BTC"](200)));
+                    env(pay(gw2, a, gw2["BTC"](100)));
+                    env.close();
                 }
-                return std::make_pair(gw["BTC"], gw["ETH"]);
+                // gw2 needs an ETH trust line to receive ETH when its offers fill
+                // gw needs BTC to sell BTC
+                env(trust(gw2, gw["ETH"](200)));
+                env(trust(gw, gw2["BTC"](200)));
+                env(pay(gw2, gw, gw2["BTC"](100)));
+                env.close();
+                return std::make_pair(gw2["BTC"], gw["ETH"]);
             };
             auto lock = [&](
                             Env& env, Account const& account, Token& token, LockType lock) {
@@ -4246,12 +5027,12 @@ class MPToken_test : public beast::unit_test::Suite
                 {
                     if (lock == LockType::Global)
                     {
-                        env(fset(gw, asfGlobalFreeze));
+                        env(fset(token.account, asfGlobalFreeze));
                     }
                     else
                     {
                         IOU const iou{account, token.currency};
-                        env(trust(gw, iou(0), tfSetFreeze));
+                        env(trust(token.account, iou(0), tfSetFreeze));
                     }
                 }
                 else if constexpr (std::is_same_v)
@@ -4268,7 +5049,7 @@ class MPToken_test : public beast::unit_test::Suite
             };
             auto test = [&](auto&& getTokens, TestArg const& arg) {
                 Env env(*this);
-                env.fund(XRP(1'000), gw, alice, carol, bob);
+                env.fund(XRP(1'000), gw, gw2, alice, carol, bob);
 
                 auto [btc, eth] = getTokens(env);
 
@@ -4286,7 +5067,7 @@ class MPToken_test : public beast::unit_test::Suite
                 }
                 if (arg.globalFlagSell != LockType::None)
                 {
-                    lock(env, gw, btc, LockType::Global);
+                    lock(env, gw2, btc, LockType::Global);
                 }
                 else
                 {
@@ -4304,34 +5085,58 @@ class MPToken_test : public beast::unit_test::Suite
             };
             // clang-format off
             std::vector const tests = {
-                    // src, dst, offer's owner are a holder
-                    {.src = alice, .dst = carol, .offerOwner = bob, .srcFlag = LockType::Individual, .err = tecPATH_DRY},
-                    // dst can receive IOU even if the account is frozen
-                    {.src = alice, .dst = carol, .offerOwner = bob, .dstFlag = LockType::Individual, .err = tecPATH_DRY, .errIOU = tesSUCCESS},
-                    {.src = alice, .dst = carol, .offerOwner = bob, .globalFlagBuy = LockType::Global, .err = tecPATH_DRY},
-                    {.src = alice, .dst = carol, .offerOwner = bob, .globalFlagSell = LockType::Global, .err = tecPATH_DRY},
-                    // offer's owner can receive IOU even if the account is frozen
-                    {.src = alice, .dst = carol, .offerOwner = bob, .offerFlagBuy = LockType::Individual, .err =
-                    tecPATH_PARTIAL, .errIOU = tesSUCCESS},
-                    {.src = alice, .dst = carol, .offerOwner = bob, .offerFlagSell = LockType::Individual, .err = tecPATH_PARTIAL},
-                    // src, dst are a holder, offer's owner is an issuer
-                    {.src = alice, .dst = carol, .offerOwner = gw, .srcFlag = LockType::Individual, .err = tecPATH_DRY},
-                    // dst can receive IOU even if the account is frozen
-                    {.src = alice, .dst = carol, .offerOwner = gw, .dstFlag = LockType::Individual, .err = tecPATH_DRY, .errIOU = tesSUCCESS},
-                    {.src = alice, .dst = carol, .offerOwner = gw, .globalFlagBuy = LockType::Global, .err = tecPATH_DRY},
-                    {.src = alice, .dst = carol, .offerOwner = gw, .globalFlagSell = LockType::Global, .err = tecPATH_DRY},
-                    // src is issuer, dst and offer's owner are a holder
-                    // dst can receive IOU even if the account is frozen
-                    {.src = gw, .dst = carol, .offerOwner = bob, .dstFlag = LockType::Individual, .err = tecPATH_DRY, .errIOU = tesSUCCESS},
-                    // offer's owner can receive IOU from an issuer even if takerBuys is frozen, MPT offer is unfunded in this case
-                    {.src = gw, .dst = carol, .offerOwner = bob, .offerFlagBuy = LockType::Individual, .err = tecPATH_PARTIAL, .errIOU = tesSUCCESS},
-                    {.src = gw, .dst = carol, .offerOwner = bob, .offerFlagSell = LockType::Individual, .err = tecPATH_PARTIAL},
-                    // dst is issuer, src and offer's owner are a holder
-                    {.src = alice, .dst = gw, .offerOwner = bob, .srcFlag = LockType::Individual, .err = tecPATH_DRY},
-                    // offer's owner can receive IOU even if the account is frozen
-                    {.src = alice, .dst = gw, .offerOwner = bob, .offerFlagBuy = LockType::Individual, .err = tecPATH_PARTIAL,
-                     .errIOU = tesSUCCESS},
-                    {.src = alice, .dst = gw, .offerOwner = bob, .offerFlagSell = LockType::Individual, .err = tecPATH_PARTIAL},
+                // ----- src=alice (holder), dst=carol (holder), offerOwner=bob (holder) -----
+                // alice's ETH locked: caught in check()
+                {.src = alice, .dst = carol, .offerOwner = bob, .srcFlag = LockType::Individual, .err = tecPATH_DRY},
+                // carol's BTC locked: caught in MPT check(); IOU dst can still receive when frozen
+                {.src = alice, .dst = carol, .offerOwner = bob, .dstFlag = LockType::Individual, .err = tecPATH_DRY, .errIOU = tesSUCCESS},
+                // ETH globally locked: caught in check()
+                {.src = alice, .dst = carol, .offerOwner = bob, .globalFlagBuy = LockType::Global, .err = tecPATH_DRY},
+                // BTC globally locked: bob's offer unfunded in OfferStream
+                {.src = alice, .dst = carol, .offerOwner = bob, .globalFlagSell = LockType::Global, .err = tecPATH_PARTIAL},
+                // bob's ETH (takerPays) locked: MPT offer unfunded in OfferStream (locked holder cannot receive); IOU can still receive
+                {.src = alice, .dst = carol, .offerOwner = bob, .offerFlagBuy = LockType::Individual, .err = tecPATH_PARTIAL, .errIOU = tesSUCCESS},
+                // bob's BTC (takerGets) locked: offer unfunded in OfferStream
+                {.src = alice, .dst = carol, .offerOwner = bob, .offerFlagSell = LockType::Individual, .err = tecPATH_PARTIAL},
+                // ----- src=alice (holder), dst=carol (holder), offerOwner=gw2 (BTC issuer) -----
+                // alice's ETH locked: caught in check()
+                {.src = alice, .dst = carol, .offerOwner = gw2, .srcFlag = LockType::Individual, .err = tecPATH_DRY},
+                // carol's BTC locked: caught in MPT check(); IOU dst can still receive when frozen
+                {.src = alice, .dst = carol, .offerOwner = gw2, .dstFlag = LockType::Individual, .err = tecPATH_DRY, .errIOU = tesSUCCESS},
+                // ETH globally locked: caught in check()
+                {.src = alice, .dst = carol, .offerOwner = gw2, .globalFlagBuy = LockType::Global, .err = tecPATH_DRY},
+                // BTC globally locked: gw2 is the BTC issuer, offer always permitted regardless of global freeze
+                {.src = alice, .dst = carol, .offerOwner = gw2, .globalFlagSell = LockType::Global, .err = tesSUCCESS},
+                // ----- src=alice (holder), dst=carol (holder), offerOwner=gw (ETH issuer, BTC holder) -----
+                // alice's ETH locked: caught in check()
+                {.src = alice, .dst = carol, .offerOwner = gw, .srcFlag = LockType::Individual, .err = tecPATH_DRY},
+                // carol's BTC locked: caught in MPT check(); IOU dst can still receive when frozen
+                {.src = alice, .dst = carol, .offerOwner = gw, .dstFlag = LockType::Individual, .err = tecPATH_DRY, .errIOU = tesSUCCESS},
+                // ETH globally locked: caught in check()
+                {.src = alice, .dst = carol, .offerOwner = gw, .globalFlagBuy = LockType::Global, .err = tecPATH_DRY},
+                // BTC globally locked: gw holds BTC as a holder (not BTC issuer), offer unfunded in OfferStream
+                {.src = alice, .dst = carol, .offerOwner = gw, .globalFlagSell = LockType::Global, .err = tecPATH_PARTIAL},
+                // ----- src=gw (ETH issuer), dst=carol (holder), offerOwner=bob (holder) -----
+                // ETH globally locked, src is ETH issuer: no first MPTEndpointStep so check() passes;
+                // MPT offer unfunded in OfferStream (globally-locked ETH cannot flow to holder via DEX); IOU issuer can still issue
+                {.src = gw, .dst = carol, .offerOwner = bob, .srcFlag = LockType::Global, .err = tecPATH_PARTIAL, .errIOU = tesSUCCESS},
+                // BTC globally locked: last MPTEndpointStep only checks individual freeze, check() passes; offer unfunded in OfferStream
+                {.src = gw, .dst = carol, .offerOwner = bob, .dstFlag = LockType::Global, .err = tecPATH_PARTIAL},
+                // carol's BTC locked: caught in MPT check(); IOU dst can still receive when frozen
+                {.src = gw, .dst = carol, .offerOwner = bob, .dstFlag = LockType::Individual, .err = tecPATH_DRY, .errIOU = tesSUCCESS},
+                // bob's ETH (takerPays) locked: MPT offer unfunded in OfferStream (locked holder cannot receive); IOU can still receive
+                {.src = gw, .dst = carol, .offerOwner = bob, .offerFlagBuy = LockType::Individual, .err = tecPATH_PARTIAL, .errIOU = tesSUCCESS},
+                // bob's BTC (takerGets) locked: offer unfunded in OfferStream
+                {.src = gw, .dst = carol, .offerOwner = bob, .offerFlagSell = LockType::Individual, .err = tecPATH_PARTIAL},
+                // ----- src=alice (holder), dst=gw2 (BTC issuer), offerOwner=bob (holder) -----
+                // alice's ETH locked: caught in check()
+                {.src = alice, .dst = gw2, .offerOwner = bob, .srcFlag = LockType::Individual, .err = tecPATH_DRY},
+                // BTC globally locked, dst is BTC issuer: no last MPTEndpointStep so check() passes; offer unfunded in OfferStream
+                {.src = alice, .dst = gw2, .offerOwner = bob, .dstFlag = LockType::Global, .err = tecPATH_PARTIAL},
+                // bob's ETH (takerPays) locked: MPT offer unfunded in OfferStream (locked holder cannot receive); IOU can still receive
+                {.src = alice, .dst = gw2, .offerOwner = bob, .offerFlagBuy = LockType::Individual, .err = tecPATH_PARTIAL, .errIOU = tesSUCCESS},
+                // bob's BTC (takerGets) locked: offer unfunded in OfferStream
+                {.src = alice, .dst = gw2, .offerOwner = bob, .offerFlagSell = LockType::Individual, .err = tecPATH_PARTIAL},
             };
             // clang-format on
 
@@ -5932,7 +6737,7 @@ class MPToken_test : public beast::unit_test::Suite
             env.close();
         }
 
-        // MPTCanTransfer disabled
+        // MPTCanTransfer is disabled
         {
             Env env{*this, features};
             env.fund(XRP(1'000), gw, alice, carol);
@@ -5943,7 +6748,7 @@ class MPToken_test : public beast::unit_test::Suite
                  .issuer = gw,
                  .holders = {alice, carol},
                  .flags = tfMPTCanTrade,
-                 .mutableFlags = tmfMPTCanMutateCanTransfer});
+                 .mutableFlags = tmfMPTCanEnableCanTransfer});
 
             // src is issuer
             uint256 checkId{keylet::check(gw, env.seq(gw)).key};
@@ -5973,49 +6778,45 @@ class MPToken_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(alice, mpt) == mpt(0));
             BEAST_EXPECT(env.balance(gw, mpt) == mpt(0));
 
-            // neither src nor dst is issuer, can still create
+            // neither src nor dst is issuer, can't create
+            checkId = keylet::check(alice, env.seq(alice)).key;
+            env(check::create(alice, carol, mpt(100)), Ter(tecNO_AUTH));
+            env.close();
+
+            // can create now
+            mpt.set({.account = gw, .mutableFlags = tmfMPTSetCanTransfer});
             checkId = keylet::check(alice, env.seq(alice)).key;
             env(check::create(alice, carol, mpt(100)));
             env.close();
-
-            // can't cash
-            env(check::cash(carol, checkId, mpt(10)), Ter(tecPATH_PARTIAL));
-            env.close();
-
-            // can cash now
-            mpt.set({.account = gw, .mutableFlags = tmfMPTSetCanTransfer});
             env(pay(gw, alice, mpt(10)));
             env.close();
+
+            // can cash since MPTCanTransfer is enabled
             env(check::cash(carol, checkId, mpt(10)));
             env.close();
         }
 
-        // MPTCanTrade disabled
+        // MPTCanTrade is disabled
         {
             Env env{*this, features};
             env.fund(XRP(1'000), gw, alice, carol);
             env.close();
 
-            MPTTester mpt(
+            MPT const mpt = MPTTester(
                 {.env = env,
                  .issuer = gw,
                  .holders = {alice, carol},
-                 .flags = tfMPTCanTransfer,
-                 .mutableFlags = tmfMPTCanMutateCanTrade});
+                 .pay = 10,
+                 .flags = tfMPTCanTransfer});
 
-            uint256 checkId{keylet::check(gw, env.seq(gw)).key};
+            uint256 const checkId{keylet::check(alice, env.seq(alice)).key};
 
-            // can't create
-            env(check::create(gw, alice, mpt(100)), Ter(tecNO_PERMISSION));
+            // can create
+            env(check::create(alice, carol, mpt(100)));
             env.close();
-            mpt.set({.account = gw, .mutableFlags = tmfMPTSetCanTrade});
 
-            // can't cash
-            checkId = keylet::check(gw, env.seq(gw)).key;
-            env(check::create(gw, carol, mpt(100)));
-            env.close();
-            mpt.set({.account = gw, .mutableFlags = tmfMPTClearCanTrade});
-            env(check::cash(carol, checkId, mpt(10)), Ter(tecNO_PERMISSION));
+            // can cash
+            env(check::cash(carol, checkId, mpt(10)));
             env.close();
         }
 
@@ -6070,33 +6871,6 @@ class MPToken_test : public beast::unit_test::Suite
             env.close();
         }
 
-        // MPTCanTransfer is not set and the account is not the issuer of MPT
-        {
-            Env env{*this, features};
-            env.fund(XRP(1'000), gw, alice, carol);
-
-            auto eur = MPTTester(
-                {.env = env, .issuer = gw, .holders = {alice, carol}, .flags = tfMPTCanTrade});
-            uint256 const chkId{getCheckIndex(alice, env.seq(alice))};
-            // alice can create
-            env(check::create(alice, carol, eur(1)));
-            env.close();
-
-            // carol can't cash
-            env(check::cash(carol, chkId, eur(1)), Ter(tecPATH_PARTIAL));
-            env.close();
-
-            // if issuer creates a check then carol can cash since
-            // it's a transfer from the issuer
-            uint256 const chkId1{getCheckIndex(gw, env.seq(gw))};
-            // alice can't create since CanTransfer is not set
-            env(check::create(gw, carol, eur(1)));
-            env.close();
-
-            env(check::cash(carol, chkId1, eur(1)));
-            env.close();
-        }
-
         // Can create check if src/dst don't own MPT
         {
             Env env{*this, features};
@@ -6257,8 +7031,7 @@ class MPToken_test : public beast::unit_test::Suite
             AMM amm(env, gw, btc(100), usd(100));
             env.close();
             // alice can't deposit since MPTCanTransfer is not set
-            amm.deposit(
-                DepositArg{.account = alice, .tokens = 1'000, .err = Ter(tecNO_PERMISSION)});
+            amm.deposit(DepositArg{.account = alice, .tokens = 1'000, .err = Ter(tecNO_AUTH)});
             env.close();
 
             // can't clawback since alice is not an LP
@@ -6437,285 +7210,445 @@ class MPToken_test : public beast::unit_test::Suite
             Env env(*this);
             env.fund(XRP(1'000'000), gw, alice, carol);
 
-            auto usd = MPTTester(
-                {.env = env,
-                 .issuer = gw,
-                 .flags = tfMPTCanLock | kMptDexFlags,
-                 .mutableFlags = tmfMPTCanMutateRequireAuth | tmfMPTCanMutateCanTransfer |
-                     tmfMPTCanMutateCanClawback | tmfMPTCanMutateCanTrade});
-            auto eur = MPTTester({.env = env, .issuer = gw, .holders = {alice}, .pay = 1'000'000});
-
             auto const increment = env.current()->fees().increment;
             auto const txfee = Fee(drops(increment));
             auto const badMPT = MPT(gw, 1'000);
 
-            auto createDeleteAMM = [&](Account const& lp) {
-                AMM amm(
-                    env,
-                    lp,
-                    usd(1'000),
-                    eur(1'000),
-                    CreateArg{.fee = static_cast(increment.value())});
-                amm.withdrawAll(lp);
-                BEAST_EXPECT(!amm.ammExists());
+            auto const makeMPT = [&](std::uint32_t const flags,
+                                     Holders holders = {},
+                                     std::uint64_t const pay = 0,
+                                     std::optional const mutableFlags =
+                                         std::nullopt) {
+                return MPTTester(
+                    {.env = env,
+                     .issuer = gw,
+                     .holders = holders,
+                     .pay = pay ? std::optional{pay} : std::nullopt,
+                     .flags = flags,
+                     .mutableFlags = mutableFlags});
+            };
+
+            auto const makeDexMPT = [&](Holders holders = {}, std::uint64_t const pay = 0) {
+                return makeMPT(
+                    tfMPTCanLock | kMptDexFlags,
+                    holders,
+                    pay,
+                    tmfMPTCanEnableRequireAuth | tmfMPTCanEnableCanTransfer |
+                        tmfMPTCanEnableCanTrade);
+            };
+
+            auto const makeNoTransferMPT = [&](Holders holders = {}, std::uint64_t const pay = 0) {
+                return makeMPT(
+                    tfMPTCanLock | tfMPTCanTrade, holders, pay, tmfMPTCanEnableCanTransfer);
+            };
+
+            auto const makeNoTradeMPT = [&](Holders holders = {}, std::uint64_t const pay = 0) {
+                return makeMPT(
+                    tfMPTCanLock | tfMPTCanTransfer, holders, pay, tmfMPTCanEnableCanTrade);
             };
 
-            //
             // AMMCreate
-            //
-
-            auto createJv = AMM::createJv(alice, badMPT(1'000), eur(1'000), 0);
-
-            auto createFail = [&](Account const& account, auto const& err) {
-                createJv[sfAccount] = account.human();
-                env(createJv, txfee, Ter(err));
-                env.close();
-            };
-
-            // MPTokenIssuance doesn't exist
-
-            createFail(alice, tecOBJECT_NOT_FOUND);
-
-            // MPToken doesn't exist
-
-            createJv[sfAmount] = STAmount{usd(1'000)}.getJson();
-            createFail(alice, tecNO_AUTH);
-
-            // alice authorizes MPToken, can create
-            usd.authorize({.account = alice});
-            env(pay(gw, alice, usd(1'000'000)), txfee);
-            env.close();
-            createDeleteAMM(alice);
-
-            // MPTLock is set
-
-            // alice and issuer can't create
-            usd.set({.flags = tfMPTLock});
-            createFail(alice, tecFROZEN);
-            createFail(gw, tecFROZEN);
-
-            // MPTRequireAuth is set
-
-            // alice is not authorized
-            usd.set({.flags = tfMPTUnlock});
-            usd.set({.mutableFlags = tmfMPTSetRequireAuth});
-            createFail(alice, tecNO_AUTH);
-            // issuer can create
-            createDeleteAMM(gw);
-
-            // alice is authorized, can create
-            usd.authorize({.account = gw, .holder = alice});
-            createDeleteAMM(alice);
-
-            // MPTCanTransfer is not set
-
-            usd.set({.mutableFlags = tmfMPTClearRequireAuth});
-            usd.set({.mutableFlags = tmfMPTClearCanTransfer});
-            // alice can't create
-            createFail(alice, tecNO_PERMISSION);
-            // issuer can create
-            createDeleteAMM(gw);
-            usd.set({.mutableFlags = tmfMPTSetCanTransfer});
-            // alice can create
-            createDeleteAMM(alice);
-
-            // MPTCanTrade is not set
-
-            usd.set({.mutableFlags = tmfMPTSetCanTransfer});
-            usd.set({.mutableFlags = tmfMPTClearCanTrade});
-            // alice and issuer can't create
-            createFail(alice, tecNO_PERMISSION);
-            createFail(gw, tecNO_PERMISSION);
-            usd.set({.mutableFlags = tmfMPTSetCanTrade});
-
-            //
-            // AMMDeposit
-            //
-
-            AMM amm(env, gw, usd(1'000), eur(1'000));
-
-            // MPTokenIssuance doesn't exist
-
-            amm.deposit(
-                {.account = alice,
-                 .asset1In = badMPT(1),
-                 .asset2In = eur(1),
-                 .assets = std::make_pair(badMPT, eur),
-                 .err = Ter(terNO_AMM)});
-
-            // MPToken doesn't exist
-
-            amm.deposit(
-                {.account = carol, .asset1In = usd(1), .asset2In = eur(1), .err = Ter(tecNO_AUTH)});
-
-            // MPTLock is set
-
-            usd.set({.flags = tfMPTLock});
-            // alice and issuer can't deposit
-            for (auto const& account : {carol, gw})
             {
+                auto usd = makeDexMPT();
+                auto eur = makeDexMPT({alice}, 1'000'000);
+
+                auto createDeleteAMM = [&](auto const& asset, Account const& lp) {
+                    AMM amm(
+                        env,
+                        lp,
+                        asset(1'000),
+                        eur(1'000),
+                        CreateArg{.fee = static_cast(increment.value())});
+                    amm.withdrawAll(lp);
+                    BEAST_EXPECT(!amm.ammExists());
+                };
+
+                auto createFail = [&](auto const& asset, Account const& account, auto const& err) {
+                    auto const createJv = AMM::createJv(account, asset(1'000), eur(1'000), 0);
+                    env(createJv, txfee, Ter(err));
+                    env.close();
+                };
+
+                // MPTokenIssuance doesn't exist
+                createFail(badMPT, alice, tecOBJECT_NOT_FOUND);
+
+                // MPToken doesn't exist
+                createFail(usd, alice, tecNO_AUTH);
+
+                // alice authorizes MPToken, can create
+                usd.authorize({.account = alice});
+                env(pay(gw, alice, usd(1'000'000)), txfee);
+                env.close();
+                createDeleteAMM(usd, alice);
+
+                // MPTLock is set
+                // alice and issuer can't create
+                usd.set({.flags = tfMPTLock});
+                createFail(usd, alice, tecLOCKED);
+                createFail(usd, gw, tecLOCKED);
+
+                // MPTRequireAuth is set
+                // alice is not authorized
+                usd.set({.flags = tfMPTUnlock});
+                usd.set({.mutableFlags = tmfMPTSetRequireAuth});
+                createFail(usd, alice, tecNO_AUTH);
+                // issuer can create
+                createDeleteAMM(usd, gw);
+
+                // alice is authorized, can create
+                usd.authorize({.account = gw, .holder = alice});
+                createDeleteAMM(usd, alice);
+
+                // MPTCanTransfer is not set
+                {
+                    auto usd2 = makeNoTransferMPT({alice}, 1'000'000);
+
+                    // alice can't create
+                    createFail(usd2, alice, tecNO_AUTH);
+                    // issuer can create
+                    createDeleteAMM(usd2, gw);
+                    usd2.set({.mutableFlags = tmfMPTSetCanTransfer});
+                    // alice can create
+                    createDeleteAMM(usd2, alice);
+                }
+
+                // MPTCanTrade is not set
+                {
+                    auto usd3 = makeNoTradeMPT({alice}, 1'000'000);
+
+                    // alice and issuer can't create
+                    createFail(usd3, alice, tecNO_PERMISSION);
+                    createFail(usd3, gw, tecNO_PERMISSION);
+                    usd3.set({.mutableFlags = tmfMPTSetCanTrade});
+                    // alice can create
+                    createDeleteAMM(usd3, alice);
+                }
+            }
+
+            // AMMDeposit
+            {
+                auto usd = makeDexMPT();
+                auto eur = makeDexMPT({alice}, 1'000'000);
+                AMM amm(env, gw, usd(1'000), eur(1'000));
+
+                // MPTokenIssuance doesn't exist
                 amm.deposit(
-                    {.account = account,
+                    {.account = alice,
+                     .asset1In = badMPT(1),
+                     .asset2In = eur(1),
+                     .assets = std::make_pair(badMPT, eur),
+                     .err = Ter(terNO_AMM)});
+
+                // MPToken doesn't exist
+                amm.deposit(
+                    {.account = carol,
                      .asset1In = usd(1),
                      .asset2In = eur(1),
-                     .err = Ter(tecFROZEN)});
+                     .err = Ter(tecNO_AUTH)});
+
+                // Fund carol for the AMMDeposit checks.
+                usd.authorize({.account = carol});
+                env(pay(gw, carol, usd(1'000'000)));
+                eur.authorize({.account = carol});
+                env(pay(gw, carol, eur(1'000'000)));
+                env.close();
+
+                // MPTLock is set
+                usd.set({.flags = tfMPTLock});
+
+                // alice and issuer can't deposit
+                for (auto const& account : {carol, gw})
+                {
+                    amm.deposit(
+                        {.account = account,
+                         .asset1In = usd(1),
+                         .asset2In = eur(1),
+                         .err = Ter(tecLOCKED)});
+                    amm.deposit(
+                        {.account = account,
+                         .asset1In = eur(1),
+                         .assets = std::make_pair(eur, usd),
+                         .err = Ter(tecLOCKED)});
+                }
+                usd.set({.flags = tfMPTUnlock});
+
+                // MPTRequireAuth is set
+                // carol is not authorized by the issuer
+                usd.set({.mutableFlags = tmfMPTSetRequireAuth});
+                env.close();
                 amm.deposit(
-                    {.account = account,
+                    {.account = carol,
+                     .asset1In = usd(1),
+                     .asset2In = eur(1),
+                     .err = Ter(tecNO_AUTH)});
+                amm.deposit(
+                    {.account = carol,
                      .asset1In = eur(1),
                      .assets = std::make_pair(eur, usd),
-                     .err = Ter(tecFROZEN)});
+                     .err = Ter(tecNO_AUTH)});
+                // issuer can deposit
+                amm.deposit({.account = gw, .tokens = 1'000});
+                // carol is authorized, can deposit
+                usd.authorize({.account = gw, .holder = carol});
+                amm.deposit({.account = carol, .tokens = 1'000});
+                // Can't authorize or unauthorize AMM pseudo-account
+                usd.authorize(
+                    {.account = gw,
+                     .holder = Account{"amm", amm.ammAccount()},
+                     .err = tecNO_PERMISSION});
+                usd.authorize(
+                    {.account = gw,
+                     .holder = Account{"amm", amm.ammAccount()},
+                     .flags = tfMPTUnauthorize,
+                     .err = tecNO_PERMISSION});
+
+                // MPTCanTransfer is not set
+                {
+                    auto usd2 = makeNoTransferMPT({carol}, 1'000'000);
+                    AMM amm2(env, gw, usd2(1'000), eur(1'000));
+
+                    // carol can't deposit
+                    amm2.deposit(
+                        {.account = carol,
+                         .asset1In = usd2(1),
+                         .asset2In = eur(1),
+                         .err = Ter(tecNO_AUTH)});
+                    amm2.deposit(
+                        {.account = carol,
+                         .asset1In = eur(1),
+                         .assets = std::make_pair(eur, usd2),
+                         .err = Ter(tecNO_AUTH)});
+                    // issuer can deposit
+                    amm2.deposit({.account = gw, .tokens = 1'000});
+                    usd2.set({.mutableFlags = tmfMPTSetCanTransfer});
+                    // carol can deposit
+                    amm2.deposit({.account = carol, .tokens = 1'000});
+                }
             }
-            usd.set({.flags = tfMPTUnlock});
 
-            // MPTRequireAuth is set
-
-            // carol authorizes MPToken but is not authorized by the issuer
-            usd.authorize({.account = carol});
-            env(pay(gw, carol, usd(1'000'000)));
-            // carol authorizes EUR
-            eur.authorize({.account = carol});
-            env(pay(gw, carol, eur(1'000'000)));
-            usd.set({.mutableFlags = tmfMPTSetRequireAuth});
-            // have to authorize amm account
-            usd.authorize({.account = gw, .holder = Account{"amm", amm.ammAccount()}});
-            env.close();
-            amm.deposit(
-                {.account = carol, .asset1In = usd(1), .asset2In = eur(1), .err = Ter(tecNO_AUTH)});
-            amm.deposit(
-                {.account = carol,
-                 .asset1In = eur(1),
-                 .assets = std::make_pair(eur, usd),
-                 .err = Ter(tecNO_AUTH)});
-            // issuer can deposit
-            amm.deposit({.account = gw, .tokens = 1'000});
-            // carol is authorized, can deposit
-            usd.authorize({.account = gw, .holder = carol});
-            amm.deposit({.account = carol, .tokens = 1'000});
-
-            // MPTCanTransfer is not set
-
-            usd.set({.mutableFlags = tmfMPTClearRequireAuth});
-            usd.set({.mutableFlags = tmfMPTClearCanTransfer});
-            // carol can't deposit
-            amm.deposit(
-                {.account = carol,
-                 .asset1In = usd(1),
-                 .asset2In = eur(1),
-                 .err = Ter(tecNO_PERMISSION)});
-            amm.deposit(
-                {.account = carol,
-                 .asset1In = eur(1),
-                 .assets = std::make_pair(eur, usd),
-                 .err = Ter(tecNO_PERMISSION)});
-            // issuer can deposit
-            amm.deposit({.account = gw, .tokens = 1'000});
-            // carol can deposit
-            usd.set({.mutableFlags = tmfMPTSetCanTransfer});
-            amm.deposit({.account = carol, .tokens = 1'000});
-
-            // MPTCanTrade is not set
-
-            usd.set({.mutableFlags = tmfMPTSetCanTransfer});
-            usd.set({.mutableFlags = tmfMPTClearCanTrade});
-            amm.deposit({.account = gw, .tokens = 1'000, .err = Ter(tecNO_PERMISSION)});
-            amm.deposit({.account = carol, .tokens = 1'000, .err = Ter(tecNO_PERMISSION)});
-            usd.set({.mutableFlags = tmfMPTSetCanTrade});
-
-            //
             // AMMWithdraw
-            //
-
-            // MPTokenIssuance doesn't exist
-
-            amm.withdraw(
-                WithdrawArg{
-                    .account = carol,
-                    .asset1Out = badMPT(1),
-                    .asset2Out = eur(1),
-                    .assets = std::make_pair(badMPT, eur),
-                    .err = Ter(terNO_AMM)});
-
-            // MPToken doesn't exist - doesn't apply since MPToken is created
-            // on withdraw in this case
-
-            // MPTLock is set
-
-            usd.set({.flags = tfMPTLock});
-            // carol and issuer can't withdraw
-            for (auto const& account : {carol, gw})
             {
+                auto usd = makeDexMPT();
+                auto eur = makeDexMPT({carol}, 1'000'000);
+                AMM amm(env, gw, usd(1'000), eur(1'000));
+
+                usd.authorize({.account = carol});
+                env(pay(gw, carol, usd(1'000'000)));
+                env.close();
+                amm.deposit({.account = carol, .tokens = 1'000});
+
+                // MPTokenIssuance doesn't exist
                 amm.withdraw(
-                    {.account = account,
+                    WithdrawArg{
+                        .account = carol,
+                        .asset1Out = badMPT(1),
+                        .asset2Out = eur(1),
+                        .assets = std::make_pair(badMPT, eur),
+                        .err = Ter(terNO_AMM)});
+
+                // MPToken doesn't exist - doesn't apply since MPToken is created
+                // on withdraw in this case
+
+                // MPTLock is set
+                usd.set({.flags = tfMPTLock});
+                auto const fix330 = env.current()->rules().enabled(fixCleanup3_3_0);
+
+                // carol can't withdraw the locked token (any withdrawal type)
+                amm.withdraw(
+                    {.account = carol,
                      .asset1Out = usd(1),
                      .asset2Out = eur(1),
-                     .err = Ter(tecFROZEN)});
-                amm.withdraw({.account = account, .tokens = 1'000, .err = Ter(tecFROZEN)});
-                // can single withdraw another asset
+                     .err = Ter(tecLOCKED)});
+                amm.withdraw({.account = carol, .tokens = 1'000, .err = Ter(tecLOCKED)});
+                // can single withdraw the non-locked asset
                 amm.withdraw(
-                    {.account = account, .asset1Out = eur(1), .assets = std::make_pair(eur, usd)});
+                    {.account = carol, .asset1Out = eur(1), .assets = std::make_pair(eur, usd)});
+
+                // post-fixCleanup3_3_0 the issuer can redeem even when locked.
+                // Each successful withdrawal burns LP tokens, so replenish between
+                // each type to keep gw's LP balance stable.
+                auto const gwLockErr = fix330 ? Ter(tesSUCCESS) : Ter(tecLOCKED);
+                auto const replenish = [&](IOUAmount tokens) {
+                    usd.set({.flags = tfMPTUnlock});
+                    amm.deposit({.account = gw, .tokens = tokens});
+                    usd.set({.flags = tfMPTLock});
+                };
+
+                amm.withdraw(
+                    {.account = gw, .asset1Out = usd(1), .asset2Out = eur(1), .err = gwLockErr});
+                if (fix330)
+                    replenish(IOUAmount{1});
+
+                amm.withdraw({.account = gw, .tokens = 1'000, .err = gwLockErr});
+                if (fix330)
+                    replenish(IOUAmount{1'000});
+
+                // can single withdraw the non-locked asset
+                amm.withdraw(
+                    {.account = gw, .asset1Out = eur(1), .assets = std::make_pair(eur, usd)});
+
+                usd.set({.flags = tfMPTUnlock});
+
+                // MPTRequireAuth is set
+                usd.set({.mutableFlags = tmfMPTSetRequireAuth});
+                usd.authorize({.account = gw, .holder = carol, .flags = tfMPTUnauthorize});
+                // carol can't withdraw
+                amm.withdraw(
+                    {.account = carol,
+                     .asset1Out = usd(1),
+                     .asset2Out = eur(1),
+                     .err = Ter(tecNO_AUTH)});
+                // can withdraw another asset
+                amm.withdraw(
+                    {.account = carol, .asset1Out = eur(1), .assets = std::make_pair(eur, usd)});
+                // issuer can withdraw
+                amm.withdraw({.account = gw, .asset1Out = usd(1), .asset2Out = eur(1)});
+                // carol is authorized, can withdraw
+                usd.authorize({.account = gw, .holder = carol});
+                amm.withdraw({.account = carol, .asset1Out = usd(1), .asset2Out = eur(1)});
+
+                // MPTCanTransfer is not set, allow to withdraw
+                {
+                    auto usd2 = makeNoTransferMPT({carol}, 1'000'000);
+                    AMM amm2(env, gw, usd2(1'000), eur(1'000));
+
+                    // carol cannot deposit usd2 without MPTCanTransfer, so give her
+                    // LP tokens directly to test the withdraw path.
+                    env.trust(STAmount{amm2.lptIssue(), 1'000}, carol);
+                    env(pay(gw, carol, STAmount{amm2.lptIssue(), 100}));
+                    env.close();
+
+                    // carol can withdraw
+                    amm2.withdraw({.account = carol, .asset1Out = usd2(1), .asset2Out = eur(1)});
+                    // can withdraw another asset
+                    amm2.withdraw(
+                        {.account = carol,
+                         .asset1Out = eur(1),
+                         .assets = std::make_pair(eur, usd2)});
+                    // issuer can withdraw
+                    amm2.withdraw({.account = gw, .asset1Out = usd2(1), .asset2Out = eur(1)});
+                    // Holder can't transfer to another holder
+                    env.fund(XRP(1'000), bob);
+                    usd2.authorize({.account = bob});
+                    env(pay(carol, bob, usd2(1)), Ter(tecNO_AUTH));
+                    usd2.authorize({.account = bob, .flags = tfMPTUnauthorize});
+                    // Can redeem
+                    env(pay(carol, gw, usd2(1)));
+                    usd2.set({.mutableFlags = tmfMPTSetCanTransfer});
+                    // carol can withdraw
+                    amm2.withdraw({.account = carol, .asset1Out = usd2(1), .asset2Out = eur(1)});
+                }
+
+                // MPToken created on withdraw
+                {
+                    auto usd3 = makeDexMPT();
+                    auto eur3 = makeDexMPT({carol}, 1'000'000);
+                    AMM amm3(env, gw, usd3(1'000), eur3(1'000));
+
+                    BEAST_EXPECT(env.le(keylet::mptoken(usd3.issuanceID(), carol)) == nullptr);
+                    // single-deposit EUR
+                    amm3.deposit(
+                        {.account = carol,
+                         .asset1In = eur3(1'000),
+                         .assets = std::make_pair(eur3, usd3)});
+                    BEAST_EXPECT(env.le(keylet::mptoken(usd3.issuanceID(), carol)) == nullptr);
+                    // withdraw in USD to create MPToken
+                    amm3.withdraw({.account = carol, .asset1Out = usd3(100)});
+                    BEAST_EXPECT(env.le(keylet::mptoken(usd3.issuanceID(), carol)));
+                }
             }
-            usd.set({.flags = tfMPTUnlock});
-
-            // MPTRequireAuth is set
-
-            usd.set({.mutableFlags = tmfMPTSetRequireAuth});
-            usd.authorize({.account = gw, .holder = carol, .flags = tfMPTUnauthorize});
-            // carol can't withdraw
-            amm.withdraw(
-                {.account = carol,
-                 .asset1Out = usd(1),
-                 .asset2Out = eur(1),
-                 .err = Ter(tecNO_AUTH)});
-            // can withdraw another asset
-            amm.withdraw(
-                {.account = carol, .asset1Out = eur(1), .assets = std::make_pair(eur, usd)});
-            // issuer can withdraw
-            amm.withdraw({.account = gw, .asset1Out = usd(1), .asset2Out = eur(1)});
-            // carol is authorized, can withdraw
-            usd.authorize({.account = gw, .holder = carol});
-            amm.withdraw({.account = carol, .asset1Out = usd(1), .asset2Out = eur(1)});
-
-            // MPTCanTransfer is set
-
-            usd.set({.mutableFlags = tmfMPTClearRequireAuth});
-            usd.set({.mutableFlags = tmfMPTClearCanTransfer});
-            // carol can't withdraw
-            amm.withdraw(
-                {.account = carol,
-                 .asset1Out = usd(1),
-                 .asset2Out = eur(1),
-                 .err = Ter(tecNO_PERMISSION)});
-            // can withdraw another asset
-            amm.withdraw(
-                {.account = carol, .asset1Out = eur(1), .assets = std::make_pair(eur, usd)});
-            // issuer can withdraw
-            amm.withdraw({.account = gw, .asset1Out = usd(1), .asset2Out = eur(1)});
-            // carol can withdraw
-            usd.set({.mutableFlags = tmfMPTSetCanTransfer});
-            amm.withdraw({.account = carol, .asset1Out = usd(1), .asset2Out = eur(1)});
-
-            usd.set({.mutableFlags = tmfMPTSetCanTransfer});
-            usd.set({.mutableFlags = tmfMPTClearCanTrade});
-            amm.withdraw({.account = gw, .tokens = 1'000, .err = Ter(tecNO_PERMISSION)});
-            amm.withdraw({.account = carol, .tokens = 1'000, .err = Ter(tecNO_PERMISSION)});
-            usd.set({.mutableFlags = tmfMPTSetCanTrade});
-
-            // MPToken created on withdraw
-
-            // redeem all carol's USD and unauthorize USD
-            amm.withdrawAll(carol);
-            env(pay(carol, gw, env.balance(carol, usd)));
-            usd.authorize({.account = carol, .flags = tfMPTUnauthorize});
-            BEAST_EXPECT(env.le(keylet::mptoken(usd.issuanceID(), carol)) == nullptr);
-            // single-deposit EUR
-            amm.deposit(
-                {.account = carol, .asset1In = eur(1'000), .assets = std::make_pair(eur, usd)});
-            // withdraw in USD to create MPToken
-            amm.withdraw({.account = carol, .asset1Out = usd(100)});
-            BEAST_EXPECT(env.le(keylet::mptoken(usd.issuanceID(), carol)));
         }
     }
 
+    void
+    testFixDoubleOwnerCount(FeatureBitset all)
+    {
+        testcase("Fix Double adjustOwnerCount in AMMWithdraw");
+
+        using namespace jtx;
+
+        // Carol deposits XRP into an XRP/MPT pool, then withdraws MPT.
+        // Carol has no MPToken before the withdrawal. If the bug exists,
+        // her ownerCount will be inflated by +1 extra.
+        Account const gw{"gw"};
+        Account const alice{"alice"};
+        Account const carol{"carol"};
+        Env env(*this, all);
+        env.fund(XRP(30'000), gw, alice, carol);
+        env.close();
+
+        // Create MPT with DEX flags. Only alice is a holder initially.
+        MPT const btc = MPTTester(
+            {.env = env, .issuer = gw, .holders = {alice}, .pay = 20'000, .flags = kMptDexFlags});
+
+        // Alice creates XRP/MPT AMM pool
+        AMM amm(env, alice, XRP(10'000), btc(10'000));
+
+        // Carol deposits XRP (single asset) into the pool.
+        // Carol gets LP tokens but does NOT have an MPToken yet.
+        auto const carolOwnersBefore = ownerCount(env, carol);
+        amm.deposit(carol, XRP(1'000), std::nullopt, std::nullopt, tfSingleAsset);
+        auto const carolOwnersAfterDeposit = ownerCount(env, carol);
+        // Carol should have +1 for LP token trustline
+        BEAST_EXPECT(carolOwnersAfterDeposit == carolOwnersBefore + 1);
+
+        auto const carolOwnersBeforeWithdraw = ownerCount(env, carol);
+        // Carol withdraws single MPT asset. She doesn't have an MPToken,
+        // so one must be created. Bug: ownerCount incremented twice.
+        amm.withdraw({.account = carol, .asset1Out = btc(100), .flags = tfSingleAsset});
+        auto const carolOwnersAfterWithdraw = ownerCount(env, carol);
+
+        // Expected: +1 for the new MPToken (so total increase = 1)
+        BEAST_EXPECT(carolOwnersAfterWithdraw == carolOwnersBeforeWithdraw + 1);
+    }
+
+    void
+    testTradeAndTransfer()
+    {
+        using namespace jtx;
+        testcase("Trade and Transfer");
+
+        // Verify canMPTTradeAndTransfer validates the flags when from == to and from != to
+
+        Account const gw{"gw"};
+        Account const alice{"alice"};
+        Account const carol{"carol"};
+        Env env(*this);
+        env.fund(XRP(1'000), gw, alice, carol);
+
+        auto const checkCanTradeCanTransfer = [&](std::uint32_t const flags,
+                                                  TER const gwToGw,
+                                                  TER const gwToAlice,
+                                                  TER const aliceToAlice,
+                                                  TER const aliceToCarol) {
+            MPTTester const mpt(
+                {.env = env, .issuer = gw, .holders = {alice, carol}, .pay = 100, .flags = flags});
+
+            BEAST_EXPECT(canMPTTradeAndTransfer(*env.current(), mpt, gw, gw) == gwToGw);
+            BEAST_EXPECT(canMPTTradeAndTransfer(*env.current(), mpt, gw, alice) == gwToAlice);
+            BEAST_EXPECT(canMPTTradeAndTransfer(*env.current(), mpt, alice, alice) == aliceToAlice);
+            BEAST_EXPECT(canMPTTradeAndTransfer(*env.current(), mpt, alice, carol) == aliceToCarol);
+        };
+
+        // Both flags are enabled
+        checkCanTradeCanTransfer(kMptDexFlags, tesSUCCESS, tesSUCCESS, tesSUCCESS, tesSUCCESS);
+
+        // MPTCanTrade is disabled
+        checkCanTradeCanTransfer(
+            tfMPTCanTransfer,
+            tecNO_PERMISSION,
+            tecNO_PERMISSION,
+            tecNO_PERMISSION,
+            tecNO_PERMISSION);
+
+        // MPTCanTransfer is disabled
+        checkCanTradeCanTransfer(tfMPTCanTrade, tesSUCCESS, tesSUCCESS, tecNO_AUTH, tecNO_AUTH);
+
+        // Both flags are disabled
+        checkCanTradeCanTransfer(
+            0, tecNO_PERMISSION, tecNO_PERMISSION, tecNO_PERMISSION, tecNO_PERMISSION);
+    }
+
 public:
     void
     run() override
@@ -6784,7 +7717,7 @@ public:
 
         // Test MPT Amount is invalid in Tx, which don't support MPT
         testMPTInvalidInTx(all);
-
+        testNonCanonicalMPTAmountCleanup(all);
         // Test parsed MPTokenIssuanceID in API response metadata
         testTxJsonMetaFields(all);
 
@@ -6822,6 +7755,12 @@ public:
 
         // Test AMM
         testBasicAMM(all);
+
+        // Test Trade/Transfer
+        testTradeAndTransfer();
+
+        // Fixes
+        testFixDoubleOwnerCount(all);
     }
 };
 
diff --git a/src/test/app/Manifest_test.cpp b/src/test/app/Manifest_test.cpp
index 55aa93bf79..50e8ab4a8d 100644
--- a/src/test/app/Manifest_test.cpp
+++ b/src/test/app/Manifest_test.cpp
@@ -8,6 +8,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -115,7 +116,7 @@ public:
         SecretKey const& ssk,
         int seq)
     {
-        STObject st(kSfGeneric);
+        STObject st(sfGeneric);
         st[sfSequence] = seq;
         st[sfPublicKey] = pk;
         st[sfSigningPubKey] = spk;
@@ -137,7 +138,7 @@ public:
     {
         auto const pk = derivePublicKey(type, sk);
 
-        STObject st(kSfGeneric);
+        STObject st(sfGeneric);
         st[sfSequence] = std::numeric_limits::max();
         st[sfPublicKey] = pk;
 
@@ -156,7 +157,7 @@ public:
     {
         auto const pk = derivePublicKey(type, sk);
 
-        STObject st(kSfGeneric);
+        STObject st(sfGeneric);
         st[sfSequence] = std::numeric_limits::max();
         st[sfPublicKey] = pk;
 
@@ -186,7 +187,7 @@ public:
         auto const pk = derivePublicKey(type, sk);
         auto const spk = derivePublicKey(stype, ssk);
 
-        STObject st(kSfGeneric);
+        STObject st(sfGeneric);
         st[sfSequence] = seq;
         st[sfPublicKey] = pk;
         st[sfSigningPubKey] = spk;
@@ -246,7 +247,7 @@ public:
 
             auto& app = env.app();
             auto unl = std::make_unique(
-                m, m, env.timeKeeper(), app.config().legacy("database_path"), env.journal);
+                m, m, env.timeKeeper(), app.config().legacy(Sections::kDatabasePath), env.journal);
 
             {
                 // save should not store untrusted master keys to db
@@ -289,10 +290,9 @@ public:
                 if (inManifests.size() == loadedManifests.size())
                 {
                     BEAST_EXPECT(
-                        std::equal(
-                            inManifests.begin(),
-                            inManifests.end(),
-                            loadedManifests.begin(),
+                        std::ranges::equal(
+                            inManifests,
+                            loadedManifests,
                             [](Manifest const* lhs, Manifest const* rhs) { return *lhs == *rhs; }));
                 }
                 else
@@ -363,7 +363,7 @@ public:
         auto const kp = randomKeyPair(KeyType::Secp256k1);
         auto const m = makeManifest(sk, KeyType::Ed25519, kp.second, KeyType::Secp256k1, 0);
 
-        STObject st(kSfGeneric);
+        STObject st(sfGeneric);
         st[sfSequence] = 0;
         st[sfPublicKey] = pk;
         st[sfSigningPubKey] = kp.first;
@@ -495,7 +495,7 @@ public:
         auto const spk = derivePublicKey(KeyType::Secp256k1, ssk);
 
         auto buildManifestObject = [&](std::uint16_t version) {
-            STObject st(kSfGeneric);
+            STObject st(sfGeneric);
             st[sfSequence] = 3;
             st[sfPublicKey] = pk;
             st[sfSigningPubKey] = spk;
@@ -573,7 +573,7 @@ public:
                                                std::optional domain,
                                                bool noSigningPublic = false,
                                                bool noSignature = false) {
-                    STObject st(kSfGeneric);
+                    STObject st(sfGeneric);
                     st[sfSequence] = seq;
                     st[sfPublicKey] = pk;
 
@@ -724,7 +724,7 @@ public:
                     }
                     {
                         // reject matching master & ephemeral keys
-                        STObject st(kSfGeneric);
+                        STObject st(sfGeneric);
                         st[sfSequence] = 314159;
                         st[sfPublicKey] = pk;
                         st[sfSigningPubKey] = pk;
@@ -797,7 +797,7 @@ public:
         auto const pk2 = derivePublicKey(KeyType::Secp256k1, sk2);
 
         auto test = [&](std::string domain) {
-            STObject st(kSfGeneric);
+            STObject st(sfGeneric);
             st[sfSequence] = 7;
             st[sfPublicKey] = pk1;
             st[sfDomain] = makeSlice(domain);
diff --git a/src/test/app/MultiSign_test.cpp b/src/test/app/MultiSign_test.cpp
index f21611df0e..8fb1eb31ab 100644
--- a/src/test/app/MultiSign_test.cpp
+++ b/src/test/app/MultiSign_test.cpp
@@ -24,11 +24,11 @@
 #include 
 
 #include 
-#include 
 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -496,7 +496,7 @@ public:
         Env env(
             *this,
             envconfig([](std::unique_ptr cfg) {
-                cfg->loadFromString("[" SECTION_SIGNING_SUPPORT "]\ntrue");
+                cfg->loadFromString(std::string("[") + Sections::kSigningSupport + "]\ntrue");
                 return cfg;
             }),
             features);
@@ -1121,8 +1121,8 @@ public:
             // Signature should fail.
             auto const info = submitSTTx(local);
             BEAST_EXPECT(
-                info[jss::result][jss::error_exception].asString().find(
-                    "Invalid signature on account r") != std::string::npos);
+                info[jss::result][jss::error_exception].asString().contains(
+                    "Invalid signature on account r"));
         }
         {
             // Multisign with an empty signers array should fail.
@@ -1308,7 +1308,7 @@ public:
         Env env(
             *this,
             envconfig([](std::unique_ptr cfg) {
-                cfg->loadFromString("[" SECTION_SIGNING_SUPPORT "]\ntrue");
+                cfg->loadFromString(std::string("[") + Sections::kSigningSupport + "]\ntrue");
                 return cfg;
             }),
             features);
@@ -1511,7 +1511,7 @@ public:
         env.close();
 
         // Verify that the SignerList object was created correctly.
-        auto const& sle = env.le(keylet::signers(alice.id()));
+        auto const& sle = env.le(keylet::signerList(alice.id()));
         BEAST_EXPECT(sle);
         BEAST_EXPECT(sle->getFieldArray(sfSignerEntries).size() == 2);
         if (features[fixIncludeKeyletFields])
diff --git a/src/test/app/NFTokenAuth_test.cpp b/src/test/app/NFTokenAuth_test.cpp
index 4929cadd65..66716a13b7 100644
--- a/src/test/app/NFTokenAuth_test.cpp
+++ b/src/test/app/NFTokenAuth_test.cpp
@@ -43,7 +43,7 @@ class NFTokenAuth_test : public beast::unit_test::Suite
         env(token::mint(account, 0), token::XferFee(xfee), Txflags(tfTransferable));
         env.close();
 
-        auto const sellIdx = keylet::nftoffer(account, env.seq(account)).key;
+        auto const sellIdx = keylet::nftokenOffer(account, env.seq(account)).key;
         env(token::createOffer(account, nftID, currency), Txflags(tfSellNFToken));
         env.close();
 
@@ -74,7 +74,7 @@ public:
         env(pay(g1, a1, usd(1000)));
 
         auto const [nftID, _] = mintAndOfferNFT(env, a2, drops(1));
-        auto const buyIdx = keylet::nftoffer(a1, env.seq(a1)).key;
+        auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
 
         // It should be possible to create a buy offer even if NFT owner is not
         // authorized
@@ -130,7 +130,7 @@ public:
         // close ledger before running the actual tests against this trustline.
         // After ledger is closed, the trustline will not exist.
         auto const unauthTrustline = [&](OpenView& view, beast::Journal) -> bool {
-            auto const sleA1 = std::make_shared(keylet::line(a1, g1, g1["USD"].currency));
+            auto const sleA1 = std::make_shared(keylet::trustLine(a1, g1, g1["USD"].currency));
             sleA1->setFieldAmount(sfBalance, a1["USD"](-1000));
             view.rawInsert(sleA1);
             return true;
@@ -179,7 +179,7 @@ public:
         env(pay(g1, a2, usd(10)));
         env.close();
 
-        auto const buyIdx = keylet::nftoffer(a1, env.seq(a1)).key;
+        auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
         env(token::createOffer(a1, nftID, usd(10)), token::Owner(a2));
         env.close();
 
@@ -193,7 +193,7 @@ public:
         // tests against this trustline. After ledger is closed, the trustline
         // will not exist.
         auto const unauthTrustline = [&](OpenView& view, beast::Journal) -> bool {
-            auto const sleA1 = std::make_shared(keylet::line(a1, g1, g1["USD"].currency));
+            auto const sleA1 = std::make_shared(keylet::trustLine(a1, g1, g1["USD"].currency));
             sleA1->setFieldAmount(sfBalance, a1["USD"](-1000));
             view.rawInsert(sleA1);
             return true;
@@ -244,7 +244,7 @@ public:
             // Authorizing trustline to make an offer creation possible
             env(trust(g1, usd(0), a2, tfSetfAuth));
             env.close();
-            auto const sellIdx = keylet::nftoffer(a2, env.seq(a2)).key;
+            auto const sellIdx = keylet::nftokenOffer(a2, env.seq(a2)).key;
             env(token::createOffer(a2, nftID, usd(10)), Txflags(tfSellNFToken));
             env.close();
             //
@@ -268,7 +268,7 @@ public:
         }
         else
         {
-            auto const sellIdx = keylet::nftoffer(a2, env.seq(a2)).key;
+            auto const sellIdx = keylet::nftokenOffer(a2, env.seq(a2)).key;
 
             // Old behavior: sell offer can be created without authorization
             env(token::createOffer(a2, nftID, usd(10)), Txflags(tfSellNFToken));
@@ -313,7 +313,7 @@ public:
 
         // Creating an artificial unauth trustline
         auto const unauthTrustline = [&](OpenView& view, beast::Journal) -> bool {
-            auto const sleA1 = std::make_shared(keylet::line(a1, g1, g1["USD"].currency));
+            auto const sleA1 = std::make_shared(keylet::trustLine(a1, g1, g1["USD"].currency));
             sleA1->setFieldAmount(sfBalance, a1["USD"](-1000));
             view.rawInsert(sleA1);
             return true;
@@ -353,7 +353,7 @@ public:
         env.close();
 
         auto const [nftID, sellIdx] = mintAndOfferNFT(env, a2, usd(10));
-        auto const buyIdx = keylet::nftoffer(a1, env.seq(a1)).key;
+        auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
         env(token::createOffer(a1, nftID, usd(11)), token::Owner(a2));
         env.close();
 
@@ -422,7 +422,7 @@ public:
         env.close();
 
         auto const [nftID, sellIdx] = mintAndOfferNFT(env, a2, usd(10));
-        auto const buyIdx = keylet::nftoffer(a1, env.seq(a1)).key;
+        auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
         env(token::createOffer(a1, nftID, usd(11)), token::Owner(a2));
         env.close();
 
@@ -432,7 +432,7 @@ public:
         env.close();
 
         auto const unauthTrustline = [&](OpenView& view, beast::Journal) -> bool {
-            auto const sleA1 = std::make_shared(keylet::line(a1, g1, g1["USD"].currency));
+            auto const sleA1 = std::make_shared(keylet::trustLine(a1, g1, g1["USD"].currency));
             sleA1->setFieldAmount(sfBalance, a1["USD"](-1000));
             view.rawInsert(sleA1);
             return true;
@@ -483,7 +483,7 @@ public:
         env.close();
 
         auto const [nftID, sellIdx] = mintAndOfferNFT(env, a2, usd(10));
-        auto const buyIdx = keylet::nftoffer(a1, env.seq(a1)).key;
+        auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
         env(token::createOffer(a1, nftID, usd(11)), token::Owner(a2));
         env.close();
 
@@ -559,7 +559,7 @@ public:
         auto const [nftID, minterSellIdx] = mintAndOfferNFT(env, minter, drops(1), 1);
         env(token::acceptSellOffer(a1, minterSellIdx));
 
-        uint256 const sellIdx = keylet::nftoffer(a1, env.seq(a1)).key;
+        uint256 const sellIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
         env(token::createOffer(a1, nftID, usd(100)), Txflags(tfSellNFToken));
 
         if (features[fixEnforceNFTokenTrustlineV2])
diff --git a/src/test/app/NFTokenBurn_test.cpp b/src/test/app/NFTokenBurn_test.cpp
index f08793da72..140fe2de15 100644
--- a/src/test/app/NFTokenBurn_test.cpp
+++ b/src/test/app/NFTokenBurn_test.cpp
@@ -76,7 +76,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
         for (uint32_t i = 0; i < tokenCancelCount; ++i)
         {
             // Create sell offer
-            offerIndexes.push_back(keylet::nftoffer(owner, env.seq(owner)).key);
+            offerIndexes.push_back(keylet::nftokenOffer(owner, env.seq(owner)).key);
             env(token::createOffer(owner, nftokenID, drops(1)), Txflags(tfSellNFToken));
             env.close();
         }
@@ -198,6 +198,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
         // Use a default initialized mersenne_twister because we want the
         // effect of random numbers, but we want the test to run the same
         // way each time.
+        // NOLINTNEXTLINE(bugprone-random-generator-seed): fixed seed for reproducible test
         std::mt19937 engine;
         std::uniform_int_distribution feeDist(
             decltype(kMaxTransferFee){}, kMaxTransferFee);
@@ -235,7 +236,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             {
                 // We do the same work on alice and minter, so make a lambda.
                 auto xferNFT = [&env, &becky](AcctStat& acct, auto& iter) {
-                    uint256 const offerIndex = keylet::nftoffer(acct.acct, env.seq(acct.acct)).key;
+                    uint256 const offerIndex =
+                        keylet::nftokenOffer(acct.acct, env.seq(acct.acct)).key;
                     env(token::createOffer(acct, *iter, XRP(0)), Txflags(tfSellNFToken));
                     env.close();
                     env(token::acceptSellOffer(becky, offerIndex));
@@ -472,19 +474,19 @@ class NFTokenBurn_test : public beast::unit_test::Suite
 
             // Verify that that all three pages are present and remember the
             // indexes.
-            auto lastNFTokenPage = env.le(keylet::nftpageMax(alice));
+            auto lastNFTokenPage = env.le(keylet::nftokenPageMax(alice));
             if (!BEAST_EXPECT(lastNFTokenPage))
                 return;
 
             uint256 const middleNFTokenPageIndex = lastNFTokenPage->at(sfPreviousPageMin);
             auto middleNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(alice), middleNFTokenPageIndex));
+                env.le(keylet::nftokenPage(keylet::nftokenPageMin(alice), middleNFTokenPageIndex));
             if (!BEAST_EXPECT(middleNFTokenPage))
                 return;
 
             uint256 const firstNFTokenPageIndex = middleNFTokenPage->at(sfPreviousPageMin);
             auto firstNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(alice), firstNFTokenPageIndex));
+                env.le(keylet::nftokenPage(keylet::nftokenPageMin(alice), firstNFTokenPageIndex));
             if (!BEAST_EXPECT(firstNFTokenPage))
                 return;
 
@@ -498,7 +500,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
 
             // Verify that the last page is still present and contains just one
             // NFT.
-            lastNFTokenPage = env.le(keylet::nftpageMax(alice));
+            lastNFTokenPage = env.le(keylet::nftokenPageMax(alice));
             if (!BEAST_EXPECT(lastNFTokenPage))
                 return;
 
@@ -515,23 +517,23 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             {
                 // Removing the last token from the last page deletes the
                 // _previous_ page because we need to preserve that last
-                // page an an anchor.  The contents of the next-to-last page
+                // page as an anchor.  The contents of the next-to-last page
                 // are moved into the last page.
-                lastNFTokenPage = env.le(keylet::nftpageMax(alice));
+                lastNFTokenPage = env.le(keylet::nftokenPageMax(alice));
                 BEAST_EXPECT(lastNFTokenPage);
                 BEAST_EXPECT(lastNFTokenPage->at(~sfPreviousPageMin) == firstNFTokenPageIndex);
                 BEAST_EXPECT(!lastNFTokenPage->isFieldPresent(sfNextPageMin));
                 BEAST_EXPECT(lastNFTokenPage->getFieldArray(sfNFTokens).size() == 32);
 
                 // The "middle" page should be gone.
-                middleNFTokenPage =
-                    env.le(keylet::nftpage(keylet::nftpageMin(alice), middleNFTokenPageIndex));
+                middleNFTokenPage = env.le(
+                    keylet::nftokenPage(keylet::nftokenPageMin(alice), middleNFTokenPageIndex));
                 BEAST_EXPECT(!middleNFTokenPage);
 
                 // The "first" page should still be present and linked to
                 // the last page.
-                firstNFTokenPage =
-                    env.le(keylet::nftpage(keylet::nftpageMin(alice), firstNFTokenPageIndex));
+                firstNFTokenPage = env.le(
+                    keylet::nftokenPage(keylet::nftokenPageMin(alice), firstNFTokenPageIndex));
                 BEAST_EXPECT(firstNFTokenPage);
                 BEAST_EXPECT(!firstNFTokenPage->isFieldPresent(sfPreviousPageMin));
                 BEAST_EXPECT(firstNFTokenPage->at(~sfNextPageMin) == lastNFTokenPage->key());
@@ -542,13 +544,13 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 // Removing the last token from the last page deletes the last
                 // page.  This is a bug.  The contents of the next-to-last page
                 // should have been moved into the last page.
-                lastNFTokenPage = env.le(keylet::nftpageMax(alice));
+                lastNFTokenPage = env.le(keylet::nftokenPageMax(alice));
                 BEAST_EXPECT(!lastNFTokenPage);
 
                 // The "middle" page is still present, but has lost the
                 // NextPageMin field.
-                middleNFTokenPage =
-                    env.le(keylet::nftpage(keylet::nftpageMin(alice), middleNFTokenPageIndex));
+                middleNFTokenPage = env.le(
+                    keylet::nftokenPage(keylet::nftokenPageMin(alice), middleNFTokenPageIndex));
                 if (!BEAST_EXPECT(middleNFTokenPage))
                     return;
                 BEAST_EXPECT(middleNFTokenPage->isFieldPresent(sfPreviousPageMin));
@@ -576,19 +578,19 @@ class NFTokenBurn_test : public beast::unit_test::Suite
 
             // Verify that that all three pages are present and remember the
             // indexes.
-            auto lastNFTokenPage = env.le(keylet::nftpageMax(alice));
+            auto lastNFTokenPage = env.le(keylet::nftokenPageMax(alice));
             if (!BEAST_EXPECT(lastNFTokenPage))
                 return;
 
             uint256 const middleNFTokenPageIndex = lastNFTokenPage->at(sfPreviousPageMin);
             auto middleNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(alice), middleNFTokenPageIndex));
+                env.le(keylet::nftokenPage(keylet::nftokenPageMin(alice), middleNFTokenPageIndex));
             if (!BEAST_EXPECT(middleNFTokenPage))
                 return;
 
             uint256 const firstNFTokenPageIndex = middleNFTokenPage->at(sfPreviousPageMin);
             auto firstNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(alice), firstNFTokenPageIndex));
+                env.le(keylet::nftokenPage(keylet::nftokenPageMin(alice), firstNFTokenPageIndex));
             if (!BEAST_EXPECT(firstNFTokenPage))
                 return;
 
@@ -604,17 +606,17 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             // Verify that middle page is gone and the links in the two
             // remaining pages are correct.
             middleNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(alice), middleNFTokenPageIndex));
+                env.le(keylet::nftokenPage(keylet::nftokenPageMin(alice), middleNFTokenPageIndex));
             BEAST_EXPECT(!middleNFTokenPage);
 
-            lastNFTokenPage = env.le(keylet::nftpageMax(alice));
+            lastNFTokenPage = env.le(keylet::nftokenPageMax(alice));
             BEAST_EXPECT(!lastNFTokenPage->isFieldPresent(sfNextPageMin));
             BEAST_EXPECT(lastNFTokenPage->getFieldH256(sfPreviousPageMin) == firstNFTokenPageIndex);
 
             firstNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(alice), firstNFTokenPageIndex));
+                env.le(keylet::nftokenPage(keylet::nftokenPageMin(alice), firstNFTokenPageIndex));
             BEAST_EXPECT(
-                firstNFTokenPage->getFieldH256(sfNextPageMin) == keylet::nftpageMax(alice).key);
+                firstNFTokenPage->getFieldH256(sfNextPageMin) == keylet::nftokenPageMax(alice).key);
             BEAST_EXPECT(!firstNFTokenPage->isFieldPresent(sfPreviousPageMin));
 
             // Burn the remaining nfts.
@@ -637,19 +639,19 @@ class NFTokenBurn_test : public beast::unit_test::Suite
 
             // Verify that that all three pages are present and remember the
             // indexes.
-            auto lastNFTokenPage = env.le(keylet::nftpageMax(alice));
+            auto lastNFTokenPage = env.le(keylet::nftokenPageMax(alice));
             if (!BEAST_EXPECT(lastNFTokenPage))
                 return;
 
             uint256 const middleNFTokenPageIndex = lastNFTokenPage->at(sfPreviousPageMin);
             auto middleNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(alice), middleNFTokenPageIndex));
+                env.le(keylet::nftokenPage(keylet::nftokenPageMin(alice), middleNFTokenPageIndex));
             if (!BEAST_EXPECT(middleNFTokenPage))
                 return;
 
             uint256 const firstNFTokenPageIndex = middleNFTokenPage->at(sfPreviousPageMin);
             auto firstNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(alice), firstNFTokenPageIndex));
+                env.le(keylet::nftokenPage(keylet::nftokenPageMin(alice), firstNFTokenPageIndex));
             if (!BEAST_EXPECT(firstNFTokenPage))
                 return;
 
@@ -664,18 +666,18 @@ class NFTokenBurn_test : public beast::unit_test::Suite
 
             // Verify the first page is gone.
             firstNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(alice), firstNFTokenPageIndex));
+                env.le(keylet::nftokenPage(keylet::nftokenPageMin(alice), firstNFTokenPageIndex));
             BEAST_EXPECT(!firstNFTokenPage);
 
             // Check the links in the other two pages.
             middleNFTokenPage =
-                env.le(keylet::nftpage(keylet::nftpageMin(alice), middleNFTokenPageIndex));
+                env.le(keylet::nftokenPage(keylet::nftokenPageMin(alice), middleNFTokenPageIndex));
             if (!BEAST_EXPECT(middleNFTokenPage))
                 return;
             BEAST_EXPECT(!middleNFTokenPage->isFieldPresent(sfPreviousPageMin));
             BEAST_EXPECT(middleNFTokenPage->isFieldPresent(sfNextPageMin));
 
-            lastNFTokenPage = env.le(keylet::nftpageMax(alice));
+            lastNFTokenPage = env.le(keylet::nftokenPageMax(alice));
             if (!BEAST_EXPECT(lastNFTokenPage))
                 return;
             BEAST_EXPECT(lastNFTokenPage->isFieldPresent(sfPreviousPageMin));
@@ -694,22 +696,22 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             {
                 // Removing the last token from the last page deletes the
                 // _previous_ page because we need to preserve that last
-                // page an an anchor.  The contents of the next-to-last page
+                // page as an anchor.  The contents of the next-to-last page
                 // are moved into the last page.
-                lastNFTokenPage = env.le(keylet::nftpageMax(alice));
+                lastNFTokenPage = env.le(keylet::nftokenPageMax(alice));
                 BEAST_EXPECT(lastNFTokenPage);
                 BEAST_EXPECT(!lastNFTokenPage->isFieldPresent(sfPreviousPageMin));
                 BEAST_EXPECT(!lastNFTokenPage->isFieldPresent(sfNextPageMin));
                 BEAST_EXPECT(lastNFTokenPage->getFieldArray(sfNFTokens).size() == 32);
 
                 // The "middle" page should be gone.
-                middleNFTokenPage =
-                    env.le(keylet::nftpage(keylet::nftpageMin(alice), middleNFTokenPageIndex));
+                middleNFTokenPage = env.le(
+                    keylet::nftokenPage(keylet::nftokenPageMin(alice), middleNFTokenPageIndex));
                 BEAST_EXPECT(!middleNFTokenPage);
 
                 // The "first" page should still be gone.
-                firstNFTokenPage =
-                    env.le(keylet::nftpage(keylet::nftpageMin(alice), firstNFTokenPageIndex));
+                firstNFTokenPage = env.le(
+                    keylet::nftokenPage(keylet::nftokenPageMin(alice), firstNFTokenPageIndex));
                 BEAST_EXPECT(!firstNFTokenPage);
             }
             else
@@ -717,13 +719,13 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 // Removing the last token from the last page deletes the last
                 // page.  This is a bug.  The contents of the next-to-last page
                 // should have been moved into the last page.
-                lastNFTokenPage = env.le(keylet::nftpageMax(alice));
+                lastNFTokenPage = env.le(keylet::nftokenPageMax(alice));
                 BEAST_EXPECT(!lastNFTokenPage);
 
                 // The "middle" page is still present, but has lost the
                 // NextPageMin field.
-                middleNFTokenPage =
-                    env.le(keylet::nftpage(keylet::nftpageMin(alice), middleNFTokenPageIndex));
+                middleNFTokenPage = env.le(
+                    keylet::nftokenPage(keylet::nftokenPageMin(alice), middleNFTokenPageIndex));
                 if (!BEAST_EXPECT(middleNFTokenPage))
                     return;
                 BEAST_EXPECT(!middleNFTokenPage->isFieldPresent(sfPreviousPageMin));
@@ -777,7 +779,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                     env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
 
                 // Verify that the last page is present and contains one NFT.
-                auto lastNFTokenPage = ac.view().peek(keylet::nftpageMax(alice));
+                auto lastNFTokenPage = ac.view().peek(keylet::nftokenPageMax(alice));
                 if (!BEAST_EXPECT(lastNFTokenPage))
                     return;
                 BEAST_EXPECT(lastNFTokenPage->getFieldArray(sfNFTokens).size() == 1);
@@ -794,9 +796,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                     BEAST_EXPECT(sink.messages().str().starts_with("Invariant failed:"));
                     // uncomment to log the invariant failure message
                     // log << "   --> " << sink.messages().str() << std::endl;
-                    BEAST_EXPECT(
-                        sink.messages().str().find(
-                            "Last NFT page deleted with non-empty directory") != std::string::npos);
+                    BEAST_EXPECT(sink.messages().str().contains(
+                        "Last NFT page deleted with non-empty directory"));
                 }
             }
             {
@@ -810,10 +811,10 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                     env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
 
                 // Verify that the middle  page is present.
-                auto lastNFTokenPage = ac.view().peek(keylet::nftpageMax(alice));
+                auto lastNFTokenPage = ac.view().peek(keylet::nftokenPageMax(alice));
                 auto middleNFTokenPage = ac.view().peek(
-                    keylet::nftpage(
-                        keylet::nftpageMin(alice),
+                    keylet::nftokenPage(
+                        keylet::nftokenPageMin(alice),
                         lastNFTokenPage->getFieldH256(sfPreviousPageMin)));
                 BEAST_EXPECT(middleNFTokenPage);
 
@@ -831,8 +832,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                     BEAST_EXPECT(sink.messages().str().starts_with("Invariant failed:"));
                     // uncomment to log the invariant failure message
                     // log << "   --> " << sink.messages().str() << std::endl;
-                    BEAST_EXPECT(
-                        sink.messages().str().find("Lost NextMinPage link") != std::string::npos);
+                    BEAST_EXPECT(sink.messages().str().contains("Lost NextMinPage link"));
                 }
             }
         }
@@ -867,11 +867,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             // Verify all sell offers are present in the ledger.
             for (uint256 const& offerIndex : offerIndexes)
             {
-                BEAST_EXPECT(env.le(keylet::nftoffer(offerIndex)));
+                BEAST_EXPECT(env.le(keylet::nftokenOffer(offerIndex)));
             }
 
             // Becky creates a buy offer
-            uint256 const beckyOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftokenID, drops(1)), token::Owner(alice));
             env.close();
 
@@ -883,12 +883,12 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             // that alice created
             for (uint256 const& offerIndex : offerIndexes)
             {
-                BEAST_EXPECT(!env.le(keylet::nftoffer(offerIndex)));
+                BEAST_EXPECT(!env.le(keylet::nftokenOffer(offerIndex)));
             }
 
             // Burning the token should also remove the one buy offer
             // that becky created
-            BEAST_EXPECT(!env.le(keylet::nftoffer(beckyOfferIndex)));
+            BEAST_EXPECT(!env.le(keylet::nftokenOffer(beckyOfferIndex)));
 
             // alice and becky should have ownerCounts of zero
             BEAST_EXPECT(ownerCount(env, alice) == 0);
@@ -914,7 +914,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             // Verify all sell offers are present in the ledger.
             for (uint256 const& offerIndex : offerIndexes)
             {
-                BEAST_EXPECT(env.le(keylet::nftoffer(offerIndex)));
+                BEAST_EXPECT(env.le(keylet::nftokenOffer(offerIndex)));
             }
 
             // Burn the token
@@ -925,7 +925,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             // Count the number of sell offers that have been deleted
             for (uint256 const& offerIndex : offerIndexes)
             {
-                if (!env.le(keylet::nftoffer(offerIndex)))
+                if (!env.le(keylet::nftokenOffer(offerIndex)))
                     offerDeletedCount++;
             }
 
@@ -958,7 +958,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             // Verify all sell offers are present in the ledger.
             for (uint256 const& offerIndex : offerIndexes)
             {
-                BEAST_EXPECT(env.le(keylet::nftoffer(offerIndex)));
+                BEAST_EXPECT(env.le(keylet::nftokenOffer(offerIndex)));
             }
 
             // becky creates 2 buy offers
@@ -975,7 +975,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             // ledger.
             for (uint256 const& offerIndex : offerIndexes)
             {
-                BEAST_EXPECT(!env.le(keylet::nftoffer(offerIndex)));
+                BEAST_EXPECT(!env.le(keylet::nftokenOffer(offerIndex)));
             }
 
             // alice should have ownerCount of zero because all her
@@ -1046,7 +1046,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 env.close();
 
                 // Minter creates an offer for the NFToken.
-                uint256 const minterOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
                 env(token::createOffer(minter, nfts.back(), XRP(0)), Txflags(tfSellNFToken));
                 env.close();
 
@@ -1093,19 +1093,19 @@ class NFTokenBurn_test : public beast::unit_test::Suite
 
         // Verify that that all three pages are present and remember the
         // indexes.
-        auto lastNFTokenPage = env.le(keylet::nftpageMax(alice));
+        auto lastNFTokenPage = env.le(keylet::nftokenPageMax(alice));
         if (!BEAST_EXPECT(lastNFTokenPage))
             return;
 
         uint256 const middleNFTokenPageIndex = lastNFTokenPage->at(sfPreviousPageMin);
         auto middleNFTokenPage =
-            env.le(keylet::nftpage(keylet::nftpageMin(alice), middleNFTokenPageIndex));
+            env.le(keylet::nftokenPage(keylet::nftokenPageMin(alice), middleNFTokenPageIndex));
         if (!BEAST_EXPECT(middleNFTokenPage))
             return;
 
         uint256 const firstNFTokenPageIndex = middleNFTokenPage->at(sfPreviousPageMin);
         auto firstNFTokenPage =
-            env.le(keylet::nftpage(keylet::nftpageMin(alice), firstNFTokenPageIndex));
+            env.le(keylet::nftokenPage(keylet::nftokenPageMin(alice), firstNFTokenPageIndex));
         if (!BEAST_EXPECT(firstNFTokenPage))
             return;
 
@@ -1117,7 +1117,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             nfts.pop_back();
 
             // alice creates an offer for the NFToken.
-            uint256 const aliceOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, last32NFTs.back(), XRP(0)), Txflags(tfSellNFToken));
             env.close();
 
@@ -1129,14 +1129,14 @@ class NFTokenBurn_test : public beast::unit_test::Suite
         // Removing the last token from the last page deletes alice's last
         // page.  This is a bug.  The contents of the next-to-last page
         // should have been moved into the last page.
-        lastNFTokenPage = env.le(keylet::nftpageMax(alice));
+        lastNFTokenPage = env.le(keylet::nftokenPageMax(alice));
         BEAST_EXPECT(!lastNFTokenPage);
         BEAST_EXPECT(ownerCount(env, alice) == 2);
 
         // The "middle" page is still present, but has lost the
         // NextPageMin field.
         middleNFTokenPage =
-            env.le(keylet::nftpage(keylet::nftpageMin(alice), middleNFTokenPageIndex));
+            env.le(keylet::nftokenPage(keylet::nftokenPageMin(alice), middleNFTokenPageIndex));
         if (!BEAST_EXPECT(middleNFTokenPage))
             return;
         BEAST_EXPECT(middleNFTokenPage->isFieldPresent(sfPreviousPageMin));
@@ -1151,7 +1151,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
         for (uint256 const nftID : last32NFTs)
         {
             // minter creates an offer for the NFToken.
-            uint256 const minterOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken));
             env.close();
 
diff --git a/src/test/app/NFTokenDir_test.cpp b/src/test/app/NFTokenDir_test.cpp
index 117f2bc816..19bf58f247 100644
--- a/src/test/app/NFTokenDir_test.cpp
+++ b/src/test/app/NFTokenDir_test.cpp
@@ -142,8 +142,8 @@ class NFTokenDir_test : public beast::unit_test::Suite
         std::vector offers;
         for (uint256 const& nftID : nftIDs)
         {
-            offers.emplace_back(keylet::nftoffer(issuer, env.seq(issuer)).key);
-            env(token::createOffer(issuer, nftID, XRP(0)), Txflags((tfSellNFToken)));
+            offers.emplace_back(keylet::nftokenOffer(issuer, env.seq(issuer)).key);
+            env(token::createOffer(issuer, nftID, XRP(0)), Txflags(tfSellNFToken));
             env.close();
         }
 
@@ -214,10 +214,10 @@ class NFTokenDir_test : public beast::unit_test::Suite
                 env.close();
 
                 // Create an offer to give the NFT to buyer for free.
-                offers.emplace_back(keylet::nftoffer(account, env.seq(account)).key);
+                offers.emplace_back(keylet::nftokenOffer(account, env.seq(account)).key);
                 env(token::createOffer(account, nftID, XRP(0)),
                     token::Destination(buyer),
-                    Txflags((tfSellNFToken)));
+                    Txflags(tfSellNFToken));
             }
             env.close();
 
@@ -237,7 +237,7 @@ class NFTokenDir_test : public beast::unit_test::Suite
             // generates a non-tesSUCCESS error code.
             for (uint256 const& nftID : nftIDs)
             {
-                uint256 const offerID = keylet::nftoffer(buyer, env.seq(buyer)).key;
+                uint256 const offerID = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
                 env(token::createOffer(buyer, nftID, XRP(100)), Txflags(tfSellNFToken));
                 env.close();
 
@@ -418,10 +418,10 @@ class NFTokenDir_test : public beast::unit_test::Suite
                 env.close();
 
                 // Create an offer to give the NFT to buyer for free.
-                offers.emplace_back(keylet::nftoffer(account, env.seq(account)).key);
+                offers.emplace_back(keylet::nftokenOffer(account, env.seq(account)).key);
                 env(token::createOffer(account, nftID, XRP(0)),
                     token::Destination(buyer),
-                    Txflags((tfSellNFToken)));
+                    Txflags(tfSellNFToken));
             }
             env.close();
 
@@ -445,7 +445,7 @@ class NFTokenDir_test : public beast::unit_test::Suite
             // generates a non-tesSUCCESS error code.
             for (uint256 const& nftID : nftIDs)
             {
-                uint256 const offerID = keylet::nftoffer(buyer, env.seq(buyer)).key;
+                uint256 const offerID = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
                 env(token::createOffer(buyer, nftID, XRP(100)), Txflags(tfSellNFToken));
                 env.close();
 
@@ -648,10 +648,10 @@ class NFTokenDir_test : public beast::unit_test::Suite
             env.close();
 
             // Create an offer to give the NFT to buyer for free.
-            offers.emplace_back(keylet::nftoffer(account, env.seq(account)).key);
+            offers.emplace_back(keylet::nftokenOffer(account, env.seq(account)).key);
             env(token::createOffer(account, nftID, XRP(0)),
                 token::Destination(buyer),
-                Txflags((tfSellNFToken)));
+                Txflags(tfSellNFToken));
         }
         env.close();
 
@@ -684,7 +684,7 @@ class NFTokenDir_test : public beast::unit_test::Suite
         // a non-tesSUCCESS error code.
         for (uint256 const& nftID : nftIDs)
         {
-            uint256 const offerID = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const offerID = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftID, XRP(100)), Txflags(tfSellNFToken));
             env.close();
 
@@ -820,10 +820,10 @@ class NFTokenDir_test : public beast::unit_test::Suite
                 env.close();
 
                 // Create an offer to give the NFT to buyer for free.
-                offers[i].emplace_back(keylet::nftoffer(account, env.seq(account)).key);
+                offers[i].emplace_back(keylet::nftokenOffer(account, env.seq(account)).key);
                 env(token::createOffer(account, nftID, XRP(0)),
                     token::Destination(buyer),
-                    Txflags((tfSellNFToken)));
+                    Txflags(tfSellNFToken));
             }
         }
         env.close();
diff --git a/src/test/app/NFToken_test.cpp b/src/test/app/NFToken_test.cpp
index ebd470ec92..f191e04a47 100644
--- a/src/test/app/NFToken_test.cpp
+++ b/src/test/app/NFToken_test.cpp
@@ -143,7 +143,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             Account const alice{"alice"};
             env.fund(XRP(10000), alice);
             env.close();
-            uint256 const aliceOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftId1, XRP(1000)), token::Owner(master));
             env.close();
 
@@ -861,7 +861,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(ownerCount(env, alice) == 1);
 
         // This is the offer we'll try to cancel.
-        uint256 const buyerOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+        uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
         env(token::createOffer(buyer, nftAlice0ID, XRP(1)), token::Owner(alice), Ter(tesSUCCESS));
         env.close();
         BEAST_EXPECT(ownerCount(env, buyer) == 1);
@@ -892,6 +892,25 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(ownerCount(env, buyer) == 1);
         }
 
+        // Only test this with fixCleanup3_2_0 enabled. Without the fix,
+        // an assert-enabled build can crash when Ledger::read() receives
+        // a zero-key offer ID.
+        if (features[fixCleanup3_2_0])
+        {
+            // Zero is not a valid offer ID.
+            env(token::cancelOffer(buyer, {uint256{}}), Ter(temMALFORMED));
+            env.close();
+            BEAST_EXPECT(ownerCount(env, buyer) == 1);
+
+            // List of offer IDs containing zero is invalid.
+            // craftedIndex is not a valid offer index but it is not zero.
+            auto const craftedIndex = keylet::nftokenOffer(gw, env.seq(gw)).key;
+            env(token::cancelOffer(buyer, {buyerOfferIndex, uint256{}, craftedIndex}),
+                Ter(temMALFORMED));
+            env.close();
+            BEAST_EXPECT(ownerCount(env, buyer) == 1);
+        }
+
         // List of tokens to delete is too long.
         {
             std::vector const offers(kMaxTokenOfferCancelCount + 1, buyerOfferIndex);
@@ -987,32 +1006,32 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(ownerCount(env, alice) == aliceCount);
 
         // alice creates sell offers for her nfts.
-        uint256 const plainOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+        uint256 const plainOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
         env(token::createOffer(alice, nftAlice0ID, XRP(10)), Txflags(tfSellNFToken));
         env.close();
         aliceCount++;
         BEAST_EXPECT(ownerCount(env, alice) == aliceCount);
 
-        uint256 const audOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+        uint256 const audOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
         env(token::createOffer(alice, nftAlice0ID, gwAUD(30)), Txflags(tfSellNFToken));
         env.close();
         aliceCount++;
         BEAST_EXPECT(ownerCount(env, alice) == aliceCount);
 
-        uint256 const xrpOnlyOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+        uint256 const xrpOnlyOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
         env(token::createOffer(alice, nftXrpOnlyID, XRP(20)), Txflags(tfSellNFToken));
         env.close();
         aliceCount++;
         BEAST_EXPECT(ownerCount(env, alice) == aliceCount);
 
-        uint256 const noXferOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+        uint256 const noXferOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
         env(token::createOffer(alice, nftNoXferID, XRP(30)), Txflags(tfSellNFToken));
         env.close();
         aliceCount++;
         BEAST_EXPECT(ownerCount(env, alice) == aliceCount);
 
         // alice creates a sell offer that will expire soon.
-        uint256 const aliceExpOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+        uint256 const aliceExpOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
         env(token::createOffer(alice, nftNoXferID, XRP(40)),
             Txflags(tfSellNFToken),
             token::Expiration(lastClose(env) + 5));
@@ -1021,7 +1040,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(ownerCount(env, alice) == aliceCount);
 
         // buyer creates a Buy offer that will expire soon.
-        uint256 const buyerExpOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+        uint256 const buyerExpOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
         env(token::createOffer(buyer, nftAlice0ID, XRP(40)),
             token::Owner(alice),
             token::Expiration(lastClose(env) + 5));
@@ -1089,7 +1108,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(ownerCount(env, buyer) == buyerCount);
 
         // The buy offer must be present in the ledger.
-        uint256 const missingOfferIndex = keylet::nftoffer(alice, 1).key;
+        uint256 const missingOfferIndex = keylet::nftokenOffer(alice, 1).key;
         env(token::acceptBuyOffer(buyer, missingOfferIndex), Ter(tecOBJECT_NOT_FOUND));
         env.close();
         BEAST_EXPECT(ownerCount(env, buyer) == buyerCount);
@@ -1101,6 +1120,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         if (features[fixCleanup3_1_3])
         {
             buyerCount--;
+            BEAST_EXPECT(!env.closed()->exists(keylet::nftokenOffer(buyerExpOfferIndex)));
         }
         BEAST_EXPECT(ownerCount(env, buyer) == buyerCount);
 
@@ -1124,6 +1144,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         if (features[fixCleanup3_1_3])
         {
             aliceCount--;
+            BEAST_EXPECT(!env.closed()->exists(keylet::nftokenOffer(aliceExpOfferIndex)));
         }
         BEAST_EXPECT(ownerCount(env, alice) == aliceCount);
         BEAST_EXPECT(ownerCount(env, buyer) == buyerCount);
@@ -1150,7 +1171,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // corresponding buy and sell offers.
         {
             // buyer creates a buy offer for one of alice's nfts.
-            uint256 const buyerOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftAlice0ID, gwAUD(29)), token::Owner(alice));
             env.close();
             buyerCount++;
@@ -1183,7 +1204,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         }
         {
             // buyer creates a buy offer for one of alice's nfts.
-            uint256 const buyerOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftAlice0ID, gwAUD(31)), token::Owner(alice));
             env.close();
             buyerCount++;
@@ -1222,7 +1243,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // preclaim buy
         {
             // buyer creates a buy offer for one of alice's nfts.
-            uint256 const buyerOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftAlice0ID, gwAUD(30)), token::Owner(alice));
             env.close();
             buyerCount++;
@@ -1249,7 +1270,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // alice gives her NFT to gw, so alice no longer owns nftAlice0.
             {
-                uint256 const offerIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+                uint256 const offerIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
                 env(token::createOffer(alice, nftAlice0ID, XRP(0)), Txflags(tfSellNFToken));
                 env.close();
                 env(token::acceptSellOffer(gw, offerIndex));
@@ -1274,7 +1295,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // preclaim sell
         {
             // buyer creates a buy offer for one of alice's nfts.
-            uint256 const buyerOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftXrpOnlyID, XRP(30)), token::Owner(alice));
             env.close();
             buyerCount++;
@@ -1302,7 +1323,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // buyer attempting to accept one of alice's offers with
             // insufficient funds.
             {
-                uint256 const offerIndex = keylet::nftoffer(gw, env.seq(gw)).key;
+                uint256 const offerIndex = keylet::nftokenOffer(gw, env.seq(gw)).key;
                 env(token::createOffer(gw, nftAlice0ID, XRP(0)), Txflags(tfSellNFToken));
                 env.close();
                 env(token::acceptSellOffer(alice, offerIndex));
@@ -1355,7 +1376,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env(token::mint(minter1, 0u), token::Issuer(alice), Txflags(flags));
             env.close();
 
-            uint256 const offerIndex = keylet::nftoffer(minter1, env.seq(minter1)).key;
+            uint256 const offerIndex = keylet::nftokenOffer(minter1, env.seq(minter1)).key;
             env(token::createOffer(minter1, nftID, XRP(0)), Txflags(tfSellNFToken));
             env.close();
 
@@ -1458,13 +1479,13 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
 
             BEAST_EXPECT(ownerCount(env, alice) == 2);
-            uint256 const aliceOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftIOUsOkayID, gwAUD(50)), Txflags(tfSellNFToken));
             env.close();
             BEAST_EXPECT(ownerCount(env, alice) == 3);
 
             BEAST_EXPECT(ownerCount(env, buyer) == 1);
-            uint256 const buyerOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftIOUsOkayID, gwAUD(50)), token::Owner(alice));
             env.close();
             BEAST_EXPECT(ownerCount(env, buyer) == 2);
@@ -1567,7 +1588,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 env.close();
 
                 // becky buys the nft for 1 drop.
-                uint256 const beckyBuyOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+                uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
                 env(token::createOffer(becky, nftNoAutoTrustID, drops(1)), token::Owner(alice));
                 env.close();
                 env(token::acceptBuyOffer(alice, beckyBuyOfferIndex));
@@ -1575,14 +1596,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
                 // becky attempts to sell the nft for AUD.
                 TER const createOfferTER = (xferFee != 0u) ? TER(tecNO_LINE) : TER(tesSUCCESS);
-                uint256 const beckyOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+                uint256 const beckyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
                 env(token::createOffer(becky, nftNoAutoTrustID, gwAUD(100)),
                     Txflags(tfSellNFToken),
                     Ter(createOfferTER));
                 env.close();
 
                 // cheri offers to buy the nft for CAD.
-                uint256 const cheriOfferIndex = keylet::nftoffer(cheri, env.seq(cheri)).key;
+                uint256 const cheriOfferIndex = keylet::nftokenOffer(cheri, env.seq(cheri)).key;
                 env(token::createOffer(cheri, nftNoAutoTrustID, gwCAD(100)),
                     token::Owner(becky),
                     Ter(createOfferTER));
@@ -1620,14 +1641,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                         break;
                 }
                 // becky buys the nft for 1 drop.
-                uint256 const beckyBuyOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+                uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
                 env(token::createOffer(becky, nftAutoTrustID, drops(1)), token::Owner(alice));
                 env.close();
                 env(token::acceptBuyOffer(alice, beckyBuyOfferIndex));
                 env.close();
 
                 // becky sells the nft for AUD.
-                uint256 const beckySellOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+                uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
                 env(token::createOffer(becky, nftAutoTrustID, gwAUD(100)), Txflags(tfSellNFToken));
                 env.close();
                 env(token::acceptSellOffer(cheri, beckySellOfferIndex));
@@ -1637,7 +1658,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 BEAST_EXPECT(env.balance(alice, gwAUD) == gwAUD(10));
 
                 // becky buys the nft back for CAD.
-                uint256 const beckyBuyBackOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+                uint256 const beckyBuyBackOfferIndex =
+                    keylet::nftokenOffer(becky, env.seq(becky)).key;
                 env(token::createOffer(becky, nftAutoTrustID, gwCAD(50)), token::Owner(cheri));
                 env.close();
                 env(token::acceptBuyOffer(cheri, beckyBuyBackOfferIndex));
@@ -1657,7 +1679,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 env.close();
 
                 // alice sells the nft using AUD.
-                uint256 const aliceSellOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+                uint256 const aliceSellOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
                 env(token::createOffer(alice, nftNoAutoTrustID, gwAUD(200)),
                     Txflags(tfSellNFToken));
                 env.close();
@@ -1674,7 +1696,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                     Txflags(tfSellNFToken),
                     Ter(tecNO_LINE));
                 env.close();
-                uint256 const cheriSellOfferIndex = keylet::nftoffer(cheri, env.seq(cheri)).key;
+                uint256 const cheriSellOfferIndex = keylet::nftokenOffer(cheri, env.seq(cheri)).key;
                 env(token::createOffer(cheri, nftNoAutoTrustID, gwCAD(100)),
                     Txflags(tfSellNFToken));
                 env.close();
@@ -1721,7 +1743,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 Ter(tefNFTOKEN_IS_NOT_TRANSFERABLE));
 
             // alice offers to sell the nft and becky accepts the offer.
-            uint256 const aliceSellOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceSellOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftAliceNoTransferID, XRP(20)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(becky, aliceSellOfferIndex));
@@ -1749,7 +1771,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // alice offers to buy the nft back from becky.  becky accepts
             // the offer.
-            uint256 const aliceBuyOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceBuyOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftAliceNoTransferID, XRP(22)), token::Owner(becky));
             env.close();
             env(token::acceptBuyOffer(becky, aliceBuyOfferIndex));
@@ -1805,7 +1827,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // minter successfully offers their nft for sale.
             BEAST_EXPECT(ownerCount(env, minter) == 1);
-            uint256 const minterSellOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftMinterNoTransferID, XRP(22)), Txflags(tfSellNFToken));
             env.close();
             BEAST_EXPECT(ownerCount(env, minter) == 2);
@@ -1840,7 +1862,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // alice can create an offer to buy the nft.
             BEAST_EXPECT(ownerCount(env, alice) == 0);
-            uint256 const aliceBuyOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceBuyOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftMinterNoTransferID, XRP(25)), token::Owner(becky));
             env.close();
             BEAST_EXPECT(ownerCount(env, alice) == 1);
@@ -1855,7 +1877,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // Now minter can create an offer to buy the nft.
             BEAST_EXPECT(ownerCount(env, minter) == 0);
-            uint256 const minterBuyOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftMinterNoTransferID, XRP(26)), token::Owner(becky));
             env.close();
             BEAST_EXPECT(ownerCount(env, minter) == 1);
@@ -1894,12 +1916,12 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(ownerCount(env, alice) == 1);
 
             // Both alice and becky can make offers for alice's nft.
-            uint256 const aliceSellOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceSellOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftAliceID, XRP(20)), Txflags(tfSellNFToken));
             env.close();
             BEAST_EXPECT(ownerCount(env, alice) == 2);
 
-            uint256 const beckyBuyOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftAliceID, XRP(21)), token::Owner(alice));
             env.close();
             BEAST_EXPECT(ownerCount(env, alice) == 2);
@@ -1911,7 +1933,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(ownerCount(env, becky) == 2);
 
             // becky offers to sell the nft.
-            uint256 const beckySellOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftAliceID, XRP(22)), Txflags(tfSellNFToken));
             env.close();
             BEAST_EXPECT(ownerCount(env, alice) == 0);
@@ -1926,7 +1948,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(ownerCount(env, minter) == 1);
 
             // minter offers to sell the nft.
-            uint256 const minterSellOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftAliceID, XRP(23)), Txflags(tfSellNFToken));
             env.close();
             BEAST_EXPECT(ownerCount(env, alice) == 0);
@@ -2008,7 +2030,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
 
             // Becky buys the nft for XAU(10).  Check balances.
-            uint256 const beckyBuyOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftID, gwXAU(10)), token::Owner(alice));
             env.close();
             BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000));
@@ -2020,7 +2042,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(990));
 
             // becky sells nft to carol.  alice's balance should not change.
-            uint256 const beckySellOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftID, gwXAU(10)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(carol, beckySellOfferIndex));
@@ -2030,7 +2052,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(990));
 
             // minter buys nft from carol.  alice's balance should not change.
-            uint256 const minterBuyOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftID, gwXAU(10)), token::Owner(carol));
             env.close();
             env(token::acceptBuyOffer(carol, minterBuyOfferIndex));
@@ -2042,7 +2064,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // minter sells the nft to alice.  gwXAU balances should finish
             // where they started.
-            uint256 const minterSellOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftID, gwXAU(10)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(alice, minterSellOfferIndex));
@@ -2069,7 +2091,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
 
             // Becky buys the nft for XAU(10).  Check balances.
-            uint256 const beckyBuyOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftID, gwXAU(10)), token::Owner(alice));
             env.close();
             BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000));
@@ -2081,7 +2103,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(990));
 
             // becky sells nft to carol.  alice's balance goes up.
-            uint256 const beckySellOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftID, gwXAU(10)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(carol, beckySellOfferIndex));
@@ -2092,7 +2114,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(990));
 
             // minter buys nft from carol.  alice's balance goes up.
-            uint256 const minterBuyOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftID, gwXAU(10)), token::Owner(carol));
             env.close();
             env(token::acceptBuyOffer(carol, minterBuyOfferIndex));
@@ -2105,7 +2127,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // minter sells the nft to alice.  Because alice is part of the
             // transaction no transfer fee is removed.
-            uint256 const minterSellOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftID, gwXAU(10)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(alice, minterSellOfferIndex));
@@ -2150,7 +2172,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
 
             // Becky buys the nft for XAU(10).  Check balances.
-            uint256 const beckyBuyOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftID, gwXAU(10)), token::Owner(alice));
             env.close();
             BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000));
@@ -2162,7 +2184,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(990));
 
             // becky sells nft to minter.  alice's balance goes up.
-            uint256 const beckySellOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftID, gwXAU(100)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(minter, beckySellOfferIndex));
@@ -2173,7 +2195,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(900));
 
             // carol buys nft from minter.  alice's balance goes up.
-            uint256 const carolBuyOfferIndex = keylet::nftoffer(carol, env.seq(carol)).key;
+            uint256 const carolBuyOfferIndex = keylet::nftokenOffer(carol, env.seq(carol)).key;
             env(token::createOffer(carol, nftID, gwXAU(10)), token::Owner(minter));
             env.close();
             env(token::acceptBuyOffer(minter, carolBuyOfferIndex));
@@ -2186,7 +2208,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // carol sells the nft to alice.  Because alice is part of the
             // transaction no transfer fee is removed.
-            uint256 const carolSellOfferIndex = keylet::nftoffer(carol, env.seq(carol)).key;
+            uint256 const carolSellOfferIndex = keylet::nftokenOffer(carol, env.seq(carol)).key;
             env(token::createOffer(carol, nftID, gwXAU(10)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(alice, carolSellOfferIndex));
@@ -2217,17 +2239,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // See the impact of rounding when the nft is sold for small amounts
         // of drops.
-        for (auto numberSwitchOver : {true})
         {
-            if (numberSwitchOver)
-            {
-                env.enableFeature(fixUniversalNumber);
-            }
-            else
-            {
-                env.disableFeature(fixUniversalNumber);
-            }
-
             // An nft with a transfer fee of 1 basis point.
             uint256 const nftID = token::getNextID(env, alice, 0u, tfTransferable, 1);
             env(token::mint(alice), Txflags(tfTransferable), token::XferFee(1));
@@ -2237,7 +2249,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // alice there should be no transfer fee.
             STAmount aliceBalance = env.balance(alice);
             STAmount minterBalance = env.balance(minter);
-            uint256 const minterBuyOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftID, XRP(1)), token::Owner(alice));
             env.close();
             env(token::acceptBuyOffer(alice, minterBuyOfferIndex));
@@ -2249,9 +2261,9 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // minter sells to carol.  The payment is just small enough that
             // alice does not get any transfer fee.
-            auto pmt = numberSwitchOver ? drops(50000) : drops(99999);
+            auto pmt = drops(50000);
             STAmount carolBalance = env.balance(carol);
-            uint256 const minterSellOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftID, pmt), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(carol, minterSellOfferIndex));
@@ -2265,8 +2277,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // carol sells to becky. This is the smallest amount to pay for a
             // transfer that enables a transfer fee of 1 basis point.
             STAmount beckyBalance = env.balance(becky);
-            uint256 const beckyBuyOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
-            pmt = numberSwitchOver ? drops(50001) : drops(100000);
+            uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+            pmt = drops(50001);
             env(token::createOffer(becky, nftID, pmt), token::Owner(carol));
             env.close();
             env(token::acceptBuyOffer(carol, beckyBuyOfferIndex));
@@ -2310,7 +2322,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // alice there should be no transfer fee.
             STAmount aliceBalance = env.balance(alice, gwXAU);
             STAmount minterBalance = env.balance(minter, gwXAU);
-            uint256 const minterBuyOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftID, tinyXAU), token::Owner(alice));
             env.close();
             env(token::acceptBuyOffer(alice, minterBuyOfferIndex));
@@ -2322,7 +2334,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // minter sells to carol.
             STAmount carolBalance = env.balance(carol, gwXAU);
-            uint256 const minterSellOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftID, tinyXAU), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(carol, minterSellOfferIndex));
@@ -2340,7 +2352,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             STAmount const cheapNFT(gwXAU, STAmount::kMinValue, STAmount::kMinOffset + 5);
 
             STAmount beckyBalance = env.balance(becky, gwXAU);
-            uint256 const beckyBuyOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftID, cheapNFT), token::Owner(carol));
             env.close();
             env(token::acceptBuyOffer(carol, beckyBuyOfferIndex));
@@ -2570,22 +2582,22 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // Test how adding a Destination field to an offer affects permissions
         // for canceling offers.
         {
-            uint256 const offerMinterToIssuer = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterToIssuer = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(issuer),
                 Txflags(tfSellNFToken));
 
-            uint256 const offerMinterToBuyer = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterToBuyer = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(buyer),
                 Txflags(tfSellNFToken));
 
-            uint256 const offerIssuerToMinter = keylet::nftoffer(issuer, env.seq(issuer)).key;
+            uint256 const offerIssuerToMinter = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
             env(token::createOffer(issuer, nftokenID, drops(1)),
                 token::Owner(minter),
                 token::Destination(minter));
 
-            uint256 const offerIssuerToBuyer = keylet::nftoffer(issuer, env.seq(issuer)).key;
+            uint256 const offerIssuerToBuyer = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
             env(token::createOffer(issuer, nftokenID, drops(1)),
                 token::Owner(minter),
                 token::Destination(buyer));
@@ -2626,7 +2638,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // Test how adding a Destination field to a sell offer affects
         // accepting that offer.
         {
-            uint256 const offerMinterSellsToBuyer = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterSellsToBuyer =
+                keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(buyer),
                 Txflags(tfSellNFToken));
@@ -2654,7 +2667,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // Test how adding a Destination field to a buy offer affects
         // accepting that offer.
         {
-            uint256 const offerMinterBuysFromBuyer = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterBuysFromBuyer =
+                keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Owner(buyer),
                 token::Destination(buyer));
@@ -2681,7 +2695,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // If a destination other than the NFToken owner is set, that
             // destination must act as a broker.  The NFToken owner may not
             // simply accept the offer.
-            uint256 const offerBuyerBuysFromMinter = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const offerBuyerBuysFromMinter =
+                keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID, drops(1)),
                 token::Owner(minter),
                 token::Destination(broker));
@@ -2704,12 +2719,12 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // Show that a sell offer's Destination can broker that sell offer
         // to another account.
         {
-            uint256 const offerMinterToBroker = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterToBroker = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(broker),
                 Txflags(tfSellNFToken));
 
-            uint256 const offerBuyerToMinter = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const offerBuyerToMinter = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID, drops(1)), token::Owner(minter));
 
             env.close();
@@ -2741,15 +2756,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // Destination doesn't match, but can complete if the Destination
         // does match.
         {
-            uint256 const offerBuyerToMinter = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const offerBuyerToMinter = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID, drops(1)),
                 token::Destination(minter),
                 Txflags(tfSellNFToken));
 
-            uint256 const offerMinterToBuyer = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterToBuyer = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID, drops(1)), token::Owner(buyer));
 
-            uint256 const offerIssuerToBuyer = keylet::nftoffer(issuer, env.seq(issuer)).key;
+            uint256 const offerIssuerToBuyer = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
             env(token::createOffer(issuer, nftokenID, drops(1)), token::Owner(buyer));
 
             env.close();
@@ -2797,12 +2812,12 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // Show that if a buy and a sell offer both have the same destination,
         // then that destination can broker the offers.
         {
-            uint256 const offerMinterToBroker = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterToBroker = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(broker),
                 Txflags(tfSellNFToken));
 
-            uint256 const offerBuyerToBroker = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const offerBuyerToBroker = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID, drops(1)),
                 token::Owner(minter),
                 token::Destination(broker));
@@ -2872,7 +2887,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // create offer (allowed now) then cancel
         {
-            uint256 const offerIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const offerIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
 
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(buyer),
@@ -2885,7 +2900,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // create offer, enable flag, then cancel
         {
-            uint256 const offerIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const offerIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
 
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(buyer),
@@ -2904,7 +2919,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // create offer then transfer
         {
-            uint256 const offerIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const offerIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
 
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(buyer),
@@ -2991,23 +3006,23 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         {
             std::uint32_t const expiration = lastClose(env) + 25;
 
-            uint256 const offerMinterToIssuer = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterToIssuer = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID0, drops(1)),
                 token::Destination(issuer),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
 
-            uint256 const offerMinterToAnyone = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterToAnyone = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID0, drops(1)),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
 
-            uint256 const offerIssuerToMinter = keylet::nftoffer(issuer, env.seq(issuer)).key;
+            uint256 const offerIssuerToMinter = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
             env(token::createOffer(issuer, nftokenID0, drops(1)),
                 token::Owner(minter),
                 token::Expiration(expiration));
 
-            uint256 const offerBuyerToMinter = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const offerBuyerToMinter = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID0, drops(1)),
                 token::Owner(minter),
                 token::Expiration(expiration));
@@ -3067,13 +3082,13 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         {
             std::uint32_t const expiration = lastClose(env) + 25;
 
-            uint256 const offer0 = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const offer0 = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID0, drops(1)),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
             minterCount++;
 
-            uint256 const offer1 = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const offer1 = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID1, drops(1)),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
@@ -3138,7 +3153,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // Transfer nftokenID0 back to minter so we start the next test in
             // a simple place.
-            uint256 const offerSellBack = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID0, XRP(0)),
                 Txflags(tfSellNFToken),
                 token::Destination(minter));
@@ -3157,13 +3172,13 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         {
             std::uint32_t const expiration = lastClose(env) + 25;
 
-            uint256 const offer0 = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const offer0 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID0, drops(1)),
                 token::Owner(minter),
                 token::Expiration(expiration));
             buyerCount++;
 
-            uint256 const offer1 = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const offer1 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID1, drops(1)),
                 token::Owner(minter),
                 token::Expiration(expiration));
@@ -3226,7 +3241,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // Transfer nftokenID0 back to minter so we start the next test in
             // a simple place.
-            uint256 const offerSellBack = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID0, XRP(0)),
                 Txflags(tfSellNFToken),
                 token::Destination(minter));
@@ -3245,23 +3260,23 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         {
             std::uint32_t const expiration = lastClose(env) + 25;
 
-            uint256 const sellOffer0 = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const sellOffer0 = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID0, drops(1)),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
             minterCount++;
 
-            uint256 const sellOffer1 = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const sellOffer1 = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID1, drops(1)),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
             minterCount++;
 
-            uint256 const buyOffer0 = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const buyOffer0 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID0, drops(1)), token::Owner(minter));
             buyerCount++;
 
-            uint256 const buyOffer1 = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const buyOffer1 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID1, drops(1)), token::Owner(minter));
             buyerCount++;
 
@@ -3320,7 +3335,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // Transfer nftokenID0 back to minter so we start the next test in
             // a simple place.
-            uint256 const offerSellBack = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID0, XRP(0)),
                 Txflags(tfSellNFToken),
                 token::Destination(minter));
@@ -3339,18 +3354,18 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         {
             std::uint32_t const expiration = lastClose(env) + 25;
 
-            uint256 const sellOffer0 = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const sellOffer0 = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID0, drops(1)), Txflags(tfSellNFToken));
 
-            uint256 const sellOffer1 = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const sellOffer1 = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID1, drops(1)), Txflags(tfSellNFToken));
 
-            uint256 const buyOffer0 = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const buyOffer0 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID0, drops(1)),
                 token::Expiration(expiration),
                 token::Owner(minter));
 
-            uint256 const buyOffer1 = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const buyOffer1 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID1, drops(1)),
                 token::Expiration(expiration),
                 token::Owner(minter));
@@ -3401,7 +3416,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // Transfer nftokenID0 back to minter so we start the next test in
             // a simple place.
-            uint256 const offerSellBack = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID0, XRP(0)),
                 Txflags(tfSellNFToken),
                 token::Destination(minter));
@@ -3420,22 +3435,22 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         {
             std::uint32_t const expiration = lastClose(env) + 25;
 
-            uint256 const sellOffer0 = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const sellOffer0 = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID0, drops(1)),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
 
-            uint256 const sellOffer1 = keylet::nftoffer(minter, env.seq(minter)).key;
+            uint256 const sellOffer1 = keylet::nftokenOffer(minter, env.seq(minter)).key;
             env(token::createOffer(minter, nftokenID1, drops(1)),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
 
-            uint256 const buyOffer0 = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const buyOffer0 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID0, drops(1)),
                 token::Expiration(expiration),
                 token::Owner(minter));
 
-            uint256 const buyOffer1 = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const buyOffer1 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID1, drops(1)),
                 token::Expiration(expiration),
                 token::Owner(minter));
@@ -3477,7 +3492,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // Transfer nftokenID0 back to minter so we start the next test in
             // a simple place.
-            uint256 const offerSellBack = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftokenID0, XRP(0)),
                 Txflags(tfSellNFToken),
                 token::Destination(minter));
@@ -3515,7 +3530,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         env.close();
 
         // Anyone can cancel an expired offer.
-        uint256 const expiredOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+        uint256 const expiredOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
 
         env(token::createOffer(alice, nftokenID, XRP(1000)),
             Txflags(tfSellNFToken),
@@ -3537,7 +3552,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // Create a couple of offers with a destination.  Those offers
         // should be cancellable by the creator and the destination.
-        uint256 const dest1OfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+        uint256 const dest1OfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
 
         env(token::createOffer(alice, nftokenID, XRP(1000)),
             token::Destination(becky),
@@ -3555,7 +3570,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(ownerCount(env, alice) == 1);
 
         // alice can cancel her own offer, even if becky is the destination.
-        uint256 const dest2OfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+        uint256 const dest2OfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
 
         env(token::createOffer(alice, nftokenID, XRP(1000)),
             token::Destination(becky),
@@ -3574,7 +3589,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         env(token::mint(minter, 0), token::Issuer(alice), Txflags(tfTransferable));
         env.close();
 
-        uint256 const minterOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+        uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
 
         env(token::createOffer(minter, mintersNFTokenID, XRP(1000)), Txflags(tfSellNFToken));
         env.close();
@@ -3632,7 +3647,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env(token::mint(nftAcct, 0), token::Uri(uri), Txflags(tfTransferable));
             env.close();
 
-            offerIndexes.push_back(keylet::nftoffer(offerAcct, env.seq(offerAcct)).key);
+            offerIndexes.push_back(keylet::nftokenOffer(offerAcct, env.seq(offerAcct)).key);
             env(token::createOffer(offerAcct, nftokenID, drops(1)),
                 token::Owner(nftAcct),
                 token::Expiration(lastClose(env) + 5));
@@ -3645,7 +3660,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // All offers should be in the ledger.
         for (uint256 const& offerIndex : offerIndexes)
         {
-            BEAST_EXPECT(env.le(keylet::nftoffer(offerIndex)));
+            BEAST_EXPECT(env.le(keylet::nftokenOffer(offerIndex)));
         }
 
         // alice attempts to cancel all of the expired offers.  There is one
@@ -3658,7 +3673,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         env.close();
 
         // Verify that offer is gone from the ledger.
-        BEAST_EXPECT(!env.le(keylet::nftoffer(offerIndexes.back())));
+        BEAST_EXPECT(!env.le(keylet::nftokenOffer(offerIndexes.back())));
         offerIndexes.pop_back();
 
         // But alice adds a sell offer to the list...
@@ -3667,7 +3682,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env(token::mint(alice, 0), token::Uri(uri), Txflags(tfTransferable));
             env.close();
 
-            offerIndexes.push_back(keylet::nftoffer(alice, env.seq(alice)).key);
+            offerIndexes.push_back(keylet::nftokenOffer(alice, env.seq(alice)).key);
             env(token::createOffer(alice, nftokenID, drops(1)), Txflags(tfSellNFToken));
             env.close();
 
@@ -3697,7 +3712,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // Verify that remaining offers are gone from the ledger.
         for (uint256 const& offerIndex : offerIndexes)
         {
-            BEAST_EXPECT(!env.le(keylet::nftoffer(offerIndex)));
+            BEAST_EXPECT(!env.le(keylet::nftokenOffer(offerIndex)));
         }
     }
 
@@ -3778,13 +3793,13 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT();
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
                 env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken));
                 env.close();
 
                 // buyer creates their offer.  Note: a buy offer can never
                 // offer zero.
-                uint256 const buyOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
                 env(token::createOffer(buyer, nftID, XRP(1)), token::Owner(minter));
                 env.close();
 
@@ -3820,13 +3835,13 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT();
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
                 env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken));
                 env.close();
 
                 // buyer creates their offer.  Note: a buy offer can never
                 // offer zero.
-                uint256 const buyOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
                 env(token::createOffer(buyer, nftID, XRP(1)), token::Owner(minter));
                 env.close();
 
@@ -3869,13 +3884,13 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT(kMaxTransferFee);
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
                 env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken));
                 env.close();
 
                 // buyer creates their offer.  Note: a buy offer can never
                 // offer zero.
-                uint256 const buyOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
                 env(token::createOffer(buyer, nftID, XRP(1)), token::Owner(minter));
                 env.close();
 
@@ -3911,13 +3926,13 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT(kMaxTransferFee);
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
                 env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken));
                 env.close();
 
                 // buyer creates their offer.  Note: a buy offer can never
                 // offer zero.
-                uint256 const buyOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
                 env(token::createOffer(buyer, nftID, XRP(1)), token::Owner(minter));
                 env.close();
 
@@ -3984,14 +3999,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT();
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
                 env(token::createOffer(minter, nftID, gwXAU(1000)), Txflags(tfSellNFToken));
                 env.close();
 
                 {
                     // buyer creates an offer for more XAU than they currently
                     // own.
-                    uint256 const buyOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+                    uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
                     env(token::createOffer(buyer, nftID, gwXAU(1001)), token::Owner(minter));
                     env.close();
 
@@ -4008,7 +4023,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 {
                     // buyer creates an offer for less that what minter is
                     // asking.
-                    uint256 const buyOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+                    uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
                     env(token::createOffer(buyer, nftID, gwXAU(999)), token::Owner(minter));
                     env.close();
 
@@ -4024,7 +4039,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 }
 
                 // buyer creates a large enough offer.
-                uint256 const buyOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
                 env(token::createOffer(buyer, nftID, gwXAU(1000)), token::Owner(minter));
                 env.close();
 
@@ -4061,13 +4076,13 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT(kMaxTransferFee);
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
                 env(token::createOffer(minter, nftID, gwXAU(900)), Txflags(tfSellNFToken));
                 env.close();
                 {
                     // buyer creates an offer for more XAU than they currently
                     // own.
-                    uint256 const buyOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+                    uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
                     env(token::createOffer(buyer, nftID, gwXAU(1001)), token::Owner(minter));
                     env.close();
 
@@ -4084,7 +4099,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 {
                     // buyer creates an offer for less that what minter is
                     // asking.
-                    uint256 const buyOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+                    uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
                     env(token::createOffer(buyer, nftID, gwXAU(899)), token::Owner(minter));
                     env.close();
 
@@ -4099,7 +4114,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                     env.close();
                 }
                 // buyer creates a large enough offer.
-                uint256 const buyOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
                 env(token::createOffer(buyer, nftID, gwXAU(1000)), token::Owner(minter));
                 env.close();
 
@@ -4139,12 +4154,12 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT(kMaxTransferFee / 2);  // 25%
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
                 env(token::createOffer(minter, nftID, gwXAU(900)), Txflags(tfSellNFToken));
                 env.close();
 
                 // buyer creates a large enough offer.
-                uint256 const buyOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
                 env(token::createOffer(buyer, nftID, gwXAU(1000)), token::Owner(minter));
                 env.close();
 
@@ -4176,12 +4191,12 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT(kMaxTransferFee / 2);  // 25%
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftoffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
                 env(token::createOffer(minter, nftID, gwXAU(900)), Txflags(tfSellNFToken));
                 env.close();
 
                 // buyer creates a large enough offer.
-                uint256 const buyOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
                 env(token::createOffer(buyer, nftID, gwXAU(1000)), token::Owner(minter));
                 env.close();
 
@@ -4231,9 +4246,9 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(nftCount(env, buyer2) == 0);
 
         // Both buyer1 and buyer2 create buy offers for nftId.
-        uint256 const buyer1OfferIndex = keylet::nftoffer(buyer1, env.seq(buyer1)).key;
+        uint256 const buyer1OfferIndex = keylet::nftokenOffer(buyer1, env.seq(buyer1)).key;
         env(token::createOffer(buyer1, nftId, XRP(100)), token::Owner(issuer));
-        uint256 const buyer2OfferIndex = keylet::nftoffer(buyer2, env.seq(buyer2)).key;
+        uint256 const buyer2OfferIndex = keylet::nftokenOffer(buyer2, env.seq(buyer2)).key;
         env(token::createOffer(buyer2, nftId, XRP(100)), token::Owner(issuer));
         env.close();
 
@@ -4321,7 +4336,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // NFTokenCreateOffer
         BEAST_EXPECT(ownerCount(env, buyer) == 10);
-        uint256 const offerIndex0 = keylet::nftoffer(buyer, buyerTicketSeq).key;
+        uint256 const offerIndex0 = keylet::nftokenOffer(buyer, buyerTicketSeq).key;
         env(token::createOffer(buyer, nftId, XRP(1)),
             token::Owner(issuer),
             ticket::Use(buyerTicketSeq++));
@@ -4336,7 +4351,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(ticketCount(env, buyer) == 8);
 
         // NFTokenCreateOffer.  buyer tries again.
-        uint256 const offerIndex1 = keylet::nftoffer(buyer, buyerTicketSeq).key;
+        uint256 const offerIndex1 = keylet::nftokenOffer(buyer, buyerTicketSeq).key;
         env(token::createOffer(buyer, nftId, XRP(2)),
             token::Owner(issuer),
             ticket::Use(buyerTicketSeq++));
@@ -4413,7 +4428,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         env(token::createOffer(becky, nftId, XRP(2)), token::Owner(minter));
         env.close();
 
-        uint256 const carlaOfferIndex = keylet::nftoffer(carla, env.seq(carla)).key;
+        uint256 const carlaOfferIndex = keylet::nftokenOffer(carla, env.seq(carla)).key;
         env(token::createOffer(carla, nftId, XRP(3)), token::Owner(minter));
         env.close();
 
@@ -4691,25 +4706,25 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             TER const offerCreateTER = temBAD_AMOUNT;
 
             // Make offers with negative amounts for the NFTs
-            uint256 const sellNegXrpOfferIndex = keylet::nftoffer(issuer, env.seq(issuer)).key;
+            uint256 const sellNegXrpOfferIndex = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
             env(token::createOffer(issuer, nftID0, XRP(-2)),
                 Txflags(tfSellNFToken),
                 Ter(offerCreateTER));
             env.close();
 
-            uint256 const sellNegIouOfferIndex = keylet::nftoffer(issuer, env.seq(issuer)).key;
+            uint256 const sellNegIouOfferIndex = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
             env(token::createOffer(issuer, nftID1, gwXAU(-2)),
                 Txflags(tfSellNFToken),
                 Ter(offerCreateTER));
             env.close();
 
-            uint256 const buyNegXrpOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const buyNegXrpOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftID0, XRP(-1)),
                 token::Owner(issuer),
                 Ter(offerCreateTER));
             env.close();
 
-            uint256 const buyNegIouOfferIndex = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const buyNegIouOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::createOffer(buyer, nftID1, gwXAU(-1)),
                 token::Owner(issuer),
                 Ter(offerCreateTER));
@@ -4872,7 +4887,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                                       uint256 const& nftID,
                                       STAmount const& amount,
                                       std::optional const terCode = {}) {
-                uint256 const offerID = keylet::nftoffer(offerer, env.seq(offerer)).key;
+                uint256 const offerID = keylet::nftokenOffer(offerer, env.seq(offerer)).key;
                 env(token::createOffer(offerer, nftID, amount),
                     token::Owner(owner),
                     terCode ? Ter(*terCode) : Ter(static_cast(tesSUCCESS)));
@@ -4885,7 +4900,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                                        uint256 const& nftID,
                                        STAmount const& amount,
                                        std::optional const terCode = {}) {
-                uint256 const offerID = keylet::nftoffer(offerer, env.seq(offerer)).key;
+                uint256 const offerID = keylet::nftokenOffer(offerer, env.seq(offerer)).key;
                 env(token::createOffer(offerer, nftID, amount),
                     Txflags(tfSellNFToken),
                     terCode ? Ter(*terCode) : Ter(static_cast(tesSUCCESS)));
@@ -5398,10 +5413,10 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // Bob creates a buy offer for 5 XRP.  Alice creates a sell offer
         // for 0 XRP.
-        uint256 const bobBuyOfferIndex = keylet::nftoffer(bob, env.seq(bob)).key;
+        uint256 const bobBuyOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key;
         env(token::createOffer(bob, nftId, XRP(5)), token::Owner(alice));
 
-        uint256 const aliceSellOfferIndex = keylet::nftoffer(alice, env.seq(alice)).key;
+        uint256 const aliceSellOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
         env(token::createOffer(alice, nftId, XRP(0)),
             token::Destination(bob),
             Txflags(tfSellNFToken));
@@ -5412,10 +5427,10 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         env.close();
 
         // Note that bob still has a buy offer on the books.
-        BEAST_EXPECT(env.le(keylet::nftoffer(bobBuyOfferIndex)));
+        BEAST_EXPECT(env.le(keylet::nftokenOffer(bobBuyOfferIndex)));
 
         // Bob creates a sell offer for the gift NFT from alice.
-        uint256 const bobSellOfferIndex = keylet::nftoffer(bob, env.seq(bob)).key;
+        uint256 const bobSellOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key;
         env(token::createOffer(bob, nftId, XRP(4)), Txflags(tfSellNFToken));
         env.close();
 
@@ -6032,7 +6047,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 token::Amount(XRP(10)),
                 token::Destination(buyer),
                 token::Expiration(lastClose(env) + 25));
-            uint256 const offerAliceSellsToBuyer = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const offerAliceSellsToBuyer = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::cancelOffer(alice, {offerAliceSellsToBuyer}));
             env.close();
 
@@ -6041,7 +6056,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 token::Amount(XRP(10)),
                 token::Destination(alice),
                 token::Expiration(lastClose(env) + 25));
-            uint256 const offerBuyerSellsToAlice = keylet::nftoffer(buyer, env.seq(buyer)).key;
+            uint256 const offerBuyerSellsToAlice = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
             env(token::cancelOffer(alice, {offerBuyerSellsToAlice}));
             env.close();
 
@@ -6192,12 +6207,12 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // Alice creates one sell offer for each NFT
             // Verify the offer indexes are correct in the NFTokenCreateOffer tx
             // meta
-            uint256 const aliceOfferIndex1 = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex1 = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftId1, drops(1)), Txflags(tfSellNFToken));
             env.close();
             verifyNFTokenOfferID(aliceOfferIndex1);
 
-            uint256 const aliceOfferIndex2 = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex2 = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftId2, drops(1)), Txflags(tfSellNFToken));
             env.close();
             verifyNFTokenOfferID(aliceOfferIndex2);
@@ -6211,7 +6226,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // Bobs creates a buy offer for nftId1
             // Verify the offer id is correct in the NFTokenCreateOffer tx meta
-            auto const bobBuyOfferIndex = keylet::nftoffer(bob, env.seq(bob)).key;
+            auto const bobBuyOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key;
             env(token::createOffer(bob, nftId1, drops(1)), token::Owner(alice));
             env.close();
             verifyNFTokenOfferID(bobBuyOfferIndex);
@@ -6232,7 +6247,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             verifyNFTokenID(nftId);
 
             // Alice creates sell offer and set broker as destination
-            uint256 const offerAliceToBroker = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const offerAliceToBroker = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftId, drops(1)),
                 token::Destination(broker),
                 Txflags(tfSellNFToken));
@@ -6240,7 +6255,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             verifyNFTokenOfferID(offerAliceToBroker);
 
             // Bob creates buy offer
-            uint256 const offerBobToBroker = keylet::nftoffer(bob, env.seq(bob)).key;
+            uint256 const offerBobToBroker = keylet::nftokenOffer(bob, env.seq(bob)).key;
             env(token::createOffer(bob, nftId, drops(1)), token::Owner(alice));
             env.close();
             verifyNFTokenOfferID(offerBobToBroker);
@@ -6261,12 +6276,12 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             verifyNFTokenID(nftId);
 
             // Alice creates 2 sell offers for the same NFT
-            uint256 const aliceOfferIndex1 = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex1 = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftId, drops(1)), Txflags(tfSellNFToken));
             env.close();
             verifyNFTokenOfferID(aliceOfferIndex1);
 
-            uint256 const aliceOfferIndex2 = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex2 = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftId, drops(1)), Txflags(tfSellNFToken));
             env.close();
             verifyNFTokenOfferID(aliceOfferIndex2);
@@ -6280,7 +6295,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         if (features[featureNFTokenMintOffer])
         {
-            uint256 const aliceMintWithOfferIndex1 = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceMintWithOfferIndex1 =
+                keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::mint(alice), token::Amount(XRP(0)));
             env.close();
             verifyNFTokenOfferID(aliceMintWithOfferIndex1);
@@ -6303,7 +6319,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
 
             // acct makes an sell offer
-            uint256 const sellOfferIndex = keylet::nftoffer(acct, env.seq(acct)).key;
+            uint256 const sellOfferIndex = keylet::nftokenOffer(acct, env.seq(acct)).key;
             env(token::createOffer(acct, nftId, amt), Txflags(tfSellNFToken));
             env.close();
 
@@ -6335,60 +6351,44 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // Bob owns no object
             BEAST_EXPECT(ownerCount(env, bob) == 0);
 
-            // Without fixNFTokenReserve amendment, when bob accepts an NFT sell
-            // offer, he can get the NFT free of reserve
-            if (!features[fixNFTokenReserve])
-            {
-                // Bob is able to accept the offer
-                env(token::acceptSellOffer(bob, sellOfferIndex));
-                env.close();
-
-                // Bob now owns an extra objects
-                BEAST_EXPECT(ownerCount(env, bob) == 1);
-
-                // This is the wrong behavior, since Bob should need at least
-                // one incremental reserve.
-            }
-            // With fixNFTokenReserve, bob can no longer accept the offer unless
+            // bob can no longer accept the offer unless
             // there is enough reserve. A detail to note is that NFTs(sell
             // offer) will not allow one to go below the reserve requirement,
             // because buyer's balance is computed after the transaction fee is
             // deducted. This means that the reserve requirement will be `base
             // fee` drops higher than normal.
-            else
-            {
-                // Bob is not able to accept the offer with only the account
-                // reserve (200,000,000 drops)
-                env(token::acceptSellOffer(bob, sellOfferIndex), Ter(tecINSUFFICIENT_RESERVE));
-                env.close();
 
-                // after prev transaction, Bob owns `200M - base fee` drops due
-                // to burnt tx fee
+            // Bob is not able to accept the offer with only the account
+            // reserve (200,000,000 drops)
+            env(token::acceptSellOffer(bob, sellOfferIndex), Ter(tecINSUFFICIENT_RESERVE));
+            env.close();
 
-                BEAST_EXPECT(ownerCount(env, bob) == 0);
+            // after prev transaction, Bob owns `200M - base fee` drops due
+            // to burnt tx fee
 
-                // Send bob an kIncrement reserve and base fee (to make up for
-                // the transaction fee burnt from the prev failed tx) Bob now
-                // owns 250,000,000 drops
-                env(pay(env.master, bob, incReserve + drops(baseFee)));
-                env.close();
+            BEAST_EXPECT(ownerCount(env, bob) == 0);
 
-                // However, this transaction will still fail because the reserve
-                // requirement is `base fee` drops higher
-                env(token::acceptSellOffer(bob, sellOfferIndex), Ter(tecINSUFFICIENT_RESERVE));
-                env.close();
+            // Send bob an kIncrement reserve and base fee (to make up for
+            // the transaction fee burnt from the prev failed tx) Bob now
+            // owns 250,000,000 drops
+            env(pay(env.master, bob, incReserve + drops(baseFee)));
+            env.close();
 
-                // Send bob `base fee * 2` drops
-                // Bob now owns `250M + base fee` drops
-                env(pay(env.master, bob, drops(baseFee * 2)));
-                env.close();
+            // However, this transaction will still fail because the reserve
+            // requirement is `base fee` drops higher
+            env(token::acceptSellOffer(bob, sellOfferIndex), Ter(tecINSUFFICIENT_RESERVE));
+            env.close();
 
-                // Bob is now able to accept the offer
-                env(token::acceptSellOffer(bob, sellOfferIndex));
-                env.close();
+            // Send bob `base fee * 2` drops
+            // Bob now owns `250M + base fee` drops
+            env(pay(env.master, bob, drops(baseFee * 2)));
+            env.close();
 
-                BEAST_EXPECT(ownerCount(env, bob) == 1);
-            }
+            // Bob is now able to accept the offer
+            env(token::acceptSellOffer(bob, sellOfferIndex));
+            env.close();
+
+            BEAST_EXPECT(ownerCount(env, bob) == 1);
         }
 
         // Now exercise the scenario when the buyer accepts
@@ -6407,83 +6407,63 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.fund(acctReserve + XRP(1), bob);
             env.close();
 
-            if (!features[fixNFTokenReserve])
+            // alice mints the first NFT and creates a sell offer for 0 XRP
+            auto const sellOfferIndex1 = mintAndCreateSellOffer(env, alice, XRP(0));
+
+            // Bob cannot accept this offer because he doesn't have the
+            // reserve for the NFT
+            env(token::acceptSellOffer(bob, sellOfferIndex1), Ter(tecINSUFFICIENT_RESERVE));
+            env.close();
+
+            // Give bob enough reserve
+            env(pay(env.master, bob, drops(incReserve)));
+            env.close();
+
+            BEAST_EXPECT(ownerCount(env, bob) == 0);
+
+            // Bob now owns his first NFT
+            env(token::acceptSellOffer(bob, sellOfferIndex1));
+            env.close();
+
+            BEAST_EXPECT(ownerCount(env, bob) == 1);
+
+            // alice now mints 31 more NFTs and creates an offer for each
+            // NFT, then sells to bob
+            for (size_t i = 0; i < 31; i++)
             {
-                // Bob can accept many NFTs without having a single reserve!
-                for (size_t i = 0; i < 200; i++)
-                {
-                    // alice mints an NFT and creates a sell offer for 0 XRP
-                    auto const sellOfferIndex = mintAndCreateSellOffer(env, alice, XRP(0));
+                // alice mints an NFT and creates a sell offer for 0 XRP
+                auto const sellOfferIndex = mintAndCreateSellOffer(env, alice, XRP(0));
 
-                    // Bob is able to accept the offer
-                    env(token::acceptSellOffer(bob, sellOfferIndex));
-                    env.close();
-                }
+                // Bob can accept the offer because the new NFT is stored in
+                // an existing NFTokenPage so no new reserve is required
+                env(token::acceptSellOffer(bob, sellOfferIndex));
+                env.close();
             }
-            else
-            {
-                // alice mints the first NFT and creates a sell offer for 0 XRP
-                auto const sellOfferIndex1 = mintAndCreateSellOffer(env, alice, XRP(0));
 
-                // Bob cannot accept this offer because he doesn't have the
-                // reserve for the NFT
-                env(token::acceptSellOffer(bob, sellOfferIndex1), Ter(tecINSUFFICIENT_RESERVE));
-                env.close();
+            BEAST_EXPECT(ownerCount(env, bob) == 1);
 
-                // Give bob enough reserve
-                env(pay(env.master, bob, drops(incReserve)));
-                env.close();
+            // alice now mints the 33rd NFT and creates an sell offer for 0
+            // XRP
+            auto const sellOfferIndex33 = mintAndCreateSellOffer(env, alice, XRP(0));
 
-                BEAST_EXPECT(ownerCount(env, bob) == 0);
+            // Bob fails to accept this NFT because he does not have enough
+            // reserve for a new NFTokenPage
+            env(token::acceptSellOffer(bob, sellOfferIndex33), Ter(tecINSUFFICIENT_RESERVE));
+            env.close();
 
-                // Bob now owns his first NFT
-                env(token::acceptSellOffer(bob, sellOfferIndex1));
-                env.close();
+            // Send bob incremental reserve
+            env(pay(env.master, bob, drops(incReserve)));
+            env.close();
 
-                BEAST_EXPECT(ownerCount(env, bob) == 1);
+            // Bob now has enough reserve to accept the offer and now
+            // owns one more NFTokenPage
+            env(token::acceptSellOffer(bob, sellOfferIndex33));
+            env.close();
 
-                // alice now mints 31 more NFTs and creates an offer for each
-                // NFT, then sells to bob
-                for (size_t i = 0; i < 31; i++)
-                {
-                    // alice mints an NFT and creates a sell offer for 0 XRP
-                    auto const sellOfferIndex = mintAndCreateSellOffer(env, alice, XRP(0));
-
-                    // Bob can accept the offer because the new NFT is stored in
-                    // an existing NFTokenPage so no new reserve is required
-                    env(token::acceptSellOffer(bob, sellOfferIndex));
-                    env.close();
-                }
-
-                BEAST_EXPECT(ownerCount(env, bob) == 1);
-
-                // alice now mints the 33rd NFT and creates an sell offer for 0
-                // XRP
-                auto const sellOfferIndex33 = mintAndCreateSellOffer(env, alice, XRP(0));
-
-                // Bob fails to accept this NFT because he does not have enough
-                // reserve for a new NFTokenPage
-                env(token::acceptSellOffer(bob, sellOfferIndex33), Ter(tecINSUFFICIENT_RESERVE));
-                env.close();
-
-                // Send bob incremental reserve
-                env(pay(env.master, bob, drops(incReserve)));
-                env.close();
-
-                // Bob now has enough reserve to accept the offer and now
-                // owns one more NFTokenPage
-                env(token::acceptSellOffer(bob, sellOfferIndex33));
-                env.close();
-
-                BEAST_EXPECT(ownerCount(env, bob) == 2);
-            }
+            BEAST_EXPECT(ownerCount(env, bob) == 2);
         }
 
         // Test the behavior when the seller accepts a buy offer.
-        // The behavior should not change regardless whether fixNFTokenReserve
-        // is enabled or not, since the ledger is able to guard against
-        // free NFTokenPages when buy offer is accepted. This is merely an
-        // additional test to exercise existing offer behavior.
         {
             Account const alice{"alice"};
             Account const bob{"bob"};
@@ -6508,7 +6488,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
 
             // Bob makes a buy offer for 1 XRP
-            auto const buyOfferIndex = keylet::nftoffer(bob, env.seq(bob)).key;
+            auto const buyOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key;
             env(token::createOffer(bob, nftId, XRP(1)), token::Owner(alice));
             env.close();
 
@@ -6528,10 +6508,6 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         }
 
         // Test the reserve behavior in brokered mode.
-        // The behavior should not change regardless whether fixNFTokenReserve
-        // is enabled or not, since the ledger is able to guard against
-        // free NFTokenPages in brokered mode. This is merely an
-        // additional test to exercise existing offer behavior.
         {
             Account const alice{"alice"};
             Account const bob{"bob"};
@@ -6556,14 +6532,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
 
             // Alice creates sell offer and set broker as destination
-            uint256 const offerAliceToBroker = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const offerAliceToBroker = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftId, XRP(1)),
                 token::Destination(broker),
                 Txflags(tfSellNFToken));
             env.close();
 
             // Bob creates buy offer
-            uint256 const offerBobToBroker = keylet::nftoffer(bob, env.seq(bob)).key;
+            uint256 const offerBobToBroker = keylet::nftokenOffer(bob, env.seq(bob)).key;
             env(token::createOffer(bob, nftId, XRP(1)), token::Owner(alice));
             env.close();
 
@@ -6657,10 +6633,10 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // becky buys the nfts for 1 drop each.
             {
-                uint256 const beckyBuyOfferIndex1 = keylet::nftoffer(becky, env.seq(becky)).key;
+                uint256 const beckyBuyOfferIndex1 = keylet::nftokenOffer(becky, env.seq(becky)).key;
                 env(token::createOffer(becky, nftAutoTrustID, drops(1)), token::Owner(issuer));
 
-                uint256 const beckyBuyOfferIndex2 = keylet::nftoffer(becky, env.seq(becky)).key;
+                uint256 const beckyBuyOfferIndex2 = keylet::nftokenOffer(becky, env.seq(becky)).key;
                 env(token::createOffer(becky, nftNoAutoTrustID, drops(1)), token::Owner(issuer));
 
                 env.close();
@@ -6670,7 +6646,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             }
 
             // becky creates offers to sell the nfts for AUD.
-            uint256 const beckyAutoTrustOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckyAutoTrustOfferIndex =
+                keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftAutoTrustID, gwAUD(100)), Txflags(tfSellNFToken));
             env.close();
 
@@ -6688,7 +6665,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
             BEAST_EXPECT(ownerCount(env, issuer) == 1);
 
-            uint256 const beckyNoAutoTrustOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckyNoAutoTrustOfferIndex =
+                keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftNoAutoTrustID, gwAUD(100)), Txflags(tfSellNFToken));
             env.close();
 
@@ -6812,10 +6790,10 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // becky buys the nfts for 1 drop each.
         {
-            uint256 const beckyBuyOfferIndex1 = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex1 = keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftAutoTrustID, drops(1)), token::Owner(issuer));
 
-            uint256 const beckyBuyOfferIndex2 = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex2 = keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftNoAutoTrustID, drops(1)), token::Owner(issuer));
 
             env.close();
@@ -6842,7 +6820,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // However if the NFToken has the tfTrustLine flag set,
             // then becky can create the offer.
-            uint256 const beckyAutoTrustOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckyAutoTrustOfferIndex =
+                keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftAutoTrustID, isISU(100)), Txflags(tfSellNFToken));
             env.close();
 
@@ -6859,10 +6838,12 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         {
             // With featureNFTokenMintOffer things go better.
             // becky creates offers to sell the nfts for ISU.
-            uint256 const beckyNoAutoTrustOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckyNoAutoTrustOfferIndex =
+                keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftNoAutoTrustID, isISU(100)), Txflags(tfSellNFToken));
             env.close();
-            uint256 const beckyAutoTrustOfferIndex = keylet::nftoffer(becky, env.seq(becky)).key;
+            uint256 const beckyAutoTrustOfferIndex =
+                keylet::nftokenOffer(becky, env.seq(becky)).key;
             env(token::createOffer(becky, nftAutoTrustID, isISU(100)), Txflags(tfSellNFToken));
             env.close();
 
@@ -7096,7 +7077,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             checkURI(issuer, "uri", __LINE__);
 
             // Account != Owner
-            uint256 const offerID = keylet::nftoffer(issuer, env.seq(issuer)).key;
+            uint256 const offerID = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
             env(token::createOffer(issuer, nftId, XRP(0)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(alice, offerID));
@@ -7190,9 +7171,7 @@ public:
     void
     run() override
     {
-        testWithFeats(
-            allFeatures_ - fixNFTokenReserve - featureNFTokenMintOffer - featureDynamicNFT -
-            fixCleanup3_1_3);
+        testWithFeats(allFeatures_ - featureNFTokenMintOffer - featureDynamicNFT - fixCleanup3_1_3);
     }
 };
 
@@ -7201,8 +7180,7 @@ class NFTokenDisallowIncoming_test : public NFTokenBaseUtil_test
     void
     run() override
     {
-        testWithFeats(
-            allFeatures_ - fixNFTokenReserve - featureNFTokenMintOffer - featureDynamicNFT);
+        testWithFeats(allFeatures_ - featureNFTokenMintOffer - featureDynamicNFT);
     }
 };
 
diff --git a/src/test/app/NetworkID_test.cpp b/src/test/app/NetworkID_test.cpp
index a98fbd6b4f..677d8f166d 100644
--- a/src/test/app/NetworkID_test.cpp
+++ b/src/test/app/NetworkID_test.cpp
@@ -37,7 +37,7 @@ public:
     {
         using namespace jtx;
         return envconfig([&](std::unique_ptr cfg) {
-            cfg->NETWORK_ID = networkID;
+            cfg->networkId = networkID;
             return cfg;
         });
     }
diff --git a/src/test/app/NetworkOPs_test.cpp b/src/test/app/NetworkOPs_test.cpp
index d0a692b894..c5c8d33706 100644
--- a/src/test/app/NetworkOPs_test.cpp
+++ b/src/test/app/NetworkOPs_test.cpp
@@ -54,7 +54,7 @@ public:
             env.close();
         }
 
-        BEAST_EXPECT(logs.find("No transaction to process!") != std::string::npos);
+        BEAST_EXPECT(logs.contains("No transaction to process!"));
     }
 };
 
diff --git a/src/test/app/OfferMPT_test.cpp b/src/test/app/OfferMPT_test.cpp
index a52ba7fd58..9958515c5f 100644
--- a/src/test/app/OfferMPT_test.cpp
+++ b/src/test/app/OfferMPT_test.cpp
@@ -44,7 +44,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -721,11 +720,11 @@ public:
     }
 
     // Helper function that returns the Offers on an account.
-    static std::vector>
+    static std::vector
     offersOnAccount(jtx::Env& env, jtx::Account account)
     {
-        std::vector> result;
-        forEachItem(*env.current(), account, [&result](std::shared_ptr const& sle) {
+        std::vector result;
+        forEachItem(*env.current(), account, [&result](SLE::const_ref sle) {
             if (sle->getType() == ltOFFER)
                 result.push_back(sle);
         });
@@ -973,7 +972,7 @@ public:
 
         // Offers with negative amounts
         {
-            env(offer(alice, -usd(1'000), XRP(1'000)), Ter(temBAD_OFFER));
+            env(offer(alice, -usd(1'000), XRP(1'000)), Ter(temBAD_AMOUNT));
             env.require(Owners(alice, 1), offers(alice, 0));
         }
 
@@ -1828,7 +1827,6 @@ public:
         using namespace jtx;
 
         Env env{*this, features};
-        env.enableFeature(fixUniversalNumber);
 
         auto const gw = Account{"gateway"};
         auto const alice = Account{"alice"};
@@ -2764,7 +2762,7 @@ public:
                 // USD(125) was removed from his account due to the gateway fee.
                 //
                 // A comparable payment would look like this:
-                //   env (pay (bob, alice, USD(100)), sendmax(USD(125)))
+                //   env (pay (bob, alice, USD(100)), Sendmax(USD(125)))
                 env(offer(bob, XRP(1), usd(10'000)));
                 env.close();
 
@@ -3012,6 +3010,92 @@ public:
             }
         };
         testHelper2TokensMix(test);
+
+        // Payment trIn: MPT transfer fee must be charged when the payment
+        // destination is the MPT issuer and MPT crosses the DEX (1-hop).
+        // Bug: rate() returned parity because strandDst_ == MPT issuer.
+        // Fix: parity only when this asset IS the final delivered asset.
+        {
+            auto const gw = Account("gw_tr1");
+            auto const alice = Account("alice_tr1");
+            auto const bob = Account("bob_tr1");
+
+            Env env{*this, features};
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPT const usd = MPTTester(
+                {.env = env, .issuer = gw, .holders = {alice, bob}, .transferFee = 25'000});
+
+            // alice needs MPT(1250): MPT(1000) to bob's offer + MPT(250) transfer fee (25%)
+            env(pay(gw, alice, usd(1'250)));
+            // bob's offer: give XRP(1000), want MPT(1000)
+            env(offer(bob, usd(1'000), XRP(1'000)));
+            env.close();
+
+            // alice pays gw (MPT issuer) XRP(1000) using MPT as source
+            // strand: alice -> [MPT/XRP BookStep] -> gw
+            // strandDst_ = gw = MPT issuer, strandDeliver_ = XRP
+            // trIn = rate(MPT, gw): fix charges 25% (MPT != strandDeliver_)
+            env(pay(alice, gw, XRP(1'000)), Path(~XRP), Sendmax(usd(1'250)));
+            env.close();
+
+            // alice consumed all MPT(1250): MPT(1000) to bob + MPT(250) fee
+            BEAST_EXPECT(env.balance(alice, usd) == usd(0));
+            // bob received MPT(1000) net
+            BEAST_EXPECT(env.balance(bob, usd) == usd(1'000));
+        }
+
+        // Payment trIn (2-hop): MPT transfer fee must be charged when MPT is
+        // intermediate and the destination is the MPT issuer.
+        // BookStep2(MPT/XRP) prevStep=BookStep1 returns redeems direction
+        // (ownerPaysTransferFee_=false for Payment), so trIn applies.
+        // Bug: parity because strandDst_ == MPT issuer.
+        // Fix: 25% fee because MPT != strandDeliver_(XRP).
+        {
+            auto const gw = Account("gw_tr2");
+            auto const gw2 = Account("gw2_tr2");
+            auto const alice = Account("alice_tr2");
+            auto const bob = Account("bob_tr2");
+            auto const carol = Account("carol_tr2");
+
+            Env env{*this, features};
+            env.fund(XRP(10'000), gw, gw2, alice, bob, carol);
+            env.close();
+
+            MPT const musd = MPTTester(
+                {.env = env, .issuer = gw, .holders = {bob, carol}, .transferFee = 25'000});
+            auto const gusd = gw2["USD"];
+
+            env(trust(alice, gusd(10'000)));
+            env(trust(bob, gusd(10'000)));
+            env.close();
+
+            env(pay(gw2, alice, gusd(1'000)));
+            env(pay(gw, bob, musd(1'000)));
+            env.close();
+
+            // bob's offer: give MPT(1000), want GUSD(1000)
+            env(offer(bob, gusd(1'000), musd(1'000)));
+            // carol's offer: give XRP(800), want MPT(800)
+            env(offer(carol, musd(800), XRP(800)));
+            env.close();
+
+            // Payment: alice GUSD -> [BookStep1: GUSD/MUSD] -> [BookStep2: MUSD/XRP] -> gw XRP
+            // strandDst_ = gw = MPT issuer, strandDeliver_ = XRP
+            // BookStep2 trIn: fix = 1.25 -> upstream needs MUSD(1000) for carol's MUSD(800) offer
+            // => alice must provide full GUSD(1000) to bob's offer; without fix alice only pays
+            // GUSD(800)
+            env(pay(alice, gw, XRP(800)), Path(~musd), Sendmax(gusd(1'000)));
+            env.close();
+
+            // alice spent all GUSD(1000); bug would leave GUSD(200) unspent
+            BEAST_EXPECT(env.balance(alice, gusd) == gusd(0));
+            // bob gave MPT(1000) and received GUSD(1000)
+            BEAST_EXPECT(env.balance(bob, musd) == musd(0));
+            // carol received MPT(800) net (MPT(200) went to gw as fee)
+            BEAST_EXPECT(env.balance(carol, musd) == musd(800));
+        }
     }
 
     void
@@ -3347,7 +3431,7 @@ public:
         auto const gw = Account("gateway");
 
         auto const fee = env.current()->fees().base;
-        env.fund(reserve(env, 2) + drops(9999640) + (fee), ann);
+        env.fund(reserve(env, 2) + drops(9999640) + fee, ann);
         env.fund(reserve(env, 2) + (fee * 4), gw);
         env.close();
 
@@ -3383,7 +3467,7 @@ public:
         auto const bob = Account("bob");
 
         auto const fee = env.current()->fees().base;
-        env.fund(reserve(env, 2) + drops(400'000'000'000) + (fee), alice, bob);
+        env.fund(reserve(env, 2) + drops(400'000'000'000) + fee, alice, bob);
         env.fund(reserve(env, 2) + (fee * 4), gw);
         env.close();
 
@@ -3644,12 +3728,9 @@ public:
                     auto actorOffers = offersOnAccount(env, actor.acct);
                     auto const offerCount = std::distance(
                         actorOffers.begin(),
-                        std::remove_if(
-                            actorOffers.begin(),
-                            actorOffers.end(),
-                            [](std::shared_ptr& offer) {
-                                return (*offer)[sfTakerGets].signum() == 0;
-                            }));
+                        std::ranges::remove_if(actorOffers, [](SLE::const_pointer& offer) {
+                            return (*offer)[sfTakerGets].signum() == 0;
+                        }).begin());
                     BEAST_EXPECT(offerCount == actor.offers);
 
                     env.require(Balance(actor.acct, actor.xrp));
@@ -3816,12 +3897,9 @@ public:
                     auto actorOffers = offersOnAccount(env, actor.acct);
                     auto const offerCount = std::distance(
                         actorOffers.begin(),
-                        std::remove_if(
-                            actorOffers.begin(),
-                            actorOffers.end(),
-                            [](std::shared_ptr& offer) {
-                                return (*offer)[sfTakerGets].signum() == 0;
-                            }));
+                        std::ranges::remove_if(actorOffers, [](SLE::const_pointer& offer) {
+                            return (*offer)[sfTakerGets].signum() == 0;
+                        }).begin());
                     BEAST_EXPECT(offerCount == actor.offers);
 
                     env.require(Balance(actor.acct, actor.xrp));
@@ -4153,15 +4231,13 @@ public:
     }
 
     // Helper function that returns offers on an account sorted by sequence.
-    static std::vector>
+    static std::vector
     sortedOffersOnAccount(jtx::Env& env, jtx::Account const& acct)
     {
-        std::vector> offers{offersOnAccount(env, acct)};
-        std::ranges::sort(
-            offers,
-            [](std::shared_ptr const& rhs, std::shared_ptr const& lhs) {
-                return (*rhs)[sfSequence] < (*lhs)[sfSequence];
-            });
+        std::vector offers{offersOnAccount(env, acct)};
+        std::ranges::sort(offers, [](SLE::const_ref rhs, SLE::const_ref lhs) {
+            return (*rhs)[sfSequence] < (*lhs)[sfSequence];
+        });
         return offers;
     }
 
@@ -4606,14 +4682,14 @@ public:
         // IOU/IOU, XRP/IOU, IOU/XRP offers have TickSize logic unchanged
         // IOU/MPT, MPT/IOU have TickSize logic applied to adjust IOU only
         std::vector const tests = {
-            {getIOU, getIOU, 10, 30},
-            {getIOU, getXRP, 10, 30'000'000},
-            {getXRP, getIOU, 10'000'000, 30},
-            {getMPT, getXRP, 10'000'000, 30'000'000},
-            {getXRP, getMPT, 10'000'000, 30'000'000},
-            {getIOU, getMPT, 10, 30'000'000},
-            {getMPT, getIOU, 10'000'000, 30},
-            {getMPT, getMPT, 10'000'000, 30'000'000}};
+            {.toAsset1 = getIOU, .toAsset2 = getIOU, .val1 = 10, .val2 = 30},
+            {.toAsset1 = getIOU, .toAsset2 = getXRP, .val1 = 10, .val2 = 30'000'000},
+            {.toAsset1 = getXRP, .toAsset2 = getIOU, .val1 = 10'000'000, .val2 = 30},
+            {.toAsset1 = getMPT, .toAsset2 = getXRP, .val1 = 10'000'000, .val2 = 30'000'000},
+            {.toAsset1 = getXRP, .toAsset2 = getMPT, .val1 = 10'000'000, .val2 = 30'000'000},
+            {.toAsset1 = getIOU, .toAsset2 = getMPT, .val1 = 10, .val2 = 30'000'000},
+            {.toAsset1 = getMPT, .toAsset2 = getIOU, .val1 = 10'000'000, .val2 = 30},
+            {.toAsset1 = getMPT, .toAsset2 = getMPT, .val1 = 10'000'000, .val2 = 30'000'000}};
         for (TestInfo const& t : tests)
         {
             Env env{*this, features};
@@ -4645,7 +4721,7 @@ public:
             env(offer(alice, xts(t.val2), xxx(t.val1)), Json(jss::Flags, tfSell));
 
             std::map> offers;
-            forEachItem(*env.current(), alice, [&](std::shared_ptr const& sle) {
+            forEachItem(*env.current(), alice, [&](SLE::const_ref sle) {
                 if (sle->getType() == ltOFFER)
                 {
                     offers.emplace(
@@ -4707,6 +4783,101 @@ public:
         }
     }
 
+    void
+    testAutoCreateReserve(FeatureBitset features)
+    {
+        // When an offer on the book is partially crossed, the payment engine
+        // auto-creates a new ledger object (MPToken or IOU trustline) for the
+        // offer owner to hold the incoming asset.  This happens inside
+        // BookStep::forEachOffer (MPT: checkCreateMPT) and BookStep::consumeOffer
+        // (IOU: directSendNoFeeIOU -> trustCreate) without a reserve sufficiency
+        // check.  The offer owner can therefore end up with more objects than
+        // their XRP balance can reserve for, consistent with IOU behavior.
+
+        testcase("Auto-Create Object Without Reserve Check During Partial Crossing");
+
+        using namespace jtx;
+
+        auto const gw = Account{"gateway"};
+        auto const alice = Account{"alice"};
+        auto const carol = Account{"carol"};
+        auto const bob = Account{"bob"};
+
+        auto test = [&](auto&& getToken, auto&& execTx) {
+            // MPT/IOU: carol's existing offer buys MPT/IOU by selling XRP.
+            // carol has no MPToken/Trustline for this issuance.  When alice partially crosses
+            // carol's offer, an MPToken/Trustline is auto-created for carol without checking
+            // that she can afford the extra reserve slot.
+            Env env{*this, features};
+
+            auto const f = env.current()->fees().base;
+            auto const r = reserve(env, 0);
+            auto const inc = reserve(env, 1) - r;
+
+            env.fund(XRP(10'000), gw, alice, bob);
+
+            // getToken:
+            //  - Create MPT with CanTransfer + CanTrade; authorize alice as holder.
+            //  - Create IOU trustline
+            auto const token = getToken(env);
+
+            // carol: reserve(0) + 1 increment + fee covers placing one offer.
+            // After the offer tx she has exactly reserve(1) + XRP(30).
+            // XRP(30) < inc (50 XRP), so receiving a second object will put her
+            // below reserve(2).
+            if (BEAST_EXPECT(inc > XRP(30)))
+                env.fund(r + inc + f + XRP(30), carol);
+
+            // carol's offer goes on the book (no counterpart yet).
+            // TakerPays=Token(30): carol will receive Token when crossed.
+            // TakerGets=XRP(30):  carol will give XRP when crossed.
+            env(offer(carol, token(30), XRP(30)));
+            env.require(Owners(carol, 1));
+
+            // Execute offer create or cross-currency payment
+            // alice partially crosses carol's offer.
+            // alice sends Token(15) to carol and receives XRP(15).
+            // Token:
+            // - MPT: checkCreateMPT auto-creates an MPToken for carol (no reserve check).
+            // - IOU: directSendNoFeeIOU auto-creates an Trustline for carol (no reserve check).
+            execTx(env, token);
+
+            // Carol now owns 2 objects (remaining offer + new MPToken) even
+            // though her XRP balance is only reserve(1) + XRP(15), which is
+            // below reserve(2) = reserve(1) + inc.
+            auto const carolBalance = r + inc + XRP(15);
+            env.require(Owners(carol, 2), Balance(carol, token(15)), Balance(carol, carolBalance));
+            BEAST_EXPECT(carolBalance < r + 2 * inc);  // below reserve(2)
+        };
+        std::function const getIOU = [&](Env& env) -> PrettyAsset {
+            env.trust(gw["USD"](1'000), alice);
+            env(pay(gw, alice, gw["USD"](100)));
+            return gw["USD"];
+        };
+        std::function const getMPT = [&](Env& env) -> PrettyAsset {
+            MPT const mpT1 = MPTTester({.env = env, .issuer = gw, .holders = {alice}, .pay = 100});
+            return mpT1;
+        };
+        for (auto&& getToken : {getIOU, getMPT})
+        {
+            test(getToken, [&](Env& env, PrettyAsset const& token) {
+                // alice partially crosses carol's offer.
+                // alice sends Token(15) to carol and receives XRP(15).
+                // Token is MPT: checkCreateMPT auto-creates an MPToken for carol (no reserve
+                // check). Token is IOU: directSendNoFeeIOU auto-creates a trustline for carol (no
+                // reserve check).
+                env(offer(alice, XRP(15), token(15)));
+            });
+            test(getToken, [&](Env& env, PrettyAsset const& token) {
+                // Similar to above but with cross-currency payment.
+                env(pay(alice, bob, XRP(15)),
+                    Sendmax(token(15)),
+                    Path(~XRP),
+                    Txflags(tfNoRippleDirect | tfPartialPayment));
+            });
+        }
+    }
+
     void
     testAll(FeatureBitset features)
     {
@@ -4763,6 +4934,7 @@ public:
         testRmSmallIncreasedQOffersMPT(features);
         testFillOrKill(features);
         testTickSize(features);
+        testAutoCreateReserve(features);
     }
 
     void
diff --git a/src/test/app/Offer_test.cpp b/src/test/app/Offer_test.cpp
index 811a18dda5..804bd9b86c 100644
--- a/src/test/app/Offer_test.cpp
+++ b/src/test/app/Offer_test.cpp
@@ -49,7 +49,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -755,11 +754,11 @@ public:
     }
 
     // Helper function that returns the Offers on an account.
-    static std::vector>
+    static std::vector
     offersOnAccount(jtx::Env& env, jtx::Account const& account)
     {
-        std::vector> result;
-        forEachItem(*env.current(), account, [&result](std::shared_ptr const& sle) {
+        std::vector result;
+        forEachItem(*env.current(), account, [&result](SLE::const_ref sle) {
             if (sle->getType() == ltOFFER)
                 result.push_back(sle);
         });
@@ -798,11 +797,13 @@ public:
             // The offer expires (it's not removed yet).
             env.close();
             env.require(Owners(bob, 1), offers(bob, 1));
+            auto const expiredBobOffer = keylet::offer(bob, env.seq(bob) - 1);
 
             // bob creates the offer that will be crossed.
             env(offer(bob, usd(500), XRP(500)), Ter(tesSUCCESS));
             env.close();
             env.require(Owners(bob, 2), offers(bob, 2));
+            auto const crossedBobOffer = keylet::offer(bob, env.seq(bob) - 1);
 
             env(trust(alice, usd(1000)), Ter(tesSUCCESS));
             env(pay(gw, alice, usd(1000)), Ter(tesSUCCESS));
@@ -821,6 +822,8 @@ public:
                 Balance(bob, usd(kNone)),
                 Owners(bob, 1),
                 offers(bob, 1));
+            BEAST_EXPECT(!env.current()->exists(expiredBobOffer));
+            BEAST_EXPECT(env.current()->exists(crossedBobOffer));
 
             // Order that can be filled
             env(offer(alice, XRP(500), usd(500)), Txflags(tfFillOrKill), Ter(tesSUCCESS));
@@ -836,6 +839,27 @@ public:
                 offers(bob, 0));
         }
 
+        // A failed Fill-or-Kill may tentatively consume a funded offer before
+        // the transaction is reset. That offer must not be treated as an
+        // unfunded offer cleanup.
+        {
+            Env env{*this, features};
+
+            env.fund(startBalance, gw, alice, bob);
+            env.close();
+
+            env(offer(bob, usd(500), XRP(500)), Ter(tesSUCCESS));
+            env.close();
+            auto const bobOffer = keylet::offer(bob, env.seq(bob) - 1);
+
+            env(trust(alice, usd(1000)), Ter(tesSUCCESS));
+            env(pay(gw, alice, usd(1000)), Ter(tesSUCCESS));
+            env(offer(alice, XRP(1000), usd(1000)), Txflags(tfFillOrKill), Ter(tecKILLED));
+
+            env.require(offers(alice, 0), offers(bob, 1), Balance(alice, usd(1000)));
+            BEAST_EXPECT(env.current()->exists(bobOffer));
+        }
+
         // Immediate or Cancel - cross as much as possible
         // and add nothing on the books:
         {
@@ -1974,54 +1998,36 @@ public:
 
         using namespace jtx;
 
-        for (auto numberSwitchOver : {false, true})
-        {
-            Env env{*this, features};
-            if (numberSwitchOver)
-            {
-                env.enableFeature(fixUniversalNumber);
-            }
-            else
-            {
-                env.disableFeature(fixUniversalNumber);
-            }
+        Env env{*this, features};
 
-            auto const gw = Account{"gateway"};
-            auto const alice = Account{"alice"};
-            auto const bob = Account{"bob"};
-            auto const usd = gw["USD"];
+        auto const gw = Account{"gateway"};
+        auto const alice = Account{"alice"};
+        auto const bob = Account{"bob"};
+        auto const usd = gw["USD"];
 
-            env.fund(XRP(10000), gw, alice, bob);
-            env.close();
+        env.fund(XRP(10000), gw, alice, bob);
+        env.close();
 
-            env(rate(gw, 1.005));
+        env(rate(gw, 1.005));
 
-            env(trust(alice, usd(1000)));
-            env(trust(bob, usd(1000)));
-            env(trust(gw, alice["USD"](50)));
+        env(trust(alice, usd(1000)));
+        env(trust(bob, usd(1000)));
+        env(trust(gw, alice["USD"](50)));
 
-            env(pay(gw, bob, bob["USD"](1)));
-            env(pay(alice, gw, usd(50)));
+        env(pay(gw, bob, bob["USD"](1)));
+        env(pay(alice, gw, usd(50)));
 
-            env(trust(gw, alice["USD"](0)));
+        env(trust(gw, alice["USD"](0)));
 
-            env(offer(alice, usd(50), XRP(150000)));
-            env(offer(bob, XRP(100), usd(0.1)));
+        env(offer(alice, usd(50), XRP(150000)));
+        env(offer(bob, XRP(100), usd(0.1)));
 
-            auto jrr = ledgerEntryState(env, alice, gw, "USD");
-            BEAST_EXPECT(jrr[jss::node][sfBalance.fieldName][jss::value] == "49.96666666666667");
+        auto jrr = ledgerEntryState(env, alice, gw, "USD");
+        BEAST_EXPECT(jrr[jss::node][sfBalance.fieldName][jss::value] == "49.96666666666667");
 
-            jrr = ledgerEntryState(env, bob, gw, "USD");
-            json::Value const bobUSD = jrr[jss::node][sfBalance.fieldName][jss::value];
-            if (!numberSwitchOver)
-            {
-                BEAST_EXPECT(bobUSD == "-0.966500000033334");
-            }
-            else
-            {
-                BEAST_EXPECT(bobUSD == "-0.9665000000333333");
-            }
-        }
+        jrr = ledgerEntryState(env, bob, gw, "USD");
+        json::Value const bobUSD = jrr[jss::node][sfBalance.fieldName][jss::value];
+        BEAST_EXPECT(bobUSD == "-0.9665000000333333");
     }
 
     void
@@ -2190,7 +2196,7 @@ public:
         jtx::PrettyAmount const& expectBalance)
     {
         Issue const& issue = expectBalance.value().get();
-        auto const sleTrust = env.le(keylet::line(account.id(), issue));
+        auto const sleTrust = env.le(keylet::trustLine(account.id(), issue));
         BEAST_EXPECT(sleTrust);
         if (sleTrust)
         {
@@ -2357,7 +2363,7 @@ public:
                 else
                 {
                     // Verify that no trustline was created.
-                    auto const sleTrust = env.le(keylet::line(acct, usd));
+                    auto const sleTrust = env.le(keylet::trustLine(acct, usd));
                     BEAST_EXPECT(!sleTrust);
                 }
             }
@@ -2542,8 +2548,8 @@ public:
         env.require(offers(bob, 0));
 
         // The two trustlines that were generated by offers should be gone.
-        BEAST_EXPECT(!env.le(keylet::line(alice.id(), eur)));
-        BEAST_EXPECT(!env.le(keylet::line(bob.id(), usd)));
+        BEAST_EXPECT(!env.le(keylet::trustLine(alice.id(), eur)));
+        BEAST_EXPECT(!env.le(keylet::trustLine(bob.id(), usd)));
 
         // Make two more offers that leave one of the offers non-dry. We
         // need to properly sequence the transactions:
@@ -3615,7 +3621,7 @@ public:
         auto const btc = gw["BTC"];
 
         auto const fee = env.current()->fees().base;
-        env.fund(reserve(env, 2) + drops(9999640) + (fee), ann);
+        env.fund(reserve(env, 2) + drops(9999640) + fee, ann);
         env.fund(reserve(env, 2) + (fee * 4), gw);
         env.close();
 
@@ -3653,7 +3659,7 @@ public:
         auto const cny = gw["CNY"];
 
         auto const fee = env.current()->fees().base;
-        env.fund(reserve(env, 2) + drops(400000000000) + (fee), alice, bob);
+        env.fund(reserve(env, 2) + drops(400000000000) + fee, alice, bob);
         env.fund(reserve(env, 2) + (fee * 4), gw);
         env.close();
 
@@ -3700,7 +3706,7 @@ public:
         auto const jpy = gw["JPY"];
 
         auto const fee = env.current()->fees().base;
-        env.fund(reserve(env, 2) + drops(400000000000) + (fee), alice, bob);
+        env.fund(reserve(env, 2) + drops(400000000000) + fee, alice, bob);
         env.fund(reserve(env, 2) + (fee * 4), gw);
         env.close();
 
@@ -3753,7 +3759,7 @@ public:
         auto const jpy = gw2["JPY"];
 
         auto const fee = env.current()->fees().base;
-        env.fund(reserve(env, 2) + drops(400000000000) + (fee), alice, bob);
+        env.fund(reserve(env, 2) + drops(400000000000) + fee, alice, bob);
         env.fund(reserve(env, 2) + (fee * 4), gw1, gw2);
         env.close();
 
@@ -3928,10 +3934,10 @@ public:
         // clang-format off
         TestData const tests[]{
             //        btcStart   --------------------- actor[0] ---------------------    -------------------- actor[1] -------------------
-            {.self=0, .leg0=0, .leg1=1, .btcStart=btc(20), .actors={{"ann", 0, drops(3900000'000000 - (4 * baseFee)), btc(20.0), usd(3000)}, {"abe", 0, drops(4100000'000000 - (3 * baseFee)), btc( 0), usd(750)}}},  // no BTC xfer fee
-            {.self=0, .leg0=1, .leg1=0, .btcStart=btc(20), .actors={{"bev", 0, drops(4100000'000000 - (4 * baseFee)), btc( 7.5), usd(2000)}, {"bob", 0, drops(3900000'000000 - (3 * baseFee)), btc(10), usd(  0)}}},  // no USD xfer fee
-            {.self=0, .leg0=0, .leg1=0, .btcStart=btc(20), .actors={{"cam", 0, drops(4000000'000000 - (5 * baseFee)), btc(20.0), usd(2000)}                                                     }},  // no xfer fee
-            {.self=0, .leg0=1, .leg1=0, .btcStart=btc( 5), .actors={{"deb", 1, drops(4040000'000000 - (4 * baseFee)), btc( 0.0), usd(2000)}, {"dan", 1, drops(3960000'000000 - (3 * baseFee)), btc( 4), usd(  0)}}},  // no USD xfer fee
+            {.self=0, .leg0=0, .leg1=1, .btcStart=btc(20), .actors={{.acct="ann", .offers=0, .xrp=drops(3900000'000000 - (4 * baseFee)), .btc=btc(20.0), .usd=usd(3000)}, {.acct="abe", .offers=0, .xrp=drops(4100000'000000 - (3 * baseFee)), .btc=btc( 0), .usd=usd(750)}}},  // no BTC xfer fee
+            {.self=0, .leg0=1, .leg1=0, .btcStart=btc(20), .actors={{.acct="bev", .offers=0, .xrp=drops(4100000'000000 - (4 * baseFee)), .btc=btc( 7.5), .usd=usd(2000)}, {.acct="bob", .offers=0, .xrp=drops(3900000'000000 - (3 * baseFee)), .btc=btc(10), .usd=usd(  0)}}},  // no USD xfer fee
+            {.self=0, .leg0=0, .leg1=0, .btcStart=btc(20), .actors={{.acct="cam", .offers=0, .xrp=drops(4000000'000000 - (5 * baseFee)), .btc=btc(20.0), .usd=usd(2000)}                                                     }},  // no xfer fee
+            {.self=0, .leg0=1, .leg1=0, .btcStart=btc( 5), .actors={{.acct="deb", .offers=1, .xrp=drops(4040000'000000 - (4 * baseFee)), .btc=btc( 0.0), .usd=usd(2000)}, {.acct="dan", .offers=1, .xrp=drops(3960000'000000 - (3 * baseFee)), .btc=btc( 4), .usd=usd(  0)}}},  // no USD xfer fee
         };
         // clang-format on
 
@@ -3980,7 +3986,7 @@ public:
                 auto actorOffers = offersOnAccount(env, actor.acct);
                 auto const offerCount = std::distance(
                     actorOffers.begin(),
-                    std::ranges::remove_if(actorOffers, [](std::shared_ptr& offer) {
+                    std::ranges::remove_if(actorOffers, [](SLE::const_pointer& offer) {
                         return (*offer)[sfTakerGets].signum() == 0;
                     }).begin());
                 BEAST_EXPECT(offerCount == actor.offers);
@@ -4076,8 +4082,8 @@ public:
         // clang-format off
         TestData const tests[]{
             //         btcStart    ------------------- actor[0] --------------------    ------------------- actor[1] --------------------
-            {.self=0, .leg0=0, .leg1=1, .btcStart=btc(5), .actors={{"gay", 1, drops(3950000'000000 - (4 * baseFee)), btc(5), usd(2500)}, {"gar", 1, drops(4050000'000000 - (3 * baseFee)), btc(0), usd(1375)}}}, // no BTC xfer fee
-            {.self=0, .leg0=0, .leg1=0, .btcStart=btc(5), .actors={{"hye", 2, drops(4000000'000000 - (5 * baseFee)), btc(5), usd(2000)}                                                     }}  // no xfer fee
+            {.self=0, .leg0=0, .leg1=1, .btcStart=btc(5), .actors={{.acct="gay", .offers=1, .xrp=drops(3950000'000000 - (4 * baseFee)), .btc=btc(5), .usd=usd(2500)}, {.acct="gar", .offers=1, .xrp=drops(4050000'000000 - (3 * baseFee)), .btc=btc(0), .usd=usd(1375)}}}, // no BTC xfer fee
+            {.self=0, .leg0=0, .leg1=0, .btcStart=btc(5), .actors={{.acct="hye", .offers=2, .xrp=drops(4000000'000000 - (5 * baseFee)), .btc=btc(5), .usd=usd(2000)}                                                     }}  // no xfer fee
         };
         // clang-format on
 
@@ -4126,7 +4132,7 @@ public:
                 auto actorOffers = offersOnAccount(env, actor.acct);
                 auto const offerCount = std::distance(
                     actorOffers.begin(),
-                    std::ranges::remove_if(actorOffers, [](std::shared_ptr& offer) {
+                    std::ranges::remove_if(actorOffers, [](SLE::const_pointer& offer) {
                         return (*offer)[sfTakerGets].signum() == 0;
                     }).begin());
                 BEAST_EXPECT(offerCount == actor.offers);
@@ -4478,7 +4484,7 @@ public:
                                   jtx::Account const& src,
                                   jtx::Account const& dst,
                                   Currency const& cur) -> bool {
-            return bool(env.le(keylet::line(src, dst, cur)));
+            return bool(env.le(keylet::trustLine(src, dst, cur)));
         };
 
         Account const alice("alice");
@@ -4641,7 +4647,7 @@ public:
         env(offer(alice, xts(30), xxx(10)), Json(jss::Flags, tfSell));
 
         std::map> offers;
-        forEachItem(*env.current(), alice, [&](std::shared_ptr const& sle) {
+        forEachItem(*env.current(), alice, [&](SLE::const_ref sle) {
             if (sle->getType() == ltOFFER)
             {
                 offers.emplace(
@@ -4676,15 +4682,13 @@ public:
     }
 
     // Helper function that returns offers on an account sorted by sequence.
-    static std::vector>
+    static std::vector
     sortedOffersOnAccount(jtx::Env& env, jtx::Account const& acct)
     {
-        std::vector> offers{offersOnAccount(env, acct)};
-        std::ranges::sort(
-            offers,
-            [](std::shared_ptr const& rhs, std::shared_ptr const& lhs) {
-                return (*rhs)[sfSequence] < (*lhs)[sfSequence];
-            });
+        std::vector offers{offersOnAccount(env, acct)};
+        std::ranges::sort(offers, [](SLE::const_ref rhs, SLE::const_ref lhs) {
+            return (*rhs)[sfSequence] < (*lhs)[sfSequence];
+        });
         return offers;
     }
 
diff --git a/src/test/app/PathMPT_test.cpp b/src/test/app/PathMPT_test.cpp
index da5597cf05..3ba67b58a6 100644
--- a/src/test/app/PathMPT_test.cpp
+++ b/src/test/app/PathMPT_test.cpp
@@ -87,9 +87,9 @@ class PathMPT_test : public beast::unit_test::Suite
         // with the search parameters that the tests were written for.
         using namespace jtx;
         return Env(*this, envconfig([](std::unique_ptr cfg) {
-            cfg->PATH_SEARCH_OLD = 7;
-            cfg->PATH_SEARCH = 7;
-            cfg->PATH_SEARCH_MAX = 10;
+            cfg->pathSearchOld = 7;
+            cfg->pathSearch = 7;
+            cfg->pathSearchMax = 10;
             return cfg;
         }));
     }
diff --git a/src/test/app/Path_test.cpp b/src/test/app/Path_test.cpp
index 22044a2572..8f19a419a0 100644
--- a/src/test/app/Path_test.cpp
+++ b/src/test/app/Path_test.cpp
@@ -101,9 +101,9 @@ class Path_test : public beast::unit_test::Suite
         // with the search parameters that the tests were written for.
         using namespace jtx;
         return Env(*this, envconfig([](std::unique_ptr cfg) {
-            cfg->PATH_SEARCH_OLD = 7;
-            cfg->PATH_SEARCH = 7;
-            cfg->PATH_SEARCH_MAX = 10;
+            cfg->pathSearchOld = 7;
+            cfg->pathSearch = 7;
+            cfg->pathSearchMax = 10;
             return cfg;
         }));
     }
@@ -216,7 +216,7 @@ public:
 
         STAmount da;
         if (result.isMember(jss::destination_amount))
-            da = amountFromJson(kSfGeneric, result[jss::destination_amount]);
+            da = amountFromJson(sfGeneric, result[jss::destination_amount]);
 
         STAmount sa;
         STPathSet paths;
@@ -228,10 +228,10 @@ public:
                 auto const& path = alts[0u];
 
                 if (path.isMember(jss::source_amount))
-                    sa = amountFromJson(kSfGeneric, path[jss::source_amount]);
+                    sa = amountFromJson(sfGeneric, path[jss::source_amount]);
 
                 if (path.isMember(jss::destination_amount))
-                    da = amountFromJson(kSfGeneric, path[jss::destination_amount]);
+                    da = amountFromJson(sfGeneric, path[jss::destination_amount]);
 
                 if (path.isMember(jss::paths_computed))
                 {
@@ -880,7 +880,7 @@ public:
             })",
             jv);
 
-        auto const jvL = env.le(keylet::line(Account("bob").id(), Account("alice")["USD"]))
+        auto const jvL = env.le(keylet::trustLine(Account("bob").id(), Account("alice")["USD"]))
                              ->getJson(JsonOptions::Values::None);
         for (auto it = jv.begin(); it != jv.end(); ++it)
             BEAST_EXPECT(*it == jvL[it.memberName()]);
@@ -922,14 +922,15 @@ public:
             })",
             jv);
 
-        auto const jvL = env.le(keylet::line(Account("bob").id(), Account("alice")["USD"]))
+        auto const jvL = env.le(keylet::trustLine(Account("bob").id(), Account("alice")["USD"]))
                              ->getJson(JsonOptions::Values::None);
         for (auto it = jv.begin(); it != jv.end(); ++it)
             BEAST_EXPECT(*it == jvL[it.memberName()]);
 
         env.trust(Account("bob")["USD"](0), "alice");
         env.trust(Account("alice")["USD"](0), "bob");
-        BEAST_EXPECT(env.le(keylet::line(Account("bob").id(), Account("alice")["USD"])) == nullptr);
+        BEAST_EXPECT(
+            env.le(keylet::trustLine(Account("bob").id(), Account("alice")["USD"])) == nullptr);
     }
 
     void
@@ -972,13 +973,14 @@ public:
             })",
             jv);
 
-        auto const jvL = env.le(keylet::line(Account("alice").id(), Account("bob")["USD"]))
+        auto const jvL = env.le(keylet::trustLine(Account("alice").id(), Account("bob")["USD"]))
                              ->getJson(JsonOptions::Values::None);
         for (auto it = jv.begin(); it != jv.end(); ++it)
             BEAST_EXPECT(*it == jvL[it.memberName()]);
 
         env(pay("alice", "bob", Account("alice")["USD"](50)));
-        BEAST_EXPECT(env.le(keylet::line(Account("alice").id(), Account("bob")["USD"])) == nullptr);
+        BEAST_EXPECT(
+            env.le(keylet::trustLine(Account("alice").id(), Account("bob")["USD"])) == nullptr);
     }
 
     void
diff --git a/src/test/app/PayChan_test.cpp b/src/test/app/PayChan_test.cpp
index b81afa830e..fd2c7790d5 100644
--- a/src/test/app/PayChan_test.cpp
+++ b/src/test/app/PayChan_test.cpp
@@ -45,7 +45,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -57,13 +56,13 @@ using namespace jtx::paychan;
 
 struct PayChan_test : public beast::unit_test::Suite
 {
-    static std::pair>
+    static std::pair
     channelKeyAndSle(ReadView const& view, jtx::Account const& account, jtx::Account const& dst)
     {
         auto const sle = view.read(keylet::account(account));
         if (!sle)
             return {};
-        auto const k = keylet::payChan(account, dst, (*sle)[sfSequence] - 1);
+        auto const k = keylet::payChannel(account, dst, (*sle)[sfSequence] - 1);
         return {k.key, view.read(k)};
     }
 
@@ -869,7 +868,7 @@ struct PayChan_test : public beast::unit_test::Suite
             env.close();
 
             // Setup deposit authorization
-            env(deposit::authCredentials(bob, {{carol, credType}}));
+            env(deposit::authCredentials(bob, {{.issuer = carol, .credType = credType}}));
             env.close();
 
             // Fail, credentials doesn’t belong to root account
@@ -1665,9 +1664,8 @@ struct PayChan_test : public beast::unit_test::Suite
         auto const settleDelay = 100s;
         auto const pk = alice.pk();
 
-        auto inOwnerDir = [](ReadView const& view,
-                             Account const& acc,
-                             std::shared_ptr const& chan) -> bool {
+        auto inOwnerDir =
+            [](ReadView const& view, Account const& acc, SLE::const_ref chan) -> bool {
             xrpl::Dir const ownerDir(view, keylet::ownerDir(acc.id()));
             // NOLINTNEXTLINE(modernize-use-ranges)
             return std::find(ownerDir.begin(), ownerDir.end(), chan) != ownerDir.end();
@@ -1992,7 +1990,10 @@ public:
     run() override
     {
         using namespace test::jtx;
-        FeatureBitset const all{testableAmendments()};
+        // fixCleanup3_2_0 changes payment-channel error codes (tem* -> tec*)
+        // and channel-closing semantics. This suite asserts the
+        // pre-amendment behavior, so run it with the amendment disabled.
+        FeatureBitset const all{testableAmendments() - fixCleanup3_2_0};
         testWithFeats(all);
         testDepositAuthCreds();
         testMetaAndOwnership(all - fixIncludeKeyletFields);
diff --git a/src/test/app/PayStrand_test.cpp b/src/test/app/PayStrand_test.cpp
index 67a37833b2..1f9290de63 100644
--- a/src/test/app/PayStrand_test.cpp
+++ b/src/test/app/PayStrand_test.cpp
@@ -80,7 +80,7 @@ getTrustFlag(
     Currency const& cur,
     TrustFlag flag)
 {
-    if (auto sle = env.le(keylet::line(src, dst, cur)))
+    if (auto sle = env.le(keylet::trustLine(src, dst, cur)))
     {
         auto const useHigh = src.id() > dst.id();
         return sle->isFlag(trustFlag(flag, useHigh));
@@ -454,7 +454,7 @@ struct ExistingElementPool
                 for (auto const& c : currencies)
                 {
                     // Line balance
-                    auto const lk = keylet::line(*ai1, *ai2, c);
+                    auto const lk = keylet::trustLine(*ai1, *ai2, c);
                     auto const b1 = lineBalance(v1, lk);
                     auto const b2 = lineBalance(v2, lk);
                     if (b1 != b2)
@@ -632,7 +632,13 @@ struct PayStrand_test : public beast::unit_test::Suite
 
             // Insert implied account
             test(
-                env, usd, std::nullopt, STPath(), tesSUCCESS, D{alice, gw, usdC}, D{gw, bob, usdC});
+                env,
+                usd,
+                std::nullopt,
+                STPath(),
+                tesSUCCESS,
+                D{.src = alice, .dst = gw, .currency = usdC},
+                D{.src = gw, .dst = bob, .currency = usdC});
             env.trust(eur(1000), alice, bob);
 
             // Insert implied offer
@@ -642,9 +648,9 @@ struct PayStrand_test : public beast::unit_test::Suite
                 usd,
                 STPath(),
                 tesSUCCESS,
-                D{alice, gw, usdC},
+                D{.src = alice, .dst = gw, .currency = usdC},
                 B{usd, eur, std::nullopt},
-                D{gw, bob, eurC});
+                D{.src = gw, .dst = bob, .currency = eurC});
 
             // Path with explicit offer
             test(
@@ -653,9 +659,9 @@ struct PayStrand_test : public beast::unit_test::Suite
                 usd,
                 STPath({ipe(eur)}),
                 tesSUCCESS,
-                D{alice, gw, usdC},
+                D{.src = alice, .dst = gw, .currency = usdC},
                 B{usd, eur, std::nullopt},
-                D{gw, bob, eurC});
+                D{.src = gw, .dst = bob, .currency = eurC});
 
             // Path with offer that changes issuer only
             env.trust(carol["USD"](1000), bob);
@@ -665,9 +671,9 @@ struct PayStrand_test : public beast::unit_test::Suite
                 usd,
                 STPath({iape(carol)}),
                 tesSUCCESS,
-                D{alice, gw, usdC},
+                D{.src = alice, .dst = gw, .currency = usdC},
                 B{usd, carol["USD"], std::nullopt},
-                D{carol, bob, usdC});
+                D{.src = carol, .dst = bob, .currency = usdC});
 
             // Path with XRP src currency
             test(
@@ -678,7 +684,7 @@ struct PayStrand_test : public beast::unit_test::Suite
                 tesSUCCESS,
                 XRPS{alice},
                 B{XRP, usd, std::nullopt},
-                D{gw, bob, usdC});
+                D{.src = gw, .dst = bob, .currency = usdC});
 
             // Path with XRP dst currency.
             test(
@@ -688,7 +694,7 @@ struct PayStrand_test : public beast::unit_test::Suite
                 STPath({STPathElement{
                     STPathElement::TypeCurrency, xrpAccount(), xrpCurrency(), xrpAccount()}}),
                 tesSUCCESS,
-                D{alice, gw, usdC},
+                D{.src = alice, .dst = gw, .currency = usdC},
                 B{usd, XRP, std::nullopt},
                 XRPS{bob});
 
@@ -699,10 +705,10 @@ struct PayStrand_test : public beast::unit_test::Suite
                 usd,
                 STPath({cpe(xrpCurrency())}),
                 tesSUCCESS,
-                D{alice, gw, usdC},
+                D{.src = alice, .dst = gw, .currency = usdC},
                 B{usd, XRP, std::nullopt},
                 B{XRP, eur, std::nullopt},
-                D{gw, bob, eurC});
+                D{.src = gw, .dst = bob, .currency = eurC});
 
             // XRP -> XRP transaction can't include a path
             test(env, XRP, std::nullopt, STPath({ape(carol)}), temBAD_PATH);
diff --git a/src/test/app/PermissionedDEX_test.cpp b/src/test/app/PermissionedDEX_test.cpp
index be377c0c1d..8578b8a9ba 100644
--- a/src/test/app/PermissionedDEX_test.cpp
+++ b/src/test/app/PermissionedDEX_test.cpp
@@ -7,6 +7,9 @@
 #include 
 #include 
 #include 
+#include 
+#include 
+#include 
 #include 
 #include   // IWYU pragma: keep
 #include 
@@ -34,6 +37,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -140,7 +144,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
     static uint256
     getBookDirKey(Book const& book, STAmount const& takerPays, STAmount const& takerGets)
     {
-        return keylet::quality(keylet::kBook(book), getRate(takerGets, takerPays)).key;
+        return keylet::quality(keylet::book(book), getRate(takerGets, takerPays)).key;
     }
 
     static std::optional
@@ -181,22 +185,36 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // test preflight
         {
             Env env(*this, features - featurePermissionedDEX);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID), Ter(temDISABLED));
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID), Ter(temDISABLED));
             env.close();
 
             env.enableFeature(featurePermissionedDEX);
             env.close();
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
+            env.close();
+        }
+
+        // test preflight - malformed DomainID being zero
+        // Only test this with fixCleanup3_2_0 enabled. Without the fix,
+        // an assert-enabled build can crash when Ledger::read() receives
+        // a zero-key PermissionedDomain keylet.
+        if (features[fixCleanup3_2_0])
+        {
+            Env env(*this, features);
+            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+                PermissionedDEX(env);
+
+            env(offer(bob_, XRP(10), USD(10)), Domain(uint256{}), Ter(temMALFORMED));
             env.close();
         }
 
         // preclaim - someone outside of the domain cannot create domain offer
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
             // create devin account who is not part of the domain
@@ -205,7 +223,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
             env.trust(USD(1000), devin);
             env.close();
-            env(pay(gw_, devin, USD(100)));
+            env(pay(gw, devin, USD(100)));
             env.close();
 
             env(offer(devin, XRP(10), USD(10)), Domain(domainID), Ter(tecNO_PERMISSION));
@@ -229,7 +247,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // preclaim - someone with expired cred cannot create domain offer
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
             // create devin account who is not part of the domain
@@ -238,7 +256,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
             env.trust(USD(1000), devin);
             env.close();
-            env(pay(gw_, devin, USD(100)));
+            env(pay(gw, devin, USD(100)));
             env.close();
 
             auto jv = credentials::create(devin, domainOwner, credType);
@@ -264,13 +282,13 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // preclaim - cannot create an offer in a non existent domain
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
             uint256 const badDomain{
                 "F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E3370F3649CE134"
                 "E5"};
 
-            env(offer(bob_, XRP(10), USD(10)), Domain(badDomain), Ter(tecNO_PERMISSION));
+            env(offer(bob, XRP(10), USD(10)), Domain(badDomain), Ter(tecNO_PERMISSION));
             env.close();
         }
 
@@ -278,68 +296,68 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // domain
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            env(credentials::deleteCred(domainOwner, gw_, domainOwner, credType));
+            env(credentials::deleteCred(domainOwner, gw, domainOwner, credType));
             env.close();
 
-            auto const bobOfferSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            auto const bobOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
             env.close();
 
-            BEAST_EXPECT(checkOffer(env, bob_, bobOfferSeq, XRP(10), USD(10), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
         }
 
         // apply - offer can be created even if takerpays issuer is not in
         // domain
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            env(credentials::deleteCred(domainOwner, gw_, domainOwner, credType));
+            env(credentials::deleteCred(domainOwner, gw, domainOwner, credType));
             env.close();
 
-            auto const bobOfferSeq{env.seq(bob_)};
-            env(offer(bob_, USD(10), XRP(10)), Domain(domainID));
+            auto const bobOfferSeq{env.seq(bob)};
+            env(offer(bob, USD(10), XRP(10)), Domain(domainID));
             env.close();
 
-            BEAST_EXPECT(checkOffer(env, bob_, bobOfferSeq, USD(10), XRP(10), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, USD(10), XRP(10), 0, true));
         }
 
         // apply - two domain offers cross with each other
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            auto const bobOfferSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            auto const bobOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
             env.close();
 
-            BEAST_EXPECT(checkOffer(env, bob_, bobOfferSeq, XRP(10), USD(10), 0, true));
-            BEAST_EXPECT(ownerCount(env, bob_) == 3);
+            BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
+            BEAST_EXPECT(ownerCount(env, bob) == 3);
 
             // a non domain offer cannot cross with domain offer
-            env(offer(carol_, USD(10), XRP(10)));
+            env(offer(carol, USD(10), XRP(10)));
             env.close();
 
-            BEAST_EXPECT(checkOffer(env, bob_, bobOfferSeq, XRP(10), USD(10), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
 
-            auto const aliceOfferSeq{env.seq(alice_)};
-            env(offer(alice_, USD(10), XRP(10)), Domain(domainID));
+            auto const aliceOfferSeq{env.seq(alice)};
+            env(offer(alice, USD(10), XRP(10)), Domain(domainID));
             env.close();
 
-            BEAST_EXPECT(!offerExists(env, alice_, aliceOfferSeq));
-            BEAST_EXPECT(!offerExists(env, bob_, bobOfferSeq));
-            BEAST_EXPECT(ownerCount(env, alice_) == 2);
+            BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq));
+            BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq));
+            BEAST_EXPECT(ownerCount(env, alice) == 2);
         }
 
         // apply - create lots of domain offers
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
             std::vector offerSeqs;
@@ -347,19 +365,19 @@ class PermissionedDEX_test : public beast::unit_test::Suite
 
             for (size_t i = 0; i <= 100; i++)
             {
-                auto const bobOfferSeq{env.seq(bob_)};
+                auto const bobOfferSeq{env.seq(bob)};
                 offerSeqs.emplace_back(bobOfferSeq);
 
-                env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+                env(offer(bob, XRP(10), USD(10)), Domain(domainID));
                 env.close();
-                BEAST_EXPECT(checkOffer(env, bob_, bobOfferSeq, XRP(10), USD(10), 0, true));
+                BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
             }
 
             for (auto const offerSeq : offerSeqs)
             {
-                env(offerCancel(bob_, offerSeq));
+                env(offerCancel(bob, offerSeq));
                 env.close();
-                BEAST_EXPECT(!offerExists(env, bob_, offerSeq));
+                BEAST_EXPECT(!offerExists(env, bob, offerSeq));
             }
         }
     }
@@ -372,10 +390,10 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // test preflight - without enabling featurePermissionedDEX amendment
         {
             Env env(*this, features - featurePermissionedDEX);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            env(pay(bob_, alice_, USD(10)),
+            env(pay(bob, alice, USD(10)),
                 Path(~USD),
                 Sendmax(XRP(10)),
                 Domain(domainID),
@@ -385,23 +403,41 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.enableFeature(featurePermissionedDEX);
             env.close();
 
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
             env.close();
 
-            env(pay(bob_, alice_, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
+            env(pay(bob, alice, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
+            env.close();
+        }
+
+        // test preflight - malformed DomainID being zero
+        // Only test this with fixCleanup3_2_0 enabled. Without the fix,
+        // an assert-enabled build can crash when Ledger::read() receives
+        // a zero-key PermissionedDomain keylet.
+        if (features[fixCleanup3_2_0])
+        {
+            Env env(*this, features);
+            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+                PermissionedDEX(env);
+
+            env(pay(bob_, alice_, USD(10)),
+                Path(~USD),
+                Sendmax(XRP(10)),
+                Domain(uint256{}),
+                Ter(temMALFORMED));
             env.close();
         }
 
         // preclaim - cannot send payment with non existent domain
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
             uint256 const badDomain{
                 "F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E3370F3649CE134"
                 "E5"};
 
-            env(pay(bob_, alice_, USD(10)),
+            env(pay(bob, alice, USD(10)),
                 Path(~USD),
                 Sendmax(XRP(10)),
                 Domain(badDomain),
@@ -412,10 +448,10 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // preclaim - payment with non-domain destination fails
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
             env.close();
 
             // create devin account who is not part of the domain
@@ -424,11 +460,11 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
             env.trust(USD(1000), devin);
             env.close();
-            env(pay(gw_, devin, USD(100)));
+            env(pay(gw, devin, USD(100)));
             env.close();
 
             // devin is not part of domain
-            env(pay(alice_, devin, USD(10)),
+            env(pay(alice, devin, USD(10)),
                 Path(~USD),
                 Sendmax(XRP(10)),
                 Domain(domainID),
@@ -440,7 +476,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
 
             // devin has not yet accepted cred
-            env(pay(alice_, devin, USD(10)),
+            env(pay(alice, devin, USD(10)),
                 Path(~USD),
                 Sendmax(XRP(10)),
                 Domain(domainID),
@@ -451,17 +487,17 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
 
             // devin can now receive payment after he is in domain
-            env(pay(alice_, devin, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
+            env(pay(alice, devin, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
             env.close();
         }
 
         // preclaim - non-domain sender cannot send payment
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
             env.close();
 
             // create devin account who is not part of the domain
@@ -470,11 +506,11 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
             env.trust(USD(1000), devin);
             env.close();
-            env(pay(gw_, devin, USD(100)));
+            env(pay(gw, devin, USD(100)));
             env.close();
 
             // devin tries to send domain payment
-            env(pay(devin, alice_, USD(10)),
+            env(pay(devin, alice, USD(10)),
                 Path(~USD),
                 Sendmax(XRP(10)),
                 Domain(domainID),
@@ -486,7 +522,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
 
             // devin has not yet accepted cred
-            env(pay(devin, alice_, USD(10)),
+            env(pay(devin, alice, USD(10)),
                 Path(~USD),
                 Sendmax(XRP(10)),
                 Domain(domainID),
@@ -497,28 +533,28 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
 
             // devin can now send payment after he is in domain
-            env(pay(devin, alice_, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
+            env(pay(devin, alice, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
             env.close();
         }
 
         // apply - domain owner can always send and receive domain payment
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
             env.close();
 
             // domain owner can always be destination
-            env(pay(alice_, domainOwner, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
+            env(pay(alice, domainOwner, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
             env.close();
 
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
             env.close();
 
             // domain owner can send
-            env(pay(domainOwner, alice_, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
+            env(pay(domainOwner, alice, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
             env.close();
         }
     }
@@ -531,22 +567,22 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // test domain cross currency payment consuming one offer
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
             // create a regular offer without domain
-            auto const regularOfferSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)));
+            auto const regularOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)));
             env.close();
-            BEAST_EXPECT(checkOffer(env, bob_, regularOfferSeq, XRP(10), USD(10)));
+            BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10)));
 
-            auto const regularDirKey = getDefaultOfferDirKey(env, bob_, regularOfferSeq);
+            auto const regularDirKey = getDefaultOfferDirKey(env, bob, regularOfferSeq);
             BEAST_EXPECT(regularDirKey);
             BEAST_EXPECT(checkDirectorySize(
                 env, *regularDirKey, 1));  // NOLINT(bugprone-unchecked-optional-access)
 
             // a domain payment cannot consume regular offers
-            env(pay(alice_, carol_, USD(10)),
+            env(pay(alice, carol, USD(10)),
                 Path(~USD),
                 Sendmax(XRP(10)),
                 Domain(domainID),
@@ -554,23 +590,23 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
 
             // create a domain offer
-            auto const domainOfferSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            auto const domainOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
             env.close();
 
-            BEAST_EXPECT(checkOffer(env, bob_, domainOfferSeq, XRP(10), USD(10), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, domainOfferSeq, XRP(10), USD(10), 0, true));
 
-            auto const domainDirKey = getDefaultOfferDirKey(env, bob_, domainOfferSeq);
+            auto const domainDirKey = getDefaultOfferDirKey(env, bob, domainOfferSeq);
             BEAST_EXPECT(domainDirKey);
             BEAST_EXPECT(checkDirectorySize(
                 env, *domainDirKey, 1));  // NOLINT(bugprone-unchecked-optional-access)
 
             // cross-currency permissioned payment consumed
             // domain offer instead of regular offer
-            env(pay(alice_, carol_, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
+            env(pay(alice, carol, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
             env.close();
-            BEAST_EXPECT(!offerExists(env, bob_, domainOfferSeq));
-            BEAST_EXPECT(checkOffer(env, bob_, regularOfferSeq, XRP(10), USD(10)));
+            BEAST_EXPECT(!offerExists(env, bob, domainOfferSeq));
+            BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10)));
 
             // domain directory is empty
             BEAST_EXPECT(checkDirectorySize(
@@ -582,79 +618,79 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // test domain payment consuming two offers in the path
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            auto const eur = gw_["EUR"];
-            env.trust(eur(1000), alice_);
+            auto const eur = gw["EUR"];
+            env.trust(eur(1000), alice);
             env.close();
-            env.trust(eur(1000), bob_);
+            env.trust(eur(1000), bob);
             env.close();
-            env.trust(eur(1000), carol_);
+            env.trust(eur(1000), carol);
             env.close();
-            env(pay(gw_, bob_, eur(100)));
+            env(pay(gw, bob, eur(100)));
             env.close();
 
             // create XRP/USD domain offer
-            auto const usdOfferSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            auto const usdOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
             env.close();
 
-            BEAST_EXPECT(checkOffer(env, bob_, usdOfferSeq, XRP(10), USD(10), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, true));
 
             // payment fail because there isn't eur offer
-            env(pay(alice_, carol_, eur(10)),
+            env(pay(alice, carol, eur(10)),
                 Path(~USD, ~eur),
                 Sendmax(XRP(10)),
                 Domain(domainID),
                 Ter(tecPATH_PARTIAL));
             env.close();
-            BEAST_EXPECT(checkOffer(env, bob_, usdOfferSeq, XRP(10), USD(10), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, true));
 
-            // bob_ creates a regular USD/EUR offer
-            auto const regularOfferSeq{env.seq(bob_)};
-            env(offer(bob_, USD(10), eur(10)));
+            // bob creates a regular USD/EUR offer
+            auto const regularOfferSeq{env.seq(bob)};
+            env(offer(bob, USD(10), eur(10)));
             env.close();
-            BEAST_EXPECT(checkOffer(env, bob_, regularOfferSeq, USD(10), eur(10)));
+            BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, USD(10), eur(10)));
 
-            // alice_ tries to pay again, but still fails because the regular
+            // alice tries to pay again, but still fails because the regular
             // offer cannot be consumed
-            env(pay(alice_, carol_, eur(10)),
+            env(pay(alice, carol, eur(10)),
                 Path(~USD, ~eur),
                 Sendmax(XRP(10)),
                 Domain(domainID),
                 Ter(tecPATH_PARTIAL));
             env.close();
 
-            // bob_ creates a domain USD/EUR offer
-            auto const eurOfferSeq{env.seq(bob_)};
-            env(offer(bob_, USD(10), eur(10)), Domain(domainID));
+            // bob creates a domain USD/EUR offer
+            auto const eurOfferSeq{env.seq(bob)};
+            env(offer(bob, USD(10), eur(10)), Domain(domainID));
             env.close();
-            BEAST_EXPECT(checkOffer(env, bob_, eurOfferSeq, USD(10), eur(10), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, eurOfferSeq, USD(10), eur(10), 0, true));
 
-            // alice_ successfully consume two domain offers: xrp/usd and usd/eur
-            env(pay(alice_, carol_, eur(5)), Sendmax(XRP(5)), Domain(domainID), Path(~USD, ~eur));
+            // alice successfully consume two domain offers: xrp/usd and usd/eur
+            env(pay(alice, carol, eur(5)), Sendmax(XRP(5)), Domain(domainID), Path(~USD, ~eur));
             env.close();
 
-            BEAST_EXPECT(checkOffer(env, bob_, usdOfferSeq, XRP(5), USD(5), 0, true));
-            BEAST_EXPECT(checkOffer(env, bob_, eurOfferSeq, USD(5), eur(5), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(5), USD(5), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, eurOfferSeq, USD(5), eur(5), 0, true));
 
-            // alice_ successfully consume two domain offers and deletes them
+            // alice successfully consume two domain offers and deletes them
             // we compute path this time using `paths`
-            env(pay(alice_, carol_, eur(5)), Sendmax(XRP(5)), Domain(domainID), Paths(XRP));
+            env(pay(alice, carol, eur(5)), Sendmax(XRP(5)), Domain(domainID), Paths(XRP));
             env.close();
 
-            BEAST_EXPECT(!offerExists(env, bob_, usdOfferSeq));
-            BEAST_EXPECT(!offerExists(env, bob_, eurOfferSeq));
+            BEAST_EXPECT(!offerExists(env, bob, usdOfferSeq));
+            BEAST_EXPECT(!offerExists(env, bob, eurOfferSeq));
 
             // regular offer is not consumed
-            BEAST_EXPECT(checkOffer(env, bob_, regularOfferSeq, USD(10), eur(10)));
+            BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, USD(10), eur(10)));
         }
 
         // domain payment cannot consume offer from another domain
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
             // Fund devin and create USD trustline
@@ -664,11 +700,12 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
             env.trust(USD(1000), devin);
             env.close();
-            env(pay(gw_, devin, USD(100)));
+            env(pay(gw, devin, USD(100)));
             env.close();
 
             auto const badCredType = "badCred";
-            pdomain::Credentials const credentials{{badDomainOwner, badCredType}};
+            pdomain::Credentials const credentials{
+                {.issuer = badDomainOwner, .credType = badCredType}};
             env(pdomain::setTx(badDomainOwner, credentials));
 
             auto objects = pdomain::getObjects(badDomainOwner, env);
@@ -683,24 +720,24 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
 
             // domain payment can't consume an offer from another domain
-            env(pay(alice_, carol_, USD(10)),
+            env(pay(alice, carol, USD(10)),
                 Path(~USD),
                 Sendmax(XRP(10)),
                 Domain(domainID),
                 Ter(tecPATH_PARTIAL));
             env.close();
 
-            // bob_ creates an offer under the right domain
-            auto const bobOfferSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            // bob creates an offer under the right domain
+            auto const bobOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
             env.close();
-            BEAST_EXPECT(checkOffer(env, bob_, bobOfferSeq, XRP(10), USD(10), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
 
             // domain payment now consumes from the right domain
-            env(pay(alice_, carol_, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
+            env(pay(alice, carol, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
             env.close();
 
-            BEAST_EXPECT(!offerExists(env, bob_, bobOfferSeq));
+            BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq));
         }
 
         // sanity check: devin, who is part of the domain but doesn't have a
@@ -708,10 +745,10 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // offer
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
             env.close();
 
             // fund devin but don't create a USD trustline with gateway
@@ -727,14 +764,14 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
 
             // successful payment because offer is consumed
-            env(pay(devin, alice_, USD(10)), Sendmax(XRP(10)), Domain(domainID));
+            env(pay(devin, alice, USD(10)), Sendmax(XRP(10)), Domain(domainID));
             env.close();
         }
 
         // offer becomes unfunded when offer owner's cred expires
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
             // create devin account who is not part of the domain
@@ -743,7 +780,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
             env.trust(USD(1000), devin);
             env.close();
-            env(pay(gw_, devin, USD(100)));
+            env(pay(gw, devin, USD(100)));
             env.close();
 
             auto jv = credentials::create(devin, domainOwner, credType);
@@ -760,7 +797,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
 
             // devin's offer can still be consumed while his cred isn't expired
-            env(pay(alice_, carol_, USD(5)), Path(~USD), Sendmax(XRP(5)), Domain(domainID));
+            env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)), Domain(domainID));
             env.close();
             BEAST_EXPECT(checkOffer(env, devin, offerSeq, XRP(5), USD(5), 0, true));
 
@@ -768,7 +805,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close(std::chrono::seconds(20));
 
             // devin's offer is unfunded now due to expired cred
-            env(pay(alice_, carol_, USD(5)),
+            env(pay(alice, carol, USD(5)),
                 Path(~USD),
                 Sendmax(XRP(5)),
                 Domain(domainID),
@@ -780,30 +817,30 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // offer becomes unfunded when offer owner's cred is removed
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            auto const offerSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            auto const offerSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
             env.close();
 
-            // bob_'s offer can still be consumed while his cred exists
-            env(pay(alice_, carol_, USD(5)), Path(~USD), Sendmax(XRP(5)), Domain(domainID));
+            // bob's offer can still be consumed while his cred exists
+            env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)), Domain(domainID));
             env.close();
-            BEAST_EXPECT(checkOffer(env, bob_, offerSeq, XRP(5), USD(5), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, offerSeq, XRP(5), USD(5), 0, true));
 
-            // remove bob_'s cred
-            env(credentials::deleteCred(domainOwner, bob_, domainOwner, credType));
+            // remove bob's cred
+            env(credentials::deleteCred(domainOwner, bob, domainOwner, credType));
             env.close();
 
-            // bob_'s offer is unfunded now due to expired cred
-            env(pay(alice_, carol_, USD(5)),
+            // bob's offer is unfunded now due to expired cred
+            env(pay(alice, carol, USD(5)),
                 Path(~USD),
                 Sendmax(XRP(5)),
                 Domain(domainID),
                 Ter(tecPATH_PARTIAL));
             env.close();
-            BEAST_EXPECT(checkOffer(env, bob_, offerSeq, XRP(5), USD(5), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, offerSeq, XRP(5), USD(5), 0, true));
         }
     }
 
@@ -816,34 +853,34 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // payment. If the domain wishes to control who is allowed to ripple
         // through, they should set the rippling individually
         Env env(*this, features);
-        auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+        auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
             PermissionedDEX(env);
 
-        auto const eura = alice_["EUR"];
-        auto const eurb = bob_["EUR"];
+        auto const eura = alice["EUR"];
+        auto const eurb = bob["EUR"];
 
-        env.trust(eura(100), bob_);
-        env.trust(eurb(100), carol_);
+        env.trust(eura(100), bob);
+        env.trust(eurb(100), carol);
         env.close();
 
-        // remove bob_ from domain
-        env(credentials::deleteCred(domainOwner, bob_, domainOwner, credType));
+        // remove bob from domain
+        env(credentials::deleteCred(domainOwner, bob, domainOwner, credType));
         env.close();
 
-        // alice_ can still ripple through bob_ even though he's not part
+        // alice can still ripple through bob even though he's not part
         // of the domain, this is intentional
-        env(pay(alice_, carol_, eurb(10)), Paths(eura), Domain(domainID));
+        env(pay(alice, carol, eurb(10)), Paths(eura), Domain(domainID));
         env.close();
-        env.require(Balance(bob_, eura(10)), Balance(carol_, eurb(10)));
+        env.require(Balance(bob, eura(10)), Balance(carol, eurb(10)));
 
-        // carol_ sets no ripple on bob_
-        env(trust(carol_, bob_["EUR"](0), bob_, tfSetNoRipple));
+        // carol sets no ripple on bob
+        env(trust(carol, bob["EUR"](0), bob, tfSetNoRipple));
         env.close();
 
-        // payment no longer works because carol_ has no ripple on bob_
-        env(pay(alice_, carol_, eurb(5)), Paths(eura), Domain(domainID), Ter(tecPATH_DRY));
+        // payment no longer works because carol has no ripple on bob
+        env(pay(alice, carol, eurb(5)), Paths(eura), Domain(domainID), Ter(tecPATH_DRY));
         env.close();
-        env.require(Balance(bob_, eura(10)), Balance(carol_, eurb(10)));
+        env.require(Balance(bob, eura(10)), Balance(carol, eurb(10)));
     }
 
     void
@@ -854,37 +891,37 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // whether the issuer is in the domain should NOT affect whether an
         // offer can be consumed in domain payment
         Env env(*this, features);
-        auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+        auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
             PermissionedDEX(env);
 
         // create an xrp/usd offer with usd as takergets
-        auto const bobOffer1Seq{env.seq(bob_)};
-        env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+        auto const bobOffer1Seq{env.seq(bob)};
+        env(offer(bob, XRP(10), USD(10)), Domain(domainID));
         env.close();
 
         // create an usd/xrp offer with usd as takerpays
-        auto const bobOffer2Seq{env.seq(bob_)};
-        env(offer(bob_, USD(10), XRP(10)), Domain(domainID), Txflags(tfPassive));
+        auto const bobOffer2Seq{env.seq(bob)};
+        env(offer(bob, USD(10), XRP(10)), Domain(domainID), Txflags(tfPassive));
         env.close();
 
-        BEAST_EXPECT(checkOffer(env, bob_, bobOffer1Seq, XRP(10), USD(10), 0, true));
-        BEAST_EXPECT(checkOffer(env, bob_, bobOffer2Seq, USD(10), XRP(10), lsfPassive, true));
+        BEAST_EXPECT(checkOffer(env, bob, bobOffer1Seq, XRP(10), USD(10), 0, true));
+        BEAST_EXPECT(checkOffer(env, bob, bobOffer2Seq, USD(10), XRP(10), lsfPassive, true));
 
         // remove gateway from domain
-        env(credentials::deleteCred(domainOwner, gw_, domainOwner, credType));
+        env(credentials::deleteCred(domainOwner, gw, domainOwner, credType));
         env.close();
 
         // payment succeeds even if issuer is not in domain
         // xrp/usd offer is consumed
-        env(pay(alice_, carol_, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
+        env(pay(alice, carol, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
         env.close();
-        BEAST_EXPECT(!offerExists(env, bob_, bobOffer1Seq));
+        BEAST_EXPECT(!offerExists(env, bob, bobOffer1Seq));
 
         // payment succeeds even if issuer is not in domain
         // usd/xrp offer is consumed
-        env(pay(alice_, carol_, XRP(10)), Path(~XRP), Sendmax(USD(10)), Domain(domainID));
+        env(pay(alice, carol, XRP(10)), Path(~XRP), Sendmax(USD(10)), Domain(domainID));
         env.close();
-        BEAST_EXPECT(!offerExists(env, bob_, bobOffer2Seq));
+        BEAST_EXPECT(!offerExists(env, bob, bobOffer2Seq));
     }
 
     void
@@ -895,36 +932,36 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // checking that an unfunded offer will be implicitly removed by a
         // successful payment tx
         Env env(*this, features);
-        auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+        auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
             PermissionedDEX(env);
 
-        auto const aliceOfferSeq{env.seq(alice_)};
-        env(offer(alice_, XRP(100), USD(100)), Domain(domainID));
+        auto const aliceOfferSeq{env.seq(alice)};
+        env(offer(alice, XRP(100), USD(100)), Domain(domainID));
         env.close();
 
-        auto const bobOfferSeq{env.seq(bob_)};
-        env(offer(bob_, XRP(20), USD(20)), Domain(domainID));
+        auto const bobOfferSeq{env.seq(bob)};
+        env(offer(bob, XRP(20), USD(20)), Domain(domainID));
         env.close();
 
-        BEAST_EXPECT(checkOffer(env, bob_, bobOfferSeq, XRP(20), USD(20), 0, true));
-        BEAST_EXPECT(checkOffer(env, alice_, aliceOfferSeq, XRP(100), USD(100), 0, true));
+        BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(20), USD(20), 0, true));
+        BEAST_EXPECT(checkOffer(env, alice, aliceOfferSeq, XRP(100), USD(100), 0, true));
 
-        auto const domainDirKey = getDefaultOfferDirKey(env, bob_, bobOfferSeq);
+        auto const domainDirKey = getDefaultOfferDirKey(env, bob, bobOfferSeq);
         BEAST_EXPECT(domainDirKey);
         BEAST_EXPECT(checkDirectorySize(
             env, *domainDirKey, 2));  // NOLINT(bugprone-unchecked-optional-access)
 
-        // remove alice_ from domain and thus alice_'s offer becomes unfunded
-        env(credentials::deleteCred(domainOwner, alice_, domainOwner, credType));
+        // remove alice from domain and thus alice's offer becomes unfunded
+        env(credentials::deleteCred(domainOwner, alice, domainOwner, credType));
         env.close();
 
-        env(pay(gw_, carol_, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
+        env(pay(gw, carol, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
         env.close();
 
-        BEAST_EXPECT(checkOffer(env, bob_, bobOfferSeq, XRP(10), USD(10), 0, true));
+        BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
 
-        // alice_'s unfunded offer is removed implicitly
-        BEAST_EXPECT(!offerExists(env, alice_, aliceOfferSeq));
+        // alice's unfunded offer is removed implicitly
+        BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq));
         BEAST_EXPECT(checkDirectorySize(
             env, *domainDirKey, 1));  // NOLINT(bugprone-unchecked-optional-access)
     }
@@ -935,12 +972,12 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         testcase("AMM not used");
 
         Env env(*this, features);
-        auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+        auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
             PermissionedDEX(env);
-        AMM const amm(env, alice_, XRP(10), USD(50));
+        AMM const amm(env, alice, XRP(10), USD(50));
 
         // a domain payment isn't able to consume AMM
-        env(pay(bob_, carol_, USD(5)),
+        env(pay(bob, carol, USD(5)),
             Path(~USD),
             Sendmax(XRP(5)),
             Domain(domainID),
@@ -948,7 +985,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         env.close();
 
         // a non domain payment can use AMM
-        env(pay(bob_, carol_, USD(5)), Path(~USD), Sendmax(XRP(5)));
+        env(pay(bob, carol, USD(5)), Path(~USD), Sendmax(XRP(5)));
         env.close();
 
         // USD amount in AMM is changed
@@ -956,6 +993,83 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         BEAST_EXPECT(usd == USD(45));
     }
 
+    void
+    testAmmQualityNotLeaked(FeatureBitset features)
+    {
+        bool const excludesAmmFromDomainQuality = features[fixCleanup3_3_0];
+
+        testcase << "AMM quality not leaked into domain BookStep"
+                 << (excludesAmmFromDomainQuality ? " (Cleanup3_3_0 enabled)"
+                                                  : " (Cleanup3_3_0 disabled)");
+
+        Env env(*this, features);
+        auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
+            PermissionedDEX(env);
+        auto const eur = gw["EUR"];
+
+        env.trust(eur(1000), bob, domainOwner);
+        env.close();
+        env(pay(gw, bob, eur(100)));
+        env.close();
+
+        env(pay(gw, alice, USD(500)));
+        env.close();
+
+        // The AMM makes the direct XRP->USD book look much better than it
+        // really is for domain payments. The domain LOB direct path is 1:1,
+        // while the competing XRP->EUR->USD path is 2:1.
+        AMM const amm(env, alice, XRP(10), USD(500));
+
+        auto const directOfferSeq{env.seq(bob)};
+        env(offer(bob, XRP(10), USD(10)), Domain(domainID));
+        env.close();
+
+        auto const xrpEurOfferSeq{env.seq(bob)};
+        env(offer(bob, XRP(10), eur(20)), Domain(domainID));
+        env.close();
+
+        auto const eurUsdOfferSeq{env.seq(domainOwner)};
+        env(offer(domainOwner, eur(20), USD(20)), Domain(domainID));
+        env.close();
+
+        auto const carolBalBefore = env.balance(carol, USD);
+
+        // Both paths compete for the same XRP(10) sendmax. If AMM quality leaks
+        // into the direct domain book, the engine ranks direct XRP->USD first
+        // but crossing can only consume the 1:1 LOB offer. With the fix, the
+        // direct book is ranked by its domain LOB quality, so the 2:1
+        // XRP->EUR->USD path executes first.
+        env(pay(alice, carol, USD(100)),
+            Path(~USD),
+            Path(~eur, ~USD),
+            Sendmax(XRP(10)),
+            Txflags(tfPartialPayment | tfNoRippleDirect),
+            Domain(domainID));
+        env.close();
+
+        auto const delivered = env.balance(carol, USD) - carolBalBefore;
+        if (excludesAmmFromDomainQuality)
+        {
+            BEAST_EXPECT(delivered == USD(20));
+
+            BEAST_EXPECT(checkOffer(env, bob, directOfferSeq, XRP(10), USD(10), 0, true));
+            BEAST_EXPECT(!offerExists(env, bob, xrpEurOfferSeq));
+            BEAST_EXPECT(!offerExists(env, domainOwner, eurUsdOfferSeq));
+        }
+        else
+        {
+            BEAST_EXPECT(delivered == USD(10));
+
+            BEAST_EXPECT(!offerExists(env, bob, directOfferSeq));
+            BEAST_EXPECT(checkOffer(env, bob, xrpEurOfferSeq, XRP(10), eur(20), 0, true));
+            BEAST_EXPECT(checkOffer(env, domainOwner, eurUsdOfferSeq, eur(20), USD(20), 0, true));
+        }
+
+        auto [xrp, usd, lpt] = amm.balances(XRP, USD);
+        BEAST_EXPECT(xrp == XRP(10));
+        BEAST_EXPECT(usd == USD(500));
+    }
+
     void
     testHybridOfferCreate(FeatureBitset features)
     {
@@ -964,126 +1078,126 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // test preflight - invalid hybrid flag
         {
             Env env(*this, features - featurePermissionedDEX);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            env(offer(bob_, XRP(10), USD(10)),
+            env(offer(bob, XRP(10), USD(10)),
                 Domain(domainID),
                 Txflags(tfHybrid),
                 Ter(temDISABLED));
             env.close();
 
-            env(offer(bob_, XRP(10), USD(10)), Txflags(tfHybrid), Ter(temINVALID_FLAG));
+            env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Ter(temINVALID_FLAG));
             env.close();
 
             env.enableFeature(featurePermissionedDEX);
             env.close();
 
             // hybrid offer must have domainID
-            env(offer(bob_, XRP(10), USD(10)), Txflags(tfHybrid), Ter(temINVALID_FLAG));
+            env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Ter(temINVALID_FLAG));
             env.close();
 
             // hybrid offer must have domainID
-            auto const offerSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
+            auto const offerSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
             env.close();
-            BEAST_EXPECT(checkOffer(env, bob_, offerSeq, XRP(10), USD(10), lsfHybrid, true));
+            BEAST_EXPECT(checkOffer(env, bob, offerSeq, XRP(10), USD(10), lsfHybrid, true));
         }
 
         // apply - domain offer can cross with hybrid
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            auto const bobOfferSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
+            auto const bobOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
             env.close();
 
-            BEAST_EXPECT(checkOffer(env, bob_, bobOfferSeq, XRP(10), USD(10), lsfHybrid, true));
-            BEAST_EXPECT(offerExists(env, bob_, bobOfferSeq));
-            BEAST_EXPECT(ownerCount(env, bob_) == 3);
+            BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), lsfHybrid, true));
+            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
+            BEAST_EXPECT(ownerCount(env, bob) == 3);
 
-            auto const aliceOfferSeq{env.seq(alice_)};
-            env(offer(alice_, USD(10), XRP(10)), Domain(domainID));
+            auto const aliceOfferSeq{env.seq(alice)};
+            env(offer(alice, USD(10), XRP(10)), Domain(domainID));
             env.close();
 
-            BEAST_EXPECT(!offerExists(env, alice_, aliceOfferSeq));
-            BEAST_EXPECT(!offerExists(env, bob_, bobOfferSeq));
-            BEAST_EXPECT(ownerCount(env, alice_) == 2);
+            BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq));
+            BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq));
+            BEAST_EXPECT(ownerCount(env, alice) == 2);
         }
 
         // apply - open offer can cross with hybrid
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            auto const bobOfferSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
+            auto const bobOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
             env.close();
 
-            BEAST_EXPECT(offerExists(env, bob_, bobOfferSeq));
-            BEAST_EXPECT(ownerCount(env, bob_) == 3);
-            BEAST_EXPECT(checkOffer(env, bob_, bobOfferSeq, XRP(10), USD(10), lsfHybrid, true));
+            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
+            BEAST_EXPECT(ownerCount(env, bob) == 3);
+            BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), lsfHybrid, true));
 
-            auto const aliceOfferSeq{env.seq(alice_)};
-            env(offer(alice_, USD(10), XRP(10)));
+            auto const aliceOfferSeq{env.seq(alice)};
+            env(offer(alice, USD(10), XRP(10)));
             env.close();
 
-            BEAST_EXPECT(!offerExists(env, alice_, aliceOfferSeq));
-            BEAST_EXPECT(!offerExists(env, bob_, bobOfferSeq));
-            BEAST_EXPECT(ownerCount(env, alice_) == 2);
+            BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq));
+            BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq));
+            BEAST_EXPECT(ownerCount(env, alice) == 2);
         }
 
         // apply - by default, hybrid offer tries to cross with offers in the
         // domain book
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            auto const bobOfferSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            auto const bobOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
             env.close();
 
-            BEAST_EXPECT(checkOffer(env, bob_, bobOfferSeq, XRP(10), USD(10), 0, true));
-            BEAST_EXPECT(ownerCount(env, bob_) == 3);
+            BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
+            BEAST_EXPECT(ownerCount(env, bob) == 3);
 
             // hybrid offer auto crosses with domain offer
-            auto const aliceOfferSeq{env.seq(alice_)};
-            env(offer(alice_, USD(10), XRP(10)), Domain(domainID), Txflags(tfHybrid));
+            auto const aliceOfferSeq{env.seq(alice)};
+            env(offer(alice, USD(10), XRP(10)), Domain(domainID), Txflags(tfHybrid));
             env.close();
 
-            BEAST_EXPECT(!offerExists(env, alice_, aliceOfferSeq));
-            BEAST_EXPECT(!offerExists(env, bob_, bobOfferSeq));
-            BEAST_EXPECT(ownerCount(env, alice_) == 2);
+            BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq));
+            BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq));
+            BEAST_EXPECT(ownerCount(env, alice) == 2);
         }
 
         // apply - hybrid offer does not automatically cross with open offers
         // because by default, it only tries to cross domain offers
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            auto const bobOfferSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)));
+            auto const bobOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)));
             env.close();
 
-            BEAST_EXPECT(checkOffer(env, bob_, bobOfferSeq, XRP(10), USD(10), 0, false));
-            BEAST_EXPECT(ownerCount(env, bob_) == 3);
+            BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, false));
+            BEAST_EXPECT(ownerCount(env, bob) == 3);
 
             // hybrid offer auto crosses with domain offer
-            auto const aliceOfferSeq{env.seq(alice_)};
-            env(offer(alice_, USD(10), XRP(10)), Domain(domainID), Txflags(tfHybrid));
+            auto const aliceOfferSeq{env.seq(alice)};
+            env(offer(alice, USD(10), XRP(10)), Domain(domainID), Txflags(tfHybrid));
             env.close();
 
-            BEAST_EXPECT(offerExists(env, alice_, aliceOfferSeq));
-            BEAST_EXPECT(offerExists(env, bob_, bobOfferSeq));
-            BEAST_EXPECT(checkOffer(env, bob_, bobOfferSeq, XRP(10), USD(10), 0, false));
-            BEAST_EXPECT(checkOffer(env, alice_, aliceOfferSeq, USD(10), XRP(10), lsfHybrid, true));
-            BEAST_EXPECT(ownerCount(env, alice_) == 3);
+            BEAST_EXPECT(offerExists(env, alice, aliceOfferSeq));
+            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
+            BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, false));
+            BEAST_EXPECT(checkOffer(env, alice, aliceOfferSeq, USD(10), XRP(10), lsfHybrid, true));
+            BEAST_EXPECT(ownerCount(env, alice) == 3);
         }
     }
 
@@ -1092,58 +1206,97 @@ class PermissionedDEX_test : public beast::unit_test::Suite
     {
         testcase("Hybrid invalid offer");
 
-        // bob_ has a hybrid offer and then he is removed from domain.
-        // in this case, the hybrid offer will be considered as unfunded even in
-        // a regular payment
+        // bob has a hybrid offer and then he is removed from the domain.
+        // Domain payments must not consume the offer; regular open-book
+        // payments follow the fixCleanup3_3_0 behavior checked below.
         Env env(*this, features);
-        auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+        auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
             PermissionedDEX(env);
 
-        auto const hybridOfferSeq{env.seq(bob_)};
-        env(offer(bob_, XRP(50), USD(50)), Txflags(tfHybrid), Domain(domainID));
+        auto const hybridOfferSeq{env.seq(bob)};
+        env(offer(bob, XRP(50), USD(50)), Txflags(tfHybrid), Domain(domainID));
         env.close();
 
-        // remove bob_ from domain
-        env(credentials::deleteCred(domainOwner, bob_, domainOwner, credType));
+        // remove bob from domain
+        env(credentials::deleteCred(domainOwner, bob, domainOwner, credType));
         env.close();
 
-        // bob_'s hybrid offer is unfunded and can not be consumed in a domain
+        // bob's hybrid offer is unfunded and can not be consumed in a domain
         // payment
-        env(pay(alice_, carol_, USD(5)),
+        env(pay(alice, carol, USD(5)),
             Path(~USD),
             Sendmax(XRP(5)),
             Domain(domainID),
             Ter(tecPATH_PARTIAL));
         env.close();
-        BEAST_EXPECT(checkOffer(env, bob_, hybridOfferSeq, XRP(50), USD(50), lsfHybrid, true));
+        BEAST_EXPECT(checkOffer(env, bob, hybridOfferSeq, XRP(50), USD(50), lsfHybrid, true));
 
-        // bob_'s unfunded hybrid offer can't be consumed even with a regular
-        // payment
-        env(pay(alice_, carol_, USD(5)), Path(~USD), Sendmax(XRP(5)), Ter(tecPATH_PARTIAL));
-        env.close();
-        BEAST_EXPECT(checkOffer(env, bob_, hybridOfferSeq, XRP(50), USD(50), lsfHybrid, true));
+        if (features[fixCleanup3_3_0])
+        {
+            // Post-fixCleanup3_3_0: hybrid offer can still be consumed via a regular
+            // open-book payment even though the domain credential was revoked.
+            auto const carolBalBefore = env.balance(carol, USD);
+            env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)));
+            env.close();
+            BEAST_EXPECT(env.balance(carol, USD) - carolBalBefore == USD(5));
+            BEAST_EXPECT(checkOffer(env, bob, hybridOfferSeq, XRP(45), USD(45), lsfHybrid, true));
 
-        // create a regular offer
-        auto const regularOfferSeq{env.seq(bob_)};
-        env(offer(bob_, XRP(10), USD(10)));
-        env.close();
-        BEAST_EXPECT(offerExists(env, bob_, regularOfferSeq));
-        BEAST_EXPECT(checkOffer(env, bob_, regularOfferSeq, XRP(10), USD(10)));
+            // create a regular offer alongside the hybrid one
+            auto const regularOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)));
+            env.close();
+            BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10)));
 
-        auto const sleHybridOffer = env.le(keylet::offer(bob_.id(), hybridOfferSeq));
-        BEAST_EXPECT(sleHybridOffer);
-        auto const openDir =
-            sleHybridOffer->getFieldArray(sfAdditionalBooks)[0].getFieldH256(sfBookDirectory);
-        BEAST_EXPECT(checkDirectorySize(env, openDir, 2));
+            auto const sleHybridOffer = env.le(keylet::offer(bob.id(), hybridOfferSeq));
+            if (!BEAST_EXPECT(sleHybridOffer))
+                return;
+            auto const openDir =
+                sleHybridOffer->getFieldArray(sfAdditionalBooks)[0].getFieldH256(sfBookDirectory);
+            // both offers are in the open book directory
+            BEAST_EXPECT(checkDirectorySize(env, openDir, 2));
 
-        // this normal payment should consume the regular offer and remove the
-        // unfunded hybrid offer
-        env(pay(alice_, carol_, USD(5)), Path(~USD), Sendmax(XRP(5)));
-        env.close();
+            // A regular payment crosses the hybrid offer first (FIFO, older
+            // offer), then stops; the regular offer is untouched.
+            env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)));
+            env.close();
 
-        BEAST_EXPECT(!offerExists(env, bob_, hybridOfferSeq));
-        BEAST_EXPECT(checkOffer(env, bob_, regularOfferSeq, XRP(5), USD(5)));
-        BEAST_EXPECT(checkDirectorySize(env, openDir, 1));
+            BEAST_EXPECT(checkOffer(env, bob, hybridOfferSeq, XRP(40), USD(40), lsfHybrid, true));
+            BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10)));
+            BEAST_EXPECT(checkDirectorySize(env, openDir, 2));
+        }
+        else
+        {
+            // Pre-fixCleanup3_3_0: the open-book traversal
+            // also runs the offerInDomain eviction check, so the hybrid offer
+            // is treated as unfunded and the regular payment fails.
+            env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)), Ter(tecPATH_PARTIAL));
+            env.close();
+            BEAST_EXPECT(checkOffer(env, bob, hybridOfferSeq, XRP(50), USD(50), lsfHybrid, true));
+
+            // create a regular offer
+            auto const regularOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)));
+            env.close();
+            BEAST_EXPECT(offerExists(env, bob, regularOfferSeq));
+            BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10)));
+
+            auto const sleHybridOffer = env.le(keylet::offer(bob.id(), hybridOfferSeq));
+            if (!BEAST_EXPECT(sleHybridOffer))
+                return;
+            auto const openDir =
+                sleHybridOffer->getFieldArray(sfAdditionalBooks)[0].getFieldH256(sfBookDirectory);
+            BEAST_EXPECT(checkDirectorySize(env, openDir, 2));
+
+            // This payment crosses the regular offer and permanently evicts the
+            // hybrid offer from the open book (since the payment succeeds, the
+            // sandbox, including the hybrid eviction, is committed).
+            env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)));
+            env.close();
+
+            BEAST_EXPECT(!offerExists(env, bob, hybridOfferSeq));
+            BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(5), USD(5)));
+            BEAST_EXPECT(checkDirectorySize(env, openDir, 1));
+        }
     }
 
     void
@@ -1154,29 +1307,29 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // both non domain and domain payments can consume hybrid offer
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            auto const hybridOfferSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
+            auto const hybridOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
             env.close();
 
-            env(pay(alice_, carol_, USD(5)), Path(~USD), Sendmax(XRP(5)), Domain(domainID));
+            env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)), Domain(domainID));
             env.close();
-            BEAST_EXPECT(checkOffer(env, bob_, hybridOfferSeq, XRP(5), USD(5), lsfHybrid, true));
+            BEAST_EXPECT(checkOffer(env, bob, hybridOfferSeq, XRP(5), USD(5), lsfHybrid, true));
 
-            // hybrid offer can't be consumed since bob_ is not in domain anymore
-            env(pay(alice_, carol_, USD(5)), Path(~USD), Sendmax(XRP(5)));
+            // hybrid offer can't be consumed since bob is not in domain anymore
+            env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)));
             env.close();
 
-            BEAST_EXPECT(!offerExists(env, bob_, hybridOfferSeq));
+            BEAST_EXPECT(!offerExists(env, bob, hybridOfferSeq));
         }
 
         // someone from another domain can't cross hybrid if they specified
         // wrong domainID
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
             // Fund accounts
@@ -1186,7 +1339,8 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
 
             auto const badCredType = "badCred";
-            pdomain::Credentials const credentials{{badDomainOwner, badCredType}};
+            pdomain::Credentials const credentials{
+                {.issuer = badDomainOwner, .credType = badCredType}};
             env(pdomain::setTx(badDomainOwner, credentials));
 
             auto objects = pdomain::getObjects(badDomainOwner, env);
@@ -1197,8 +1351,8 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env(credentials::accept(devin, badDomainOwner, badCredType));
             env.close();
 
-            auto const hybridOfferSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
+            auto const hybridOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
             env.close();
 
             // other domains can't consume the offer
@@ -1208,107 +1362,197 @@ class PermissionedDEX_test : public beast::unit_test::Suite
                 Domain(badDomainID),
                 Ter(tecPATH_DRY));
             env.close();
-            BEAST_EXPECT(checkOffer(env, bob_, hybridOfferSeq, XRP(10), USD(10), lsfHybrid, true));
+            BEAST_EXPECT(checkOffer(env, bob, hybridOfferSeq, XRP(10), USD(10), lsfHybrid, true));
 
-            env(pay(alice_, carol_, USD(5)), Path(~USD), Sendmax(XRP(5)), Domain(domainID));
+            env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)), Domain(domainID));
             env.close();
-            BEAST_EXPECT(checkOffer(env, bob_, hybridOfferSeq, XRP(5), USD(5), lsfHybrid, true));
+            BEAST_EXPECT(checkOffer(env, bob, hybridOfferSeq, XRP(5), USD(5), lsfHybrid, true));
 
-            // hybrid offer can't be consumed since bob_ is not in domain anymore
-            env(pay(alice_, carol_, USD(5)), Path(~USD), Sendmax(XRP(5)));
+            // hybrid offer can't be consumed since bob is not in domain anymore
+            env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)));
             env.close();
 
-            BEAST_EXPECT(!offerExists(env, bob_, hybridOfferSeq));
+            BEAST_EXPECT(!offerExists(env, bob, hybridOfferSeq));
         }
 
         // test domain payment consuming two offers w/ hybrid offer
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            auto const eur = gw_["EUR"];
-            env.trust(eur(1000), alice_);
+            auto const eur = gw["EUR"];
+            env.trust(eur(1000), alice);
             env.close();
-            env.trust(eur(1000), bob_);
+            env.trust(eur(1000), bob);
             env.close();
-            env.trust(eur(1000), carol_);
+            env.trust(eur(1000), carol);
             env.close();
-            env(pay(gw_, bob_, eur(100)));
+            env(pay(gw, bob, eur(100)));
             env.close();
 
-            auto const usdOfferSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)), Domain(domainID));
+            auto const usdOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
             env.close();
 
-            BEAST_EXPECT(checkOffer(env, bob_, usdOfferSeq, XRP(10), USD(10), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, true));
 
             // payment fail because there isn't eur offer
-            env(pay(alice_, carol_, eur(5)),
+            env(pay(alice, carol, eur(5)),
                 Path(~USD, ~eur),
                 Sendmax(XRP(5)),
                 Domain(domainID),
                 Ter(tecPATH_PARTIAL));
             env.close();
-            BEAST_EXPECT(checkOffer(env, bob_, usdOfferSeq, XRP(10), USD(10), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, true));
 
-            // bob_ creates a hybrid eur offer
-            auto const eurOfferSeq{env.seq(bob_)};
-            env(offer(bob_, USD(10), eur(10)), Domain(domainID), Txflags(tfHybrid));
+            // bob creates a hybrid eur offer
+            auto const eurOfferSeq{env.seq(bob)};
+            env(offer(bob, USD(10), eur(10)), Domain(domainID), Txflags(tfHybrid));
             env.close();
-            BEAST_EXPECT(checkOffer(env, bob_, eurOfferSeq, USD(10), eur(10), lsfHybrid, true));
+            BEAST_EXPECT(checkOffer(env, bob, eurOfferSeq, USD(10), eur(10), lsfHybrid, true));
 
-            // alice_ successfully consume two domain offers: xrp/usd and usd/eur
-            env(pay(alice_, carol_, eur(5)), Path(~USD, ~eur), Sendmax(XRP(5)), Domain(domainID));
+            // alice successfully consume two domain offers: xrp/usd and usd/eur
+            env(pay(alice, carol, eur(5)), Path(~USD, ~eur), Sendmax(XRP(5)), Domain(domainID));
             env.close();
 
-            BEAST_EXPECT(checkOffer(env, bob_, usdOfferSeq, XRP(5), USD(5), 0, true));
-            BEAST_EXPECT(checkOffer(env, bob_, eurOfferSeq, USD(5), eur(5), lsfHybrid, true));
+            BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(5), USD(5), 0, true));
+            BEAST_EXPECT(checkOffer(env, bob, eurOfferSeq, USD(5), eur(5), lsfHybrid, true));
         }
 
         // test regular payment using a regular offer and a hybrid offer
         {
             Env env(*this, features);
-            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
                 PermissionedDEX(env);
 
-            auto const eur = gw_["EUR"];
-            env.trust(eur(1000), alice_);
+            auto const eur = gw["EUR"];
+            env.trust(eur(1000), alice);
             env.close();
-            env.trust(eur(1000), bob_);
+            env.trust(eur(1000), bob);
             env.close();
-            env.trust(eur(1000), carol_);
+            env.trust(eur(1000), carol);
             env.close();
-            env(pay(gw_, bob_, eur(100)));
+            env(pay(gw, bob, eur(100)));
             env.close();
 
-            // bob_ creates a regular usd offer
-            auto const usdOfferSeq{env.seq(bob_)};
-            env(offer(bob_, XRP(10), USD(10)));
+            // bob creates a regular usd offer
+            auto const usdOfferSeq{env.seq(bob)};
+            env(offer(bob, XRP(10), USD(10)));
             env.close();
 
-            BEAST_EXPECT(checkOffer(env, bob_, usdOfferSeq, XRP(10), USD(10), 0, false));
+            BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, false));
 
-            // bob_ creates a hybrid eur offer
-            auto const eurOfferSeq{env.seq(bob_)};
-            env(offer(bob_, USD(10), eur(10)), Domain(domainID), Txflags(tfHybrid));
+            // bob creates a hybrid eur offer
+            auto const eurOfferSeq{env.seq(bob)};
+            env(offer(bob, USD(10), eur(10)), Domain(domainID), Txflags(tfHybrid));
             env.close();
-            BEAST_EXPECT(checkOffer(env, bob_, eurOfferSeq, USD(10), eur(10), lsfHybrid, true));
+            BEAST_EXPECT(checkOffer(env, bob, eurOfferSeq, USD(10), eur(10), lsfHybrid, true));
 
-            // alice_ successfully consume two offers: xrp/usd and usd/eur
-            env(pay(alice_, carol_, eur(5)), Path(~USD, ~eur), Sendmax(XRP(5)));
+            // alice successfully consume two offers: xrp/usd and usd/eur
+            env(pay(alice, carol, eur(5)), Path(~USD, ~eur), Sendmax(XRP(5)));
             env.close();
 
-            BEAST_EXPECT(checkOffer(env, bob_, usdOfferSeq, XRP(5), USD(5), 0, false));
-            BEAST_EXPECT(checkOffer(env, bob_, eurOfferSeq, USD(5), eur(5), lsfHybrid, true));
+            BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(5), USD(5), 0, false));
+            BEAST_EXPECT(checkOffer(env, bob, eurOfferSeq, USD(5), eur(5), lsfHybrid, true));
         }
     }
 
+    // Test that a hybrid offer remains crossable in the open book after the
+    // owner's domain credential expires. A domain payment after expiry should
+    // fail (domain book evicts the offer in its sandbox), but the open book
+    // remains usable.
+    void
+    testHybridOpenBookAfterCredentialExpiry(FeatureBitset features)
+    {
+        testcase("Hybrid open book after credential expiry");
+
+        Env env(*this, features);
+        auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
+            PermissionedDEX(env);
+
+        Account const devin("devin");
+        env.fund(XRP(100000), devin);
+        env.close();
+        env.trust(USD(1000), devin);
+        env.close();
+        env(pay(gw, devin, USD(100)));
+        env.close();
+
+        // Give devin a credential that expires far enough in the future to
+        // survive the setup env.close() calls.
+        auto jv = credentials::create(devin, domainOwner, credType);
+        uint32_t const t = env.current()->header().parentCloseTime.time_since_epoch().count();
+        jv[sfExpiration.jsonName] = t + 100;
+        env(jv);
+        env.close();
+        env(credentials::accept(devin, domainOwner, credType));
+        env.close();
+
+        // Devin creates a hybrid offer: sell USD(10) for XRP(10).
+        // The offer is placed in both the domain book and the open book.
+        auto const hybridOfferSeq{env.seq(devin)};
+        env(offer(devin, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
+        env.close();
+
+        BEAST_EXPECT(checkOffer(env, devin, hybridOfferSeq, XRP(10), USD(10), lsfHybrid, true));
+
+        // A non-domain open-book payment partially crosses the offer while
+        // devin's credential is still valid.
+        auto carolBalance = env.balance(carol, USD);
+        env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)));
+        env.close();
+        BEAST_EXPECT(env.balance(carol, USD) - carolBalance == USD(5));
+        BEAST_EXPECT(checkOffer(env, devin, hybridOfferSeq, XRP(5), USD(5), lsfHybrid, true));
+
+        // Advance time so that devin's credential expires.
+        env.close(std::chrono::seconds(100));
+
+        // Confirm devin can no longer create domain offers.
+        env(offer(devin, XRP(1), USD(1)), Domain(domainID), Ter(tecNO_PERMISSION));
+        env.close();
+
+        // The hybrid offer must still exist in the open book after expiry.
+        BEAST_EXPECT(offerExists(env, devin, hybridOfferSeq));
+
+        // A non-domain open-book payment must cross (not evict) the
+        // remaining portion of devin's hybrid offer.
+        carolBalance = env.balance(carol, USD);
+        env(pay(alice, carol, USD(2)), Path(~USD), Sendmax(XRP(2)));
+        env.close();
+
+        // Carol received USD; the offer was crossed, not evicted.
+        BEAST_EXPECT(env.balance(carol, USD) - carolBalance == USD(2));
+        // Offer still exists with 3 USD / 3 XRP remaining.
+        BEAST_EXPECT(checkOffer(env, devin, hybridOfferSeq, XRP(3), USD(3), lsfHybrid, true));
+
+        // A domain payment now fails because the domain book evicts devin's
+        // offer (his credential has expired).  The eviction is rolled back with
+        // the failed sandbox, so the offer is NOT permanently removed.
+        env(pay(alice, carol, USD(1)),
+            Path(~USD),
+            Sendmax(XRP(1)),
+            Domain(domainID),
+            Ter(tecPATH_PARTIAL));
+        env.close();
+
+        // Offer still intact in the open book; domain payment did not
+        // permanently delete it.
+        BEAST_EXPECT(checkOffer(env, devin, hybridOfferSeq, XRP(3), USD(3), lsfHybrid, true));
+
+        // The open book can still fully consume the remaining portion.
+        carolBalance = env.balance(carol, USD);
+        env(pay(alice, carol, USD(3)), Path(~USD), Sendmax(XRP(3)));
+        env.close();
+        BEAST_EXPECT(env.balance(carol, USD) - carolBalance == USD(3));
+        BEAST_EXPECT(!offerExists(env, devin, hybridOfferSeq));
+    }
+
     void
     testHybridOfferDirectories(FeatureBitset features)
     {
         Env env(*this, features);
-        auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+        auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
             PermissionedDEX(env);
 
         std::vector offerSeqs;
@@ -1324,12 +1568,12 @@ class PermissionedDEX_test : public beast::unit_test::Suite
 
         for (size_t i = 1; i <= dirCnt; i++)
         {
-            auto const bobOfferSeq{env.seq(bob_)};
+            auto const bobOfferSeq{env.seq(bob)};
             offerSeqs.emplace_back(bobOfferSeq);
-            env(offer(bob_, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
+            env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
             env.close();
 
-            auto const sleOffer = env.le(keylet::offer(bob_.id(), bobOfferSeq));
+            auto const sleOffer = env.le(keylet::offer(bob.id(), bobOfferSeq));
             BEAST_EXPECT(sleOffer);
             BEAST_EXPECT(sleOffer->getFieldH256(sfBookDirectory) == domainDir);
             BEAST_EXPECT(sleOffer->getFieldArray(sfAdditionalBooks).size() == 1);
@@ -1337,17 +1581,17 @@ class PermissionedDEX_test : public beast::unit_test::Suite
                 sleOffer->getFieldArray(sfAdditionalBooks)[0].getFieldH256(sfBookDirectory) ==
                 openDir);
 
-            BEAST_EXPECT(checkOffer(env, bob_, bobOfferSeq, XRP(10), USD(10), lsfHybrid, true));
+            BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), lsfHybrid, true));
             BEAST_EXPECT(checkDirectorySize(env, domainDir, i));
             BEAST_EXPECT(checkDirectorySize(env, openDir, i));
         }
 
         for (auto const offerSeq : offerSeqs)
         {
-            env(offerCancel(bob_, offerSeq));
+            env(offerCancel(bob, offerSeq));
             env.close();
             dirCnt--;
-            BEAST_EXPECT(!offerExists(env, bob_, offerSeq));
+            BEAST_EXPECT(!offerExists(env, bob, offerSeq));
             BEAST_EXPECT(checkDirectorySize(env, domainDir, dirCnt));
             BEAST_EXPECT(checkDirectorySize(env, openDir, dirCnt));
         }
@@ -1359,34 +1603,34 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         testcase("Auto bridge");
 
         Env env(*this, features);
-        auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+        auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
             PermissionedDEX(env);
-        auto const eur = gw_["EUR"];
+        auto const eur = gw["EUR"];
 
-        for (auto const& account : {alice_, bob_, carol_})
+        for (auto const& account : {alice, bob, carol})
         {
             env(trust(account, eur(10000)));
             env.close();
         }
 
-        env(pay(gw_, carol_, eur(1)));
+        env(pay(gw, carol, eur(1)));
         env.close();
 
-        auto const aliceOfferSeq{env.seq(alice_)};
-        auto const bobOfferSeq{env.seq(bob_)};
-        env(offer(alice_, XRP(100), USD(1)), Domain(domainID));
-        env(offer(bob_, eur(1), XRP(100)), Domain(domainID));
+        auto const aliceOfferSeq{env.seq(alice)};
+        auto const bobOfferSeq{env.seq(bob)};
+        env(offer(alice, XRP(100), USD(1)), Domain(domainID));
+        env(offer(bob, eur(1), XRP(100)), Domain(domainID));
         env.close();
 
-        // carol_'s offer should cross bob_ and alice_'s offers due to auto
+        // carol's offer should cross bob and alice's offers due to auto
         // bridging
-        auto const carolOfferSeq{env.seq(carol_)};
-        env(offer(carol_, USD(1), eur(1)), Domain(domainID));
+        auto const carolOfferSeq{env.seq(carol)};
+        env(offer(carol, USD(1), eur(1)), Domain(domainID));
         env.close();
 
-        BEAST_EXPECT(!offerExists(env, bob_, aliceOfferSeq));
-        BEAST_EXPECT(!offerExists(env, bob_, bobOfferSeq));
-        BEAST_EXPECT(!offerExists(env, bob_, carolOfferSeq));
+        BEAST_EXPECT(!offerExists(env, bob, aliceOfferSeq));
+        BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq));
+        BEAST_EXPECT(!offerExists(env, bob, carolOfferSeq));
     }
 
     void
@@ -1455,6 +1699,311 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testHybridOfferCrossingQuality(FeatureBitset features)
+    {
+        bool const fixEnabled = features[fixCleanup3_2_0];
+        testcase << "Hybrid offer crossing quality"
+                 << (fixEnabled ? " (fixCleanup3_2_0)" : " (pre-fix)");
+
+        // Partially-crossed hybrid offer should have consistent quality
+        // across both book directories.
+        //
+        // Steps:
+        //   - Bob places a hybrid offer.
+        //   - Alice places an opposing hybrid offer that partially crosses.
+        //
+        // Verify:
+        //   - Domain-book key quality == its sfExchangeRate.
+        //   - Post-fix: open-book key quality == domain-book key quality.
+        //   - Pre-fix: open-book key quality != domain-book key quality
+        //     (key used post-crossing rate, sfExchangeRate used pre-crossing).
+
+        Env env(*this, features);
+        auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+            PermissionedDEX(env);
+
+        // Bob places a hybrid offer: TakerPays = XRP(100), TakerGets = USD(40)
+        auto const bobOfferSeq{env.seq(bob_)};
+        env(offer(bob_, XRP(100), USD(40)), Txflags(tfHybrid), Domain(domainID));
+        env.close();
+        BEAST_EXPECT(offerExists(env, bob_, bobOfferSeq));
+
+        // Alice places a hybrid offer in the opposite direction that
+        // partially crosses Bob's offer.
+        // Alice: TakerPays = USD(100), TakerGets = XRP(300) (rate = 3 XRP/USD)
+        // Bob's offer is at a better rate (2.5 XRP/USD) so crossing occurs.
+        auto const aliceOfferSeq{env.seq(alice_)};
+        env(offer(alice_, USD(100), XRP(300)), Txflags(tfHybrid), Domain(domainID));
+        env.close();
+
+        // After crossing, Alice's remaining offer should be placed.
+        auto const sle = env.le(keylet::offer(alice_.id(), aliceOfferSeq));
+        BEAST_EXPECT(sle);
+        BEAST_EXPECT(sle->isFieldPresent(sfAdditionalBooks));
+        BEAST_EXPECT(sle->getFieldArray(sfAdditionalBooks).size() == 1);
+
+        auto const domainDirKey = sle->getFieldH256(sfBookDirectory);
+        auto const openDirKey =
+            sle->getFieldArray(sfAdditionalBooks)[0].getFieldH256(sfBookDirectory);
+
+        auto const domainQuality = getQuality(domainDirKey);
+        auto const openQuality = getQuality(openDirKey);
+
+        // Read the directory SLEs and check sfExchangeRate vs key quality.
+        auto const domainDirSle = env.le(Keylet(ltDIR_NODE, domainDirKey));
+        auto const openDirSle = env.le(Keylet(ltDIR_NODE, openDirKey));
+        BEAST_EXPECT(domainDirSle);
+        BEAST_EXPECT(openDirSle);
+
+        auto const domainExRate = domainDirSle->getFieldU64(sfExchangeRate);
+        auto const openExRate = openDirSle->getFieldU64(sfExchangeRate);
+        auto const preCrossingQuality = std::uint64_t{5623825668291712342ULL};
+        auto const postCrossingQuality = std::uint64_t{5623825668291712341ULL};
+
+        // Domain directory: sfExchangeRate should always match key quality
+        // (both use the pre-crossing rate). Correct behavior.
+        BEAST_EXPECT(domainQuality == preCrossingQuality);
+        BEAST_EXPECT(domainExRate == preCrossingQuality);
+        BEAST_EXPECT(domainExRate == domainQuality);
+
+        if (fixEnabled)
+        {
+            // Correct behavior: both directory keys use the pre-crossing rate.
+            BEAST_EXPECT(openQuality == preCrossingQuality);
+            BEAST_EXPECT(domainQuality == openQuality);
+
+            // sfExchangeRate matches key quality on both directories.
+            BEAST_EXPECT(openExRate == preCrossingQuality);
+            BEAST_EXPECT(openExRate == openQuality);
+        }
+        else
+        {
+            // Wrong legacy behavior: the open-book directory key uses the
+            // post-crossing rate instead of the domain-book rate.
+            BEAST_EXPECT(openQuality == postCrossingQuality);
+            BEAST_EXPECT(domainQuality != openQuality);
+
+            // The open-book sfExchangeRate still uses the pre-crossing rate,
+            // so it no longer matches the actual quality encoded in the
+            // open-book directory key.
+            BEAST_EXPECT(openExRate == preCrossingQuality);
+            BEAST_EXPECT(openExRate != openQuality);
+            BEAST_EXPECT(openExRate == domainQuality);
+        }
+    }
+
+    void
+    testBookExchangeRateFix(FeatureBitset features)
+    {
+        testcase("LedgerStateFix BookExchangeRate");
+
+        // Use the pre-fix path to create a hybrid offer with a mismatched
+        // sfExchangeRate, then apply LedgerStateFix to correct it.
+        //
+        // Steps:
+        //   - Create a partially-crossed hybrid offer (pre-fixCleanup3_2_0)
+        //     so the open-book directory has wrong sfExchangeRate.
+        //   - Re-enable fixCleanup3_2_0 and submit a LedgerStateFix to
+        //     repair the open-book directory's sfExchangeRate.
+        //
+        // Verify:
+        //   - Before fix: sfExchangeRate != getQuality(key).
+        //   - After fix: sfExchangeRate == getQuality(key).
+
+        {
+            // Amendment gate: BookExchangeRate fixes require fixCleanup3_2_0.
+            Env env(*this, features - fixCleanup3_2_0);
+            Account const carol{"carol"};
+
+            env.fund(XRP(1000), carol);
+            env.close();
+
+            env(ledgerStateFix::bookExchangeRate(carol, uint256{1}), Ter(temDISABLED));
+        }
+
+        {
+            // Preflight check: BookExchangeRate fixes only accept their
+            // required fix-specific field.
+            Env env(*this, features);
+            Account const carol{"carol"};
+
+            env.fund(XRP(1000), carol);
+            env.close();
+
+            // BookExchangeRate fixes require sfBookDirectory.
+            auto missingBookDirectory = ledgerStateFix::bookExchangeRate(carol, uint256{1});
+            missingBookDirectory.removeMember(sfBookDirectory.jsonName);
+            env(missingBookDirectory, Ter(temINVALID));
+
+            // BookExchangeRate fixes reject fields that belong to other
+            // LedgerStateFix types.
+            auto extraOwner = ledgerStateFix::bookExchangeRate(carol, uint256{1});
+            extraOwner[sfOwner.jsonName] = carol.human();
+            env(extraOwner, Ter(temINVALID));
+        }
+
+        {
+            Env env(*this, features);
+            auto const setup = PermissionedDEX(env);
+            auto const fixFee = drops(env.current()->fees().increment);
+
+            {
+                // Preclaim check: the target directory must exist.
+                env(ledgerStateFix::bookExchangeRate(setup.carol, uint256{1}),
+                    Fee(fixFee),
+                    Ter(tecOBJECT_NOT_FOUND));
+            }
+
+            {
+                // Preclaim check: the target directory must be a book root
+                // page. Owner directories are ltDIR_NODE entries, but they do
+                // not carry sfExchangeRate.
+                auto const ownerDir = keylet::ownerDir(setup.bob.id());
+                auto const ownerDirSle = env.le(ownerDir);
+                BEAST_EXPECT(ownerDirSle);
+                BEAST_EXPECT(!ownerDirSle->isFieldPresent(sfExchangeRate));
+
+                env(ledgerStateFix::bookExchangeRate(setup.carol, ownerDir.key),
+                    Fee(fixFee),
+                    Ter(tecNO_PERMISSION));
+            }
+
+            {
+                // Preclaim check: a correct sfExchangeRate leaves nothing to
+                // repair.
+                auto const bobOfferSeq{env.seq(setup.bob)};
+                env(offer(setup.bob, XRP(100), setup.usd(40)));
+                env.close();
+
+                auto const sle = env.le(keylet::offer(setup.bob.id(), bobOfferSeq));
+                BEAST_EXPECT(sle);
+
+                auto const dirKey = sle->getFieldH256(sfBookDirectory);
+                {
+                    auto const dirSle = env.le(Keylet(ltDIR_NODE, dirKey));
+                    BEAST_EXPECT(dirSle);
+                    auto const exchangeRate = dirSle->getFieldU64(sfExchangeRate);
+                    auto const quality = getQuality(dirKey);
+                    BEAST_EXPECT(exchangeRate == quality);
+                }
+
+                env(ledgerStateFix::bookExchangeRate(setup.carol, dirKey),
+                    Fee(fixFee),
+                    Ter(tecNO_PERMISSION));
+            }
+        }
+
+        {
+            // Repair path: start without fixCleanup3_2_0 to produce the
+            // mismatch, then enable the amendment and fix it.
+            Env env(*this, features - fixCleanup3_2_0);
+            auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
+                PermissionedDEX(env);
+
+            // Bob places a hybrid offer.
+            env(offer(bob_, XRP(100), USD(40)), Txflags(tfHybrid), Domain(domainID));
+            env.close();
+
+            // Alice partially crosses Bob.
+            auto const aliceOfferSeq{env.seq(alice_)};
+            env(offer(alice_, USD(100), XRP(300)), Txflags(tfHybrid), Domain(domainID));
+            env.close();
+
+            auto const sle = env.le(keylet::offer(alice_.id(), aliceOfferSeq));
+            BEAST_EXPECT(sle);
+
+            auto const openDirKey =
+                sle->getFieldArray(sfAdditionalBooks)[0].getFieldH256(sfBookDirectory);
+
+            auto const preCrossingQuality = std::uint64_t{5623825668291712342ULL};
+            auto const postCrossingQuality = std::uint64_t{5623825668291712341ULL};
+
+            // Confirm mismatch exists.
+            {
+                auto const dirSle = env.le(Keylet(ltDIR_NODE, openDirKey));
+                BEAST_EXPECT(dirSle);
+                auto const exchangeRate = dirSle->getFieldU64(sfExchangeRate);
+                auto const quality = getQuality(openDirKey);
+                BEAST_EXPECT(exchangeRate == preCrossingQuality);
+                BEAST_EXPECT(quality == postCrossingQuality);
+                BEAST_EXPECT(exchangeRate != quality);
+            }
+
+            // Enable fixCleanup3_2_0 and apply the LedgerStateFix.
+            env.enableFeature(fixCleanup3_2_0);
+            env.close();
+
+            auto const fixFee = drops(env.current()->fees().increment);
+            env(ledgerStateFix::bookExchangeRate(carol_, openDirKey), Fee(fixFee));
+            env.close();
+
+            // Confirm sfExchangeRate now matches the key quality.
+            {
+                auto const dirSle = env.le(Keylet(ltDIR_NODE, openDirKey));
+                BEAST_EXPECT(dirSle);
+                auto const exchangeRate = dirSle->getFieldU64(sfExchangeRate);
+                auto const quality = getQuality(openDirKey);
+                BEAST_EXPECT(exchangeRate == postCrossingQuality);
+                BEAST_EXPECT(quality == postCrossingQuality);
+                BEAST_EXPECT(exchangeRate == quality);
+            }
+
+            // Submitting again should fail — nothing to fix.
+            env(ledgerStateFix::bookExchangeRate(carol_, openDirKey),
+                Fee(fixFee),
+                Ter(tecNO_PERMISSION));
+        }
+    }
+
+    void
+    testCancelRegularOfferWithDomainCreate(FeatureBitset features)
+    {
+        bool const fixEnabled = features[fixCleanup3_2_0];
+
+        testcase << "Cancel regular offer via domain OfferCreate"
+                 << (fixEnabled ? " (fixCleanup3_2_0 enabled)" : " (fixCleanup3_2_0 disabled)");
+
+        // An OfferCreate with sfDomainID and sfOfferSequence pointing to
+        // the user's own non-domain offer should atomically cancel the
+        // regular offer and place the new domain offer.
+        //
+        // Pre-fixCleanup3_2_0: ValidPermissionedDEX flagged the deleted
+        // regular offer, so the transaction failed with tecINVARIANT_FAILED.
+        // Post-fixCleanup3_2_0: the invariant ignores deletions and the
+        // transaction succeeds.
+
+        Env env(*this, features);
+        auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
+            PermissionedDEX(env);
+
+        auto const regularSeq = env.seq(bob);
+        env(offer(bob, XRP(10), USD(10)));
+        env.close();
+        BEAST_EXPECT(checkOffer(env, bob, regularSeq, XRP(10), USD(10), 0, false));
+
+        auto const domainSeq = env.seq(bob);
+        if (fixEnabled)
+        {
+            env(offer(bob, XRP(20), USD(20)),
+                Domain(domainID),
+                Json(jss::OfferSequence, regularSeq));
+            env.close();
+            BEAST_EXPECT(!offerExists(env, bob, regularSeq));
+            BEAST_EXPECT(checkOffer(env, bob, domainSeq, XRP(20), USD(20), 0, true));
+        }
+        else
+        {
+            env(offer(bob, XRP(20), USD(20)),
+                Domain(domainID),
+                Json(jss::OfferSequence, regularSeq),
+                Ter(tecINVARIANT_FAILED));
+            env.close();
+            BEAST_EXPECT(offerExists(env, bob, regularSeq));
+            BEAST_EXPECT(!offerExists(env, bob, domainSeq));
+        }
+    }
+
 public:
     void
     run() override
@@ -1463,21 +2012,35 @@ public:
 
         // Test domain offer (w/o hybrid)
         testOfferCreate(all);
+        testOfferCreate(all - fixCleanup3_2_0);
         testPayment(all);
+        testPayment(all - fixCleanup3_2_0);
         testBookStep(all);
         testRippling(all);
         testOfferTokenIssuerInDomain(all);
         testRemoveUnfundedOffer(all);
         testAmmNotUsed(all);
+        testAmmQualityNotLeaked(all);
+        testAmmQualityNotLeaked(all - fixCleanup3_3_0);
         testAutoBridge(all);
 
         // Test hybrid offers
         testHybridOfferCreate(all);
         testHybridBookStep(all);
+        testHybridInvalidOffer(all - fixCleanup3_3_0);
         testHybridInvalidOffer(all);
+        testHybridOpenBookAfterCredentialExpiry(all);
         testHybridOfferDirectories(all);
         testHybridMalformedOffer(all);
         testHybridMalformedOffer(all - fixCleanup3_1_3);
+        testHybridOfferCrossingQuality(all);
+        testHybridOfferCrossingQuality(all - fixCleanup3_2_0);
+        testBookExchangeRateFix(all);
+
+        // Cancelling a regular offer in a domain OfferCreate is allowed
+        // only after fixCleanup3_2_0.
+        testCancelRegularOfferWithDomainCreate(all);
+        testCancelRegularOfferWithDomainCreate(all - fixCleanup3_2_0);
     }
 };
 
diff --git a/src/test/app/PermissionedDomains_test.cpp b/src/test/app/PermissionedDomains_test.cpp
index 0857a4bdef..2cffc18682 100644
--- a/src/test/app/PermissionedDomains_test.cpp
+++ b/src/test/app/PermissionedDomains_test.cpp
@@ -8,18 +8,21 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -62,7 +65,7 @@ class PermissionedDomains_test : public beast::unit_test::Suite
         Account const alice("alice");
         Env env(*this, features);
         env.fund(XRP(1000), alice);
-        pdomain::Credentials const credentials{{alice, "first credential"}};
+        pdomain::Credentials const credentials{{.issuer = alice, .credType = "first credential"}};
         env(pdomain::setTx(alice, credentials));
         BEAST_EXPECT(env.ownerCount(alice) == 1);
         auto objects = pdomain::getObjects(alice, env);
@@ -84,7 +87,7 @@ class PermissionedDomains_test : public beast::unit_test::Suite
         Account const alice("alice");
         Env env(*this, amendments);
         env.fund(XRP(1000), alice);
-        pdomain::Credentials const credentials{{alice, "first credential"}};
+        pdomain::Credentials const credentials{{.issuer = alice, .credType = "first credential"}};
         env(pdomain::setTx(alice, credentials), Ter(temDISABLED));
     }
 
@@ -96,7 +99,7 @@ class PermissionedDomains_test : public beast::unit_test::Suite
         Account const alice("alice");
         Env env(*this, testableAmendments() - featurePermissionedDomains);
         env.fund(XRP(1000), alice);
-        pdomain::Credentials const credentials{{alice, "first credential"}};
+        pdomain::Credentials const credentials{{.issuer = alice, .credType = "first credential"}};
         env(pdomain::setTx(alice, credentials), Ter(temDISABLED));
         env(pdomain::deleteTx(alice, uint256(75)), Ter(temDISABLED));
     }
@@ -124,40 +127,40 @@ class PermissionedDomains_test : public beast::unit_test::Suite
 
         // Test 11 credentials.
         pdomain::Credentials const credentials11{
-            {alice2, "credential1"},
-            {alice3, "credential2"},
-            {alice4, "credential3"},
-            {alice5, "credential4"},
-            {alice6, "credential5"},
-            {alice7, "credential6"},
-            {alice8, "credential7"},
-            {alice9, "credential8"},
-            {alice10, "credential9"},
-            {alice11, "credential10"},
-            {alice12, "credential11"}};
+            {.issuer = alice2, .credType = "credential1"},
+            {.issuer = alice3, .credType = "credential2"},
+            {.issuer = alice4, .credType = "credential3"},
+            {.issuer = alice5, .credType = "credential4"},
+            {.issuer = alice6, .credType = "credential5"},
+            {.issuer = alice7, .credType = "credential6"},
+            {.issuer = alice8, .credType = "credential7"},
+            {.issuer = alice9, .credType = "credential8"},
+            {.issuer = alice10, .credType = "credential9"},
+            {.issuer = alice11, .credType = "credential10"},
+            {.issuer = alice12, .credType = "credential11"}};
         BEAST_EXPECT(credentials11.size() == kMaxPermissionedDomainCredentialsArraySize + 1);
         env(pdomain::setTx(account, credentials11, domain), Ter(temARRAY_TOO_LARGE));
 
         // Test credentials including non-existent issuer.
         Account const nobody("nobody");
         pdomain::Credentials const credentialsNon{
-            {alice2, "credential1"},
-            {alice3, "credential2"},
-            {alice4, "credential3"},
-            {nobody, "credential4"},
-            {alice5, "credential5"},
-            {alice6, "credential6"},
-            {alice7, "credential7"}};
+            {.issuer = alice2, .credType = "credential1"},
+            {.issuer = alice3, .credType = "credential2"},
+            {.issuer = alice4, .credType = "credential3"},
+            {.issuer = nobody, .credType = "credential4"},
+            {.issuer = alice5, .credType = "credential5"},
+            {.issuer = alice6, .credType = "credential6"},
+            {.issuer = alice7, .credType = "credential7"}};
         env(pdomain::setTx(account, credentialsNon, domain), Ter(tecNO_ISSUER));
 
         // Test bad fee
         env(pdomain::setTx(account, credentials11, domain), Fee(1, true), Ter(temBAD_FEE));
 
         pdomain::Credentials const credentials4{
-            {alice2, "credential1"},
-            {alice3, "credential2"},
-            {alice4, "credential3"},
-            {alice5, "credential4"},
+            {.issuer = alice2, .credType = "credential1"},
+            {.issuer = alice3, .credType = "credential2"},
+            {.issuer = alice4, .credType = "credential3"},
+            {.issuer = alice5, .credType = "credential4"},
         };
         auto txJsonMutable = pdomain::setTx(account, credentials4, domain);
         auto const credentialOrig = txJsonMutable["AcceptedCredentials"][2u];
@@ -192,11 +195,11 @@ class PermissionedDomains_test : public beast::unit_test::Suite
         // permissioned domains, so transactions should return errors
         {
             pdomain::Credentials const credentialsDup{
-                {alice7, "credential6"},
-                {alice2, "credential1"},
-                {alice3, "credential2"},
-                {alice2, "credential1"},
-                {alice5, "credential4"},
+                {.issuer = alice7, .credType = "credential6"},
+                {.issuer = alice2, .credType = "credential1"},
+                {.issuer = alice3, .credType = "credential2"},
+                {.issuer = alice2, .credType = "credential1"},
+                {.issuer = alice5, .credType = "credential4"},
             };
 
             std::unordered_map human2Acc;
@@ -230,11 +233,11 @@ class PermissionedDomains_test : public beast::unit_test::Suite
         // sort correctly.
         {
             pdomain::Credentials const credentialsSame{
-                {alice2, "credential3"},
-                {alice3, "credential2"},
-                {alice2, "credential9"},
-                {alice5, "credential4"},
-                {alice2, "credential6"},
+                {.issuer = alice2, .credType = "credential3"},
+                {.issuer = alice3, .credType = "credential2"},
+                {.issuer = alice2, .credType = "credential9"},
+                {.issuer = alice5, .credType = "credential4"},
+                {.issuer = alice2, .credType = "credential6"},
             };
             std::unordered_map human2Acc;
             for (auto const& c : credentialsSame)
@@ -290,7 +293,7 @@ class PermissionedDomains_test : public beast::unit_test::Suite
             env.fund(XRP(1000), alice[i]);
 
         // Create new from existing account with a single credential.
-        pdomain::Credentials const credentials1{{alice[2], "credential1"}};
+        pdomain::Credentials const credentials1{{.issuer = alice[2], .credType = "credential1"}};
         {
             env(pdomain::setTx(alice[0], credentials1));
             BEAST_EXPECT(env.ownerCount(alice[0]) == 1);
@@ -314,7 +317,7 @@ class PermissionedDomains_test : public beast::unit_test::Suite
                 "89";
             static_assert(kLongCredentialType.size() == kMaxCredentialTypeLength);
             pdomain::Credentials const longCredentials{
-                {alice[1], std::string(kLongCredentialType)}};
+                {.issuer = alice[1], .credType = std::string(kLongCredentialType)}};
 
             env(pdomain::setTx(alice[0], longCredentials));
 
@@ -345,16 +348,16 @@ class PermissionedDomains_test : public beast::unit_test::Suite
         // Create new from existing account with 10 credentials.
         // Last credential describe domain owner itself
         pdomain::Credentials const credentials10{
-            {alice[2], "credential1"},
-            {alice[3], "credential2"},
-            {alice[4], "credential3"},
-            {alice[5], "credential4"},
-            {alice[6], "credential5"},
-            {alice[7], "credential6"},
-            {alice[8], "credential7"},
-            {alice[9], "credential8"},
-            {alice[10], "credential9"},
-            {alice[0], "credential10"},
+            {.issuer = alice[2], .credType = "credential1"},
+            {.issuer = alice[3], .credType = "credential2"},
+            {.issuer = alice[4], .credType = "credential3"},
+            {.issuer = alice[5], .credType = "credential4"},
+            {.issuer = alice[6], .credType = "credential5"},
+            {.issuer = alice[7], .credType = "credential6"},
+            {.issuer = alice[8], .credType = "credential7"},
+            {.issuer = alice[9], .credType = "credential8"},
+            {.issuer = alice[10], .credType = "credential9"},
+            {.issuer = alice[0], .credType = "credential10"},
         };
         uint256 domain2;
         {
@@ -434,7 +437,7 @@ class PermissionedDomains_test : public beast::unit_test::Suite
         env.fund(XRP(1000), alice);
         auto const setFee(drops(env.current()->fees().increment));
 
-        pdomain::Credentials const credentials{{alice, "first credential"}};
+        pdomain::Credentials const credentials{{.issuer = alice, .credType = "first credential"}};
         env(pdomain::setTx(alice, credentials));
         env.close();
 
@@ -498,7 +501,7 @@ class PermissionedDomains_test : public beast::unit_test::Suite
         BEAST_EXPECT(env.ownerCount(alice) == 0);
 
         // alice does not have enough XRP to cover the reserve.
-        pdomain::Credentials const credentials{{alice, "first credential"}};
+        pdomain::Credentials const credentials{{.issuer = alice, .credType = "first credential"}};
         env(pdomain::setTx(alice, credentials), Ter(tecINSUFFICIENT_RESERVE));
         BEAST_EXPECT(env.ownerCount(alice) == 0);
         BEAST_EXPECT(pdomain::getObjects(alice, env).empty());
@@ -526,6 +529,47 @@ class PermissionedDomains_test : public beast::unit_test::Suite
         BEAST_EXPECT(env.ownerCount(alice) == 1);
     }
 
+    void
+    testTicket(FeatureBitset features)
+    {
+        testcase("Tickets");
+
+        using namespace test::jtx;
+
+        Env env(*this, features);
+        Account const alice("alice");
+        env.fund(XRP(1000), alice);
+
+        pdomain::Credentials const credentials{
+            {.issuer = alice, .credType = "credential1"},
+        };
+
+        std::uint32_t seq{env.seq(alice)};
+        env(ticket::create(alice, 2));
+
+        {
+            env(pdomain::setTx(alice, credentials), ticket::Use(++seq));
+            auto domain = pdomain::getNewDomain(env.meta());
+            if (features[fixCleanup3_1_3])
+            {
+                BEAST_EXPECT(domain == keylet::permissionedDomain(alice.id(), seq).key);
+            }
+            else
+            {
+                BEAST_EXPECT(domain == keylet::permissionedDomain(alice.id(), 0).key);
+            }
+        }
+
+        if (features[fixCleanup3_1_3])
+        {
+            env(pdomain::setTx(alice, credentials), ticket::Use(++seq));
+        }
+        else
+        {
+            env(pdomain::setTx(alice, credentials), ticket::Use(++seq), Ter(tefEXCEPTION));
+        }
+    }
+
 public:
     void
     run() override
@@ -540,6 +584,8 @@ public:
         testDelete(withFix_);
         testAccountReserve(withFeature_);
         testAccountReserve(withFix_);
+        testTicket(withFeature_);
+        testTicket(withFix_);
     }
 };
 
diff --git a/src/test/app/RCLValidations_test.cpp b/src/test/app/RCLValidations_test.cpp
index df8e873c75..aaf84225a7 100644
--- a/src/test/app/RCLValidations_test.cpp
+++ b/src/test/app/RCLValidations_test.cpp
@@ -71,7 +71,7 @@ class RCLValidations_test : public beast::unit_test::Suite
         auto prev = std::make_shared(
             kCreateGenesis,
             Rules{config.features},
-            config.FEES.toFees(),
+            config.fees.toFees(),
             std::vector{},
             env.app().getNodeFamily());
         history.push_back(prev);
@@ -97,7 +97,7 @@ class RCLValidations_test : public beast::unit_test::Suite
             auto next = std::make_shared(*prev, env.app().getTimeKeeper().closeTime());
             // Force a different hash on the first iteration
             next->updateSkipList();
-            BEAST_EXPECT(next->read(keylet::fees()));
+            BEAST_EXPECT(next->read(keylet::feeSettings()));
             if (forceHash)
             {
                 next->setImmutable();
@@ -237,7 +237,7 @@ class RCLValidations_test : public beast::unit_test::Suite
         auto prev = std::make_shared(
             kCreateGenesis,
             Rules{config.features},
-            config.FEES.toFees(),
+            config.fees.toFees(),
             std::vector{},
             env.app().getNodeFamily());
         history.push_back(prev);
diff --git a/src/test/app/Regression_test.cpp b/src/test/app/Regression_test.cpp
index ee3e25e2f4..6ebde20176 100644
--- a/src/test/app/Regression_test.cpp
+++ b/src/test/app/Regression_test.cpp
@@ -25,6 +25,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -87,7 +88,7 @@ struct Regression_test : public beast::unit_test::Suite
         auto closed = std::make_shared(
             kCreateGenesis,
             Rules{env.app().config().features},
-            env.app().config().FEES.toFees(),
+            env.app().config().fees.toFees(),
             std::vector{},
             env.app().getNodeFamily());
         auto expectedDrops = kInitialXrp;
@@ -193,8 +194,8 @@ struct Regression_test : public beast::unit_test::Suite
         testcase("Autofilled fee should use the escalated fee");
         using namespace jtx;
         Env env(*this, envconfig([](std::unique_ptr cfg) {
-            cfg->section("transaction_queue").set("minimum_txn_in_ledger_standalone", "3");
-            cfg->FEES.reference_fee = 10;
+            cfg->section(Sections::kTransactionQueue).set(Keys::kMinimumTxnInLedgerStandalone, "3");
+            cfg->fees.referenceFee = 10;
             return cfg;
         }));
         EnvSs envs(env);
@@ -233,11 +234,11 @@ struct Regression_test : public beast::unit_test::Suite
         using namespace std::chrono_literals;
 
         Env env(*this, envconfig([](std::unique_ptr cfg) {
-            auto& s = cfg->section("transaction_queue");
-            s.set("minimum_txn_in_ledger_standalone", "4294967295");
-            s.set("minimum_txn_in_ledger", "4294967295");
-            s.set("target_txn_in_ledger", "4294967295");
-            s.set("normal_consensus_increase_percent", "4294967295");
+            auto& s = cfg->section(Sections::kTransactionQueue);
+            s.set(Keys::kMinimumTxnInLedgerStandalone, "4294967295");
+            s.set(Keys::kMinimumTxnInLedger, "4294967295");
+            s.set(Keys::kTargetTxnInLedger, "4294967295");
+            s.set(Keys::kNormalConsensusIncreasePercent, "4294967295");
 
             return cfg;
         }));
diff --git a/src/test/app/SHAMapStore_test.cpp b/src/test/app/SHAMapStore_test.cpp
index f03fabd92c..7a149b2f64 100644
--- a/src/test/app/SHAMapStore_test.cpp
+++ b/src/test/app/SHAMapStore_test.cpp
@@ -7,11 +7,12 @@
 #include 
 #include 
 #include 
-#include 
 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -41,9 +42,9 @@ class SHAMapStore_test : public beast::unit_test::Suite
     static auto
     onlineDelete(std::unique_ptr cfg)
     {
-        cfg->LEDGER_HISTORY = kDeleteInterval;
-        auto& section = cfg->section(ConfigSection::nodeDatabase());
-        section.set("online_delete", std::to_string(kDeleteInterval));
+        cfg->ledgerHistory = kDeleteInterval;
+        auto& section = cfg->section(Sections::kNodeDatabase);
+        section.set(Keys::kOnlineDelete, std::to_string(kDeleteInterval));
         return cfg;
     }
 
@@ -51,7 +52,7 @@ class SHAMapStore_test : public beast::unit_test::Suite
     advisoryDelete(std::unique_ptr cfg)
     {
         cfg = onlineDelete(std::move(cfg));
-        cfg->section(ConfigSection::nodeDatabase()).set("advisory_delete", "1");
+        cfg->section(Sections::kNodeDatabase).set(Keys::kAdvisoryDelete, "1");
         return cfg;
     }
 
@@ -490,13 +491,13 @@ public:
     std::unique_ptr
     makeBackendRotating(jtx::Env& env, NodeStoreScheduler& scheduler, std::string path)
     {
-        Section section{env.app().config().section(ConfigSection::nodeDatabase())};
+        Section section{env.app().config().section(Sections::kNodeDatabase)};
         boost::filesystem::path newPath;
 
         if (!BEAST_EXPECT(path.size()))
             return {};
         newPath = path;
-        section.set("path", newPath.string());
+        section.set(Keys::kPath, newPath.string());
 
         auto backend{NodeStore::Manager::instance().makeBackend(
             section,
@@ -520,6 +521,25 @@ public:
         /////////////////////////////////////////////////////////////
         // Create NodeStore with two backends to allow online deletion of data.
         // Normally, SHAMapStoreImp handles all these details.
+        auto nscfg = env.app().config().section(Sections::kNodeDatabase);
+
+        // Provide default values.
+        if (!nscfg.exists(Keys::kCacheSize))
+        {
+            nscfg.set(
+                Keys::kCacheSize,
+                std::to_string(
+                    env.app().config().getValueFor(SizedItem::TreeCacheSize, std::nullopt)));
+        }
+
+        if (!nscfg.exists(Keys::kCacheAge))
+        {
+            nscfg.set(
+                Keys::kCacheAge,
+                std::to_string(
+                    env.app().config().getValueFor(SizedItem::TreeCacheAge, std::nullopt)));
+        }
+
         NodeStoreScheduler scheduler(env.app().getJobQueue());
 
         std::string const writableDb = "write";
@@ -528,7 +548,6 @@ public:
         auto archiveBackend = makeBackendRotating(env, scheduler, archiveDb);
 
         static constexpr int kReadThreads = 4;
-        auto nscfg = env.app().config().section(ConfigSection::nodeDatabase());
         auto dbr = std::make_unique(
             scheduler,
             kReadThreads,
diff --git a/src/test/app/SetAuth_test.cpp b/src/test/app/SetAuth_test.cpp
index df0c6fe5d0..57526d0a0c 100644
--- a/src/test/app/SetAuth_test.cpp
+++ b/src/test/app/SetAuth_test.cpp
@@ -51,7 +51,7 @@ struct SetAuth_test : public beast::unit_test::Suite
         env(fset(gw, asfRequireAuth));
         env.close();
         env(auth(gw, "alice", "USD"));
-        BEAST_EXPECT(env.le(keylet::line(Account("alice").id(), gw.id(), usd.currency)));
+        BEAST_EXPECT(env.le(keylet::trustLine(Account("alice").id(), gw.id(), usd.currency)));
         env(trust("alice", usd(1000)));
         env(trust("bob", usd(1000)));
         env(pay(gw, "alice", usd(100)));
diff --git a/src/test/app/SetRegularKey_test.cpp b/src/test/app/SetRegularKey_test.cpp
index b5d1af9ef0..b512885606 100644
--- a/src/test/app/SetRegularKey_test.cpp
+++ b/src/test/app/SetRegularKey_test.cpp
@@ -72,6 +72,27 @@ public:
         env(regkey(alice, alice), Ter(temBAD_REGKEY));
     }
 
+    void
+    testNoAlternativeKey()
+    {
+        using namespace test::jtx;
+
+        testcase("Cannot remove last signing method");
+        Env env{*this, testableAmendments()};
+        Account const alice("alice");
+        Account const bob("bob");
+        env.fund(XRP(10000), alice);
+
+        env(regkey(alice, bob));
+        env(fset(alice, asfDisableMaster), Sig(alice));
+
+        env(regkey(alice, kDisabled), Sig(bob), Ter(tecNO_ALTERNATIVE_KEY));
+
+        auto const sle = env.le(alice);
+        BEAST_EXPECT(
+            sle && sle->isFlag(lsfDisableMaster) && sle->getAccountID(sfRegularKey) == bob.id());
+    }
+
     void
     testPasswordSpent()
     {
@@ -169,6 +190,7 @@ public:
     {
         testDisabledMasterKey();
         testDisabledRegularKey();
+        testNoAlternativeKey();
         testPasswordSpent();
         testUniversalMask();
         testTicketRegularKey();
diff --git a/src/test/app/Transaction_ordering_test.cpp b/src/test/app/Transaction_ordering_test.cpp
index 6672b3f834..7c0721cde3 100644
--- a/src/test/app/Transaction_ordering_test.cpp
+++ b/src/test/app/Transaction_ordering_test.cpp
@@ -64,7 +64,7 @@ struct Transaction_ordering_test : public beast::unit_test::Suite
         testcase("Incorrect order");
 
         Env env(*this, envconfig([](std::unique_ptr cfg) {
-            cfg->FORCE_MULTI_THREAD = false;
+            cfg->forceMultiThread = false;
             return cfg;
         }));
 
@@ -102,7 +102,7 @@ struct Transaction_ordering_test : public beast::unit_test::Suite
         testcase("Incorrect order multiple intermediaries");
 
         Env env(*this, envconfig([](std::unique_ptr cfg) {
-            cfg->FORCE_MULTI_THREAD = true;
+            cfg->forceMultiThread = true;
             return cfg;
         }));
 
diff --git a/src/test/app/TxQ_test.cpp b/src/test/app/TxQ_test.cpp
index d8882cf346..2d10eb4beb 100644
--- a/src/test/app/TxQ_test.cpp
+++ b/src/test/app/TxQ_test.cpp
@@ -5,6 +5,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -29,6 +30,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -167,7 +169,7 @@ public:
         using namespace std::chrono;
         testcase("queue sequence");
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "3"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}));
 
         auto alice = Account("alice");
         auto bob = Account("bob");
@@ -380,7 +382,7 @@ public:
         using namespace jtx;
         testcase("queue ticket");
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "3"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}));
 
         auto alice = Account("alice");
 
@@ -618,7 +620,7 @@ public:
         using namespace jtx;
         testcase("queue tec");
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "2"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "2"}}));
 
         auto alice = Account("alice");
         auto gw = Account("gw");
@@ -655,7 +657,7 @@ public:
         using namespace std::chrono;
         testcase("local tx retry");
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "2"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "2"}}));
 
         auto alice = Account("alice");
         auto bob = Account("bob");
@@ -708,7 +710,7 @@ public:
         using namespace std::chrono;
         testcase("last ledger sequence");
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "2"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "2"}}));
 
         auto alice = Account("alice");
         auto bob = Account("bob");
@@ -830,7 +832,7 @@ public:
         using namespace std::chrono;
         testcase("zero transaction fee");
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "2"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "2"}}));
 
         auto alice = Account("alice");
         auto bob = Account("bob");
@@ -957,7 +959,7 @@ public:
         using namespace jtx;
         testcase("queued tx fails");
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "2"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "2"}}));
 
         auto alice = Account("alice");
         auto bob = Account("bob");
@@ -1009,8 +1011,8 @@ public:
         Env env(
             *this,
             makeConfig(
-                {{"minimum_txn_in_ledger_standalone", "3"}},
-                {{"account_reserve", "200"}, {"owner_reserve", "50"}}));
+                {{Keys::kMinimumTxnInLedgerStandalone, "3"}},
+                {{Keys::kAccountReserve, "200"}, {Keys::kOwnerReserve, "50"}}));
 
         auto alice = Account("alice");
         auto bob = Account("bob");
@@ -1175,7 +1177,7 @@ public:
         // bankrupt Alice. Fails, because an account can't have
         // more than the minimum reserve in flight before the
         // last queued transaction
-        aliceFee = env.le(alice)->getFieldAmount(sfBalance).xrp().drops() - (62);
+        aliceFee = env.le(alice)->getFieldAmount(sfBalance).xrp().drops() - 62;
         env(noop(alice), Seq(aliceSeq), Fee(aliceFee), Ter(telCAN_NOT_QUEUE_BALANCE));
         checkMetrics(*this, env, 4, 10, 6, 5);
 
@@ -1228,7 +1230,7 @@ public:
         // Try to replace a middle item in the queue
         // with enough fee to bankrupt bob and make the
         // later transactions unable to pay their fees
-        std::int64_t bobFee = env.le(bob)->getFieldAmount(sfBalance).xrp().drops() - (9 * 10 - 1);
+        std::int64_t bobFee = env.le(bob)->getFieldAmount(sfBalance).xrp().drops() - ((9 * 10) - 1);
         env(noop(bob), Seq(bobSeq + 5), Fee(bobFee), Ter(telCAN_NOT_QUEUE_BALANCE));
         checkMetrics(*this, env, 10, 12, 7, 6);
 
@@ -1258,8 +1260,8 @@ public:
         using namespace std::chrono;
         testcase("tie breaking");
 
-        auto cfg = makeConfig({{"minimum_txn_in_ledger_standalone", "4"}});
-        cfg->FEES.reference_fee = 10;
+        auto cfg = makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "4"}});
+        cfg->fees.referenceFee = 10;
         Env env(*this, std::move(cfg));
 
         auto alice = Account("alice");
@@ -1471,7 +1473,7 @@ public:
         using namespace jtx;
         testcase("acct tx id");
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "1"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "1"}}));
 
         auto alice = Account("alice");
 
@@ -1511,10 +1513,10 @@ public:
             Env env(
                 *this,
                 makeConfig(
-                    {{"minimum_txn_in_ledger_standalone", "2"},
-                     {"minimum_txn_in_ledger", "5"},
-                     {"target_txn_in_ledger", "4"},
-                     {"maximum_txn_in_ledger", "5"}}));
+                    {{Keys::kMinimumTxnInLedgerStandalone, "2"},
+                     {Keys::kMinimumTxnInLedger, "5"},
+                     {Keys::kTargetTxnInLedger, "4"},
+                     {Keys::kMaximumTxnInLedger, "5"}}));
             auto const baseFee = env.current()->fees().base.drops();
 
             auto alice = Account("alice");
@@ -1555,10 +1557,10 @@ public:
             Env const env(
                 *this,
                 makeConfig(
-                    {{"minimum_txn_in_ledger", "200"},
-                     {"minimum_txn_in_ledger_standalone", "200"},
-                     {"target_txn_in_ledger", "4"},
-                     {"maximum_txn_in_ledger", "5"}}));
+                    {{Keys::kMinimumTxnInLedger, "200"},
+                     {Keys::kMinimumTxnInLedgerStandalone, "200"},
+                     {Keys::kTargetTxnInLedger, "4"},
+                     {Keys::kMaximumTxnInLedger, "5"}}));
             // should throw
             fail();
         }
@@ -1576,10 +1578,10 @@ public:
             Env const env(
                 *this,
                 makeConfig(
-                    {{"minimum_txn_in_ledger", "200"},
-                     {"minimum_txn_in_ledger_standalone", "2"},
-                     {"target_txn_in_ledger", "4"},
-                     {"maximum_txn_in_ledger", "5"}}));
+                    {{Keys::kMinimumTxnInLedger, "200"},
+                     {Keys::kMinimumTxnInLedgerStandalone, "2"},
+                     {Keys::kTargetTxnInLedger, "4"},
+                     {Keys::kMaximumTxnInLedger, "5"}}));
             // should throw
             fail();
         }
@@ -1597,10 +1599,10 @@ public:
             Env const env(
                 *this,
                 makeConfig(
-                    {{"minimum_txn_in_ledger", "2"},
-                     {"minimum_txn_in_ledger_standalone", "200"},
-                     {"target_txn_in_ledger", "4"},
-                     {"maximum_txn_in_ledger", "5"}}));
+                    {{Keys::kMinimumTxnInLedger, "2"},
+                     {Keys::kMinimumTxnInLedgerStandalone, "200"},
+                     {Keys::kTargetTxnInLedger, "4"},
+                     {Keys::kMaximumTxnInLedger, "5"}}));
             // should throw
             fail();
         }
@@ -1624,8 +1626,8 @@ public:
         Env env(
             *this,
             makeConfig(
-                {{"minimum_txn_in_ledger_standalone", "3"}},
-                {{"account_reserve", "200"}, {"owner_reserve", "50"}}));
+                {{Keys::kMinimumTxnInLedgerStandalone, "3"}},
+                {{Keys::kAccountReserve, "200"}, {Keys::kOwnerReserve, "50"}}));
 
         auto alice = Account("alice");
         auto bob = Account("bob");
@@ -1716,7 +1718,7 @@ public:
 
         auto queued = Ter(terQUEUED);
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "3"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}));
         auto const baseFee = env.current()->fees().base.drops();
 
         checkMetrics(*this, env, 0, std::nullopt, 0, 3);
@@ -1845,7 +1847,7 @@ public:
 
         auto queued = Ter(terQUEUED);
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "3"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}));
         auto const baseFee = env.current()->fees().base.drops();
 
         checkMetrics(*this, env, 0, std::nullopt, 0, 3);
@@ -1996,8 +1998,8 @@ public:
         Env env(
             *this,
             makeConfig(
-                {{"minimum_txn_in_ledger_standalone", "3"}},
-                {{"account_reserve", "200"}, {"owner_reserve", "50"}}));
+                {{Keys::kMinimumTxnInLedgerStandalone, "3"}},
+                {{Keys::kAccountReserve, "200"}, {Keys::kOwnerReserve, "50"}}));
 
         auto alice = Account("alice");
         auto charlie = Account("charlie");
@@ -2334,6 +2336,42 @@ public:
         BEAST_EXPECT(env.balance(alice) == drops(5));
     }
 
+    void
+    testDelegateTxCannotQueue()
+    {
+        using namespace jtx;
+        testcase("disallow delegate transaction from being queued");
+
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}));
+
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+        auto const carol = Account("carol");
+
+        env.fund(XRP(50000), alice, bob);
+        env.close();
+        env.fund(XRP(50000), carol);
+        env.close();
+
+        env(delegate::set(alice, bob, {"Payment"}));
+        env.close();
+
+        fillQueue(env, alice);
+        checkMetrics(*this, env, 0, 8, 5, 4);
+
+        // Delegated transactions are not allowed to be queued.
+        env(pay(alice, carol, drops(1)), delegate::As(bob), Ter(telCAN_NOT_QUEUE));
+        checkMetrics(*this, env, 0, 8, 5, 4);
+
+        // Delegated transactions may still apply directly if they pay the
+        // open ledger fee. They just cannot be held in the queue.
+        env(pay(alice, carol, drops(1)),
+            delegate::As(bob),
+            Fee(openLedgerCost(env)),
+            Ter(tesSUCCESS));
+        checkMetrics(*this, env, 0, 8, 6, 4);
+    }
+
     void
     testConsequences()
     {
@@ -2399,7 +2437,7 @@ public:
 
         auto queued = Ter(terQUEUED);
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "3"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}));
         auto const baseFee = env.current()->fees().base.drops();
 
         checkMetrics(*this, env, 0, std::nullopt, 0, 3);
@@ -2568,9 +2606,9 @@ public:
         Env env(
             *this,
             makeConfig(
-                {{"minimum_txn_in_ledger_standalone", "1"},
-                 {"ledgers_in_queue", "10"},
-                 {"maximum_txn_per_account", "20"}}));
+                {{Keys::kMinimumTxnInLedgerStandalone, "1"},
+                 {Keys::kLedgersInQueue, "10"},
+                 {Keys::kMaximumTxnPerAccount, "20"}}));
 
         auto const baseFee = env.current()->fees().base.drops();
 
@@ -2650,10 +2688,10 @@ public:
         testcase("full queue gap handling");
 
         auto cfg = makeConfig(
-            {{"minimum_txn_in_ledger_standalone", "1"},
-             {"ledgers_in_queue", "10"},
-             {"maximum_txn_per_account", "11"}});
-        cfg->FEES.reference_fee = 10;
+            {{Keys::kMinimumTxnInLedgerStandalone, "1"},
+             {Keys::kLedgersInQueue, "10"},
+             {Keys::kMaximumTxnPerAccount, "11"}});
+        cfg->fees.referenceFee = 10;
         Env env(*this, std::move(cfg));
 
         auto const baseFee = env.current()->fees().base.drops();
@@ -2777,7 +2815,7 @@ public:
     {
         testcase("Autofilled sequence should account for TxQ");
         using namespace jtx;
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "6"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "6"}}));
         auto const baseFee = env.current()->fees().base.drops();
         EnvSs envs(env);
         auto const& txQ = env.app().getTxQ();
@@ -2911,7 +2949,7 @@ public:
         using namespace jtx;
         testcase("account info");
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "3"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}));
         auto const baseFee = env.current()->fees().base.drops();
         EnvSs envs(env);
 
@@ -3181,7 +3219,7 @@ public:
         using namespace jtx;
         testcase("server info");
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "3"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}));
         auto const baseFee = env.current()->fees().base.drops();
         EnvSs envs(env);
 
@@ -3407,7 +3445,7 @@ public:
         using namespace jtx;
         testcase("server subscribe");
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "3"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}));
         auto const baseFee = env.current()->fees().base.drops();
 
         json::Value stream;
@@ -3546,7 +3584,7 @@ public:
         using namespace jtx;
         testcase("clear queued acct txs");
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "3"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}));
         auto const baseFee = env.current()->fees().base.drops();
         auto alice = Account("alice");
         auto bob = Account("bob");
@@ -3756,11 +3794,11 @@ public:
             Env env(
                 *this,
                 makeConfig(
-                    {{"minimum_txn_in_ledger_standalone", "3"},
-                     {"normal_consensus_increase_percent", "25"},
-                     {"slow_consensus_decrease_percent", "50"},
-                     {"target_txn_in_ledger", "10"},
-                     {"maximum_txn_per_account", "200"}}));
+                    {{Keys::kMinimumTxnInLedgerStandalone, "3"},
+                     {Keys::kNormalConsensusIncreasePercent, "25"},
+                     {Keys::kSlowConsensusDecreasePercent, "50"},
+                     {Keys::kTargetTxnInLedger, "10"},
+                     {Keys::kMaximumTxnPerAccount, "200"}}));
             auto alice = Account("alice");
 
             checkMetrics(*this, env, 0, std::nullopt, 0, 3);
@@ -3842,11 +3880,11 @@ public:
             Env env(
                 *this,
                 makeConfig(
-                    {{"minimum_txn_in_ledger_standalone", "3"},
-                     {"normal_consensus_increase_percent", "150"},
-                     {"slow_consensus_decrease_percent", "150"},
-                     {"target_txn_in_ledger", "10"},
-                     {"maximum_txn_per_account", "200"}}));
+                    {{Keys::kMinimumTxnInLedgerStandalone, "3"},
+                     {Keys::kNormalConsensusIncreasePercent, "150"},
+                     {Keys::kSlowConsensusDecreasePercent, "150"},
+                     {Keys::kTargetTxnInLedger, "10"},
+                     {Keys::kMaximumTxnPerAccount, "200"}}));
             auto alice = Account("alice");
 
             checkMetrics(*this, env, 0, std::nullopt, 0, 3);
@@ -3899,7 +3937,7 @@ public:
         testcase("Sequence in queue and open ledger");
         using namespace jtx;
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "3"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}));
 
         auto const alice = Account("alice");
 
@@ -3962,7 +4000,7 @@ public:
         testcase("Ticket in queue and open ledger");
         using namespace jtx;
 
-        Env env(*this, makeConfig({{"minimum_txn_in_ledger_standalone", "3"}}));
+        Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}));
 
         auto alice = Account("alice");
 
@@ -4063,15 +4101,16 @@ public:
 
         static constexpr int kLedgersInQueue = 30;
         auto cfg = makeConfig(
-            {{"minimum_txn_in_ledger_standalone", "1"},
-             {"ledgers_in_queue", std::to_string(kLedgersInQueue)},
-             {"maximum_txn_per_account", "10"}},
-            {{"account_reserve", "1000"}, {"owner_reserve", "50"}});
+            {{Keys::kMinimumTxnInLedgerStandalone, "1"},
+             {Keys::kLedgersInQueue, std::to_string(kLedgersInQueue)},
+             {Keys::kMaximumTxnPerAccount, "10"}},
+            {{Keys::kAccountReserve, "1000"}, {Keys::kOwnerReserve, "50"}});
 
-        auto& votingSection = cfg->section("voting");
-        votingSection.set("account_reserve", std::to_string(cfg->FEES.reference_fee.drops() * 100));
+        auto& votingSection = cfg->section(Sections::kVoting);
+        votingSection.set(
+            Keys::kAccountReserve, std::to_string(cfg->fees.referenceFee.drops() * 100));
 
-        votingSection.set("reference_fee", std::to_string(cfg->FEES.reference_fee.drops()));
+        votingSection.set(Keys::kReferenceFee, std::to_string(cfg->fees.referenceFee.drops()));
 
         Env env(*this, std::move(cfg));
 
@@ -4228,10 +4267,10 @@ public:
         Account const fiona("fiona");
 
         auto cfg = makeConfig(
-            {{"minimum_txn_in_ledger_standalone", "5"},
-             {"ledgers_in_queue", "5"},
-             {"maximum_txn_per_account", "30"},
-             {"minimum_queue_size", "50"}});
+            {{Keys::kMinimumTxnInLedgerStandalone, "5"},
+             {Keys::kLedgersInQueue, "5"},
+             {Keys::kMaximumTxnPerAccount, "30"},
+             {Keys::kMinimumQueueSize, "50"}});
 
         Env env(*this, std::move(cfg));
         auto const baseFee = env.current()->fees().base.drops();
@@ -4437,10 +4476,10 @@ public:
         auto usd = gw["USD"];
 
         auto cfg = makeConfig(
-            {{"minimum_txn_in_ledger_standalone", "5"},
-             {"ledgers_in_queue", "5"},
-             {"maximum_txn_per_account", "30"},
-             {"minimum_queue_size", "50"}});
+            {{Keys::kMinimumTxnInLedgerStandalone, "5"},
+             {Keys::kLedgersInQueue, "5"},
+             {Keys::kMaximumTxnPerAccount, "30"},
+             {Keys::kMinimumQueueSize, "50"}});
 
         Env env(*this, std::move(cfg));
 
@@ -4537,8 +4576,10 @@ public:
         Env env(
             *this,
             makeConfig(
-                {{"minimum_txn_in_ledger_standalone", "3"}},
-                {{"reference_fee", "0"}, {"account_reserve", "0"}, {"owner_reserve", "0"}}));
+                {{Keys::kMinimumTxnInLedgerStandalone, "3"}},
+                {{Keys::kReferenceFee, "0"},
+                 {Keys::kAccountReserve, "0"},
+                 {Keys::kOwnerReserve, "0"}}));
 
         checkMetrics(*this, env, 0, std::nullopt, 0, 3);
 
@@ -4658,6 +4699,7 @@ public:
         testBlockersSeq();
         testBlockersTicket();
         testInFlightBalance();
+        testDelegateTxCannotQueue();
         testConsequences();
     }
 
diff --git a/src/test/app/ValidatorKeys_test.cpp b/src/test/app/ValidatorKeys_test.cpp
index 83267bf0a7..ca0e76c0b3 100644
--- a/src/test/app/ValidatorKeys_test.cpp
+++ b/src/test/app/ValidatorKeys_test.cpp
@@ -4,11 +4,11 @@
 
 #include 
 #include 
-#include 
 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -100,7 +100,7 @@ public:
         {
             // validation seed section -> empty manifest and valid seeds
             Config c;
-            c.section(SECTION_VALIDATION_SEED).append(seed_);
+            c.section(Sections::kValidationSeed).append(seed_);
 
             ValidatorKeys k{c, journal};
             if (BEAST_EXPECT(k.keys); k.keys.has_value())
@@ -116,7 +116,7 @@ public:
         {
             // validation seed bad seed -> invalid
             Config c;
-            c.section(SECTION_VALIDATION_SEED).append("badseed");
+            c.section(Sections::kValidationSeed).append("badseed");
 
             ValidatorKeys const k{c, journal};
             BEAST_EXPECT(k.configInvalid());
@@ -127,7 +127,7 @@ public:
         {
             // validator token
             Config c;
-            c.section(SECTION_VALIDATOR_TOKEN).append(tokenBlob_);
+            c.section(Sections::kValidatorToken).append(tokenBlob_);
             ValidatorKeys k{c, journal};
 
             if (BEAST_EXPECT(k.keys); k.keys.has_value())
@@ -142,7 +142,7 @@ public:
         {
             // invalid validator token
             Config c;
-            c.section(SECTION_VALIDATOR_TOKEN).append("badtoken");
+            c.section(Sections::kValidatorToken).append("badtoken");
             ValidatorKeys const k{c, journal};
             BEAST_EXPECT(k.configInvalid());
             BEAST_EXPECT(!k.keys);
@@ -152,8 +152,8 @@ public:
         {
             // Cannot specify both
             Config c;
-            c.section(SECTION_VALIDATION_SEED).append(seed_);
-            c.section(SECTION_VALIDATOR_TOKEN).append(tokenBlob_);
+            c.section(Sections::kValidationSeed).append(seed_);
+            c.section(Sections::kValidatorToken).append(tokenBlob_);
             ValidatorKeys const k{c, journal};
 
             BEAST_EXPECT(k.configInvalid());
@@ -164,7 +164,7 @@ public:
         {
             // Token manifest and private key must match
             Config c;
-            c.section(SECTION_VALIDATOR_TOKEN).append(invalidTokenBlob_);
+            c.section(Sections::kValidatorToken).append(invalidTokenBlob_);
             ValidatorKeys const k{c, journal};
 
             BEAST_EXPECT(k.configInvalid());
diff --git a/src/test/app/ValidatorList_test.cpp b/src/test/app/ValidatorList_test.cpp
index 15bd1bc586..d71554f714 100644
--- a/src/test/app/ValidatorList_test.cpp
+++ b/src/test/app/ValidatorList_test.cpp
@@ -13,6 +13,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -81,7 +82,7 @@ private:
         SecretKey const& ssk,
         int seq)
     {
-        STObject st(kSfGeneric);
+        STObject st(sfGeneric);
         st[sfSequence] = seq;
         st[sfPublicKey] = pk;
 
@@ -104,7 +105,7 @@ private:
     static std::string
     makeRevocationString(PublicKey const& pk, SecretKey const& sk)
     {
-        STObject st(kSfGeneric);
+        STObject st(sfGeneric);
         st[sfSequence] = std::numeric_limits::max();
         st[sfPublicKey] = pk;
 
@@ -198,7 +199,7 @@ private:
                 manifests,
                 manifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
             BEAST_EXPECT(trustedKeys->quorum() == 1);
         }
@@ -208,7 +209,7 @@ private:
                 manifests,
                 manifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal,
                 minQuorum);
             BEAST_EXPECT(trustedKeys->quorum() == minQuorum);
@@ -266,7 +267,7 @@ private:
                 manifests,
                 manifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             // Correct (empty) configuration
@@ -292,7 +293,7 @@ private:
                 manifests,
                 manifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             BEAST_EXPECT(trustedKeys->load({}, cfgKeys, emptyCfgPublishers));
@@ -327,7 +328,7 @@ private:
                 manifests,
                 manifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             auto const localSigningPublic =
@@ -347,7 +348,7 @@ private:
                 manifests,
                 manifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             auto const localSigningPublic = randomNode();
@@ -365,7 +366,7 @@ private:
                 manifests,
                 manifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
@@ -385,7 +386,7 @@ private:
                 manifests,
                 manifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             // load should reject invalid validator list signing keys
@@ -421,7 +422,7 @@ private:
                 manifests,
                 manifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             std::vector const keys(
@@ -446,7 +447,7 @@ private:
                 valManifests,
                 pubManifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             auto const pubRevokedSecret = randomSecretKey();
@@ -485,7 +486,7 @@ private:
                 valManifests,
                 pubManifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             auto const pubRevokedSecret = randomSecretKey();
@@ -571,7 +572,7 @@ private:
             manifests,
             manifests,
             env.app().getTimeKeeper(),
-            app.config().legacy("database_path"),
+            app.config().legacy(Sections::kDatabasePath),
             env.journal);
 
         auto expectTrusted = [this, &trustedKeys](std::vector const& list) {
@@ -632,7 +633,11 @@ private:
 
         checkResult(
             trustedKeys->applyLists(
-                manifest1, version, {{expiredblob, expiredSig, {}}, {blob2, sig2, {}}}, siteUri),
+                manifest1,
+                version,
+                {{.blob = expiredblob, .signature = expiredSig, .manifest = {}},
+                 {.blob = blob2, .signature = sig2, .manifest = {}}},
+                siteUri),
             publisherPublic,
             ListDisposition::Expired,
             ListDisposition::Accepted);
@@ -665,7 +670,11 @@ private:
 
         checkResult(
             trustedKeys->applyLists(
-                manifest1, version2, {{blob7, sig7, {}}, {blob8, sig8, {}}}, siteUri),
+                manifest1,
+                version2,
+                {{.blob = blob7, .signature = sig7, .manifest = {}},
+                 {.blob = blob8, .signature = sig8, .manifest = {}}},
+                siteUri),
             publisherPublic,
             ListDisposition::Pending,
             ListDisposition::Pending);
@@ -697,7 +706,11 @@ private:
 
         checkResult(
             trustedKeys->applyLists(
-                manifest1, version, {{blob6a, sig6a, {}}, {blob6, sig6, {}}}, siteUri),
+                manifest1,
+                version,
+                {{.blob = blob6a, .signature = sig6a, .manifest = {}},
+                 {.blob = blob6, .signature = sig6, .manifest = {}}},
+                siteUri),
             publisherPublic,
             ListDisposition::Pending,
             ListDisposition::Pending);
@@ -709,7 +722,11 @@ private:
 
         checkResult(
             trustedKeys->applyLists(
-                manifest1, version, {{blob7, sig7, {}}, {blob6, sig6, {}}}, siteUri),
+                manifest1,
+                version,
+                {{.blob = blob7, .signature = sig7, .manifest = {}},
+                 {.blob = blob6, .signature = sig6, .manifest = {}}},
+                siteUri),
             publisherPublic,
             ListDisposition::KnownSequence,
             ListDisposition::KnownSequence);
@@ -720,7 +737,12 @@ private:
 
         // try empty or mangled manifest
         checkResult(
-            trustedKeys->applyLists("", version, {{blob7, sig7, {}}, {blob6, sig6, {}}}, siteUri),
+            trustedKeys->applyLists(
+                "",
+                version,
+                {{.blob = blob7, .signature = sig7, .manifest = {}},
+                 {.blob = blob6, .signature = sig6, .manifest = {}}},
+                siteUri),
             publisherPublic,
             ListDisposition::Invalid,
             ListDisposition::Invalid);
@@ -729,7 +751,8 @@ private:
             trustedKeys->applyLists(
                 base64Encode("not a manifest"),
                 version,
-                {{blob7, sig7, {}}, {blob6, sig6, {}}},
+                {{.blob = blob7, .signature = sig7, .manifest = {}},
+                 {.blob = blob6, .signature = sig6, .manifest = {}}},
                 siteUri),
             publisherPublic,
             ListDisposition::Invalid,
@@ -740,7 +763,11 @@ private:
             randomMasterKey(), publisherSecret, pubSigningKeys1.first, pubSigningKeys1.second, 1));
 
         checkResult(
-            trustedKeys->applyLists(untrustedManifest, version, {{blob2, sig2, {}}}, siteUri),
+            trustedKeys->applyLists(
+                untrustedManifest,
+                version,
+                {{.blob = blob2, .signature = sig2, .manifest = {}}},
+                siteUri),
             publisherPublic,
             ListDisposition::Untrusted,
             ListDisposition::Untrusted);
@@ -748,7 +775,11 @@ private:
         // do not use list with unhandled version
         auto const badVersion = 666;
         checkResult(
-            trustedKeys->applyLists(manifest1, badVersion, {{blob2, sig2, {}}}, siteUri),
+            trustedKeys->applyLists(
+                manifest1,
+                badVersion,
+                {{.blob = blob2, .signature = sig2, .manifest = {}}},
+                siteUri),
             publisherPublic,
             ListDisposition::UnsupportedVersion,
             ListDisposition::UnsupportedVersion);
@@ -759,7 +790,8 @@ private:
         auto const sig3 = signList(blob3, pubSigningKeys1);
 
         checkResult(
-            trustedKeys->applyLists(manifest1, version, {{blob3, sig3, {}}}, siteUri),
+            trustedKeys->applyLists(
+                manifest1, version, {{.blob = blob3, .signature = sig3, .manifest = {}}}, siteUri),
             publisherPublic,
             ListDisposition::Accepted,
             ListDisposition::Accepted);
@@ -780,7 +812,11 @@ private:
         // do not re-apply lists with past or current sequence numbers
         checkResult(
             trustedKeys->applyLists(
-                manifest1, version, {{blob2, sig2, {}}, {blob3, sig3, {}}}, siteUri),
+                manifest1,
+                version,
+                {{.blob = blob2, .signature = sig2, .manifest = {}},
+                 {.blob = blob3, .signature = sig3, .manifest = {}}},
+                siteUri),
             publisherPublic,
             ListDisposition::Stale,
             ListDisposition::SameSequence);
@@ -799,7 +835,9 @@ private:
             trustedKeys->applyLists(
                 manifest2,
                 version,
-                {{blob2, sig2, manifest1}, {blob3, sig3, manifest1}, {blob4, sig4, {}}},
+                {{.blob = blob2, .signature = sig2, .manifest = manifest1},
+                 {.blob = blob3, .signature = sig3, .manifest = manifest1},
+                 {.blob = blob4, .signature = sig4, .manifest = {}}},
                 siteUri),
             publisherPublic,
             ListDisposition::Stale,
@@ -820,7 +858,11 @@ private:
         auto const blob5 = makeList(lists.at(5), sequence5, validUntil.time_since_epoch().count());
         auto const badSig = signList(blob5, pubSigningKeys1);
         checkResult(
-            trustedKeys->applyLists(manifest1, version, {{blob5, badSig, {}}}, siteUri),
+            trustedKeys->applyLists(
+                manifest1,
+                version,
+                {{.blob = blob5, .signature = badSig, .manifest = {}}},
+                siteUri),
             publisherPublic,
             ListDisposition::Invalid,
             ListDisposition::Invalid);
@@ -833,7 +875,11 @@ private:
         // Reprocess the pending list, but the signature is no longer valid
         checkResult(
             trustedKeys->applyLists(
-                manifest1, version, {{blob7, sig7, {}}, {blob8, sig8, {}}}, siteUri),
+                manifest1,
+                version,
+                {{.blob = blob7, .signature = sig7, .manifest = {}},
+                 {.blob = blob8, .signature = sig8, .manifest = {}}},
+                siteUri),
             publisherPublic,
             ListDisposition::Invalid,
             ListDisposition::Invalid);
@@ -884,7 +930,11 @@ private:
 
         checkResult(
             trustedKeys->applyLists(
-                manifest2, version, {{blob8, sig8, manifest1}, {blob8, sig82, {}}}, siteUri),
+                manifest2,
+                version,
+                {{.blob = blob8, .signature = sig8, .manifest = manifest1},
+                 {.blob = blob8, .signature = sig82, .manifest = {}}},
+                siteUri),
             publisherPublic,
             ListDisposition::Invalid,
             ListDisposition::SameSequence);
@@ -903,7 +953,11 @@ private:
         auto const sig9 = signList(blob9, signingKeysMax);
 
         checkResult(
-            trustedKeys->applyLists(maxManifest, version, {{blob9, sig9, {}}}, siteUri),
+            trustedKeys->applyLists(
+                maxManifest,
+                version,
+                {{.blob = blob9, .signature = sig9, .manifest = {}}},
+                siteUri),
             publisherPublic,
             ListDisposition::Untrusted,
             ListDisposition::Untrusted);
@@ -933,7 +987,7 @@ private:
             manifests,
             manifests,
             env.app().getTimeKeeper(),
-            app.config().legacy("database_path"),
+            app.config().legacy(Sections::kDatabasePath),
             env.journal);
 
         auto const publisherSecret = randomSecretKey();
@@ -1064,7 +1118,7 @@ private:
             manifestsOuter,
             manifestsOuter,
             env.timeKeeper(),
-            app.config().legacy("database_path"),
+            app.config().legacy(Sections::kDatabasePath),
             env.journal);
 
         std::vector const cfgPublishersOuter;
@@ -1230,7 +1284,7 @@ private:
                 manifestsOuter,
                 manifestsOuter,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
             auto const publisherSecret = randomSecretKey();
             auto const publisherPublic = derivePublicKey(KeyType::Ed25519, publisherSecret);
@@ -1257,7 +1311,7 @@ private:
                 manifestsOuter,
                 manifestsOuter,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
             auto const masterPrivate = randomSecretKey();
             auto const masterPublic = derivePublicKey(KeyType::Ed25519, masterPrivate);
@@ -1291,7 +1345,7 @@ private:
                 manifests,
                 manifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal,
                 minQuorum);
 
@@ -1347,7 +1401,7 @@ private:
                 manifestsOuter,
                 manifestsOuter,
                 env.app().getTimeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             std::vector const emptyCfgKeys;
@@ -1446,7 +1500,7 @@ private:
                 manifestsOuter,
                 manifestsOuter,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             std::vector const cfgPublishers;
@@ -1482,7 +1536,7 @@ private:
                 manifestsOuter,
                 manifestsOuter,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             auto const localKey = randomNode();
@@ -1529,7 +1583,7 @@ private:
                 manifests,
                 manifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             hash_set activeValidators;
@@ -1617,7 +1671,7 @@ private:
                 manifests,
                 manifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             hash_set activeValidators;
@@ -1824,7 +1878,7 @@ private:
                 manifests,
                 manifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             // Empty list has no expiration
@@ -1846,7 +1900,7 @@ private:
                 manifests,
                 manifests,
                 env.app().getTimeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             std::vector validators = {randomValidator()};
@@ -1900,7 +1954,9 @@ private:
                 return PreparedList{
                     .publisherPublic = publisherPublic,
                     .manifest = manifest,
-                    .blobs = {{blob1, sig1, {}}, {blob2, sig2, {}}},
+                    .blobs =
+                        {{.blob = blob1, .signature = sig1, .manifest = {}},
+                         {.blob = blob2, .signature = sig2, .manifest = {}}},
                     .version = version,
                     .expirations = {expiration1, expiration2}};
             };
@@ -1985,7 +2041,7 @@ private:
                 manifests,
                 manifests,
                 env.timeKeeper(),
-                env.app().config().legacy("database_path"),
+                env.app().config().legacy(Sections::kDatabasePath),
                 env.journal,
                 minimumQuorum);
 
@@ -2257,7 +2313,7 @@ private:
         auto extractProtocolMessage1 = [this, &extractHeader](Message& message) {
             auto [header, buffers] = extractHeader(message);
             if (BEAST_EXPECT(header) &&
-                BEAST_EXPECT(header->message_type == protocol::mtVALIDATOR_LIST))
+                BEAST_EXPECT(header->messageType == protocol::mtVALIDATOR_LIST))
             {
                 auto const msg =
                     detail::parseMessageContent(*header, buffers.data());
@@ -2269,7 +2325,7 @@ private:
         auto extractProtocolMessage2 = [this, &extractHeader](Message& message) {
             auto [header, buffers] = extractHeader(message);
             if (BEAST_EXPECT(header) &&
-                BEAST_EXPECT(header->message_type == protocol::mtVALIDATOR_LIST_COLLECTION))
+                BEAST_EXPECT(header->messageType == protocol::mtVALIDATOR_LIST_COLLECTION))
             {
                 auto const msg = detail::parseMessageContent(
                     *header, buffers.data());
@@ -2581,7 +2637,7 @@ private:
                 valManifests,
                 pubManifests,
                 env.timeKeeper(),
-                app.config().legacy("database_path"),
+                app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
             std::vector cfgPublishers;
diff --git a/src/test/app/ValidatorSite_test.cpp b/src/test/app/ValidatorSite_test.cpp
index f7f805faa2..8400f2d794 100644
--- a/src/test/app/ValidatorSite_test.cpp
+++ b/src/test/app/ValidatorSite_test.cpp
@@ -239,7 +239,7 @@ private:
             json::Value myStatus;
             for (auto const& vs : jv[jss::validator_sites])
             {
-                if (vs[jss::uri].asString().find(u.uri) != std::string::npos)
+                if (vs[jss::uri].asString().contains(u.uri))
                     myStatus = vs;
             }
             BEAST_EXPECTS(
@@ -248,9 +248,7 @@ private:
 
             if (!u.cfg.msg.empty())
             {
-                BEAST_EXPECTS(
-                    sink.messages().str().find(u.cfg.msg) != std::string::npos,
-                    sink.messages().str());
+                BEAST_EXPECTS(sink.messages().str().contains(u.cfg.msg), sink.messages().str());
             }
 
             if (u.cfg.expectedRefreshMin != 0)
@@ -324,7 +322,7 @@ private:
             json::Value myStatus;
             for (auto const& vs : jv[jss::validator_sites])
             {
-                if (vs[jss::uri].asString().find(u.uri) != std::string::npos)
+                if (vs[jss::uri].asString().contains(u.uri))
                     myStatus = vs;
             }
             BEAST_EXPECTS(
@@ -332,9 +330,7 @@ private:
                 to_string(myStatus));
             if (u.shouldFail)
             {
-                BEAST_EXPECTS(
-                    sink.messages().str().find(u.expectMsg) != std::string::npos,
-                    sink.messages().str());
+                BEAST_EXPECTS(sink.messages().str().contains(u.expectMsg), sink.messages().str());
             }
         }
     }
@@ -380,226 +376,333 @@ public:
         for (auto ssl : {true, false})
         {
             // fetch single site
-            testFetchList(good, {{"/validators", "", ssl}});
-            testFetchList(good, {{"/validators2", "", ssl}});
+            testFetchList(good, {{.path = "/validators", .msg = "", .ssl = ssl}});
+            testFetchList(good, {{.path = "/validators2", .msg = "", .ssl = ssl}});
             // fetch multiple sites
-            testFetchList(good, {{"/validators", "", ssl}, {"/validators", "", ssl}});
-            testFetchList(good, {{"/validators", "", ssl}, {"/validators2", "", ssl}});
-            testFetchList(good, {{"/validators2", "", ssl}, {"/validators", "", ssl}});
-            testFetchList(good, {{"/validators2", "", ssl}, {"/validators2", "", ssl}});
+            testFetchList(
+                good,
+                {{.path = "/validators", .msg = "", .ssl = ssl},
+                 {.path = "/validators", .msg = "", .ssl = ssl}});
+            testFetchList(
+                good,
+                {{.path = "/validators", .msg = "", .ssl = ssl},
+                 {.path = "/validators2", .msg = "", .ssl = ssl}});
+            testFetchList(
+                good,
+                {{.path = "/validators2", .msg = "", .ssl = ssl},
+                 {.path = "/validators", .msg = "", .ssl = ssl}});
+            testFetchList(
+                good,
+                {{.path = "/validators2", .msg = "", .ssl = ssl},
+                 {.path = "/validators2", .msg = "", .ssl = ssl}});
             // fetch single site with single redirects
-            testFetchList(good, {{"/redirect_once/301", "", ssl}});
-            testFetchList(good, {{"/redirect_once/302", "", ssl}});
-            testFetchList(good, {{"/redirect_once/307", "", ssl}});
-            testFetchList(good, {{"/redirect_once/308", "", ssl}});
+            testFetchList(good, {{.path = "/redirect_once/301", .msg = "", .ssl = ssl}});
+            testFetchList(good, {{.path = "/redirect_once/302", .msg = "", .ssl = ssl}});
+            testFetchList(good, {{.path = "/redirect_once/307", .msg = "", .ssl = ssl}});
+            testFetchList(good, {{.path = "/redirect_once/308", .msg = "", .ssl = ssl}});
             // one redirect, one not
-            testFetchList(good, {{"/validators", "", ssl}, {"/redirect_once/302", "", ssl}});
-            testFetchList(good, {{"/validators2", "", ssl}, {"/redirect_once/302", "", ssl}});
+            testFetchList(
+                good,
+                {{.path = "/validators", .msg = "", .ssl = ssl},
+                 {.path = "/redirect_once/302", .msg = "", .ssl = ssl}});
+            testFetchList(
+                good,
+                {{.path = "/validators2", .msg = "", .ssl = ssl},
+                 {.path = "/redirect_once/302", .msg = "", .ssl = ssl}});
             // UNLs with a "gap" between validUntil of one and validFrom of the
             // next
             testFetchList(
                 good,
-                {{"/validators2",
-                  "",
-                  ssl,
-                  false,
-                  false,
-                  1,
-                  detail::kDefaultExpires,
-                  std::chrono::seconds{-90}}});
+                {{.path = "/validators2",
+                  .msg = "",
+                  .ssl = ssl,
+                  .failFetch = false,
+                  .failApply = false,
+                  .serverVersion = 1,
+                  .expiresFromNow = detail::kDefaultExpires,
+                  .effectiveOverlap = std::chrono::seconds{-90}}});
             // fetch single site with unending redirect (fails to load)
             testFetchList(
-                good, {{"/redirect_forever/301", "Exceeded max redirects", ssl, true, true}});
+                good,
+                {{.path = "/redirect_forever/301",
+                  .msg = "Exceeded max redirects",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true}});
             // two that redirect forever
             testFetchList(
                 good,
-                {{"/redirect_forever/307", "Exceeded max redirects", ssl, true, true},
-                 {"/redirect_forever/308", "Exceeded max redirects", ssl, true, true}});
+                {{.path = "/redirect_forever/307",
+                  .msg = "Exceeded max redirects",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true},
+                 {.path = "/redirect_forever/308",
+                  .msg = "Exceeded max redirects",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true}});
             // one unending redirect, one not
             testFetchList(
                 good,
-                {{"/validators", "", ssl},
-                 {"/redirect_forever/302", "Exceeded max redirects", ssl, true, true}});
+                {{.path = "/validators", .msg = "", .ssl = ssl},
+                 {.path = "/redirect_forever/302",
+                  .msg = "Exceeded max redirects",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true}});
             // one unending redirect, one not
             testFetchList(
                 good,
-                {{"/validators2", "", ssl},
-                 {"/redirect_forever/302", "Exceeded max redirects", ssl, true, true}});
+                {{.path = "/validators2", .msg = "", .ssl = ssl},
+                 {.path = "/redirect_forever/302",
+                  .msg = "Exceeded max redirects",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true}});
             // invalid redir Location
             testFetchList(
                 good,
-                {{"/redirect_to/ftp://invalid-url/302",
-                  "Invalid redirect location",
-                  ssl,
-                  true,
-                  true}});
+                {{.path = "/redirect_to/ftp://invalid-url/302",
+                  .msg = "Invalid redirect location",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true}});
             testFetchList(
                 good,
-                {{"/redirect_to/file://invalid-url/302",
-                  "Invalid redirect location",
-                  ssl,
-                  true,
-                  true}});
+                {{.path = "/redirect_to/file://invalid-url/302",
+                  .msg = "Invalid redirect location",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true}});
             // invalid json
             testFetchList(
-                good, {{"/validators/bad", "Unable to parse JSON response", ssl, true, true}});
+                good,
+                {{.path = "/validators/bad",
+                  .msg = "Unable to parse JSON response",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true}});
             testFetchList(
-                good, {{"/validators2/bad", "Unable to parse JSON response", ssl, true, true}});
+                good,
+                {{.path = "/validators2/bad",
+                  .msg = "Unable to parse JSON response",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true}});
             // error status returned
-            testFetchList(good, {{"/bad-resource", "returned bad status", ssl, true, true}});
+            testFetchList(
+                good,
+                {{.path = "/bad-resource",
+                  .msg = "returned bad status",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true}});
             // location field missing
             testFetchList(
                 good,
-                {{"/redirect_nolo/308", "returned a redirect with no Location", ssl, true, true}});
+                {{.path = "/redirect_nolo/308",
+                  .msg = "returned a redirect with no Location",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true}});
             // json fields missing
             testFetchList(
                 good,
-                {{"/validators/missing", "Missing fields in JSON response", ssl, true, true}});
+                {{.path = "/validators/missing",
+                  .msg = "Missing fields in JSON response",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true}});
             testFetchList(
                 good,
-                {{"/validators2/missing", "Missing fields in JSON response", ssl, true, true}});
+                {{.path = "/validators2/missing",
+                  .msg = "Missing fields in JSON response",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true}});
             // timeout
-            testFetchList(good, {{"/sleep/13", "took too long", ssl, true, true}});
+            testFetchList(
+                good,
+                {{.path = "/sleep/13",
+                  .msg = "took too long",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true}});
             // bad manifest format using known versions
             // * Retrieves a v1 formatted list claiming version 2
-            testFetchList(good, {{"/validators", "Missing fields", ssl, true, true, 2}});
+            testFetchList(
+                good,
+                {{.path = "/validators",
+                  .msg = "Missing fields",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true,
+                  .serverVersion = 2}});
             // * Retrieves a v2 formatted list claiming version 1
-            testFetchList(good, {{"/validators2", "Missing fields", ssl, true, true, 0}});
+            testFetchList(
+                good,
+                {{.path = "/validators2",
+                  .msg = "Missing fields",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true,
+                  .serverVersion = 0}});
             // bad manifest version
             // Because versions other than 1 are treated as v2, the v1
             // list won't have the blobs_v2 fields, and thus will claim to have
             // missing fields
-            testFetchList(good, {{"/validators", "Missing fields", ssl, true, true, 4}});
-            testFetchList(good, {{"/validators2", "1 unsupported version", ssl, false, true, 4}});
+            testFetchList(
+                good,
+                {{.path = "/validators",
+                  .msg = "Missing fields",
+                  .ssl = ssl,
+                  .failFetch = true,
+                  .failApply = true,
+                  .serverVersion = 4}});
+            testFetchList(
+                good,
+                {{.path = "/validators2",
+                  .msg = "1 unsupported version",
+                  .ssl = ssl,
+                  .failFetch = false,
+                  .failApply = true,
+                  .serverVersion = 4}});
             using namespace std::chrono_literals;
             // get expired validator list
             testFetchList(
                 good,
-                {{"/validators", "Applied 1 expired validator list(s)", ssl, false, false, 1, 0s}});
+                {{.path = "/validators",
+                  .msg = "Applied 1 expired validator list(s)",
+                  .ssl = ssl,
+                  .failFetch = false,
+                  .failApply = false,
+                  .serverVersion = 1,
+                  .expiresFromNow = 0s}});
             testFetchList(
                 good,
-                {{"/validators2",
-                  "Applied 1 expired validator list(s)",
-                  ssl,
-                  false,
-                  false,
-                  1,
-                  0s,
-                  -1s}});
+                {{.path = "/validators2",
+                  .msg = "Applied 1 expired validator list(s)",
+                  .ssl = ssl,
+                  .failFetch = false,
+                  .failApply = false,
+                  .serverVersion = 1,
+                  .expiresFromNow = 0s,
+                  .effectiveOverlap = -1s}});
             // force an out-of-range validUntil value
             testFetchList(
                 good,
-                {{"/validators",
-                  "1 invalid validator list(s)",
-                  ssl,
-                  false,
-                  true,
-                  1,
-                  std::chrono::seconds{json::Value::kMinInt}}});
+                {{.path = "/validators",
+                  .msg = "1 invalid validator list(s)",
+                  .ssl = ssl,
+                  .failFetch = false,
+                  .failApply = true,
+                  .serverVersion = 1,
+                  .expiresFromNow = std::chrono::seconds{json::Value::kMinInt}}});
             // force an out-of-range validUntil value on the future list
             // The first list is accepted. The second fails. The parser
             // returns the "best" result, so this looks like a success.
             testFetchList(
                 good,
-                {{"/validators2",
-                  "",
-                  ssl,
-                  false,
-                  false,
-                  1,
-                  std::chrono::seconds{json::Value::kMaxInt - 300},
-                  299s}});
+                {{.path = "/validators2",
+                  .msg = "",
+                  .ssl = ssl,
+                  .failFetch = false,
+                  .failApply = false,
+                  .serverVersion = 1,
+                  .expiresFromNow = std::chrono::seconds{json::Value::kMaxInt - 300},
+                  .effectiveOverlap = 299s}});
             // force an out-of-range validFrom value
             // The first list is accepted. The second fails. The parser
             // returns the "best" result, so this looks like a success.
             testFetchList(
                 good,
-                {{"/validators2",
-                  "",
-                  ssl,
-                  false,
-                  false,
-                  1,
-                  std::chrono::seconds{json::Value::kMaxInt - 300},
-                  301s}});
+                {{.path = "/validators2",
+                  .msg = "",
+                  .ssl = ssl,
+                  .failFetch = false,
+                  .failApply = false,
+                  .serverVersion = 1,
+                  .expiresFromNow = std::chrono::seconds{json::Value::kMaxInt - 300},
+                  .effectiveOverlap = 301s}});
             // force an out-of-range validUntil value on _both_ lists
             testFetchList(
                 good,
-                {{"/validators2",
-                  "2 invalid validator list(s)",
-                  ssl,
-                  false,
-                  true,
-                  1,
-                  std::chrono::seconds{json::Value::kMinInt},
-                  std::chrono::seconds{json::Value::kMaxInt - 6000}}});
+                {{.path = "/validators2",
+                  .msg = "2 invalid validator list(s)",
+                  .ssl = ssl,
+                  .failFetch = false,
+                  .failApply = true,
+                  .serverVersion = 1,
+                  .expiresFromNow = std::chrono::seconds{json::Value::kMinInt},
+                  .effectiveOverlap = std::chrono::seconds{json::Value::kMaxInt - 6000}}});
             // verify refresh intervals are properly clamped
             testFetchList(
                 good,
-                {{"/validators/refresh/0",
-                  "",
-                  ssl,
-                  false,
-                  false,
-                  1,
-                  detail::kDefaultExpires,
-                  detail::kDefaultEffectiveOverlap,
-                  1}});  // minimum of 1 minute
+                {{.path = "/validators/refresh/0",
+                  .msg = "",
+                  .ssl = ssl,
+                  .failFetch = false,
+                  .failApply = false,
+                  .serverVersion = 1,
+                  .expiresFromNow = detail::kDefaultExpires,
+                  .effectiveOverlap = detail::kDefaultEffectiveOverlap,
+                  .expectedRefreshMin = 1}});  // minimum of 1 minute
             testFetchList(
                 good,
-                {{"/validators2/refresh/0",
-                  "",
-                  ssl,
-                  false,
-                  false,
-                  1,
-                  detail::kDefaultExpires,
-                  detail::kDefaultEffectiveOverlap,
-                  1}});  // minimum of 1 minute
+                {{.path = "/validators2/refresh/0",
+                  .msg = "",
+                  .ssl = ssl,
+                  .failFetch = false,
+                  .failApply = false,
+                  .serverVersion = 1,
+                  .expiresFromNow = detail::kDefaultExpires,
+                  .effectiveOverlap = detail::kDefaultEffectiveOverlap,
+                  .expectedRefreshMin = 1}});  // minimum of 1 minute
             testFetchList(
                 good,
-                {{"/validators/refresh/10",
-                  "",
-                  ssl,
-                  false,
-                  false,
-                  1,
-                  detail::kDefaultExpires,
-                  detail::kDefaultEffectiveOverlap,
-                  10}});  // 10 minutes is fine
+                {{.path = "/validators/refresh/10",
+                  .msg = "",
+                  .ssl = ssl,
+                  .failFetch = false,
+                  .failApply = false,
+                  .serverVersion = 1,
+                  .expiresFromNow = detail::kDefaultExpires,
+                  .effectiveOverlap = detail::kDefaultEffectiveOverlap,
+                  .expectedRefreshMin = 10}});  // 10 minutes is fine
             testFetchList(
                 good,
-                {{"/validators2/refresh/10",
-                  "",
-                  ssl,
-                  false,
-                  false,
-                  1,
-                  detail::kDefaultExpires,
-                  detail::kDefaultEffectiveOverlap,
-                  10}});  // 10 minutes is fine
+                {{.path = "/validators2/refresh/10",
+                  .msg = "",
+                  .ssl = ssl,
+                  .failFetch = false,
+                  .failApply = false,
+                  .serverVersion = 1,
+                  .expiresFromNow = detail::kDefaultExpires,
+                  .effectiveOverlap = detail::kDefaultEffectiveOverlap,
+                  .expectedRefreshMin = 10}});  // 10 minutes is fine
             testFetchList(
                 good,
-                {{"/validators/refresh/2000",
-                  "",
-                  ssl,
-                  false,
-                  false,
-                  1,
-                  detail::kDefaultExpires,
-                  detail::kDefaultEffectiveOverlap,
-                  60 * 24}});  // max of 24 hours
+                {{.path = "/validators/refresh/2000",
+                  .msg = "",
+                  .ssl = ssl,
+                  .failFetch = false,
+                  .failApply = false,
+                  .serverVersion = 1,
+                  .expiresFromNow = detail::kDefaultExpires,
+                  .effectiveOverlap = detail::kDefaultEffectiveOverlap,
+                  .expectedRefreshMin = 60 * 24}});  // max of 24 hours
             testFetchList(
                 good,
-                {{"/validators2/refresh/2000",
-                  "",
-                  ssl,
-                  false,
-                  false,
-                  1,
-                  detail::kDefaultExpires,
-                  detail::kDefaultEffectiveOverlap,
-                  60 * 24}});  // max of 24 hours
+                {{.path = "/validators2/refresh/2000",
+                  .msg = "",
+                  .ssl = ssl,
+                  .failFetch = false,
+                  .failApply = false,
+                  .serverVersion = 1,
+                  .expiresFromNow = detail::kDefaultExpires,
+                  .effectiveOverlap = detail::kDefaultEffectiveOverlap,
+                  .expectedRefreshMin = 60 * 24}});  // max of 24 hours
         }
         using namespace boost::filesystem;
         for (auto const& file : directory_iterator(good.subdir()))
diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp
index 318a6dc45c..9b29442197 100644
--- a/src/test/app/Vault_test.cpp
+++ b/src/test/app/Vault_test.cpp
@@ -1,6 +1,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -9,9 +10,12 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -35,6 +39,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -57,6 +62,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -108,7 +114,7 @@ class Vault_test : public beast::unit_test::Suite
                 {
                     BEAST_EXPECT(vault->at(sfScale) == 0);
                 }
-                auto const shares = env.le(keylet::mptIssuance(vault->at(sfShareMPTID)));
+                auto const shares = env.le(keylet::mptokenIssuance(vault->at(sfShareMPTID)));
                 BEAST_EXPECT(shares != nullptr);
                 if (!asset.integral())
                 {
@@ -1568,6 +1574,7 @@ class Vault_test : public beast::unit_test::Suite
             bool enableClawback = true;
             bool requireAuth = true;
             int initialXRP = 1000;
+            FeatureBitset features = testableAmendments();
         };
 
         auto testCase = [this](
@@ -1580,7 +1587,7 @@ class Vault_test : public beast::unit_test::Suite
                                 Vault& vault,
                                 MPTTester& mptt)> test,
                             CaseArgs args = {}) {
-            Env env{*this, testableAmendments()};
+            Env env{*this, args.features};
             Account const issuer{"issuer"};
             Account const owner{"owner"};
             Account const depositor{"depositor"};
@@ -1594,7 +1601,7 @@ class Vault_test : public beast::unit_test::Suite
                 {.flags = tfMPTCanTransfer | tfMPTCanLock |
                      (args.enableClawback ? tfMPTCanClawback : kNone) |
                      (args.requireAuth ? tfMPTRequireAuth : kNone),
-                 .mutableFlags = tmfMPTCanMutateCanTransfer});
+                 .mutableFlags = tmfMPTCanEnableCanTransfer});
             PrettyAsset const asset = mptt.issuanceID();
             mptt.authorize({.account = owner});
             mptt.authorize({.account = depositor});
@@ -1627,6 +1634,8 @@ class Vault_test : public beast::unit_test::Suite
             env(tx, Ter(tecNO_ENTRY));
         });
 
+        // Freeze/lock tests are in testVaultDepositFreeze/testVaultWithdrawFreeze
+
         testCase([this](
                      Env& env,
                      Account const& issuer,
@@ -1641,81 +1650,6 @@ class Vault_test : public beast::unit_test::Suite
             env(tx, Ter(tecLOCKED));
         });
 
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault,
-                     MPTTester& mptt) {
-            testcase("MPT global lock blocks deposit");
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            mptt.set({.account = issuer, .flags = tfMPTLock});
-            env.close();
-
-            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-            env(tx, Ter{tecLOCKED});
-            env.close();
-
-            // Can delete empty vault, even if global lock
-            tx = vault.del({.owner = owner, .id = keylet.key});
-            env(tx);
-        });
-
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault,
-                     MPTTester& mptt) {
-            testcase("MPT global lock blocks withdrawal");
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-            env(tx);
-            env.close();
-
-            // Check that the OutstandingAmount field of MPTIssuance
-            // accounts for the issued shares.
-            auto v = env.le(keylet);
-            BEAST_EXPECT(v);
-            MPTID const share = (*v)[sfShareMPTID];
-            auto issuance = env.le(keylet::mptIssuance(share));
-            BEAST_EXPECT(issuance);
-            Number const outstandingShares = issuance->at(sfOutstandingAmount);
-            BEAST_EXPECT(outstandingShares == 100);
-
-            mptt.set({.account = issuer, .flags = tfMPTLock});
-            env.close();
-
-            tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-            env(tx, Ter(tecLOCKED));
-
-            tx[sfDestination] = issuer.human();
-            env(tx, Ter(tecLOCKED));
-
-            // Clawback is still permitted, even with global lock
-            tx = vault.clawback(
-                {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
-            env(tx);
-            env.close();
-
-            // Clawback removed shares MPToken
-            auto const mptSle = env.le(keylet::mptoken(share, depositor.id()));
-            BEAST_EXPECT(mptSle == nullptr);
-
-            // Can delete empty vault, even if global lock
-            tx = vault.del({.owner = owner, .id = keylet.key});
-            env(tx);
-        });
-
         testCase([this](
                      Env& env,
                      Account const& issuer,
@@ -2150,57 +2084,6 @@ class Vault_test : public beast::unit_test::Suite
             env(vault.del({.owner = owner, .id = keylet.key}));
         });
 
-        testCase([this](
-                     Env& env,
-                     Account const& issuer,
-                     Account const& owner,
-                     Account const& depositor,
-                     Asset const& asset,
-                     Vault& vault,
-                     MPTTester& mptt) {
-            testcase("MPT lock of vault pseudo-account");
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            auto const vaultAccount = [&env, keylet = keylet, this]() -> AccountID {
-                auto const vault = env.le(keylet);
-                BEAST_EXPECT(vault != nullptr);
-                return vault->at(sfAccount);
-            }();
-
-            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-            env(tx);
-            env.close();
-
-            tx = [&]() {
-                json::Value jv;
-                jv[jss::Account] = issuer.human();
-                jv[sfMPTokenIssuanceID] = to_string(asset.get().getMptID());
-                jv[jss::Holder] = toBase58(vaultAccount);
-                jv[jss::TransactionType] = jss::MPTokenIssuanceSet;
-                jv[jss::Flags] = tfMPTLock;
-                return jv;
-            }();
-            env(tx);
-            env.close();
-
-            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-            env(tx, Ter(tecLOCKED));
-
-            tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
-            env(tx, Ter(tecLOCKED));
-
-            // Clawback works, even when locked
-            tx = vault.clawback(
-                {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(100)});
-            env(tx);
-
-            // Can delete an empty vault even when asset is locked.
-            tx = vault.del({.owner = owner, .id = keylet.key});
-            env(tx);
-        });
-
         {
             testcase("MPT shares to a vault");
 
@@ -2233,46 +2116,156 @@ class Vault_test : public beast::unit_test::Suite
             env.close();
         }
 
-        testCase([this](
-                     Env& env,
-                     Account const&,
-                     Account const& owner,
-                     Account const& depositor,
-                     PrettyAsset const& asset,
-                     Vault& vault,
-                     MPTTester& mptt) {
-            testcase("MPT non-transferable");
+        {
+            testcase("MPT locked: vault shares inherit underlying lock");
+
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const carol{"carol"};
+            env.fund(XRP(10'000), issuer, owner, alice, bob, carol);
+            env.close();
+            Vault const vault{env};
+
+            MPTTester asset{
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {owner, alice, bob, carol},
+                 .flags = tfMPTCanTransfer | tfMPTCanTrade | tfMPTCanLock}};
+            env(pay(issuer, alice, asset(1'000)));
+            env(pay(issuer, bob, asset(1'000)));
+            env.close();
 
             auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
             env(tx);
             env.close();
 
-            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(500)}));
+            // Bob also deposits so he has a share MPToken to receive into.
+            env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(500)}));
+            env.close();
+
+            auto const shares = [&]() -> PrettyAsset {
+                auto const sle = env.le(keylet);
+                BEAST_EXPECT(sle != nullptr);
+                return MPTIssue(sle->at(sfShareMPTID));
+            }();
+            auto const shareMptID = shares.raw().get().getMptID();
+            auto const shareBalance = [&](Account const& account) {
+                auto const sle = env.le(keylet::mptoken(shareMptID, account));
+                return sle ? sle->at(sfMPTAmount) : 0;
+            };
+
+            // Sanity: before the underlying lock, peer-to-peer share
+            // transfers are allowed.
+            env(pay(alice, bob, shares(1)));
+            env.close();
+
+            // Create the offer while shares are spendable, then lock the
+            // underlying to test whether a stale offer can still be crossed.
+            env(offer(alice, XRP(1), shares(1)));
+            env.close();
+
+            // Lock the underlying after the vault and share balances exist.
+            asset.set({.account = issuer, .flags = tfMPTLock});
+            env.close();
+
+            // Direct vault share payment inherits the underlying lock via
+            // sfReferenceHolding.
+            BEAST_EXPECT(shareBalance(alice) == 499);
+            BEAST_EXPECT(shareBalance(bob) == 501);
+            env(pay(alice, bob, shares(1)), Ter{tecLOCKED});
+            env.close();
+            BEAST_EXPECT(shareBalance(alice) == 499);
+            BEAST_EXPECT(shareBalance(bob) == 501);
+
+            // The same inherited lock must also block DEX payment paths that
+            // would consume an offer selling vault shares.
+            env(pay(carol, bob, shares(1)),
+                Sendmax(XRP(1)),
+                Path(BookSpec{shares.raw()}),
+                Ter{tecPATH_PARTIAL});
+            env.close();
+            BEAST_EXPECT(shareBalance(alice) == 499);
+            BEAST_EXPECT(shareBalance(bob) == 501);
+            BEAST_EXPECT(expectOffers(env, alice, 1));
+        }
+
+        {
+            testcase("MPT CanTrade governance: share inherits underlying on DEX and AMM");
+
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            env.fund(XRP(100'000), issuer, owner, alice, bob);
+            env.close();
+            Vault const vault{env};
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create(
+                {.flags = tfMPTCanTransfer | tfMPTCanLock,
+                 .mutableFlags = tmfMPTCanEnableCanTrade});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = alice});
+            mptt.authorize({.account = bob});
+            env(pay(issuer, alice, asset(10'000)));
+            env(pay(issuer, bob, asset(10'000)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
             env(tx);
             env.close();
 
-            // Remove CanTransfer
-            mptt.set({.mutableFlags = tmfMPTClearCanTransfer});
+            // Seed shares so we can later place them on trading venues.
+            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(5'000)}));
+            env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(5'000)}));
             env.close();
 
-            env(tx, Ter{tecNO_AUTH});
+            auto const shares = [&]() -> PrettyAsset {
+                auto const sle = env.le(keylet);
+                BEAST_EXPECT(sle != nullptr);
+                return MPTIssue(sle->at(sfShareMPTID));
+            }();
+
+            // CanTrade is not set on the underlying, both the asset and
+            // the vault share are blocked on the DEX.
+            env(offer(alice, XRP(1), asset(10)), Ter{tecNO_PERMISSION});
+            env(offer(alice, XRP(1), shares(1)), Ter{tecNO_PERMISSION});
             env.close();
 
-            tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+            // The inherited CanTrade restriction also blocks AMM creation.
+            AMM const ammUnderlyingFail(
+                env, alice, XRP(1'000), asset(1'000), Ter{tecNO_PERMISSION});
+            AMM const ammShares(env, alice, XRP(1'000), shares(100), Ter{tecNO_PERMISSION});
 
-            env(tx, Ter{tecNO_AUTH});
+            // Deposit still works before enabling CanTrade.
+            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(100)}));
             env.close();
 
-            // Restore CanTransfer
-            mptt.set({.mutableFlags = tmfMPTSetCanTransfer});
+            // Peer-to-peer share transfers still work (CanTransfer is set on
+            // both layers).
+            env(pay(alice, bob, shares(1)));
             env.close();
 
-            env(tx);
+            // Withdraw still works before enabling CanTrade.
+            env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = asset(100)}));
             env.close();
 
-            // Delete vault with zero balance
-            env(vault.del({.owner = owner, .id = keylet.key}));
-        });
+            // Enable CanTrade on the underlying.
+            mptt.set({.mutableFlags = tmfMPTSetCanTrade});
+            env.close();
+
+            env(offer(alice, XRP(1), asset(10)));
+            env(offer(alice, XRP(1), shares(1)));
+            env.close();
+
+            AMM const ammUnderlying(env, alice, XRP(1'000), asset(1'000));
+        }
 
         {
             testcase("MPT OutstandingAmount > MaximumAmount");
@@ -2323,6 +2316,7 @@ class Vault_test : public beast::unit_test::Suite
             Number initialIOU = 200;
             double transferRate = 1.0;
             bool charlieRipple = true;
+            FeatureBitset features = testableAmendments();
         };
 
         auto testCase = [&, this](
@@ -2336,7 +2330,7 @@ class Vault_test : public beast::unit_test::Suite
                                 PrettyAsset const& asset,
                                 std::function issuanceId)> test,
                             CaseArgs args = {}) {
-            Env env{*this, testableAmendments()};
+            Env env{*this, args.features};
             Account const owner{"owner"};
             Account const issuer{"issuer"};
             Account const charlie{"charlie"};
@@ -2428,83 +2422,6 @@ class Vault_test : public beast::unit_test::Suite
             env.close();
         });
 
-        testCase([&, this](
-                     Env& env,
-                     Account const& owner,
-                     Account const& issuer,
-                     Account const& charlie,
-                     auto vaultAccount,
-                     Vault& vault,
-                     PrettyAsset const& asset,
-                     auto issuanceId) {
-            testcase("IOU frozen trust line to vault account");
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
-            env.close();
-
-            Asset const share = Asset(issuanceId(keylet));
-
-            // Freeze the trustline to the vault
-            auto trustSet = [&, account = vaultAccount(keylet)]() {
-                json::Value jv;
-                jv[jss::Account] = issuer.human();
-                {
-                    auto& ja = jv[jss::LimitAmount] =
-                        asset(0).value().getJson(JsonOptions::Values::None);
-                    ja[jss::issuer] = toBase58(account);
-                }
-                jv[jss::TransactionType] = jss::TrustSet;
-                jv[jss::Flags] = tfSetFreeze;
-                return jv;
-            }();
-            env(trustSet);
-            env.close();
-
-            {
-                // Note, the "frozen" state of the trust line to vault account
-                // is reported as  "locked" state of the vault shares, because
-                // this state is attached to shares by means of the transitive
-                // isFrozen.
-                auto tx =
-                    vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(80)});
-                env(tx, Ter{tecLOCKED});
-            }
-
-            {
-                auto tx =
-                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(100)});
-                env(tx, Ter{tecLOCKED});
-
-                // also when trying to withdraw to a 3rd party
-                tx[sfDestination] = charlie.human();
-                env(tx, Ter{tecLOCKED});
-                env.close();
-            }
-
-            {
-                // Clawback works, even when locked
-                auto tx = vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)});
-                env(tx);
-                env.close();
-            }
-
-            // Clear the frozen state
-            trustSet[jss::Flags] = tfClearFreeze;
-            env(trustSet);
-            env.close();
-
-            env(vault.withdraw(
-                {.depositor = owner, .id = keylet.key, .amount = share(50'000'000)}));
-
-            env(vault.del({.owner = owner, .id = keylet.key}));
-            env.close();
-        });
-
         testCase(
             [&, this](
                 Env& env,
@@ -2566,65 +2483,6 @@ class Vault_test : public beast::unit_test::Suite
             },
             CaseArgs{.transferRate = 1.25});
 
-        testCase([&, this](
-                     Env& env,
-                     Account const& owner,
-                     Account const& issuer,
-                     Account const& charlie,
-                     auto,
-                     Vault& vault,
-                     PrettyAsset const& asset,
-                     auto&&...) {
-            testcase("IOU frozen trust line to depositor");
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
-            env.close();
-
-            // Withdraw to 3rd party works
-            auto const withdrawToCharlie = [&](xrpl::Keylet keylet) {
-                auto tx =
-                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-                tx[sfDestination] = charlie.human();
-                return tx;
-            }(keylet);
-            env(withdrawToCharlie);
-
-            // Freeze the owner
-            env(trust(issuer, asset(0), owner, tfSetFreeze));
-            env.close();
-
-            // Cannot withdraw
-            auto const withdraw =
-                vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-            env(withdraw, Ter{tecFROZEN});
-
-            // Cannot withdraw to 3rd party
-            env(withdrawToCharlie, Ter{tecLOCKED});
-            env.close();
-
-            {
-                // Cannot deposit some more
-                auto tx =
-                    vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-                env(tx, Ter{tecFROZEN});
-            }
-
-            {
-                // Clawback still works
-                auto tx = vault.clawback(
-                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)});
-                env(tx);
-                env.close();
-            }
-
-            env(vault.del({.owner = owner, .id = keylet.key}));
-            env.close();
-        });
-
         testCase([&, this](
                      Env& env,
                      Account const& owner,
@@ -2679,7 +2537,7 @@ class Vault_test : public beast::unit_test::Suite
             env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)}));
             env.close();
 
-            auto trustline = env.le(keylet::line(owner, asset.raw().get()));
+            auto trustline = env.le(keylet::trustLine(owner, asset.raw().get()));
             BEAST_EXPECT(trustline == nullptr);
 
             // Withdraw without trust line, will succeed
@@ -2744,16 +2602,17 @@ class Vault_test : public beast::unit_test::Suite
                         env(tx);
                         env.close();
                     }
-                    env(pay(owner, charlie, shares(100)));
-                    env.close();
-
-                    // Charlie cannot withdraw
-                    auto tx3 = vault.withdraw(
-                        {.depositor = charlie, .id = keylet.key, .amount = shares(100)});
-                    env(tx3, Ter{terNO_RIPPLE});
-                    env.close();
-
-                    env(pay(charlie, owner, shares(100)));
+                    // Behavioral shift introduced by share inheritance:
+                    // before fixCleanup3_2_0 this share Payment succeeded
+                    // and the underlying IOU's NoRipple restriction surfaced
+                    // only later on Charlie's withdrawal (terNO_RIPPLE).
+                    // Post-amendment, canTransfer reads the share's
+                    // sfReferenceHolding and dispatches to the underlying IOU;
+                    // rippling is disabled between owner and charlie so the
+                    // share payment itself is now blocked. tecPATH_DRY is
+                    // the path-find layer's translation of the underlying
+                    // terNO_RIPPLE under featureMPTokensV2.
+                    env(pay(owner, charlie, shares(100)), Ter{tecPATH_DRY});
                     env.close();
                 }
 
@@ -2861,7 +2720,7 @@ class Vault_test : public beast::unit_test::Suite
                 env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)}));
                 env.close();
 
-                auto trustline = env.le(keylet::line(owner, asset.raw().get()));
+                auto trustline = env.le(keylet::trustLine(owner, asset.raw().get()));
                 BEAST_EXPECT(trustline == nullptr);
 
                 env(ticket::create(owner, 1));
@@ -2915,102 +2774,6 @@ class Vault_test : public beast::unit_test::Suite
                 env.close();
             },
             CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1});
-
-        testCase([&, this](
-                     Env& env,
-                     Account const& owner,
-                     Account const& issuer,
-                     Account const& charlie,
-                     auto,
-                     Vault& vault,
-                     PrettyAsset const& asset,
-                     auto&&...) {
-            testcase("IOU frozen trust line to 3rd party");
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
-            env.close();
-
-            // Withdraw to 3rd party works
-            auto const withdrawToCharlie = [&](xrpl::Keylet keylet) {
-                auto tx =
-                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-                tx[sfDestination] = charlie.human();
-                return tx;
-            }(keylet);
-            env(withdrawToCharlie);
-
-            // Freeze the 3rd party
-            env(trust(issuer, asset(0), charlie, tfSetFreeze));
-            env.close();
-
-            // Can withdraw
-            auto const withdraw =
-                vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-            env(withdraw);
-            env.close();
-
-            // Cannot withdraw to 3rd party
-            env(withdrawToCharlie, Ter{tecFROZEN});
-            env.close();
-
-            env(vault.clawback(
-                {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)}));
-            env.close();
-
-            env(vault.del({.owner = owner, .id = keylet.key}));
-            env.close();
-        });
-
-        testCase([&, this](
-                     Env& env,
-                     Account const& owner,
-                     Account const& issuer,
-                     Account const& charlie,
-                     auto,
-                     Vault& vault,
-                     PrettyAsset const& asset,
-                     auto&&...) {
-            testcase("IOU global freeze");
-
-            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
-            env(tx);
-            env.close();
-
-            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
-            env.close();
-
-            env(fset(issuer, asfGlobalFreeze));
-            env.close();
-
-            {
-                // Cannot withdraw
-                auto tx =
-                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-                env(tx, Ter{tecFROZEN});
-
-                // Cannot withdraw to 3rd party
-                tx[sfDestination] = charlie.human();
-                env(tx, Ter{tecFROZEN});
-                env.close();
-
-                // Cannot deposit some more
-                tx = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
-
-                env(tx, Ter{tecFROZEN});
-            }
-
-            // Clawback is permitted
-            env(vault.clawback(
-                {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)}));
-            env.close();
-
-            env(vault.del({.owner = owner, .id = keylet.key}));
-            env.close();
-        });
     }
 
     void
@@ -3289,7 +3052,7 @@ class Vault_test : public beast::unit_test::Suite
             return {vault->at(sfAccount), vault->at(sfShareMPTID)};
         }();
         BEAST_EXPECT(env.le(keylet::account(vaultAccount)));
-        BEAST_EXPECT(env.le(keylet::mptIssuance(issuanceId)));
+        BEAST_EXPECT(env.le(keylet::mptokenIssuance(issuanceId)));
         PrettyAsset const shares{issuanceId};
 
         {
@@ -3443,7 +3206,7 @@ class Vault_test : public beast::unit_test::Suite
                         auto vault = sb.peek(keylet::vault(keylet.key));
                         if (!BEAST_EXPECT(vault))
                             return false;
-                        auto shares = sb.peek(keylet::mptIssuance(vault->at(sfShareMPTID)));
+                        auto shares = sb.peek(keylet::mptokenIssuance(vault->at(sfShareMPTID)));
                         if (!BEAST_EXPECT(shares))
                             return false;
                         if (fn(*vault, *shares))
@@ -4192,7 +3955,7 @@ class Vault_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
 
             // Create a loan broker backed by this vault
-            auto const brokerKeylet = keylet::loanbroker(d.owner.id(), env.seq(d.owner));
+            auto const brokerKeylet = keylet::loanBroker(d.owner.id(), env.seq(d.owner));
             env(set(d.owner, d.keylet.key));
             env.close();
 
@@ -4667,7 +4430,7 @@ class Vault_test : public beast::unit_test::Suite
             auto const sleVault = env.le(vaultKeylet);
             BEAST_EXPECT(sleVault != nullptr);
 
-            auto const sleIssuance = env.le(keylet::mptIssuance(sleVault->at(sfShareMPTID)));
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
             BEAST_EXPECT(sleIssuance != nullptr);
 
             return sleIssuance->at(sfOutstandingAmount);
@@ -4706,7 +4469,7 @@ class Vault_test : public beast::unit_test::Suite
             env.close();
 
             auto const& sharesAvailable = vaultShareBalance(vaultKeylet);
-            auto const& brokerKeylet = keylet::loanbroker(owner.id(), env.seq(owner));
+            auto const& brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner));
 
             env(set(owner, vaultKeylet.key));
             env.close();
@@ -5099,7 +4862,7 @@ class Vault_test : public beast::unit_test::Suite
                 PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
 
                 // Create a loan broker backed by this vault
-                auto const brokerKeylet = keylet::loanbroker(owner.id(), env.seq(owner));
+                auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner));
                 env(set(owner, vaultKeylet.key));
                 env.close();
 
@@ -5157,7 +4920,7 @@ class Vault_test : public beast::unit_test::Suite
                 PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
 
                 // Create a loan broker backed by this vault
-                auto const brokerKeylet = keylet::loanbroker(owner.id(), env.seq(owner));
+                auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner));
                 env(set(owner, vaultKeylet.key));
                 env.close();
 
@@ -5212,7 +4975,7 @@ class Vault_test : public beast::unit_test::Suite
                 PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
 
                 // Create a loan broker backed by this vault
-                auto const brokerKeylet = keylet::loanbroker(owner.id(), env.seq(owner));
+                auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner));
                 env(set(owner, vaultKeylet.key));
                 env.close();
 
@@ -5266,7 +5029,7 @@ class Vault_test : public beast::unit_test::Suite
                     return;
                 PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
 
-                auto const brokerKeylet = keylet::loanbroker(owner.id(), env.seq(owner));
+                auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner));
                 env(set(owner, vaultKeylet.key));
                 env.close();
 
@@ -5314,7 +5077,7 @@ class Vault_test : public beast::unit_test::Suite
                     return;
                 PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
 
-                auto const brokerKeylet = keylet::loanbroker(owner.id(), env.seq(owner));
+                auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner));
                 env(set(owner, vaultKeylet.key));
                 env.close();
 
@@ -5421,7 +5184,7 @@ class Vault_test : public beast::unit_test::Suite
             PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
 
             // Create a loan broker backed by this vault
-            auto const brokerKeylet = keylet::loanbroker(owner.id(), env.seq(owner));
+            auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner));
             env(set(owner, vaultKeylet.key));
             env.close();
 
@@ -6140,6 +5903,1535 @@ class Vault_test : public beast::unit_test::Suite
         runTest(amendments);
     }
 
+    void
+    testRemoveEmptyHoldingConfidentialBalances()
+    {
+        testcase("removeEmptyHolding keeps MPToken with confidential balances");
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+
+        Account const issuer{"issuer"};
+        Account const holder{"holder"};
+        MPTTester mpt{env, issuer, {.holders = {holder}}};
+        mpt.create({.authorize = MPTCreate::allHolders});
+
+        auto const tokenKeylet = keylet::mptoken(mpt.issuanceID(), holder.id());
+        auto const encryptedBalanceFields = {
+            &sfConfidentialBalanceInbox,
+            &sfConfidentialBalanceSpending,
+            &sfIssuerEncryptedBalance,
+            &sfAuditorEncryptedBalance};
+
+        env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal j) {
+            for (auto const field : encryptedBalanceFields)
+            {
+                Sandbox sb(&view, TapNone);
+                auto const token = sb.peek(tokenKeylet);
+                if (!BEAST_EXPECT(token))
+                    return false;
+
+                token->setFieldVL(*field, gMakeZeroBuffer(kEcGamalEncryptedTotalLength));
+                sb.update(token);
+
+                BEAST_EXPECT(
+                    removeEmptyHolding(sb, holder.id(), MPTIssue(mpt.issuanceID()), j) ==
+                    tecHAS_OBLIGATIONS);
+                BEAST_EXPECT(sb.peek(tokenKeylet) != nullptr);
+            }
+            return true;
+        });
+    }
+
+    // -----------------------------------------------------------------------
+    // Helpers and tests: sole-shareholder / stuck-depositor (XLS-0065 +
+    // fixCleanup3_2_0). The vault-level withdraw behavior is tested here;
+    // the loan-protocol setup is incidental.
+    // -----------------------------------------------------------------------
+
+    FeatureBitset const all_{test::jtx::testableAmendments()};
+    std::string const iouCurrency_{"IOU"};
+
+    // design doc:
+    //     AssetsAvailable ≈ 3,333.50
+    //     AssetsTotal     ≈ 6,666.50  (3,333.50 cash + 3,333 receivable)
+    //     LossUnrealized  =  3,333
+    //     OutstandingShares = sharesLender   (5e9 at IOU scale 1e6)
+    struct StuckDepositorFixture
+    {
+        test::jtx::Account issuer{"issuer"};
+        test::jtx::Account lender{"lender"};
+        test::jtx::Account bob{"bob"};
+        test::jtx::Account borrower{"borrower"};
+        std::optional asset;
+        std::optional vaultKeylet;
+        uint256 brokerID;
+        std::optional loanKeylet;
+        MPTID shareAsset;
+        std::uint64_t sharesLender = 0;
+    };
+
+    static constexpr std::int64_t kStuckFunding = 1'000'000;
+    static constexpr std::int64_t kStuckDepositorIOU = 1'000'000;
+    static constexpr std::int64_t kStuckBorrowerIOU = 100'000;
+    static constexpr std::int64_t kStuckDeposit = 5'000;
+    static constexpr std::int64_t kStuckPrincipal = 3'333;
+    static constexpr std::uint32_t kStuckPayInterval = 600;
+    static constexpr std::uint32_t kStuckPayTotal = 2;
+
+    [[nodiscard]] StuckDepositorFixture
+    setupStuckDepositor(test::jtx::Env& env)
+    {
+        using namespace test::jtx;
+
+        StuckDepositorFixture f;
+        f.asset = f.issuer[iouCurrency_];
+
+        env.fund(XRP(kStuckFunding), f.issuer, f.lender, f.bob, f.borrower);
+        env.close();
+
+        env(trust(f.lender, (*f.asset)(10'000'000)));
+        env(trust(f.bob, (*f.asset)(10'000'000)));
+        env(trust(f.borrower, (*f.asset)(10'000'000)));
+        env.close();
+
+        env(pay(f.issuer, f.lender, (*f.asset)(kStuckDepositorIOU)));
+        env(pay(f.issuer, f.bob, (*f.asset)(kStuckDepositorIOU)));
+        env(pay(f.issuer, f.borrower, (*f.asset)(kStuckBorrowerIOU)));
+        env.close();
+
+        // Vault: Lender creates and seeds it; Bob matches the deposit for a
+        // clean 50/50 split.
+        Vault const v{env};
+        auto [createTx, vaultKeylet] = v.create({.owner = f.lender, .asset = *f.asset});
+        env(createTx);
+        env.close();
+        if (!BEAST_EXPECT(env.le(vaultKeylet)))
+            return f;
+        f.vaultKeylet = vaultKeylet;
+
+        env(v.deposit({
+                .depositor = f.lender,
+                .id = vaultKeylet.key,
+                .amount = (*f.asset)(kStuckDeposit),
+            }),
+            Ter(tesSUCCESS));
+        env(v.deposit({
+                .depositor = f.bob,
+                .id = vaultKeylet.key,
+                .amount = (*f.asset)(kStuckDeposit),
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // Loan broker: no cover, no management fee, debt cap 10x principal.
+        f.brokerID = keylet::loanBroker(f.lender.id(), env.seq(f.lender)).key;
+        {
+            using namespace loanBroker;
+            env(set(f.lender, vaultKeylet.key),
+                kDebtMaximum((*f.asset)(kStuckPrincipal * 10).value()));
+            env.close();
+        }
+
+        // Loan: 3,333 USD principal, impaired immediately.
+        auto const sleBroker = env.le(keylet::loanBroker(f.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return f;
+        f.loanKeylet = keylet::loan(f.brokerID, sleBroker->at(sfLoanSequence));
+
+        {
+            using namespace loan;
+            env(set(f.borrower, f.brokerID, kStuckPrincipal),
+                Sig(sfCounterpartySignature, f.lender),
+                kPaymentTotal(kStuckPayTotal),
+                kPaymentInterval(kStuckPayInterval),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+            env(manage(f.lender, f.loanKeylet->key, tfLoanImpair), Ter(tesSUCCESS));
+            env.close();
+        }
+
+        auto const vaultSle = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultSle))
+            return f;
+        BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
+
+        f.shareAsset = vaultSle->at(sfShareMPTID);
+
+        auto const tokenBob = env.le(keylet::mptoken(f.shareAsset, f.bob.id()));
+        if (!BEAST_EXPECT(tokenBob))
+            return f;
+        std::uint64_t const sharesBob = tokenBob->getFieldU64(sfMPTAmount);
+
+        // Bob (non-sole) exits at the discounted rate. Always succeeds.
+        STAmount const bobShareAmt{MPTIssue{f.shareAsset}, Number(sharesBob)};
+        env(v.withdraw({
+                .depositor = f.bob,
+                .id = vaultKeylet.key,
+                .amount = bobShareAmt,
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const tokenLender = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
+        if (!BEAST_EXPECT(tokenLender))
+            return f;
+        f.sharesLender = tokenLender->getFieldU64(sfMPTAmount);
+
+        auto const sleIssuance = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(sleIssuance))
+            return f;
+        BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == f.sharesLender);
+
+        auto const vaultAfterBob = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultAfterBob))
+            return f;
+        // After Bob's exit: loss is unchanged (3,333 receivable), and the
+        // gap between assetsTotal and assetsAvailable equals exactly that
+        // receivable.
+        BEAST_EXPECT(vaultAfterBob->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
+        BEAST_EXPECT(
+            vaultAfterBob->at(sfAssetsTotal) - vaultAfterBob->at(sfAssetsAvailable) ==
+            vaultAfterBob->at(sfLossUnrealized));
+
+        return f;
+    }
+
+    // Reproduces the worked example from the XLS-0065 design doc. The sole
+    // remaining shareholder asks (via fixed-asset input) for the vault's
+    // entire AssetsAvailable. Pre-fix this fails with the zero-sized-vault
+    // invariant violation. Post-fix the full-price exchange rate burns
+    // only a portion of the shares, the depositor receives all of
+    // AssetsAvailable, and the residual shares remain backed by the
+    // impaired-loan receivable.
+    void
+    testWithdrawSoleShareholderFixedAssetExit(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const withFix = features[fixCleanup3_2_0];
+        testcase(
+            std::string{"Vault withdraw: sole shareholder exits via "
+                        "fixed-asset amount with impaired loan"} +
+            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
+
+        std::string logs;
+        Env env(*this, features, std::make_unique(&logs));
+        auto const f = setupStuckDepositor(env);
+        if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
+        {
+            BEAST_EXPECT(false);
+            return;
+        }
+        Keylet const& vaultKey = *f.vaultKeylet;
+        PrettyAsset const& asset = *f.asset;
+
+        auto const vaultBefore = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultBefore))
+            return;
+        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
+        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
+        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
+
+        STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
+
+        // The requested amount differs between feature regimes because
+        // the two regimes are testing different behaviors:
+        //
+        // - Pre-fix: request the full AssetsAvailable (3,333.50). Under
+        //   the discounted formula this would burn every outstanding
+        //   share, hitting the zero-sized-vault invariant. The
+        //   transaction is rejected with tecINVARIANT_FAILED — the
+        //   stuck-depositor bug.
+        //
+        // - Post-fix: request a strictly smaller amount (1,000 USD).
+        //   The full-price formula burns only ~30% of the outstanding
+        //   shares; the vault retains the rest, backed by the impaired
+        //   receivable. Requesting *exactly* AssetsAvailable post-fix
+        //   would currently fail with tecINSUFFICIENT_FUNDS due to the
+        //   round-to-nearest used by assetsToSharesWithdraw (the
+        //   recomputed payout can overshoot the request by a few ULPs).
+        //   The "force payout to AssetsAvailable" branch in doApply
+        //   only triggers when every share is burned, which is covered
+        //   by the loan-repayment test.
+        STAmount const requestAssets =
+            withFix ? asset(1000).value() : STAmount{asset.raw(), availableBefore};
+        Vault const v{env};
+        env(v.withdraw({
+                .depositor = f.lender,
+                .id = vaultKey.key,
+                .amount = requestAssets,
+            }),
+            Ter(withFix ? TER{tesSUCCESS} : TER{tecINVARIANT_FAILED}));
+        env.close();
+
+        auto const vaultAfter = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultAfter))
+            return;
+        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(issuanceAfter))
+            return;
+
+        std::uint64_t const sharesAfter = issuanceAfter->getFieldU64(sfOutstandingAmount);
+        Number const availableAfter = vaultAfter->at(sfAssetsAvailable);
+        Number const totalAfter = vaultAfter->at(sfAssetsTotal);
+        Number const lossAfter = vaultAfter->at(sfLossUnrealized);
+
+        if (!withFix)
+        {
+            // Pre-fix: rejected — vault state unchanged.
+            BEAST_EXPECT(sharesAfter == f.sharesLender);
+            BEAST_EXPECT(availableAfter == availableBefore);
+            BEAST_EXPECT(totalAfter == totalBefore);
+            BEAST_EXPECT(lossAfter == lossBefore);
+            return;
+        }
+
+        // Post-fix exact-value derivation (fixture: sharesLender=5e9,
+        // totalBefore=6666.5, request=1000):
+        //   sharesRedeemed = round(sharesLender * request / totalBefore)
+        //                  = round(750,018,750.469) = 750,018,750
+        //   received       = totalBefore * sharesRedeemed / sharesLender
+        //                  = 999.999999375  (slightly under 1,000 due to
+        //                                    integer-share rounding)
+        constexpr std::uint64_t kExpectedSharesRedeemed = 750'018'750;
+        Number const expectedReceived =
+            totalBefore * Number(kExpectedSharesRedeemed) / Number(f.sharesLender);
+
+        BEAST_EXPECT(sharesAfter == f.sharesLender - kExpectedSharesRedeemed);
+
+        // LossUnrealized is unchanged: the loan-protocol side is untouched.
+        BEAST_EXPECT(lossAfter == lossBefore);
+
+        // The entire (total - available) gap is the impaired receivable,
+        // i.e. equal to lossUnrealized.
+        BEAST_EXPECT(totalAfter - availableAfter == lossAfter);
+
+        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
+        Number const received{lenderBalanceAfter - lenderBalanceBefore};
+        BEAST_EXPECT(received == expectedReceived);
+
+        // Conservation: assets removed from the vault equal what the
+        // depositor received.
+        BEAST_EXPECT(totalBefore - totalAfter == received);
+        BEAST_EXPECT(availableBefore - availableAfter == received);
+    }
+
+    // Sole shareholder attempts to burn ALL outstanding shares via
+    // fixed-shares input while the vault still holds an impaired
+    // receivable. Pre-fix this fails with the zero-sized-vault invariant
+    // violation. Post-fix the full-price rate causes assetsWithdrawn to
+    // equal assetsTotal, which exceeds assetsAvailable, so the transaction
+    // is rejected with tecINSUFFICIENT_FUNDS.
+    void
+    testWithdrawSoleShareholderFullSharesRejected(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const withFix = features[fixCleanup3_2_0];
+        testcase(
+            std::string{"Vault withdraw: sole shareholder full-shares "
+                        "burn is rejected while loss outstanding"} +
+            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
+
+        std::string logs;
+        Env env(*this, features, std::make_unique(&logs));
+        auto const f = setupStuckDepositor(env);
+        if (!f.vaultKeylet || f.sharesLender == 0)
+        {
+            BEAST_EXPECT(false);
+            return;
+        }
+        Keylet const& vaultKey = *f.vaultKeylet;
+
+        auto const vaultBefore = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultBefore))
+            return;
+        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
+        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
+        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
+
+        // Fixed-shares input: ask for ALL outstanding shares.
+        STAmount const shareAmt{MPTIssue{f.shareAsset}, Number(f.sharesLender)};
+        Vault const v{env};
+        env(v.withdraw({
+                .depositor = f.lender,
+                .id = vaultKey.key,
+                .amount = shareAmt,
+            }),
+            Ter(withFix ? TER{tecINSUFFICIENT_FUNDS} : TER{tecINVARIANT_FAILED}));
+        env.close();
+
+        // Either way the transaction was rejected; vault state unchanged.
+        auto const vaultAfter = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultAfter))
+            return;
+        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(issuanceAfter))
+            return;
+        BEAST_EXPECT(issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender);
+        BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == availableBefore);
+        BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore);
+        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
+    }
+
+    // Post-fix end-to-end resolution: after the sole-shareholder partial
+    // exit, the loan is repaid in full. With unrealized loss cleared and
+    // all assets back as cash, the depositor can burn all remaining
+    // shares and fully exit the vault. The final withdrawal hits the
+    // "force payout to assetsAvailable" branch in doApply.
+    void
+    testWithdrawSoleShareholderLoanRepaymentExit()
+    {
+        using namespace test::jtx;
+        using namespace loan;
+
+        testcase(
+            "Vault withdraw: sole shareholder fully exits after impaired "
+            "loan is repaid (fixCleanup3_2_0)");
+
+        Env env(*this, all_ | fixCleanup3_2_0);
+        auto const f = setupStuckDepositor(env);
+        if (!f.vaultKeylet || !f.asset || !f.loanKeylet || f.sharesLender == 0)
+        {
+            BEAST_EXPECT(false);
+            return;
+        }
+        Keylet const& vaultKey = *f.vaultKeylet;
+        Keylet const& loanKey = *f.loanKeylet;
+        PrettyAsset const& asset = *f.asset;
+
+        Vault const v{env};
+
+        // Sole-shareholder partial exit (see comment in
+        // testWithdrawSoleShareholderFixedAssetExit for why we request
+        // less than full AssetsAvailable).
+        {
+            STAmount const requestAssets = asset(1000).value();
+            env(v.withdraw({
+                    .depositor = f.lender,
+                    .id = vaultKey.key,
+                    .amount = requestAssets,
+                }),
+                Ter(tesSUCCESS));
+            env.close();
+        }
+
+        // Confirm the "dormant-but-alive" state from the design doc. The
+        // partial exit burned exactly 750,018,750 shares (see derivation
+        // in testWithdrawSoleShareholderFixedAssetExit).
+        auto const tokenAfterExit = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
+        if (!BEAST_EXPECT(tokenAfterExit))
+            return;
+        std::uint64_t const retainedShares = tokenAfterExit->getFieldU64(sfMPTAmount);
+        BEAST_EXPECT(retainedShares == f.sharesLender - 750'018'750);
+
+        // Borrower repays the loan in full (pays more than the outstanding
+        // total; the loan transactor caps the receivable).
+        env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2)), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultAfterRepay = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultAfterRepay))
+            return;
+        // Repayment converts the 3,333 receivable back to cash; assetsTotal
+        // is unchanged but assetsAvailable jumps by exactly the same amount,
+        // and lossUnrealized clears to zero.
+        BEAST_EXPECT(vaultAfterRepay->at(sfLossUnrealized) == beast::kZero);
+        BEAST_EXPECT(vaultAfterRepay->at(sfAssetsAvailable) == vaultAfterRepay->at(sfAssetsTotal));
+
+        STAmount const lenderBalanceBeforeFinal = env.balance(f.lender, asset);
+        Number const availableBeforeFinal = vaultAfterRepay->at(sfAssetsAvailable);
+
+        // Burn all remaining shares — the clean-state preconditions of
+        // the "final withdrawal" guard are now satisfied.
+        STAmount const allShares{MPTIssue{f.shareAsset}, Number(retainedShares)};
+        env(v.withdraw({
+                .depositor = f.lender,
+                .id = vaultKey.key,
+                .amount = allShares,
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultFinal = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultFinal))
+            return;
+        auto const issuanceFinal = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(issuanceFinal))
+            return;
+
+        // Zero-sized vault invariant satisfied: 0 shares, 0 assets.
+        BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
+        BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
+        BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
+        BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
+
+        // The final payout equals exactly the AssetsAvailable that
+        // existed before the call (the "force payout" branch).
+        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
+        Number const finalReceived{lenderBalanceAfter - lenderBalanceBeforeFinal};
+        BEAST_EXPECT(finalReceived == availableBeforeFinal);
+    }
+
+    // Clean-state regression: with no impaired loan, a sole shareholder
+    // burning all their shares fully empties the vault under both the
+    // pre-fix and post-fix code paths. Confirms the new logic doesn't
+    // break the existing happy-path close-out.
+    void
+    testWithdrawSoleShareholderCleanVaultUnaffected(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const withFix = features[fixCleanup3_2_0];
+        testcase(
+            std::string{"Vault withdraw: sole shareholder clean-state "
+                        "close-out unchanged"} +
+            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
+
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+
+        env.fund(XRP(kStuckFunding), issuer, lender);
+        env.close();
+
+        PrettyAsset const asset = issuer[iouCurrency_];
+        env(trust(lender, asset(10'000'000)));
+        env.close();
+        env(pay(issuer, lender, asset(kStuckDepositorIOU)));
+        env.close();
+
+        // Sole shareholder of a clean vault — no loan broker needed.
+        Vault const v{env};
+        auto [createTx, vaultKeylet] = v.create({.owner = lender, .asset = asset});
+        env(createTx);
+        env.close();
+
+        env(v.deposit({
+                .depositor = lender,
+                .id = vaultKeylet.key,
+                .amount = asset(kStuckDeposit),
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultBefore = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultBefore))
+            return;
+        auto const shareAsset = vaultBefore->at(sfShareMPTID);
+        auto const tokenLender = env.le(keylet::mptoken(shareAsset, lender.id()));
+        if (!BEAST_EXPECT(tokenLender))
+            return;
+        std::uint64_t const sharesLender = tokenLender->getFieldU64(sfMPTAmount);
+
+        // Sole shareholder, no loans, no loss. Burn everything.
+        STAmount const allShares{MPTIssue{shareAsset}, Number(sharesLender)};
+        env(v.withdraw({
+                .depositor = lender,
+                .id = vaultKeylet.key,
+                .amount = allShares,
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultFinal = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultFinal))
+            return;
+        auto const issuanceFinal = env.le(keylet::mptokenIssuance(shareAsset));
+        if (!BEAST_EXPECT(issuanceFinal))
+            return;
+        BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
+        BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
+        BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
+        BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
+
+        // (Pre-fix path takes the regular code path; post-fix path enters
+        // the new final-withdrawal guard, which forces payout to exactly
+        // assetsAvailable. Either way the result is identical for a clean
+        // vault.)
+        (void)withFix;
+    }
+
+    // Sole shareholder in an impaired vault redeems a *partial* count of
+    // shares via fixed-shares input. Pre-fix the discounted formula is
+    // used; post-fix the full-price formula is used (waiveUnrealizedLoss
+    // = Yes). The relative payout therefore differs, and post-fix the
+    // depositor recovers proportionally more of the residual cash for
+    // the shares burned. In both cases the vault is left in a valid
+    // (non-empty) state.
+    void
+    testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice()
+    {
+        using namespace test::jtx;
+
+        testcase(
+            "Vault withdraw: sole-shareholder partial fixed-shares uses "
+            "full-price rate (fixCleanup3_2_0)");
+
+        Env env(*this, all_ | fixCleanup3_2_0);
+        auto const f = setupStuckDepositor(env);
+        if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
+        {
+            BEAST_EXPECT(false);
+            return;
+        }
+        Keylet const& vaultKey = *f.vaultKeylet;
+        PrettyAsset const& asset = *f.asset;
+
+        auto const vaultBefore = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultBefore))
+            return;
+        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
+        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
+        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
+
+        // Burn exactly half of the outstanding shares.
+        std::uint64_t const halfShares = f.sharesLender / 2;
+        STAmount const halfAmt{MPTIssue{f.shareAsset}, Number(halfShares)};
+
+        STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
+
+        Vault const v{env};
+        env(v.withdraw({
+                .depositor = f.lender,
+                .id = vaultKey.key,
+                .amount = halfAmt,
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // Expected payout under the full-price formula:
+        //   assets = totalBefore * halfShares / sharesLender
+        // which (with halfShares == sharesLender/2) is roughly
+        //   totalBefore / 2.
+        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
+        Number const received{lenderBalanceAfter - lenderBalanceBefore};
+        Number const expected = totalBefore * Number(halfShares) / Number(f.sharesLender);
+        BEAST_EXPECT(received == expected);
+
+        // The full-price payout exceeds the discounted formula by exactly
+        // lossBefore * halfShares / sharesLender — that's the whole point
+        // of the waive.
+        Number const discounted =
+            (totalBefore - lossBefore) * Number(halfShares) / Number(f.sharesLender);
+        Number const expectedDelta = lossBefore * Number(halfShares) / Number(f.sharesLender);
+        BEAST_EXPECT(received - discounted == expectedDelta);
+
+        auto const vaultAfter = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultAfter))
+            return;
+        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(issuanceAfter))
+            return;
+
+        // Vault remains valid: half the shares remain, lossUnrealized
+        // is untouched, and the entire (total - available) gap is still
+        // the impaired receivable.
+        BEAST_EXPECT(
+            issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender - halfShares);
+        BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore - received);
+        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
+        BEAST_EXPECT(
+            vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) ==
+            vaultAfter->at(sfLossUnrealized));
+
+        // Conservation: vault delta matches the depositor's gain.
+        BEAST_EXPECT(totalBefore - vaultAfter->at(sfAssetsTotal) == received);
+        BEAST_EXPECT(availableBefore - vaultAfter->at(sfAssetsAvailable) == received);
+    }
+
+    // Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for the
+    // sfAssetsTotal and sfAssetsAvailable deltas, and visitEntry applies the
+    // same max() for the vault pseudo-account RippleState.  When
+    // sfAssetsTotal sits exactly at 1e16 (IOU exponent 1, ULP = 10) and a
+    // withdrawal of 5 USD brings it to 9.999...995e15 (IOU exponent 0,
+    // ULP = 1), all three computations pick the anterior coarser scale 1.
+    // roundToAsset(-5, scale=1) collapses to 0, so the invariant check
+    // vaultPseudoDeltaAssets >= kZero fires even though the state change is
+    // valid and fully consistent at IOU precision.
+    //
+    // Fix (fixCleanup3_2_0): finalize compares the vault pseudo-account and
+    // sfAssetsTotal/Available deltas directly in Number space, bypassing
+    // scale-coarsened rounding.
+    void
+    testBugMakeDeltaAnteriorScale()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+
+            env.fund(XRP(100'000), issuer, alice);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            // Trust limit of 2e16, fund exactly 1e16 so deposit lands at the
+            // IOU scale-1 boundary (exponent 1, ULP = 10).
+            STAmount const fundAndDeposit{usd.raw(), Number{1, 16}};
+
+            env(trust(alice, STAmount{usd.raw(), 2, 16}));
+            env.close();
+            env(pay(issuer, alice, fundAndDeposit));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            // sfAssetsTotal = sfAssetsAvailable = 1e16 (exponent 1, ULP = 10).
+            env(vault.deposit(
+                {.depositor = alice, .id = vaultKeylet.key, .amount = fundAndDeposit}));
+            env.close();
+
+            // Withdraw 5 USD: -5 is sub-ULP at the anterior scale (ULP = 10)
+            // but exact at the posterior scale (ULP = 1).  The state change is
+            // consistent; only the invariant's scale selection is wrong.
+            env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(5)}),
+                Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultWithdraw across IOU scale boundary fires invariant "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw across IOU scale boundary succeeds "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), tesSUCCESS);
+        }
+    }
+
+    // Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for
+    // sfAssetsTotal/Available deltas.  This is symmetric to
+    // testBugMakeDeltaAnteriorScale but in the opposite direction: a deposit
+    // pushes assetsTotal from just below 1e16 (IOU exponent 0, ULP = 1) to just
+    // above it (exponent 1, ULP = 10).  makeDelta picks the coarser *posterior*
+    // scale 1.  The trust line balance rounds from atEdge + 2 = 10,000,000,000,000,001
+    // → 1e16, so the pseudo-account delta is only +1 in IOU space.
+    // roundToAsset(+1, scale=1) = 0 fires "deposit must increase vault balance"
+    // even though the state change is consistent at every precision boundary.
+    //
+    // Fix (fixCleanup3_2_0): computeVaultMinScale uses the posterior Number-space
+    // scale of sfAssetsTotal (which retains the full value 10,000,000,000,000,001,
+    // exponent 0), giving minScale = 0.  roundToAsset(+1, scale=0) = 1 > 0 and
+    // the invariant passes.  However the transactor's own precision guard fires
+    // first (bob pays 2 USD, vault receives only 1 due to IOU rounding), so the
+    // post-amendment result is tecPRECISION_LOSS rather than tesSUCCESS —
+    // the depositor is protected from silently losing 1 USD to rounding.
+    void
+    testBugMakeDeltaPosteriorScale()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100'000), issuer, alice, bob);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            // atEdge is the largest IOU value with exponent 0 (ULP = 1).
+            // A deposit of 2 USD brings assetsTotal to 10,000,000,000,000,001
+            // in Number space, crossing the 1e16 boundary in IOU space.
+            STAmount const atEdge{usd.raw(), Number{9'999'999'999'999'999LL}};
+
+            env(trust(alice, STAmount{usd.raw(), 2, 16}));
+            env(trust(bob, usd(100)));
+            env.close();
+            env(pay(issuer, alice, atEdge));
+            env(pay(issuer, bob, usd(2)));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            // sfAssetsTotal = sfAssetsAvailable = atEdge (exponent 0, ULP = 1)
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = atEdge}));
+            env.close();
+
+            // Deposit 2 USD: +2 is sub-ULP at the posterior IOU scale (ULP = 10)
+            // but exact at the Number scale retained by sfAssetsTotal.
+            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(2)}),
+                Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultDeposit across IOU scale boundary fires invariant "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit across IOU scale boundary succeeds "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), tecPRECISION_LOSS);
+        }
+    }
+
+    // Bug: ValidVault::visitEntry computes destinationDelta.scale as
+    // max(before_exponent, after_exponent) for RippleState entries.  When a
+    // withdrawal credits a destination whose IOU balance sits just below a
+    // power-of-10 boundary (atEdge = 9'999'999'999'999'999), the post-credit
+    // STAmount rounds up one exponent (exponent 0 → 1), making
+    // destinationDelta.scale = 1.  The invariant then calls
+    // roundToAsset(+2 USD, scale=1) = 0 and incorrectly fires
+    // "withdrawal must increase destination balance".
+    //
+    // Fix (fixCleanup3_2_0): finalize compares destination delta directly in
+    // Number space, bypassing scale-coarsened rounding.  The transaction
+    // itself succeeds because the effective IOU credit is non-trivial at
+    // Number precision even though the STAmount exponent shifted.
+    void
+    testVaultWithdrawCanonicalizeToZero()
+    {
+        using namespace test::jtx;
+
+        enum class DestKind : bool { ThirdParty = false, Self = true };
+
+        auto runScenario = [this](FeatureBitset features, DestKind destKind, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100'000), issuer, alice, bob);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            STAmount const aliceLimit{usd.raw(), 2, 16};
+            STAmount const bobLimit{usd.raw(), 2, 16};
+            STAmount const atEdge{usd.raw(), Number{9'999'999'999'999'999LL}};
+
+            env(trust(alice, aliceLimit));
+            if (destKind == DestKind::ThirdParty)
+                env(trust(bob, bobLimit));
+            env.close();
+
+            env(pay(issuer, alice, usd(1'000)));
+            if (destKind == DestKind::ThirdParty)
+                env(pay(issuer, bob, atEdge));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
+            env.close();
+
+            // For the self-destination case, push alice's own trust line to
+            // the IOU edge so the next withdraw inflow crosses the boundary.
+            if (destKind == DestKind::Self)
+            {
+                env(pay(issuer, alice, atEdge));
+                env.close();
+            }
+
+            auto tx = vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(2)});
+            if (destKind == DestKind::ThirdParty)
+                tx[sfDestination] = bob.human();
+            env(tx, Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultWithdraw to third-party at IOU edge fires invariant "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(
+                testableAmendments() - fixCleanup3_2_0, DestKind::ThirdParty, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw to third-party at IOU edge succeeds "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), DestKind::ThirdParty, tesSUCCESS);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw to self at IOU edge fires invariant "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(
+                testableAmendments() - fixCleanup3_2_0, DestKind::Self, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw to self at IOU edge succeeds "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), DestKind::Self, tesSUCCESS);
+        }
+    }
+
+    // Bug: the equality check (vault outflow == destination inflow) was
+    // skipped whenever the destination delta rounded to zero at localMinScale,
+    // including cases where the vault outflow rounded to a non-zero value and
+    // a representable amount of value was genuinely destroyed.
+    //
+    // Scenario: Bob's IOU balance sits 5 units below the 10^16 STAmount
+    // precision boundary (atEdge2 = 9,999,999,999,999,995).  A withdrawal of
+    // 6 USD shifts his balance across that boundary: the exponent increments
+    // (0 → 1), so his effective inflow in Number space is only +5 — 1 USD is
+    // consumed by the precision-boundary rounding and cannot be credited.
+    //
+    // The destroyed amount (1 USD) is sub-ULP at destinationScale=1 (step=10),
+    // so the check treats it as an unavoidable IOU-precision artefact and
+    // lets the transaction succeed.
+    //
+    // Contrast: if 15 USD were destroyed at the same scale (destroyed ≥ step),
+    // floor(15/10)=1 ≠ 0 and the invariant would fire — that discrepancy IS
+    // representable and indicates a real accounting bug.
+    //
+    // Pre-fixCleanup3_2_0: the "must increase destination balance" check fires
+    // because roundedDestinationDelta = 0 ≤ 0.
+    void
+    testVaultWithdrawEqualityEnforced()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100'000), issuer, alice, bob);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            STAmount const aliceLimit{usd.raw(), 2, 16};
+            STAmount const bobLimit{usd.raw(), 2, 16};
+            // Bob's balance sits 5 units below the 10^16 STAmount precision
+            // boundary.  Receiving 6 USD shifts his exponent 0 → 1; the
+            // STAmount records +5, not +6 (1 USD is lost to rounding).
+            STAmount const atEdge2{usd.raw(), Number{9'999'999'999'999'995LL}};
+
+            env(trust(alice, aliceLimit));
+            env(trust(bob, bobLimit));
+            env.close();
+
+            env(pay(issuer, alice, usd(1'000)));
+            env(pay(issuer, bob, atEdge2));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
+            env.close();
+
+            // Withdraw 6 USD to Bob: vault loses 6, Bob gains only 5.
+            // Destroyed amount = 1 USD, which is sub-ULP at destinationScale=1.
+            auto tx = vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(6)});
+            tx[sfDestination] = bob.human();
+            env(tx, Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultWithdraw to destination at IOU precision boundary fires "
+                "invariant (pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw to destination at IOU precision boundary succeeds "
+                "when destroyed amount is sub-ULP (post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), tesSUCCESS);
+        }
+    }
+
+    // Bug: when a depositor's IOU trustline balance is very large (e.g.
+    // ~1e17), adding a small deposit (e.g. 1 USD) leaves sfAssetsTotal
+    // unchanged at IOU precision because the increment is sub-ULP at the
+    // vault's current asset scale.  The vault records the deposit, mints
+    // shares, and decrements the depositor's trustline, but sfAssetsTotal
+    // does not change — the conservation invariant fires because the rail
+    // delta is zero.
+    //
+    // Two sub-cases are exercised:
+    //   1. First-ever deposit into an empty vault: the depositor's own
+    //      trustline has a large balance so 1 USD canonicalizes to zero
+    //      when written back through the IOU rail.
+    //   2. Subsequent deposit after the vault already holds a large
+    //      sfAssetsTotal: a different depositor (bob, with a small balance)
+    //      sends 1 USD, which again rounds to zero at the vault's coarse
+    //      asset scale.
+    //
+    // Fix (fixCleanup3_2_0): the deposit transactor checks whether
+    // roundToAsset(amount, vault_scale) == 0 and rejects early with
+    // tecPRECISION_LOSS before any state is modified.
+    void
+    testVaultDepositCanonicalizeToZero()
+    {
+        using namespace test::jtx;
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100'000), issuer, alice, bob);
+            env.close();
+
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+
+            STAmount const trustLimit{usd.raw(), Number{99'999'999'999'999'999LL}};
+            STAmount const aliceFund{usd.raw(), Number{99'999'999'999'999'999LL}};
+
+            env(trust(alice, trustLimit));
+            env(trust(bob, trustLimit));
+            env.close();
+
+            env(pay(issuer, alice, aliceFund));
+            env(pay(issuer, bob, usd(1000)));
+            env.close();
+
+            Vault const vault{env};
+
+            // Scale=0 so sfAssetsTotal stores whole USD
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            // Alice's deposit canonicalizes to zero at her own trustline scale
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1)}),
+                Ter(expected));
+
+            // Increase vault-scale
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = aliceFund}));
+            env.close();
+
+            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(1)}),
+                Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultDeposit below Vault precision canonicalized to zero "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit below Vault precision canonicalized to zero "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), tecPRECISION_LOSS);
+        }
+    }
+
+    // VaultDeposit by issuer with the vault parked at the IOU 16-digit
+    // edge (9.999e15). Issuer mints 2 more USD; the vault trust line
+    // goes 9.999e15 → 10^16, gaining 1 unit instead of 2 (canonicalization).
+    //
+    // Pre-fixCleanup3_2_0: the proactive check is absent; the deposit
+    // applies, then VaultInvariant's "deposit must increase vault
+    // balance" assertion fires at finalize time on the rounded vault
+    // delta of zero, returning tecINVARIANT_FAILED.
+    // Post-amendment: reject deposit that is not representable at Vault scale.
+    void
+    testBugIssuerVaultDepositAtEdge()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+
+            env.fund(XRP(100'000), issuer, owner);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            STAmount const trustLimit{usd.raw(), 2, 16};
+            STAmount const ownerFund{usd.raw(), Number{9'999'999'999'999'999LL}};
+
+            env(trust(owner, trustLimit));
+            env.close();
+            env(pay(issuer, owner, ownerFund));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+            env(vault.deposit({.depositor = owner, .id = vaultKeylet.key, .amount = ownerFund}));
+            env.close();
+
+            // Vault pseudo-account is now at 9.999e15. Issuer mints 2
+            // more USD. Pre: tecINVARIANT_FAILED at finalize. Post:
+            // tecPRECISION_LOSS proactively. Either way, no value moves.
+            env(vault.deposit({.depositor = issuer, .id = vaultKeylet.key, .amount = usd(2)}),
+                Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultDeposit by issuer at IOU edge fires "
+                "tecINVARIANT_FAILED at finalize (pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit by issuer at IOU edge rejects with "
+                "tecPRECISION_LOSS proactively (post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), tecPRECISION_LOSS);
+        }
+    }
+
+    void
+    testReferenceHolding()
+    {
+        using namespace test::jtx;
+
+        auto readReferenceHolding = [&](Env const& env,
+                                        Keylet const& vaultKeylet) -> std::optional {
+            auto const sleVault = env.le(vaultKeylet);
+            if (!sleVault)
+                return std::nullopt;
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
+            if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
+                return std::nullopt;
+            return sleIssuance->getFieldH256(sfReferenceHolding);
+        };
+
+        // Post-fixCleanup3_2_0: vault share carries sfReferenceHolding
+        // pointing to the vault pseudo's MPToken (for MPT-backed vaults)
+        // or RippleState (for IOU-backed vaults).
+        {
+            testcase("sfReferenceHolding: MPT-backed vault, post-amendment");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            auto const sleVault = env.le(keylet);
+            BEAST_EXPECT(sleVault != nullptr);
+            auto const pseudoId = sleVault->at(sfAccount);
+            auto const expected = keylet::mptoken(mptt.issuanceID(), pseudoId).key;
+
+            auto const stored = readReferenceHolding(env, keylet);
+            BEAST_EXPECT(stored.has_value());
+            BEAST_EXPECT(stored && *stored == expected);
+            // The pointed-to MPToken must actually exist.
+            BEAST_EXPECT(env.le(keylet::mptoken(mptt.issuanceID(), pseudoId)) != nullptr);
+        }
+
+        {
+            testcase("sfReferenceHolding: IOU-backed vault, post-amendment");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1'000'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            auto const sleVault = env.le(keylet);
+            BEAST_EXPECT(sleVault != nullptr);
+            auto const pseudoId = sleVault->at(sfAccount);
+            auto const expected = keylet::trustLine(pseudoId, asset.raw().get()).key;
+
+            auto const stored = readReferenceHolding(env, keylet);
+            BEAST_EXPECT(stored.has_value());
+            BEAST_EXPECT(stored && *stored == expected);
+            // The pointed-to RippleState must actually exist.
+            BEAST_EXPECT(env.le(keylet::trustLine(pseudoId, asset.raw().get())) != nullptr);
+        }
+
+        // XRP-backed vaults leave the field absent: XRP has no separate
+        // holding ledger entry and no transferability concept to inherit.
+        {
+            testcase("sfReferenceHolding: XRP-backed vault, field absent");
+            Env env{*this, testableAmendments()};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), owner);
+            env.close();
+
+            PrettyAsset const asset{xrpIssue(), 1'000'000};
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            BEAST_EXPECT(!readReferenceHolding(env, keylet).has_value());
+        }
+
+        // Pre-fixCleanup3_2_0: vault share has the field absent regardless
+        // of underlying type.
+        {
+            testcase("sfReferenceHolding: vault share, pre-amendment");
+            Env env{*this, testableAmendments() - fixCleanup3_2_0};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            BEAST_EXPECT(!readReferenceHolding(env, keylet).has_value());
+        }
+
+        // Plain MPTokenIssuanceCreate (not a vault share) must never
+        // populate the field. Only the post-amendment case is
+        // interesting; pre-amendment nothing writes the field at all.
+        {
+            testcase("sfReferenceHolding: plain MPT issuance never set");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            env.fund(XRP(10'000), issuer);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            env.close();
+
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(mptt.issuanceID()));
+            if (BEAST_EXPECT(sleIssuance))
+                BEAST_EXPECT(!sleIssuance->isFieldPresent(sfReferenceHolding));
+        }
+    }
+
+    // Probe every transactor surface that might delete the vault pseudo-
+    // account's underlying holding (the MPToken or RippleState pointed to
+    // by sfReferenceHolding). Each scenario asserts either that the
+    // existing pseudo-account guards stop the deletion at preclaim, or
+    // that the ledger leaves the holding intact afterwards. This is a
+    // regression guard: if any of these guards regresses, the share's
+    // sfReferenceHolding pointer would dangle and the new ValidMPTIssuance
+    // invariant would catch it - but we want to fail much earlier, at
+    // the transactor's preclaim / doApply, not at invariant time.
+    void
+    testHoldingDeletionBlocked()
+    {
+        using namespace test::jtx;
+
+        // Helper: read the share's referenced holding and confirm the
+        // pointed-to SLE still exists after the probe.
+        auto referencedHoldingExists = [&](Env const& env, Keylet const& vaultKeylet) -> bool {
+            auto const sleVault = env.le(vaultKeylet);
+            if (!sleVault)
+                return false;
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
+            if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
+                return false;
+            auto const holdingKey = sleIssuance->getFieldH256(sfReferenceHolding);
+            return env.le(keylet::unchecked(holdingKey)) != nullptr;
+        };
+
+        // ---- MPT-backed vault ----------------------------------------
+        {
+            testcase("vault pseudo MPToken: Clawback blocked by tecPSEUDO_ACCOUNT");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            env.fund(XRP(10'000), issuer, owner, depositor);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            env(pay(issuer, depositor, asset(1'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(500)}));
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
+            // Issuer attempts to claw back the FULL underlying balance
+            // (500) directly from the vault pseudo-account. With the
+            // full amount, the doApply path would drain the pseudo's
+            // MPToken to zero and removeEmptyHolding would erase it -
+            // if doApply ever ran. SAV's pseudo-account guard at
+            // Clawback.cpp:201 refuses at preclaim with
+            // tecPSEUDO_ACCOUNT before any state change.
+            env(claw(issuer, asset(500), pseudoAccount), Ter{tecPSEUDO_ACCOUNT});
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+            // Sanity: pseudo's full balance is intact.
+            BEAST_EXPECT(env.balance(pseudoAccount, asset).number() == 500);
+        }
+
+        {
+            testcase("vault pseudo MPToken: Issuer cannot Unauthorize pseudo");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = issuer, .holder = owner});
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            auto const pseudoId = env.le(keylet)->at(sfAccount);
+            // Issuer attempts MPTokenAuthorize against the pseudo with
+            // tfMPTUnauthorize. MPTokenAuthorize.cpp blocks pseudo
+            // accounts via isPseudoAccount; the pseudo's MPToken is
+            // preserved. Construct the tx manually since the pseudo
+            // lacks a signing key, and the issuer-driven flavour is
+            // expressed via sfHolder.
+            json::Value jv;
+            jv[sfAccount] = issuer.human();
+            jv[sfHolder] = toBase58(pseudoId);
+            jv[sfMPTokenIssuanceID] = to_string(mptt.issuanceID());
+            jv[sfFlags] = tfMPTUnauthorize;
+            jv[sfTransactionType] = jss::MPTokenAuthorize;
+            env(jv, Ter{tecNO_PERMISSION});
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+        }
+
+        {
+            testcase("vault pseudo MPToken: MPTokenIssuanceDestroy blocked while vault holds");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            env.fund(XRP(10'000), issuer, owner, depositor);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            env(pay(issuer, depositor, asset(1'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(500)}));
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            // While the vault holds outstanding underlying, the issuer
+            // cannot destroy the issuance. tecHAS_OBLIGATIONS confirms
+            // the protection - and as a side effect, the share's
+            // sfReferenceHolding pointer cannot be left pointing at a
+            // ghost issuance.
+            mptt.destroy({.id = mptt.issuanceID(), .err = tecHAS_OBLIGATIONS});
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+        }
+
+        // ---- IOU-backed vault ----------------------------------------
+        {
+            testcase("vault pseudo trust line: Clawback blocked by tecPSEUDO_ACCOUNT");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1'000'000), owner);
+            env(pay(issuer, owner, asset(1'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(500)}));
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
+            // Issuer attempts to claw back the FULL IOU balance (500)
+            // directly from the vault pseudo. With the full amount, the
+            // doApply path would drain the trust line to zero and (if
+            // both reserve flags clear) trustDelete would erase it - if
+            // doApply ever ran. The same SAV pseudo-account guard
+            // refuses at preclaim with tecPSEUDO_ACCOUNT. The amount's
+            // STAmount issuer field is the holder, per IOU clawback
+            // convention.
+            env(claw(issuer, pseudoAccount["IOU"](500)), Ter{tecPSEUDO_ACCOUNT});
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+            // Sanity: pseudo's full balance is intact.
+            BEAST_EXPECT(env.balance(pseudoAccount, asset).number() == 500);
+        }
+
+        {
+            testcase("vault pseudo trust line: TrustSet limit=0 from issuer preserves line");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1'000'000), owner);
+            env(pay(issuer, owner, asset(1'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(500)}));
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            // Issuer submits TrustSet with limit=0 against the vault
+            // pseudo. The pseudo's side of the line still has the
+            // original (non-zero) limit and a non-zero balance, so the
+            // line is preserved - even though the issuer cleared its
+            // own side. trustDelete only fires when both limits clear
+            // and the balance is zero.
+            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
+            env(trust(issuer, pseudoAccount["IOU"](0)));
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+        }
+
+        // ---- Positive control: VaultDelete is the only legitimate path
+        {
+            testcase("vault pseudo holding: VaultDelete is the legitimate cleanup path");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+            auto const pseudoId = env.le(keylet)->at(sfAccount);
+            auto const sharedMptId = env.le(keylet)->at(sfShareMPTID);
+            auto const holdingKeylet = keylet::mptoken(mptt.issuanceID(), pseudoId);
+
+            // VaultDelete tears down the vault pseudo's holding, the
+            // share issuance, and the pseudo-account itself. Invariant
+            // permits this because the tx is ttVAULT_DELETE.
+            env(vault.del({.owner = owner, .id = keylet.key}));
+            env.close();
+
+            BEAST_EXPECT(env.le(keylet) == nullptr);
+            BEAST_EXPECT(env.le(holdingKeylet) == nullptr);
+            BEAST_EXPECT(env.le(keylet::mptokenIssuance(sharedMptId)) == nullptr);
+        }
+    }
+
     // VaultDeposit::preclaim uses accountHolds(..., SpendableHandling::
     // shFULL_BALANCE), which for an IOU asset adds the counterparty's
     // LowLimit/HighLimit to the depositor's raw balance (TokenHelpers.cpp:
@@ -6219,10 +7511,592 @@ class Vault_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testVaultDepositFreeze()
+    {
+        using namespace test::jtx;
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+
+        // === IOU ===
+        {
+            testcase("VaultDeposit IOU freeze checks");
+            Env env{*this};
+            Vault vault{env};
+
+            env.fund(XRP(100'000), issuer, owner);
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1'000'000), owner);
+            env(pay(issuer, owner, asset(100'000)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+            auto const vaultAcct = Account("vault", env.le(keylet)->at(sfAccount));
+
+            // Initial deposit so the vault pseudo-account has a trustline
+            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
+            env.close();
+
+            auto runTests = [&]() {
+                auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
+
+                // Global freeze
+                env(fset(issuer, asfGlobalFreeze));
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    Ter(tecFROZEN));
+                env(fclear(issuer, asfGlobalFreeze));
+
+                // Depositor regular freeze
+                env(trust(issuer, asset(0), owner, tfSetFreeze));
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    Ter(tecFROZEN));
+                env(trust(issuer, asset(0), owner, tfClearFreeze));
+
+                // Depositor deep freeze
+                env(trust(issuer, asset(0), owner, tfSetFreeze | tfSetDeepFreeze));
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    Ter(tecFROZEN));
+                env(trust(issuer, asset(0), owner, tfClearFreeze | tfClearDeepFreeze));
+
+                // Vault-account regular freeze
+                // Post-fix: checkDepositFreeze catches it → tecFROZEN
+                // Pre-fix: not checked directly, but the transitive share
+                //          check triggers → tecLOCKED
+                {
+                    auto trustSet = [&]() {
+                        json::Value jv;
+                        jv[jss::Account] = issuer.human();
+                        {
+                            auto& ja = jv[jss::LimitAmount] =
+                                asset(0).value().getJson(JsonOptions::Values::None);
+                            ja[jss::issuer] = toBase58(vaultAcct.id());
+                        }
+                        jv[jss::TransactionType] = jss::TrustSet;
+                        return jv;
+                    }();
+
+                    trustSet[jss::Flags] = tfSetFreeze;
+                    env(trustSet);
+                    env.close();
+
+                    TER const expected = fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED);
+                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                        Ter(expected));
+
+                    trustSet[jss::Flags] = tfClearFreeze;
+                    env(trustSet);
+                    env.close();
+                }
+
+                // Vault-account deep freeze
+                {
+                    auto trustSet = [&]() {
+                        json::Value jv;
+                        jv[jss::Account] = issuer.human();
+                        {
+                            auto& ja = jv[jss::LimitAmount] =
+                                asset(0).value().getJson(JsonOptions::Values::None);
+                            ja[jss::issuer] = toBase58(vaultAcct.id());
+                        }
+                        jv[jss::TransactionType] = jss::TrustSet;
+                        return jv;
+                    }();
+
+                    trustSet[jss::Flags] = tfSetFreeze | tfSetDeepFreeze;
+                    env(trustSet);
+                    env.close();
+
+                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                        Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
+
+                    trustSet[jss::Flags] = tfClearFreeze | tfClearDeepFreeze;
+                    env(trustSet);
+                    env.close();
+                }
+
+                // Clawback works while frozen
+                env(fset(issuer, asfGlobalFreeze));
+                env(vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(1)}));
+                env(fclear(issuer, asfGlobalFreeze));
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
+                env.close();
+            };
+
+            runTests();
+            env.disableFeature(fixCleanup3_3_0);
+            runTests();
+            env.enableFeature(fixCleanup3_3_0);
+        }
+
+        // === MPT ===
+        {
+            testcase("VaultDeposit MPT lock checks");
+            Env env{*this};
+            Vault vault{env};
+
+            env.fund(XRP(100'000), issuer, owner);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create(
+                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
+            PrettyAsset const mpt{mptt.issuanceID()};
+
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = issuer, .holder = owner});
+            env.close();
+            env(pay(issuer, owner, mpt(100'000)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = mpt});
+            env(tx);
+            env.close();
+            auto const vaultAcctID = env.le(keylet)->at(sfAccount);
+            Account const vaultAcct("vault", vaultAcctID);
+
+            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(100)}));
+            env.close();
+
+            // For MPT isDeepFrozen == isFrozen, so all locks block in
+            // both pre- and post-fix.
+            auto runTests = [&]() {
+                // Global lock
+                mptt.set({.flags = tfMPTLock});
+                env.close();
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
+                    Ter(tecLOCKED));
+                mptt.set({.flags = tfMPTUnlock});
+                env.close();
+
+                // Depositor individual lock
+                mptt.set({.holder = owner, .flags = tfMPTLock});
+                env.close();
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
+                    Ter(tecLOCKED));
+                mptt.set({.holder = owner, .flags = tfMPTUnlock});
+                env.close();
+
+                // Vault pseudo-account individual lock
+                mptt.set({.holder = vaultAcct, .flags = tfMPTLock});
+                env.close();
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
+                    Ter(tecLOCKED));
+                mptt.set({.holder = vaultAcct, .flags = tfMPTUnlock});
+                env.close();
+
+                // Clawback works while locked
+                mptt.set({.flags = tfMPTLock});
+                env.close();
+                env(vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = mpt(1)}));
+                mptt.set({.flags = tfMPTUnlock});
+                env.close();
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
+                env.close();
+            };
+
+            runTests();
+            env.disableFeature(fixCleanup3_3_0);
+            runTests();
+            env.enableFeature(fixCleanup3_3_0);
+        }
+    }
+
+    // Focused demonstration: a depositor under a regular individual IOU freeze
+    // can still withdraw to themselves (self-withdrawal), but is blocked from
+    // withdrawing to a third party.
+    //
+    // Pre-fixCleanup3_3_0: both the self-withdrawal AND the third-party
+    // withdrawal were blocked because the old code checked checkFrozen on the
+    // destination regardless of whether it was the submitter.
+    // Post-fixCleanup3_3_0: checkWithdrawFreeze skips the submitter freeze
+    // check when submitter == destination, so self-withdrawal succeeds.
+    void
+    testVaultSelfWithdrawWhileFrozen()
+    {
+        testcase("VaultWithdraw IOU self-withdrawal while individually frozen");
+
+        using namespace test::jtx;
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const charlie{"charlie"};
+        Env env{*this};
+        Vault vault{env};
+
+        env.fund(XRP(100'000), issuer, owner, charlie);
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env.close();
+
+        PrettyAsset const asset = issuer["IOU"];
+        env.trust(asset(1'000'000), owner);
+        env.trust(asset(1'000'000), charlie);
+        env(pay(issuer, owner, asset(100'000)));
+        env.close();
+
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+        env(tx);
+        env.close();
+
+        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)}));
+        env.close();
+
+        auto runTests = [&]() {
+            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
+
+            // Set a regular individual freeze on the owner's IOU trustline.
+            env(trust(issuer, asset(0), owner, tfSetFreeze));
+            env.close();
+
+            // Self-withdrawal: submitter == destination, so the submitter
+            // freeze check is skipped.
+            // Post-fix: tesSUCCESS.  Pre-fix: tecFROZEN.
+            env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
+
+            // Withdrawal to a third party is blocked: submitter != destination
+            // so the submitter freeze check applies.
+            {
+                auto withdrawToCharlie =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
+                withdrawToCharlie[sfDestination] = charlie.human();
+                // Post-fix: tecFROZEN (checkIndividualFrozen on submitter).
+                // Pre-fix: tecLOCKED (isFrozen on the vault share).
+                env(withdrawToCharlie, Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
+            }
+
+            env(trust(issuer, asset(0), owner, tfClearFreeze));
+            env.close();
+        };
+
+        runTests();
+        env.disableFeature(fixCleanup3_3_0);
+        runTests();
+        env.enableFeature(fixCleanup3_3_0);
+    }
+
+    void
+    testVaultWithdrawFreeze()
+    {
+        using namespace test::jtx;
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+
+        // === IOU ===
+        {
+            testcase("VaultWithdraw IOU freeze checks");
+            Env env{*this};
+            Vault vault{env};
+
+            env.fund(XRP(100'000), issuer, owner);
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1'000'000), owner);
+            env(pay(issuer, owner, asset(100'000)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+            auto const vaultAcct = Account("vault", env.le(keylet)->at(sfAccount));
+
+            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
+            env.close();
+
+            Account const charlie{"charlie"};
+            env.fund(XRP(10'000), charlie);
+            env.trust(asset(1'000'000), charlie);
+            env.close();
+
+            auto runTests = [&]() {
+                auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
+                // Post-fix: submitter freeze blocks withdraw to 3rd party
+                // Pre-fix: submitter's IOU freeze not checked, but
+                //          checkFrozen(depositor, share) may trigger tecLOCKED
+                TER const submitterTo3rd = fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED);
+
+                // Global freeze → self-withdraw
+                env(fset(issuer, asfGlobalFreeze));
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    Ter(tecFROZEN));
+                // Global freeze → withdraw to 3rd party
+                {
+                    auto withdrawToCharlie =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
+                    withdrawToCharlie[sfDestination] = charlie.human();
+                    env(withdrawToCharlie, Ter(tecFROZEN));
+                }
+                env(fclear(issuer, asfGlobalFreeze));
+
+                // Vault-account regular freeze
+                {
+                    auto trustSet = [&]() {
+                        json::Value jv;
+                        jv[jss::Account] = issuer.human();
+                        {
+                            auto& ja = jv[jss::LimitAmount] =
+                                asset(0).value().getJson(JsonOptions::Values::None);
+                            ja[jss::issuer] = toBase58(vaultAcct.id());
+                        }
+                        jv[jss::TransactionType] = jss::TrustSet;
+                        return jv;
+                    }();
+
+                    trustSet[jss::Flags] = tfSetFreeze;
+                    env(trustSet);
+                    env.close();
+
+                    TER const vaultAcctFreeze = fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED);
+
+                    // Self-withdraw
+                    env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                        Ter(vaultAcctFreeze));
+                    // Withdraw to 3rd party
+                    {
+                        auto withdrawToCharlie = vault.withdraw(
+                            {.depositor = owner, .id = keylet.key, .amount = asset(1)});
+                        withdrawToCharlie[sfDestination] = charlie.human();
+                        env(withdrawToCharlie, Ter(vaultAcctFreeze));
+                    }
+
+                    trustSet[jss::Flags] = tfClearFreeze;
+                    env(trustSet);
+                    env.close();
+                }
+
+                // Depositor regular freeze → self-withdraw
+                env(trust(issuer, asset(0), owner, tfSetFreeze));
+                // Post-fix: self-withdraw allowed (submitter==dst skip)
+                // Pre-fix: isFrozen(depositor, iou) catches it
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
+
+                // Depositor regular freeze → withdraw to 3rd party
+                {
+                    auto withdrawTo3rd =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
+                    withdrawTo3rd[sfDestination] = charlie.human();
+                    env(withdrawTo3rd, Ter(submitterTo3rd));
+                }
+                env(trust(issuer, asset(0), owner, tfClearFreeze));
+                // Replenish what was withdrawn
+                if (fix330Enabled)
+                {
+                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
+                }
+                env.close();
+
+                // Depositor deep freeze → self-withdraw blocked
+                env(trust(issuer, asset(0), owner, tfSetFreeze | tfSetDeepFreeze));
+                // TODO: branches are identical - confirm the intended pre/post-fix330
+                // expectations and replace with the correct values (one branch may be wrong).
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
+                    // NOLINTNEXTLINE(bugprone-branch-clone)
+                    Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecFROZEN)));
+                env(trust(issuer, asset(0), owner, tfClearFreeze | tfClearDeepFreeze));
+
+                // Destination regular freeze → withdraw to 3rd party
+                env(trust(issuer, asset(0), charlie, tfSetFreeze));
+                // Self-withdraw unaffected by charlie's freeze
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
+                {
+                    auto withdrawToCharlie =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
+                    withdrawToCharlie[sfDestination] = charlie.human();
+                    // Post-fix: regular freeze on dst allowed
+                    // Pre-fix: checkFrozen(dst, iou) catches it
+                    env(withdrawToCharlie, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
+                }
+                env(trust(issuer, asset(0), charlie, tfClearFreeze));
+                // Replenish: 1 for self-withdraw + 1 if charlie withdraw succeeded
+                env(vault.deposit(
+                    {.depositor = owner,
+                     .id = keylet.key,
+                     .amount = asset(fix330Enabled ? 2 : 1)}));
+                env.close();
+
+                // Destination deep freeze → withdraw to 3rd party blocked
+                env(trust(issuer, asset(0), charlie, tfSetFreeze | tfSetDeepFreeze));
+                {
+                    auto withdrawToCharlie =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
+                    withdrawToCharlie[sfDestination] = charlie.human();
+                    env(withdrawToCharlie, Ter(tecFROZEN));
+                }
+                // Destination deep freeze → self-withdraw unaffected
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
+                env(trust(issuer, asset(0), charlie, tfClearFreeze | tfClearDeepFreeze));
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
+                env.close();
+
+                // Clawback works while frozen
+                env(fset(issuer, asfGlobalFreeze));
+                env(vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(1)}));
+                env(fclear(issuer, asfGlobalFreeze));
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
+                env.close();
+            };
+
+            runTests();
+            env.disableFeature(fixCleanup3_3_0);
+            runTests();
+            env.enableFeature(fixCleanup3_3_0);
+        }
+
+        // === MPT ===
+        {
+            testcase("VaultWithdraw MPT lock checks");
+            Env env{*this};
+            Vault vault{env};
+
+            env.fund(XRP(100'000), issuer, owner);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create(
+                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
+            PrettyAsset const mpt{mptt.issuanceID()};
+
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = issuer, .holder = owner});
+            env.close();
+            env(pay(issuer, owner, mpt(100'000)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = mpt});
+            env(tx);
+            env.close();
+            Account const vaultAcct("vault", env.le(keylet)->at(sfAccount));
+
+            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(100)}));
+            env.close();
+
+            Account const charlie{"charlie"};
+            env.fund(XRP(10'000), charlie);
+            env.close();
+            mptt.authorize({.account = charlie});
+            mptt.authorize({.account = issuer, .holder = charlie});
+            env.close();
+
+            auto runTests = [&]() {
+                auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
+
+                // Global lock
+                mptt.set({.flags = tfMPTLock});
+                env.close();
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
+                    Ter(tecLOCKED));
+
+                // Global lock → withdraw to issuer
+                // Post-fix: bypasses freeze checks, but accountHolds
+                //           on the pseudo returns 0 under global lock
+                // Pre-fix: checkFrozen(dst=issuer) catches global lock
+                {
+                    auto withdrawToIssuer =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
+                    withdrawToIssuer[sfDestination] = issuer.human();
+                    env(withdrawToIssuer, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecLOCKED)));
+                }
+                mptt.set({.flags = tfMPTUnlock});
+                env.close();
+                if (fix330Enabled)
+                {
+                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
+                }
+                env.close();
+
+                // Vault pseudo-account individual lock
+                mptt.set({.holder = vaultAcct, .flags = tfMPTLock});
+                env.close();
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
+                    Ter(tecLOCKED));
+                mptt.set({.holder = vaultAcct, .flags = tfMPTUnlock});
+                env.close();
+
+                // Depositor individual lock → self-withdraw blocked
+                // (isDeepFrozen == isFrozen for MPT)
+                mptt.set({.holder = owner, .flags = tfMPTLock});
+                env.close();
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
+                    Ter(tecLOCKED));
+                // Depositor lock → withdraw to 3rd party also blocked
+                {
+                    auto withdrawToCharlie =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
+                    withdrawToCharlie[sfDestination] = charlie.human();
+                    env(withdrawToCharlie, Ter(tecLOCKED));
+                }
+
+                // Depositor lock → withdraw to issuer
+                // Post-fix: issuer bypass in checkWithdrawFreezes
+                // Pre-fix: checkFrozen(depositor, share) blocks transitively
+                {
+                    auto withdrawToIssuer =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
+                    withdrawToIssuer[sfDestination] = issuer.human();
+                    env(withdrawToIssuer, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecLOCKED)));
+                }
+                mptt.set({.holder = owner, .flags = tfMPTUnlock});
+                env.close();
+                if (fix330Enabled)
+                {
+                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
+                }
+                env.close();
+
+                // 3rd party destination lock → withdraw to 3rd party blocked
+                mptt.set({.holder = charlie, .flags = tfMPTLock});
+                env.close();
+                {
+                    auto withdrawToCharlie =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
+                    withdrawToCharlie[sfDestination] = charlie.human();
+                    env(withdrawToCharlie, Ter{tecLOCKED});
+                }
+                // 3rd party lock → self-withdraw unaffected
+                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
+                mptt.set({.holder = charlie, .flags = tfMPTUnlock});
+                env.close();
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
+                env.close();
+
+                // Clawback works while locked
+                mptt.set({.flags = tfMPTLock});
+                env.close();
+                env(vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = mpt(1)}));
+                mptt.set({.flags = tfMPTUnlock});
+                env.close();
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
+                env.close();
+            };
+
+            runTests();
+            env.disableFeature(fixCleanup3_3_0);
+            runTests();
+            env.enableFeature(fixCleanup3_3_0);
+        }
+    }
+
 public:
     void
     run() override
     {
+        testVaultWithdrawEqualityEnforced();
+        testBugIssuerVaultDepositAtEdge();
+        testBugMakeDeltaPosteriorScale();
+        testBugMakeDeltaAnteriorScale();
+        testVaultDepositCanonicalizeToZero();
+        testVaultWithdrawCanonicalizeToZero();
         testVaultDepositNegativeBalanceFromOppositeLimit();
         testSequences();
         testPreflight();
@@ -6243,6 +8117,23 @@ public:
         testAssetsMaximum();
         testBug6LimitBypassWithShares();
         testRemoveEmptyHoldingLockedAmount();
+        testRemoveEmptyHoldingConfidentialBalances();
+
+        testWithdrawSoleShareholderFixedAssetExit(all_ - fixCleanup3_2_0);
+        testWithdrawSoleShareholderFixedAssetExit(all_);
+        testWithdrawSoleShareholderFullSharesRejected(all_ - fixCleanup3_2_0);
+        testWithdrawSoleShareholderFullSharesRejected(all_);
+        testWithdrawSoleShareholderCleanVaultUnaffected(all_ - fixCleanup3_2_0);
+        testWithdrawSoleShareholderCleanVaultUnaffected(all_);
+        testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice();
+        testWithdrawSoleShareholderLoanRepaymentExit();
+
+        testVaultDepositFreeze();
+        testVaultWithdrawFreeze();
+        testVaultSelfWithdrawWhileFrozen();
+
+        testReferenceHolding();
+        testHoldingDeletionBlocked();
     }
 };
 
diff --git a/src/test/app/XChain_test.cpp b/src/test/app/XChain_test.cpp
index 6266d5870d..437c329e02 100644
--- a/src/test/app/XChain_test.cpp
+++ b/src/test/app/XChain_test.cpp
@@ -150,18 +150,18 @@ struct SEnv
         return env.current()->fees().base;
     }
 
-    std::shared_ptr
+    SLE::const_pointer
     account(jtx::Account const& account)
     {
         return env.le(account);
     }
 
-    std::shared_ptr
+    SLE::const_pointer
     bridge(json::Value const& jvb)
     {
         STXChainBridge const b(jvb);
 
-        auto tryGet = [&](STXChainBridge::ChainType ct) -> std::shared_ptr {
+        auto tryGet = [&](STXChainBridge::ChainType ct) -> SLE::const_pointer {
             if (auto r = env.le(keylet::bridge(b, ct)))
             {
                 if ((*r)[sfXChainBridge] == b)
@@ -186,13 +186,13 @@ struct SEnv
         return (*bridge(jvb))[sfXChainClaimID];
     }
 
-    std::shared_ptr
+    SLE::const_pointer
     claimID(json::Value const& jvb, std::uint64_t seq)
     {
         return env.le(keylet::xChainClaimID(STXChainBridge(jvb), seq));
     }
 
-    std::shared_ptr
+    SLE::const_pointer
     caClaimID(json::Value const& jvb, std::uint64_t seq)
     {
         return env.le(keylet::xChainCreateAccountClaimID(STXChainBridge(jvb), seq));
@@ -266,8 +266,8 @@ struct BalanceTransfer
 
     balance from;
     balance to;
-    balance payer;                         // pays the rewards
-    std::vector reward_accounts;  // receives the reward
+    balance payer;                        // pays the rewards
+    std::vector rewardAccounts;  // receives the reward
     XRPAmount txFees;
 
     BalanceTransfer(
@@ -281,7 +281,7 @@ struct BalanceTransfer
         : from(env, fromAcct)
         , to(env, toAcct)
         , payer(env, payer)
-        , reward_accounts([&]() {
+        , rewardAccounts([&]() {
             std::vector r;
             r.reserve(numPayees);
             for (size_t i = 0; i < numPayees; ++i)
@@ -306,9 +306,8 @@ struct BalanceTransfer
     [[nodiscard]] bool
     payeesReceived(STAmount const& reward) const
     {
-        return std::all_of(reward_accounts.begin(), reward_accounts.end(), [&](balance const& b) {
-            return b.diff() == reward;
-        });
+        return std::ranges::all_of(
+            rewardAccounts, [&](balance const& b) { return b.diff() == reward; });
     }
 
     bool
@@ -320,7 +319,7 @@ struct BalanceTransfer
     bool
     hasHappened(STAmount const& amt, STAmount const& reward, bool checkPayer = true)
     {
-        auto rewardCost = multiply(reward, STAmount(reward_accounts.size()), reward.asset());
+        auto rewardCost = multiply(reward, STAmount(rewardAccounts.size()), reward.asset());
         return checkMostBalances(amt, reward) &&
             (!checkPayer || payer.diff() == -(rewardCost + txFees));
     }
@@ -1007,7 +1006,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
                 scEnv.tx(xchainClaim(scAlice, jvb, claimID, amt, scBob)).close();
             }
 
-            BEAST_EXPECT(transfer.hasHappened(amt, split_reward_quorum));
+            BEAST_EXPECT(transfer.hasHappened(amt, splitRewardQuorum));
         }
 
         // Check that the reward paid from a claim Id was the reward when
@@ -1059,7 +1058,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
 
             // make sure the reward accounts indeed received the original
             // split reward (1 split 5 ways) instead of the updated 2 XRP.
-            BEAST_EXPECT(transfer.hasHappened(amt, split_reward_quorum));
+            BEAST_EXPECT(transfer.hasHappened(amt, splitRewardQuorum));
         }
 
         // Check that the signatures used to verify attestations and decide
@@ -1086,7 +1085,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
 
             // change signers - claim should not be processed is the batch
             // is signed by original signers
-            scEnv.tx(jtx::signers(Account::kMaster, quorum, alt_signers)).close();
+            scEnv.tx(jtx::signers(Account::kMaster, quorum, altSigners)).close();
 
             BalanceTransfer transfer(
                 scEnv,
@@ -1120,7 +1119,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
             // submit claim using current signers - should succeed
             scEnv
                 .multiTx(claimAttestations(
-                    scAttester, jvb, mcAlice, amt, payees, true, claimID, dst, alt_signers))
+                    scAttester, jvb, mcAlice, amt, payees, true, claimID, dst, altSigners))
                 .close();
             if (withClaim)
             {
@@ -1132,7 +1131,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
 
             // make sure the transfer went through as we sent attestations
             // using new signers
-            BEAST_EXPECT(transfer.hasHappened(amt, split_reward_quorum, false));
+            BEAST_EXPECT(transfer.hasHappened(amt, splitRewardQuorum, false));
         }
 
         // coverage test: bridge_modify transaction with incorrect flag
@@ -1220,7 +1219,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
         // Creating the new object would put the account below the reserve
         XEnv(*this, true)
             .tx(createBridge(Account::kMaster, jvb))
-            .fund(res1 - xrp_dust, scuAlice)  // barely not enough
+            .fund(res1 - xrpDust, scuAlice)  // barely not enough
             .close()
             .tx(xchainCreateClaimId(scuAlice, jvb, reward, mcAlice), Ter(tecINSUFFICIENT_RESERVE))
             .close();
@@ -1231,7 +1230,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
         XEnv(*this, true)
             .tx(createBridge(Account::kMaster, jvb))
             .close()
-            .tx(xchainCreateClaimId(scAlice, jvb, split_reward_quorum, mcAlice),
+            .tx(xchainCreateClaimId(scAlice, jvb, splitRewardQuorum, mcAlice),
                 Ter(tecXCHAIN_REWARD_MISMATCH))
             .close();
 
@@ -1273,7 +1272,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
         testcase("Commit");
 
         // Commit to a non-existent bridge
-        XEnv(*this).tx(xchainCommit(mcAlice, jvb, 1, one_xrp, scBob), Ter(tecNO_ENTRY));
+        XEnv(*this).tx(xchainCommit(mcAlice, jvb, 1, oneXrp, scBob), Ter(tecNO_ENTRY));
 
         // check that reward not deducted when doing the commit
         {
@@ -1309,17 +1308,17 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
         // reserve (if XRP)
         XEnv(*this)
             .tx(createBridge(mcDoor, jvb))
-            .fund(res0 + one_xrp - xrp_dust, mcuAlice)  // barely not enough
+            .fund(res0 + oneXrp - xrpDust, mcuAlice)  // barely not enough
             .close()
-            .tx(xchainCommit(mcuAlice, jvb, 1, one_xrp, scBob), Ter(tecUNFUNDED_PAYMENT));
+            .tx(xchainCommit(mcuAlice, jvb, 1, oneXrp, scBob), Ter(tecUNFUNDED_PAYMENT));
 
         XEnv(*this)
             .tx(createBridge(mcDoor, jvb))
             .fund(
-                res0 + one_xrp + xrp_dust,  // "xrp_dust" for tx fees
-                mcuAlice)                   // exactly enough => should succeed
+                res0 + oneXrp + xrpDust,  // "xrp_dust" for tx fees
+                mcuAlice)                 // exactly enough => should succeed
             .close()
-            .tx(xchainCommit(mcuAlice, jvb, 1, one_xrp, scBob));
+            .tx(xchainCommit(mcuAlice, jvb, 1, oneXrp, scBob));
 
         // Commit an amount above the account's balance (for both XRP and
         // IOUs)
@@ -1327,7 +1326,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
             .tx(createBridge(mcDoor, jvb))
             .fund(res0, mcuAlice)  // barely not enough
             .close()
-            .tx(xchainCommit(mcuAlice, jvb, 1, res0 + one_xrp, scBob), Ter(tecUNFUNDED_PAYMENT));
+            .tx(xchainCommit(mcuAlice, jvb, 1, res0 + oneXrp, scBob), Ter(tecUNFUNDED_PAYMENT));
 
         auto jvbUsd = bridge(mcDoor, mcUSD, scGw, scUSD);
 
@@ -1377,7 +1376,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
         XEnv(*this)
             .tx(createBridge(mcDoor))
             .close()
-            .tx(xchainCommit(mcAlice, jvb, 1, one_xrp, scBob),
+            .tx(xchainCommit(mcAlice, jvb, 1, oneXrp, scBob),
                 Txflags(tfFillOrKill),
                 Ter(temINVALID_FLAG));
 
@@ -1387,7 +1386,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
             .tx(createBridge(mcDoor))
             .disableFeature(featureXChainBridge)
             .close()
-            .tx(xchainCommit(mcAlice, jvb, 1, one_xrp, scBob), Ter(temDISABLED));
+            .tx(xchainCommit(mcAlice, jvb, 1, oneXrp, scBob), Ter(temDISABLED));
     }
 
     void
@@ -1467,7 +1466,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
                 BEAST_EXPECT(scEnv.claimID(jvb) == claimID);
             }
 
-            BEAST_EXPECT(transfer.hasHappened(amt, split_reward_everyone));
+            BEAST_EXPECT(transfer.hasHappened(amt, splitRewardEveryone));
         }
 
         // Test that signature weights are correctly handled. Assign
@@ -2164,14 +2163,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
                     scAttester, jvb, mcAlice, amt, payees[i], true, claimID, dst, signers[i]);
 
                 TER const expectedTER = i < quorum ? tesSUCCESS : TER{tecXCHAIN_NO_CLAIM_ID};
-                if (i + 1 == quorum)
-                {
-                    scEnv.tx(att, Ter(expectedTER)).close();
-                }
-                else
-                {
-                    scEnv.tx(att, Ter(expectedTER)).close();
-                }
+                scEnv.tx(att, Ter(expectedTER)).close();
 
                 if (i + 1 < quorum)
                 {
@@ -2208,12 +2200,12 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
             std::uint32_t const claimID = 1;
 
             for (auto i = 0; i < kUtXchainDefaultNumSigners - 2; ++i)
-                scEnv.fund(amt, alt_signers[i].account);
+                scEnv.fund(amt, altSigners[i].account);
 
             mcEnv.tx(createBridge(mcDoor, jvb)).close();
 
             scEnv.tx(createBridge(Account::kMaster, jvb))
-                .tx(jtx::signers(Account::kMaster, quorum, alt_signers))
+                .tx(jtx::signers(Account::kMaster, quorum, altSigners))
                 .close()
                 .tx(xchainCreateClaimId(scAlice, jvb, reward, mcAlice))
                 .close();
@@ -2225,24 +2217,24 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
             {
                 // G1: master key
                 auto att = claimAttestation(
-                    scAttester, jvb, mcAlice, amt, payees[0], true, claimID, dst, alt_signers[0]);
+                    scAttester, jvb, mcAlice, amt, payees[0], true, claimID, dst, altSigners[0]);
                 scEnv.tx(att).close();
             }
             {
                 // G2: regular key
                 // alt_signers[0] is the regular key of alt_signers[1]
                 // There should be 2 attestations after the transaction
-                scEnv.tx(jtx::regkey(alt_signers[1].account, alt_signers[0].account)).close();
+                scEnv.tx(jtx::regkey(altSigners[1].account, altSigners[0].account)).close();
                 auto att = claimAttestation(
-                    scAttester, jvb, mcAlice, amt, payees[1], true, claimID, dst, alt_signers[0]);
-                att[sfAttestationSignerAccount.getJsonName()] = alt_signers[1].account.human();
+                    scAttester, jvb, mcAlice, amt, payees[1], true, claimID, dst, altSigners[0]);
+                att[sfAttestationSignerAccount.getJsonName()] = altSigners[1].account.human();
                 scEnv.tx(att).close();
             }
             {
                 // B3: public key and non-exist (unfunded) account mismatch
                 // G3: public key and non-exist (unfunded) account match
-                auto const unfundedSigner1 = alt_signers[kUtXchainDefaultNumSigners - 1];
-                auto const unfundedSigner2 = alt_signers[kUtXchainDefaultNumSigners - 2];
+                auto const unfundedSigner1 = altSigners[kUtXchainDefaultNumSigners - 1];
+                auto const unfundedSigner2 = altSigners[kUtXchainDefaultNumSigners - 2];
                 auto att = claimAttestation(
                     scAttester,
                     jvb,
@@ -2261,7 +2253,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
             {
                 // B2: single item signer list
                 std::vector tempSignerList = {signers[0]};
-                scEnv.tx(jtx::signers(alt_signers[2].account, 1, tempSignerList));
+                scEnv.tx(jtx::signers(altSigners[2].account, 1, tempSignerList));
                 auto att = claimAttestation(
                     scAttester,
                     jvb,
@@ -2272,14 +2264,14 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
                     claimID,
                     dst,
                     tempSignerList.front());
-                att[sfAttestationSignerAccount.getJsonName()] = alt_signers[2].account.human();
+                att[sfAttestationSignerAccount.getJsonName()] = altSigners[2].account.human();
                 scEnv.tx(att, Ter(tecXCHAIN_BAD_PUBLIC_KEY_ACCOUNT_PAIR)).close();
             }
             {
                 // B1: disabled master key
-                scEnv.tx(fset(alt_signers[2].account, asfDisableMaster, 0)).close();
+                scEnv.tx(fset(altSigners[2].account, asfDisableMaster, 0)).close();
                 auto att = claimAttestation(
-                    scAttester, jvb, mcAlice, amt, payees[2], true, claimID, dst, alt_signers[2]);
+                    scAttester, jvb, mcAlice, amt, payees[2], true, claimID, dst, altSigners[2]);
                 scEnv.tx(att, Ter(tecXCHAIN_BAD_PUBLIC_KEY_ACCOUNT_PAIR)).close();
             }
             {
@@ -2292,11 +2284,11 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
                 // --B5: missing sfAttestationSignerAccount field
                 // Then submit the one with the field. Should reach quorum.
                 auto att = claimAttestation(
-                    scAttester, jvb, mcAlice, amt, payees[3], true, claimID, dst, alt_signers[3]);
+                    scAttester, jvb, mcAlice, amt, payees[3], true, claimID, dst, altSigners[3]);
                 att.removeMember(sfAttestationSignerAccount.getJsonName());
                 scEnv.tx(att, Ter(temMALFORMED)).close();
                 BEAST_EXPECT(dstStartBalance == scEnv.env.balance(dst));
-                att[sfAttestationSignerAccount.getJsonName()] = alt_signers[3].account.human();
+                att[sfAttestationSignerAccount.getJsonName()] = altSigners[3].account.human();
                 scEnv.tx(att).close();
                 BEAST_EXPECT(dstStartBalance + amt == scEnv.env.balance(dst));
             }
@@ -2431,7 +2423,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
                 scEnv.tx(xchainClaim(scAlice, jvb, claimID, amt, scBob)).close();
             }
 
-            BEAST_EXPECT(transfer.hasHappened(amt, split_reward_quorum));
+            BEAST_EXPECT(transfer.hasHappened(amt, splitRewardQuorum));
         }
 
         // Claim with just one attestation signed by the Master key
@@ -2641,7 +2633,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
             }
             else
             {
-                BEAST_EXPECT(transfer.hasHappened(amt, split_reward_quorum));
+                BEAST_EXPECT(transfer.hasHappened(amt, splitRewardQuorum));
             }
         }
 
@@ -3180,7 +3172,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
 
                 // claim wrong amount
                 scEnv
-                    .tx(xchainClaim(scAlice, jvb, claimID, one_xrp, scBob),
+                    .tx(xchainClaim(scAlice, jvb, claimID, oneXrp, scBob),
                         Ter(tecXCHAIN_CLAIM_NO_QUORUM))
                     .close();
             }
@@ -3232,7 +3224,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
                 claimCost += fee;
             }
 
-            BEAST_EXPECT(transfer.hasHappened(amt, split_reward_quorum));
+            BEAST_EXPECT(transfer.hasHappened(amt, splitRewardQuorum));
             BEAST_EXPECT(scAliceBal.diff() == -claimCost);  // because reward % 4 == 0
         }
 
@@ -3244,12 +3236,12 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
             XEnv mcEnv(*this);
             XEnv scEnv(*this, true);
 
-            mcEnv.tx(createBridge(mcDoor, jvb, tiny_reward)).close();
+            mcEnv.tx(createBridge(mcDoor, jvb, tinyReward)).close();
 
-            scEnv.tx(createBridge(Account::kMaster, jvb, tiny_reward))
+            scEnv.tx(createBridge(Account::kMaster, jvb, tinyReward))
                 .tx(jtx::signers(Account::kMaster, quorum, signers))
                 .close()
-                .tx(xchainCreateClaimId(scAlice, jvb, tiny_reward, mcAlice))
+                .tx(xchainCreateClaimId(scAlice, jvb, tinyReward, mcAlice))
                 .close();
 
             auto dst(withClaim ? std::nullopt : std::optional{scBob});
@@ -3268,7 +3260,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
             test::Balance const scAliceBal(scEnv, scAlice);
             scEnv.multiTx(claimAttestations(
                 scAttester, jvb, mcAlice, amt, payees, true, claimID, dst, signers));
-            STAmount claimCost = tiny_reward;
+            STAmount claimCost = tinyReward;
 
             if (withClaim)
             {
@@ -3279,8 +3271,8 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
                 claimCost += fee;
             }
 
-            BEAST_EXPECT(transfer.hasHappened(amt, tiny_reward_split));
-            BEAST_EXPECT(scAliceBal.diff() == -(claimCost - tiny_reward_remainder));
+            BEAST_EXPECT(transfer.hasHappened(amt, tinyRewardSplit));
+            BEAST_EXPECT(scAliceBal.diff() == -(claimCost - tinyRewardRemainder));
         }
 
         // If a reward distribution fails for one of the reward accounts
@@ -3331,7 +3323,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
             // this also checks that only 3 * split_reward was deducted from
             // scAlice (the payer account), since we passed alt_payees to
             // BalanceTransfer
-            BEAST_EXPECT(transfer.hasHappened(amt, split_reward_quorum));
+            BEAST_EXPECT(transfer.hasHappened(amt, splitRewardQuorum));
         }
 
         for (auto withClaim : {false, true})
@@ -3381,7 +3373,7 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
             // this also checks that only 3 * split_reward was deducted from
             // scAlice (the payer account), since we passed payees.size() -
             // 1 to BalanceTransfer
-            BEAST_EXPECT(transfer.hasHappened(amt, split_reward_quorum));
+            BEAST_EXPECT(transfer.hasHappened(amt, splitRewardQuorum));
 
             // and make sure the account with deposit auth received nothing
             BEAST_EXPECT(lastSigner.diff() == STAmount(0));
@@ -3578,17 +3570,17 @@ struct XChain_test : public beast::unit_test::Suite, public jtx::XChainBridgeObj
         // commit where the fee dips into the reserve, this should succeed
         XEnv(*this)
             .tx(createBridge(mcDoor, jvb))
-            .fund(res0 + one_xrp + fee - drops(1), mcuAlice)
+            .fund(res0 + oneXrp + fee - drops(1), mcuAlice)
             .close()
-            .tx(xchainCommit(mcuAlice, jvb, 1, one_xrp, scBob), Ter(tesSUCCESS));
+            .tx(xchainCommit(mcuAlice, jvb, 1, oneXrp, scBob), Ter(tesSUCCESS));
 
         // commit where the commit amount drips into the reserve, this should
         // fail
         XEnv(*this)
             .tx(createBridge(mcDoor, jvb))
-            .fund(res0 + one_xrp - drops(1), mcuAlice)
+            .fund(res0 + oneXrp - drops(1), mcuAlice)
             .close()
-            .tx(xchainCommit(mcuAlice, jvb, 1, one_xrp, scBob), Ter(tecUNFUNDED_PAYMENT));
+            .tx(xchainCommit(mcuAlice, jvb, 1, oneXrp, scBob), Ter(tecUNFUNDED_PAYMENT));
 
         auto const minAccountCreate = XRP(20);
 
@@ -3757,8 +3749,8 @@ private:
         jtx::Account finaldest;
         STAmount amt;
         bool a2b;  // direction of transfer
-        WithClaim with_claim{WithClaim::No};
-        uint32_t claim_id{0};
+        WithClaim withClaim{WithClaim::No};
+        uint32_t claimId{0};
         std::array attested{};
     };
 
@@ -3769,7 +3761,7 @@ private:
         STAmount amt;
         STAmount reward;
         bool a2b;
-        uint32_t claim_id{0};
+        uint32_t claimId{0};
         std::array attested{};
     };
 
@@ -3806,7 +3798,7 @@ private:
         using CreateClaimVec = jtx::JValueVec;
         using CreateClaimMap = std::map;
 
-        ChainStateTrack(ENV& env) : env(env), tx_fee(env.env.current()->fees().base)
+        ChainStateTrack(ENV& env) : env(env), txFee(env.env.current()->fees().base)
         {
         }
 
@@ -3851,31 +3843,30 @@ private:
             {
                 callbackCalled = false;
                 // cspell: ignore attns
-                for (size_t i = 0; i < signers_attns.size(); ++i)
+                for (size_t i = 0; i < signersAttns.size(); ++i)
                 {
-                    for (auto& [bridge, claims] : signers_attns[i])
+                    for (auto& [bridge, claims] : signersAttns[i])
                     {
-                        sendAttestations(i, bridge, claims.xfer_claims);
+                        sendAttestations(i, bridge, claims.xferClaims);
 
                         auto& c = counters[bridge];
-                        auto& createClaims = claims.create_claims[c.claim_count];
+                        auto& createClaims = claims.createClaims[c.claimCount];
                         auto numAttns = createClaims.size();
                         if (numAttns != 0u)
                         {
-                            c.num_create_attn_sent +=
-                                sendCreateAttestations(i, bridge, createClaims);
+                            c.numCreateAttnSent += sendCreateAttestations(i, bridge, createClaims);
                         }
-                        assert(claims.create_claims[c.claim_count].empty());
+                        assert(claims.createClaims[c.claimCount].empty());
                     }
                 }
                 for (auto& [bridge, c] : counters)
                 {
-                    if (c.num_create_attn_sent >= bridge->quorum)
+                    if (c.numCreateAttnSent >= bridge->quorum)
                     {
                         callbackCalled = true;
-                        c.create_callbacks[c.claim_count](c.signers);
-                        ++c.claim_count;
-                        c.num_create_attn_sent = 0;
+                        c.createCallbacks[c.claimCount](c.signers);
+                        ++c.claimCount;
+                        c.numCreateAttnSent = 0;
                         c.signers.clear();
                     }
                 }
@@ -3926,7 +3917,7 @@ private:
         void
         spendFee(jtx::Account const& acct, size_t times = 1)
         {
-            spend(acct, tx_fee, times);
+            spend(acct, txFee, times);
         }
 
         [[nodiscard]] bool
@@ -3944,20 +3935,20 @@ private:
         {
             using complete_cb = std::function const& signers)>;
 
-            uint32_t claim_id{0};
-            uint32_t create_count{0};  // for account create. First should be 1
-            uint32_t claim_count{0};   // for account create. Increments after quorum for
-                                       // current create_count (starts at 1) is reached.
+            uint32_t claimId{0};
+            uint32_t createCount{0};  // for account create. First should be 1
+            uint32_t claimCount{0};   // for account create. Increments after quorum for
+                                      // current createCount (starts at 1) is reached.
 
-            uint32_t num_create_attn_sent{0};  // for current claim_count
+            uint32_t numCreateAttnSent{0};  // for current claimCount
             std::vector signers;
-            std::vector create_callbacks;
+            std::vector createCallbacks;
         };
 
         struct Claims
         {
-            ClaimVec xfer_claims;
-            CreateClaimMap create_claims;
+            ClaimVec xferClaims;
+            CreateClaimMap createClaims;
         };
 
         using SignerAttns = std::unordered_map;
@@ -3965,9 +3956,9 @@ private:
 
         ENV& env;
         std::map accounts;
-        SignersAttns signers_attns;
+        SignersAttns signersAttns;
         std::map counters;
-        STAmount tx_fee;
+        STAmount txFee;
     };
 
     struct ChainStateTracker
@@ -4086,7 +4077,7 @@ private:
             st.transfer(cr_.from, srcdoor, cr_.amt);
             st.transfer(cr_.from, srcdoor, cr_.reward);
 
-            return ++st.counters[&bridge_].create_count;
+            return ++st.counters[&bridge_].createCount;
         }
 
         void
@@ -4105,9 +4096,8 @@ private:
                     // enqueue one attestation for this signer
                     cr_.attested[signerIdx] = true;
 
-                    st.signers_attns[signerIdx][&bridge_]
-                        .create_claims[cr_.claim_id - 1]
-                        .emplace_back(createAccountAttestation(
+                    st.signersAttns[signerIdx][&bridge_].createClaims[cr_.claimId - 1].emplace_back(
+                        createAccountAttestation(
                             bridge_.signers[signerIdx].account,
                             bridge_.jvb,
                             cr_.from,
@@ -4115,7 +4105,7 @@ private:
                             cr_.reward,
                             bridge_.signers[signerIdx].account,
                             cr_.a2b,
-                            cr_.claim_id,
+                            cr_.claimId,
                             cr_.to,
                             bridge_.signers[signerIdx]));
                     break;
@@ -4126,15 +4116,15 @@ private:
                 return;  // did not attest
 
             auto& counters = st.counters[&bridge_];
-            if (counters.create_callbacks.size() < cr_.claim_id)
-                counters.create_callbacks.resize(cr_.claim_id);
+            if (counters.createCallbacks.size() < cr_.claimId)
+                counters.createCallbacks.resize(cr_.claimId);
 
             auto completeCb = [&](std::vector const& signers) {
                 auto numAttestors = signers.size();
                 st.env.close();
                 assert(numAttestors <= std::count(cr_.attested.begin(), cr_.attested.end(), true));
                 assert(numAttestors >= bridge_.quorum);
-                assert(cr_.claim_id - 1 == counters.claim_count);
+                assert(cr_.claimId - 1 == counters.claimCount);
 
                 auto r = cr_.reward;
                 auto reward = divide(r, STAmount(numAttestors), r.asset());
@@ -4145,20 +4135,20 @@ private:
                 st.spend(dstDoor(), reward, numAttestors);
                 st.transfer(dstDoor(), cr_.to, cr_.amt);
                 st.env.env.memoize(cr_.to);
-                sm_state_ = SmState::Completed;
+                smState_ = SmState::Completed;
             };
 
-            counters.create_callbacks[cr_.claim_id - 1] = std::move(completeCb);
+            counters.createCallbacks[cr_.claimId - 1] = std::move(completeCb);
         }
 
         SmState
         advance(uint64_t time, uint32_t rnd)
         {
-            switch (sm_state_)
+            switch (smState_)
             {
                 case SmState::Initial:
-                    cr_.claim_id = issueAccountCreate();
-                    sm_state_ = SmState::Attesting;
+                    cr_.claimId = issueAccountCreate();
+                    smState_ = SmState::Attesting;
                     break;
 
                 case SmState::Attesting:
@@ -4172,11 +4162,11 @@ private:
                 case SmState::Completed:
                     break;  // will get this once
             }
-            return sm_state_;
+            return smState_;
         }
 
     private:
-        SmState sm_state_{SmState::Initial};
+        SmState smState_{SmState::Initial};
         AccountCreate cr_;
     };
 
@@ -4209,7 +4199,7 @@ private:
                 .close();  // needed for claim_id sequence to be
                            // correct'
             st.spendFee(xfer_.to);
-            return ++st.counters[&bridge_].claim_id;
+            return ++st.counters[&bridge_].claimId;
         }
 
         void
@@ -4226,10 +4216,10 @@ private:
             st.env.tx(xchainCommit(
                 xfer_.from,
                 bridge_.jvb,
-                xfer_.claim_id,
+                xfer_.claimId,
                 xfer_.amt,
-                xfer_.with_claim == WithClaim::Yes ? std::nullopt
-                                                   : std::optional(xfer_.finaldest)));
+                xfer_.withClaim == WithClaim::Yes ? std::nullopt
+                                                  : std::optional(xfer_.finaldest)));
             st.spendFee(xfer_.from);
             st.transfer(xfer_.from, srcdoor, xfer_.amt);
         }
@@ -4262,15 +4252,15 @@ private:
                     // enqueue one attestation for this signer
                     xfer_.attested[signerIdx] = true;
 
-                    st.signers_attns[signerIdx][&bridge_].xfer_claims.emplace_back(claimAttestation(
+                    st.signersAttns[signerIdx][&bridge_].xferClaims.emplace_back(claimAttestation(
                         bridge_.signers[signerIdx].account,
                         bridge_.jvb,
                         xfer_.from,
                         xfer_.amt,
                         bridge_.signers[signerIdx].account,
                         xfer_.a2b,
-                        xfer_.claim_id,
-                        xfer_.with_claim == WithClaim::Yes
+                        xfer_.claimId,
+                        xfer_.withClaim == WithClaim::Yes
                             ? std::nullopt
                             : std::optional(xfer_.finaldest),
                         bridge_.signers[signerIdx]));
@@ -4281,7 +4271,7 @@ private:
             // return true if quorum was reached, false otherwise
             bool const quorum =
                 std::count(xfer_.attested.begin(), xfer_.attested.end(), true) >= bridge_.quorum;
-            if (quorum && xfer_.with_claim == WithClaim::No)
+            if (quorum && xfer_.withClaim == WithClaim::No)
             {
                 distributeReward(st);
                 st.transfer(dstDoor(), xfer_.finaldest, xfer_.amt);
@@ -4294,7 +4284,7 @@ private:
         {
             ChainStateTrack& st = destState();
             st.env.tx(
-                xchainClaim(xfer_.to, bridge_.jvb, xfer_.claim_id, xfer_.amt, xfer_.finaldest));
+                xchainClaim(xfer_.to, bridge_.jvb, xfer_.claimId, xfer_.amt, xfer_.finaldest));
             distributeReward(st);
             st.transfer(dstDoor(), xfer_.finaldest, xfer_.amt);
             st.spendFee(xfer_.to);
@@ -4303,34 +4293,34 @@ private:
         SmState
         advance(uint64_t time, uint32_t rnd)
         {
-            switch (sm_state_)
+            switch (smState_)
             {
                 case SmState::Initial:
-                    xfer_.claim_id = createClaimId();
-                    sm_state_ = SmState::ClaimIdCreated;
+                    xfer_.claimId = createClaimId();
+                    smState_ = SmState::ClaimIdCreated;
                     break;
 
                 case SmState::ClaimIdCreated:
                     commit();
-                    sm_state_ = SmState::Attesting;
+                    smState_ = SmState::Attesting;
                     break;
 
                 case SmState::Attesting:
                     if (attest(time, rnd))
                     {
-                        sm_state_ = xfer_.with_claim == WithClaim::Yes ? SmState::Attested
-                                                                       : SmState::Completed;
+                        smState_ = xfer_.withClaim == WithClaim::Yes ? SmState::Attested
+                                                                     : SmState::Completed;
                     }
                     else
                     {
-                        sm_state_ = SmState::Attesting;
+                        smState_ = SmState::Attesting;
                     }
                     break;
 
                 case SmState::Attested:
-                    assert(xfer_.with_claim == WithClaim::Yes);
+                    assert(xfer_.withClaim == WithClaim::Yes);
                     claim();
-                    sm_state_ = SmState::Completed;
+                    smState_ = SmState::Completed;
                     break;
 
                 default:
@@ -4338,12 +4328,12 @@ private:
                     assert(0);  // should have been removed
                     break;
             }
-            return sm_state_;
+            return smState_;
         }
 
     private:
         Transfer xfer_;
-        SmState sm_state_{SmState::Initial};
+        SmState smState_{SmState::Initial};
     };
 
     // --------------------------------------------------
@@ -4377,6 +4367,7 @@ public:
     {
         using namespace jtx;
         uint64_t time = 0;
+        // NOLINTNEXTLINE(bugprone-random-generator-seed): fixed seed for reproducible test
         std::mt19937 gen(27);  // Standard mersenne_twister_engine
         std::uniform_int_distribution distrib(0, 9);
 
@@ -4573,7 +4564,7 @@ public:
              .finaldest = a[1],
              .amt = XRP(6),
              .a2b = true,
-             .with_claim = WithClaim::No});
+             .withClaim = WithClaim::No});
         xfer(
             1,
             st,
@@ -4583,7 +4574,7 @@ public:
              .finaldest = a[1],
              .amt = XRP(8),
              .a2b = false,
-             .with_claim = WithClaim::No});
+             .withClaim = WithClaim::No});
         xfer(
             1, st, xrpB, {.from = a[1], .to = a[1], .finaldest = a[1], .amt = XRP(1), .a2b = true});
         xfer(
@@ -4605,7 +4596,7 @@ public:
              .finaldest = a[1],
              .amt = XRP(7),
              .a2b = false,
-             .with_claim = WithClaim::No});
+             .withClaim = WithClaim::No});
         xfer(
             2, st, xrpB, {.from = a[1], .to = a[1], .finaldest = a[1], .amt = XRP(9), .a2b = true});
         runSimulation(st);
diff --git a/src/test/basics/Expected_test.cpp b/src/test/basics/Expected_test.cpp
deleted file mode 100644
index c8e570835b..0000000000
--- a/src/test/basics/Expected_test.cpp
+++ /dev/null
@@ -1,221 +0,0 @@
-#include 
-#include 
-#include 
-
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-
-#if BOOST_VERSION >= 107500
-#endif  // BOOST_VERSION
-#include 
-
-namespace xrpl::test {
-
-struct Expected_test : beast::unit_test::Suite
-{
-    void
-    run() override
-    {
-        // Test non-error const construction.
-        {
-            auto const expected = []() -> Expected { return "Valid value"; }();
-            BEAST_EXPECT(expected);
-            BEAST_EXPECT(expected.has_value());
-            BEAST_EXPECT(expected.value() == "Valid value");
-            BEAST_EXPECT(*expected == "Valid value");
-            BEAST_EXPECT(expected->at(0) == 'V');
-
-            bool throwOccurred = false;
-            try
-            {
-                // There's no error, so should throw.
-                [[maybe_unused]] TER const t = expected.error();
-            }
-            catch (std::runtime_error const& e)
-            {
-                BEAST_EXPECT(e.what() == std::string("bad expected access"));
-                throwOccurred = true;
-            }
-            BEAST_EXPECT(throwOccurred);
-        }
-        // Test non-error non-const construction.
-        {
-            auto expected = []() -> Expected { return "Valid value"; }();
-            BEAST_EXPECT(expected);
-            BEAST_EXPECT(expected.has_value());
-            BEAST_EXPECT(expected.value() == "Valid value");
-            BEAST_EXPECT(*expected == "Valid value");
-            BEAST_EXPECT(expected->at(0) == 'V');
-            std::string const mv = std::move(*expected);
-            BEAST_EXPECT(mv == "Valid value");
-
-            bool throwOccurred = false;
-            try
-            {
-                // There's no error, so should throw.
-                [[maybe_unused]] TER const t = expected.error();
-            }
-            catch (std::runtime_error const& e)
-            {
-                BEAST_EXPECT(e.what() == std::string("bad expected access"));
-                throwOccurred = true;
-            }
-            BEAST_EXPECT(throwOccurred);
-        }
-        // Test non-error overlapping type construction.
-        {
-            auto expected = []() -> Expected { return 1; }();
-            BEAST_EXPECT(expected);
-            BEAST_EXPECT(expected.has_value());
-            BEAST_EXPECT(expected.value() == 1);
-            BEAST_EXPECT(*expected == 1);
-
-            bool throwOccurred = false;
-            try
-            {
-                // There's no error, so should throw.
-                [[maybe_unused]] std::uint16_t const t = expected.error();
-            }
-            catch (std::runtime_error const& e)
-            {
-                BEAST_EXPECT(e.what() == std::string("bad expected access"));
-                throwOccurred = true;
-            }
-            BEAST_EXPECT(throwOccurred);
-        }
-        // Test error construction from rvalue.
-        {
-            auto const expected = []() -> Expected {
-                return Unexpected(telLOCAL_ERROR);
-            }();
-            BEAST_EXPECT(!expected);
-            BEAST_EXPECT(!expected.has_value());
-            BEAST_EXPECT(expected.error() == telLOCAL_ERROR);
-
-            bool throwOccurred = false;
-            try
-            {
-                // There's no result, so should throw.
-                [[maybe_unused]] std::string const s = *expected;
-            }
-            catch (std::runtime_error const& e)
-            {
-                BEAST_EXPECT(e.what() == std::string("bad expected access"));
-                throwOccurred = true;
-            }
-            BEAST_EXPECT(throwOccurred);
-        }
-        // Test error construction from lvalue.
-        {
-            auto const err(telLOCAL_ERROR);
-            auto expected = [&err]() -> Expected { return Unexpected(err); }();
-            BEAST_EXPECT(!expected);
-            BEAST_EXPECT(!expected.has_value());
-            BEAST_EXPECT(expected.error() == telLOCAL_ERROR);
-
-            bool throwOccurred = false;
-            try
-            {
-                // There's no result, so should throw.
-                [[maybe_unused]] std::size_t const s = expected->size();
-            }
-            catch (std::runtime_error const& e)
-            {
-                BEAST_EXPECT(e.what() == std::string("bad expected access"));
-                throwOccurred = true;
-            }
-            BEAST_EXPECT(throwOccurred);
-        }
-        // Test error construction from const char*.
-        {
-            auto const expected = []() -> Expected {
-                return Unexpected("Not what is expected!");
-            }();
-            BEAST_EXPECT(!expected);
-            BEAST_EXPECT(!expected.has_value());
-            BEAST_EXPECT(expected.error() == std::string("Not what is expected!"));
-        }
-        // Test error construction of string from const char*.
-        {
-            auto expected = []() -> Expected {
-                return Unexpected("Not what is expected!");
-            }();
-            BEAST_EXPECT(!expected);
-            BEAST_EXPECT(!expected.has_value());
-            BEAST_EXPECT(expected.error() == "Not what is expected!");
-            std::string const s(std::move(expected.error()));
-            BEAST_EXPECT(s == "Not what is expected!");
-        }
-        // Test non-error const construction of Expected.
-        {
-            auto const expected = []() -> Expected { return {}; }();
-            BEAST_EXPECT(expected);
-            bool throwOccurred = false;
-            try
-            {
-                // There's no error, so should throw.
-                [[maybe_unused]] std::size_t const s = expected.error().size();
-            }
-            catch (std::runtime_error const& e)
-            {
-                BEAST_EXPECT(e.what() == std::string("bad expected access"));
-                throwOccurred = true;
-            }
-            BEAST_EXPECT(throwOccurred);
-        }
-        // Test non-error non-const construction of Expected.
-        {
-            auto expected = []() -> Expected { return {}; }();
-            BEAST_EXPECT(expected);
-            bool throwOccurred = false;
-            try
-            {
-                // There's no error, so should throw.
-                [[maybe_unused]] std::size_t const s = expected.error().size();
-            }
-            catch (std::runtime_error const& e)
-            {
-                BEAST_EXPECT(e.what() == std::string("bad expected access"));
-                throwOccurred = true;
-            }
-            BEAST_EXPECT(throwOccurred);
-        }
-        // Test error const construction of Expected.
-        {
-            auto const expected = []() -> Expected {
-                return Unexpected("Not what is expected!");
-            }();
-            BEAST_EXPECT(!expected);
-            BEAST_EXPECT(expected.error() == "Not what is expected!");
-        }
-        // Test error non-const construction of Expected.
-        {
-            auto expected = []() -> Expected {
-                return Unexpected("Not what is expected!");
-            }();
-            BEAST_EXPECT(!expected);
-            BEAST_EXPECT(expected.error() == "Not what is expected!");
-            std::string const s(std::move(expected.error()));
-            BEAST_EXPECT(s == "Not what is expected!");
-        }
-        // Test a case that previously unintentionally returned an array.
-#if BOOST_VERSION >= 107500
-        {
-            auto expected = []() -> Expected {
-                return boost::json::object{{"oops", "me array now"}};
-            }();
-            BEAST_EXPECT(expected);
-            BEAST_EXPECT(!expected.value().is_array());
-        }
-#endif  // BOOST_VERSION
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Expected, basics, xrpl);
-
-}  // namespace xrpl::test
diff --git a/src/test/basics/IOUAmount_test.cpp b/src/test/basics/IOUAmount_test.cpp
index ef053449a5..b652d27625 100644
--- a/src/test/basics/IOUAmount_test.cpp
+++ b/src/test/basics/IOUAmount_test.cpp
@@ -156,8 +156,7 @@ public:
             BEAST_EXPECTS(result == expected, ss.str());
         };
 
-        for (auto const mantissaSize :
-             {MantissaRange::MantissaScale::Small, MantissaRange::MantissaScale::Large})
+        for (auto const mantissaSize : MantissaRange::getAllScales())
         {
             NumberMantissaScaleGuard const mg(mantissaSize);
 
diff --git a/src/test/basics/Number_test.cpp b/src/test/basics/Number_test.cpp
index 2a4e176ae5..2d2745de14 100644
--- a/src/test/basics/Number_test.cpp
+++ b/src/test/basics/Number_test.cpp
@@ -6,19 +6,80 @@
 #include 
 #include 
 
+// NOLINTNEXTLINE(misc-include-cleaner)
+#include 
+#include 
+
+#include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 
 namespace xrpl {
 
 class Number_test : public beast::unit_test::Suite
 {
+    using BigInt = boost::multiprecision::cpp_int;
+
+    static std::string
+    fmt(BigInt const& value)
+    {
+        auto s = to_string(value);
+        std::string out;
+        int count = 0;
+        for (auto it = s.rbegin(); it != s.rend(); ++it)
+        {
+            if (count != 0 && count % 3 == 0 && (isdigit(*it) != 0))
+                out.insert(out.begin(), '_');
+            out.insert(out.begin(), *it);
+            ++count;
+        }
+        return out;
+    }
+
+    using dec = boost::multiprecision::cpp_dec_float_50;
+
+    template 
+    static T
+    pow10(int n)
+    {
+        if (n == 0)
+            return 1;
+        if (n == 1)
+            return 10;
+
+        if (n > 1)
+        {
+            auto r = pow10(n / 2);
+            r *= r;
+            if (n % 2 != 0)
+                r *= 10;
+            return r;
+        }
+
+        // n < 0
+        T p = 1;
+        p /= pow10(-n);
+        return p;
+    }
+
+    static std::string
+    fmt(dec const& v)
+    {
+        std::ostringstream os;
+        os << std::setprecision(40) << v;
+        return os.str();
+    }
+
 public:
     void
     testZero()
@@ -178,7 +239,6 @@ public:
                 {Number{true, 9'999'999'999'999'999'999ULL, -37, Number::Normalized{}},
                  Number{1'000'000'000'000'000'000, -18},
                  Number{false, 9'999'999'999'999'999'990ULL, -19, Number::Normalized{}}},
-                {Number{Number::kMaxRep}, Number{6, -1}, Number{Number::kMaxRep / 10, 1}},
                 {Number{Number::kMaxRep - 1}, Number{1, 0}, Number{Number::kMaxRep}},
                 // Test extremes
                 {
@@ -189,16 +249,22 @@ public:
                     Number{2, 19},
                 },
                 {
-                    // Does not round. Mantissas are going to be > maxRep, so if
+                    // Does not round. Mantissas are going to be > kMaxRep, so if
                     // added together as uint64_t's, the result will overflow.
                     // With addition using uint128_t, there's no problem. After
                     // normalizing, the resulting mantissa ends up less than
-                    // maxRep.
+                    // kMaxRep.
                     Number{false, 9'999'999'999'999'999'990ULL, 0, Number::Normalized{}},
                     Number{false, 9'999'999'999'999'999'990ULL, 0, Number::Normalized{}},
                     Number{false, 1'999'999'999'999'999'998ULL, 1, Number::Normalized{}},
                 },
             });
+        auto const cLargeLegacy = std::to_array({
+            {Number{Number::kMaxRep}, Number{6, -1}, Number{Number::kMaxRep / 10, 1}},
+        });
+        auto const cLargeCorrected = std::to_array({
+            {Number{Number::kMaxRep}, Number{6, -1}, Number{(Number::kMaxRep / 10) + 1, 1}},
+        });
         auto test = [this](auto const& c) {
             for (auto const& [x, y, z] : c)
             {
@@ -215,6 +281,14 @@ public:
         else
         {
             test(cLarge);
+            if (scale == MantissaRange::MantissaScale::LargeLegacy)
+            {
+                test(cLargeLegacy);
+            }
+            else
+            {
+                test(cLargeCorrected);
+            }
         }
         {
             bool caught = false;
@@ -835,7 +909,7 @@ public:
         /*
         auto tests = [&](auto const& cSmall, auto const& cLarge) {
             test(cSmall);
-            if (scale != MantissaRange::mantissa_scale::small)
+            if (scale != MantissaRange::MantissaScale::Small)
                 test(cLarge);
         };
         */
@@ -1266,6 +1340,7 @@ public:
                         "9223372036854775e3");
                 }
                 break;
+            case MantissaRange::MantissaScale::LargeLegacy:
             case MantissaRange::MantissaScale::Large:
                 // Test the edges
                 // ((exponent < -(28)) || (exponent > -(8)))))
@@ -1314,10 +1389,103 @@ public:
     testRelationals()
     {
         testcase << "test_relationals " << to_string(Number::getMantissaScale());
-        BEAST_EXPECT(!(Number{100} < Number{10}));
-        BEAST_EXPECT(Number{100} > Number{10});
-        BEAST_EXPECT(Number{100} >= Number{10});
-        BEAST_EXPECT(!(Number{100} <= Number{10}));
+
+        {
+            auto test = [this](auto const& nums) {
+                BEAST_EXPECT(std::ranges::is_sorted(nums));
+
+                for (auto iter1 = nums.begin(); iter1 != nums.end(); ++iter1)
+                {
+                    auto iter2 = iter1;
+                    for (++iter2; iter2 != nums.end(); ++iter2)
+                    {
+                        Number const& smaller = *iter1;
+                        Number const& larger = *iter2;
+                        std::stringstream ss;
+                        ss << smaller << " < " << larger;
+                        auto const str = ss.str();
+
+                        // The ==/!= operators use a completely different code path than <, etc.
+                        // This helps detect a breakage in one but not the other. It also helps
+                        // verify that the values are being ordered correctly.
+                        BEAST_EXPECTS(smaller != larger, str + " (!=)");
+                        BEAST_EXPECTS(!(smaller == larger), str + " (==)");
+
+                        // true results using operator< and derived operators
+                        BEAST_EXPECTS(smaller < larger, str + " (<)");
+                        BEAST_EXPECTS(larger > smaller, str + " (>)");
+                        BEAST_EXPECTS(larger >= smaller, str + " (>=)");
+                        BEAST_EXPECTS(smaller <= larger, str + " (<=)");
+
+                        // false results using operator< and derived operators
+                        BEAST_EXPECTS(!(larger < smaller), str + " (! <)");
+                        BEAST_EXPECTS(!(smaller > larger), str + " (! >)");
+                        BEAST_EXPECTS(!(smaller >= larger), str + " (! >=)");
+                        BEAST_EXPECTS(!(larger <= smaller), str + " (! <=)");
+                    }
+                }
+            };
+
+            auto const intNums = [this]() {
+                // Inequality test cases are built from a list of sorted integers
+                auto const values =
+                    std::to_array({-100, -50, -20, -10, -1, 0, 1, 10, 20, 50, 100});
+                // Check this list is sorted before converting it to Numbers.
+                // That way if any of the other tests fail, we know it's because of code and not the
+                // source data.
+                BEAST_EXPECT(std::ranges::is_sorted(values));
+
+                std::vector result;
+                result.reserve(values.size());
+                for (auto const v : values)
+                    result.emplace_back(v);
+                return result;
+            }();
+
+            auto const otherNums = std::to_array({
+                Number{-5, 100},
+                Number{-1, 100},
+                Number{-7, -10},
+                Number{-2, -10},
+                Number{0},
+                Number{2, -10},
+                Number{7, -10},
+                Number{1, 100},
+                Number{5, 100},
+            });
+
+            test(intNums);
+            test(otherNums);
+        }
+
+        {
+            // Equality test cases are . Number will be compared against itself
+            using Case = std::pair;
+            auto const c = std::to_array({
+                {700, __LINE__},
+                {50, __LINE__},
+                {1, __LINE__},
+                {0, __LINE__},
+                {-1, __LINE__},
+                {-30, __LINE__},
+                {-600, __LINE__},
+            });
+            for (auto const& [n, line] : c)
+            {
+                auto const str = to_string(n);
+
+                // NOLINTBEGIN(misc-redundant-expression) Explicitly testing operators with
+                // equivalent values
+                expect(n == n, str + " ==", __FILE__, line);
+                expect(!(n != n), str + " !=", __FILE__, line);
+
+                expect(!(n < n), str + " < ", __FILE__, line);
+                expect(!(n > n), str + " >", __FILE__, line);
+                expect(n >= n, str + " >=", __FILE__, line);
+                expect(n <= n, str + " <=", __FILE__, line);
+                // NOLINTEND(misc-redundant-expression)
+            }
+        }
     }
 
     void
@@ -1346,7 +1514,6 @@ public:
     void
     testToStAmount()
     {
-        NumberSO const stNumberSO{true};
         Issue const issue;
         Number const n{7'518'783'80596, -5};
         SaveNumberRoundMode const save{Number::setround(Number::RoundingMode::ToNearest)};
@@ -1551,11 +1718,258 @@ public:
         }
     }
 
+    void
+    testUpwardRoundsDown()
+    {
+        auto const scale = Number::getMantissaScale();
+        {
+            testcase << "upward rounding produces a value below exact at kMaxRep cusp "
+                     << to_string(scale);
+
+            NumberRoundModeGuard const rg{Number::RoundingMode::Upward};
+
+            constexpr std::int64_t kAValue = 1'000'000'000'000'049'863LL;
+            constexpr std::int64_t kBValue = 9'223'372'036'854'315'903LL;
+
+            Number const a = kAValue;
+            Number const b = kBValue;
+            Number const product = a * b;
+
+            // Exact reference in BigInt.
+            BigInt const exactProduct = BigInt(kAValue) * BigInt(kBValue);
+
+            // What Number actually stored.
+            BigInt storedValue = BigInt(product.mantissa());
+            for (int i = 0; i < product.exponent(); ++i)
+                storedValue *= 10;
+
+            BigInt const signedDifference = storedValue - exactProduct;
+
+            log << "\n"
+                << "  a              = " << fmt(BigInt(kAValue)) << "\n"
+                << "  b              = " << fmt(BigInt(kBValue)) << "\n"
+                << "  exact a*b      = " << fmt(exactProduct) << "\n"
+                << "  stored         = " << fmt(storedValue) << "\n"
+                << "  stored - exact = " << fmt(signedDifference) << "\n"
+                << "  upward         = " << (signedDifference >= 0 ? "held" : "VIOLATED") << "\n"
+                << " stored.mantissa = " << product.mantissa() << "\n"
+                << " stored.exponent = " << product.exponent() << "\n";
+            log.flush();
+
+            switch (scale)
+            {
+                case MantissaRange::MantissaScale::Large:
+                    BEAST_EXPECT(signedDifference >= 0);
+                    BEAST_EXPECT(signedDifference < pow10(product.exponent()));
+                    BEAST_EXPECT(
+                        product.mantissa() == (std::numeric_limits::max() / 10) + 1);
+                    BEAST_EXPECT(product.exponent() == 19);
+                    break;
+
+                case MantissaRange::MantissaScale::LargeLegacy:
+                    BEAST_EXPECT(signedDifference < 0);
+                    BEAST_EXPECT(
+                        product.mantissa() ==
+                        (std::numeric_limits::max() / 100) * 100);
+                    BEAST_EXPECT(product.exponent() == 18);
+                    break;
+
+                case MantissaRange::MantissaScale::Small:
+                    // The seemingly weird rounding here is because
+                    // a & b are both normalized, and both round up when
+                    // being converted to Number, so you're really
+                    // getting 1_000_000_000_000_050 * 9_223_372_036_854_316
+                    BEAST_EXPECT(signedDifference >= 0);
+                    BEAST_EXPECT(
+                        product.mantissa() ==
+                        (std::numeric_limits::max() / 1000) + 3);
+                    BEAST_EXPECT(product.exponent() == 21);
+                    break;
+            }
+        }
+
+        {
+            /* Companion regression for the kMaxRep cusp behavior, but for
+             * `operator/=` on the cusp-fix-ENABLED `Large` scale.
+             *
+             * Before the dropped-remainder fix, `operator/=` with Upward
+             * rounding could return a value STRICTLY LESS than the exact quotient,
+             * violating Upward's directional invariant.
+             *
+             * Mechanism (fix-enabled path):
+             *   1. `operator/=` computes `numerator = nm * 10^17` and
+             *      `zm = numerator / dm` (integer division, truncates remainder).
+             *   2. If `remainder != 0`, the correction block runs:
+             *        zm *= 100000
+             *        correction = (remainder * 100000) / dm  // also truncates
+             *        zm += correction
+             *        ze -= 5
+             *      The truncation in `correction` discards a sub-1/100000 residual.
+             *   3. `normalize`'s shift loop reduces zm to fit, but the discarded
+             *      residual is BELOW the Guard's visibility, so the Guard sees fraction = 0.
+             *   4. Under Upward + positive, `round()` returns -1 (no round-up), and
+             *      the algorithm returns the truncated zm
+             */
+            testcase << "operator/= Upward on Large returns value < truth " << to_string(scale);
+
+            NumberRoundModeGuard const roundGuard{Number::RoundingMode::Upward};
+
+            constexpr std::int64_t aValue = 2LL;
+            constexpr std::int64_t bValue = 1'000'000'000'000'000'007LL;
+            // bValue = 10^18 + 7 (prime, in [minMantissa, kMaxRep]).
+
+            Number const a{aValue, 0};
+            Number const b{bValue, 0};
+            Number const quotient = a / b;
+
+            dec const exact = dec(aValue) / dec(bValue);
+            dec const stored = dec(quotient.mantissa()) * pow10(quotient.exponent());
+            dec const diff = stored - exact;
+
+            log << "\n"
+                << "  a                 = " << aValue << "\n"
+                << "  b                 = " << bValue << "\n"
+                << "  exact a/b         = " << fmt(exact) << "\n"
+                << "  stored a/b        = " << fmt(stored) << "\n"
+                << "  stored - exact    = " << fmt(diff)
+                << "    (negative => Upward gave value BELOW truth)\n"
+                << "  quotient.mantissa = " << quotient.mantissa() << "\n"
+                << "  quotient.exponent = " << quotient.exponent() << "\n";
+            log.flush();
+
+            // Upward invariant: stored >= exact. Bug: stored < exact.
+            switch (scale)
+            {
+                case MantissaRange::MantissaScale::Large:
+                    BEAST_EXPECT(stored >= exact);
+                    BEAST_EXPECT(diff < pow10(quotient.exponent()));
+                    break;
+
+                case MantissaRange::MantissaScale::LargeLegacy:
+                    BEAST_EXPECT(stored < exact);
+                    BEAST_EXPECT(diff >= -pow10(quotient.exponent()));
+                    break;
+
+                case MantissaRange::MantissaScale::Small:
+                    // Small mantissa doesn't have the correction for
+                    // dropped remainders
+                    BEAST_EXPECT(stored < exact);
+                    break;
+            }
+        }
+        {
+            /* Companion test case for Upward positive operator/=: Downward negative
+             */
+            testcase << "operator/= Downward on Large returns value < truth " << to_string(scale);
+
+            NumberRoundModeGuard const roundGuard{Number::RoundingMode::Downward};
+
+            constexpr std::int64_t aValue = -2LL;
+            constexpr std::int64_t bValue = 1'000'000'000'000'000'007LL;
+            // bValue = 10^18 + 7 (prime, in [minMantissa, kMaxRep]).
+
+            Number const a{aValue, 0};
+            Number const b{bValue, 0};
+            Number const quotient = a / b;
+
+            dec const exact = dec(aValue) / dec(bValue);
+            dec const stored = dec(quotient.mantissa()) * pow10(quotient.exponent());
+            dec const diff = stored - exact;
+
+            log << "\n"
+                << "  a                 = " << aValue << "\n"
+                << "  b                 = " << bValue << "\n"
+                << "  exact a/b         = " << fmt(exact) << "\n"
+                << "  stored a/b        = " << fmt(stored) << "\n"
+                << "  stored - exact    = " << fmt(diff)
+                << "    (positive => Downward gave value ABOVE truth)\n"
+                << "  quotient.mantissa = " << quotient.mantissa() << "\n"
+                << "  quotient.exponent = " << quotient.exponent() << "\n";
+            log.flush();
+
+            // invariant: stored <= exact. Bug: stored > exact.
+            switch (scale)
+            {
+                case MantissaRange::MantissaScale::Large:
+                    BEAST_EXPECT(stored <= exact);
+                    BEAST_EXPECT(diff > -pow10(quotient.exponent()));
+                    break;
+
+                case MantissaRange::MantissaScale::LargeLegacy:
+                    BEAST_EXPECT(stored > exact);
+                    BEAST_EXPECT(diff <= pow10(quotient.exponent()));
+                    break;
+
+                case MantissaRange::MantissaScale::Small:
+                    // Small mantissa doesn't have the correction for
+                    // dropped remainders
+                    BEAST_EXPECT(stored < exact);
+                    break;
+            }
+        }
+        {
+            /* Companion test case for Upward positive operator/=: ToNearest
+             *
+             * With ToNearest, if the dropped digits are exactly "5", then the mantissa will be
+             * rounded to even. The numbers below result in a value where the unrounded mantissa
+             * ends in an even digit, and "infinite precision" would drop
+             * "500000000000000000145...", but doNormalize only sees "5". Without the rounding fix,
+             * doNormalize rounds down to the even value. With the rounding fix, doNormalize knows
+             * there are more digits beyond "5", and so rounds _up_ to the odd value.
+             */
+            testcase << "operator/= ToNearest on Large returns value < truth " << to_string(scale);
+
+            NumberRoundModeGuard const roundGuard{Number::RoundingMode::ToNearest};
+
+            constexpr std::int64_t aValue = 1'269'917'268'816'087'809LL;
+            constexpr std::int64_t bValue = 3'458'525'013'821'685'511LL;
+            // bValue = 10^18 + 7 (prime, in [minMantissa, kMaxRep]).
+
+            Number const a{aValue, 0};
+            Number const b{bValue, 0};
+            Number const quotient = a / b;
+
+            dec const exact = dec(aValue) / dec(bValue);
+            dec const stored = dec(quotient.mantissa()) * pow10(quotient.exponent());
+            dec const diff = stored - exact;
+
+            log << "\n"
+                << "  a                 = " << aValue << "\n"
+                << "  b                 = " << bValue << "\n"
+                << "  exact a/b         = " << fmt(exact) << "\n"
+                << "  stored a/b        = " << fmt(stored) << "\n"
+                << "  stored - exact    = " << fmt(diff)
+                << "    (negative => ToNearest gave value BELOW truth)\n"
+                << "  quotient.mantissa = " << quotient.mantissa() << "\n"
+                << "  quotient.exponent = " << quotient.exponent() << "\n";
+            log.flush();
+
+            // invariant: stored >= exact. Bug: stored < exact.
+            switch (scale)
+            {
+                case MantissaRange::MantissaScale::Large:
+                    BEAST_EXPECT(stored >= exact);
+                    BEAST_EXPECT(diff < pow10(quotient.exponent()));
+                    break;
+
+                case MantissaRange::MantissaScale::LargeLegacy:
+                    BEAST_EXPECT(stored < exact);
+                    BEAST_EXPECT(diff >= -pow10(quotient.exponent()));
+                    break;
+
+                case MantissaRange::MantissaScale::Small:
+                    // Small mantissa doesn't have the correction for
+                    // dropped remainders
+                    BEAST_EXPECT(stored < exact);
+                    break;
+            }
+        }
+    }
+
     void
     run() override
     {
-        for (auto const scale :
-             {MantissaRange::MantissaScale::Small, MantissaRange::MantissaScale::Large})
+        for (auto const scale : MantissaRange::getAllScales())
         {
             NumberMantissaScaleGuard const sg(scale);
             testZero();
@@ -1579,6 +1993,8 @@ public:
             testTruncate();
             testRounding();
             testInt64();
+
+            testUpwardRoundsDown();
         }
     }
 };
diff --git a/src/test/basics/PerfLog_test.cpp b/src/test/basics/PerfLog_test.cpp
index c370c241c5..41b5f81f5d 100644
--- a/src/test/basics/PerfLog_test.cpp
+++ b/src/test/basics/PerfLog_test.cpp
@@ -619,7 +619,7 @@ public:
 
                 // Total queued duration is triangle number of (i + 1).
                 BEAST_EXPECT(
-                    jsonToUInt64(total[jss::queued_duration_us]) == (((i * i) + 3 * i + 2) / 2));
+                    jsonToUInt64(total[jss::queued_duration_us]) == (((i * i) + (3 * i) + 2) / 2));
                 BEAST_EXPECT(total[jss::running_duration_us] == "0");
             }
 
diff --git a/src/test/basics/TaggedCache_test.cpp b/src/test/basics/TaggedCache_test.cpp
index 77cd25e543..26564a4de8 100644
--- a/src/test/basics/TaggedCache_test.cpp
+++ b/src/test/basics/TaggedCache_test.cpp
@@ -1,5 +1,7 @@
 #include 
 
+#include 
+#include 
 #include 
 #include   // IWYU pragma: keep
 #include 
@@ -8,6 +10,7 @@
 #include 
 
 #include 
+#include 
 
 namespace xrpl {
 
@@ -133,6 +136,113 @@ public:
             BEAST_EXPECT(c.getCacheSize() == 0);
             BEAST_EXPECT(c.getTrackSize() == 0);
         }
+        {
+            BEAST_EXPECT(!c.insert(5, "five"));
+            BEAST_EXPECT(c.getCacheSize() == 1);
+            BEAST_EXPECT(c.size() == 1);
+
+            {
+                auto const p1 = c.fetch(5);
+                BEAST_EXPECT(p1 != nullptr);
+                BEAST_EXPECT(c.getCacheSize() == 1);
+                BEAST_EXPECT(c.size() == 1);
+
+                // Advance the clock a lot
+                ++clock;
+                c.sweep();
+                BEAST_EXPECT(c.getCacheSize() == 0);
+                BEAST_EXPECT(c.size() == 1);
+
+                auto p2 = std::make_shared("five_2");
+                BEAST_EXPECT(c.canonicalizeReplaceCache(5, p2));
+                BEAST_EXPECT(c.getCacheSize() == 1);
+                BEAST_EXPECT(c.size() == 1);
+                // Make sure the caller's original pointer is unchanged
+                BEAST_EXPECT(p1.get() != p2.get());
+                BEAST_EXPECT(*p2 == "five_2");
+
+                auto const p3 = c.fetch(5);
+                BEAST_EXPECT(p3 != nullptr);
+                BEAST_EXPECT(p3.get() == p2.get());
+                BEAST_EXPECT(p3.get() != p1.get());
+            }
+
+            ++clock;
+            c.sweep();
+            BEAST_EXPECT(c.getCacheSize() == 0);
+            BEAST_EXPECT(c.size() == 0);
+        }
+
+        {
+            testcase("intrptr");
+
+            struct MyRefCountObject : IntrusiveRefCounts
+            {
+                std::string data;
+
+                // Needed to support weak intrusive pointers
+                virtual void
+                partialDestructor() {};
+
+                MyRefCountObject() = default;
+                explicit MyRefCountObject(std::string data) : data(std::move(data))
+                {
+                }
+
+                bool
+                operator==(std::string const& other) const
+                {
+                    return data == other;
+                }
+            };
+
+            using IntrPtrCache = TaggedCache<
+                Key,
+                MyRefCountObject,
+                /*IsKeyCache*/ false,
+                intr_ptr::SharedWeakUnionPtr,
+                intr_ptr::SharedPtr>;
+
+            IntrPtrCache intrPtrCache("IntrPtrTest", 1, 1s, clock, journal);
+
+            intrPtrCache.canonicalizeReplaceCache(1, intr_ptr::makeShared("one"));
+            BEAST_EXPECT(intrPtrCache.getCacheSize() == 1);
+            BEAST_EXPECT(intrPtrCache.size() == 1);
+
+            {
+                {
+                    intrPtrCache.canonicalizeReplaceCache(
+                        1, intr_ptr::makeShared("one_replaced"));
+
+                    auto p = intrPtrCache.fetch(1);
+                    BEAST_EXPECT(*p == "one_replaced");
+
+                    // Advance the clock a lot
+                    ++clock;
+                    intrPtrCache.sweep();
+                    BEAST_EXPECT(intrPtrCache.getCacheSize() == 0);
+                    BEAST_EXPECT(intrPtrCache.size() == 1);
+
+                    intrPtrCache.canonicalizeReplaceCache(
+                        1, intr_ptr::makeShared("one_replaced_2"));
+
+                    auto p2 = intrPtrCache.fetch(1);
+                    BEAST_EXPECT(*p2 == "one_replaced_2");
+
+                    intrPtrCache.del(1, true);
+                }
+
+                intrPtrCache.canonicalizeReplaceCache(
+                    1, intr_ptr::makeShared("one_replaced_3"));
+                auto p3 = intrPtrCache.fetch(1);
+                BEAST_EXPECT(*p3 == "one_replaced_3");
+            }
+
+            ++clock;
+            intrPtrCache.sweep();
+            BEAST_EXPECT(intrPtrCache.getCacheSize() == 0);
+            BEAST_EXPECT(intrPtrCache.size() == 0);
+        }
     }
 };
 
diff --git a/src/test/beast/IPEndpointCommon.h b/src/test/beast/IPEndpointCommon.h
index 15566cb830..0ff0da35be 100644
--- a/src/test/beast/IPEndpointCommon.h
+++ b/src/test/beast/IPEndpointCommon.h
@@ -1,3 +1,5 @@
+#pragma once
+
 #include 
 #include 
 
diff --git a/src/test/beast/aged_associative_container_test.cpp b/src/test/beast/aged_associative_container_test.cpp
index f2ce72b584..413194491a 100644
--- a/src/test/beast/aged_associative_container_test.cpp
+++ b/src/test/beast/aged_associative_container_test.cpp
@@ -11,6 +11,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -232,10 +233,10 @@ public:
     {
     public:
         using T = void;
-        using Value = typename Base::Key;
+        using Value = Base::Key;
         using Values = std::vector;
 
-        static typename Base::Key const&
+        static Base::Key const&
         extract(Value const& value)
         {
             return value;  // NOLINT(bugprone-return-const-ref-from-parameter)
@@ -271,7 +272,7 @@ public:
         using Value = std::pair;
         using Values = std::vector;
 
-        static typename Base::Key const&
+        static Base::Key const&
         extract(Value const& value)
         {
             return value.first;
@@ -387,7 +388,7 @@ public:
     struct EqualValue
     {
         bool
-        operator()(typename Traits::Value const& lhs, typename Traits::Value const& rhs)
+        operator()(Traits::Value const& lhs, Traits::Value const& rhs)
         {
             return Traits::extract(lhs) == Traits::extract(rhs);
         }
@@ -414,11 +415,11 @@ public:
 
     // unordered
     template 
-    std::enable_if_t::type::is_unordered::value>
+    std::enable_if_t::is_unordered::value>
     checkUnorderedContentsRefRef(C&& c, Values const& v);
 
     template 
-    std::enable_if_t::type::is_unordered::value>
+    std::enable_if_t::is_unordered::value>
     checkUnorderedContentsRefRef(C&&, Values const&)
     {
     }
@@ -641,13 +642,13 @@ AgedAssociativeContainerTestBase::checkMapContents(Container& c, Values const& v
 
 // unordered
 template 
-std::enable_if_t::type::is_unordered::value>
+std::enable_if_t::is_unordered::value>
 AgedAssociativeContainerTestBase::checkUnorderedContentsRefRef(C&& c, Values const& v)
 {
     using Cont = std::remove_reference_t;
     using Traits =
         TestTraits;
-    using size_type = typename Cont::size_type;
+    using size_type = Cont::size_type;
     auto const hash(c.hashFunction());
     auto const keyEq(c.keyEq());
     for (size_type i(0); i < c.bucketCount(); ++i)
@@ -655,10 +656,9 @@ AgedAssociativeContainerTestBase::checkUnorderedContentsRefRef(C&& c, Values con
         auto const last(c.end(i));
         for (auto iter(c.begin(i)); iter != last; ++iter)
         {
-            auto const match(
-                std::find_if(v.begin(), v.end(), [iter](typename Values::value_type const& e) {
-                    return Traits::extract(*iter) == Traits::extract(e);
-                }));
+            auto const match(std::ranges::find_if(v, [iter](Values::value_type const& e) {
+                return Traits::extract(*iter) == Traits::extract(e);
+            }));
             BEAST_EXPECT(match != v.end());
             BEAST_EXPECT(keyEq(Traits::extract(*iter), Traits::extract(*match)));
             BEAST_EXPECT(hash(Traits::extract(*iter)) == hash(Traits::extract(*match)));
@@ -671,7 +671,7 @@ void
 AgedAssociativeContainerTestBase::checkContentsRefRef(C&& c, Values const& v)
 {
     using Cont = std::remove_reference_t;
-    using size_type = typename Cont::size_type;
+    using size_type = Cont::size_type;
 
     BEAST_EXPECT(c.size() == v.size());
     BEAST_EXPECT(size_type(std::distance(c.begin(), c.end())) == v.size());
@@ -703,7 +703,7 @@ AgedAssociativeContainerTestBase::checkContents(Cont& c)
 {
     using Traits =
         TestTraits;
-    using Values = typename Traits::Values;
+    using Values = Traits::Values;
     checkContents(c, Values());
 }
 
@@ -719,10 +719,10 @@ std::enable_if_t
 AgedAssociativeContainerTestBase::testConstructEmpty()
 {
     using Traits = TestTraits;
-    using Comp = typename Traits::Comp;
-    using Alloc = typename Traits::Alloc;
-    using MyComp = typename Traits::MyComp;
-    using MyAlloc = typename Traits::MyAlloc;
+    using Comp = Traits::Comp;
+    using Alloc = Traits::Alloc;
+    using MyComp = Traits::MyComp;
+    using MyAlloc = Traits::MyAlloc;
     typename Traits::ManualClock clock;
 
     // testcase (Traits::name() + " empty");
@@ -755,12 +755,12 @@ std::enable_if_t
 AgedAssociativeContainerTestBase::testConstructEmpty()
 {
     using Traits = TestTraits;
-    using Hash = typename Traits::Hash;
-    using Equal = typename Traits::Equal;
-    using Alloc = typename Traits::Alloc;
-    using MyHash = typename Traits::MyHash;
-    using MyEqual = typename Traits::MyEqual;
-    using MyAlloc = typename Traits::MyAlloc;
+    using Hash = Traits::Hash;
+    using Equal = Traits::Equal;
+    using Alloc = Traits::Alloc;
+    using MyHash = Traits::MyHash;
+    using MyEqual = Traits::MyEqual;
+    using MyAlloc = Traits::MyAlloc;
     typename Traits::ManualClock clock;
 
     // testcase (Traits::name() + " empty");
@@ -813,10 +813,10 @@ std::enable_if_t
 AgedAssociativeContainerTestBase::testConstructRange()
 {
     using Traits = TestTraits;
-    using Comp = typename Traits::Comp;
-    using Alloc = typename Traits::Alloc;
-    using MyComp = typename Traits::MyComp;
-    using MyAlloc = typename Traits::MyAlloc;
+    using Comp = Traits::Comp;
+    using Alloc = Traits::Alloc;
+    using MyComp = Traits::MyComp;
+    using MyAlloc = Traits::MyAlloc;
     typename Traits::ManualClock clock;
     auto const v(Traits::values());
 
@@ -860,12 +860,12 @@ std::enable_if_t
 AgedAssociativeContainerTestBase::testConstructRange()
 {
     using Traits = TestTraits;
-    using Hash = typename Traits::Hash;
-    using Equal = typename Traits::Equal;
-    using Alloc = typename Traits::Alloc;
-    using MyHash = typename Traits::MyHash;
-    using MyEqual = typename Traits::MyEqual;
-    using MyAlloc = typename Traits::MyAlloc;
+    using Hash = Traits::Hash;
+    using Equal = Traits::Equal;
+    using Alloc = Traits::Alloc;
+    using MyHash = Traits::MyHash;
+    using MyEqual = Traits::MyEqual;
+    using MyAlloc = Traits::MyAlloc;
     typename Traits::ManualClock clock;
     auto const v(Traits::values());
 
@@ -962,7 +962,7 @@ void
 AgedAssociativeContainerTestBase::testCopyMove()
 {
     using Traits = TestTraits;
-    using Alloc = typename Traits::Alloc;
+    using Alloc = Traits::Alloc;
     typename Traits::ManualClock clock;
     auto const v(Traits::values());
 
@@ -1296,18 +1296,12 @@ AgedAssociativeContainerTestBase::testChronological()
 
     typename Traits::template Cont<> c(v.begin(), v.end(), clock);
 
-    BEAST_EXPECT(
-        std::equal(
-            c.chronological.cbegin(),
-            c.chronological.cend(),
-            v.begin(),
-            v.end(),
-            EqualValue()));
+    BEAST_EXPECT(std::ranges::equal(c.chronological, v, EqualValue()));
 
     // Test touch() with a non-const iterator.
     for (auto iter(v.crbegin()); iter != v.crend(); ++iter)
     {
-        using iterator = typename decltype(c)::iterator;
+        using iterator = decltype(c)::iterator;
         iterator const found(c.find(Traits::extract(*iter)));
 
         BEAST_EXPECT(found != c.cend());
@@ -1327,7 +1321,7 @@ AgedAssociativeContainerTestBase::testChronological()
     // Test touch() with a const_iterator
     for (auto iter(v.cbegin()); iter != v.cend(); ++iter)
     {
-        using const_iterator = typename decltype(c)::const_iterator;
+        using const_iterator = decltype(c)::const_iterator;
         const_iterator const found(c.find(Traits::extract(*iter)));
 
         BEAST_EXPECT(found != c.cend());
@@ -1336,13 +1330,7 @@ AgedAssociativeContainerTestBase::testChronological()
         c.touch(found);
     }
 
-    BEAST_EXPECT(
-        std::equal(
-            c.chronological.cbegin(),
-            c.chronological.cend(),
-            v.cbegin(),
-            v.cend(),
-            EqualValue()));
+    BEAST_EXPECT(std::ranges::equal(c.chronological, v, EqualValue()));
 
     {
         // Because touch (reverse_iterator pos) is not allowed, the following
@@ -1407,8 +1395,8 @@ AgedAssociativeContainerTestBase::reverseFillAgedContainer(Container& c, Values
     clk.set(0);
 
     Values rev(values);
-    std::sort(rev.begin(), rev.end());
-    std::reverse(rev.begin(), rev.end());
+    std::ranges::sort(rev);
+    std::ranges::reverse(rev);
     for (auto& v : rev)
     {
         // Add values in reverse order so they are reversed chronologically.
diff --git a/src/test/beast/beast_io_latency_probe_test.cpp b/src/test/beast/beast_io_latency_probe_test.cpp
index 5f183ef091..9a93968dab 100644
--- a/src/test/beast/beast_io_latency_probe_test.cpp
+++ b/src/test/beast/beast_io_latency_probe_test.cpp
@@ -8,6 +8,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include   // IWYU pragma: keep
 #include 
@@ -36,8 +37,8 @@ class io_latency_probe_test : public beast::unit_test::Suite, public beast::test
     template 
     struct MeasureAsioTimers
     {
-        using duration = typename Clock::duration;
-        using rep = typename MeasureClock::duration::rep;
+        using duration = Clock::duration;
+        using rep = MeasureClock::duration::rep;
 
         std::vector elapsedTimes;
 
@@ -94,18 +95,14 @@ class io_latency_probe_test : public beast::unit_test::Suite, public beast::test
         auto
         getMax()
         {
-            return std::chrono::duration_cast(
-                       *std::max_element(elapsedTimes.begin(), elapsedTimes.end()))
-                .count();
+            return std::chrono::duration_cast(*std::ranges::max_element(elapsedTimes)).count();
         }
 
         template 
         auto
         getMin()
         {
-            return std::chrono::duration_cast(
-                       *std::min_element(elapsedTimes.begin(), elapsedTimes.end()))
-                .count();
+            return std::chrono::duration_cast(*std::ranges::min_element(elapsedTimes)).count();
         }
     };
 #endif
diff --git a/src/test/consensus/ByzantineFailureSim_test.cpp b/src/test/consensus/ByzantineFailureSim_test.cpp
index ad75a78086..c3c51125b5 100644
--- a/src/test/consensus/ByzantineFailureSim_test.cpp
+++ b/src/test/consensus/ByzantineFailureSim_test.cpp
@@ -1,4 +1,3 @@
-#include 
 #include 
 #include 
 #include 
diff --git a/src/test/consensus/Consensus_test.cpp b/src/test/consensus/Consensus_test.cpp
index b8fed1d981..92a4c67e32 100644
--- a/src/test/consensus/Consensus_test.cpp
+++ b/src/test/consensus/Consensus_test.cpp
@@ -1,4 +1,3 @@
-#include 
 #include 
 #include 
 #include 
@@ -453,7 +452,7 @@ public:
 
         // Vary the time it takes to process validations to exercise detecting
         // the wrong LCL at different phases of consensus
-        for (auto validationDelay : {0ms, parms.ledgerMIN_CLOSE})
+        for (auto validationDelay : {0ms, parms.ledgerMinClose})
         {
             // Consider 10 peers:
             // 0 1         2 3 4       5 6 7 8 9
@@ -492,7 +491,7 @@ public:
             CollectByNode jumps;
             sim.collectors.add(jumps);
 
-            BEAST_EXPECT(sim.trustGraph.canFork(parms.minCONSENSUS_PCT / 100.));
+            BEAST_EXPECT(sim.trustGraph.canFork(parms.minConsensusPct / 100.));
 
             // initial round to set prior state
             sim.run(1);
@@ -1296,14 +1295,11 @@ public:
                 auto const s = clog->str();
                 expect(s.find("stalled"), s, __FILE__, line);
                 expect(s.starts_with("Transaction "s + std::to_string(txid)), s, __FILE__, line);
-                expect(s.find("voting "s + (ourVote ? "YES" : "NO")) != s.npos, s, __FILE__, line);
+                expect(s.contains("voting "s + (ourVote ? "YES" : "NO")), s, __FILE__, line);
                 expect(
-                    s.find("for "s + std::to_string(ourTime) + " rounds."s) != s.npos,
-                    s,
-                    __FILE__,
-                    line);
+                    s.contains("for "s + std::to_string(ourTime) + " rounds."s), s, __FILE__, line);
                 expect(
-                    s.find("votes in "s + std::to_string(peerTime) + " rounds.") != s.npos,
+                    s.contains("votes in "s + std::to_string(peerTime) + " rounds."),
                     s,
                     __FILE__,
                     line);
diff --git a/src/test/consensus/DistributedValidatorsSim_test.cpp b/src/test/consensus/DistributedValidatorsSim_test.cpp
index 6d9ac6bede..1def09db13 100644
--- a/src/test/consensus/DistributedValidatorsSim_test.cpp
+++ b/src/test/consensus/DistributedValidatorsSim_test.cpp
@@ -1,4 +1,3 @@
-#include 
 #include 
 #include 
 #include 
diff --git a/src/test/consensus/LedgerTrie_test.cpp b/src/test/consensus/LedgerTrie_test.cpp
index 2b638b1dfa..0ddf1bf82f 100644
--- a/src/test/consensus/LedgerTrie_test.cpp
+++ b/src/test/consensus/LedgerTrie_test.cpp
@@ -663,6 +663,7 @@ class LedgerTrie_test : public beast::unit_test::Suite
         std::uint32_t const iterations = 10000;
 
         // Use explicit seed to have same results for CI
+        // NOLINTNEXTLINE(bugprone-random-generator-seed): fixed seed for reproducible test
         std::mt19937 gen{42};
         std::uniform_int_distribution<> depthDist(0, depthConst - 1);
         std::uniform_int_distribution<> widthDist(0, width - 1);
diff --git a/src/test/consensus/NegativeUNL_test.cpp b/src/test/consensus/NegativeUNL_test.cpp
index 05dc3f0e2f..142077a42f 100644
--- a/src/test/consensus/NegativeUNL_test.cpp
+++ b/src/test/consensus/NegativeUNL_test.cpp
@@ -239,7 +239,7 @@ class NegativeUNL_test : public beast::unit_test::Suite
         auto l = std::make_shared(
             kCreateGenesis,
             Rules{env.app().config().features},
-            env.app().config().FEES.toFees(),
+            env.app().config().fees.toFees(),
             std::vector{},
             env.app().getNodeFamily());
 
@@ -554,12 +554,12 @@ struct NetworkHistory
     createNodes()
     {
         assert(param.numNodes <= 256);
-        UNLKeys = createPublicKeys(param.numNodes);
+        unlKeys = createPublicKeys(param.numNodes);
         for (int i = 0; i < param.numNodes; ++i)
         {
-            UNLKeySet.insert(UNLKeys[i]);
-            UNLNodeIDs.push_back(calcNodeID(UNLKeys[i]));
-            UNLNodeIDSet.insert(UNLNodeIDs.back());
+            unlKeySet.insert(unlKeys[i]);
+            unlNodeIDs.push_back(calcNodeID(unlKeys[i]));
+            unlNodeIdSet.insert(unlNodeIDs.back());
         }
     }
 
@@ -574,7 +574,7 @@ struct NetworkHistory
         auto l = std::make_shared(
             kCreateGenesis,
             Rules{env.app().config().features},
-            env.app().config().FEES.toFees(),
+            env.app().config().fees.toFees(),
             std::vector{kFakeAmendment++},
             env.app().getNodeFamily());
         history.push_back(l);
@@ -593,7 +593,7 @@ struct NetworkHistory
                 OpenView accum(&*l);
                 if (l->negativeUNL().size() < param.negUNLSize)
                 {
-                    auto tx = createTx(true, l->seq(), UNLKeys[nidx]);
+                    auto tx = createTx(true, l->seq(), unlKeys[nidx]);
                     if (!applyAndTestResult(env, accum, tx, true))
                         break;
                     ++nidx;
@@ -602,14 +602,14 @@ struct NetworkHistory
                 {
                     if (param.hasToDisable)
                     {
-                        auto tx = createTx(true, l->seq(), UNLKeys[nidx]);
+                        auto tx = createTx(true, l->seq(), unlKeys[nidx]);
                         if (!applyAndTestResult(env, accum, tx, true))
                             break;
                         ++nidx;
                     }
                     if (param.hasToReEnable)
                     {
-                        auto tx = createTx(false, l->seq(), UNLKeys[0]);
+                        auto tx = createTx(false, l->seq(), unlKeys[0]);
                         if (!applyAndTestResult(env, accum, tx, true))
                             break;
                     }
@@ -665,9 +665,9 @@ struct NetworkHistory
             {
                 if (needVal(history[curr], i))
                 {
-                    RCLValidation v(createSTVal(history[curr], UNLNodeIDs[i]));
+                    RCLValidation v(createSTVal(history[curr], unlNodeIDs[i]));
                     v.setTrusted();
-                    validations.add(UNLNodeIDs[i], v);
+                    validations.add(unlNodeIDs[i], v);
                 }
             }
         }
@@ -682,10 +682,10 @@ struct NetworkHistory
     jtx::Env env;
     Parameter param;
     RCLValidations& validations;
-    std::vector UNLKeys;
-    hash_set UNLKeySet;
-    std::vector UNLNodeIDs;
-    hash_set UNLNodeIDSet;
+    std::vector unlKeys;
+    hash_set unlKeySet;
+    std::vector unlNodeIDs;
+    hash_set unlNodeIdSet;
     LedgerHistory history;
     bool goodHistory;
 };
@@ -714,7 +714,7 @@ voteAndCheck(
     pre(vote);
     auto txSet =
         std::make_shared(SHAMapType::TRANSACTION, history.env.app().getNodeFamily());
-    vote.doVoting(history.lastLedger(), history.UNLKeySet, history.validations, txSet);
+    vote.doVoting(history.lastLedger(), history.unlKeySet, history.validations, txSet);
     return countTx(txSet) == expect;
 }
 
@@ -795,9 +795,9 @@ class NegativeUNLVoteInternal_test : public beast::unit_test::Suite
             BEAST_EXPECT(history.goodHistory);
             if (history.goodHistory)
             {
-                NegativeUNLVote vote(history.UNLNodeIDs[3], history.env.journal);
+                NegativeUNLVote vote(history.unlNodeIDs[3], history.env.journal);
                 BEAST_EXPECT(!vote.buildScoreTable(
-                    history.lastLedger(), history.UNLNodeIDSet, history.validations));
+                    history.lastLedger(), history.unlNodeIdSet, history.validations));
             }
         }
 
@@ -813,9 +813,9 @@ class NegativeUNLVoteInternal_test : public beast::unit_test::Suite
             BEAST_EXPECT(history.goodHistory);
             if (history.goodHistory)
             {
-                NegativeUNLVote vote(history.UNLNodeIDs[3], history.env.journal);
+                NegativeUNLVote vote(history.unlNodeIDs[3], history.env.journal);
                 BEAST_EXPECT(!vote.buildScoreTable(
-                    history.lastLedger(), history.UNLNodeIDSet, history.validations));
+                    history.lastLedger(), history.unlNodeIdSet, history.validations));
             }
         }
 
@@ -831,15 +831,15 @@ class NegativeUNLVoteInternal_test : public beast::unit_test::Suite
             BEAST_EXPECT(history.goodHistory);
             if (history.goodHistory)
             {
-                NodeID myId = history.UNLNodeIDs[3];
+                NodeID myId = history.unlNodeIDs[3];
                 history.walkHistoryAndAddValidations(
                     [&](std::shared_ptr const& l, std::size_t idx) -> bool {
                         // skip half my validations.
-                        return history.UNLNodeIDs[idx] != myId || l->seq() % 2 != 0;
+                        return history.unlNodeIDs[idx] != myId || l->seq() % 2 != 0;
                     });
                 NegativeUNLVote vote(myId, history.env.journal);
                 BEAST_EXPECT(!vote.buildScoreTable(
-                    history.lastLedger(), history.UNLNodeIDSet, history.validations));
+                    history.lastLedger(), history.unlNodeIdSet, history.validations));
             }
         }
 
@@ -864,12 +864,12 @@ class NegativeUNLVoteInternal_test : public beast::unit_test::Suite
 
             if (history.goodHistory && wrongChainSuccess)
             {
-                NodeID myId = history.UNLNodeIDs[3];
-                NodeID const badNode = history.UNLNodeIDs[4];
+                NodeID myId = history.unlNodeIDs[3];
+                NodeID const badNode = history.unlNodeIDs[4];
                 history.walkHistoryAndAddValidations(
                     [&](std::shared_ptr const& l, std::size_t idx) -> bool {
                         // everyone but me
-                        return !(history.UNLNodeIDs[idx] == myId);
+                        return !(history.unlNodeIDs[idx] == myId);
                     });
 
                 // local node validate wrong chain
@@ -887,7 +887,7 @@ class NegativeUNLVoteInternal_test : public beast::unit_test::Suite
                 // local node still on wrong chain, can build a scoreTable,
                 // but all other nodes' scores are zero
                 auto scoreTable = vote.buildScoreTable(
-                    wrongChain.back(), history.UNLNodeIDSet, history.validations);
+                    wrongChain.back(), history.unlNodeIdSet, history.validations);
                 BEAST_EXPECT(scoreTable);
                 if (scoreTable)
                 {
@@ -907,7 +907,7 @@ class NegativeUNLVoteInternal_test : public beast::unit_test::Suite
                 // if local node switched to right history, but cannot build
                 // scoreTable because not enough local validations
                 BEAST_EXPECT(!vote.buildScoreTable(
-                    history.lastLedger(), history.UNLNodeIDSet, history.validations));
+                    history.lastLedger(), history.unlNodeIdSet, history.validations));
             }
         }
 
@@ -927,9 +927,9 @@ class NegativeUNLVoteInternal_test : public beast::unit_test::Suite
                     [&](std::shared_ptr const& l, std::size_t idx) -> bool {
                         return true;
                     });
-                NegativeUNLVote vote(history.UNLNodeIDs[3], history.env.journal);
+                NegativeUNLVote vote(history.unlNodeIDs[3], history.env.journal);
                 auto scoreTable = vote.buildScoreTable(
-                    history.lastLedger(), history.UNLNodeIDSet, history.validations);
+                    history.lastLedger(), history.unlNodeIdSet, history.validations);
                 BEAST_EXPECT(scoreTable);
                 if (scoreTable)
                 {
@@ -1001,35 +1001,35 @@ class NegativeUNLVoteInternal_test : public beast::unit_test::Suite
 
         hash_set negUnl012;
         for (std::uint32_t i = 0; i < 3; ++i)
-            negUnl012.insert(history.UNLNodeIDs[i]);
+            negUnl012.insert(history.unlNodeIDs[i]);
 
         // build a good scoreTable to use, or copy and modify
         hash_map goodScoreTable;
-        for (auto const& n : history.UNLNodeIDs)
+        for (auto const& n : history.unlNodeIDs)
             goodScoreTable[n] = NegativeUNLVote::kNegativeUnlHighWaterMark + 1;
 
-        NegativeUNLVote vote(history.UNLNodeIDs[0], history.env.journal);
+        NegativeUNLVote vote(history.unlNodeIDs[0], history.env.journal);
 
         {
             // all good scores
             BEAST_EXPECT(
-                checkCandidateSizes(vote, history.UNLNodeIDSet, negUnl012, goodScoreTable, 0, 3));
+                checkCandidateSizes(vote, history.unlNodeIdSet, negUnl012, goodScoreTable, 0, 3));
         }
         {
             // all bad scores
             hash_map scoreTable;
-            for (auto& n : history.UNLNodeIDs)
+            for (auto& n : history.unlNodeIDs)
                 scoreTable[n] = NegativeUNLVote::kNegativeUnlLowWaterMark - 1;
             BEAST_EXPECT(
-                checkCandidateSizes(vote, history.UNLNodeIDSet, negUnl012, scoreTable, 35 - 3, 0));
+                checkCandidateSizes(vote, history.unlNodeIdSet, negUnl012, scoreTable, 35 - 3, 0));
         }
         {
             // all between watermarks
             hash_map scoreTable;
-            for (auto& n : history.UNLNodeIDs)
+            for (auto& n : history.unlNodeIDs)
                 scoreTable[n] = NegativeUNLVote::kNegativeUnlLowWaterMark + 1;
             BEAST_EXPECT(
-                checkCandidateSizes(vote, history.UNLNodeIDSet, negUnl012, scoreTable, 0, 0));
+                checkCandidateSizes(vote, history.unlNodeIdSet, negUnl012, scoreTable, 0, 0));
         }
 
         {
@@ -1037,36 +1037,36 @@ class NegativeUNLVoteInternal_test : public beast::unit_test::Suite
             auto scoreTable = goodScoreTable;
             scoreTable[*negUnl012.begin()] = NegativeUNLVote::kNegativeUnlLowWaterMark + 1;
             BEAST_EXPECT(
-                checkCandidateSizes(vote, history.UNLNodeIDSet, negUnl012, scoreTable, 0, 2));
+                checkCandidateSizes(vote, history.unlNodeIdSet, negUnl012, scoreTable, 0, 2));
         }
 
         {
             // 2 bad scorers not in negUnl
             auto scoreTable = goodScoreTable;
-            scoreTable[history.UNLNodeIDs[11]] = NegativeUNLVote::kNegativeUnlLowWaterMark - 1;
-            scoreTable[history.UNLNodeIDs[12]] = NegativeUNLVote::kNegativeUnlLowWaterMark - 1;
+            scoreTable[history.unlNodeIDs[11]] = NegativeUNLVote::kNegativeUnlLowWaterMark - 1;
+            scoreTable[history.unlNodeIDs[12]] = NegativeUNLVote::kNegativeUnlLowWaterMark - 1;
             BEAST_EXPECT(
-                checkCandidateSizes(vote, history.UNLNodeIDSet, negUnl012, scoreTable, 2, 3));
+                checkCandidateSizes(vote, history.unlNodeIdSet, negUnl012, scoreTable, 2, 3));
         }
 
         {
             // 2 in negUnl but not in unl, have a remove candidate from score
             // table
-            hash_set unlTemp = history.UNLNodeIDSet;
-            unlTemp.erase(history.UNLNodeIDs[0]);
-            unlTemp.erase(history.UNLNodeIDs[1]);
+            hash_set unlTemp = history.unlNodeIdSet;
+            unlTemp.erase(history.unlNodeIDs[0]);
+            unlTemp.erase(history.unlNodeIDs[1]);
             BEAST_EXPECT(checkCandidateSizes(vote, unlTemp, negUnl012, goodScoreTable, 0, 3));
         }
 
         {
             // 2 in negUnl but not in unl, no remove candidate from score table
             auto scoreTable = goodScoreTable;
-            scoreTable.erase(history.UNLNodeIDs[0]);
-            scoreTable.erase(history.UNLNodeIDs[1]);
-            scoreTable[history.UNLNodeIDs[2]] = NegativeUNLVote::kNegativeUnlLowWaterMark + 1;
-            hash_set unlTemp = history.UNLNodeIDSet;
-            unlTemp.erase(history.UNLNodeIDs[0]);
-            unlTemp.erase(history.UNLNodeIDs[1]);
+            scoreTable.erase(history.unlNodeIDs[0]);
+            scoreTable.erase(history.unlNodeIDs[1]);
+            scoreTable[history.unlNodeIDs[2]] = NegativeUNLVote::kNegativeUnlLowWaterMark + 1;
+            hash_set unlTemp = history.unlNodeIdSet;
+            unlTemp.erase(history.unlNodeIDs[0]);
+            unlTemp.erase(history.unlNodeIDs[1]);
             BEAST_EXPECT(checkCandidateSizes(vote, unlTemp, negUnl012, scoreTable, 0, 2));
         }
 
@@ -1075,7 +1075,7 @@ class NegativeUNLVoteInternal_test : public beast::unit_test::Suite
             NodeID const new1(0xbead);
             NodeID const new2(0xbeef);
             hash_set const nowTrusted = {new1, new2};
-            hash_set unlTemp = history.UNLNodeIDSet;
+            hash_set unlTemp = history.unlNodeIdSet;
             unlTemp.insert(new1);
             unlTemp.insert(new2);
             vote.newValidators(256, nowTrusted);
@@ -1381,7 +1381,7 @@ class NegativeUNLVoteScoreTable_test : public beast::unit_test::Suite
                 BEAST_EXPECT(history.goodHistory);
                 if (history.goodHistory)
                 {
-                    NodeID myId = history.UNLNodeIDs[3];
+                    NodeID myId = history.unlNodeIDs[3];
                     history.walkHistoryAndAddValidations(
                         [&](std::shared_ptr const& l, std::size_t idx) -> bool {
                             std::size_t k = 0;
@@ -1400,19 +1400,19 @@ class NegativeUNLVoteScoreTable_test : public beast::unit_test::Suite
 
                             bool const add50 = scorePattern[sp][k] == 50 && l->seq() % 2 == 0;
                             bool const add100 = scorePattern[sp][k] == 100;
-                            bool const addMe = history.UNLNodeIDs[idx] == myId;
+                            bool const addMe = history.unlNodeIDs[idx] == myId;
                             return add50 || add100 || addMe;
                         });
 
                     NegativeUNLVote vote(myId, history.env.journal);
                     auto scoreTable = vote.buildScoreTable(
-                        history.lastLedger(), history.UNLNodeIDSet, history.validations);
+                        history.lastLedger(), history.unlNodeIdSet, history.validations);
                     BEAST_EXPECT(scoreTable);
                     if (scoreTable)
                     {
                         std::uint32_t i = 0;  // looping unl
                         auto checkScores = [&](std::uint32_t score, std::uint32_t k) -> bool {
-                            if (history.UNLNodeIDs[i] == myId)
+                            if (history.unlNodeIDs[i] == myId)
                                 return score == 256;
                             if (scorePattern[sp][k] == 0)
                                 return score == 0;
@@ -1427,15 +1427,15 @@ class NegativeUNLVoteScoreTable_test : public beast::unit_test::Suite
                         };
                         for (; i < 2; ++i)
                         {
-                            BEAST_EXPECT(checkScores((*scoreTable)[history.UNLNodeIDs[i]], 0));
+                            BEAST_EXPECT(checkScores((*scoreTable)[history.unlNodeIDs[i]], 0));
                         }
                         for (; i < 4; ++i)
                         {
-                            BEAST_EXPECT(checkScores((*scoreTable)[history.UNLNodeIDs[i]], 1));
+                            BEAST_EXPECT(checkScores((*scoreTable)[history.unlNodeIDs[i]], 1));
                         }
                         for (; i < unlSize; ++i)
                         {
-                            BEAST_EXPECT(checkScores((*scoreTable)[history.UNLNodeIDs[i]], 2));
+                            BEAST_EXPECT(checkScores((*scoreTable)[history.unlNodeIDs[i]], 2));
                         }
                     }
                 }
@@ -1506,7 +1506,7 @@ class NegativeUNLVoteGoodScore_test : public beast::unit_test::Suite
                     [&](std::shared_ptr const& l, std::size_t idx) -> bool {
                         return true;
                     });
-                BEAST_EXPECT(voteAndCheck(history, history.UNLNodeIDs[0], 0));
+                BEAST_EXPECT(voteAndCheck(history, history.unlNodeIDs[0], 0));
             }
         }
 
@@ -1527,7 +1527,7 @@ class NegativeUNLVoteGoodScore_test : public beast::unit_test::Suite
                     [&](std::shared_ptr const& l, std::size_t idx) -> bool {
                         return true;
                     });
-                BEAST_EXPECT(voteAndCheck(history, history.UNLNodeIDs[0], 1));
+                BEAST_EXPECT(voteAndCheck(history, history.unlNodeIDs[0], 1));
             }
         }
     }
@@ -1564,7 +1564,7 @@ class NegativeUNLVoteOffline_test : public beast::unit_test::Suite
                         // skip node 0 and node 1
                         return idx > 1;
                     });
-                BEAST_EXPECT(voteAndCheck(history, history.UNLNodeIDs.back(), 1));
+                BEAST_EXPECT(voteAndCheck(history, history.unlNodeIDs.back(), 1));
             }
         }
 
@@ -1587,9 +1587,9 @@ class NegativeUNLVoteOffline_test : public beast::unit_test::Suite
                 history.walkHistoryAndAddValidations(
                     [&](std::shared_ptr const& l, std::size_t idx) -> bool {
                         // skip node 0 and node 1
-                        return history.UNLNodeIDs[idx] != n1 && history.UNLNodeIDs[idx] != n2;
+                        return history.unlNodeIDs[idx] != n1 && history.unlNodeIDs[idx] != n2;
                     });
-                BEAST_EXPECT(voteAndCheck(history, history.UNLNodeIDs.back(), 0));
+                BEAST_EXPECT(voteAndCheck(history, history.unlNodeIDs.back(), 0));
             }
         }
     }
@@ -1626,7 +1626,7 @@ class NegativeUNLVoteMaxListed_test : public beast::unit_test::Suite
                         // skip node 0 ~ 10
                         return idx > 10;
                     });
-                BEAST_EXPECT(voteAndCheck(history, history.UNLNodeIDs.back(), 0));
+                BEAST_EXPECT(voteAndCheck(history, history.unlNodeIDs.back(), 0));
             }
         }
     }
@@ -1662,7 +1662,7 @@ class NegativeUNLVoteRetiredValidator_test : public beast::unit_test::Suite
                     [&](std::shared_ptr const& l, std::size_t idx) -> bool {
                         return idx > 1;
                     });
-                BEAST_EXPECT(voteAndCheck(history, history.UNLNodeIDs[0], 0));
+                BEAST_EXPECT(voteAndCheck(history, history.unlNodeIDs[0], 0));
             }
         }
 
@@ -1705,9 +1705,9 @@ class NegativeUNLVoteRetiredValidator_test : public beast::unit_test::Suite
                         return idx > 1;
                     });
                 BEAST_EXPECT(
-                    voteAndCheck(history, history.UNLNodeIDs.back(), 1, [&](NegativeUNLVote& vote) {
-                        history.UNLKeySet.erase(history.UNLKeys[0]);
-                        history.UNLKeySet.erase(history.UNLKeys[1]);
+                    voteAndCheck(history, history.unlNodeIDs.back(), 1, [&](NegativeUNLVote& vote) {
+                        history.unlKeySet.erase(history.unlKeys[0]);
+                        history.unlKeySet.erase(history.unlKeys[1]);
                     }));
             }
         }
@@ -1745,11 +1745,11 @@ class NegativeUNLVoteNewValidator_test : public beast::unit_test::Suite
                         return true;
                     });
                 BEAST_EXPECT(
-                    voteAndCheck(history, history.UNLNodeIDs[0], 0, [&](NegativeUNLVote& vote) {
+                    voteAndCheck(history, history.unlNodeIDs[0], 0, [&](NegativeUNLVote& vote) {
                         auto extraKey1 = randomKeyPair(KeyType::Ed25519).first;
                         auto extraKey2 = randomKeyPair(KeyType::Ed25519).first;
-                        history.UNLKeySet.insert(extraKey1);
-                        history.UNLKeySet.insert(extraKey2);
+                        history.unlKeySet.insert(extraKey1);
+                        history.unlKeySet.insert(extraKey2);
                         hash_set nowTrusted;
                         nowTrusted.insert(calcNodeID(extraKey1));
                         nowTrusted.insert(calcNodeID(extraKey2));
@@ -1776,11 +1776,11 @@ class NegativeUNLVoteNewValidator_test : public beast::unit_test::Suite
                         return true;
                     });
                 BEAST_EXPECT(
-                    voteAndCheck(history, history.UNLNodeIDs[0], 1, [&](NegativeUNLVote& vote) {
+                    voteAndCheck(history, history.unlNodeIDs[0], 1, [&](NegativeUNLVote& vote) {
                         auto extraKey1 = randomKeyPair(KeyType::Ed25519).first;
                         auto extraKey2 = randomKeyPair(KeyType::Ed25519).first;
-                        history.UNLKeySet.insert(extraKey1);
-                        history.UNLKeySet.insert(extraKey2);
+                        history.unlKeySet.insert(extraKey1);
+                        history.unlKeySet.insert(extraKey2);
                         hash_set nowTrusted;
                         nowTrusted.insert(calcNodeID(extraKey1));
                         nowTrusted.insert(calcNodeID(extraKey2));
@@ -1807,7 +1807,7 @@ class NegativeUNLVoteFilterValidations_test : public beast::unit_test::Suite
         auto l = std::make_shared(
             kCreateGenesis,
             Rules{env.app().config().features},
-            env.app().config().FEES.toFees(),
+            env.app().config().fees.toFees(),
             std::vector{},
             env.app().getNodeFamily());
 
diff --git a/src/test/consensus/RCLCensorshipDetector_test.cpp b/src/test/consensus/RCLCensorshipDetector_test.cpp
index 32117e0089..722a34f937 100644
--- a/src/test/consensus/RCLCensorshipDetector_test.cpp
+++ b/src/test/consensus/RCLCensorshipDetector_test.cpp
@@ -31,12 +31,12 @@ class RCLCensorshipDetector_test : public beast::unit_test::Suite
         cdet.check(std::move(accepted), [&remove, &remain](auto id, auto seq) {
             // If the item is supposed to be removed from the censorship
             // detector internal tracker manually, do it now:
-            if (std::find(remove.begin(), remove.end(), id) != remove.end())
+            if (std::ranges::find(remove, id) != remove.end())
                 return true;
 
             // If the item is supposed to still remain in the censorship
             // detector internal tracker; remove it from the vector.
-            auto it = std::find(remain.begin(), remain.end(), id);
+            auto it = std::ranges::find(remain, id);
             if (it != remain.end())
                 remain.erase(it);
             return false;
diff --git a/src/test/consensus/ScaleFreeSim_test.cpp b/src/test/consensus/ScaleFreeSim_test.cpp
index 7e75aea72a..e533e09eb0 100644
--- a/src/test/consensus/ScaleFreeSim_test.cpp
+++ b/src/test/consensus/ScaleFreeSim_test.cpp
@@ -1,4 +1,3 @@
-#include 
 #include 
 #include 
 #include 
diff --git a/src/test/consensus/Validations_test.cpp b/src/test/consensus/Validations_test.cpp
index fb453b0791..606c6f2824 100644
--- a/src/test/consensus/Validations_test.cpp
+++ b/src/test/consensus/Validations_test.cpp
@@ -324,15 +324,15 @@ class Validations_test : public beast::unit_test::Suite
 
             BEAST_EXPECT(
                 ValStatus::Stale ==
-                harness.add(n.validate(ledgerA, -harness.parms().validationCURRENT_EARLY, 0s)));
+                harness.add(n.validate(ledgerA, -harness.parms().validationCurrentEarly, 0s)));
 
             BEAST_EXPECT(
                 ValStatus::Stale ==
-                harness.add(n.validate(ledgerA, harness.parms().validationCURRENT_WALL, 0s)));
+                harness.add(n.validate(ledgerA, harness.parms().validationCurrentWall, 0s)));
 
             BEAST_EXPECT(
                 ValStatus::Stale ==
-                harness.add(n.validate(ledgerA, 0s, harness.parms().validationCURRENT_LOCAL)));
+                harness.add(n.validate(ledgerA, 0s, harness.parms().validationCurrentLocal)));
         }
 
         {
@@ -357,7 +357,7 @@ class Validations_test : public beast::unit_test::Suite
                 // If we advance far enough for AB to expire, we can fully
                 // validate or partially validate that sequence number again
                 BEAST_EXPECT(ValStatus::Conflicting == process(ledgerAZ));
-                harness.clock().advance(harness.parms().validationSET_EXPIRES + 1ms);
+                harness.clock().advance(harness.parms().validationSetExpires + 1ms);
                 BEAST_EXPECT(ValStatus::Current == process(ledgerAZ));
             }
         }
@@ -392,7 +392,7 @@ class Validations_test : public beast::unit_test::Suite
             BEAST_EXPECT(
                 harness.vals().getPreferred(genesisLedger_) ==
                 std::make_pair(ledgerAB.seq(), ledgerAB.id()));
-            harness.clock().advance(harness.parms().validationCURRENT_LOCAL);
+            harness.clock().advance(harness.parms().validationCurrentLocal);
 
             // trigger check for stale
             trigger(harness.vals());
@@ -487,7 +487,7 @@ class Validations_test : public beast::unit_test::Suite
         BEAST_EXPECT(harness.vals().currentTrusted()[0].seq() == ledgerAC.seq());
 
         // Pass enough time for it to go stale
-        harness.clock().advance(harness.parms().validationCURRENT_LOCAL);
+        harness.clock().advance(harness.parms().validationCurrentLocal);
         BEAST_EXPECT(harness.vals().currentTrusted().empty());
     }
 
@@ -528,7 +528,7 @@ class Validations_test : public beast::unit_test::Suite
         }
 
         // Pass enough time for them to go stale
-        harness.clock().advance(harness.parms().validationCURRENT_LOCAL);
+        harness.clock().advance(harness.parms().validationCurrentLocal);
         BEAST_EXPECT(harness.vals().getCurrentNodeIDs().empty());
     }
 
@@ -657,7 +657,7 @@ class Validations_test : public beast::unit_test::Suite
         BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerA.id()) == 1);
         harness.vals().expire(j);
         BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerA.id()) == 1);
-        harness.clock().advance(harness.parms().validationSET_EXPIRES);
+        harness.clock().advance(harness.parms().validationSetExpires);
         harness.vals().expire(j);
         BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerA.id()) == 0);
 
@@ -666,14 +666,14 @@ class Validations_test : public beast::unit_test::Suite
         BEAST_EXPECT(ValStatus::Current == harness.add(a.validate(ledgerB)));
         BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerB.id()) == 1);
         harness.vals().setSeqToKeep(ledgerB.seq(), ledgerB.seq() + kOne);
-        harness.clock().advance(harness.parms().validationSET_EXPIRES);
+        harness.clock().advance(harness.parms().validationSetExpires);
         harness.vals().expire(j);
         BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerB.id()) == 1);
         // change toKeep
         harness.vals().setSeqToKeep(ledgerB.seq() + kOne, ledgerB.seq() + kTwo);
         // advance clock slowly
         int const loops =
-            harness.parms().validationSET_EXPIRES / harness.parms().validationFRESHNESS + 1;
+            harness.parms().validationSetExpires / harness.parms().validationFRESHNESS + 1;
         for (int i = 0; i < loops; ++i)
         {
             harness.clock().advance(harness.parms().validationFRESHNESS);
@@ -686,7 +686,7 @@ class Validations_test : public beast::unit_test::Suite
         BEAST_EXPECT(ValStatus::Current == harness.add(a.validate(ledgerC)));
         BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerC.id()) == 1);
         harness.vals().setSeqToKeep(ledgerC.seq() - kOne, ledgerC.seq());
-        harness.clock().advance(harness.parms().validationSET_EXPIRES);
+        harness.clock().advance(harness.parms().validationSetExpires);
         harness.vals().expire(j);
         BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerC.id()) == 0);
     }
@@ -934,7 +934,7 @@ class Validations_test : public beast::unit_test::Suite
         BEAST_EXPECT(enforcer(clock.now(), Seq{10}, p));
         BEAST_EXPECT(!enforcer(clock.now(), Seq{5}, p));
         BEAST_EXPECT(!enforcer(clock.now(), Seq{9}, p));
-        clock.advance(p.validationSET_EXPIRES - 1ms);
+        clock.advance(p.validationSetExpires - 1ms);
         BEAST_EXPECT(!enforcer(clock.now(), Seq{1}, p));
         clock.advance(2ms);
         BEAST_EXPECT(enforcer(clock.now(), Seq{1}, p));
diff --git a/src/test/core/Config_test.cpp b/src/test/core/Config_test.cpp
index 3e6495bc2f..fdac1e450d 100644
--- a/src/test/core/Config_test.cpp
+++ b/src/test/core/Config_test.cpp
@@ -2,11 +2,11 @@
 #include 
 
 #include 
-#include 
 
-#include 
 #include 
 #include 
+#include 
+#include 
 #include   // IWYU pragma: keep
 #include 
 
@@ -295,9 +295,9 @@ port_wss_admin
 
         c.loadFromString(toLoad);
 
-        BEAST_EXPECT(c.legacy("ssl_verify") == "0");
+        BEAST_EXPECT(c.legacy(Sections::kSslVerify) == "0");
         expectException(
-            [&c] { [[maybe_unused]] auto _ = c.legacy("server"); });  // not a single line
+            [&c] { [[maybe_unused]] auto _ = c.legacy(Sections::kServer); });  // not a single line
 
         // set a legacy value
         BEAST_EXPECT(c.legacy("not_in_file").empty());
@@ -329,9 +329,9 @@ port_wss_admin
             // Load the config file from the current directory and verify it.
             Config c;
             c.setup("", true, false, true);
-            BEAST_EXPECT(c.section(SECTION_DEBUG_LOGFILE).values().size() == 1);
+            BEAST_EXPECT(c.section(Sections::kDebugLogfile).values().size() == 1);
             BEAST_EXPECT(
-                c.section(SECTION_DEBUG_LOGFILE).values()[0] ==
+                c.section(Sections::kDebugLogfile).values()[0] ==
                 "/Users/dummy/xrpld/config/log/debug.log");
         }
 
@@ -368,9 +368,9 @@ port_wss_admin
                 // Load the config file from the config directory and verify it.
                 Config c;
                 c.setup("", true, false, true);
-                BEAST_EXPECT(c.section(SECTION_DEBUG_LOGFILE).values().size() == 1);
+                BEAST_EXPECT(c.section(Sections::kDebugLogfile).values().size() == 1);
                 BEAST_EXPECT(
-                    c.section(SECTION_DEBUG_LOGFILE).values()[0] ==
+                    c.section(Sections::kDebugLogfile).values()[0] ==
                     "/Users/dummy/xrpld/config/log/debug.log");
 
                 // Restore the environment variables.
@@ -404,9 +404,9 @@ port_wss_admin
                 // Load the config file from the config directory and verify it.
                 Config c;
                 c.setup("", true, false, true);
-                BEAST_EXPECT(c.section(SECTION_DEBUG_LOGFILE).values().size() == 1);
+                BEAST_EXPECT(c.section(Sections::kDebugLogfile).values().size() == 1);
                 BEAST_EXPECT(
-                    c.section(SECTION_DEBUG_LOGFILE).values()[0] ==
+                    c.section(Sections::kDebugLogfile).values()[0] ==
                     "/Users/dummy/xrpld/config/log/debug.log");
 
                 // Restore the environment variables.
@@ -436,13 +436,13 @@ port_wss_admin
                 // Dummy test - do we get back what we put in
                 Config c;
                 c.loadFromString(boost::str(cc % dataDirAbs.string()));
-                BEAST_EXPECT(c.legacy("database_path") == dataDirAbs.string());
+                BEAST_EXPECT(c.legacy(Sections::kDatabasePath) == dataDirAbs.string());
             }
             {
                 // Rel paths should convert to abs paths
                 Config c;
                 c.loadFromString(boost::str(cc % dataDirRel.string()));
-                BEAST_EXPECT(c.legacy("database_path") == dataDirAbs.string());
+                BEAST_EXPECT(c.legacy(Sections::kDatabasePath) == dataDirAbs.string());
             }
             {
                 // No db section.
@@ -450,7 +450,7 @@ port_wss_admin
                 // load will not.
                 Config c;
                 c.loadFromString("");
-                BEAST_EXPECT(c.legacy("database_path").empty());
+                BEAST_EXPECT(c.legacy(Sections::kDatabasePath).empty());
             }
         }
         {
@@ -464,7 +464,7 @@ port_wss_admin
             auto const& c(g.config());
             BEAST_EXPECT(g.dataDirExists());
             BEAST_EXPECT(g.configFileExists());
-            BEAST_EXPECT(c.legacy("database_path") == dataDirAbs.string());
+            BEAST_EXPECT(c.legacy(Sections::kDatabasePath) == dataDirAbs.string());
         }
         {
             // read from file relative path
@@ -474,7 +474,7 @@ port_wss_admin
             std::string const nativeDbPath = absolute(path(dbPath)).string();
             BEAST_EXPECT(g.dataDirExists());
             BEAST_EXPECT(g.configFileExists());
-            BEAST_EXPECT(c.legacy("database_path") == nativeDbPath);
+            BEAST_EXPECT(c.legacy(Sections::kDatabasePath) == nativeDbPath);
         }
         {
             // read from file no path
@@ -484,7 +484,7 @@ port_wss_admin
                 absolute(g.subdir() / path(Config::kDatabaseDirName)).string();
             BEAST_EXPECT(g.dataDirExists());
             BEAST_EXPECT(g.configFileExists());
-            BEAST_EXPECT(c.legacy("database_path") == nativeDbPath);
+            BEAST_EXPECT(c.legacy(Sections::kDatabasePath) == nativeDbPath);
         }
     }
 
@@ -550,7 +550,7 @@ main
         }
 
         BEAST_EXPECT(error.empty());
-        BEAST_EXPECT(c.NETWORK_ID == 0);
+        BEAST_EXPECT(c.networkId == 0);
 
         try
         {
@@ -563,7 +563,7 @@ main
         }
 
         BEAST_EXPECT(error.empty());
-        BEAST_EXPECT(c.NETWORK_ID == 0);
+        BEAST_EXPECT(c.networkId == 0);
 
         try
         {
@@ -578,7 +578,7 @@ main
         }
 
         BEAST_EXPECT(error.empty());
-        BEAST_EXPECT(c.NETWORK_ID == 255);
+        BEAST_EXPECT(c.networkId == 255);
 
         try
         {
@@ -593,7 +593,7 @@ main
         }
 
         BEAST_EXPECT(error.empty());
-        BEAST_EXPECT(c.NETWORK_ID == 10000);
+        BEAST_EXPECT(c.networkId == 10000);
     }
 
     void
@@ -653,9 +653,9 @@ nHUhG1PgAG8H8myUENypM35JgfqXAKNQvRVVAFDRzJrny5eZN8d5
 nHBu9PTL9dn2GuZtdW4U2WzBwffyX9qsQCd9CNU4Z5YG3PQfViM8
 )xrpldConfig");
             c.loadFromString(toLoad);
-            BEAST_EXPECT(c.legacy("validators_file").empty());
-            BEAST_EXPECT(c.section(SECTION_VALIDATORS).values().size() == 5);
-            BEAST_EXPECT(c.VALIDATOR_LIST_THRESHOLD == std::nullopt);
+            BEAST_EXPECT(c.legacy(Sections::kValidatorsFile).empty());
+            BEAST_EXPECT(c.section(Sections::kValidators).values().size() == 5);
+            BEAST_EXPECT(c.validatorListThreshold == std::nullopt);
         }
         {
             // load validator list sites and keys from config
@@ -672,20 +672,20 @@ trust-these-validators.gov
 1
 )xrpldConfig");
             c.loadFromString(toLoad);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_SITES).values().size() == 2);
+            BEAST_EXPECT(c.section(Sections::kValidatorListSites).values().size() == 2);
             BEAST_EXPECT(
-                c.section(SECTION_VALIDATOR_LIST_SITES).values()[0] == "xrpl-validators.com");
+                c.section(Sections::kValidatorListSites).values()[0] == "xrpl-validators.com");
             BEAST_EXPECT(
-                c.section(SECTION_VALIDATOR_LIST_SITES).values()[1] ==
+                c.section(Sections::kValidatorListSites).values()[1] ==
                 "trust-these-validators.gov");
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_KEYS).values().size() == 1);
+            BEAST_EXPECT(c.section(Sections::kValidatorListKeys).values().size() == 1);
             BEAST_EXPECT(
-                c.section(SECTION_VALIDATOR_LIST_KEYS).values()[0] ==
+                c.section(Sections::kValidatorListKeys).values()[0] ==
                 "021A99A537FDEBC34E4FCA03B39BEADD04299BB19E85097EC92B15A3518801"
                 "E566");
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_THRESHOLD).values().size() == 1);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_THRESHOLD).values()[0] == "1");
-            BEAST_EXPECT(c.VALIDATOR_LIST_THRESHOLD == std::size_t(1));
+            BEAST_EXPECT(c.section(Sections::kValidatorListThreshold).values().size() == 1);
+            BEAST_EXPECT(c.section(Sections::kValidatorListThreshold).values()[0] == "1");
+            BEAST_EXPECT(c.validatorListThreshold == std::size_t(1));
         }
         {
             // load validator list sites and keys from config
@@ -702,20 +702,20 @@ trust-these-validators.gov
 0
 )xrpldConfig");
             c.loadFromString(toLoad);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_SITES).values().size() == 2);
+            BEAST_EXPECT(c.section(Sections::kValidatorListSites).values().size() == 2);
             BEAST_EXPECT(
-                c.section(SECTION_VALIDATOR_LIST_SITES).values()[0] == "xrpl-validators.com");
+                c.section(Sections::kValidatorListSites).values()[0] == "xrpl-validators.com");
             BEAST_EXPECT(
-                c.section(SECTION_VALIDATOR_LIST_SITES).values()[1] ==
+                c.section(Sections::kValidatorListSites).values()[1] ==
                 "trust-these-validators.gov");
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_KEYS).values().size() == 1);
+            BEAST_EXPECT(c.section(Sections::kValidatorListKeys).values().size() == 1);
             BEAST_EXPECT(
-                c.section(SECTION_VALIDATOR_LIST_KEYS).values()[0] ==
+                c.section(Sections::kValidatorListKeys).values()[0] ==
                 "021A99A537FDEBC34E4FCA03B39BEADD04299BB19E85097EC92B15A3518801"
                 "E566");
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_THRESHOLD).values().size() == 1);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_THRESHOLD).values()[0] == "0");
-            BEAST_EXPECT(c.VALIDATOR_LIST_THRESHOLD == std::nullopt);
+            BEAST_EXPECT(c.section(Sections::kValidatorListThreshold).values().size() == 1);
+            BEAST_EXPECT(c.section(Sections::kValidatorListThreshold).values()[0] == "0");
+            BEAST_EXPECT(c.validatorListThreshold == std::nullopt);
         }
         {
             // load should throw if [validator_list_threshold] is greater than
@@ -831,12 +831,12 @@ trust-these-validators.gov
             Config c;
             boost::format cc("[validators_file]\n%1%\n");
             c.loadFromString(boost::str(cc % vtg.validatorsFile()));
-            BEAST_EXPECT(c.legacy("validators_file") == vtg.validatorsFile());
-            BEAST_EXPECT(c.section(SECTION_VALIDATORS).values().size() == 8);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_SITES).values().size() == 2);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_KEYS).values().size() == 2);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_THRESHOLD).values().size() == 1);
-            BEAST_EXPECT(c.VALIDATOR_LIST_THRESHOLD == 2);
+            BEAST_EXPECT(c.legacy(Sections::kValidatorsFile) == vtg.validatorsFile());
+            BEAST_EXPECT(c.section(Sections::kValidators).values().size() == 8);
+            BEAST_EXPECT(c.section(Sections::kValidatorListSites).values().size() == 2);
+            BEAST_EXPECT(c.section(Sections::kValidatorListKeys).values().size() == 2);
+            BEAST_EXPECT(c.section(Sections::kValidatorListThreshold).values().size() == 1);
+            BEAST_EXPECT(c.validatorListThreshold == 2);
         }
         {
             // load from specified [validators_file] file name
@@ -848,12 +848,12 @@ trust-these-validators.gov
             BEAST_EXPECT(vtg.validatorsFileExists());
             BEAST_EXPECT(rcg.configFileExists());
             auto const& c(rcg.config());
-            BEAST_EXPECT(c.legacy("validators_file") == valFileName);
-            BEAST_EXPECT(c.section(SECTION_VALIDATORS).values().size() == 8);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_SITES).values().size() == 2);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_KEYS).values().size() == 2);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_THRESHOLD).values().size() == 1);
-            BEAST_EXPECT(c.VALIDATOR_LIST_THRESHOLD == 2);
+            BEAST_EXPECT(c.legacy(Sections::kValidatorsFile) == valFileName);
+            BEAST_EXPECT(c.section(Sections::kValidators).values().size() == 8);
+            BEAST_EXPECT(c.section(Sections::kValidatorListSites).values().size() == 2);
+            BEAST_EXPECT(c.section(Sections::kValidatorListKeys).values().size() == 2);
+            BEAST_EXPECT(c.section(Sections::kValidatorListThreshold).values().size() == 1);
+            BEAST_EXPECT(c.validatorListThreshold == 2);
         }
         {
             // load from specified [validators_file] relative path
@@ -865,12 +865,12 @@ trust-these-validators.gov
             BEAST_EXPECT(vtg.validatorsFileExists());
             BEAST_EXPECT(rcg.configFileExists());
             auto const& c(rcg.config());
-            BEAST_EXPECT(c.legacy("validators_file") == valFilePath);
-            BEAST_EXPECT(c.section(SECTION_VALIDATORS).values().size() == 8);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_SITES).values().size() == 2);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_KEYS).values().size() == 2);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_THRESHOLD).values().size() == 1);
-            BEAST_EXPECT(c.VALIDATOR_LIST_THRESHOLD == 2);
+            BEAST_EXPECT(c.legacy(Sections::kValidatorsFile) == valFilePath);
+            BEAST_EXPECT(c.section(Sections::kValidators).values().size() == 8);
+            BEAST_EXPECT(c.section(Sections::kValidatorListSites).values().size() == 2);
+            BEAST_EXPECT(c.section(Sections::kValidatorListKeys).values().size() == 2);
+            BEAST_EXPECT(c.section(Sections::kValidatorListThreshold).values().size() == 1);
+            BEAST_EXPECT(c.validatorListThreshold == 2);
         }
         {
             // load from validators file in default location
@@ -880,12 +880,12 @@ trust-these-validators.gov
             BEAST_EXPECT(vtg.validatorsFileExists());
             BEAST_EXPECT(rcg.configFileExists());
             auto const& c(rcg.config());
-            BEAST_EXPECT(c.legacy("validators_file").empty());
-            BEAST_EXPECT(c.section(SECTION_VALIDATORS).values().size() == 8);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_SITES).values().size() == 2);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_KEYS).values().size() == 2);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_THRESHOLD).values().size() == 1);
-            BEAST_EXPECT(c.VALIDATOR_LIST_THRESHOLD == 2);
+            BEAST_EXPECT(c.legacy(Sections::kValidatorsFile).empty());
+            BEAST_EXPECT(c.section(Sections::kValidators).values().size() == 8);
+            BEAST_EXPECT(c.section(Sections::kValidatorListSites).values().size() == 2);
+            BEAST_EXPECT(c.section(Sections::kValidatorListKeys).values().size() == 2);
+            BEAST_EXPECT(c.section(Sections::kValidatorListThreshold).values().size() == 1);
+            BEAST_EXPECT(c.validatorListThreshold == 2);
         }
         {
             // load from specified [validators_file] instead
@@ -899,12 +899,12 @@ trust-these-validators.gov
                 *this, vtg.subdir(), "", Config::kConfigFileName, vtg.validatorsFile(), false);
             BEAST_EXPECT(rcg.configFileExists());
             auto const& c(rcg.config());
-            BEAST_EXPECT(c.legacy("validators_file") == vtg.validatorsFile());
-            BEAST_EXPECT(c.section(SECTION_VALIDATORS).values().size() == 8);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_SITES).values().size() == 2);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_KEYS).values().size() == 2);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_THRESHOLD).values().size() == 1);
-            BEAST_EXPECT(c.VALIDATOR_LIST_THRESHOLD == 2);
+            BEAST_EXPECT(c.legacy(Sections::kValidatorsFile) == vtg.validatorsFile());
+            BEAST_EXPECT(c.section(Sections::kValidators).values().size() == 8);
+            BEAST_EXPECT(c.section(Sections::kValidatorListSites).values().size() == 2);
+            BEAST_EXPECT(c.section(Sections::kValidatorListKeys).values().size() == 2);
+            BEAST_EXPECT(c.section(Sections::kValidatorListThreshold).values().size() == 1);
+            BEAST_EXPECT(c.validatorListThreshold == 2);
         }
 
         {
@@ -935,12 +935,12 @@ trust-these-validators.gov
             BEAST_EXPECT(vtg.validatorsFileExists());
             Config c;
             c.loadFromString(boost::str(cc % vtg.validatorsFile()));
-            BEAST_EXPECT(c.legacy("validators_file") == vtg.validatorsFile());
-            BEAST_EXPECT(c.section(SECTION_VALIDATORS).values().size() == 15);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_SITES).values().size() == 4);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_KEYS).values().size() == 3);
-            BEAST_EXPECT(c.section(SECTION_VALIDATOR_LIST_THRESHOLD).values().size() == 1);
-            BEAST_EXPECT(c.VALIDATOR_LIST_THRESHOLD == 2);
+            BEAST_EXPECT(c.legacy(Sections::kValidatorsFile) == vtg.validatorsFile());
+            BEAST_EXPECT(c.section(Sections::kValidators).values().size() == 15);
+            BEAST_EXPECT(c.section(Sections::kValidatorListSites).values().size() == 4);
+            BEAST_EXPECT(c.section(Sections::kValidatorListKeys).values().size() == 3);
+            BEAST_EXPECT(c.section(Sections::kValidatorListThreshold).values().size() == 1);
+            BEAST_EXPECT(c.validatorListThreshold == 2);
         }
         {
             // load should throw if [validator_list_threshold] is present both
@@ -1017,8 +1017,8 @@ trust-these-validators.gov
             BEAST_EXPECT(!config.quiet());
             BEAST_EXPECT(!config.silent());
             BEAST_EXPECT(!config.standalone());
-            BEAST_EXPECT(config.LEDGER_HISTORY == 256);
-            BEAST_EXPECT(!config.legacy("database_path").empty());
+            BEAST_EXPECT(config.ledgerHistory == 256);
+            BEAST_EXPECT(!config.legacy(Sections::kDatabasePath).empty());
         }
         {
             Config config;
@@ -1030,8 +1030,8 @@ trust-these-validators.gov
             BEAST_EXPECT(config.quiet());
             BEAST_EXPECT(!config.silent());
             BEAST_EXPECT(!config.standalone());
-            BEAST_EXPECT(config.LEDGER_HISTORY == 256);
-            BEAST_EXPECT(!config.legacy("database_path").empty());
+            BEAST_EXPECT(config.ledgerHistory == 256);
+            BEAST_EXPECT(!config.legacy(Sections::kDatabasePath).empty());
         }
         {
             Config config;
@@ -1043,8 +1043,8 @@ trust-these-validators.gov
             BEAST_EXPECT(config.quiet());
             BEAST_EXPECT(config.silent());
             BEAST_EXPECT(!config.standalone());
-            BEAST_EXPECT(config.LEDGER_HISTORY == 256);
-            BEAST_EXPECT(!config.legacy("database_path").empty());
+            BEAST_EXPECT(config.ledgerHistory == 256);
+            BEAST_EXPECT(!config.legacy(Sections::kDatabasePath).empty());
         }
         {
             Config config;
@@ -1056,8 +1056,8 @@ trust-these-validators.gov
             BEAST_EXPECT(config.quiet());
             BEAST_EXPECT(config.silent());
             BEAST_EXPECT(!config.standalone());
-            BEAST_EXPECT(config.LEDGER_HISTORY == 256);
-            BEAST_EXPECT(!config.legacy("database_path").empty());
+            BEAST_EXPECT(config.ledgerHistory == 256);
+            BEAST_EXPECT(!config.legacy(Sections::kDatabasePath).empty());
         }
         {
             Config config;
@@ -1069,8 +1069,8 @@ trust-these-validators.gov
             BEAST_EXPECT(!config.quiet());
             BEAST_EXPECT(!config.silent());
             BEAST_EXPECT(config.standalone());
-            BEAST_EXPECT(config.LEDGER_HISTORY == 0);
-            BEAST_EXPECT(config.legacy("database_path").empty() == !explicitPath);
+            BEAST_EXPECT(config.ledgerHistory == 0);
+            BEAST_EXPECT(config.legacy(Sections::kDatabasePath).empty() == !explicitPath);
         }
         {
             Config config;
@@ -1082,8 +1082,8 @@ trust-these-validators.gov
             BEAST_EXPECT(config.quiet());
             BEAST_EXPECT(!config.silent());
             BEAST_EXPECT(config.standalone());
-            BEAST_EXPECT(config.LEDGER_HISTORY == 0);
-            BEAST_EXPECT(config.legacy("database_path").empty() == !explicitPath);
+            BEAST_EXPECT(config.ledgerHistory == 0);
+            BEAST_EXPECT(config.legacy(Sections::kDatabasePath).empty() == !explicitPath);
         }
         {
             Config config;
@@ -1095,8 +1095,8 @@ trust-these-validators.gov
             BEAST_EXPECT(config.quiet());
             BEAST_EXPECT(config.silent());
             BEAST_EXPECT(config.standalone());
-            BEAST_EXPECT(config.LEDGER_HISTORY == 0);
-            BEAST_EXPECT(config.legacy("database_path").empty() == !explicitPath);
+            BEAST_EXPECT(config.ledgerHistory == 0);
+            BEAST_EXPECT(config.legacy(Sections::kDatabasePath).empty() == !explicitPath);
         }
         {
             Config config;
@@ -1108,8 +1108,8 @@ trust-these-validators.gov
             BEAST_EXPECT(config.quiet());
             BEAST_EXPECT(config.silent());
             BEAST_EXPECT(config.standalone());
-            BEAST_EXPECT(config.LEDGER_HISTORY == 0);
-            BEAST_EXPECT(config.legacy("database_path").empty() == !explicitPath);
+            BEAST_EXPECT(config.ledgerHistory == 0);
+            BEAST_EXPECT(config.legacy(Sections::kDatabasePath).empty() == !explicitPath);
         }
     }
 
@@ -1118,18 +1118,18 @@ trust-these-validators.gov
     {
         detail::FileCfgGuard const cfg(*this, "testPort", "", Config::kConfigFileName, "");
         auto const& conf = cfg.config();
-        if (!BEAST_EXPECT(conf.exists("port_rpc")))
+        if (!BEAST_EXPECT(conf.exists(Sections::kPortRpc)))
             return;
-        if (!BEAST_EXPECT(conf.exists("port_wss_admin")))
+        if (!BEAST_EXPECT(conf.exists(Sections::kPortWssAdmin)))
             return;
         ParsedPort rpc;
-        if (!unexcept([&]() { parsePort(rpc, conf["port_rpc"], log); }))
+        if (!unexcept([&]() { parsePort(rpc, conf[Sections::kPortRpc], log); }))
             return;
-        BEAST_EXPECT(rpc.admin_nets_v4.size() + rpc.admin_nets_v6.size() == 2);
+        BEAST_EXPECT(rpc.adminNetsV4.size() + rpc.adminNetsV6.size() == 2);
         ParsedPort wss;
-        if (!unexcept([&]() { parsePort(wss, conf["port_wss_admin"], log); }))
+        if (!unexcept([&]() { parsePort(wss, conf[Sections::kPortWssAdmin], log); }))
             return;
-        BEAST_EXPECT(wss.admin_nets_v4.size() + wss.admin_nets_v6.size() == 1);
+        BEAST_EXPECT(wss.adminNetsV4.size() + wss.adminNetsV6.size() == 1);
     }
 
     void
@@ -1182,14 +1182,15 @@ r.ripple.com 51235
 )");
         cfg.loadFromString(toLoad);
         BEAST_EXPECT(
-            cfg.exists("port_rpc") && cfg.section("port_rpc").lines().empty() &&
-            cfg.section("port_rpc").values().empty());
+            cfg.exists(Sections::kPortRpc) && cfg.section(Sections::kPortRpc).lines().empty() &&
+            cfg.section(Sections::kPortRpc).values().empty());
         BEAST_EXPECT(
-            cfg.exists(SECTION_IPS) && cfg.section(SECTION_IPS).lines().size() == 1 &&
-            cfg.section(SECTION_IPS).values().size() == 1);
+            cfg.exists(Sections::kIps) && cfg.section(Sections::kIps).lines().size() == 1 &&
+            cfg.section(Sections::kIps).values().size() == 1);
         BEAST_EXPECT(
-            cfg.exists(SECTION_IPS_FIXED) && cfg.section(SECTION_IPS_FIXED).lines().size() == 2 &&
-            cfg.section(SECTION_IPS_FIXED).values().size() == 2);
+            cfg.exists(Sections::kIpsFixed) &&
+            cfg.section(Sections::kIpsFixed).lines().size() == 2 &&
+            cfg.section(Sections::kIpsFixed).values().size() == 2);
     }
 
     void
@@ -1237,33 +1238,34 @@ r.ripple.com:51235
 )");
         cfg.loadFromString(toLoad);
         BEAST_EXPECT(
-            cfg.exists("port_rpc") && cfg.section("port_rpc").lines().empty() &&
-            cfg.section("port_rpc").values().empty());
+            cfg.exists(Sections::kPortRpc) && cfg.section(Sections::kPortRpc).lines().empty() &&
+            cfg.section(Sections::kPortRpc).values().empty());
         BEAST_EXPECT(
-            cfg.exists(SECTION_IPS) && cfg.section(SECTION_IPS).lines().size() == 1 &&
-            cfg.section(SECTION_IPS).values().size() == 1);
+            cfg.exists(Sections::kIps) && cfg.section(Sections::kIps).lines().size() == 1 &&
+            cfg.section(Sections::kIps).values().size() == 1);
         BEAST_EXPECT(
-            cfg.exists(SECTION_IPS_FIXED) && cfg.section(SECTION_IPS_FIXED).lines().size() == 15 &&
-            cfg.section(SECTION_IPS_FIXED).values().size() == 15);
-        BEAST_EXPECT(cfg.IPS[0] == "r.ripple.com 51235");
+            cfg.exists(Sections::kIpsFixed) &&
+            cfg.section(Sections::kIpsFixed).lines().size() == 15 &&
+            cfg.section(Sections::kIpsFixed).values().size() == 15);
+        BEAST_EXPECT(cfg.ips[0] == "r.ripple.com 51235");
 
-        BEAST_EXPECT(cfg.IPS_FIXED[0] == "s1.ripple.com 51235");
-        BEAST_EXPECT(cfg.IPS_FIXED[1] == "s2.ripple.com 51235");
-        BEAST_EXPECT(cfg.IPS_FIXED[2] == "anotherserversansport");
-        BEAST_EXPECT(cfg.IPS_FIXED[3] == "anotherserverwithport 12");
-        BEAST_EXPECT(cfg.IPS_FIXED[4] == "1.1.1.1 1");
-        BEAST_EXPECT(cfg.IPS_FIXED[5] == "1.1.1.1 1");
-        BEAST_EXPECT(cfg.IPS_FIXED[6] == "12.34.12.123 12345");
-        BEAST_EXPECT(cfg.IPS_FIXED[7] == "12.34.12.123 12345");
+        BEAST_EXPECT(cfg.ipsFixed[0] == "s1.ripple.com 51235");
+        BEAST_EXPECT(cfg.ipsFixed[1] == "s2.ripple.com 51235");
+        BEAST_EXPECT(cfg.ipsFixed[2] == "anotherserversansport");
+        BEAST_EXPECT(cfg.ipsFixed[3] == "anotherserverwithport 12");
+        BEAST_EXPECT(cfg.ipsFixed[4] == "1.1.1.1 1");
+        BEAST_EXPECT(cfg.ipsFixed[5] == "1.1.1.1 1");
+        BEAST_EXPECT(cfg.ipsFixed[6] == "12.34.12.123 12345");
+        BEAST_EXPECT(cfg.ipsFixed[7] == "12.34.12.123 12345");
 
         // all ipv6 should be ignored by colon replacer, howsoever formatted
-        BEAST_EXPECT(cfg.IPS_FIXED[8] == "::");
-        BEAST_EXPECT(cfg.IPS_FIXED[9] == "2001:db8::");
-        BEAST_EXPECT(cfg.IPS_FIXED[10] == "::1");
-        BEAST_EXPECT(cfg.IPS_FIXED[11] == "::1:12345");
-        BEAST_EXPECT(cfg.IPS_FIXED[12] == "[::1]:12345");
-        BEAST_EXPECT(cfg.IPS_FIXED[13] == "2001:db8:3333:4444:5555:6666:7777:8888:12345");
-        BEAST_EXPECT(cfg.IPS_FIXED[14] == "[2001:db8:3333:4444:5555:6666:7777:8888]:1");
+        BEAST_EXPECT(cfg.ipsFixed[8] == "::");
+        BEAST_EXPECT(cfg.ipsFixed[9] == "2001:db8::");
+        BEAST_EXPECT(cfg.ipsFixed[10] == "::1");
+        BEAST_EXPECT(cfg.ipsFixed[11] == "::1:12345");
+        BEAST_EXPECT(cfg.ipsFixed[12] == "[::1]:12345");
+        BEAST_EXPECT(cfg.ipsFixed[13] == "2001:db8:3333:4444:5555:6666:7777:8888:12345");
+        BEAST_EXPECT(cfg.ipsFixed[14] == "[2001:db8:3333:4444:5555:6666:7777:8888]:1");
     }
 
     void
@@ -1274,51 +1276,51 @@ r.ripple.com:51235
             std::string_view line;
             std::string_view field;
             std::string_view expect;
-            bool had_comment;
+            bool hadComment;
         };
 
         std::array const tests = {
             {{.line = "password = aaaa\\#bbbb",
               .field = "password",
               .expect = "aaaa#bbbb",
-              .had_comment = false},
+              .hadComment = false},
              {.line = "password = aaaa#bbbb",
               .field = "password",
               .expect = "aaaa",
-              .had_comment = true},
+              .hadComment = true},
              {.line = "password = aaaa #bbbb",
               .field = "password",
               .expect = "aaaa",
-              .had_comment = true},
+              .hadComment = true},
              // since the value is all comment, this doesn't parse as k=v :
              {.line = "password = #aaaa #bbbb",
               .field = "",
               .expect = "password =",
-              .had_comment = true},
+              .hadComment = true},
              {.line = "password = aaaa\\# #bbbb",
               .field = "password",
               .expect = "aaaa#",
-              .had_comment = true},
+              .hadComment = true},
              {.line = "password = aaaa\\##bbbb",
               .field = "password",
               .expect = "aaaa#",
-              .had_comment = true},
-             {.line = "aaaa#bbbb", .field = "", .expect = "aaaa", .had_comment = true},
-             {.line = "aaaa\\#bbbb", .field = "", .expect = "aaaa#bbbb", .had_comment = false},
-             {.line = "aaaa\\##bbbb", .field = "", .expect = "aaaa#", .had_comment = true},
-             {.line = "aaaa #bbbb", .field = "", .expect = "aaaa", .had_comment = true},
-             {.line = "1 #comment", .field = "", .expect = "1", .had_comment = true},
-             {.line = "#whole thing is comment", .field = "", .expect = "", .had_comment = false},
+              .hadComment = true},
+             {.line = "aaaa#bbbb", .field = "", .expect = "aaaa", .hadComment = true},
+             {.line = "aaaa\\#bbbb", .field = "", .expect = "aaaa#bbbb", .hadComment = false},
+             {.line = "aaaa\\##bbbb", .field = "", .expect = "aaaa#", .hadComment = true},
+             {.line = "aaaa #bbbb", .field = "", .expect = "aaaa", .hadComment = true},
+             {.line = "1 #comment", .field = "", .expect = "1", .hadComment = true},
+             {.line = "#whole thing is comment", .field = "", .expect = "", .hadComment = false},
              {.line = "  #whole comment with space",
               .field = "",
               .expect = "",
-              .had_comment = false}}};
+              .hadComment = false}}};
 
         for (auto const& t : tests)
         {
             Section s;
             s.append(std::string(t.line));
-            BEAST_EXPECT(s.hadTrailingComments() == t.had_comment);
+            BEAST_EXPECT(s.hadTrailingComments() == t.hadComment);
             if (t.field.empty())
             {
                 BEAST_EXPECTS(s.legacy() == t.expect, s.legacy());
@@ -1335,18 +1337,18 @@ r.ripple.com:51235
             Section s;
             s.append("online_delete = 3000");
             std::uint32_t od = 0;
-            BEAST_EXPECT(set(od, "online_delete", s));
+            BEAST_EXPECT(set(od, Keys::kOnlineDelete, s));
             // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            BEAST_EXPECTS(od == 3000, *(s.get("online_delete")));
+            BEAST_EXPECTS(od == 3000, *(s.get(Keys::kOnlineDelete)));
         }
 
         {
             Section s;
             s.append("online_delete = 2000 #my comment on this");
             std::uint32_t od = 0;
-            BEAST_EXPECT(set(od, "online_delete", s));
+            BEAST_EXPECT(set(od, Keys::kOnlineDelete, s));
             // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            BEAST_EXPECTS(od == 2000, *(s.get("online_delete")));
+            BEAST_EXPECTS(od == 2000, *(s.get(Keys::kOnlineDelete)));
         }
     }
 
@@ -1457,14 +1459,14 @@ r.ripple.com:51235
         };
 
         std::vector const units = {
-            {"seconds", 1, 15 * 60, false},
-            {"minutes", 60, 14, false},
-            {"minutes", 60, 15, true},
-            {"hours", 3600, 10, true},
-            {"days", 86400, 10, true},
-            {"weeks", 604800, 2, true},
-            {"months", 2592000, 1, false},
-            {"years", 31536000, 1, false}};
+            {.unit = "seconds", .numSeconds = 1, .configVal = 15 * 60, .shouldPass = false},
+            {.unit = "minutes", .numSeconds = 60, .configVal = 14, .shouldPass = false},
+            {.unit = "minutes", .numSeconds = 60, .configVal = 15, .shouldPass = true},
+            {.unit = "hours", .numSeconds = 3600, .configVal = 10, .shouldPass = true},
+            {.unit = "days", .numSeconds = 86400, .configVal = 10, .shouldPass = true},
+            {.unit = "weeks", .numSeconds = 604800, .configVal = 2, .shouldPass = true},
+            {.unit = "months", .numSeconds = 2592000, .configVal = 1, .shouldPass = false},
+            {.unit = "years", .numSeconds = 31536000, .configVal = 1, .shouldPass = false}};
 
         std::string space;
         for (auto& [unit, sec, val, shouldPass] : units)
@@ -1481,7 +1483,7 @@ r.ripple.com:51235
                 c.loadFromString(toLoad);
                 if (shouldPass)
                 {
-                    BEAST_EXPECT(c.AMENDMENT_MAJORITY_TIME.count() == val * sec);
+                    BEAST_EXPECT(c.amendmentMajorityTime.count() == val * sec);
                 }
                 else
                 {
@@ -1512,7 +1514,7 @@ r.ripple.com:51235
             {
                 Config c;
                 c.loadFromString("[overlay]\nmax_unknown_time=" + value);
-                return c.MAX_UNKNOWN_TIME;
+                return c.maxUnknownTime;
             }
             catch (std::runtime_error const&)
             {
@@ -1546,7 +1548,7 @@ r.ripple.com:51235
             {
                 Config c;
                 c.loadFromString("[overlay]\nmax_diverged_time=" + value);
-                return c.MAX_DIVERGED_TIME;
+                return c.maxDivergedTime;
             }
             catch (std::runtime_error const&)
             {
diff --git a/src/test/core/Coroutine_test.cpp b/src/test/core/Coroutine_test.cpp
index 103191ec59..bf1d98c779 100644
--- a/src/test/core/Coroutine_test.cpp
+++ b/src/test/core/Coroutine_test.cpp
@@ -58,7 +58,7 @@ public:
         testcase("correct order");
 
         Env env(*this, envconfig([](std::unique_ptr cfg) {
-            cfg->FORCE_MULTI_THREAD = true;
+            cfg->forceMultiThread = true;
             return cfg;
         }));
 
@@ -85,7 +85,7 @@ public:
         testcase("incorrect order");
 
         Env env(*this, envconfig([](std::unique_ptr cfg) {
-            cfg->FORCE_MULTI_THREAD = true;
+            cfg->forceMultiThread = true;
             return cfg;
         }));
 
diff --git a/src/test/core/SociDB_test.cpp b/src/test/core/SociDB_test.cpp
index f4c6fb04f1..57ff19fec5 100644
--- a/src/test/core/SociDB_test.cpp
+++ b/src/test/core/SociDB_test.cpp
@@ -1,8 +1,9 @@
 #include 
 
-#include 
 #include 
 #include 
+#include 
+#include 
 #include 
 
 #include 
@@ -32,10 +33,10 @@ private:
     static void
     setupSQLiteConfig(BasicConfig& config, boost::filesystem::path const& dbPath)
     {
-        config.overwrite("sqdb", "backend", "sqlite");
+        config.overwrite(Sections::kSqdb, Keys::kBackend, "sqlite");
         auto value = dbPath.string();
         if (!value.empty())
-            config.legacy("database_path", value);
+            config.legacy(Sections::kDatabasePath, value);
     }
 
     static void
diff --git a/src/test/csf.h b/src/test/csf.h
index 81af3491c4..d2ddbb460d 100644
--- a/src/test/csf.h
+++ b/src/test/csf.h
@@ -1,3 +1,5 @@
+#pragma once
+
 #include 
 #include 
 #include 
diff --git a/src/test/csf/BasicNetwork.h b/src/test/csf/BasicNetwork.h
index e1d519b30b..418cdcf289 100644
--- a/src/test/csf/BasicNetwork.h
+++ b/src/test/csf/BasicNetwork.h
@@ -64,9 +64,9 @@ class BasicNetwork
 
     using clock_type = Scheduler::clock_type;
 
-    using duration = typename clock_type::duration;
+    using duration = clock_type::duration;
 
-    using time_point = typename clock_type::time_point;
+    using time_point = clock_type::time_point;
 
     struct LinkType
     {
diff --git a/src/test/csf/Digraph.h b/src/test/csf/Digraph.h
index 6fbb8c3514..20e45faa5b 100644
--- a/src/test/csf/Digraph.h
+++ b/src/test/csf/Digraph.h
@@ -128,7 +128,7 @@ public:
     outVertices() const
     {
         return boost::adaptors::transform(
-            graph_, [](typename Graph::value_type const& v) { return v.first; });
+            graph_, [](Graph::value_type const& v) { return v.first; });
     }
 
     /** Range over target vertices
@@ -139,7 +139,7 @@ public:
     [[nodiscard]] auto
     outVertices(Vertex source) const
     {
-        auto transform = [](typename Links::value_type const& link) { return link.first; };
+        auto transform = [](Links::value_type const& link) { return link.first; };
         auto it = graph_.find(source);
         if (it != graph_.end())
             return boost::adaptors::transform(it->second, transform);
@@ -165,7 +165,7 @@ public:
     [[nodiscard]] auto
     outEdges(Vertex source) const
     {
-        auto transform = [source](typename Links::value_type const& link) {
+        auto transform = [source](Links::value_type const& link) {
             return Edge{source, link.first, link.second};
         };
 
diff --git a/src/test/csf/Scheduler.h b/src/test/csf/Scheduler.h
index c70b7d4854..e7cbe27036 100644
--- a/src/test/csf/Scheduler.h
+++ b/src/test/csf/Scheduler.h
@@ -27,9 +27,9 @@ class Scheduler
 public:
     using clock_type = beast::ManualClock;
 
-    using duration = typename clock_type::duration;
+    using duration = clock_type::duration;
 
-    using time_point = typename clock_type::time_point;
+    using time_point = clock_type::time_point;
 
 private:
     using by_when_hook =
@@ -87,14 +87,14 @@ private:
     class QueueType
     {
     private:
-        using by_when_set = typename boost::intrusive::
+        using by_when_set = boost::intrusive::
             make_multiset>::type;
         // alloc_ is owned by the scheduler
         boost::container::pmr::monotonic_buffer_resource* alloc_;
-        by_when_set by_when_;
+        by_when_set byWhen_;
 
     public:
-        using iterator = typename by_when_set::iterator;
+        using iterator = by_when_set::iterator;
 
         QueueType(QueueType const&) = delete;
         QueueType&
@@ -114,7 +114,7 @@ private:
         end();
 
         template 
-        typename by_when_set::iterator
+        by_when_set::iterator
         emplace(time_point when, Handler&& h);
 
         iterator
@@ -258,7 +258,7 @@ inline Scheduler::QueueType::QueueType(boost::container::pmr::monotonic_buffer_r
 
 inline Scheduler::QueueType::~QueueType()
 {
-    for (auto iter = by_when_.begin(); iter != by_when_.end();)
+    for (auto iter = byWhen_.begin(); iter != byWhen_.end();)
     {
         auto e = &*iter;
         ++iter;
@@ -270,36 +270,36 @@ inline Scheduler::QueueType::~QueueType()
 inline bool
 Scheduler::QueueType::empty() const
 {
-    return by_when_.empty();
+    return byWhen_.empty();
 }
 
 inline auto
 Scheduler::QueueType::begin() -> iterator
 {
-    return by_when_.begin();
+    return byWhen_.begin();
 }
 
 inline auto
 Scheduler::QueueType::end() -> iterator
 {
-    return by_when_.end();
+    return byWhen_.end();
 }
 
 template 
 inline auto
-Scheduler::QueueType::emplace(time_point when, Handler&& h) -> typename by_when_set::iterator
+Scheduler::QueueType::emplace(time_point when, Handler&& h) -> by_when_set::iterator
 {
     using event_type = EventImpl>;
     auto const p = alloc_->allocate(sizeof(event_type));
     auto& e = *new (p) event_type(when, std::forward(h));
-    return by_when_.insert(e);
+    return byWhen_.insert(e);
 }
 
 inline auto
-Scheduler::QueueType::erase(iterator iter) -> typename by_when_set::iterator
+Scheduler::QueueType::erase(iterator iter) -> by_when_set::iterator
 {
     auto& e = *iter;
-    auto next = by_when_.erase(iter);
+    auto next = byWhen_.erase(iter);
     e.~Event();
     alloc_->deallocate(&e, sizeof(e));
     return next;
@@ -309,7 +309,7 @@ Scheduler::QueueType::erase(iterator iter) -> typename by_when_set::iterator
 struct Scheduler::CancelToken
 {
 private:
-    typename QueueType::iterator iter_;
+    QueueType::iterator iter_;
 
 public:
     CancelToken() = delete;
@@ -319,7 +319,7 @@ public:
 
 private:
     friend class Scheduler;
-    CancelToken(typename QueueType::iterator iter) : iter_(iter)
+    CancelToken(QueueType::iterator iter) : iter_(iter)
     {
     }
 };
diff --git a/src/test/csf/Sim.h b/src/test/csf/Sim.h
index 89e6b95fe6..b5b7f5f9c2 100644
--- a/src/test/csf/Sim.h
+++ b/src/test/csf/Sim.h
@@ -65,6 +65,7 @@ public:
         and no network connections.
 
     */
+    // NOLINTNEXTLINE(bugprone-random-generator-seed): fixed seed for reproducible test
     Sim() : sink{scheduler.clock()}, j{sink}, net{scheduler}
     {
     }
diff --git a/src/test/csf/SimTime.h b/src/test/csf/SimTime.h
index e38a375e02..125674f15d 100644
--- a/src/test/csf/SimTime.h
+++ b/src/test/csf/SimTime.h
@@ -11,7 +11,7 @@ using RealDuration = RealClock::duration;
 using RealTime = RealClock::time_point;
 
 using SimClock = beast::ManualClock;
-using SimDuration = typename SimClock::duration;
-using SimTime = typename SimClock::time_point;
+using SimDuration = SimClock::duration;
+using SimTime = SimClock::time_point;
 
 }  // namespace xrpl::test::csf
diff --git a/src/test/csf/random.h b/src/test/csf/random.h
index 08002aed7f..c506397d88 100644
--- a/src/test/csf/random.h
+++ b/src/test/csf/random.h
@@ -44,7 +44,7 @@ std::vector
 sample(std::size_t size, RandomNumberDistribution dist, Generator& g)
 {
     std::vector res(size);
-    std::generate(res.begin(), res.end(), [&dist, &g]() { return dist(g); });
+    std::ranges::generate(res, [&dist, &g]() { return dist(g); });
     return res;
 }
 
@@ -72,14 +72,14 @@ public:
     Selector(RAIter first, RAIter last, std::vector const& w, Generator& g)
         : first_{first}, last_{last}, dd_{w.begin(), w.end()}, g_{g}
     {
-        using tag = typename std::iterator_traits::iterator_category;
+        using tag = std::iterator_traits::iterator_category;
         static_assert(
             std::is_same_v,
             "Selector only supports random access iterators.");
         // TODO: Allow for forward iterators
     }
 
-    typename std::iterator_traits::value_type
+    std::iterator_traits::value_type
     operator()()
     {
         auto idx = dd_(g_);
diff --git a/src/test/jtx/AMM.h b/src/test/jtx/AMM.h
index deadd80290..99131637bb 100644
--- a/src/test/jtx/AMM.h
+++ b/src/test/jtx/AMM.h
@@ -174,12 +174,22 @@ public:
     ammRpcInfo(
         std::optional const& account = std::nullopt,
         std::optional const& ledgerIndex = std::nullopt,
-        std::optional asset1 = std::nullopt,
-        std::optional asset2 = std::nullopt,
+        std::optional const& asset1 = std::nullopt,
+        std::optional const& asset2 = std::nullopt,
         std::optional const& ammAccount = std::nullopt,
         bool ignoreParams = false,
         unsigned apiVersion = RPC::kApiInvalidVersion) const;
 
+    [[nodiscard]] json::Value
+    ammRpcInfo(
+        std::optional const& account,
+        std::optional const& ledgerIndex,
+        std::optional const& asset1,
+        std::optional const& asset2,
+        std::optional const& ammAccount,
+        bool ignoreParams,
+        unsigned apiVersion) const;
+
     /** Verify the AMM balances.
      */
     [[nodiscard]] bool
diff --git a/src/test/jtx/CheckMessageLogs.h b/src/test/jtx/CheckMessageLogs.h
index 2cc8c661eb..4bbdec6f06 100644
--- a/src/test/jtx/CheckMessageLogs.h
+++ b/src/test/jtx/CheckMessageLogs.h
@@ -24,7 +24,7 @@ class CheckMessageLogs : public Logs
         void
         write(beast::Severity level, std::string const& text) override
         {
-            if (text.find(owner_.msg_) != std::string::npos)
+            if (text.contains(owner_.msg_))
                 *owner_.pFound_ = true;
         }
 
diff --git a/src/test/jtx/ConfidentialTransfer.h b/src/test/jtx/ConfidentialTransfer.h
new file mode 100644
index 0000000000..b758683da6
--- /dev/null
+++ b/src/test/jtx/ConfidentialTransfer.h
@@ -0,0 +1,496 @@
+#pragma once
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+class ConfidentialTransferTestBase : public beast::unit_test::Suite
+{
+protected:
+    template 
+    static T
+    requireOptional(std::optional value, char const* message)
+    {
+        if (!value)
+            Throw(message);
+        return std::move(*value);
+    }
+
+    template 
+    static T const&
+    requireOptionalRef(std::optional const& value, char const* message)
+    {
+        if (!value)
+            Throw(message);
+        return *value;
+    }
+
+    // Offset where the bulletproof begins in a send proof blob.
+    // Proof layout: [compact_sigma | bulletproof]
+    static constexpr size_t kBulletproofOffset = kEcSendProofLength - kEcDoubleBulletproofLength;
+
+    // Generate a forged aggregated bulletproof (double bulletproof) for
+    // the given values and blinding factors. Used to test that splicing
+    // a bulletproof claiming a different remaining balance is rejected.
+    // secp256k1 convention: returns 1 on success, 0 on failure.
+    static Buffer
+    getForgedBulletproof(
+        std::array const& values,
+        std::array const& blindingFactors,
+        uint256 const& contextHash)
+    {
+        auto* const ctx = mpt_secp256k1_context();
+
+        secp256k1_pubkey h;
+        secp256k1_mpt_get_h_generator(ctx, &h);
+
+        Buffer proof(kEcDoubleBulletproofLength);
+        size_t proofLen = kEcDoubleBulletproofLength;
+
+        unsigned char blindings[64];
+        std::memcpy(blindings, blindingFactors[0].data(), 32);
+        std::memcpy(blindings + 32, blindingFactors[1].data(), 32);
+
+        if (secp256k1_bulletproof_prove_agg(
+                ctx,
+                proof.data(),
+                &proofLen,
+                values.data(),
+                blindings,
+                2,
+                &h,
+                contextHash.data()) == 0)
+            Throw("Failed to generate forged bulletproof");
+
+        return proof;
+    }
+
+    // Get a bad ciphertext with valid structure but cryptographic invalid for
+    // testing purposes. For preflight test purposes.
+    static Buffer const&
+    getBadCiphertext()
+    {
+        static Buffer const kBadCiphertext = []() {
+            Buffer buf(kEcGamalEncryptedTotalLength);
+            std::memset(buf.data(), 0xFF, kEcGamalEncryptedTotalLength);
+
+            buf.data()[0] = kEcCompressedPrefixEvenY;
+            buf.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY;
+            return buf;
+        }();
+
+        return kBadCiphertext;
+    }
+
+    // Get a trivial buffer that is structurally and mathematically valid, but
+    // contains invalid data that does not match the ledger state. For preclaim
+    // test purposes.
+    static Buffer const&
+    getTrivialCiphertext()
+    {
+        static Buffer const kTrivialCiphertext = []() {
+            Buffer buf(kEcGamalEncryptedTotalLength);
+            std::memset(buf.data(), 0, kEcGamalEncryptedTotalLength);
+
+            buf.data()[0] = kEcCompressedPrefixEvenY;
+            buf.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY;
+
+            buf.data()[kEcCiphertextComponentLength - 1] = 0x01;
+            buf.data()[kEcGamalEncryptedTotalLength - 1] = 0x01;
+
+            return buf;
+        }();
+
+        return kTrivialCiphertext;
+    }
+
+    // Returns a valid compressed EC point (33 bytes) that can pass preflight
+    // validation but contains invalid data for preclaim test purposes.
+    static Buffer const&
+    getTrivialCommitment()
+    {
+        static Buffer const kTrivialCommitment = []() {
+            Buffer buf(kEcPedersenCommitmentLength);
+            std::memset(buf.data(), 0, kEcPedersenCommitmentLength);
+
+            buf.data()[0] = kEcCompressedPrefixEvenY;
+            // Set last byte to make it a valid x-coordinate on the curve
+            buf.data()[kEcPedersenCommitmentLength - 1] = 0x01;
+
+            return buf;
+        }();
+
+        return kTrivialCommitment;
+    }
+
+    static std::string
+    getTrivialSendProofHex()
+    {
+        Buffer buf(kEcSendProofLength);
+        std::memset(buf.data(), 0, kEcSendProofLength);
+
+        for (std::size_t i = 0; i < kEcSendProofLength; i += kEcCiphertextComponentLength)
+        {
+            buf.data()[i] = kEcCompressedPrefixEvenY;
+            if (i + kEcCiphertextComponentLength - 1 < kEcSendProofLength)
+                buf.data()[i + kEcCiphertextComponentLength - 1] = 0x01;
+        }
+
+        return strHex(buf);
+    }
+
+    // Helper struct to encapsulate common setup for integration tests.
+    struct ConfidentialSendSetup
+    {
+        // Constants
+        uint64_t sendAmount;
+        size_t nRecipients;
+        uint32_t version;
+
+        // Blinding factors
+        Buffer blindingFactor;
+        Buffer amountBlindingFactor;
+        Buffer balanceBlindingFactor;
+
+        // Encrypted amounts
+        Buffer senderAmt;
+        Buffer destAmt;
+        Buffer issuerAmt;
+        std::optional auditorAmt;
+
+        // Commitments
+        Buffer amountCommitment;
+
+        // Long-lived pub key buffers (to avoid dangling Slice)
+        Buffer senderPubKey;
+        Buffer destPubKey;
+        Buffer issuerPubKey;
+        std::optional auditorPubKey;
+
+        // Balance data
+        uint64_t prevSpending;
+        Buffer prevEncryptedSpending;
+
+        // Balance commitment (declared after prevSpending for init order)
+        Buffer balanceCommitment;
+
+        // Recipients vector
+        std::vector recipients;
+
+        // Constructor that performs all common setup
+        ConfidentialSendSetup(
+            test::jtx::MPTTester& mpt,
+            test::jtx::Account const& sender,
+            test::jtx::Account const& dest,
+            test::jtx::Account const& issuer,
+            uint64_t amount,
+            std::optional> auditor = std::nullopt)
+            : sendAmount(amount)
+            , nRecipients(auditor ? 4 : 3)
+            , version(mpt.getMPTokenVersion(sender))
+            , blindingFactor(generateBlindingFactor())
+            , amountBlindingFactor(blindingFactor)
+            , balanceBlindingFactor(generateBlindingFactor())
+            , senderAmt(mpt.encryptAmount(sender, amount, blindingFactor))
+            , destAmt(mpt.encryptAmount(dest, amount, blindingFactor))
+            , issuerAmt(mpt.encryptAmount(issuer, amount, blindingFactor))
+            , auditorAmt(
+                  auditor ? std::optional(
+                                mpt.encryptAmount(auditor->get(), amount, blindingFactor))
+                          : std::nullopt)
+            , amountCommitment(mpt.getPedersenCommitment(amount, amountBlindingFactor))
+            , senderPubKey(requireOptional(mpt.getPubKey(sender), "Missing sender public key"))
+            , destPubKey(requireOptional(mpt.getPubKey(dest), "Missing destination public key"))
+            , issuerPubKey(requireOptional(mpt.getPubKey(issuer), "Missing issuer public key"))
+            , auditorPubKey(auditor ? mpt.getPubKey(auditor->get()) : std::nullopt)
+            , prevSpending(requireOptional(
+                  mpt.getDecryptedBalance(sender, test::jtx::MPTTester::holderEncryptedSpending),
+                  "Missing sender spending balance"))
+            , prevEncryptedSpending(requireOptional(
+                  mpt.getEncryptedBalance(sender, test::jtx::MPTTester::holderEncryptedSpending),
+                  "Missing sender encrypted spending balance"))
+            , balanceCommitment(mpt.getPedersenCommitment(prevSpending, balanceBlindingFactor))
+        {
+            recipients.push_back({
+                .publicKey = Slice(senderPubKey),
+                .encryptedAmount = senderAmt,
+            });
+            recipients.push_back({
+                .publicKey = Slice(destPubKey),
+                .encryptedAmount = destAmt,
+            });
+            recipients.push_back({
+                .publicKey = Slice(issuerPubKey),
+                .encryptedAmount = issuerAmt,
+            });
+            if (auditor)
+            {
+                recipients.push_back({
+                    .publicKey =
+                        Slice(requireOptionalRef(auditorPubKey, "Missing auditor public key")),
+                    .encryptedAmount =
+                        requireOptionalRef(auditorAmt, "Missing auditor encrypted amount"),
+                });
+            }
+        }
+
+        // Generate proof with current account sequence
+        std::optional
+        generateProof(
+            test::jtx::MPTTester& mpt,
+            test::jtx::Env& env,
+            test::jtx::Account const& sender,
+            test::jtx::Account const& dest) const
+        {
+            auto const ctxHash = getSendContextHash(
+                sender.id(), mpt.issuanceID(), env.seq(sender), dest.id(), version);
+
+            return mpt.getConfidentialSendProof(
+                sender,
+                sendAmount,
+                recipients,
+                blindingFactor,
+                ctxHash,
+                {
+                    .pedersenCommitment = amountCommitment,
+                    .amt = sendAmount,
+                    .encryptedAmt = senderAmt,
+                    .blindingFactor = amountBlindingFactor,
+                },
+                {
+                    .pedersenCommitment = balanceCommitment,
+                    .amt = prevSpending,
+                    .encryptedAmt = prevEncryptedSpending,
+                    .blindingFactor = balanceBlindingFactor,
+                });
+        }
+
+        [[nodiscard]] test::jtx::MPTConfidentialSend
+        sendArgs(
+            test::jtx::Account const& sender,
+            test::jtx::Account const& dest,
+            Buffer const& proof,
+            std::optional err = std::nullopt) const
+        {
+            return {
+                .account = sender,
+                .dest = dest,
+                .amt = sendAmount,
+                .proof = strHex(proof),
+                .senderEncryptedAmt = senderAmt,
+                .destEncryptedAmt = destAmt,
+                .issuerEncryptedAmt = issuerAmt,
+                .auditorEncryptedAmt = auditorAmt,
+                .amountCommitment = amountCommitment,
+                .balanceCommitment = balanceCommitment,
+                .err = err,
+            };
+        }
+    };
+
+    // Helper that wraps the boilerplate setup: Env + MPT creation, funding, key
+    // generation, and seeding each holder with a confidential balance.
+    // The caller supplies the issuer and any number of holders.
+    struct ConfidentialEnv
+    {
+        // Per-holder configuration: the account, how much MPT to fund it
+        // with, and how much of that to convert to a confidential balance.
+        struct HolderInit
+        {
+            test::jtx::Account account;
+            std::uint64_t payAmount = 1000;
+            std::uint64_t convertAmount = 100;
+        };
+
+        test::jtx::MPTTester mpt;
+
+        ConfidentialEnv(
+            test::jtx::Env& env,
+            test::jtx::Account const& issuer,
+            std::vector const& holders,
+            std::uint32_t flags = tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
+            std::optional auditor = std::nullopt)
+            : mpt{env, issuer, {.holders = extractAccounts(holders), .auditor = auditor}}
+        {
+            mpt.create({.ownerCount = 1, .flags = flags});
+
+            for (auto const& h : holders)
+            {
+                mpt.authorize({.account = h.account});
+                if ((flags & tfMPTRequireAuth) != 0)
+                    mpt.authorize({.account = issuer, .holder = h.account});
+                mpt.pay(issuer, h.account, h.payAmount);
+            }
+
+            mpt.generateKeyPair(issuer);
+            for (auto const& h : holders)
+                mpt.generateKeyPair(h.account);
+            if (auditor)
+                mpt.generateKeyPair(requireOptionalRef(auditor, "Missing auditor"));
+
+            mpt.set({
+                .account = issuer,
+                .issuerPubKey = mpt.getPubKey(issuer),
+                .auditorPubKey = auditor
+                    ? mpt.getPubKey(requireOptionalRef(auditor, "Missing auditor"))
+                    : std::optional{},
+            });
+
+            for (auto const& h : holders)
+            {
+                mpt.convert({
+                    .account = h.account,
+                    .amt = h.convertAmount,
+                    .holderPubKey = mpt.getPubKey(h.account),
+                });
+                mpt.mergeInbox({.account = h.account});
+            }
+        }
+
+    private:
+        static std::vector
+        extractAccounts(std::vector const& holders)
+        {
+            std::vector accounts;
+            accounts.reserve(holders.size());
+            for (auto const& h : holders)
+                accounts.push_back(h.account);
+            return accounts;
+        }
+    };
+
+    // Set up an MPT environment suitable for batch testing.
+    // alice is issuer; bob has 'bobAmt' in confidential spending; carol has
+    // 'carolAmt' in confidential spending; dave is initialised with pubkey but
+    // zero spending/inbox.
+    static void
+    setupBatchEnv(
+        test::jtx::MPTTester& mpt,
+        test::jtx::Account const& alice,
+        test::jtx::Account const& bob,
+        test::jtx::Account const& carol,
+        test::jtx::Account const& dave,
+        std::uint64_t bobAmt,
+        std::uint64_t carolAmt)
+    {
+        using namespace test::jtx;
+        mpt.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
+        });
+        mpt.authorize({.account = bob});
+        mpt.authorize({.account = carol});
+        mpt.authorize({.account = dave});
+
+        if (bobAmt > 0)
+            mpt.pay(alice, bob, bobAmt);
+        if (carolAmt > 0)
+            mpt.pay(alice, carol, carolAmt);
+
+        mpt.generateKeyPair(alice);
+        mpt.generateKeyPair(bob);
+        mpt.generateKeyPair(carol);
+        mpt.generateKeyPair(dave);
+
+        mpt.set({
+            .account = alice,
+            .issuerPubKey = mpt.getPubKey(alice),
+        });
+
+        if (bobAmt > 0)
+        {
+            mpt.convert({
+                .account = bob,
+                .amt = bobAmt,
+                .holderPubKey = mpt.getPubKey(bob),
+            });
+            mpt.mergeInbox({.account = bob});
+        }
+        else
+        {
+            mpt.convert({
+                .account = bob,
+                .amt = 0,
+                .holderPubKey = mpt.getPubKey(bob),
+            });
+        }
+
+        if (carolAmt > 0)
+        {
+            mpt.convert({
+                .account = carol,
+                .amt = carolAmt,
+                .holderPubKey = mpt.getPubKey(carol),
+            });
+            mpt.mergeInbox({.account = carol});
+        }
+        else
+        {
+            mpt.convert({
+                .account = carol,
+                .amt = 0,
+                .holderPubKey = mpt.getPubKey(carol),
+            });
+        }
+
+        // dave: register pubkey only (0 spending/inbox)
+        mpt.convert({
+            .account = dave,
+            .amt = 0,
+            .holderPubKey = mpt.getPubKey(dave),
+        });
+    }
+};
+
+}  // namespace xrpl
diff --git a/src/test/jtx/Env.h b/src/test/jtx/Env.h
index 4618b36fde..aca6074c4a 100644
--- a/src/test/jtx/Env.h
+++ b/src/test/jtx/Env.h
@@ -96,6 +96,26 @@ testableAmendments()
     return kIds;
 }
 
+/**
+ * Returns all 2^N permutations of a seed FeatureBitset with each subset of
+ * the given features excluded.  The seed is included as the first element.
+ *
+ * Useful for running a test over every combination of optional amendments
+ * so that each case is exercised both with and without each feature.
+ */
+inline std::vector
+amendmentCombinations(std::initializer_list features, FeatureBitset seed)
+{
+    std::vector result{seed};
+    for (auto const& f : features)
+    {
+        auto const n = result.size();
+        for (std::size_t i = 0; i < n; ++i)
+            result.push_back(result[i] - f);
+    }
+    return result;
+}
+
 //------------------------------------------------------------------------------
 
 class SuiteLogs : public Logs
@@ -531,13 +551,13 @@ public:
     /** Return an account root.
         @return empty if the account does not exist.
     */
-    [[nodiscard]] std::shared_ptr
+    [[nodiscard]] SLE::const_pointer
     le(Account const& account) const;
 
     /** Return a ledger entry.
         @return empty if the ledger entry does not exist
     */
-    [[nodiscard]] std::shared_ptr
+    [[nodiscard]] SLE::const_pointer
     le(Keylet const& k) const;
 
     /** Create a JTx from parameters. */
diff --git a/src/test/jtx/Env_test.cpp b/src/test/jtx/Env_test.cpp
index 0c9e5ffe24..d82cb86b36 100644
--- a/src/test/jtx/Env_test.cpp
+++ b/src/test/jtx/Env_test.cpp
@@ -35,6 +35,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -863,7 +864,7 @@ public:
             jtx::Env const env{
                 *this,
                 jtx::envconfig([](std::unique_ptr cfg) {
-                    (*cfg).deprecatedClearSection("port_rpc");
+                    (*cfg).deprecatedClearSection(Sections::kPortRpc);
                     return cfg;
                 }),
                 nullptr,
diff --git a/src/test/jtx/PathSet.h b/src/test/jtx/PathSet.h
index cab31ea540..a391adcb1b 100644
--- a/src/test/jtx/PathSet.h
+++ b/src/test/jtx/PathSet.h
@@ -18,7 +18,7 @@ countOffers(
     Asset const& takerGets)
 {
     size_t count = 0;
-    forEachItem(*env.current(), account, [&](std::shared_ptr const& sle) {
+    forEachItem(*env.current(), account, [&](SLE::const_ref sle) {
         if (sle->getType() == ltOFFER && sle->getFieldAmount(sfTakerPays).asset() == takerPays &&
             sle->getFieldAmount(sfTakerGets).asset() == takerGets)
             ++count;
@@ -34,7 +34,7 @@ countOffers(
     STAmount const& takerGets)
 {
     size_t count = 0;
-    forEachItem(*env.current(), account, [&](std::shared_ptr const& sle) {
+    forEachItem(*env.current(), account, [&](SLE::const_ref sle) {
         if (sle->getType() == ltOFFER && sle->getFieldAmount(sfTakerPays) == takerPays &&
             sle->getFieldAmount(sfTakerGets) == takerGets)
             ++count;
diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h
index 011ac2e58d..0e35e6b9ec 100644
--- a/src/test/jtx/TestHelpers.h
+++ b/src/test/jtx/TestHelpers.h
@@ -27,6 +27,7 @@ namespace xrpl::test::jtx {
 */
 template <
     class SField,
+    // NOLINTNEXTLINE(readability-redundant-typename): typename required by MSVC
     class StoredValue = typename SField::type::value_type,
     class OutputValue = StoredValue>
 struct JTxField
@@ -213,8 +214,8 @@ template 
 struct JTxFieldWrapper
 {
     using JF = JTxField;
-    using SF = typename JF::SF;
-    using SV = typename JF::SV;
+    using SF = JF::SF;
+    using SV = JF::SV;
 
 protected:
     SF const& sfield_;
@@ -266,9 +267,11 @@ public:
     }
 };
 
+// NOLINTNEXTLINE(readability-redundant-typename): typename required by MSVC
 template 
 using valueUnitWrapper = JTxFieldWrapper>;
 
+// NOLINTNEXTLINE(readability-redundant-typename): typename required by MSVC
 template 
 using simpleField = JTxFieldWrapper>;
 
@@ -356,7 +359,7 @@ checkVL(Slice const& result, std::string const& expected)
 
 [[nodiscard]]
 inline bool
-checkVL(std::shared_ptr const& sle, SField const& field, std::string const& expected)
+checkVL(SLE::const_ref sle, SField const& field, std::string const& expected)
 {
     return strHex(expected) == strHex(sle->getFieldVL(field));
 }
diff --git a/src/test/jtx/TrustedPublisherServer.h b/src/test/jtx/TrustedPublisherServer.h
index 4c7528ccc3..9a53a32481 100644
--- a/src/test/jtx/TrustedPublisherServer.h
+++ b/src/test/jtx/TrustedPublisherServer.h
@@ -101,7 +101,7 @@ public:
         SecretKey const& ssk,
         int seq)
     {
-        STObject st(kSfGeneric);
+        STObject st(sfGeneric);
         st[sfSequence] = seq;
         st[sfPublicKey] = pk;
         st[sfSigningPubKey] = spk;
diff --git a/src/test/jtx/batch.h b/src/test/jtx/batch.h
index a80ce46b9c..0e78d409fa 100644
--- a/src/test/jtx/batch.h
+++ b/src/test/jtx/batch.h
@@ -14,18 +14,46 @@
 #include 
 #include 
 
-/** Batch operations */
+/** @brief Helpers for constructing Batch test transactions. */
 namespace xrpl::test::jtx::batch {
 
-/** Calculate Batch Fee. */
+/**
+ * @brief Calculate the expected outer Batch transaction fee.
+ *
+ * @param env The test environment providing ledger fee settings.
+ * @param numSigners Number of outer transaction signers.
+ * @param txns Number of inner transactions in the batch.
+ * @return The expected Batch fee.
+ */
 XRPAmount
 calcBatchFee(jtx::Env const& env, uint32_t const& numSigners, uint32_t const& txns = 0);
 
-/** Batch. */
+/**
+ * @brief Calculate the expected Batch fee when inner transactions are
+ * confidential MPT transactions.
+ *
+ * @param env The test environment providing ledger fee settings.
+ * @param numSigners Number of outer transaction signers.
+ * @param txns Number of confidential MPT inner transactions in the batch.
+ * @return The expected Batch fee including confidential transaction fee
+ *         multipliers.
+ */
+XRPAmount
+calcConfidentialBatchFee(jtx::Env const& env, uint32_t const& numSigners, uint32_t const& txns = 0);
+
+/**
+ * @brief Build an outer Batch transaction JSON object.
+ *
+ * @param account The account submitting the outer Batch transaction.
+ * @param seq The sequence number for the outer Batch transaction.
+ * @param fee The fee to set on the outer Batch transaction.
+ * @param flags The transaction flags to set.
+ * @return The outer Batch transaction JSON object.
+ */
 json::Value
 outer(jtx::Account const& account, uint32_t seq, STAmount const& fee, std::uint32_t flags);
 
-/** Adds a new Batch Txn on a JTx and autofills. */
+/** @brief Adds an inner Batch transaction to a JTx and autofills it. */
 class Inner
 {
 private:
@@ -75,7 +103,7 @@ public:
     }
 };
 
-/** Set a batch signature on a JTx. */
+/** @brief Sets the Batch transaction signers on a JTx. */
 class Sig
 {
 public:
@@ -98,7 +126,7 @@ public:
     operator()(Env&, JTx& jt) const;
 };
 
-/** Set a batch nested multi-signature on a JTx. */
+/** @brief Sets a nested multi-signature for a Batch transaction on a JTx. */
 class Msig
 {
 public:
diff --git a/src/test/jtx/credentials.h b/src/test/jtx/credentials.h
index c2719bf897..4bdd716918 100644
--- a/src/test/jtx/credentials.h
+++ b/src/test/jtx/credentials.h
@@ -40,7 +40,7 @@ private:
     std::vector const credentials_;
 
 public:
-    explicit Ids(std::vector const& creds) : credentials_(creds)
+    explicit Ids(std::vector creds) : credentials_(std::move(creds))
     {
     }
 
diff --git a/src/test/jtx/directory.h b/src/test/jtx/directory.h
index 9a87266802..c63640ae1f 100644
--- a/src/test/jtx/directory.h
+++ b/src/test/jtx/directory.h
@@ -2,11 +2,11 @@
 
 #include 
 
-#include 
 #include 
 #include 
 
 #include 
+#include 
 #include 
 
 /** Directory operations. */
@@ -34,7 +34,7 @@ bumpLastPage(
     Env& env,
     std::uint64_t newLastPage,
     Keylet directory,
-    std::function adjust) -> Expected;
+    std::function adjust) -> std::expected;
 
 /// Implementation of adjust for the most common ledger entry, i.e. one where
 /// page index is stored in sfOwnerNode (and only there). Pass this function
diff --git a/src/test/jtx/envconfig.h b/src/test/jtx/envconfig.h
index 9a4e1dc20a..4c56ec8217 100644
--- a/src/test/jtx/envconfig.h
+++ b/src/test/jtx/envconfig.h
@@ -4,11 +4,6 @@
 
 namespace xrpl::test {
 
-// frequently used macros defined here for convenience.
-#define PORT_WS "port_ws"
-#define PORT_RPC "port_rpc"
-#define PORT_PEER "port_peer"
-
 extern std::atomic gEnvUseIPv4;
 
 inline char const*
diff --git a/src/test/jtx/impl/AMM.cpp b/src/test/jtx/impl/AMM.cpp
index c6dc14081a..2184f7e1b0 100644
--- a/src/test/jtx/impl/AMM.cpp
+++ b/src/test/jtx/impl/AMM.cpp
@@ -183,15 +183,37 @@ json::Value
 AMM::ammRpcInfo(
     std::optional const& account,
     std::optional const& ledgerIndex,
-    std::optional asset1,
-    std::optional asset2,
+    std::optional const& asset1,
+    std::optional const& asset2,
     std::optional const& ammAccount,
     bool ignoreParams,
     unsigned apiVersion) const
+{
+    auto const toJson = [](AccountID const& a) { return json::Value{to_string(a)}; };
+
+    return ammRpcInfo(
+        account.transform(toJson),
+        ledgerIndex,
+        asset1,
+        asset2,
+        ammAccount.transform(toJson),
+        ignoreParams,
+        apiVersion);
+}
+
+json::Value
+AMM::ammRpcInfo(
+    std::optional const& account,
+    std::optional const& ledgerIndex,
+    std::optional const& asset1,
+    std::optional const& asset2,
+    std::optional const& ammAccount,
+    bool ignoreParams,
+    unsigned apiVersion) const
 {
     json::Value jv;
     if (account)
-        jv[jss::account] = to_string(*account);
+        jv[jss::account] = *account;
     if (ledgerIndex)
         jv[jss::ledger_index] = *ledgerIndex;
     if (!ignoreParams)
@@ -209,7 +231,7 @@ AMM::ammRpcInfo(
             jv[jss::asset2] = STIssue(sfAsset2, asset2_.asset()).getJson(JsonOptions::Values::None);
         }
         if (ammAccount)
-            jv[jss::amm_account] = to_string(*ammAccount);
+            jv[jss::amm_account] = *ammAccount;
     }
     auto jr =
         (apiVersion == RPC::kApiInvalidVersion
diff --git a/src/test/jtx/impl/AMMTest.cpp b/src/test/jtx/impl/AMMTest.cpp
index 5801e8fb44..46c5e44bd3 100644
--- a/src/test/jtx/impl/AMMTest.cpp
+++ b/src/test/jtx/impl/AMMTest.cpp
@@ -213,9 +213,9 @@ AMMTest::pathTestEnv()
     // different from the current defaults. This function creates an env
     // with the search parameters that the tests were written for.
     return Env(*this, envconfig([](std::unique_ptr cfg) {
-        cfg->PATH_SEARCH_OLD = 7;
-        cfg->PATH_SEARCH = 7;
-        cfg->PATH_SEARCH_MAX = 10;
+        cfg->pathSearchOld = 7;
+        cfg->pathSearch = 7;
+        cfg->pathSearchMax = 10;
         return cfg;
     }));
 }
diff --git a/src/test/jtx/impl/Env.cpp b/src/test/jtx/impl/Env.cpp
index 1cd4dd1029..db45deb6de 100644
--- a/src/test/jtx/impl/Env.cpp
+++ b/src/test/jtx/impl/Env.cpp
@@ -91,7 +91,7 @@ Env::AppBundle::AppBundle(
     timeKeeper = tk.get();
     // Hack so we don't have to call Config::setup
     HTTPClient::initializeSSLContext(
-        config->SSL_VERIFY_DIR, config->SSL_VERIFY_FILE, config->SSL_VERIFY, debugLog());
+        config->sslVerifyDir, config->sslVerifyFile, config->sslVerify, debugLog());
     owned = makeApplication(std::move(config), std::move(logs), std::move(tk));
     app = owned.get();
     app->getLogs().threshold(thresh);
@@ -213,7 +213,7 @@ Env::balance(Account const& account, Asset const& asset) const
         [&](Issue const& issue) -> PrettyAmount {
             if (isXRP(issue.currency))
                 return balance(account);
-            auto const sle = le(keylet::line(account.id(), issue));
+            auto const sle = le(keylet::trustLine(account.id(), issue));
             if (!sle)
                 return {STAmount(issue, 0), account.name()};
             auto amount = sle->getFieldAmount(sfBalance);
@@ -231,7 +231,7 @@ Env::balance(Account const& account, Asset const& asset) const
             if (account.id() == issuer)
             {
                 // Issuer balance
-                auto const sle = le(keylet::mptIssuance(id));
+                auto const sle = le(keylet::mptokenIssuance(id));
                 if (!sle)
                     return {STAmount(mptIssue, 0), account.name()};
 
@@ -253,7 +253,7 @@ Env::balance(Account const& account, Asset const& asset) const
 PrettyAmount
 Env::limit(Account const& account, Issue const& issue) const
 {
-    auto const sle = le(keylet::line(account.id(), issue));
+    auto const sle = le(keylet::trustLine(account.id(), issue));
     if (!sle)
         return {STAmount(issue, 0), account.name()};
     auto const aHigh = account.id() > issue.account;
@@ -280,13 +280,13 @@ Env::seq(Account const& account) const
     return sle->getFieldU32(sfSequence);
 }
 
-std::shared_ptr
+SLE::const_pointer
 Env::le(Account const& account) const
 {
     return le(keylet::account(account.id()));
 }
 
-std::shared_ptr
+SLE::const_pointer
 Env::le(Keylet const& k) const
 {
     return current()->read(k);
diff --git a/src/test/jtx/impl/JSONRPCClient.cpp b/src/test/jtx/impl/JSONRPCClient.cpp
index c8e6326ed2..495fc5a657 100644
--- a/src/test/jtx/impl/JSONRPCClient.cpp
+++ b/src/test/jtx/impl/JSONRPCClient.cpp
@@ -4,8 +4,9 @@
 
 #include 
 
-#include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -40,8 +41,8 @@ class JSONRPCClient : public AbstractClient
     {
         auto& log = std::cerr;
         ParsedPort common;
-        parsePort(common, cfg["server"], log);
-        for (auto const& name : cfg.section("server").values())
+        parsePort(common, cfg[Sections::kServer], log);
+        for (auto const& name : cfg.section(Sections::kServer).values())
         {
             if (!cfg.exists(name))
                 continue;
@@ -81,11 +82,11 @@ class JSONRPCClient : public AbstractClient
     boost::asio::ip::tcp::socket stream_;
     boost::beast::multi_buffer bin_;
     boost::beast::multi_buffer bout_;
-    unsigned rpc_version_;
+    unsigned rpcVersion_;
 
 public:
     explicit JSONRPCClient(Config const& cfg, unsigned rpcVersion)
-        : ep_(getEndpoint(cfg)), stream_(ios_), rpc_version_(rpcVersion)
+        : ep_(getEndpoint(cfg)), stream_(ios_), rpcVersion_(rpcVersion)
     {
         stream_.connect(ep_);
     }
@@ -116,7 +117,7 @@ public:
         {
             json::Value jr;
             jr[jss::method] = cmd;
-            if (rpc_version_ == 2)
+            if (rpcVersion_ == 2)
             {
                 jr[jss::jsonrpc] = "2.0";
                 jr[jss::ripplerpc] = "2.0";
@@ -148,7 +149,7 @@ public:
     [[nodiscard]] unsigned
     version() const override
     {
-        return rpc_version_;
+        return rpcVersion_;
     }
 };
 
diff --git a/src/test/jtx/impl/TestHelpers.cpp b/src/test/jtx/impl/TestHelpers.cpp
index 3e65410825..95c8a68436 100644
--- a/src/test/jtx/impl/TestHelpers.cpp
+++ b/src/test/jtx/impl/TestHelpers.cpp
@@ -172,9 +172,9 @@ pathTestEnv(beast::unit_test::Suite& suite)
     // with the search parameters that the tests were written for.
     using namespace jtx;
     return Env(suite, envconfig([](std::unique_ptr cfg) {
-                   cfg->PATH_SEARCH_OLD = 7;
-                   cfg->PATH_SEARCH = 7;
-                   cfg->PATH_SEARCH_MAX = 10;
+                   cfg->pathSearchOld = 7;
+                   cfg->pathSearch = 7;
+                   cfg->pathSearchMax = 10;
                    return cfg;
                }));
 }
@@ -262,7 +262,7 @@ findPaths(
 
     STAmount da;
     if (result.isMember(jss::destination_amount))
-        da = amountFromJson(kSfGeneric, result[jss::destination_amount]);
+        da = amountFromJson(sfGeneric, result[jss::destination_amount]);
 
     STAmount sa;
     STPathSet paths;
@@ -274,10 +274,10 @@ findPaths(
             auto const& path = alts[0u];
 
             if (path.isMember(jss::source_amount))
-                sa = amountFromJson(kSfGeneric, path[jss::source_amount]);
+                sa = amountFromJson(sfGeneric, path[jss::source_amount]);
 
             if (path.isMember(jss::destination_amount))
-                da = amountFromJson(kSfGeneric, path[jss::destination_amount]);
+                da = amountFromJson(sfGeneric, path[jss::destination_amount]);
 
             if (path.isMember(jss::paths_computed))
             {
@@ -338,7 +338,7 @@ xrpMinusFee(Env const& env, std::int64_t xrpAmount)
 [[nodiscard]] bool
 expectHolding(Env& env, AccountID const& account, STAmount const& value, bool defaultLimits)
 {
-    if (auto const sle = env.le(keylet::line(account, value.get())))
+    if (auto const sle = env.le(keylet::trustLine(account, value.get())))
     {
         Issue const issue = value.get();
         bool const accountLow = account < issue.account;
@@ -368,7 +368,7 @@ expectHolding(Env& env, AccountID const& account, STAmount const& value, bool de
 [[nodiscard]] bool
 expectHolding(Env& env, AccountID const& account, None const&, Issue const& issue)
 {
-    return !env.le(keylet::line(account, issue));
+    return !env.le(keylet::trustLine(account, issue));
 }
 
 [[nodiscard]] bool
@@ -388,7 +388,7 @@ expectHolding(Env& env, AccountID const& account, None const& value)
 [[nodiscard]] bool
 expectMPT(Env& env, AccountID const& account, STAmount const& value)
 {
-    auto const mptIssuanceID = keylet::mptIssuance(value.asset().get());
+    auto const mptIssuanceID = keylet::mptokenIssuance(value.asset().get());
     auto const mptToken = env.le(keylet::mptoken(mptIssuanceID.key, account));
     return mptToken && (*mptToken)[sfMPTAmount] == value.mpt().value();
 }
@@ -402,7 +402,7 @@ expectOffers(
 {
     std::uint16_t cnt = 0;
     std::uint16_t matched = 0;
-    forEachItem(*env.current(), account, [&](std::shared_ptr const& sle) {
+    forEachItem(*env.current(), account, [&](SLE::const_ref sle) {
         if (!sle)
             return false;
         if (sle->getType() == ltOFFER)
@@ -553,7 +553,7 @@ claim(
 uint256
 channel(AccountID const& account, AccountID const& dst, std::uint32_t seqProxyValue)
 {
-    auto const k = keylet::payChan(account, dst, seqProxyValue);
+    auto const k = keylet::payChannel(account, dst, seqProxyValue);
     return k.key;
 }
 
diff --git a/src/test/jtx/impl/WSClient.cpp b/src/test/jtx/impl/WSClient.cpp
index ba09647ce7..c00a88f270 100644
--- a/src/test/jtx/impl/WSClient.cpp
+++ b/src/test/jtx/impl/WSClient.cpp
@@ -2,8 +2,9 @@
 
 #include 
 
-#include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -62,15 +63,15 @@ class WSClientImpl : public WSClient
     {
         auto& log = std::cerr;
         ParsedPort common;
-        parsePort(common, cfg["server"], log);
+        parsePort(common, cfg[Sections::kServer], log);
         auto const ps = v2 ? "ws2" : "ws";
-        for (auto const& name : cfg.section("server").values())
+        for (auto const& name : cfg.section(Sections::kServer).values())
         {
             if (!cfg.exists(name))
                 continue;
             ParsedPort pp;
             parsePort(pp, cfg[name], log);
-            if (pp.protocol.count(ps) == 0)
+            if (!pp.protocol.contains(ps))
                 continue;
             using namespace boost::asio::ip;
             if (pp.ip && pp.ip->is_unspecified())
@@ -120,7 +121,7 @@ class WSClientImpl : public WSClient
     std::condition_variable cv_;
     std::list> msgs_;
 
-    unsigned rpc_version_;
+    unsigned rpcVersion_;
 
     void
     cleanup()
@@ -157,7 +158,7 @@ public:
         , thread_([&] { ios_.run(); })
         , stream_(ios_)
         , ws_(stream_)
-        , rpc_version_(rpcVersion)
+        , rpcVersion_(rpcVersion)
     {
         try
         {
@@ -197,7 +198,7 @@ public:
             json::Value jp;
             if (params)
                 jp = params;
-            if (rpc_version_ == 2)
+            if (rpcVersion_ == 2)
             {
                 jp[jss::method] = cmd;
                 jp[jss::jsonrpc] = "2.0";
@@ -284,7 +285,7 @@ public:
     [[nodiscard]] unsigned
     version() const override
     {
-        return rpc_version_;
+        return rpcVersion_;
     }
 
 private:
diff --git a/src/test/jtx/impl/balance.cpp b/src/test/jtx/impl/balance.cpp
index ddb45a8a97..9d1b812641 100644
--- a/src/test/jtx/impl/balance.cpp
+++ b/src/test/jtx/impl/balance.cpp
@@ -36,7 +36,7 @@ doBalance(Env& env, AccountID const& account, bool kNone, STAmount const& value,
     }
     else
     {
-        auto const sle = env.le(keylet::line(account, issue));
+        auto const sle = env.le(keylet::trustLine(account, issue));
         if (kNone)
         {
             TEST_EXPECT(!sle);
diff --git a/src/test/jtx/impl/batch.cpp b/src/test/jtx/impl/batch.cpp
index 2f7a67b45a..66ca0c7d54 100644
--- a/src/test/jtx/impl/batch.cpp
+++ b/src/test/jtx/impl/batch.cpp
@@ -11,6 +11,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -37,6 +38,16 @@ calcBatchFee(test::jtx::Env const& env, uint32_t const& numSigners, uint32_t con
     return ((numSigners + 2) * feeDrops) + feeDrops * txns;
 }
 
+XRPAmount
+calcConfidentialBatchFee(
+    test::jtx::Env const& env,
+    uint32_t const& numSigners,
+    uint32_t const& txns)
+{
+    XRPAmount const feeDrops = env.current()->fees().base;
+    return ((numSigners + 2) * feeDrops) + feeDrops * (kConfidentialFeeMultiplier + 1) * txns;
+}
+
 // Batch.
 json::Value
 outer(jtx::Account const& account, uint32_t seq, STAmount const& fee, std::uint32_t flags)
diff --git a/src/test/jtx/impl/directory.cpp b/src/test/jtx/impl/directory.cpp
index 7a92c2bf25..9fb06e25df 100644
--- a/src/test/jtx/impl/directory.cpp
+++ b/src/test/jtx/impl/directory.cpp
@@ -2,7 +2,6 @@
 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -15,6 +14,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 
@@ -26,9 +26,9 @@ bumpLastPage(
     Env& env,
     std::uint64_t newLastPage,
     Keylet directory,
-    std::function adjust) -> Expected
+    std::function adjust) -> std::expected
 {
-    Expected res{};
+    std::expected res{};
     env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal j) -> bool {
         Sandbox sb(&view, TapNone);
 
@@ -36,7 +36,7 @@ bumpLastPage(
         auto sleRoot = sb.peek(directory);
         if (!sleRoot)
         {
-            res = Unexpected(Error::DirectoryRootNotFound);
+            res = std::unexpected(Error::DirectoryRootNotFound);
             return false;
         }
 
@@ -44,26 +44,26 @@ bumpLastPage(
         auto const lastIndex = sleRoot->getFieldU64(sfIndexPrevious);
         if (lastIndex == 0)
         {
-            res = Unexpected(Error::DirectoryTooSmall);
+            res = std::unexpected(Error::DirectoryTooSmall);
             return false;
         }
 
         if (sb.exists(keylet::page(directory, newLastPage)))
         {
-            res = Unexpected(Error::DirectoryPageDuplicate);
+            res = std::unexpected(Error::DirectoryPageDuplicate);
             return false;
         }
 
         if (lastIndex >= newLastPage)
         {
-            res = Unexpected(Error::InvalidLastPage);
+            res = std::unexpected(Error::InvalidLastPage);
             return false;
         }
 
         auto slePage = sb.peek(keylet::page(directory, lastIndex));
         if (!slePage)
         {
-            res = Unexpected(Error::DirectoryPageNotFound);
+            res = std::unexpected(Error::DirectoryPageNotFound);
             return false;
         }
 
@@ -94,7 +94,7 @@ bumpLastPage(
             auto slePrev = sb.peek(keylet::page(directory, *prevIndex));
             if (!slePrev)
             {
-                res = Unexpected(Error::DirectoryPageNotFound);
+                res = std::unexpected(Error::DirectoryPageNotFound);
                 return false;
             }
             slePrev->setFieldU64(sfIndexNext, newLastPage);
@@ -109,7 +109,7 @@ bumpLastPage(
             {
                 if (!adjust(sb, key, newLastPage))
                 {
-                    res = Unexpected(Error::AdjustmentError);
+                    res = std::unexpected(Error::AdjustmentError);
                     return false;
                 }
             }
diff --git a/src/test/jtx/impl/envconfig.cpp b/src/test/jtx/impl/envconfig.cpp
index 648f8e4460..bc65738b44 100644
--- a/src/test/jtx/impl/envconfig.cpp
+++ b/src/test/jtx/impl/envconfig.cpp
@@ -3,7 +3,8 @@
 #include 
 
 #include 
-#include 
+
+#include 
 
 #include 
 #include 
@@ -20,41 +21,41 @@ setupConfigForUnitTests(Config& cfg)
     using namespace jtx;
     // Default fees to old values, so tests don't have to worry about changes in
     // Config.h
-    cfg.FEES.reference_fee = UNIT_TEST_REFERENCE_FEE;
-    cfg.FEES.account_reserve = XRP(200).value().xrp().drops();
-    cfg.FEES.owner_reserve = XRP(50).value().xrp().drops();
+    cfg.fees.referenceFee = UNIT_TEST_REFERENCE_FEE;
+    cfg.fees.accountReserve = XRP(200).value().xrp().drops();
+    cfg.fees.ownerReserve = XRP(50).value().xrp().drops();
 
     // The Beta API (currently v2) is always available to tests
-    cfg.BETA_RPC_API = true;
+    cfg.betaRpcApi = true;
 
-    cfg.overwrite(ConfigSection::nodeDatabase(), "type", "memory");
-    cfg.overwrite(ConfigSection::nodeDatabase(), "path", "main");
-    cfg.deprecatedClearSection(ConfigSection::importNodeDatabase());
-    cfg.legacy("database_path", "");
+    cfg.overwrite(Sections::kNodeDatabase, Keys::kType, "memory");
+    cfg.overwrite(Sections::kNodeDatabase, Keys::kPath, "main");
+    cfg.deprecatedClearSection(Sections::kImportNodeDatabase);
+    cfg.legacy(Sections::kDatabasePath, "");
     cfg.setupControl(true, true, true);
-    cfg["server"].append(PORT_PEER);
-    cfg[PORT_PEER].set("ip", getEnvLocalhostAddr());
+    cfg[Sections::kServer].append(Sections::kPortPeer);
+    cfg[Sections::kPortPeer].set(Keys::kIp, getEnvLocalhostAddr());
 
     // Using port 0 asks the operating system to allocate an unused port, which
     // can be obtained after a "bind" call.
     // Works for all system (Linux, Windows, Unix, Mac).
     // Check https://man7.org/linux/man-pages/man7/ip.7.html
     // "ip_local_port_range" section for more info
-    cfg[PORT_PEER].set("port", "0");
-    cfg[PORT_PEER].set("protocol", "peer");
+    cfg[Sections::kPortPeer].set(Keys::kPort, "0");
+    cfg[Sections::kPortPeer].set(Keys::kProtocol, "peer");
 
-    cfg["server"].append(PORT_RPC);
-    cfg[PORT_RPC].set("ip", getEnvLocalhostAddr());
-    cfg[PORT_RPC].set("admin", getEnvLocalhostAddr());
-    cfg[PORT_RPC].set("port", "0");
-    cfg[PORT_RPC].set("protocol", "http,ws2");
+    cfg[Sections::kServer].append(Sections::kPortRpc);
+    cfg[Sections::kPortRpc].set(Keys::kIp, getEnvLocalhostAddr());
+    cfg[Sections::kPortRpc].set(Keys::kAdmin, getEnvLocalhostAddr());
+    cfg[Sections::kPortRpc].set(Keys::kPort, "0");
+    cfg[Sections::kPortRpc].set(Keys::kProtocol, "http,ws2");
 
-    cfg["server"].append(PORT_WS);
-    cfg[PORT_WS].set("ip", getEnvLocalhostAddr());
-    cfg[PORT_WS].set("admin", getEnvLocalhostAddr());
-    cfg[PORT_WS].set("port", "0");
-    cfg[PORT_WS].set("protocol", "ws");
-    cfg.SSL_VERIFY = false;
+    cfg[Sections::kServer].append(Sections::kPortWs);
+    cfg[Sections::kPortWs].set(Keys::kIp, getEnvLocalhostAddr());
+    cfg[Sections::kPortWs].set(Keys::kAdmin, getEnvLocalhostAddr());
+    cfg[Sections::kPortWs].set(Keys::kPort, "0");
+    cfg[Sections::kPortWs].set(Keys::kProtocol, "ws");
+    cfg.sslVerify = false;
 }
 
 namespace jtx {
@@ -62,41 +63,41 @@ namespace jtx {
 std::unique_ptr
 noAdmin(std::unique_ptr cfg)
 {
-    (*cfg)[PORT_RPC].set("admin", "");
-    (*cfg)[PORT_WS].set("admin", "");
+    (*cfg)[Sections::kPortRpc].set(Keys::kAdmin, "");
+    (*cfg)[Sections::kPortWs].set(Keys::kAdmin, "");
     return cfg;
 }
 
 std::unique_ptr
 secureGateway(std::unique_ptr cfg)
 {
-    (*cfg)[PORT_RPC].set("admin", "");
-    (*cfg)[PORT_WS].set("admin", "");
-    (*cfg)[PORT_RPC].set("secure_gateway", getEnvLocalhostAddr());
+    (*cfg)[Sections::kPortRpc].set(Keys::kAdmin, "");
+    (*cfg)[Sections::kPortWs].set(Keys::kAdmin, "");
+    (*cfg)[Sections::kPortRpc].set(Keys::kSecureGateway, getEnvLocalhostAddr());
     return cfg;
 }
 
 std::unique_ptr
 adminLocalnet(std::unique_ptr cfg)
 {
-    (*cfg)[PORT_RPC].set("admin", "127.0.0.0/8");
-    (*cfg)[PORT_WS].set("admin", "127.0.0.0/8");
+    (*cfg)[Sections::kPortRpc].set(Keys::kAdmin, "127.0.0.0/8");
+    (*cfg)[Sections::kPortWs].set(Keys::kAdmin, "127.0.0.0/8");
     return cfg;
 }
 
 std::unique_ptr
 secureGatewayLocalnet(std::unique_ptr cfg)
 {
-    (*cfg)[PORT_RPC].set("admin", "");
-    (*cfg)[PORT_WS].set("admin", "");
-    (*cfg)[PORT_RPC].set("secure_gateway", "127.0.0.0/8");
-    (*cfg)[PORT_WS].set("secure_gateway", "127.0.0.0/8");
+    (*cfg)[Sections::kPortRpc].set(Keys::kAdmin, "");
+    (*cfg)[Sections::kPortWs].set(Keys::kAdmin, "");
+    (*cfg)[Sections::kPortRpc].set(Keys::kSecureGateway, "127.0.0.0/8");
+    (*cfg)[Sections::kPortWs].set(Keys::kSecureGateway, "127.0.0.0/8");
     return cfg;
 }
 std::unique_ptr
 singleThreadIo(std::unique_ptr cfg)
 {
-    cfg->IO_WORKERS = 1;
+    cfg->ioWorkers = 1;
     return cfg;
 }
 
@@ -106,7 +107,7 @@ std::unique_ptr
 validator(std::unique_ptr cfg, std::string const& seed)
 {
     // If the config has valid validation keys then we run as a validator.
-    cfg->section(SECTION_VALIDATION_SEED)
+    cfg->section(Sections::kValidationSeed)
         .append(std::vector{seed.empty() ? kDefaultSeed : seed});
     return cfg;
 }
@@ -114,20 +115,20 @@ validator(std::unique_ptr cfg, std::string const& seed)
 std::unique_ptr
 addGrpcConfig(std::unique_ptr cfg)
 {
-    (*cfg)[SECTION_PORT_GRPC].set("ip", getEnvLocalhostAddr());
-    (*cfg)[SECTION_PORT_GRPC].set("port", "0");
+    (*cfg)[Sections::kPortGrpc].set(Keys::kIp, getEnvLocalhostAddr());
+    (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
     return cfg;
 }
 
 std::unique_ptr
 addGrpcConfigWithSecureGateway(std::unique_ptr cfg, std::string const& secureGateway)
 {
-    (*cfg)[SECTION_PORT_GRPC].set("ip", getEnvLocalhostAddr());
+    (*cfg)[Sections::kPortGrpc].set(Keys::kIp, getEnvLocalhostAddr());
 
     // Check https://man7.org/linux/man-pages/man7/ip.7.html
     // "ip_local_port_range" section for using 0 ports
-    (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-    (*cfg)[SECTION_PORT_GRPC].set("secure_gateway", secureGateway);
+    (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+    (*cfg)[Sections::kPortGrpc].set(Keys::kSecureGateway, secureGateway);
     return cfg;
 }
 
@@ -137,10 +138,10 @@ addGrpcConfigWithTLS(
     std::string const& certPath,
     std::string const& keyPath)
 {
-    (*cfg)[SECTION_PORT_GRPC].set("ip", getEnvLocalhostAddr());
-    (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-    (*cfg)[SECTION_PORT_GRPC].set("ssl_cert", certPath);
-    (*cfg)[SECTION_PORT_GRPC].set("ssl_key", keyPath);
+    (*cfg)[Sections::kPortGrpc].set(Keys::kIp, getEnvLocalhostAddr());
+    (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+    (*cfg)[Sections::kPortGrpc].set(Keys::kSslCert, certPath);
+    (*cfg)[Sections::kPortGrpc].set(Keys::kSslKey, keyPath);
     return cfg;
 }
 
@@ -151,11 +152,11 @@ addGrpcConfigWithTLSAndClientCA(
     std::string const& keyPath,
     std::string const& clientCAPath)
 {
-    (*cfg)[SECTION_PORT_GRPC].set("ip", getEnvLocalhostAddr());
-    (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-    (*cfg)[SECTION_PORT_GRPC].set("ssl_cert", certPath);
-    (*cfg)[SECTION_PORT_GRPC].set("ssl_key", keyPath);
-    (*cfg)[SECTION_PORT_GRPC].set("ssl_client_ca", clientCAPath);
+    (*cfg)[Sections::kPortGrpc].set(Keys::kIp, getEnvLocalhostAddr());
+    (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+    (*cfg)[Sections::kPortGrpc].set(Keys::kSslCert, certPath);
+    (*cfg)[Sections::kPortGrpc].set(Keys::kSslKey, keyPath);
+    (*cfg)[Sections::kPortGrpc].set(Keys::kSslClientCa, clientCAPath);
     return cfg;
 }
 
@@ -166,11 +167,11 @@ addGrpcConfigWithTLSAndCertChain(
     std::string const& keyPath,
     std::string const& certChainPath)
 {
-    (*cfg)[SECTION_PORT_GRPC].set("ip", getEnvLocalhostAddr());
-    (*cfg)[SECTION_PORT_GRPC].set("port", "0");
-    (*cfg)[SECTION_PORT_GRPC].set("ssl_cert", certPath);
-    (*cfg)[SECTION_PORT_GRPC].set("ssl_key", keyPath);
-    (*cfg)[SECTION_PORT_GRPC].set("ssl_cert_chain", certChainPath);
+    (*cfg)[Sections::kPortGrpc].set(Keys::kIp, getEnvLocalhostAddr());
+    (*cfg)[Sections::kPortGrpc].set(Keys::kPort, "0");
+    (*cfg)[Sections::kPortGrpc].set(Keys::kSslCert, certPath);
+    (*cfg)[Sections::kPortGrpc].set(Keys::kSslKey, keyPath);
+    (*cfg)[Sections::kPortGrpc].set(Keys::kSslCertChain, certChainPath);
     return cfg;
 }
 
@@ -180,13 +181,13 @@ makeConfig(
     std::map extraVoting)
 {
     auto p = test::jtx::envconfig();
-    auto& section = p->section("transaction_queue");
-    section.set("ledgers_in_queue", "2");
-    section.set("minimum_queue_size", "2");
-    section.set("min_ledgers_to_compute_size_limit", "3");
-    section.set("max_ledger_counts_to_store", "100");
-    section.set("retry_sequence_percent", "25");
-    section.set("normal_consensus_increase_percent", "0");
+    auto& section = p->section(Sections::kTransactionQueue);
+    section.set(Keys::kLedgersInQueue, "2");
+    section.set(Keys::kMinimumQueueSize, "2");
+    section.set(Keys::kMinLedgersToComputeSizeLimit, "3");
+    section.set(Keys::kMaxLedgerCountsToStore, "100");
+    section.set(Keys::kRetrySequencePercent, "25");
+    section.set(Keys::kNormalConsensusIncreasePercent, "0");
 
     for (auto const& [k, v] : extraTxQ)
         section.set(k, v);
@@ -195,14 +196,14 @@ makeConfig(
     // a FeeVote
     if (!extraVoting.empty())
     {
-        auto& votingSection = p->section("voting");
+        auto& votingSection = p->section(Sections::kVoting);
         for (auto const& [k, v] : extraVoting)
         {
             votingSection.set(k, v);
         }
 
         // In order for the vote to occur, we must run as a validator
-        p->section("validation_seed").legacy("shUwVw52ofnCUX5m7kPTKzJdr4HEH");
+        p->section(Sections::kValidationSeed).legacy("shUwVw52ofnCUX5m7kPTKzJdr4HEH");
     }
     return p;
 }
diff --git a/src/test/jtx/impl/ledgerStateFixes.cpp b/src/test/jtx/impl/ledgerStateFixes.cpp
index a4b6d9b986..30c6659124 100644
--- a/src/test/jtx/impl/ledgerStateFixes.cpp
+++ b/src/test/jtx/impl/ledgerStateFixes.cpp
@@ -1,6 +1,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -22,4 +23,16 @@ nftPageLinks(jtx::Account const& acct, jtx::Account const& owner)
     return jv;
 }
 
+// Fix sfExchangeRate on a book directory.  acct pays fee.
+json::Value
+bookExchangeRate(jtx::Account const& acct, uint256 const& bookDir)
+{
+    json::Value jv;
+    jv[sfAccount.jsonName] = acct.human();
+    jv[sfLedgerFixType.jsonName] = static_cast(LedgerStateFix::FixType::BookExchangeRate);
+    jv[sfBookDirectory.jsonName] = to_string(bookDir);
+    jv[sfTransactionType.jsonName] = jss::LedgerStateFix;
+    return jv;
+}
+
 }  // namespace xrpl::test::jtx::ledgerStateFix
diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp
index 1e127e7c05..dddfc88c7f 100644
--- a/src/test/jtx/impl/mpt.cpp
+++ b/src/test/jtx/impl/mpt.cpp
@@ -10,6 +10,8 @@
 #include 
 #include 
 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -18,8 +20,10 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -27,8 +31,17 @@
 #include 
 #include 
 
+#include 
+
+#include 
+#include 
+#include 
+
 #include 
+#include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -39,6 +52,62 @@
 #include 
 
 namespace xrpl::test::jtx {
+namespace {
+
+constexpr std::uint64_t kElGamalDecryptRangeLow = 0;
+constexpr std::uint64_t kElGamalDecryptRangeHigh = 3000;
+
+/**
+ * @brief Returns a reference to the value held by an optional, throwing if it
+ *        is not an optional.
+ *
+ * @param opt The optional to unwrap.
+ * @param what Description used in the thrown exception if opt is empty.
+ * @return A const reference to the contained value.
+ */
+template 
+[[nodiscard]] T const&
+requireValue(std::optional const& opt, char const* what)
+{
+    if (!opt)
+        Throw(what);
+    return *opt;
+}
+
+/**
+ * @brief Helper function to convert a PedersenProofParams into the C library struct.
+ *
+ * @param params The Pedersen commitment proof parameters.
+ * @return The equivalent mpt_pedersen_proof_params for use with the C library.
+ */
+mpt_pedersen_proof_params
+makePedersenParams(PedersenProofParams const& params)
+{
+    mpt_pedersen_proof_params res{};
+    std::memcpy(
+        res.pedersen_commitment, params.pedersenCommitment.data(), kMPT_PEDERSEN_COMMIT_SIZE);
+    res.amount = params.amt;
+    std::memcpy(res.ciphertext, params.encryptedAmt.data(), kMPT_ELGAMAL_TOTAL_SIZE);
+    std::memcpy(res.blinding_factor, params.blindingFactor.data(), kMPT_BLINDING_FACTOR_SIZE);
+    return res;
+}
+
+}  // namespace
+
+struct MPTSetFlagMapping
+{
+    std::uint32_t setFlag;
+    std::uint32_t ledgerFlag;
+};
+
+static constexpr std::array mptSetFlagMappings = {{
+    {.setFlag = tmfMPTSetCanLock, .ledgerFlag = lsfMPTCanLock},
+    {.setFlag = tmfMPTSetRequireAuth, .ledgerFlag = lsfMPTRequireAuth},
+    {.setFlag = tmfMPTSetCanEscrow, .ledgerFlag = lsfMPTCanEscrow},
+    {.setFlag = tmfMPTSetCanClawback, .ledgerFlag = lsfMPTCanClawback},
+    {.setFlag = tmfMPTSetCanTrade, .ledgerFlag = lsfMPTCanTrade},
+    {.setFlag = tmfMPTSetCanTransfer, .ledgerFlag = lsfMPTCanTransfer},
+}};
 
 void
 MptFlags::operator()(Env& env) const
@@ -72,13 +141,20 @@ MPTTester::makeHolders(std::vector const& holders)
 }
 
 MPTTester::MPTTester(Env& env, Account issuer, MPTInit const& arg)
-    : env_(env), issuer_(std::move(issuer)), holders_(makeHolders(arg.holders)), close_(arg.close)
+    : env_(env)
+    , issuer_(std::move(issuer))
+    , holders_(makeHolders(arg.holders))
+    , auditor_(arg.auditor)
+    , close_(arg.close)
 {
     if (arg.fund)
     {
         env_.fund(arg.xrp, issuer_);
         for (auto const& it : holders_)
             env_.fund(arg.xrpHolders, it.second);
+
+        if (arg.auditor)
+            env_.fund(arg.xrp, *arg.auditor);
     }
     if (close_)
         env.close();
@@ -91,6 +167,9 @@ MPTTester::MPTTester(Env& env, Account issuer, MPTInit const& arg)
                 Throw("Issuer can't be holder");
             env_.require(Owners(it.second, 0));
         }
+
+        if (arg.auditor)
+            env_.require(Owners(*arg.auditor, 0));
     }
     if (arg.create)
         create(*arg.create);
@@ -132,7 +211,12 @@ MPTTester::MPTTester(MPTInitDef const& arg)
     : MPTTester{
           arg.env,
           arg.issuer,
-          MPTInit{.fund = arg.fund, .close = arg.close, .create = makeMPTCreate(arg)}}
+          MPTInit{
+              .auditor = arg.auditor,
+              .fund = arg.fund,
+              .close = arg.close,
+              .create = makeMPTCreate(arg),
+          }}
 {
 }
 
@@ -185,8 +269,8 @@ MPTTester::create(MPTCreate const& arg)
     if (!isTesSuccess(submit(arg, jv)))
     {
         // Verify issuance doesn't exist
-        env_.require(
-            RequireAny([&]() -> bool { return env_.le(keylet::mptIssuance(*id_)) == nullptr; }));
+        env_.require(RequireAny(
+            [&]() -> bool { return env_.le(keylet::mptokenIssuance(*id_)) == nullptr; }));
 
         id_.reset();
     }
@@ -385,6 +469,10 @@ MPTTester::setJV(MPTSet const& arg)
         jv[sfTransferFee] = *arg.transferFee;
     if (arg.metadata)
         jv[sfMPTokenMetadata] = strHex(*arg.metadata);
+    if (arg.issuerPubKey)
+        jv[sfIssuerEncryptionKey] = strHex(*arg.issuerPubKey);
+    if (arg.auditorPubKey)
+        jv[sfAuditorEncryptionKey] = strHex(*arg.auditorPubKey);
     jv[sfTransactionType] = jss::MPTokenIssuanceSet;
 
     return jv;
@@ -403,88 +491,92 @@ MPTTester::set(MPTSet const& arg)
          .transferFee = arg.transferFee,
          .metadata = arg.metadata,
          .delegate = arg.delegate,
-         .domainID = arg.domainID});
+         .domainID = arg.domainID,
+         .issuerPubKey = arg.issuerPubKey,
+         .auditorPubKey = arg.auditorPubKey});
     if (submit(arg, jv) == tesSUCCESS && ((arg.flags.value_or(0) != 0u) || arg.mutableFlags))
     {
-        auto require = [&](std::optional const& holder, bool unchanged) {
-            auto flags = getFlags(holder);
-            if (!unchanged)
+        if (((arg.flags.value_or(0) != 0u) || arg.mutableFlags))
+        {
+            auto require = [&](std::optional const& holder, bool unchanged) {
+                auto flags = getFlags(holder);
+                if (!unchanged)
+                {
+                    if (arg.flags)
+                    {
+                        if (*arg.flags & tfMPTLock)
+                        {
+                            flags |= lsfMPTLocked;
+                        }
+                        else if (*arg.flags & tfMPTUnlock)
+                        {
+                            flags &= ~lsfMPTLocked;
+                        }
+                    }
+
+                    if (arg.mutableFlags)
+                    {
+                        for (auto const& [setFlag, ledgerFlag] : mptSetFlagMappings)
+                        {
+                            if ((*arg.mutableFlags & setFlag) != 0u)
+                            {
+                                flags |= ledgerFlag;
+                            }
+                        }
+
+                        if (*arg.mutableFlags & tmfMPTSetCanHoldConfidentialBalance)
+                            flags |= tfMPTCanHoldConfidentialBalance;
+                    }
+                }
+                env_.require(MptFlags(*this, flags, holder));
+            };
+            if (arg.account)
+                require(std::nullopt, arg.holder.has_value());
+            if (auto const account = (arg.holder ? std::get_if(&(*arg.holder)) : nullptr))
+                require(*account, false);
+
+            if (arg.issuerPubKey)
             {
-                if (arg.flags)
-                {
-                    if (*arg.flags & tfMPTLock)
-                    {
-                        flags |= lsfMPTLocked;
-                    }
-                    else if (*arg.flags & tfMPTUnlock)
-                    {
-                        flags &= ~lsfMPTLocked;
-                    }
-                }
+                env_.require(RequireAny([&]() -> bool {
+                    return forObject([&](SLEP const& sle) -> bool {
+                        if (sle)
+                        {
+                            auto const issuerPubKey = getPubKey(issuer_);
+                            if (!issuerPubKey)
+                            {
+                                Throw(
+                                    "MPTTester::set: issuer's pubkey is not set");
+                            }
 
-                if (arg.mutableFlags)
-                {
-                    if (*arg.mutableFlags & tmfMPTSetCanLock)
-                    {
-                        flags |= lsfMPTCanLock;
-                    }
-                    else if (*arg.mutableFlags & tmfMPTClearCanLock)
-                    {
-                        flags &= ~lsfMPTCanLock;
-                    }
-
-                    if (*arg.mutableFlags & tmfMPTSetRequireAuth)
-                    {
-                        flags |= lsfMPTRequireAuth;
-                    }
-                    else if (*arg.mutableFlags & tmfMPTClearRequireAuth)
-                    {
-                        flags &= ~lsfMPTRequireAuth;
-                    }
-
-                    if (*arg.mutableFlags & tmfMPTSetCanEscrow)
-                    {
-                        flags |= lsfMPTCanEscrow;
-                    }
-                    else if (*arg.mutableFlags & tmfMPTClearCanEscrow)
-                    {
-                        flags &= ~lsfMPTCanEscrow;
-                    }
-
-                    if (*arg.mutableFlags & tmfMPTSetCanClawback)
-                    {
-                        flags |= lsfMPTCanClawback;
-                    }
-                    else if (*arg.mutableFlags & tmfMPTClearCanClawback)
-                    {
-                        flags &= ~lsfMPTCanClawback;
-                    }
-
-                    if (*arg.mutableFlags & tmfMPTSetCanTrade)
-                    {
-                        flags |= lsfMPTCanTrade;
-                    }
-                    else if (*arg.mutableFlags & tmfMPTClearCanTrade)
-                    {
-                        flags &= ~lsfMPTCanTrade;
-                    }
-
-                    if (*arg.mutableFlags & tmfMPTSetCanTransfer)
-                    {
-                        flags |= lsfMPTCanTransfer;
-                    }
-                    else if (*arg.mutableFlags & tmfMPTClearCanTransfer)
-                    {
-                        flags &= ~lsfMPTCanTransfer;
-                    }
-                }
+                            return strHex((*sle)[sfIssuerEncryptionKey]) == strHex(*issuerPubKey);
+                        }
+                        return false;
+                    });
+                }));
             }
-            env_.require(MptFlags(*this, flags, holder));
-        };
-        if (arg.account)
-            require(std::nullopt, arg.holder.has_value());
-        if (auto const account = (arg.holder ? std::get_if(&(*arg.holder)) : nullptr))
-            require(*account, false);
+            if (arg.auditorPubKey)
+            {
+                env_.require(RequireAny([&]() -> bool {
+                    return forObject([&](SLEP const& sle) -> bool {
+                        if (sle)
+                        {
+                            if (!auditor_.has_value())
+                                Throw("MPTTester::set: auditor is not set");
+
+                            auto const auditorPubKey = getPubKey(*auditor_);
+                            if (!auditorPubKey)
+                            {
+                                Throw(
+                                    "MPTTester::set: auditor's pubkey is not set");
+                            }
+
+                            return strHex((*sle)[sfAuditorEncryptionKey]) == strHex(*auditorPubKey);
+                        }
+                        return false;
+                    });
+                }));
+            }
+        }
     }
 }
 
@@ -495,7 +587,7 @@ MPTTester::forObject(
 {
     if (!id_)
         Throw("MPT has not been created");
-    auto const key = holder ? keylet::mptoken(*id_, holder->id()) : keylet::mptIssuance(*id_);
+    auto const key = holder ? keylet::mptoken(*id_, holder->id()) : keylet::mptokenIssuance(*id_);
     if (auto const sle = env_.le(key))
         return cb(sle);
     return false;
@@ -525,6 +617,14 @@ MPTTester::checkMPTokenOutstandingAmount(std::int64_t expectedAmount) const
         [&](SLEP const& sle) { return expectedAmount == (*sle)[sfOutstandingAmount]; });
 }
 
+[[nodiscard]] bool
+MPTTester::checkIssuanceConfidentialBalance(std::int64_t expectedAmount) const
+{
+    return forObject([&](SLEP const& sle) {
+        return expectedAmount == (*sle)[~sfConfidentialOutstandingAmount].value_or(0);
+    });
+}
+
 [[nodiscard]] bool
 MPTTester::checkFlags(uint32_t const expectedFlags, std::optional const& holder) const
 {
@@ -660,7 +760,7 @@ MPTTester::getBalance(Account const& account) const
         Throw("MPT has not been created");
     if (account == issuer_)
     {
-        if (auto const sle = env_.le(keylet::mptIssuance(*id_)))
+        if (auto const sle = env_.le(keylet::mptokenIssuance(*id_)))
             return sle->getFieldU64(sfOutstandingAmount);
     }
     else
@@ -671,6 +771,238 @@ MPTTester::getBalance(Account const& account) const
     return 0;
 }
 
+std::int64_t
+MPTTester::getIssuanceConfidentialBalance() const
+{
+    if (!id_)
+        Throw("MPT has not been created");
+
+    if (auto const sle = env_.le(keylet::mptokenIssuance(*id_)))
+        return (*sle)[~sfConfidentialOutstandingAmount].value_or(0);
+
+    return 0;
+}
+
+std::optional
+MPTTester::getClawbackProof(
+    Account const& holder,
+    std::uint64_t amount,
+    Buffer const& privateKey,
+    uint256 const& contextHash) const
+{
+    if (!id_)
+        Throw("MPT has not been created");
+
+    auto const sleHolder = env_.le(keylet::mptoken(*id_, holder.id()));
+    auto const sleIssuance = env_.le(keylet::mptokenIssuance(*id_));
+
+    if (!sleHolder || !sleIssuance)
+        return std::nullopt;
+
+    auto const ciphertextBlob = sleHolder->getFieldVL(sfIssuerEncryptedBalance);
+    if (ciphertextBlob.size() != kEcGamalEncryptedTotalLength)
+        return std::nullopt;
+
+    auto const pubKeyBlob = sleIssuance->getFieldVL(sfIssuerEncryptionKey);
+    if (pubKeyBlob.size() != kEcPubKeyLength)
+        return std::nullopt;
+
+    Buffer proof(kEcClawbackProofLength);
+
+    if (mpt_get_clawback_proof(
+            privateKey.data(),
+            pubKeyBlob.data(),
+            contextHash.data(),
+            amount,
+            ciphertextBlob.data(),
+            proof.data()) != 0)
+    {
+        return std::nullopt;
+    }
+
+    return proof;
+}
+
+std::optional
+MPTTester::getSchnorrProof(Account const& account, uint256 const& ctxHash) const
+{
+    auto const pubKey = getPubKey(account);
+    if (!pubKey || pubKey->size() != kEcPubKeyLength)
+        return std::nullopt;
+
+    auto const privKey = getPrivKey(account);
+    if (requireValue(privKey, "privKey").size() != kEcPrivKeyLength)
+        return std::nullopt;
+
+    Buffer proof(kEcSchnorrProofLength);
+
+    if (mpt_get_convert_proof(
+            requireValue(pubKey, "pubKey").data(),
+            requireValue(privKey, "privKey").data(),
+            ctxHash.data(),
+            proof.data()) != 0)
+        return std::nullopt;
+
+    return proof;
+}
+
+std::optional
+MPTTester::getConfidentialSendProof(
+    Account const& sender,
+    std::uint64_t const amount,
+    std::vector const& recipients,
+    Slice const& blindingFactor,
+    uint256 const& contextHash,
+    PedersenProofParams const& amountParams,
+    PedersenProofParams const& balanceParams) const
+{
+    auto const pedersenBalanceParams = makePedersenParams(balanceParams);
+
+    if (blindingFactor.size() != kEcBlindingFactorLength)
+        return std::nullopt;
+
+    auto const senderPrivKey = getPrivKey(sender);
+    if (!senderPrivKey)
+        return std::nullopt;
+
+    auto const senderPubKey = getPubKey(sender);
+    if (!senderPubKey || senderPubKey->size() != kEcPubKeyLength)
+        return std::nullopt;
+
+    if (amountParams.pedersenCommitment.size() != kEcPedersenCommitmentLength)
+        return std::nullopt;
+
+    // Build mpt_confidential_participant array
+    std::vector participants(recipients.size());
+    for (size_t i = 0; i < recipients.size(); ++i)
+    {
+        auto const& r = recipients[i];
+        if (r.encryptedAmount.size() != kEcGamalEncryptedTotalLength ||
+            r.publicKey.size() != kEcPubKeyLength)
+        {
+            return std::nullopt;
+        }
+        std::memcpy(participants[i].pubkey, r.publicKey.data(), kEcPubKeyLength);
+        std::memcpy(
+            participants[i].ciphertext, r.encryptedAmount.data(), kEcGamalEncryptedTotalLength);
+    }
+
+    size_t proofLen = kEcSendProofLength;
+    Buffer proof(proofLen);
+
+    if (mpt_get_confidential_send_proof(
+            senderPrivKey->data(),
+            senderPubKey->data(),
+            amount,
+            participants.data(),
+            recipients.size(),
+            blindingFactor.data(),
+            contextHash.data(),
+            amountParams.pedersenCommitment.data(),
+            &pedersenBalanceParams,
+            proof.data(),
+            &proofLen) != 0)
+        return std::nullopt;
+
+    return proof;
+}
+
+Buffer
+MPTTester::getPedersenCommitment(std::uint64_t const amount, Buffer const& pedersenBlindingFactor)
+{
+    // Blinding factor (rho) must be a 32-byte scalar
+    if (pedersenBlindingFactor.size() != kEcBlindingFactorLength)
+        Throw("Invalid blinding factor size");
+
+    // secp256k1_mpt_pedersen_commit doesn't handle amount 0, return a trivial
+    // valid commitment for test purposes
+    if (amount == 0)
+    {
+        Buffer buf(kEcPedersenCommitmentLength);
+        std::memset(buf.data(), 0, kEcPedersenCommitmentLength);
+        buf.data()[0] = kEcCompressedPrefixEvenY;
+        buf.data()[kEcPedersenCommitmentLength - 1] = 0x01;
+        return buf;
+    }
+
+    Buffer buf(kEcPedersenCommitmentLength);
+
+    if (mpt_get_pedersen_commitment(amount, pedersenBlindingFactor.data(), buf.data()) != 0)
+        Throw("Pedersen commitment generation failed");
+
+    return buf;
+}
+
+Buffer
+MPTTester::getConvertBackProof(
+    Account const& holder,
+    std::uint64_t const amount,
+    uint256 const& contextHash,
+    PedersenProofParams const& pcParams) const
+{
+    // Expected total proof length: compact sigma proof (128 bytes) + single bulletproof (688 bytes)
+    std::size_t constexpr kExpectedProofLength = kEcConvertBackProofLength;
+
+    auto const sleMptoken = env_.le(keylet::mptoken(issuanceID(), holder.id()));
+    if (!sleMptoken || !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending))
+        return gMakeZeroBuffer(kExpectedProofLength);
+
+    auto const holderPubKey = getPubKey(holder);
+    auto const holderPrivKey = getPrivKey(holder);
+
+    if (!holderPubKey || !holderPrivKey)
+        return gMakeZeroBuffer(kExpectedProofLength);
+
+    auto const pedersenParams = makePedersenParams(pcParams);
+    Buffer proof(kExpectedProofLength);
+
+    if (mpt_get_convert_back_proof(
+            holderPrivKey->data(),
+            holderPubKey->data(),
+            contextHash.data(),
+            amount,
+            &pedersenParams,
+            proof.data()) != 0)
+        return gMakeZeroBuffer(kExpectedProofLength);
+
+    return proof;
+}
+
+std::optional
+MPTTester::getEncryptedBalance(Account const& account, EncryptedBalanceType option) const
+{
+    if (!id_)
+        Throw("MPT has not been created");
+
+    if (auto const sle = env_.le(keylet::mptoken(*id_, account.id())))
+    {
+        if (option == holderEncryptedInbox && sle->isFieldPresent(sfConfidentialBalanceInbox))
+        {
+            return Buffer(
+                (*sle)[sfConfidentialBalanceInbox].data(),
+                (*sle)[sfConfidentialBalanceInbox].size());
+        }
+        if (option == holderEncryptedSpending && sle->isFieldPresent(sfConfidentialBalanceSpending))
+        {
+            return Buffer(
+                (*sle)[sfConfidentialBalanceSpending].data(),
+                (*sle)[sfConfidentialBalanceSpending].size());
+        }
+        if (option == issuerEncryptedBalance && sle->isFieldPresent(sfIssuerEncryptedBalance))
+        {
+            return Buffer(
+                (*sle)[sfIssuerEncryptedBalance].data(), (*sle)[sfIssuerEncryptedBalance].size());
+        }
+        if (option == auditorEncryptedBalance && sle->isFieldPresent(sfAuditorEncryptedBalance))
+        {
+            return Buffer(
+                (*sle)[sfAuditorEncryptedBalance].data(), (*sle)[sfAuditorEncryptedBalance].size());
+        }
+    }
+
+    return {};
+}
+
 std::uint32_t
 MPTTester::getFlags(std::optional const& holder) const
 {
@@ -697,4 +1029,1515 @@ MPTTester::operator()(std::int64_t amount) const
     return MPT("", issuanceID())(amount);
 }
 
+template 
+void
+MPTTester::fillConversionCiphertexts(
+    T const& arg,
+    json::Value& jv,
+    Buffer& holderCiphertext,
+    Buffer& issuerCiphertext,
+    std::optional& auditorCiphertext,
+    Buffer& blindingFactor) const
+{
+    blindingFactor = arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor();
+
+    // Handle Holder
+    if (arg.holderEncryptedAmt)
+    {
+        holderCiphertext = *arg.holderEncryptedAmt;
+    }
+    else
+    {
+        holderCiphertext = encryptAmount(
+            requireValue(arg.account, "account"), requireValue(arg.amt, "amt"), blindingFactor);
+    }
+
+    jv[sfHolderEncryptedAmount.jsonName] = strHex(holderCiphertext);
+
+    // Handle Issuer
+    if (arg.issuerEncryptedAmt)
+    {
+        issuerCiphertext = *arg.issuerEncryptedAmt;
+    }
+    else
+    {
+        issuerCiphertext = encryptAmount(issuer_, requireValue(arg.amt, "amt"), blindingFactor);
+    }
+
+    jv[sfIssuerEncryptedAmount.jsonName] = strHex(issuerCiphertext);
+
+    // Handle Auditor
+    if (arg.auditorEncryptedAmt)
+    {
+        auditorCiphertext = *arg.auditorEncryptedAmt;
+    }
+    else if (auditor_.has_value() && arg.fillAuditorEncryptedAmt.value_or(false))
+    {
+        auditorCiphertext = encryptAmount(
+            requireValue(auditor_, "auditor"), requireValue(arg.amt, "amt"), blindingFactor);
+    }
+
+    // Update auditor JSON only if ciphertext exists
+    if (auditorCiphertext)
+        jv[sfAuditorEncryptedAmount.jsonName] = strHex(*auditorCiphertext);
+}
+
+void
+MPTTester::convert(MPTConvert const& arg)
+{
+    json::Value jv;
+    if (arg.account)
+    {
+        jv[sfAccount] = arg.account->human();
+    }
+    else
+    {
+        Throw("Account not specified");
+    }
+
+    jv[jss::TransactionType] = jss::ConfidentialMPTConvert;
+    if (arg.id)
+    {
+        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
+    }
+    else
+    {
+        if (!id_)
+            Throw("MPT has not been created");
+        jv[sfMPTokenIssuanceID] = to_string(*id_);
+    }
+
+    if (arg.amt)
+        jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt);
+    if (arg.holderPubKey)
+        jv[sfHolderEncryptionKey.jsonName] = strHex(*arg.holderPubKey);
+
+    Buffer holderCiphertext;
+    Buffer issuerCiphertext;
+    std::optional auditorCiphertext;
+    Buffer blindingFactor;
+
+    fillConversionCiphertexts(
+        arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor);
+
+    jv[sfBlindingFactor.jsonName] = strHex(blindingFactor);
+    if (arg.proof)
+    {
+        jv[sfZKProof.jsonName] = *arg.proof;
+    }
+    else if (arg.fillSchnorrProof.value_or(arg.holderPubKey.has_value()))
+    {
+        // whether to automatically generate and attach a Schnorr proof:
+        // if fillSchnorrProof is explicitly set, follow its value;
+        // otherwise, default to generating the proof only if holder pub key is
+        // present.
+        auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account));
+        auto const contextHash =
+            getConvertContextHash(requireValue(arg.account, "account").id(), issuanceID(), seq);
+
+        auto const proof = getSchnorrProof(*arg.account, contextHash);
+        if (proof)
+        {
+            jv[sfZKProof.jsonName] = strHex(*proof);
+        }
+        else
+        {
+            jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSchnorrProofLength));
+        }
+    }
+
+    auto const holderAmt = getBalance(*arg.account);
+    auto const prevConfidentialOutstanding = getIssuanceConfidentialBalance();
+
+    auto const prevInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox);
+    auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending);
+    auto const prevIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
+
+    if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance)
+        Throw("Failed to get Pre-convert balance");
+
+    std::optional prevAuditorBalance;
+    if (arg.auditorEncryptedAmt || auditor_)
+    {
+        prevAuditorBalance = getDecryptedBalance(*arg.account, auditorEncryptedBalance);
+        if (!prevAuditorBalance)
+            Throw("Failed to get Pre-convert balance");
+    }
+
+    auto const prevOutstanding = getIssuanceOutstandingBalance();
+
+    if (submit(arg, jv) == tesSUCCESS)
+    {
+        auto const postConfidentialOutstanding = getIssuanceConfidentialBalance();
+        auto const postOutstanding = getIssuanceOutstandingBalance();
+        env_.require(MptBalance(
+            *this, requireValue(arg.account, "account"), holderAmt - requireValue(arg.amt, "amt")));
+        env_.require(RequireAny([&]() -> bool {
+            return prevOutstanding && postOutstanding && *prevOutstanding == *postOutstanding;
+        }));
+        env_.require(RequireAny([&]() -> bool {
+            return prevConfidentialOutstanding + *arg.amt == postConfidentialOutstanding;
+        }));
+
+        env_.require(RequireAny([&]() -> bool {
+            return getEncryptedBalance(*arg.account, holderEncryptedInbox).has_value();
+        }));
+        env_.require(RequireAny([&]() -> bool {
+            return getEncryptedBalance(*arg.account, holderEncryptedSpending).has_value();
+        }));
+        env_.require(RequireAny([&]() -> bool {
+            return getEncryptedBalance(*arg.account, issuerEncryptedBalance).has_value();
+        }));
+
+        auto const postInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox);
+        auto const postIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
+        auto const postSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending);
+
+        if (!postInboxBalance || !postIssuerBalance || !postSpendingBalance)
+            Throw("Failed to get post-convert balance");
+
+        if (arg.auditorEncryptedAmt || auditor_)
+        {
+            auto const postAuditorBalance =
+                getDecryptedBalance(*arg.account, auditorEncryptedBalance);
+
+            if (!postAuditorBalance)
+                Throw("Failed to get post-convert auditor balance");
+
+            env_.require(RequireAny([&]() -> bool {
+                return getEncryptedBalance(*arg.account, auditorEncryptedBalance).has_value();
+            }));
+
+            // auditor's encrypted balance is updated correctly
+            env_.require(RequireAny(
+                [&]() -> bool { return *prevAuditorBalance + *arg.amt == *postAuditorBalance; }));
+        }
+        // spending balance should not change
+        env_.require(
+            RequireAny([&]() -> bool { return *postSpendingBalance == *prevSpendingBalance; }));
+
+        // issuer's encrypted balance is updated correctly
+        env_.require(RequireAny(
+            [&]() -> bool { return *prevIssuerBalance + *arg.amt == *postIssuerBalance; }));
+
+        // holder's inbox balance is updated correctly
+        env_.require(RequireAny(
+            [&]() -> bool { return *prevInboxBalance + *arg.amt == *postInboxBalance; }));
+
+        // sum of holder's inbox and spending balance should equal to issuer's
+        // encrypted balance
+        env_.require(RequireAny([&]() -> bool {
+            return *postInboxBalance + *postSpendingBalance == *postIssuerBalance;
+        }));
+
+        if (arg.holderPubKey)
+        {
+            env_.require(RequireAny([&]() -> bool {
+                return forObject(
+                    [&](SLEP const& sle) -> bool {
+                        if (sle)
+                        {
+                            auto const holderPubKey = getPubKey(*arg.account);
+                            if (!holderPubKey)
+                            {
+                                Throw(
+                                    "MPTTester::convert: holder's pubkey is "
+                                    "not set");
+                            }
+
+                            return strHex((*sle)[sfHolderEncryptionKey]) == strHex(*holderPubKey);
+                        }
+                        return false;
+                    },
+                    arg.account);
+            }));
+        }
+    }
+}
+
+json::Value
+MPTTester::convertJV(MPTConvert const& arg, std::uint32_t seq)
+{
+    json::Value jv;
+    if (arg.account)
+    {
+        jv[sfAccount] = arg.account->human();
+    }
+    else
+    {
+        Throw("Account not specified");
+    }
+
+    jv[jss::TransactionType] = jss::ConfidentialMPTConvert;
+    if (arg.id)
+    {
+        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
+    }
+    else
+    {
+        if (!id_)
+            Throw("MPT has not been created");
+        jv[sfMPTokenIssuanceID] = to_string(*id_);
+    }
+
+    if (arg.amt)
+        jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt);
+    if (arg.holderPubKey)
+        jv[sfHolderEncryptionKey.jsonName] = strHex(*arg.holderPubKey);
+
+    Buffer holderCiphertext;
+    Buffer issuerCiphertext;
+    std::optional auditorCiphertext;
+    Buffer blindingFactor;
+
+    fillConversionCiphertexts(
+        arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor);
+
+    jv[sfBlindingFactor.jsonName] = strHex(blindingFactor);
+
+    if (arg.proof)
+    {
+        jv[sfZKProof.jsonName] = *arg.proof;
+    }
+    else if (arg.fillSchnorrProof.value_or(arg.holderPubKey.has_value()))
+    {
+        auto const contextHash =
+            getConvertContextHash(requireValue(arg.account, "account").id(), issuanceID(), seq);
+        auto const proof = getSchnorrProof(*arg.account, contextHash);
+        if (proof)
+        {
+            jv[sfZKProof.jsonName] = strHex(*proof);
+        }
+        else
+        {
+            jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSchnorrProofLength));
+        }
+    }
+
+    return jv;
+}
+
+void
+MPTTester::send(MPTConfidentialSend const& arg)
+{
+    json::Value jv;
+    jv[jss::TransactionType] = jss::ConfidentialMPTSend;
+
+    if (arg.account)
+    {
+        jv[sfAccount] = arg.account->human();
+    }
+    else
+    {
+        Throw("Account not specified");
+    }
+
+    if (arg.dest)
+    {
+        jv[sfDestination] = arg.dest->human();
+    }
+    else
+    {
+        Throw("Destination not specified");
+    }
+
+    if (!arg.amt)
+        Throw("Amount not specified for testing purposes");
+
+    if (arg.id)
+    {
+        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
+    }
+    else
+    {
+        if (!id_)
+            Throw("MPT has not been created");
+        jv[sfMPTokenIssuanceID] = to_string(*id_);
+    }
+
+    Buffer const blindingFactor =
+        arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor();
+
+    // fill in the encrypted amounts if not provided
+    auto const senderAmt = arg.senderEncryptedAmt
+        ? *arg.senderEncryptedAmt
+        : encryptAmount(*arg.account, *arg.amt, blindingFactor);
+    auto const destAmt = arg.destEncryptedAmt ? *arg.destEncryptedAmt
+                                              : encryptAmount(*arg.dest, *arg.amt, blindingFactor);
+    auto const issuerAmt = arg.issuerEncryptedAmt
+        ? *arg.issuerEncryptedAmt
+        : encryptAmount(issuer_, *arg.amt, blindingFactor);
+
+    std::optional auditorAmt;
+    if (arg.auditorEncryptedAmt)
+    {
+        auditorAmt = arg.auditorEncryptedAmt;
+    }
+    else if (auditor_.has_value() && arg.fillAuditorEncryptedAmt.value_or(false))
+    {
+        auditorAmt = encryptAmount(
+            requireValue(auditor_, "auditor"), requireValue(arg.amt, "amt"), blindingFactor);
+    }
+
+    jv[sfSenderEncryptedAmount] = strHex(senderAmt);
+    jv[sfDestinationEncryptedAmount] = strHex(destAmt);
+    jv[sfIssuerEncryptedAmount] = strHex(issuerAmt);
+    if (auditorAmt)
+        jv[sfAuditorEncryptedAmount] = strHex(*auditorAmt);
+
+    if (arg.credentials)
+    {
+        auto& arr(jv[sfCredentialIDs.jsonName] = json::ValueType::Array);
+        for (auto const& hash : *arg.credentials)
+            arr.append(hash);
+    }
+
+    // Version counters before send
+    auto const prevSenderVersion = getMPTokenVersion(*arg.account);
+    auto const prevDestVersion = getMPTokenVersion(*arg.dest);
+
+    // Sender's previous confidential state
+    auto const prevSenderInbox = getDecryptedBalance(*arg.account, holderEncryptedInbox);
+    auto const prevSenderSpending = getDecryptedBalance(*arg.account, holderEncryptedSpending);
+    auto const prevSenderIssuer = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
+    auto const prevSenderInboxEncrypted = getEncryptedBalance(*arg.account, holderEncryptedInbox);
+    auto const prevSenderSpendingEncrypted =
+        getEncryptedBalance(*arg.account, holderEncryptedSpending);
+    auto const prevSenderIssuerEncrypted =
+        getEncryptedBalance(*arg.account, issuerEncryptedBalance);
+    if (!prevSenderInbox || !prevSenderSpending || !prevSenderIssuer)
+        Throw("Failed to get Pre-send balance");
+
+    std::optional prevSenderAuditor;
+    auto const prevSenderAuditorEncrypted =
+        getEncryptedBalance(*arg.account, auditorEncryptedBalance);
+    if (arg.auditorEncryptedAmt || auditor_)
+    {
+        prevSenderAuditor = getDecryptedBalance(*arg.account, auditorEncryptedBalance);
+        if (!prevSenderAuditor)
+            Throw("Failed to get Pre-send balance");
+    }
+
+    // Destination's previous confidential state
+    auto const prevDestInbox = getDecryptedBalance(*arg.dest, holderEncryptedInbox);
+    auto const prevDestSpending = getDecryptedBalance(*arg.dest, holderEncryptedSpending);
+    auto const prevDestIssuer = getDecryptedBalance(*arg.dest, issuerEncryptedBalance);
+    auto const prevDestInboxEncrypted = getEncryptedBalance(*arg.dest, holderEncryptedInbox);
+    auto const prevDestSpendingEncrypted = getEncryptedBalance(*arg.dest, holderEncryptedSpending);
+    auto const prevDestIssuerEncrypted = getEncryptedBalance(*arg.dest, issuerEncryptedBalance);
+    if (!prevDestInbox || !prevDestSpending || !prevDestIssuer)
+        Throw("Failed to get Pre-send balance");
+
+    std::optional prevDestAuditor;
+    auto const prevDestAuditorEncrypted = getEncryptedBalance(*arg.dest, auditorEncryptedBalance);
+    if (arg.auditorEncryptedAmt || auditor_)
+    {
+        prevDestAuditor = getDecryptedBalance(*arg.dest, auditorEncryptedBalance);
+        if (!prevDestAuditor)
+            Throw("Failed to get Pre-send balance");
+    }
+
+    // Fill in the commitment if not provided
+    // The amount commitment must use the same blinding factor as the ElGamal
+    // encryption. The sigma proof links the two, so using different randomness
+    // for each would cause proof verification to fail.
+    Buffer amountCommitment, balanceCommitment;
+    if (arg.amountCommitment)
+    {
+        amountCommitment = *arg.amountCommitment;
+    }
+    else
+    {
+        amountCommitment = getPedersenCommitment(*arg.amt, blindingFactor);
+    }
+
+    jv[sfAmountCommitment] = strHex(amountCommitment);
+
+    auto const balanceBlindingFactor = generateBlindingFactor();
+    if (arg.balanceCommitment)
+    {
+        balanceCommitment = *arg.balanceCommitment;
+    }
+    else
+    {
+        balanceCommitment = getPedersenCommitment(*prevSenderSpending, balanceBlindingFactor);
+    }
+
+    jv[sfBalanceCommitment] = strHex(balanceCommitment);
+
+    // Fill in the proof if not provided
+    if (arg.proof)
+    {
+        jv[sfZKProof] = *arg.proof;
+    }
+    else
+    {
+        auto const version = getMPTokenVersion(*arg.account);
+        auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account));
+        auto const ctxHash = getSendContextHash(
+            requireValue(arg.account, "account").id(),
+            issuanceID(),
+            seq,
+            requireValue(arg.dest, "dest").id(),
+            version);
+
+        std::vector recipients;
+
+        auto const senderPubKey = getPubKey(*arg.account);
+        auto const destPubKey = getPubKey(*arg.dest);
+        auto const issuerPubKey = getPubKey(issuer_);
+
+        // If a key is missing, we skip adding the recipient. This intentionally
+        // causes proof generation to fail, triggering the dummy proof fallback.
+        if (senderPubKey)
+        {
+            recipients.push_back({
+                .publicKey = Slice(*senderPubKey),
+                .encryptedAmount = senderAmt,
+            });
+        }
+        if (destPubKey)
+        {
+            recipients.push_back({
+                .publicKey = Slice(*destPubKey),
+                .encryptedAmount = destAmt,
+            });
+        }
+        if (issuerPubKey)
+        {
+            recipients.push_back({
+                .publicKey = Slice(*issuerPubKey),
+                .encryptedAmount = issuerAmt,
+            });
+        }
+
+        std::optional auditorPubKey;
+        if (auditorAmt)
+        {
+            if (!auditor_)
+                Throw("Auditor not registered");
+
+            auditorPubKey = getPubKey(*auditor_);
+            if (auditorPubKey)
+            {
+                recipients.push_back({
+                    .publicKey = Slice(*auditorPubKey),
+                    .encryptedAmount = *auditorAmt,
+                });
+            }
+        }
+
+        std::optional proof;
+
+        // Skip proof generation if encrypted balance is missing (e.g.,
+        // feature disabled), when the sender and destination are the same
+        // (malformed case causing pcm to be zero), or when spending balance
+        // is 0
+        if (arg.account != arg.dest && prevSenderSpendingEncrypted && *prevSenderSpending > 0)
+        {
+            proof = getConfidentialSendProof(
+                *arg.account,
+                *arg.amt,
+                recipients,
+                blindingFactor,
+                ctxHash,
+                {
+                    .pedersenCommitment = amountCommitment,
+                    .amt = *arg.amt,
+                    .encryptedAmt = senderAmt,
+                    .blindingFactor = blindingFactor,
+                },
+                {
+                    .pedersenCommitment = balanceCommitment,
+                    .amt = *prevSenderSpending,
+                    .encryptedAmt = *prevSenderSpendingEncrypted,
+                    .blindingFactor = balanceBlindingFactor,
+                });
+        }
+
+        if (proof)
+        {
+            jv[sfZKProof.jsonName] = strHex(*proof);
+        }
+        else
+        {
+            jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSendProofLength));
+        }
+    }
+
+    auto const senderPubAmt = getBalance(*arg.account);
+    auto const destPubAmt = getBalance(*arg.dest);
+    auto const prevCOA = getIssuanceConfidentialBalance();
+    auto const prevOA = getIssuanceOutstandingBalance();
+
+    if (submit(arg, jv) == tesSUCCESS)
+    {
+        auto const postCOA = getIssuanceConfidentialBalance();
+        auto const postOA = getIssuanceOutstandingBalance();
+
+        // Sender's post confidential state
+        auto const postSenderInbox = getDecryptedBalance(*arg.account, holderEncryptedInbox);
+        auto const postSenderSpending = getDecryptedBalance(*arg.account, holderEncryptedSpending);
+        auto const postSenderIssuer = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
+
+        if (!postSenderInbox || !postSenderSpending || !postSenderIssuer)
+            Throw("Failed to get Post-send balance");
+
+        // Destination's post confidential state
+        auto const postDestInbox = getDecryptedBalance(*arg.dest, holderEncryptedInbox);
+        auto const postDestSpending = getDecryptedBalance(*arg.dest, holderEncryptedSpending);
+        auto const postDestIssuer = getDecryptedBalance(*arg.dest, issuerEncryptedBalance);
+
+        if (!postDestInbox || !postDestSpending || !postDestIssuer)
+            Throw("Failed to get Post-send balance");
+
+        // Public balances unchanged
+        env_.require(MptBalance(*this, *arg.account, senderPubAmt));
+        env_.require(MptBalance(*this, *arg.dest, destPubAmt));
+
+        // OA and COA unchanged
+        env_.require(RequireAny([&]() -> bool { return prevOA && postOA && *prevOA == *postOA; }));
+        env_.require(RequireAny([&]() -> bool { return prevCOA == postCOA; }));
+
+        // Verify sender changes
+        env_.require(RequireAny([&]() -> bool {
+            return *prevSenderSpending >= *arg.amt &&
+                *postSenderSpending == *prevSenderSpending - *arg.amt;
+        }));
+        env_.require(RequireAny([&]() -> bool { return postSenderInbox == prevSenderInbox; }));
+        env_.require(RequireAny([&]() -> bool {
+            return *prevSenderIssuer >= *arg.amt &&
+                *postSenderIssuer == *prevSenderIssuer - *arg.amt;
+        }));
+
+        // Verify destination changes
+        env_.require(
+            RequireAny([&]() -> bool { return *postDestInbox == *prevDestInbox + *arg.amt; }));
+        env_.require(RequireAny([&]() -> bool { return *postDestSpending == *prevDestSpending; }));
+        env_.require(
+            RequireAny([&]() -> bool { return *postDestIssuer == *prevDestIssuer + *arg.amt; }));
+
+        // Cross checks
+        env_.require(RequireAny(
+            [&]() -> bool { return *postSenderInbox + *postSenderSpending == *postSenderIssuer; }));
+        env_.require(RequireAny(
+            [&]() -> bool { return *postDestInbox + *postDestSpending == *postDestIssuer; }));
+
+        // Version: sender increments by 1; receiver version is unchanged by incoming sends
+        env_.require(RequireAny(
+            [&]() -> bool { return getMPTokenVersion(*arg.account) == prevSenderVersion + 1; }));
+        env_.require(
+            RequireAny([&]() -> bool { return getMPTokenVersion(*arg.dest) == prevDestVersion; }));
+
+        if (arg.auditorEncryptedAmt || auditor_)
+        {
+            auto const postSenderAuditor =
+                getDecryptedBalance(*arg.account, auditorEncryptedBalance);
+            auto const postDestAuditor = getDecryptedBalance(*arg.dest, auditorEncryptedBalance);
+            if (!postSenderAuditor || !postDestAuditor)
+                Throw("Failed to get Post-send balance");
+
+            env_.require(RequireAny([&]() -> bool {
+                return *postSenderAuditor == *postSenderIssuer &&
+                    *postDestAuditor == *postDestIssuer;
+            }));
+
+            // verify sender
+            env_.require(RequireAny([&]() -> bool {
+                return prevSenderAuditor >= *arg.amt &&
+                    *postSenderAuditor == *prevSenderAuditor - *arg.amt;
+            }));
+
+            // verify dest
+            env_.require(RequireAny(
+                [&]() -> bool { return *postDestAuditor == *prevDestAuditor + *arg.amt; }));
+        }
+    }
+}
+
+json::Value
+MPTTester::sendJV(
+    MPTConfidentialSend const& arg,
+    std::uint32_t seq,
+    std::optional chain)
+{
+    json::Value jv;
+    jv[jss::TransactionType] = jss::ConfidentialMPTSend;
+
+    if (arg.account)
+    {
+        jv[sfAccount] = arg.account->human();
+    }
+    else
+    {
+        Throw("Account not specified");
+    }
+
+    if (arg.dest)
+    {
+        jv[sfDestination] = arg.dest->human();
+    }
+    else
+    {
+        Throw("Destination not specified");
+    }
+
+    if (!arg.amt)
+        Throw("Amount not specified for testing purposes");
+
+    if (arg.id)
+    {
+        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
+    }
+    else
+    {
+        if (!id_)
+            Throw("MPT has not been created");
+        jv[sfMPTokenIssuanceID] = to_string(*id_);
+    }
+
+    Buffer const blindingFactor =
+        arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor();
+
+    auto const senderAmt = arg.senderEncryptedAmt
+        ? *arg.senderEncryptedAmt
+        : encryptAmount(*arg.account, *arg.amt, blindingFactor);
+    auto const destAmt = arg.destEncryptedAmt ? *arg.destEncryptedAmt
+                                              : encryptAmount(*arg.dest, *arg.amt, blindingFactor);
+    auto const issuerAmt = arg.issuerEncryptedAmt
+        ? *arg.issuerEncryptedAmt
+        : encryptAmount(issuer_, *arg.amt, blindingFactor);
+
+    std::optional auditorAmt;
+    if (arg.auditorEncryptedAmt)
+    {
+        auditorAmt = arg.auditorEncryptedAmt;
+    }
+    else if (auditor_.has_value() && arg.fillAuditorEncryptedAmt.value_or(false))
+    {
+        auditorAmt = encryptAmount(
+            requireValue(auditor_, "auditor"), requireValue(arg.amt, "amt"), blindingFactor);
+    }
+
+    jv[sfSenderEncryptedAmount] = strHex(senderAmt);
+    jv[sfDestinationEncryptedAmount] = strHex(destAmt);
+    jv[sfIssuerEncryptedAmount] = strHex(issuerAmt);
+    if (auditorAmt)
+        jv[sfAuditorEncryptedAmount] = strHex(*auditorAmt);
+
+    if (arg.credentials)
+    {
+        auto& arr(jv[sfCredentialIDs.jsonName] = json::ValueType::Array);
+        for (auto const& hash : *arg.credentials)
+            arr.append(hash);
+    }
+
+    std::uint64_t prevSenderSpending = 0;
+    std::optional prevEncryptedSenderSpending;
+    std::uint32_t version = 0;
+    if (chain)
+    {
+        prevSenderSpending = chain->spending;
+        prevEncryptedSenderSpending = chain->encSpending;
+        version = chain->version;
+    }
+    else
+    {
+        auto const ledgerSpending = getDecryptedBalance(*arg.account, holderEncryptedSpending);
+        if (!ledgerSpending)
+            Throw("Failed to get sender spending balance");
+        prevSenderSpending = *ledgerSpending;
+        prevEncryptedSenderSpending = getEncryptedBalance(*arg.account, holderEncryptedSpending);
+        version = getMPTokenVersion(*arg.account);
+    }
+
+    // The amount commitment must use the same blinding factor as the tx ElGamal
+    // encryption blinding factor.
+    Buffer amountCommitment, balanceCommitment;
+    if (arg.amountCommitment)
+    {
+        amountCommitment = *arg.amountCommitment;
+    }
+    else
+    {
+        amountCommitment = getPedersenCommitment(*arg.amt, blindingFactor);
+    }
+
+    jv[sfAmountCommitment] = strHex(amountCommitment);
+
+    auto const balanceBlindingFactor = generateBlindingFactor();
+    if (arg.balanceCommitment)
+    {
+        balanceCommitment = *arg.balanceCommitment;
+    }
+    else
+    {
+        balanceCommitment = getPedersenCommitment(prevSenderSpending, balanceBlindingFactor);
+    }
+
+    jv[sfBalanceCommitment] = strHex(balanceCommitment);
+
+    if (arg.proof)
+    {
+        jv[sfZKProof.jsonName] = *arg.proof;
+    }
+    else
+    {
+        auto const ctxHash = getSendContextHash(
+            requireValue(arg.account, "account").id(),
+            issuanceID(),
+            seq,
+            requireValue(arg.dest, "dest").id(),
+            version);
+
+        std::vector recipients;
+
+        auto const senderPubKey = getPubKey(*arg.account);
+        auto const destPubKey = getPubKey(*arg.dest);
+        auto const issuerPubKey = getPubKey(issuer_);
+
+        if (senderPubKey)
+        {
+            recipients.push_back({
+                .publicKey = Slice(*senderPubKey),
+                .encryptedAmount = senderAmt,
+            });
+        }
+        if (destPubKey)
+        {
+            recipients.push_back({
+                .publicKey = Slice(*destPubKey),
+                .encryptedAmount = destAmt,
+            });
+        }
+        if (issuerPubKey)
+        {
+            recipients.push_back({
+                .publicKey = Slice(*issuerPubKey),
+                .encryptedAmount = issuerAmt,
+            });
+        }
+
+        std::optional auditorPubKey;
+        if (auditorAmt)
+        {
+            if (!auditor_)
+                Throw("Auditor not registered");
+            auditorPubKey = getPubKey(*auditor_);
+            if (auditorPubKey)
+            {
+                recipients.push_back({
+                    .publicKey = Slice(*auditorPubKey),
+                    .encryptedAmount = *auditorAmt,
+                });
+            }
+        }
+
+        std::optional proof;
+
+        // Skip proof generation when spending balance is 0
+        if (arg.account != arg.dest && prevEncryptedSenderSpending && prevSenderSpending > 0)
+        {
+            proof = getConfidentialSendProof(
+                *arg.account,
+                *arg.amt,
+                recipients,
+                blindingFactor,
+                ctxHash,
+                {
+                    .pedersenCommitment = amountCommitment,
+                    .amt = *arg.amt,
+                    .encryptedAmt = senderAmt,
+                    .blindingFactor = blindingFactor,
+                },
+                {
+                    .pedersenCommitment = balanceCommitment,
+                    .amt = prevSenderSpending,
+                    .encryptedAmt = *prevEncryptedSenderSpending,
+                    .blindingFactor = balanceBlindingFactor,
+                });
+        }
+
+        if (proof)
+        {
+            jv[sfZKProof.jsonName] = strHex(*proof);
+        }
+        else
+        {
+            jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSendProofLength));
+        }
+    }
+
+    return jv;
+}
+
+static Buffer
+parseSenderEncAmt(json::Value const& jv)
+{
+    auto const hexStr = jv[sfSenderEncryptedAmount.jsonName].asString();
+    auto const bytes = strUnHex(hexStr);
+    if (!bytes)
+        Throw("chainAfterSend: invalid hex in sfSenderEncryptedAmount");
+    return Buffer(bytes->data(), bytes->size());
+}
+
+ConfidentialSendChainState
+MPTTester::chainAfterSend(Account const& sender, std::uint64_t sendAmt, json::Value const& jv) const
+{
+    auto const prevSpending = getDecryptedBalance(sender, holderEncryptedSpending);
+    auto const prevEncSpending = getEncryptedBalance(sender, holderEncryptedSpending);
+    auto const prevVersion = getMPTokenVersion(sender);
+
+    if (!prevSpending || !prevEncSpending)
+        Throw("chainAfterSend: failed to read sender state from ledger");
+
+    Buffer const senderEncAmt = parseSenderEncAmt(jv);
+    auto chain = computeNextSendChainState(
+        *prevSpending, Slice(*prevEncSpending), prevVersion, sendAmt, Slice(senderEncAmt));
+    if (!chain)
+        Throw("chainAfterSend: computeNextSendChainState failed");
+    return std::move(*chain);
+}
+
+std::optional
+computeNextSendChainState(
+    std::uint64_t currentSpending,
+    Slice const& currentEncSpending,
+    std::uint32_t currentVersion,
+    std::uint64_t sendAmt,
+    Slice const& senderEncAmt)
+{
+    if (sendAmt > currentSpending)
+        return std::nullopt;  // LCOV_EXCL_LINE
+
+    auto newEncSpending = homomorphicSubtract(currentEncSpending, senderEncAmt);
+    if (!newEncSpending)
+        return std::nullopt;  // LCOV_EXCL_LINE
+
+    return ConfidentialSendChainState{
+        .spending = currentSpending - sendAmt,
+        .encSpending = std::move(*newEncSpending),
+        .version = currentVersion + 1,
+    };
+}
+
+void
+MPTTester::confidentialClaw(MPTConfidentialClawback const& arg)
+{
+    json::Value jv;
+    auto const account = arg.account ? *arg.account : issuer_;
+    jv[sfAccount] = account.human();
+
+    if (arg.holder)
+    {
+        jv[sfHolder] = arg.holder->human();
+    }
+    else
+    {
+        Throw("Holder not specified");
+    }
+
+    jv[jss::TransactionType] = jss::ConfidentialMPTClawback;
+    if (arg.id)
+    {
+        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
+    }
+    else if (id_)
+    {
+        jv[sfMPTokenIssuanceID] = to_string(*id_);
+    }
+    else
+    {
+        Throw("MPT has not been created");
+    }
+
+    if (arg.amt)
+        jv[sfMPTAmount] = std::to_string(*arg.amt);
+
+    if (arg.proof)
+    {
+        jv[sfZKProof] = *arg.proof;
+    }
+    else
+    {
+        auto const seq = arg.ticketSeq ? *arg.ticketSeq : env_.seq(account);
+        auto const contextHash = getClawbackContextHash(
+            account.id(), issuanceID(), seq, requireValue(arg.holder, "holder").id());
+
+        auto const privKey = getPrivKey(account);
+        if (!privKey || privKey->size() != kEcPrivKeyLength)
+            Throw("Failed to get clawback private key");
+
+        auto const proof = getClawbackProof(
+            requireValue(arg.holder, "holder"),
+            requireValue(arg.amt, "amt"),
+            requireValue(privKey, "privKey"),
+            contextHash);
+
+        if (proof)
+        {
+            jv[sfZKProof] = strHex(*proof);
+        }
+        else
+        {
+            jv[sfZKProof] = strHex(gMakeZeroBuffer(kEcClawbackProofLength));
+        }
+    }
+
+    auto const holderPubAmt = getBalance(*arg.holder);
+    auto const prevCOA = getIssuanceConfidentialBalance();
+    auto const prevOA = getIssuanceOutstandingBalance();
+    auto const prevVersion = getMPTokenVersion(*arg.holder);
+
+    if (submit(arg, jv) == tesSUCCESS)
+    {
+        auto const postCOA = getIssuanceConfidentialBalance();
+        auto const postOA = getIssuanceOutstandingBalance();
+        auto const postVersion = getMPTokenVersion(*arg.holder);
+
+        // Verify holder's public balance is unchanged
+        env_.require(MptBalance(*this, *arg.holder, holderPubAmt));
+
+        // Verify COA and OA are reduced correctly
+        env_.require(RequireAny(
+            [&]() -> bool { return prevCOA >= *arg.amt && postCOA == prevCOA - *arg.amt; }));
+        env_.require(RequireAny([&]() -> bool {
+            return prevOA && postOA && *prevOA >= *arg.amt && *postOA == *prevOA - *arg.amt;
+        }));
+
+        // Verify holder's confidential balances are zeroed out
+        env_.require(RequireAny(
+            [&]() -> bool { return getDecryptedBalance(*arg.holder, holderEncryptedInbox) == 0; }));
+        env_.require(RequireAny([&]() -> bool {
+            return getDecryptedBalance(*arg.holder, holderEncryptedSpending) == 0;
+        }));
+        env_.require(RequireAny([&]() -> bool {
+            return getDecryptedBalance(*arg.holder, issuerEncryptedBalance) == 0;
+        }));
+        env_.require(RequireAny([&]() -> bool {
+            return getDecryptedBalance(*arg.holder, auditorEncryptedBalance) == 0;
+        }));
+
+        // Verify version is incremented
+        env_.require(RequireAny([&]() -> bool { return postVersion == prevVersion + 1; }));
+    }
+}
+
+void
+MPTTester::generateKeyPair(Account const& account)
+{
+    unsigned char privKey[kEcPrivKeyLength];
+    secp256k1_pubkey pubKey;
+    if (secp256k1_elgamal_generate_keypair(secp256k1Context(), privKey, &pubKey) == 0)
+        Throw("failed to generate key pair");
+
+    // Serialize public key to compressed format (33 bytes)
+    unsigned char compressedPubKey[kEcPubKeyLength];
+    size_t outLen = kEcPubKeyLength;
+    if (secp256k1_ec_pubkey_serialize(
+            secp256k1Context(), compressedPubKey, &outLen, &pubKey, SECP256K1_EC_COMPRESSED) != 1 ||
+        outLen != kEcPubKeyLength)
+    {
+        Throw("failed to serialize public key");
+    }
+
+    pubKeys_.insert({account.id(), Buffer{compressedPubKey, kEcPubKeyLength}});
+    privKeys_.insert({account.id(), Buffer{privKey, kEcPrivKeyLength}});
+}
+
+std::optional
+MPTTester::getPubKey(Account const& account) const
+{
+    if (auto const it = pubKeys_.find(account.id()); it != pubKeys_.end())
+        return it->second;
+
+    return std::nullopt;
+}
+
+std::optional
+MPTTester::getPrivKey(Account const& account) const
+{
+    if (auto const it = privKeys_.find(account.id()); it != privKeys_.end())
+        return it->second;
+
+    return std::nullopt;
+}
+
+Buffer
+MPTTester::encryptAmount(Account const& account, uint64_t const amt, Buffer const& blindingFactor)
+    const
+{
+    if (auto const pubKey = getPubKey(account))
+    {
+        if (auto const result = xrpl::encryptAmount(amt, *pubKey, blindingFactor))
+            return *result;
+    }
+
+    // Return a dummy buffer on failure to allow testing of
+    // failures that occur prior to encryption.
+    return gMakeZeroBuffer(kEcGamalEncryptedTotalLength);
+}
+
+std::optional
+MPTTester::decryptAmount(Account const& account, Buffer const& amt) const
+{
+    if (amt.size() != kEcGamalEncryptedTotalLength)
+        return std::nullopt;
+
+    auto const pair = makeEcPair(amt);
+    if (!pair)
+        return std::nullopt;
+
+    auto const privKey = getPrivKey(account);
+    if (!privKey || privKey->size() != kEcPrivKeyLength)
+        return std::nullopt;
+
+    uint64_t decryptedAmt = 0;
+    if (secp256k1_elgamal_decrypt(
+            secp256k1Context(),
+            &decryptedAmt,
+            &pair->c1,
+            &pair->c2,
+            privKey->data(),
+            kElGamalDecryptRangeLow,
+            kElGamalDecryptRangeHigh) == 0)
+    {
+        return std::nullopt;
+    }
+
+    return decryptedAmt;
+}
+
+std::optional
+MPTTester::getDecryptedBalance(Account const& account, EncryptedBalanceType balanceType) const
+{
+    auto const encryptedAmt = getEncryptedBalance(account, balanceType);
+
+    // Return zero to test cases like Feature Disabled, where the ledger object
+    // does not exist.
+    if (!encryptedAmt)
+        return 0;
+
+    Account decryptor = account;
+
+    if (balanceType == issuerEncryptedBalance)
+    {
+        decryptor = issuer_;
+    }
+    else if (balanceType == auditorEncryptedBalance)
+    {
+        if (!auditor_)
+            return std::nullopt;
+        decryptor = *auditor_;
+    }
+
+    return decryptAmount(decryptor, *encryptedAmt);
+};
+
+json::Value
+MPTTester::mergeInboxJV(MPTMergeInbox const& arg) const
+{
+    json::Value jv;
+    if (arg.account)
+    {
+        jv[sfAccount] = arg.account->human();
+    }
+    else
+    {
+        Throw("Account not specified");
+    }
+    if (arg.id)
+    {
+        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
+    }
+    else
+    {
+        if (!id_)
+            Throw("MPT has not been created");
+        jv[sfMPTokenIssuanceID] = to_string(*id_);
+    }
+    jv[sfTransactionType] = jss::ConfidentialMPTMergeInbox;
+    return jv;
+}
+
+void
+MPTTester::mergeInbox(MPTMergeInbox const& arg)
+{
+    json::Value jv;
+    if (arg.account)
+    {
+        jv[sfAccount] = arg.account->human();
+    }
+    else
+    {
+        Throw("Account not specified");
+    }
+    if (arg.id)
+    {
+        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
+    }
+    else
+    {
+        if (!id_)
+            Throw("MPT has not been created");
+        jv[sfMPTokenIssuanceID] = to_string(*id_);
+    }
+
+    jv[sfTransactionType] = jss::ConfidentialMPTMergeInbox;
+    auto const holderPubAmt = getBalance(*arg.account);
+    auto const prevCOA = getIssuanceConfidentialBalance();
+    auto const prevOA = getIssuanceOutstandingBalance();
+    auto const prevInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox);
+    auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending);
+    auto const prevIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
+    auto const prevIssuerEncrypted = getEncryptedBalance(*arg.account, issuerEncryptedBalance);
+    auto const prevAuditorEncrypted = getEncryptedBalance(*arg.account, auditorEncryptedBalance);
+    auto const prevVersion = getMPTokenVersion(*arg.account);
+
+    if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance)
+        Throw("Failed to get pre-mergeInbox balances");
+
+    if (submit(arg, jv) == tesSUCCESS)
+    {
+        auto const postCOA = getIssuanceConfidentialBalance();
+        auto const postOA = getIssuanceOutstandingBalance();
+        auto const postInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox);
+        auto const postSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending);
+        auto const postIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
+        auto const postInboxEncrypted = getEncryptedBalance(*arg.account, holderEncryptedInbox);
+        auto const postIssuerEncrypted = getEncryptedBalance(*arg.account, issuerEncryptedBalance);
+        auto const postAuditorEncrypted =
+            getEncryptedBalance(*arg.account, auditorEncryptedBalance);
+        auto const postVersion = getMPTokenVersion(*arg.account);
+
+        if (!postInboxBalance || !postSpendingBalance || !postIssuerBalance ||
+            !prevIssuerEncrypted || !postInboxEncrypted || !postIssuerEncrypted)
+            Throw("Failed to get post-mergeInbox balances");
+
+        env_.require(MptBalance(*this, *arg.account, holderPubAmt));
+        env_.require(RequireAny([&]() -> bool { return prevOA && postOA && *prevOA == *postOA; }));
+        env_.require(RequireAny([&]() -> bool { return prevCOA == postCOA; }));
+
+        env_.require(RequireAny([&]() -> bool {
+            return *postSpendingBalance == *prevInboxBalance + *prevSpendingBalance &&
+                *postInboxBalance == 0;
+        }));
+
+        env_.require(
+            RequireAny([&]() -> bool { return *prevIssuerBalance == *postIssuerBalance; }));
+
+        auto const holderPubKey = getPubKey(*arg.account);
+        if (!holderPubKey)
+            Throw("Failed to get holder public key");
+
+        auto const expectedInbox = encryptCanonicalZeroAmount(
+            requireValue(holderPubKey, "holderPubKey"),
+            requireValue(arg.account, "account").id(),
+            issuanceID());
+        if (!expectedInbox)
+            Throw("Failed to get canonical zero encryption");
+
+        env_.require(RequireAny([&]() -> bool { return *postInboxEncrypted == *expectedInbox; }));
+        env_.require(
+            RequireAny([&]() -> bool { return *postIssuerEncrypted == *prevIssuerEncrypted; }));
+        env_.require(RequireAny([&]() -> bool {
+            return postAuditorEncrypted.has_value() == prevAuditorEncrypted.has_value() &&
+                (!postAuditorEncrypted || *postAuditorEncrypted == *prevAuditorEncrypted);
+        }));
+        env_.require(RequireAny([&]() -> bool { return postVersion == prevVersion + 1; }));
+
+        env_.require(RequireAny([&]() -> bool {
+            return *postSpendingBalance + *postInboxBalance == *postIssuerBalance;
+        }));
+    }
+}
+
+std::optional
+MPTTester::getIssuanceOutstandingBalance() const
+{
+    if (!id_)
+        return std::nullopt;
+
+    auto const sle = env_.current()->read(keylet::mptokenIssuance(*id_));
+
+    if (!sle)
+        return std::nullopt;
+
+    return (*sle)[sfOutstandingAmount];
+}
+
+std::uint32_t
+MPTTester::getMPTokenVersion(Account const account) const
+{
+    if (!id_)
+        Throw("Issuance ID does not exist");
+
+    auto const sle = env_.current()->read(keylet::mptoken(*id_, account));
+
+    // return 0 here instead of throwing an exception since tests for
+    // preclaim will check if the MPToken exists
+    if (!sle)
+        return 0;
+
+    return (*sle)[~sfConfidentialBalanceVersion].value_or(0);
+}
+
+void
+MPTTester::convertBack(MPTConvertBack const& arg)
+{
+    json::Value jv;
+    if (arg.account)
+    {
+        jv[sfAccount] = arg.account->human();
+    }
+    else
+    {
+        Throw("Account not specified");
+    }
+
+    jv[jss::TransactionType] = jss::ConfidentialMPTConvertBack;
+    if (arg.id)
+    {
+        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
+    }
+    else
+    {
+        if (!id_)
+            Throw("MPT has not been created");
+        jv[sfMPTokenIssuanceID] = to_string(*id_);
+    }
+
+    if (arg.amt)
+        jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt);
+
+    Buffer holderCiphertext;
+    Buffer issuerCiphertext;
+    std::optional auditorCiphertext;
+    Buffer blindingFactor;
+
+    fillConversionCiphertexts(
+        arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor);
+
+    jv[sfBlindingFactor] = strHex(blindingFactor);
+
+    auto const prevInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox);
+    auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending);
+    auto const prevIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
+
+    if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance)
+        Throw("Failed to get Pre-convertBack balance");
+
+    Buffer pedersenCommitment;
+    Buffer const pcBlindingFactor = generateBlindingFactor();
+    if (arg.pedersenCommitment)
+    {
+        pedersenCommitment = *arg.pedersenCommitment;
+    }
+    else
+    {
+        pedersenCommitment = getPedersenCommitment(*prevSpendingBalance, pcBlindingFactor);
+    }
+
+    jv[sfBalanceCommitment] = strHex(pedersenCommitment);
+
+    if (arg.proof)
+    {
+        jv[sfZKProof.jsonName] = strHex(*arg.proof);
+    }
+    else
+    {
+        auto const version = getMPTokenVersion(*arg.account);
+
+        // if the caller generated ciphertexts themselves, they should also
+        // generate the proof themselves from the blinding factor
+        auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account));
+        auto const contextHash = getConvertBackContextHash(
+            requireValue(arg.account, "account").id(), issuanceID(), seq, version);
+        auto const prevEncryptedSpendingBalance =
+            getEncryptedBalance(*arg.account, holderEncryptedSpending);
+
+        Buffer proof;
+        // generate a dummy proof if no encrypted amount field, so that other
+        // preflight/preclaim are checked
+        if (!prevEncryptedSpendingBalance)
+        {
+            proof = gMakeZeroBuffer(kEcConvertBackProofLength);
+        }
+        else
+        {
+            proof = getConvertBackProof(
+                *arg.account,
+                requireValue(arg.amt, "amt"),
+                contextHash,
+                {
+                    .pedersenCommitment = pedersenCommitment,
+                    .amt = *prevSpendingBalance,
+                    .encryptedAmt = *prevEncryptedSpendingBalance,
+                    .blindingFactor = pcBlindingFactor,
+                });
+        }
+        jv[sfZKProof] = strHex(proof);
+    }
+
+    auto const holderAmt = getBalance(*arg.account);
+    auto const prevConfidentialOutstanding = getIssuanceConfidentialBalance();
+
+    std::optional prevAuditorBalance;
+    if (arg.auditorEncryptedAmt || auditor_)
+    {
+        prevAuditorBalance = getDecryptedBalance(*arg.account, auditorEncryptedBalance);
+        if (!prevAuditorBalance)
+            Throw("Failed to get Pre-convertBack balance");
+    }
+
+    auto const prevOutstanding = getIssuanceOutstandingBalance();
+    auto const prevVersion = getMPTokenVersion(*arg.account);
+
+    if (submit(arg, jv) == tesSUCCESS)
+    {
+        auto const postConfidentialOutstanding = getIssuanceConfidentialBalance();
+        auto const postOutstanding = getIssuanceOutstandingBalance();
+        auto const postVersion = getMPTokenVersion(*arg.account);
+        env_.require(MptBalance(
+            *this, requireValue(arg.account, "account"), holderAmt + requireValue(arg.amt, "amt")));
+        env_.require(RequireAny([&]() -> bool {
+            return prevOutstanding && postOutstanding && *prevOutstanding == *postOutstanding;
+        }));
+        env_.require(RequireAny([&]() -> bool {
+            return prevConfidentialOutstanding - *arg.amt == postConfidentialOutstanding;
+        }));
+
+        auto const postInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox);
+        auto const postIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
+        auto const postSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending);
+
+        if (!postInboxBalance || !postIssuerBalance || !postSpendingBalance)
+            Throw("Failed to get post-convertBack balance");
+
+        if (arg.auditorEncryptedAmt || auditor_)
+        {
+            auto const postAuditorBalance =
+                getDecryptedBalance(*arg.account, auditorEncryptedBalance);
+
+            if (!postAuditorBalance)
+                Throw("Failed to get post-convertBack balance");
+
+            // auditor's encrypted balance is updated correctly
+            env_.require(RequireAny(
+                [&]() -> bool { return *prevAuditorBalance - *arg.amt == *postAuditorBalance; }));
+        }
+
+        // inbox balance should not change
+        env_.require(RequireAny([&]() -> bool { return *postInboxBalance == *prevInboxBalance; }));
+
+        // issuer's encrypted balance is updated correctly
+        env_.require(RequireAny(
+            [&]() -> bool { return *prevIssuerBalance - *arg.amt == *postIssuerBalance; }));
+
+        // holder's spending balance is updated correctly
+        env_.require(RequireAny(
+            [&]() -> bool { return *prevSpendingBalance - *arg.amt == *postSpendingBalance; }));
+
+        // holder's confidential balance version is updated correctly
+        env_.require(RequireAny([&]() -> bool { return postVersion == prevVersion + 1; }));
+
+        // sum of holder's inbox and spending balance should equal to issuer's
+        // encrypted balance
+        env_.require(RequireAny([&]() -> bool {
+            return *postInboxBalance + *postSpendingBalance == *postIssuerBalance;
+        }));
+    }
+}
+
+json::Value
+MPTTester::convertBackJV(MPTConvertBack const& arg, std::uint32_t seq)
+{
+    json::Value jv;
+    if (arg.account)
+    {
+        jv[sfAccount] = arg.account->human();
+    }
+    else
+    {
+        Throw("Account not specified");
+    }
+
+    jv[jss::TransactionType] = jss::ConfidentialMPTConvertBack;
+    if (arg.id)
+    {
+        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
+    }
+    else
+    {
+        if (!id_)
+            Throw("MPT has not been created");
+        jv[sfMPTokenIssuanceID] = to_string(*id_);
+    }
+
+    if (arg.amt)
+        jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt);
+
+    Buffer holderCiphertext;
+    Buffer issuerCiphertext;
+    std::optional auditorCiphertext;
+    Buffer blindingFactor;
+
+    fillConversionCiphertexts(
+        arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor);
+
+    jv[sfBlindingFactor] = strHex(blindingFactor);
+
+    auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending);
+    if (!prevSpendingBalance)
+        Throw("convertBackJV: failed to read spending balance from ledger");
+
+    Buffer pedersenCommitment;
+    Buffer const pcBlindingFactor = generateBlindingFactor();
+    if (arg.pedersenCommitment)
+    {
+        pedersenCommitment = *arg.pedersenCommitment;
+    }
+    else
+    {
+        pedersenCommitment = getPedersenCommitment(*prevSpendingBalance, pcBlindingFactor);
+    }
+
+    jv[sfBalanceCommitment] = strHex(pedersenCommitment);
+
+    if (arg.proof)
+    {
+        jv[sfZKProof.jsonName] = strHex(*arg.proof);
+    }
+    else
+    {
+        auto const version = getMPTokenVersion(*arg.account);
+        auto const prevEncSpending = getEncryptedBalance(*arg.account, holderEncryptedSpending);
+        auto const contextHash = getConvertBackContextHash(
+            requireValue(arg.account, "account").id(), issuanceID(), seq, version);
+
+        Buffer proof;
+        if (!prevEncSpending)
+        {
+            proof = gMakeZeroBuffer(kEcConvertBackProofLength);
+        }
+        else
+        {
+            proof = getConvertBackProof(
+                *arg.account,
+                requireValue(arg.amt, "amt"),
+                contextHash,
+                {
+                    .pedersenCommitment = pedersenCommitment,
+                    .amt = *prevSpendingBalance,
+                    .encryptedAmt = *prevEncSpending,
+                    .blindingFactor = pcBlindingFactor,
+                });
+        }
+
+        jv[sfZKProof] = strHex(proof);
+    }
+
+    return jv;
+}
+
 }  // namespace xrpl::test::jtx
diff --git a/src/test/jtx/impl/owners.cpp b/src/test/jtx/impl/owners.cpp
index cb2cd5af29..2ff93757f0 100644
--- a/src/test/jtx/impl/owners.cpp
+++ b/src/test/jtx/impl/owners.cpp
@@ -10,8 +10,6 @@
 #include 
 
 #include 
-#include 
-
 namespace xrpl {
 namespace detail {
 
@@ -19,7 +17,7 @@ std::uint32_t
 ownedCountOf(ReadView const& view, AccountID const& id, LedgerEntryType type)
 {
     std::uint32_t count = 0;
-    forEachItem(view, id, [&count, type](std::shared_ptr const& sle) {
+    forEachItem(view, id, [&count, type](SLE::const_ref sle) {
         if (sle->getType() == type)
             ++count;
     });
diff --git a/src/test/jtx/impl/permissioned_dex.cpp b/src/test/jtx/impl/permissioned_dex.cpp
index 012932fed5..a6b24d7ac6 100644
--- a/src/test/jtx/impl/permissioned_dex.cpp
+++ b/src/test/jtx/impl/permissioned_dex.cpp
@@ -26,7 +26,7 @@ setupDomain(
     env.fund(XRP(100000), domainOwner);
     env.close();
 
-    pdomain::Credentials const credentials{{domainOwner, credType}};
+    pdomain::Credentials const credentials{{.issuer = domainOwner, .credType = credType}};
     env(pdomain::setTx(domainOwner, credentials));
 
     auto const objects = pdomain::getObjects(domainOwner, env);
@@ -48,7 +48,7 @@ PermissionedDEX::PermissionedDEX(Env& env)
     , alice("permdex-alice")
     , bob("permdex-bob")
     , carol("permdex-carol")
-    , USD(gw["USD"])
+    , usd(gw["USD"])
     , credType("permdex-abcde")
 {
     // Fund accounts
@@ -59,10 +59,10 @@ PermissionedDEX::PermissionedDEX(Env& env)
 
     for (auto const& account : {alice, bob, carol, domainOwner})
     {
-        env.trust(USD(1000), account);
+        env.trust(usd(1000), account);
         env.close();
 
-        env(pay(gw, account, USD(100)));
+        env(pay(gw, account, usd(100)));
         env.close();
     }
 }
diff --git a/src/test/jtx/impl/permissioned_domains.cpp b/src/test/jtx/impl/permissioned_domains.cpp
index 690451c7d8..385008be43 100644
--- a/src/test/jtx/impl/permissioned_domains.cpp
+++ b/src/test/jtx/impl/permissioned_domains.cpp
@@ -130,7 +130,9 @@ credentialsFromJson(
         auto const& credentialType = obj["CredentialType"];
         // NOLINTNEXTLINE(bugprone-unchecked-optional-access): used only in tests
         auto blob = strUnHex(credentialType.asString()).value();
-        ret.push_back({human2Acc.at(issuer.asString()), std::string(blob.begin(), blob.end())});
+        ret.push_back(
+            {.issuer = human2Acc.at(issuer.asString()),
+             .credType = std::string(blob.begin(), blob.end())});
     }
     return ret;
 }
diff --git a/src/test/jtx/impl/utility.cpp b/src/test/jtx/impl/utility.cpp
index 9256242417..c298cee684 100644
--- a/src/test/jtx/impl/utility.cpp
+++ b/src/test/jtx/impl/utility.cpp
@@ -13,6 +13,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -57,7 +58,22 @@ fillFee(json::Value& jv, ReadView const& view)
 {
     if (jv.isMember(jss::Fee))
         return;
-    jv[jss::Fee] = to_string(view.fees().base);
+
+    auto const base = view.fees().base;
+
+    // For confidential transactions, the fee is higher because confidential
+    // transaction processing is more expensive.
+    auto const txType = jv[jss::TransactionType].asString();
+    if (txType == jss::ConfidentialMPTConvert || txType == jss::ConfidentialMPTConvertBack ||
+        txType == jss::ConfidentialMPTSend || txType == jss::ConfidentialMPTMergeInbox ||
+        txType == jss::ConfidentialMPTClawback)
+    {
+        jv[jss::Fee] = to_string(base * (kConfidentialFeeMultiplier + 1));
+    }
+    else
+    {
+        jv[jss::Fee] = to_string(base);
+    }
 }
 
 void
@@ -100,5 +116,4 @@ cmdToJSONRPC(std::vector const& args, beast::Journal j, unsigned in
         jv[jss::id] = paramsObj[jss::id];
     return jv;
 }
-
 }  // namespace xrpl::test::jtx
diff --git a/src/test/jtx/impl/xchain_bridge.cpp b/src/test/jtx/impl/xchain_bridge.cpp
index a88535a51e..24669e6d4f 100644
--- a/src/test/jtx/impl/xchain_bridge.cpp
+++ b/src/test/jtx/impl/xchain_bridge.cpp
@@ -396,7 +396,7 @@ XChainBridgeObjects::XChainBridgeObjects()
         }
         return result;
     }())
-    , alt_signers([] {
+    , altSigners([] {
         static constexpr int kNumSigners = kUtXchainDefaultNumSigners;
         std::vector result;
         result.reserve(kNumSigners);
@@ -431,17 +431,16 @@ XChainBridgeObjects::XChainBridgeObjects()
         return r;
     }())
     , reward(XRP(1))
-    , split_reward_quorum(divide(reward, STAmount(kUtXchainDefaultQuorum), reward.get()))
-    , split_reward_everyone(
-          divide(reward, STAmount(kUtXchainDefaultNumSigners), reward.get()))
-    , tiny_reward(drops(37))
-    , tiny_reward_split(
-          (divide(tiny_reward, STAmount(kUtXchainDefaultQuorum), tiny_reward.get())))
-    , tiny_reward_remainder(
-          tiny_reward -
-          multiply(tiny_reward_split, STAmount(kUtXchainDefaultQuorum), tiny_reward.get()))
-    , one_xrp(XRP(1))
-    , xrp_dust(divide(one_xrp, STAmount(10000), one_xrp.get()))
+    , splitRewardQuorum(divide(reward, STAmount(kUtXchainDefaultQuorum), reward.get()))
+    , splitRewardEveryone(divide(reward, STAmount(kUtXchainDefaultNumSigners), reward.get()))
+    , tinyReward(drops(37))
+    , tinyRewardSplit(
+          (divide(tinyReward, STAmount(kUtXchainDefaultQuorum), tinyReward.get())))
+    , tinyRewardRemainder(
+          tinyReward -
+          multiply(tinyRewardSplit, STAmount(kUtXchainDefaultQuorum), tinyReward.get()))
+    , oneXrp(XRP(1))
+    , xrpDust(divide(oneXrp, STAmount(10000), oneXrp.get()))
 {
 }
 
diff --git a/src/test/jtx/ledgerStateFix.h b/src/test/jtx/ledgerStateFix.h
index 769a28fb08..71f3f76101 100644
--- a/src/test/jtx/ledgerStateFix.h
+++ b/src/test/jtx/ledgerStateFix.h
@@ -10,4 +10,8 @@ namespace xrpl::test::jtx::ledgerStateFix {
 json::Value
 nftPageLinks(jtx::Account const& acct, jtx::Account const& owner);
 
+/** Repair sfExchangeRate on a book directory's first page. */
+json::Value
+bookExchangeRate(jtx::Account const& acct, uint256 const& bookDir);
+
 }  // namespace xrpl::test::jtx::ledgerStateFix
diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h
index c8a65d7541..d5fba82e08 100644
--- a/src/test/jtx/mpt.h
+++ b/src/test/jtx/mpt.h
@@ -2,12 +2,21 @@
 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
 #include 
 
+#include 
 #include 
 #include 
+#include 
+
+#include 
+#include 
+#include 
 
 namespace xrpl::test::jtx {
 
@@ -15,7 +24,28 @@ class MPTTester;
 
 auto const kMptDexFlags = tfMPTCanTrade | tfMPTCanTransfer;
 
-// Check flags settings on MPT create
+/**
+ * @brief Create a zero-initialized buffer for malformed cryptography test
+ * inputs.
+ *
+ * xrpl::Buffer(size) allocates uninitialized heap memory. Because CI runs unit
+ * tests sequentially in the same process, uninitialized memory can recycle
+ * valid secp256k1 keys or Pedersen commitments from earlier tests. Explicitly
+ * zeroing the buffer guarantees structural validation fails deterministically.
+ *
+ * @param size The number of zero bytes to allocate.
+ * @return A buffer containing size zero bytes.
+ */
+[[nodiscard]] inline Buffer
+gMakeZeroBuffer(std::size_t size)
+{
+    Buffer b(size);
+    if (size > 0)
+        std::memset(b.data(), 0, size);
+    return b;
+}
+
+/** @brief Test helper that checks MPT flag settings after creation. */
 class MptFlags
 {
 private:
@@ -36,7 +66,7 @@ public:
     operator()(Env& env) const;
 };
 
-// Check mptissuance or mptoken amount balances on payment
+/** @brief Test helper that checks MPT issuance or holder balances. */
 class MptBalance
 {
 private:
@@ -54,6 +84,7 @@ public:
     operator()(Env& env) const;
 };
 
+/** @brief Test helper that accepts any condition supplied by a callback. */
 class RequireAny
 {
 private:
@@ -70,6 +101,7 @@ public:
 
 using Holders = std::vector;
 
+/** @brief Arguments for building an MPTokenIssuanceCreate test transaction. */
 struct MPTCreate
 {
     static inline std::vector allHolders = {};
@@ -93,9 +125,13 @@ struct MPTCreate
     std::optional err = std::nullopt;
 };
 
+/** @brief Arguments for initializing funded MPT test accounts and issuance. */
 struct MPTInit
 {
+    // Default-initialized so designated-initializer call sites that omit
+    // `holders` don't trip GCC's -Werror=missing-field-initializers.
     Holders holders = {};  // NOLINT(readability-redundant-member-init)
+    std::optional auditor = std::nullopt;
     PrettyAmount const xrp = XRP(10'000);
     PrettyAmount const xrpHolders = XRP(10'000);
     bool fund = true;
@@ -105,11 +141,13 @@ struct MPTInit
 };
 static MPTInit const kMptInitNoFund{.fund = false};
 
+/** @brief Full constructor arguments for MPTTester initialization. */
 struct MPTInitDef
 {
     Env& env;
     Account issuer;
     Holders holders = {};  // NOLINT(readability-redundant-member-init)
+    std::optional auditor = std::nullopt;
     std::uint16_t transferFee = 0;
     std::optional pay = std::nullopt;
     std::uint32_t flags = kMptDexFlags;
@@ -121,6 +159,7 @@ struct MPTInitDef
     std::optional err = std::nullopt;
 };
 
+/** @brief Arguments for building an MPTokenIssuanceDestroy test transaction. */
 struct MPTDestroy
 {
     std::optional issuer = std::nullopt;
@@ -131,6 +170,7 @@ struct MPTDestroy
     std::optional err = std::nullopt;
 };
 
+/** @brief Arguments for building an MPTokenAuthorize test transaction. */
 struct MPTAuthorize
 {
     std::optional account = std::nullopt;
@@ -142,6 +182,7 @@ struct MPTAuthorize
     std::optional err = std::nullopt;
 };
 
+/** @brief Arguments for building an MPTokenIssuanceSet test transaction. */
 struct MPTSet
 {
     std::optional account = std::nullopt;
@@ -155,18 +196,215 @@ struct MPTSet
     std::optional metadata = std::nullopt;
     std::optional delegate = std::nullopt;
     std::optional domainID = std::nullopt;
+    std::optional issuerPubKey = std::nullopt;
+    std::optional auditorPubKey = std::nullopt;
+    std::optional ticketSeq = std::nullopt;
     std::optional err = std::nullopt;
 };
 
+/** @brief Arguments for building a ConfidentialMPTConvert test transaction. */
+struct MPTConvert
+{
+    std::optional account = std::nullopt;
+    std::optional id = std::nullopt;
+    std::optional amt = std::nullopt;
+    std::optional proof = std::nullopt;
+    std::optional fillAuditorEncryptedAmt = true;
+    // indicates whether to autofill schnorr proof.
+    // default : auto generate proof if holderPubKey is present.
+    // true: force proof generation.
+    // false: force proof omission.
+    std::optional fillSchnorrProof = std::nullopt;
+    std::optional holderPubKey = std::nullopt;
+    std::optional holderEncryptedAmt = std::nullopt;
+    std::optional issuerEncryptedAmt = std::nullopt;
+    std::optional auditorEncryptedAmt = std::nullopt;
+
+    std::optional blindingFactor = std::nullopt;
+    std::optional delegate = std::nullopt;
+    std::optional ticketSeq = std::nullopt;
+    std::optional ownerCount = std::nullopt;
+    std::optional holderCount = std::nullopt;
+    std::optional flags = std::nullopt;
+    std::optional fee = std::nullopt;
+    std::optional err = std::nullopt;
+};
+
+/** @brief Arguments for building a ConfidentialMPTMergeInbox test transaction. */
+struct MPTMergeInbox
+{
+    std::optional account = std::nullopt;
+    std::optional id = std::nullopt;
+    std::optional delegate = std::nullopt;
+    std::optional ticketSeq = std::nullopt;
+    std::optional ownerCount = std::nullopt;
+    std::optional holderCount = std::nullopt;
+    std::optional flags = std::nullopt;
+    std::optional fee = std::nullopt;
+    std::optional err = std::nullopt;
+};
+
+/** @brief Arguments for building a ConfidentialMPTSend test transaction. */
+struct MPTConfidentialSend
+{
+    std::optional account = std::nullopt;
+    std::optional dest = std::nullopt;
+    std::optional id = std::nullopt;
+    // amt is to generate encrypted amounts for testing purposes
+    std::optional amt = std::nullopt;
+    std::optional proof = std::nullopt;
+    std::optional senderEncryptedAmt = std::nullopt;
+    std::optional destEncryptedAmt = std::nullopt;
+    std::optional issuerEncryptedAmt = std::nullopt;
+    std::optional auditorEncryptedAmt = std::nullopt;
+    std::optional fillAuditorEncryptedAmt = true;
+    std::optional> credentials = std::nullopt;
+    // not an txn param, only used for autofilling
+    std::optional blindingFactor = std::nullopt;
+    std::optional amountCommitment = std::nullopt;
+    std::optional balanceCommitment = std::nullopt;
+    std::optional delegate = std::nullopt;
+    std::optional destinationTag = std::nullopt;
+    std::optional ticketSeq = std::nullopt;
+    std::optional ownerCount = std::nullopt;
+    std::optional holderCount = std::nullopt;
+    std::optional flags = std::nullopt;
+    std::optional fee = std::nullopt;
+    std::optional err = std::nullopt;
+};
+
+/** @brief Arguments for building a ConfidentialMPTConvertBack test transaction. */
+struct MPTConvertBack
+{
+    std::optional account = std::nullopt;
+    std::optional id = std::nullopt;
+    std::optional amt = std::nullopt;
+    std::optional proof = std::nullopt;
+    std::optional holderEncryptedAmt = std::nullopt;
+    std::optional issuerEncryptedAmt = std::nullopt;
+    std::optional auditorEncryptedAmt = std::nullopt;
+    std::optional fillAuditorEncryptedAmt = true;
+    // not an txn param, only used for autofilling
+    std::optional blindingFactor = std::nullopt;
+    std::optional pedersenCommitment = std::nullopt;
+    std::optional delegate = std::nullopt;
+    std::optional ticketSeq = std::nullopt;
+    std::optional ownerCount = std::nullopt;
+    std::optional holderCount = std::nullopt;
+    std::optional flags = std::nullopt;
+    std::optional fee = std::nullopt;
+    std::optional err = std::nullopt;
+};
+
+/** @brief Arguments for building a ConfidentialMPTClawback test transaction. */
+struct MPTConfidentialClawback
+{
+    std::optional account = std::nullopt;
+    std::optional holder = std::nullopt;
+    std::optional id = std::nullopt;
+    std::optional amt = std::nullopt;
+    std::optional proof = std::nullopt;
+    std::optional delegate = std::nullopt;
+    std::optional ticketSeq = std::nullopt;
+    std::optional ownerCount = std::nullopt;
+    std::optional holderCount = std::nullopt;
+    std::optional flags = std::nullopt;
+    std::optional fee = std::nullopt;
+    std::optional err = std::nullopt;
+};
+
+/**
+ * @brief Stores the parameters that are exclusively used to generate a
+ * Pedersen linkage proof.
+ */
+struct PedersenProofParams
+{
+    /** @brief The Pedersen commitment used by the proof. */
+    Buffer const pedersenCommitment;
+
+    /** @brief Either the spending balance or the value being transferred. */
+    uint64_t const amt;
+
+    /** @brief The encrypted amount linked to the Pedersen commitment. */
+    Buffer const encryptedAmt;
+
+    /** @brief The blinding factor used to create the Pedersen commitment. */
+    Buffer const blindingFactor;
+};
+
+/**
+ * @brief When building multiple confidential sends from the same account inside a
+ * single batch transaction, pass this state to the transaction builder for
+ * each subsequent send so that its proof references the post previous-send
+ * encrypted balance rather than the stale pre-send ledger state.
+ *
+ * The fields mirror what the ledger will contain after the preceding send's
+ * doApply() completes:
+ *   encSpending = homomorphicSubtract(prevEncSpending, senderEncAmt)
+ *   version     = prevVersion + 1
+ */
+struct ConfidentialSendChainState
+{
+    /** @brief Decrypted spending balance after the previous send. */
+    std::uint64_t spending;
+
+    /** @brief Encrypted spending balance after the previous send. */
+    Buffer encSpending;
+
+    /** @brief sfConfidentialBalanceVersion after the previous send. */
+    std::uint32_t version;
+};
+
+/**
+ * @brief Use this when building a second (or later) confidential send from the same
+ * account in the same batch. Pass the state to the chain aware
+ * transaction builder so that the next proof is constructed against the
+ * correct post-send encrypted balance and version.
+ *
+ * @param currentSpending     Decrypted spending balance before the send.
+ * @param currentEncSpending  sfConfidentialBalanceSpending before the send.
+ * @param currentVersion      sfConfidentialBalanceVersion before the send.
+ * @param sendAmt             Plaintext amount being sent.
+ * @param senderEncAmt        sfSenderEncryptedAmount from the send transaction.
+ * @return The predicted chain state, or std::nullopt if homomorphic
+ *         subtraction fails.
+ */
+std::optional
+computeNextSendChainState(
+    std::uint64_t currentSpending,
+    Slice const& currentEncSpending,
+    std::uint32_t currentVersion,
+    std::uint64_t sendAmt,
+    Slice const& senderEncAmt);
+
+/**
+ * @brief Test helper for creating, mutating, and asserting MPT and confidential
+ * MPT ledger state.
+ */
 class MPTTester
 {
     Env& env_;
     Account const issuer_;
     std::unordered_map const holders_;
+    std::optional const auditor_;
     std::optional id_;
     bool close_;
+    std::unordered_map pubKeys_;
+    std::unordered_map privKeys_;
 
 public:
+    enum class EncryptedBalanceType {
+        IssuerEncryptedBalance,
+        HolderEncryptedInbox,
+        HolderEncryptedSpending,
+        AuditorEncryptedBalance,
+    };
+
+    static constexpr auto issuerEncryptedBalance = EncryptedBalanceType::IssuerEncryptedBalance;
+    static constexpr auto holderEncryptedInbox = EncryptedBalanceType::HolderEncryptedInbox;
+    static constexpr auto holderEncryptedSpending = EncryptedBalanceType::HolderEncryptedSpending;
+    static constexpr auto auditorEncryptedBalance = EncryptedBalanceType::AuditorEncryptedBalance;
+
     MPTTester(Env& env, Account issuer, MPTInit const& constr = {});
     MPTTester(MPTInitDef const& constr);
     MPTTester(
@@ -204,6 +442,83 @@ public:
     static json::Value
     setJV(MPTSet const& set = {});
 
+    void
+    convert(MPTConvert const& arg = MPTConvert{});
+
+    /**
+     * @brief Build a confidential convert JV without submitting it.
+     *
+     * @param arg Transaction builder arguments.
+     * @param seq Inner transaction sequence used in the Schnorr proof context
+     *            hash.
+     * @return The transaction JSON object.
+     */
+    json::Value
+    convertJV(MPTConvert const& arg, std::uint32_t seq);
+
+    void
+    mergeInbox(MPTMergeInbox const& arg = MPTMergeInbox{});
+
+    [[nodiscard]] json::Value
+    mergeInboxJV(MPTMergeInbox const& arg = MPTMergeInbox{}) const;
+
+    void
+    send(MPTConfidentialSend const& arg = MPTConfidentialSend{});
+
+    /**
+     * @brief Build a confidential send JV.
+     *
+     * When chain is provided, the sender's proof parameters are taken from it
+     * instead of the ledger, enabling proof generation for a second or later
+     * send from the same account inside a single batch.
+     *
+     * @param arg Transaction builder arguments.
+     * @param seq Inner transaction sequence used in the proof context hash.
+     * @param chain Optional predicted sender state from a previous batched
+     *              send.
+     * @return The transaction JSON object.
+     */
+    json::Value
+    sendJV(
+        MPTConfidentialSend const& arg,
+        std::uint32_t seq,
+        std::optional chain = std::nullopt);
+
+    /**
+     * @brief Compute the projected sender state after a confidential send in a
+     * batch.
+     *
+     * Each confidential send requires a ZK proof that the sender's spending
+     * balance covers the transfer. In a batch, the second and later sends from
+     * the same sender need proofs built against the updated spending balance.
+     *
+     * @param sender The sender whose post-send state is being predicted.
+     * @param sendAmt The plaintext amount sent by the transaction.
+     * @param jv The confidential send transaction JSON object.
+     * @return The predicted sender state after applying the send.
+     */
+    [[nodiscard]] ConfidentialSendChainState
+    chainAfterSend(Account const& sender, std::uint64_t sendAmt, json::Value const& jv) const;
+
+    void
+    convertBack(MPTConvertBack const& arg = MPTConvertBack{});
+
+    /**
+     * @brief Build a confidential convertBack JV without submitting it.
+     *
+     * Reads the current encrypted spending balance and version from the ledger,
+     * so call this before the batch is submitted.
+     *
+     * @param arg Transaction builder arguments.
+     * @param seq Inner transaction sequence used in the proof context hash.
+     * @return The transaction JSON object.
+     */
+    json::Value
+    convertBackJV(MPTConvertBack const& arg, std::uint32_t seq);
+
+    void
+    confidentialClaw(MPTConfidentialClawback const& arg = MPTConfidentialClawback{});
+
     [[nodiscard]] bool
     checkDomainID(std::optional expected) const;
 
@@ -213,6 +528,9 @@ public:
     [[nodiscard]] bool
     checkMPTokenOutstandingAmount(std::int64_t expectedAmount) const;
 
+    [[nodiscard]] bool
+    checkIssuanceConfidentialBalance(std::int64_t expectedAmount) const;
+
     [[nodiscard]] bool
     checkFlags(uint32_t const expectedFlags, std::optional const& holder = std::nullopt)
         const;
@@ -265,6 +583,13 @@ public:
     [[nodiscard]] std::int64_t
     getBalance(Account const& account) const;
 
+    [[nodiscard]] std::int64_t
+    getIssuanceConfidentialBalance() const;
+
+    [[nodiscard]] std::optional
+    getEncryptedBalance(Account const& account, EncryptedBalanceType option = holderEncryptedInbox)
+        const;
+
     MPT
     operator[](std::string const& name) const;
 
@@ -273,6 +598,60 @@ public:
 
     operator Asset() const;
 
+    void
+    generateKeyPair(Account const& account);
+
+    [[nodiscard]] std::optional
+    getPubKey(Account const& account) const;
+
+    [[nodiscard]] std::optional
+    getPrivKey(Account const& account) const;
+
+    [[nodiscard]] Buffer
+    encryptAmount(Account const& account, uint64_t const amt, Buffer const& blindingFactor) const;
+
+    [[nodiscard]] std::optional
+    decryptAmount(Account const& account, Buffer const& amt) const;
+
+    [[nodiscard]] std::optional
+    getDecryptedBalance(Account const& account, EncryptedBalanceType balanceType) const;
+
+    [[nodiscard]] std::optional
+    getIssuanceOutstandingBalance() const;
+
+    [[nodiscard]] std::optional
+    getClawbackProof(
+        Account const& holder,
+        std::uint64_t amount,
+        Buffer const& privateKey,
+        uint256 const& txHash) const;
+
+    [[nodiscard]] std::optional
+    getSchnorrProof(Account const& account, uint256 const& ctxHash) const;
+
+    [[nodiscard]] std::optional
+    getConfidentialSendProof(
+        Account const& sender,
+        std::uint64_t const amount,
+        std::vector const& recipients,
+        Slice const& blindingFactor,
+        uint256 const& contextHash,
+        PedersenProofParams const& amountParams,
+        PedersenProofParams const& balanceParams) const;
+
+    [[nodiscard]] Buffer
+    getConvertBackProof(
+        Account const& holder,
+        std::uint64_t const amount,
+        uint256 const& contextHash,
+        PedersenProofParams const& pcParams) const;
+
+    [[nodiscard]] std::uint32_t
+    getMPTokenVersion(Account const account) const;
+
+    static Buffer
+    getPedersenCommitment(std::uint64_t const amount, Buffer const& pedersenBlindingFactor);
+
     friend BookSpec
     operator~(MPTTester const& mpt)
     {
@@ -288,9 +667,54 @@ private:
 
     template 
     TER
-    submit(A const& arg, json::Value const& jv)
+    submit(A const& arg, json::Value jv)
     {
-        env_(jv, Txflags(arg.flags.value_or(0)), Ter(arg.err.value_or(tesSUCCESS)));
+        auto const expectedFlags = Txflags(arg.flags.value_or(0));
+        auto const expectedTer = Ter(arg.err.value_or(tesSUCCESS));
+
+        if constexpr (requires { arg.fee; })
+        {
+            if (arg.fee)
+                jv[jss::Fee] = to_string(*arg.fee);
+        }
+
+        std::optional ticketSeq;
+        if constexpr (requires { arg.ticketSeq; })
+            ticketSeq = arg.ticketSeq;
+
+        std::optional delegateAcct;
+        if constexpr (requires { arg.delegate; })
+            delegateAcct = arg.delegate;
+
+        std::optional dstTag;
+        if constexpr (requires { arg.destinationTag; })
+            dstTag = arg.destinationTag;
+
+        if (ticketSeq && delegateAcct)
+        {
+            env_(
+                jv,
+                expectedFlags,
+                expectedTer,
+                ticket::Use(*ticketSeq),
+                delegate::As(*delegateAcct));
+        }
+        else if (ticketSeq)
+        {
+            env_(jv, expectedFlags, expectedTer, ticket::Use(*ticketSeq));
+        }
+        else if (delegateAcct)
+        {
+            env_(jv, expectedFlags, expectedTer, delegate::As(*delegateAcct));
+        }
+        else if (dstTag)
+        {
+            env_(jv, expectedFlags, expectedTer, Dtag(*dstTag));
+        }
+        else
+        {
+            env_(jv, expectedFlags, expectedTer);
+        }
         auto const err = env_.ter();
         if (close_)
             env_.close();
@@ -309,6 +733,16 @@ private:
 
     [[nodiscard]] std::uint32_t
     getFlags(std::optional const& holder) const;
+
+    template 
+    void
+    fillConversionCiphertexts(
+        T const& arg,
+        json::Value& jv,
+        Buffer& holderCiphertext,
+        Buffer& issuerCiphertext,
+        std::optional& auditorCiphertext,
+        Buffer& blindingFactor) const;
 };
 
 }  // namespace xrpl::test::jtx
diff --git a/src/test/jtx/permissioned_dex.h b/src/test/jtx/permissioned_dex.h
index 025097116f..b0a4d4e2d5 100644
--- a/src/test/jtx/permissioned_dex.h
+++ b/src/test/jtx/permissioned_dex.h
@@ -20,7 +20,7 @@ public:
     Account alice;
     Account bob;
     Account carol;
-    IOU USD;
+    IOU usd;
     uint256 domainID;
     std::string credType;
 
diff --git a/src/test/jtx/utility.h b/src/test/jtx/utility.h
index 535e14cf1d..c3ed91f672 100644
--- a/src/test/jtx/utility.h
+++ b/src/test/jtx/utility.h
@@ -7,6 +7,7 @@
 #include 
 
 #include 
+#include 
 
 namespace xrpl::test::jtx {
 
@@ -50,5 +51,4 @@ fillSeq(json::Value& jv, ReadView const& view);
 /** Given an xrpld unit test rpc command, return the corresponding JSON. */
 json::Value
 cmdToJSONRPC(std::vector const& args, beast::Journal j, unsigned int apiVersion);
-
 }  // namespace xrpl::test::jtx
diff --git a/src/test/jtx/xchain_bridge.h b/src/test/jtx/xchain_bridge.h
index c20b6f2fc5..e4c012510d 100644
--- a/src/test/jtx/xchain_bridge.h
+++ b/src/test/jtx/xchain_bridge.h
@@ -165,21 +165,21 @@ struct XChainBridgeObjects
 
     FeatureBitset const features;
     std::vector const signers;
-    std::vector const alt_signers;
+    std::vector const altSigners;
     std::vector const payee;
     std::vector const payees;
     std::uint32_t const quorum{kUtXchainDefaultQuorum};
 
-    STAmount const reward;                 // 1 xrp
-    STAmount const split_reward_quorum;    // 250,000 drops
-    STAmount const split_reward_everyone;  // 200,000 drops
+    STAmount const reward;               // 1 xrp
+    STAmount const splitRewardQuorum;    // 250,000 drops
+    STAmount const splitRewardEveryone;  // 200,000 drops
 
-    STAmount const tiny_reward;            // 37 drops
-    STAmount const tiny_reward_split;      // 9 drops
-    STAmount const tiny_reward_remainder;  // 1 drops
+    STAmount const tinyReward;           // 37 drops
+    STAmount const tinyRewardSplit;      // 9 drops
+    STAmount const tinyRewardRemainder;  // 1 drops
 
-    STAmount const one_xrp;
-    STAmount const xrp_dust;
+    STAmount const oneXrp;
+    STAmount const xrpDust;
 
     static constexpr int kDropPerXrp = 1000000;
 
diff --git a/src/test/ledger/SkipList_test.cpp b/src/test/ledger/SkipList_test.cpp
index fe671ed128..fd3cc2f214 100644
--- a/src/test/ledger/SkipList_test.cpp
+++ b/src/test/ledger/SkipList_test.cpp
@@ -25,7 +25,7 @@ class SkipList_test : public beast::unit_test::Suite
             auto prev = std::make_shared(
                 kCreateGenesis,
                 Rules{config.features},
-                config.FEES.toFees(),
+                config.fees.toFees(),
                 std::vector{},
                 env.app().getNodeFamily());
             history.push_back(prev);
diff --git a/src/test/ledger/View_test.cpp b/src/test/ledger/View_test.cpp
index 1714702c18..b62061d38d 100644
--- a/src/test/ledger/View_test.cpp
+++ b/src/test/ledger/View_test.cpp
@@ -61,7 +61,7 @@ class View_test : public beast::unit_test::Suite
     }
 
     // Create SLE with key and payload
-    static std::shared_ptr
+    static SLE::pointer
     sle(std::uint64_t id, std::uint32_t seq = 1)
     {
         auto const le = std::make_shared(k(id));
@@ -79,7 +79,7 @@ class View_test : public beast::unit_test::Suite
 
     // Set payload on SLE
     static void
-    seq(std::shared_ptr const& le, std::uint32_t seq)
+    seq(SLE::ref le, std::uint32_t seq)
     {
         le->setFieldU32(sfSequence, seq);
     }
@@ -153,7 +153,7 @@ class View_test : public beast::unit_test::Suite
         std::shared_ptr const genesis = std::make_shared(
             kCreateGenesis,
             Rules{config.features},
-            config.FEES.toFees(),
+            config.fees.toFees(),
             std::vector{},
             env.app().getNodeFamily());
         auto const ledger =
@@ -421,7 +421,7 @@ class View_test : public beast::unit_test::Suite
         std::shared_ptr const genesis = std::make_shared(
             kCreateGenesis,
             Rules{config.features},
-            config.FEES.toFees(),
+            config.fees.toFees(),
             std::vector{},
             env.app().getNodeFamily());
         auto const ledger =
@@ -630,7 +630,7 @@ class View_test : public beast::unit_test::Suite
         std::shared_ptr const genesis = std::make_shared(
             kCreateGenesis,
             Rules{config.features},
-            config.FEES.toFees(),
+            config.fees.toFees(),
             std::vector{},
             env.app().getNodeFamily());
         auto const ledger =
@@ -1020,7 +1020,7 @@ class View_test : public beast::unit_test::Suite
             std::shared_ptr const genesis = std::make_shared(
                 kCreateGenesis,
                 Rules{config.features},
-                config.FEES.toFees(),
+                config.fees.toFees(),
                 std::vector{},
                 env.app().getNodeFamily());
             auto const ledger =
diff --git a/src/test/nodestore/Backend_test.cpp b/src/test/nodestore/Backend_test.cpp
index 32b7d46868..65601b0cf5 100644
--- a/src/test/nodestore/Backend_test.cpp
+++ b/src/test/nodestore/Backend_test.cpp
@@ -1,12 +1,13 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -33,8 +34,8 @@ public:
 
         Section params;
         beast::TempDir const tempDir;
-        params.set("type", type);
-        params.set("path", tempDir.path());
+        params.set(Keys::kType, type);
+        params.set(Keys::kPath, tempDir.path());
 
         beast::xor_shift_engine rng(seedValue);
 
diff --git a/src/test/nodestore/Database_test.cpp b/src/test/nodestore/Database_test.cpp
index 744717659a..bb8ec7d4fd 100644
--- a/src/test/nodestore/Database_test.cpp
+++ b/src/test/nodestore/Database_test.cpp
@@ -11,6 +11,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -71,10 +73,10 @@ public:
             Env env = [&]() {
                 auto p = test::jtx::envconfig();
                 {
-                    auto& section = p->section("sqlite");
-                    section.set("safety_level", "high");
+                    auto& section = p->section(Sections::kSqlite);
+                    section.set(Keys::kSafetyLevel, "high");
                 }
-                p->LEDGER_HISTORY = 100'000'000;
+                p->ledgerHistory = 100'000'000;
 
                 return Env(
                     *this,
@@ -100,10 +102,10 @@ public:
             Env env = [&]() {
                 auto p = test::jtx::envconfig();
                 {
-                    auto& section = p->section("sqlite");
-                    section.set("safety_level", "low");
+                    auto& section = p->section(Sections::kSqlite);
+                    section.set(Keys::kSafetyLevel, "low");
                 }
-                p->LEDGER_HISTORY = 100'000'000;
+                p->ledgerHistory = 100'000'000;
 
                 return Env(
                     *this,
@@ -129,10 +131,10 @@ public:
             Env env = [&]() {
                 auto p = test::jtx::envconfig();
                 {
-                    auto& section = p->section("sqlite");
-                    section.set("journal_mode", "off");
-                    section.set("synchronous", "extra");
-                    section.set("temp_store", "default");
+                    auto& section = p->section(Sections::kSqlite);
+                    section.set(Keys::kJournalMode, "off");
+                    section.set(Keys::kSynchronous, "extra");
+                    section.set(Keys::kTempStore, "default");
                 }
 
                 return Env(
@@ -161,12 +163,12 @@ public:
             Env env = [&]() {
                 auto p = test::jtx::envconfig();
                 {
-                    auto& section = p->section("sqlite");
-                    section.set("journal_mode", "off");
-                    section.set("synchronous", "extra");
-                    section.set("temp_store", "default");
+                    auto& section = p->section(Sections::kSqlite);
+                    section.set(Keys::kJournalMode, "off");
+                    section.set(Keys::kSynchronous, "extra");
+                    section.set(Keys::kTempStore, "default");
                 }
-                p->LEDGER_HISTORY = 50'000'000;
+                p->ledgerHistory = 50'000'000;
 
                 return Env(
                     *this,
@@ -197,11 +199,11 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section("sqlite");
-                section.set("safety_level", "low");
-                section.set("journal_mode", "off");
-                section.set("synchronous", "extra");
-                section.set("temp_store", "default");
+                auto& section = p->section(Sections::kSqlite);
+                section.set(Keys::kSafetyLevel, "low");
+                section.set(Keys::kJournalMode, "off");
+                section.set(Keys::kSynchronous, "extra");
+                section.set(Keys::kTempStore, "default");
             }
 
             try
@@ -228,9 +230,9 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section("sqlite");
-                section.set("safety_level", "high");
-                section.set("journal_mode", "off");
+                auto& section = p->section(Sections::kSqlite);
+                section.set(Keys::kSafetyLevel, "high");
+                section.set(Keys::kJournalMode, "off");
             }
 
             try
@@ -257,9 +259,9 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section("sqlite");
-                section.set("safety_level", "low");
-                section.set("synchronous", "extra");
+                auto& section = p->section(Sections::kSqlite);
+                section.set(Keys::kSafetyLevel, "low");
+                section.set(Keys::kSynchronous, "extra");
             }
 
             try
@@ -286,9 +288,9 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section("sqlite");
-                section.set("safety_level", "high");
-                section.set("temp_store", "default");
+                auto& section = p->section(Sections::kSqlite);
+                section.set(Keys::kSafetyLevel, "high");
+                section.set(Keys::kTempStore, "default");
             }
 
             try
@@ -315,8 +317,8 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section("sqlite");
-                section.set("safety_level", "slow");
+                auto& section = p->section(Sections::kSqlite);
+                section.set(Keys::kSafetyLevel, "slow");
             }
 
             try
@@ -343,8 +345,8 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section("sqlite");
-                section.set("journal_mode", "fast");
+                auto& section = p->section(Sections::kSqlite);
+                section.set(Keys::kJournalMode, "fast");
             }
 
             try
@@ -371,8 +373,8 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section("sqlite");
-                section.set("synchronous", "instant");
+                auto& section = p->section(Sections::kSqlite);
+                section.set(Keys::kSynchronous, "instant");
             }
 
             try
@@ -399,8 +401,8 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section("sqlite");
-                section.set("temp_store", "network");
+                auto& section = p->section(Sections::kSqlite);
+                section.set(Keys::kTempStore, "network");
             }
 
             try
@@ -434,9 +436,9 @@ public:
             Env env = [&]() {
                 auto p = test::jtx::envconfig();
                 {
-                    auto& section = p->section("sqlite");
-                    section.set("page_size", "512");
-                    section.set("journal_size_limit", "2582080");
+                    auto& section = p->section(Sections::kSqlite);
+                    section.set(Keys::kPageSize, "512");
+                    section.set(Keys::kJournalSizeLimit, "2582080");
                 }
                 return Env(*this, std::move(p));
             }();
@@ -455,8 +457,8 @@ public:
             bool found = false;
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section("sqlite");
-                section.set("page_size", "256");
+                auto& section = p->section(Sections::kSqlite);
+                section.set(Keys::kPageSize, "256");
             }
             try
             {
@@ -478,8 +480,8 @@ public:
             bool found = false;
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section("sqlite");
-                section.set("page_size", "131072");
+                auto& section = p->section(Sections::kSqlite);
+                section.set(Keys::kPageSize, "131072");
             }
             try
             {
@@ -501,8 +503,8 @@ public:
             bool found = false;
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section("sqlite");
-                section.set("page_size", "513");
+                auto& section = p->section(Sections::kSqlite);
+                section.set(Keys::kPageSize, "513");
             }
             try
             {
@@ -532,8 +534,8 @@ public:
 
         beast::TempDir const nodeDb;
         Section srcParams;
-        srcParams.set("type", srcBackendType);
-        srcParams.set("path", nodeDb.path());
+        srcParams.set(Keys::kType, srcBackendType);
+        srcParams.set(Keys::kPath, nodeDb.path());
 
         // Create a batch
         auto batch = createPredictableBatch(kNumObjectsToTest, seedValue);
@@ -555,8 +557,8 @@ public:
             // Set up the destination database
             beast::TempDir const destDb;
             Section destParams;
-            destParams.set("type", destBackendType);
-            destParams.set("path", destDb.path());
+            destParams.set(Keys::kType, destBackendType);
+            destParams.set(Keys::kPath, destDb.path());
 
             std::unique_ptr dest =
                 Manager::instance().makeDatabase(megabytes(4), scheduler, 2, destParams, journal_);
@@ -593,8 +595,8 @@ public:
 
         beast::TempDir const nodeDb;
         Section nodeParams;
-        nodeParams.set("type", type);
-        nodeParams.set("path", nodeDb.path());
+        nodeParams.set(Keys::kType, type);
+        nodeParams.set(Keys::kPath, nodeDb.path());
 
         beast::xor_shift_engine rng(seedValue);
 
@@ -653,7 +655,7 @@ public:
             // Set an invalid earliest ledger sequence
             try
             {
-                nodeParams.set("earliest_seq", "0");
+                nodeParams.set(Keys::kEarliestSeq, "0");
                 std::unique_ptr const db = Manager::instance().makeDatabase(
                     megabytes(4), scheduler, 2, nodeParams, journal_);
             }
@@ -664,7 +666,7 @@ public:
 
             {
                 // Set a valid earliest ledger sequence
-                nodeParams.set("earliest_seq", "1");
+                nodeParams.set(Keys::kEarliestSeq, "1");
                 std::unique_ptr db = Manager::instance().makeDatabase(
                     megabytes(4), scheduler, 2, nodeParams, journal_);
 
@@ -676,7 +678,7 @@ public:
             try
             {
                 // Set to default earliest ledger sequence
-                nodeParams.set("earliest_seq", std::to_string(kXrpLedgerEarliestSeq));
+                nodeParams.set(Keys::kEarliestSeq, std::to_string(kXrpLedgerEarliestSeq));
                 std::unique_ptr const db2 = Manager::instance().makeDatabase(
                     megabytes(4), scheduler, 2, nodeParams, journal_);
             }
diff --git a/src/test/nodestore/NuDBFactory_test.cpp b/src/test/nodestore/NuDBFactory_test.cpp
index fae13b9cc8..d0675b3893 100644
--- a/src/test/nodestore/NuDBFactory_test.cpp
+++ b/src/test/nodestore/NuDBFactory_test.cpp
@@ -1,12 +1,13 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -29,10 +30,10 @@ private:
     createSection(std::string const& path, std::string const& blockSize = "")
     {
         Section params;
-        params.set("type", "nudb");
-        params.set("path", path);
+        params.set(Keys::kType, "nudb");
+        params.set(Keys::kPath, path);
         if (!blockSize.empty())
-            params.set("nudb_block_size", blockSize);
+            params.set(Keys::kNudbBlockSize, blockSize);
         return params;
     }
 
@@ -87,7 +88,7 @@ private:
         auto backend = Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
 
         std::string const logOutput = sink.messages().str();
-        BEAST_EXPECT(logOutput.find(expectedMessage) != std::string::npos);
+        BEAST_EXPECT(logOutput.contains(expectedMessage));
     }
 
     // Helper function to test power of two validation
@@ -104,7 +105,7 @@ private:
         auto backend = Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
 
         std::string const logOutput = sink.messages().str();
-        bool const hasWarning = logOutput.find("Invalid nudb_block_size") != std::string::npos;
+        bool const hasWarning = logOutput.contains("Invalid nudb_block_size");
 
         BEAST_EXPECT(hasWarning == !shouldWork);
     }
@@ -220,10 +221,8 @@ public:
             catch (std::exception const& e)
             {
                 std::string const logOutput{e.what()};
-                BEAST_EXPECT(logOutput.find("Invalid nudb_block_size: 5000") != std::string::npos);
-                BEAST_EXPECT(
-                    logOutput.find("Must be power of 2 between 4096 and 32768") !=
-                    std::string::npos);
+                BEAST_EXPECT(logOutput.contains("Invalid nudb_block_size: 5000"));
+                BEAST_EXPECT(logOutput.contains("Must be power of 2 between 4096 and 32768"));
             }
         }
 
@@ -246,8 +245,7 @@ public:
             catch (std::exception const& e)
             {
                 std::string const logOutput{e.what()};
-                BEAST_EXPECT(
-                    logOutput.find("Invalid nudb_block_size value: invalid") != std::string::npos);
+                BEAST_EXPECT(logOutput.contains("Invalid nudb_block_size value: invalid"));
             }
         }
     }
@@ -290,7 +288,7 @@ public:
             catch (std::exception const& e)
             {
                 std::string const logOutput{e.what()};
-                BEAST_EXPECT(logOutput.find("Invalid nudb_block_size") != std::string::npos);
+                BEAST_EXPECT(logOutput.contains("Invalid nudb_block_size"));
             }
         }
     }
@@ -354,8 +352,7 @@ public:
 
             // Should log success message for valid values
             std::string const logOutput = sink.messages().str();
-            bool const hasSuccessMessage =
-                logOutput.find("Using custom NuDB block size") != std::string::npos;
+            bool const hasSuccessMessage = logOutput.contains("Using custom NuDB block size");
             BEAST_EXPECT(hasSuccessMessage);
         }
 
diff --git a/src/test/nodestore/TestBase.h b/src/test/nodestore/TestBase.h
index c35042bd4f..885c3bbeac 100644
--- a/src/test/nodestore/TestBase.h
+++ b/src/test/nodestore/TestBase.h
@@ -73,9 +73,7 @@ public:
                     case 2:
                         return NodeObjectType::TransactionNode;
                     case 3:
-                        return NodeObjectType::Unknown;
                     default:
-                        // will never happen, but make static analysis tool happy.
                         return NodeObjectType::Unknown;
                 }
             }();
diff --git a/src/test/nodestore/Timing_test.cpp b/src/test/nodestore/Timing_test.cpp
index d7540f0b3a..e308d0d5ff 100644
--- a/src/test/nodestore/Timing_test.cpp
+++ b/src/test/nodestore/Timing_test.cpp
@@ -1,7 +1,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -12,6 +11,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -56,7 +57,7 @@ template 
 static void
 rngcpy(void* buffer, std::size_t bytes, Generator& g)
 {
-    using result_type = typename Generator::result_type;
+    using result_type = Generator::result_type;
     while (bytes >= sizeof(result_type))
     {
         auto const v = g();
@@ -84,16 +85,16 @@ private:
 
     beast::xor_shift_engine gen_;
     std::uint8_t prefix_;
-    std::discrete_distribution d_type_;
-    std::uniform_int_distribution d_size_;
+    std::discrete_distribution dType_;
+    std::uniform_int_distribution dSize_;
 
 public:
     explicit Sequence(std::uint8_t prefix)
         : prefix_(prefix)
         // uniform distribution over hotLEDGER - hotTRANSACTION_NODE
         // but exclude  hotTRANSACTION = 2 (removed)
-        , d_type_({1, 1, 0, 1, 1})
-        , d_size_(kMinSize, kMaxSize)
+        , dType_({1, 1, 0, 1, 1})
+        , dSize_(kMinSize, kMaxSize)
     {
     }
 
@@ -116,10 +117,10 @@ public:
         auto const data = static_cast(&*key.begin());
         *data = prefix_;
         rngcpy(data + 1, key.size() - 1, gen_);
-        Blob value(d_size_(gen_));
+        Blob value(dSize_(gen_));
         rngcpy(&value[0], value.size(), gen_);
         return NodeObject::createObject(
-            safeCast(d_type_(gen_)), std::move(value), key);
+            safeCast(dType_(gen_)), std::move(value), key);
     }
 
     // returns a batch of NodeObjects starting at n
@@ -140,11 +141,11 @@ class Timing_test : public beast::unit_test::Suite
 public:
     static constexpr auto kMissingNodePercent = 20;  // percent of fetches for missing nodes
 
-    std::size_t const default_repeat = 3;
+    std::size_t const defaultRepeat = 3;
 #ifndef NDEBUG
-    std::size_t const default_items = 10000;
+    std::size_t const defaultItems = 10000;
 #else
-    std::size_t const default_items = 100000;  // release
+    std::size_t const defaultItems = 100000;  // release
 #endif
 
     using clock_type = std::chrono::steady_clock;
@@ -639,7 +640,7 @@ public:
         {
             w = std::max::size_type>(w, test.first.size());
         }
-        log << threads << " Thread" << (threads > 1 ? "s" : "") << ", " << default_items
+        log << threads << " Thread" << (threads > 1 ? "s" : "") << ", " << defaultItems
             << " Objects" << std::endl;
         {
             std::stringstream ss;
@@ -655,15 +656,16 @@ public:
         for (auto const& configString : configStrings)
         {
             Params params{};
-            params.items = default_items;
+            params.items = defaultItems;
             params.threads = threads;
-            for (auto i = default_repeat; (i--) != 0u;)
+            for (auto i = defaultRepeat; (i--) != 0u;)
             {
                 beast::TempDir const tempDir;
                 Section config = parse(configString);
-                config.set("path", tempDir.path());
+                config.set(Keys::kPath, tempDir.path());
                 std::stringstream ss;
-                ss << std::left << setw(10) << get(config, "type", std::string()) << std::right;
+                ss << std::left << setw(10) << get(config, Keys::kType, std::string())
+                   << std::right;
                 for (auto const& test : tests)
                 {
                     ss << " " << setw(w) << toString(doTest(test.second, config, params, journal));
diff --git a/src/test/nodestore/import_test.cpp b/src/test/nodestore/import_test.cpp
index a80b5ccc93..d8c4a96713 100644
--- a/src/test/nodestore/import_test.cpp
+++ b/src/test/nodestore/import_test.cpp
@@ -21,11 +21,16 @@
 #include 
 #include 
 #include 
+
+#if XRPL_ROCKSDB_AVAILABLE
+
 #include 
 #include 
 #include 
 #include 
 
+#endif
+
 #include 
 #include 
 #include 
@@ -297,17 +302,17 @@ public:
         auto const args = parseArgs(arg());
         bool usage = args.empty();
 
-        if (!usage && args.find("from") == args.end())
+        if (!usage && !args.contains("from"))
         {
             log << "Missing parameter: from";
             usage = true;
         }
-        if (!usage && args.find("to") == args.end())
+        if (!usage && !args.contains("to"))
         {
             log << "Missing parameter: to";
             usage = true;
         }
-        if (!usage && args.find("buffer") == args.end())
+        if (!usage && !args.contains("buffer"))
         {
             log << "Missing parameter: buffer";
             usage = true;
diff --git a/src/test/overlay/TMGetObjectByHash_test.cpp b/src/test/overlay/TMGetObjectByHash_test.cpp
index 961e1b7eb4..e579989181 100644
--- a/src/test/overlay/TMGetObjectByHash_test.cpp
+++ b/src/test/overlay/TMGetObjectByHash_test.cpp
@@ -100,6 +100,17 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite
             return lastSentMessage_;
         }
 
+        // Synchronous test access to the JobQueue-dispatched processor.
+        // The production path runs this on JtLedgerReq; tests need a
+        // synchronous entry point to inspect the reply via send().
+        // PeerImp::processGetObjectByHash is `protected` so the derived
+        // test subclass can call it directly.
+        void
+        runProcessGetObjectByHash(std::shared_ptr const& m)
+        {
+            processGetObjectByHash(m);
+        }
+
         static void
         resetId()
         {
@@ -179,6 +190,10 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite
     /**
      * Test that reply is limited to hardMaxReplyNodes when more objects
      * are requested than the limit allows.
+     *
+     * `onMessage(TMGetObjectByHash)` dispatches the generic-query path
+     * to the JobQueue, so tests invoke the synchronous processor
+     * directly via `runProcessGetObjectByHash`.
      */
     void
     testReplyLimit(size_t const numObjects, int const expectedReplySize)
@@ -191,8 +206,7 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite
         auto peer = createPeer(env);
 
         auto request = createRequest(numObjects, env);
-        // Call the onMessage handler
-        peer->onMessage(request);
+        peer->runProcessGetObjectByHash(request);
 
         // Verify that a reply was sent
         auto sentMessage = peer->getLastSentMessage();
diff --git a/src/test/overlay/cluster_test.cpp b/src/test/overlay/cluster_test.cpp
index 0c164dfded..6c2114b7de 100644
--- a/src/test/overlay/cluster_test.cpp
+++ b/src/test/overlay/cluster_test.cpp
@@ -3,9 +3,9 @@
 
 #include 
 
-#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
diff --git a/src/test/overlay/compression_test.cpp b/src/test/overlay/compression_test.cpp
index adb3b1b27b..60cc69a14f 100644
--- a/src/test/overlay/compression_test.cpp
+++ b/src/test/overlay/compression_test.cpp
@@ -112,16 +112,16 @@ public:
             return;
 
         std::vector decompressed;
-        decompressed.resize(header->uncompressed_size);
+        decompressed.resize(header->uncompressedSize);
 
-        BEAST_EXPECT(header->payload_wire_size == buffer.size() - header->header_size);
+        BEAST_EXPECT(header->payloadWireSize == buffer.size() - header->headerSize);
 
         ZeroCopyInputStream stream(buffers.data());
-        stream.Skip(header->header_size);
+        stream.Skip(header->headerSize);
 
         auto decompressedSize = xrpl::compression::decompress(
-            stream, header->payload_wire_size, decompressed.data(), header->uncompressed_size);
-        BEAST_EXPECT(decompressedSize == header->uncompressed_size);
+            stream, header->payloadWireSize, decompressed.data(), header->uncompressedSize);
+        BEAST_EXPECT(decompressedSize == header->uncompressedSize);
         auto const proto1 = std::make_shared();
 
         BEAST_EXPECT(proto1->ParseFromArray(decompressed.data(), decompressedSize));
@@ -142,7 +142,7 @@ public:
         {
             auto master = randomKeyPair(KeyType::Ed25519);
             auto signing = randomKeyPair(KeyType::Ed25519);
-            STObject st(kSfGeneric);
+            STObject st(sfGeneric);
             st[sfSequence] = i;
             st[sfPublicKey] = std::get<0>(master);
             st[sfSigningPubKey] = std::get<0>(signing);
@@ -299,7 +299,7 @@ public:
 
         auto master = randomKeyPair(KeyType::Ed25519);
         auto signing = randomKeyPair(KeyType::Ed25519);
-        STObject st(kSfGeneric);
+        STObject st(sfGeneric);
         st[sfSequence] = 0;
         st[sfPublicKey] = std::get<0>(master);
         st[sfSigningPubKey] = std::get<0>(signing);
@@ -326,7 +326,7 @@ public:
 
         auto master = randomKeyPair(KeyType::Ed25519);
         auto signing = randomKeyPair(KeyType::Ed25519);
-        STObject st(kSfGeneric);
+        STObject st(sfGeneric);
         st[sfSequence] = 0;
         st[sfPublicKey] = std::get<0>(master);
         st[sfSigningPubKey] = std::get<0>(signing);
@@ -408,9 +408,8 @@ public:
                 << enable << "\n";
             c.loadFromString(str.str());
             auto env = std::make_shared(*this);
-            env->app().config().COMPRESSION = c.COMPRESSION;
-            env->app().config().VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE =
-                c.VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE;
+            env->app().config().compression = c.compression;
+            env->app().config().vpReduceRelayBaseSquelchEnable = c.vpReduceRelayBaseSquelchEnable;
             return env;
         };
         auto handshake = [&](int outboundEnable, int inboundEnable) {
@@ -419,10 +418,10 @@ public:
             auto env = getEnv(outboundEnable);
             auto request = xrpl::makeRequest(
                 true,
-                env->app().config().COMPRESSION,
+                env->app().config().compression,
                 false,
-                env->app().config().TX_REDUCE_RELAY_ENABLE,
-                env->app().config().VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE);
+                env->app().config().txReduceRelayEnable,
+                env->app().config().vpReduceRelayBaseSquelchEnable);
             http_request_type httpRequest;
             httpRequest.version(request.version());
             httpRequest.base() = request.base();
diff --git a/src/test/overlay/reduce_relay_test.cpp b/src/test/overlay/reduce_relay_test.cpp
index a0afd01d2a..1c9bf1f9c1 100644
--- a/src/test/overlay/reduce_relay_test.cpp
+++ b/src/test/overlay/reduce_relay_test.cpp
@@ -806,7 +806,7 @@ public:
     {
         auto size = max - min;
         std::vector s(size);
-        std::iota(s.begin(), s.end(), min);
+        std::iota(s.begin(), s.end(), min);  // NOLINT(modernize-use-ranges)
         std::random_device d;
         std::mt19937 g(d());
         std::shuffle(s.begin(), s.end(), g);
@@ -973,8 +973,7 @@ protected:
                 auto countingState = network_.overlay().isCountingState(validator);
                 BEAST_EXPECT(
                     countingState == false &&
-                    selected.size() ==
-                        env_.app().config().VP_REDUCE_RELAY_SQUELCH_MAX_SELECTED_PEERS);
+                    selected.size() == env_.app().config().vpReduceRelaySquelchMaxSelectedPeers);
             }
 
             // Trigger Link Down or Peer Disconnect event
@@ -1163,7 +1162,7 @@ protected:
                 {
                     BEAST_EXPECT(
                         squelched ==
-                        kMaxPeers - env_.app().config().VP_REDUCE_RELAY_SQUELCH_MAX_SELECTED_PEERS);
+                        kMaxPeers - env_.app().config().vpReduceRelaySquelchMaxSelectedPeers);
                     n++;
                 }
             },
@@ -1172,8 +1171,7 @@ protected:
             purge,
             resetClock);
         auto selected = network_.overlay().getSelected(network_.validator(0));
-        BEAST_EXPECT(
-            selected.size() == env_.app().config().VP_REDUCE_RELAY_SQUELCH_MAX_SELECTED_PEERS);
+        BEAST_EXPECT(selected.size() == env_.app().config().vpReduceRelaySquelchMaxSelectedPeers);
         BEAST_EXPECT(n == 1);  // only one selection round
         auto res = checkCounting(network_.validator(0), false);
         BEAST_EXPECT(res);
@@ -1234,7 +1232,7 @@ protected:
                 id, [&](PublicKey const& key, PeerWPtr const& peer) { unsquelched++; });
             BEAST_EXPECT(
                 unsquelched ==
-                kMaxPeers - env_.app().config().VP_REDUCE_RELAY_SQUELCH_MAX_SELECTED_PEERS);
+                kMaxPeers - env_.app().config().vpReduceRelaySquelchMaxSelectedPeers);
             BEAST_EXPECT(checkCounting(network_.validator(0), true));
         });
     }
@@ -1254,7 +1252,7 @@ protected:
             auto peers = network_.overlay().getPeers(network_.validator(0));
             BEAST_EXPECT(
                 unsquelched ==
-                kMaxPeers - env_.app().config().VP_REDUCE_RELAY_SQUELCH_MAX_SELECTED_PEERS);
+                kMaxPeers - env_.app().config().vpReduceRelaySquelchMaxSelectedPeers);
             BEAST_EXPECT(checkCounting(network_.validator(0), true));
         });
     }
@@ -1293,7 +1291,7 @@ vp_enable=1
 )xrpldConfig");
 
             c.loadFromString(toLoad);
-            BEAST_EXPECT(c.VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE == true);
+            BEAST_EXPECT(c.vpReduceRelayBaseSquelchEnable == true);
         });
 
         doTest("Test Config - squelch disabled (legacy)", log, [&](bool log) {
@@ -1305,7 +1303,7 @@ vp_enable=0
 )xrpldConfig");
 
             c.loadFromString(toLoad);
-            BEAST_EXPECT(c.VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE == false);
+            BEAST_EXPECT(c.vpReduceRelayBaseSquelchEnable == false);
 
             Config c1;
 
@@ -1314,7 +1312,7 @@ vp_enable=0
 )xrpldConfig";
 
             c1.loadFromString(toLoad);
-            BEAST_EXPECT(c1.VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE == false);
+            BEAST_EXPECT(c1.vpReduceRelayBaseSquelchEnable == false);
         });
 
         doTest("Test Config - squelch enabled", log, [&](bool log) {
@@ -1326,7 +1324,7 @@ vp_base_squelch_enable=1
 )xrpldConfig");
 
             c.loadFromString(toLoad);
-            BEAST_EXPECT(c.VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE == true);
+            BEAST_EXPECT(c.vpReduceRelayBaseSquelchEnable == true);
         });
 
         doTest("Test Config - squelch disabled", log, [&](bool log) {
@@ -1338,7 +1336,7 @@ vp_base_squelch_enable=0
 )xrpldConfig");
 
             c.loadFromString(toLoad);
-            BEAST_EXPECT(c.VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE == false);
+            BEAST_EXPECT(c.vpReduceRelayBaseSquelchEnable == false);
         });
 
         doTest("Test Config - legacy and new", log, [&](bool log) {
@@ -1378,7 +1376,7 @@ vp_enable=0
 )xrpldConfig");
 
             c.loadFromString(toLoad);
-            BEAST_EXPECT(c.VP_REDUCE_RELAY_SQUELCH_MAX_SELECTED_PEERS == 5);
+            BEAST_EXPECT(c.vpReduceRelaySquelchMaxSelectedPeers == 5);
 
             Config c1;
 
@@ -1388,7 +1386,7 @@ vp_base_squelch_max_selected_peers=6
 )xrpldConfig";
 
             c1.loadFromString(toLoad);
-            BEAST_EXPECT(c1.VP_REDUCE_RELAY_SQUELCH_MAX_SELECTED_PEERS == 6);
+            BEAST_EXPECT(c1.vpReduceRelaySquelchMaxSelectedPeers == 6);
 
             Config c2;
 
@@ -1421,7 +1419,7 @@ vp_base_squelch_max_selected_peers=2
         doTest("BaseSquelchReady", log, [&](bool log) {
             ManualClock::reset();
             auto createSlots = [&](bool baseSquelchEnabled) -> reduce_relay::Slots {
-                env_.app().config().VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE = baseSquelchEnabled;
+                env_.app().config().vpReduceRelayBaseSquelchEnable = baseSquelchEnabled;
                 return reduce_relay::Slots(
                     env_.app(), network_.overlay(), env_.app().config());
             };
@@ -1577,10 +1575,10 @@ vp_base_squelch_max_selected_peers=2
                     << "[compression]\n"
                     << "1\n";
                 c.loadFromString(str.str());
-                env_.app().config().VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE =
-                    c.VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE;
+                env_.app().config().vpReduceRelayBaseSquelchEnable =
+                    c.vpReduceRelayBaseSquelchEnable;
 
-                env_.app().config().COMPRESSION = c.COMPRESSION;
+                env_.app().config().compression = c.compression;
             };
             auto handshake = [&](int outboundEnable, int inboundEnable) {
                 beast::IP::Address const addr = boost::asio::ip::make_address("172.1.1.100");
@@ -1588,10 +1586,10 @@ vp_base_squelch_max_selected_peers=2
                 setEnv(outboundEnable);
                 auto request = xrpl::makeRequest(
                     true,
-                    env_.app().config().COMPRESSION,
+                    env_.app().config().compression,
                     false,
-                    env_.app().config().TX_REDUCE_RELAY_ENABLE,
-                    env_.app().config().VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE);
+                    env_.app().config().txReduceRelayEnable,
+                    env_.app().config().vpReduceRelayBaseSquelchEnable);
                 http_request_type httpRequest;
                 httpRequest.version(request.version());
                 httpRequest.base() = request.base();
@@ -1626,8 +1624,8 @@ vp_base_squelch_max_selected_peers=2
 public:
     reduce_relay_test()
         : env_(*this, jtx::envconfig([](std::unique_ptr cfg) {
-            cfg->VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE = true;
-            cfg->VP_REDUCE_RELAY_SQUELCH_MAX_SELECTED_PEERS = 6;
+            cfg->vpReduceRelayBaseSquelchEnable = true;
+            cfg->vpReduceRelaySquelchMaxSelectedPeers = 6;
             return cfg;
         }))
         , network_(env_.app())
diff --git a/src/test/overlay/short_read_test.cpp b/src/test/overlay/short_read_test.cpp
index 95ab5e8c20..9af632d65c 100644
--- a/src/test/overlay/short_read_test.cpp
+++ b/src/test/overlay/short_read_test.cpp
@@ -62,7 +62,7 @@ private:
     using endpoint_type = boost::asio::ip::tcp::endpoint;
     using address_type = boost::asio::ip::address;
 
-    io_context_type io_context_;
+    io_context_type ioContext_;
     boost::optional> work_;
     std::thread thread_;
     std::shared_ptr context_;
@@ -185,10 +185,10 @@ private:
                 , server(server)
                 , test(server.test_)
                 , acceptor(
-                      test.io_context_,
+                      test.ioContext_,
                       endpoint_type(boost::asio::ip::make_address(test::getEnvLocalhostAddr()), 0))
-                , socket(test.io_context_)
-                , strand(boost::asio::make_strand(test.io_context_))
+                , socket(test.ioContext_)
+                , strand(boost::asio::make_strand(test.ioContext_))
             {
                 acceptor.listen();
                 server.endpoint_ = acceptor.local_endpoint();
@@ -261,8 +261,8 @@ private:
                 , test(server.test_)
                 , socket(std::move(inSocket))
                 , stream(socket, *test.context_)
-                , strand(boost::asio::make_strand(test.io_context_))
-                , timer(test.io_context_)
+                , strand(boost::asio::make_strand(test.ioContext_))
+                , timer(test.ioContext_)
             {
             }
 
@@ -450,10 +450,10 @@ private:
                 : Child(client)
                 , client(client)
                 , test(client.test_)
-                , socket(test.io_context_)
+                , socket(test.ioContext_)
                 , stream(socket, *test.context_)
-                , strand(boost::asio::make_strand(test.io_context_))
-                , timer(test.io_context_)
+                , strand(boost::asio::make_strand(test.ioContext_))
+                , timer(test.ioContext_)
                 , ep(ep)
             {
             }
@@ -632,10 +632,10 @@ private:
 
 public:
     short_read_test()
-        : work_(io_context_.get_executor())
+        : work_(ioContext_.get_executor())
         , thread_(std::thread([this]() {
             beast::setCurrentThreadName("io_context");
-            this->io_context_.run();
+            this->ioContext_.run();
         }))
         , context_(makeSslContext(""))
     {
diff --git a/src/test/overlay/tx_reduce_relay_test.cpp b/src/test/overlay/tx_reduce_relay_test.cpp
index e053774108..a0d91d3aed 100644
--- a/src/test/overlay/tx_reduce_relay_test.cpp
+++ b/src/test/overlay/tx_reduce_relay_test.cpp
@@ -81,10 +81,10 @@ private:
                 {
                     c.loadFromString(str.str());
 
-                    BEAST_EXPECT(c.TX_REDUCE_RELAY_ENABLE == enable);
-                    BEAST_EXPECT(c.TX_REDUCE_RELAY_METRICS == metrics);
-                    BEAST_EXPECT(c.TX_REDUCE_RELAY_MIN_PEERS == min);
-                    BEAST_EXPECT(c.TX_RELAY_PERCENTAGE == pct);
+                    BEAST_EXPECT(c.txReduceRelayEnable == enable);
+                    BEAST_EXPECT(c.txReduceRelayMetrics == metrics);
+                    BEAST_EXPECT(c.txReduceRelayMinPeers == min);
+                    BEAST_EXPECT(c.txRelayPercentage == pct);
                     if (success)
                     {
                         pass();
@@ -173,7 +173,7 @@ private:
     std::uint16_t rid_{1};
     shared_context context_;
     ProtocolVersion protocolVersion_;
-    boost::beast::multi_buffer read_buf_;
+    boost::beast::multi_buffer readBuf_;
 
 public:
     tx_reduce_relay_test() : context_(makeSslContext("")), protocolVersion_{1, 7}
@@ -232,9 +232,9 @@ private:
         testcase(test);
         jtx::Env env(*this);
         std::vector> peers;
-        env.app().config().TX_REDUCE_RELAY_ENABLE = txRREnabled;
-        env.app().config().TX_REDUCE_RELAY_MIN_PEERS = minPeers;
-        env.app().config().TX_RELAY_PERCENTAGE = relayPercentage;
+        env.app().config().txReduceRelayEnable = txRREnabled;
+        env.app().config().txReduceRelayMinPeers = minPeers;
+        env.app().config().txRelayPercentage = relayPercentage;
         PeerTest::init();
         lid_ = 0;
         rid_ = 0;
diff --git a/src/test/peerfinder/Livecache_test.cpp b/src/test/peerfinder/Livecache_test.cpp
index 9dae410b5b..4f2d6e97e1 100644
--- a/src/test/peerfinder/Livecache_test.cpp
+++ b/src/test/peerfinder/Livecache_test.cpp
@@ -167,10 +167,9 @@ public:
         for (auto i = std::make_pair(0, c.hops.begin()); i.second != c.hops.end();
              ++i.first, ++i.second)
         {
-            std::copy((*i.second).begin(), (*i.second).end(), std::back_inserter(before[i.first]));
-            std::copy(
-                (*i.second).begin(), (*i.second).end(), std::back_inserter(beforeSorted[i.first]));
-            std::sort(beforeSorted[i.first].begin(), beforeSorted[i.first].end(), cmpEp);
+            std::ranges::copy(*i.second, std::back_inserter(before[i.first]));
+            std::ranges::copy(*i.second, std::back_inserter(beforeSorted[i.first]));
+            std::ranges::sort(beforeSorted[i.first], cmpEp);
         }
 
         c.hops.shuffle();
@@ -180,10 +179,9 @@ public:
         for (auto i = std::make_pair(0, c.hops.begin()); i.second != c.hops.end();
              ++i.first, ++i.second)
         {
-            std::copy((*i.second).begin(), (*i.second).end(), std::back_inserter(after[i.first]));
-            std::copy(
-                (*i.second).begin(), (*i.second).end(), std::back_inserter(afterSorted[i.first]));
-            std::sort(afterSorted[i.first].begin(), afterSorted[i.first].end(), cmpEp);
+            std::ranges::copy(*i.second, std::back_inserter(after[i.first]));
+            std::ranges::copy(*i.second, std::back_inserter(afterSorted[i.first]));
+            std::ranges::sort(afterSorted[i.first], cmpEp);
         }
 
         // each hop bucket should contain the same items
diff --git a/src/test/peerfinder/PeerFinder_test.cpp b/src/test/peerfinder/PeerFinder_test.cpp
index 2e4ab001b4..c4f129c1a3 100644
--- a/src/test/peerfinder/PeerFinder_test.cpp
+++ b/src/test/peerfinder/PeerFinder_test.cpp
@@ -421,8 +421,8 @@ public:
 
             c.loadFromString(toLoad);
             BEAST_EXPECT(
-                (c.PEERS_MAX == max && c.PEERS_IN_MAX == 0 && c.PEERS_OUT_MAX == 0) ||
-                (c.PEERS_IN_MAX == *maxIn && c.PEERS_OUT_MAX == *maxOut));
+                (c.peersMax == max && c.peersInMax == 0 && c.peersOutMax == 0) ||
+                (c.peersInMax == *maxIn && c.peersOutMax == *maxOut));
 
             Config const config = Config::makeConfig(c, port, false, 0, true);
 
diff --git a/src/test/protocol/Hooks_test.cpp b/src/test/protocol/Hooks_test.cpp
index 20fe205579..082507aca7 100644
--- a/src/test/protocol/Hooks_test.cpp
+++ b/src/test/protocol/Hooks_test.cpp
@@ -73,7 +73,7 @@ class Hooks_test : public beast::unit_test::Suite
         {
             SField const& f = rf.get();
 
-            STObject dummy{kSfGeneric};
+            STObject dummy{sfGeneric};
 
             BEAST_EXPECT(!dummy.isFieldPresent(f));
 
@@ -161,8 +161,8 @@ class Hooks_test : public beast::unit_test::Suite
 
                 case STI_ARRAY: {
                     STArray dummy2{f, 2};
-                    dummy2.pushBack(STObject{kSfGeneric});
-                    dummy2.pushBack(STObject{kSfGeneric});
+                    dummy2.pushBack(STObject{sfGeneric});
+                    dummy2.pushBack(STObject{sfGeneric});
                     dummy.setFieldArray(f, dummy2);
                     BEAST_EXPECT(dummy.getFieldArray(f) == dummy2);
                     BEAST_EXPECT(dummy.isFieldPresent(f));
diff --git a/src/test/protocol/STAmount_test.cpp b/src/test/protocol/STAmount_test.cpp
index b387c361cb..720fafa8f2 100644
--- a/src/test/protocol/STAmount_test.cpp
+++ b/src/test/protocol/STAmount_test.cpp
@@ -37,7 +37,7 @@ public:
         s.add(ser);
 
         SerialIter sit(ser.slice());
-        return STAmount(sit, kSfGeneric);
+        return STAmount(sit, sfGeneric);
     }
 
     //--------------------------------------------------------------------------
@@ -1203,6 +1203,98 @@ public:
         }
     }
 
+    void
+    testIsZeroAtScale()
+    {
+        testcase("isZeroAtScale");
+
+        Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)};
+
+        // IOU: 10 IOU — mantissa = kMinValue (10^15), exponent = -14.
+        // One ULP at this scale is 10^-14; half-ULP is 5*10^-15.
+        {
+            STAmount const ref{usd, STAmount::kMinValue, -14};
+            int const refScale = ref.exponent();  // -14
+            BEAST_EXPECT(refScale == -14);
+
+            // Zero rounds to zero at any scale.
+            STAmount const iouZero{usd, 0};
+            BEAST_EXPECT(iouZero.isZeroAtScale(refScale));
+
+            // Sub-ULP: 1e-16 IOU (mantissa = kMinValue, exponent = -31).
+            // Far below half-ULP → rounds to zero.
+            STAmount const subUlp{usd, STAmount::kMinValue, -31};
+            BEAST_EXPECT(subUlp.isZeroAtScale(refScale));
+
+            // One ULP: 1e-14 IOU (mantissa = kMinValue, exponent = -29).
+            // Exactly the smallest representable unit at refScale → not zero.
+            STAmount const oneUlp{usd, STAmount::kMinValue, -29};
+            BEAST_EXPECT(!oneUlp.isZeroAtScale(refScale));
+
+            // The reference value itself: exponent == scale → returned
+            // unchanged → not zero.
+            BEAST_EXPECT(!ref.isZeroAtScale(refScale));
+
+            // A much larger value: certainly not zero at this scale.
+            STAmount const large{usd, STAmount::kMinValue, 0};  // 1e15 IOU
+            BEAST_EXPECT(!large.isZeroAtScale(refScale));
+
+            // When scale equals the value's own exponent, roundToScale
+            // short-circuits and returns the value unchanged.
+            BEAST_EXPECT(!subUlp.isZeroAtScale(subUlp.exponent()));
+            BEAST_EXPECT(!oneUlp.isZeroAtScale(oneUlp.exponent()));
+
+            // Half-ULP boundary. roundToScale forms (value + ref) - ref
+            // where ref = 10 IOU has mantissa 1e15 (LSB 0, even).
+            // Number's default rounding is to-nearest-even, so an exact
+            // half-ULP tie rounds toward the even-LSB neighbour — the
+            // reference itself — and the round-trip result is zero.
+            // Just below half-ULP rounds the same way; just above
+            // clears half-ULP and bumps the mantissa to 1e15 + 1.
+            STAmount const justBelowHalf{usd, STAmount::kMinValue * 4, -30};
+            BEAST_EXPECT(justBelowHalf.isZeroAtScale(refScale));
+
+            STAmount const halfUlp{usd, STAmount::kMinValue * 5, -30};
+            BEAST_EXPECT(halfUlp.isZeroAtScale(refScale));
+
+            STAmount const justAboveHalf{usd, STAmount::kMinValue * 6, -30};
+            BEAST_EXPECT(!justAboveHalf.isZeroAtScale(refScale));
+
+            // Large magnitude gap: dust value far below an enormous scale.
+            // 1e-80 with scale +15 — the value vanishes utterly.
+            STAmount const dust{usd, STAmount::kMinValue, -95};
+            BEAST_EXPECT(dust.isZeroAtScale(15));
+
+            // Negative values mirror positive behaviour.
+            STAmount const negSubUlp{usd, STAmount::kMinValue, -31, true};
+            BEAST_EXPECT(negSubUlp.isZeroAtScale(refScale));
+
+            STAmount const negOneUlp{usd, STAmount::kMinValue, -29, true};
+            BEAST_EXPECT(!negOneUlp.isZeroAtScale(refScale));
+        }
+
+        // XRP is integral — roundToScale short-circuits, value is preserved.
+        {
+            STAmount const xrp{XRPAmount{1}};
+            BEAST_EXPECT(!xrp.isZeroAtScale(-14));
+            BEAST_EXPECT(!xrp.isZeroAtScale(0));
+
+            STAmount const xrpZero{XRPAmount{0}};
+            BEAST_EXPECT(xrpZero.isZeroAtScale(-14));
+        }
+
+        // MPT is integral — same short-circuit behaviour as XRP.
+        {
+            MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+            STAmount const mptAmt{mpt, 1};
+            BEAST_EXPECT(!mptAmt.isZeroAtScale(0));
+            BEAST_EXPECT(!mptAmt.isZeroAtScale(-14));
+
+            STAmount const mptZero{mpt, 0};
+            BEAST_EXPECT(mptZero.isZeroAtScale(0));
+        }
+    }
+
     //--------------------------------------------------------------------------
 
     void
@@ -1223,6 +1315,7 @@ public:
         testCanSubtractXRP();
         testCanSubtractIOU();
         testCanSubtractMPT();
+        testIsZeroAtScale();
     }
 };
 
diff --git a/src/test/protocol/STNumber_test.cpp b/src/test/protocol/STNumber_test.cpp
index 4d95bc1ef7..5c9c3fd83c 100644
--- a/src/test/protocol/STNumber_test.cpp
+++ b/src/test/protocol/STNumber_test.cpp
@@ -280,8 +280,7 @@ struct STNumber_test : public beast::unit_test::Suite
     {
         static_assert(!std::is_convertible_v);
 
-        for (auto const scale :
-             {MantissaRange::MantissaScale::Small, MantissaRange::MantissaScale::Large})
+        for (auto const scale : MantissaRange::getAllScales())
         {
             NumberMantissaScaleGuard const sg(scale);
             testcase << to_string(Number::getMantissaScale());
diff --git a/src/test/protocol/STObject_test.cpp b/src/test/protocol/STObject_test.cpp
index 59b78d213d..801eebbe63 100644
--- a/src/test/protocol/STObject_test.cpp
+++ b/src/test/protocol/STObject_test.cpp
@@ -36,19 +36,19 @@ public:
     {
         testcase("serialization");
 
-        unexpected(kSfGeneric.isUseful(), "sfGeneric must not be useful");
+        unexpected(sfGeneric.isUseful(), "sfGeneric must not be useful");
         {
             // Try to put sfGeneric in an SOTemplate.
             except(
-                [&]() { SOTemplate const elements{{kSfGeneric, SoeRequired}}; });
+                [&]() { SOTemplate const elements{{sfGeneric, SoeRequired}}; });
         }
 
-        unexpected(kSfInvalid.isUseful(), "sfInvalid must not be useful");
+        unexpected(sfInvalid.isUseful(), "sfInvalid must not be useful");
         {
             // Test return of sfInvalid.
             auto testInvalid = [this](SerializedTypeID tid, int fv) {
                 SField const& shouldBeInvalid{SField::getField(tid, fv)};
-                BEAST_EXPECT(shouldBeInvalid == kSfInvalid);
+                BEAST_EXPECT(shouldBeInvalid == sfInvalid);
             };
             testInvalid(STI_VL, 255);
             testInvalid(STI_UINT256, 255);
@@ -59,7 +59,7 @@ public:
         {
             // Try to put sfInvalid in an SOTemplate.
             except(
-                [&]() { SOTemplate const elements{{kSfInvalid, SoeRequired}}; });
+                [&]() { SOTemplate const elements{{sfInvalid, SoeRequired}}; });
         }
         {
             // Try to put the same SField into an SOTemplate twice.
@@ -188,7 +188,7 @@ public:
 
         {
             auto const st = [&]() {
-                STObject s(kSfGeneric);
+                STObject s(sfGeneric);
                 s.setFieldU32(sf1Outer, 1);
                 s.setFieldU32(sf2Outer, 2);
                 return s;
@@ -219,7 +219,7 @@ public:
 
         {
             auto const st = [&]() {
-                STObject s(sotOuter, kSfGeneric);
+                STObject s(sotOuter, sfGeneric);
                 s.setFieldU32(sf1Outer, 1);
                 s.setFieldU32(sf2Outer, 2);
                 return s;
@@ -239,7 +239,7 @@ public:
         // write free object
 
         {
-            STObject st(kSfGeneric);
+            STObject st(sfGeneric);
             unexcept([&]() { st[sf1Outer]; });
             except([&]() { return st[sf1Outer] == 0; });
             BEAST_EXPECT(st[~sf1Outer] == std::nullopt);
@@ -295,7 +295,7 @@ public:
         // Write templated object
 
         {
-            STObject st(sotOuter, kSfGeneric);
+            STObject st(sotOuter, sfGeneric);
             BEAST_EXPECT(!!st[~sf1Outer]);
             BEAST_EXPECT(st[~sf1Outer] != std::nullopt);
             BEAST_EXPECT(st[sf1Outer] == 0);
@@ -353,7 +353,7 @@ public:
         // coercion operator to std::optional
 
         {
-            STObject st(kSfGeneric);
+            STObject st(sfGeneric);
             auto const v = ~st[~sf1Outer];
             static_assert(
                 std::is_same_v, std::optional>, "");
@@ -362,7 +362,7 @@ public:
         // UDT scalar fields
 
         {
-            STObject st(kSfGeneric);
+            STObject st(sfGeneric);
             st[sfAmount] = STAmount{};
             st[sfAccount] = AccountID{};
             st[sfDigest] = uint256{};
@@ -375,7 +375,7 @@ public:
 
         {
             {
-                STObject st(kSfGeneric);
+                STObject st(sfGeneric);
                 Buffer b(1);
                 BEAST_EXPECT(!b.empty());
                 st[sf4] = std::move(b);
@@ -392,7 +392,7 @@ public:
                 BEAST_EXPECT(Slice(st[sf5]).size() == 2);
             }
             {
-                STObject st(sotOuter, kSfGeneric);
+                STObject st(sotOuter, sfGeneric);
                 BEAST_EXPECT(st[sf5] == Slice{});
                 BEAST_EXPECT(!!st[~sf5]);
                 BEAST_EXPECT(!!~st[~sf5]);
@@ -408,7 +408,7 @@ public:
         // UDT blobs
 
         {
-            STObject st(kSfGeneric);
+            STObject st(sfGeneric);
             BEAST_EXPECT(!st[~sf5]);
             auto const kp = generateKeyPair(KeyType::Secp256k1, generateSeed("masterpassphrase"));
             st[sf5] = kp.first;
@@ -419,7 +419,7 @@ public:
 
         {
             auto const& sf = sfIndexes;
-            STObject st(kSfGeneric);
+            STObject st(sfGeneric);
             std::vector v;
             v.emplace_back(1);
             v.emplace_back(2);
@@ -446,7 +446,7 @@ public:
                 {sf3, SoeDefault},
             };
 
-            STObject st(sot, kSfGeneric);
+            STObject st(sot, sfGeneric);
             auto const& cst(st);
             BEAST_EXPECT(cst[sf1].empty());
             BEAST_EXPECT(!cst[~sf2]);
diff --git a/src/test/protocol/STTx_test.cpp b/src/test/protocol/STTx_test.cpp
index 42cb2bb9a7..21518d1406 100644
--- a/src/test/protocol/STTx_test.cpp
+++ b/src/test/protocol/STTx_test.cpp
@@ -1390,7 +1390,7 @@ public:
         // Lambda that returns a Payment STObject.
         auto getPayment = [kp1, id1, id2]() {
             // Account id1 pays account id2 10,000 XRP.
-            STObject payment(kSfGeneric);
+            STObject payment(sfGeneric);
             payment.setFieldU16(sfTransactionType, ttPAYMENT);
             payment.setAccountID(sfAccount, id1);
             payment.setAccountID(sfDestination, id2);
diff --git a/src/test/rpc/AMMInfo_test.cpp b/src/test/rpc/AMMInfo_test.cpp
index 28c536aab9..987df6c724 100644
--- a/src/test/rpc/AMMInfo_test.cpp
+++ b/src/test/rpc/AMMInfo_test.cpp
@@ -65,6 +65,26 @@ public:
             BEAST_EXPECT(jv[jss::error_message] == "Account malformed.");
         });
 
+        // Account is not a string
+        testAMM([&](AMM& ammAlice, Env&) {
+            auto const jv =
+                ammAlice.ammRpcInfo(json::Value{42}, std::nullopt, XRP, USD, std::nullopt, true, 3);
+            BEAST_EXPECT(jv[jss::error_message] == "Account malformed.");
+        });
+
+        // AMM Account is not a string
+        testAMM([&](AMM& ammAlice, Env&) {
+            auto const jv = ammAlice.ammRpcInfo(
+                json::Value{to_string(ammAlice.ammAccount())},
+                std::nullopt,
+                XRP,
+                USD,
+                json::Value{42},
+                false,
+                3);
+            BEAST_EXPECT(jv[jss::error_message] == "Account malformed.");
+        });
+
         std::vector, std::optional, TestAccount, bool>> const
             invalidParams = {
                 {xrpIssue(), std::nullopt, TestAccount::None, false},
diff --git a/src/test/rpc/AccountInfo_test.cpp b/src/test/rpc/AccountInfo_test.cpp
index 385fc2f58a..d061be1f0e 100644
--- a/src/test/rpc/AccountInfo_test.cpp
+++ b/src/test/rpc/AccountInfo_test.cpp
@@ -583,24 +583,17 @@ public:
         static constexpr std::pair kAllowTrustLineClawbackFlag{
             "allowTrustLineClawback", asfAllowTrustLineClawback};
 
-        if (features[featureClawback])
-        {
-            // must use bob's account because alice has noFreeze set
-            auto const f1 = getAccountFlag(kAllowTrustLineClawbackFlag.first, bob);
-            BEAST_EXPECT(f1.has_value());
-            BEAST_EXPECT(!f1.value());  // NOLINT(bugprone-unchecked-optional-access)
+        // must use bob's account because alice has noFreeze set
+        auto const f1 = getAccountFlag(kAllowTrustLineClawbackFlag.first, bob);
+        BEAST_EXPECT(f1.has_value());
+        BEAST_EXPECT(!f1.value());  // NOLINT(bugprone-unchecked-optional-access)
 
-            // Set allowTrustLineClawback
-            env(fset(bob, kAllowTrustLineClawbackFlag.second));
-            env.close();
-            auto const f2 = getAccountFlag(kAllowTrustLineClawbackFlag.first, bob);
-            BEAST_EXPECT(f2.has_value());
-            BEAST_EXPECT(f2.value());  // NOLINT(bugprone-unchecked-optional-access)
-        }
-        else
-        {
-            BEAST_EXPECT(!getAccountFlag(kAllowTrustLineClawbackFlag.first, bob));
-        }
+        // Set allowTrustLineClawback
+        env(fset(bob, kAllowTrustLineClawbackFlag.second));
+        env.close();
+        auto const f2 = getAccountFlag(kAllowTrustLineClawbackFlag.first, bob);
+        BEAST_EXPECT(f2.has_value());
+        BEAST_EXPECT(f2.value());  // NOLINT(bugprone-unchecked-optional-access)
 
         static constexpr std::pair kAllowTrustLineLockingFlag{
             "allowTrustLineLocking", asfAllowTrustLineLocking};
@@ -634,8 +627,7 @@ public:
 
         FeatureBitset const allFeatures{xrpl::test::jtx::testableAmendments()};
         testAccountFlags(allFeatures);
-        testAccountFlags(allFeatures - featureClawback);
-        testAccountFlags(allFeatures - featureClawback - featureTokenEscrow);
+        testAccountFlags(allFeatures - featureTokenEscrow);
     }
 };
 
diff --git a/src/test/rpc/AccountObjects_test.cpp b/src/test/rpc/AccountObjects_test.cpp
index 4307b7ab7f..31b20b37d4 100644
--- a/src/test/rpc/AccountObjects_test.cpp
+++ b/src/test/rpc/AccountObjects_test.cpp
@@ -692,11 +692,11 @@ public:
 
         {
             std::string const credentialType1 = "credential1";
-            Account issuer("issuer");
+            Account const issuer("issuer");
             env.fund(XRP(5000), issuer);
 
             // gw creates an PermissionedDomain.
-            env(pdomain::setTx(gw, {{issuer, credentialType1}}));
+            env(pdomain::setTx(gw, {{.issuer = issuer, .credType = credentialType1}}));
             env.close();
 
             // Find the PermissionedDomain.
diff --git a/src/test/rpc/AccountTx_test.cpp b/src/test/rpc/AccountTx_test.cpp
index 5d32eac8dc..bd7b07e936 100644
--- a/src/test/rpc/AccountTx_test.cpp
+++ b/src/test/rpc/AccountTx_test.cpp
@@ -131,7 +131,7 @@ class AccountTx_test : public beast::unit_test::Suite
         using namespace test::jtx;
 
         Env env(*this, envconfig([](std::unique_ptr cfg) {
-            cfg->FEES.reference_fee = 10;
+            cfg->fees.referenceFee = 10;
             return cfg;
         }));
         Account const a1{"A1"};
@@ -589,7 +589,7 @@ class AccountTx_test : public beast::unit_test::Suite
             env(payChanCreate, Sig(alie));
             env.close();
 
-            std::string const payChanIndex{strHex(keylet::payChan(alice, gw, payChanSeq).key)};
+            std::string const payChanIndex{strHex(keylet::payChannel(alice, gw, payChanSeq).key)};
 
             {
                 json::Value payChanFund;
@@ -821,7 +821,7 @@ class AccountTx_test : public beast::unit_test::Suite
         using namespace std::chrono_literals;
 
         auto cfg = makeConfig();
-        cfg->FEES.reference_fee = 10;
+        cfg->fees.referenceFee = 10;
         Env env(*this, std::move(cfg));
 
         Account const alice{"alice"};
diff --git a/src/test/rpc/AmendmentBlocked_test.cpp b/src/test/rpc/AmendmentBlocked_test.cpp
index ae4fb99542..850d6db35b 100644
--- a/src/test/rpc/AmendmentBlocked_test.cpp
+++ b/src/test/rpc/AmendmentBlocked_test.cpp
@@ -9,10 +9,10 @@
 #include 
 
 #include 
-#include 
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -20,6 +20,7 @@
 #include 
 
 #include 
+#include 
 
 namespace xrpl {
 
@@ -30,7 +31,7 @@ class AmendmentBlocked_test : public beast::unit_test::Suite
     {
         using namespace test::jtx;
         Env env{*this, envconfig([](std::unique_ptr cfg) {
-                    cfg->loadFromString("[" SECTION_SIGNING_SUPPORT "]\ntrue");
+                    cfg->loadFromString(std::string("[") + Sections::kSigningSupport + "]\ntrue");
                     return cfg;
                 })};
         auto const gw = Account{"gateway"};
diff --git a/src/test/rpc/Book_test.cpp b/src/test/rpc/Book_test.cpp
index bde55d4985..83f7b64b4b 100644
--- a/src/test/rpc/Book_test.cpp
+++ b/src/test/rpc/Book_test.cpp
@@ -1593,7 +1593,7 @@ public:
         auto const carol = permDex.carol;
         auto const domainID = permDex.domainID;
         auto const gw = permDex.gw;
-        auto const usd = permDex.USD;
+        auto const usd = permDex.usd;
 
         auto wsc = makeWSClient(env.app().config());
 
@@ -1718,7 +1718,7 @@ public:
         auto const carol = permDex.carol;
         auto const domainID = permDex.domainID;
         auto const gw = permDex.gw;
-        auto const usd = permDex.USD;
+        auto const usd = permDex.usd;
 
         auto wsc = makeWSClient(env.app().config());
 
diff --git a/src/test/rpc/DeliveredAmount_test.cpp b/src/test/rpc/DeliveredAmount_test.cpp
index 7daf05aaf1..ca5df7102e 100644
--- a/src/test/rpc/DeliveredAmount_test.cpp
+++ b/src/test/rpc/DeliveredAmount_test.cpp
@@ -206,7 +206,7 @@ class DeliveredAmount_test : public beast::unit_test::Suite
         for (bool const afterSwitchTime : {true, false})
         {
             auto cfg = envconfig();
-            cfg->FEES.reference_fee = 10;
+            cfg->fees.referenceFee = 10;
             Env env(*this, std::move(cfg));
             env.fund(XRP(10000), alice, bob, carol, gw);
             env.trust(usd(1000), alice, bob, carol);
@@ -297,7 +297,7 @@ class DeliveredAmount_test : public beast::unit_test::Suite
         for (bool const afterSwitchTime : {true, false})
         {
             auto cfg = envconfig();
-            cfg->FEES.reference_fee = 10;
+            cfg->fees.referenceFee = 10;
             Env env(*this, std::move(cfg));
             env.fund(XRP(10000), alice, bob, carol, gw);
             env.trust(usd(1000), alice, bob, carol);
diff --git a/src/test/rpc/DepositAuthorized_test.cpp b/src/test/rpc/DepositAuthorized_test.cpp
index 89053557a7..e6720602c9 100644
--- a/src/test/rpc/DepositAuthorized_test.cpp
+++ b/src/test/rpc/DepositAuthorized_test.cpp
@@ -324,7 +324,7 @@ public:
         env.close();
 
         // becky authorize any account recognized by carol to make a payment
-        env(deposit::authCredentials(becky, {{carol, credType}}));
+        env(deposit::authCredentials(becky, {{.issuer = carol, .credType = credType}}));
         env.close();
 
         {
@@ -507,7 +507,7 @@ public:
             env.close();
 
             // becky authorize any account recognized by carol to make a payment
-            env(deposit::authCredentials(becky, {{carol, credType2}}));
+            env(deposit::authCredentials(becky, {{.issuer = carol, .credType = credType2}}));
             env.close();
 
             {
diff --git a/src/test/rpc/Feature_test.cpp b/src/test/rpc/Feature_test.cpp
index d899b6dfd9..8cda5965cb 100644
--- a/src/test/rpc/Feature_test.cpp
+++ b/src/test/rpc/Feature_test.cpp
@@ -6,6 +6,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -276,8 +277,8 @@ class Feature_test : public beast::unit_test::Suite
 
         using namespace test::jtx;
         Env env{*this, envconfig([](std::unique_ptr cfg) {
-                    (*cfg)["port_rpc"].set("admin", "");
-                    (*cfg)["port_ws"].set("admin", "");
+                    (*cfg)[Sections::kPortRpc].set(Keys::kAdmin, "");
+                    (*cfg)[Sections::kPortWs].set(Keys::kAdmin, "");
                     return cfg;
                 })};
 
diff --git a/src/test/rpc/GetAggregatePrice_test.cpp b/src/test/rpc/GetAggregatePrice_test.cpp
index 1de08da205..214bd12183 100644
--- a/src/test/rpc/GetAggregatePrice_test.cpp
+++ b/src/test/rpc/GetAggregatePrice_test.cpp
@@ -3,12 +3,21 @@
 #include 
 #include 
 
+#include 
+
 #include 
+#include 
 #include 
+#include 
+#include 
+#include 
 #include 
+#include 
+#include 
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -177,8 +186,7 @@ public:
             auto const all = testableAmendments();
             for (auto const& feats : {all - featureSingleAssetVault - featureLendingProtocol, all})
             {
-                for (auto const mantissaSize :
-                     {MantissaRange::MantissaScale::Small, MantissaRange::MantissaScale::Large})
+                for (auto const mantissaSize : MantissaRange::getAllScales())
                 {
                     // Regardless of the features enabled, RPC is controlled by
                     // the global mantissa size. And since it's a thread-local,
@@ -313,11 +321,91 @@ public:
         }
     }
 
+    void
+    testNullTxReadMeta()
+    {
+        testcase("Null txRead metadata");
+        using namespace jtx;
+
+        // Verify that iteratePriceData handles a null txRead result
+        // gracefully (returns early) rather than crashing with a
+        // nullptr dereference. This simulates local data corruption
+        // where a transaction referenced by sfPreviousTxnID is missing
+        // from the ledger's transaction map.
+        Env env(*this);
+        auto const baseFee = static_cast(env.current()->fees().base.drops());
+
+        Account const owner{"owner"};
+        env.fund(XRP(1'000), owner);
+
+        // Create oracle with XRP/USD and XRP/EUR
+        Oracle oracle(
+            env,
+            {.owner = owner,
+             .series = {{"XRP", "USD", 740, 1}, {"XRP", "EUR", 840, 1}},
+             .fee = baseFee});
+
+        // Update oracle to only have XRP/EUR, pushing XRP/USD into
+        // history. iteratePriceData will need to read historical tx
+        // metadata to find the XRP/USD price.
+        oracle.set(UpdateArg{.series = {{"XRP", "EUR", 850, 1}}, .fee = baseFee});
+
+        OraclesData const oracles{{owner, oracle.documentID()}};
+
+        // Precondition: with an uncorrupted oracle, the historical
+        // traversal must succeed and produce a price for XRP/USD.
+        // This proves the test reaches iteratePriceData's history
+        // path; without it, a future change that breaks the setup
+        // could turn the post-corruption assertion into a vacuous
+        // pass (objectNotFound is reachable from many unrelated
+        // code paths).
+        {
+            auto const ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles);
+            BEAST_EXPECT(!ret.isMember(jss::error));
+            BEAST_EXPECT(ret.isMember(jss::median));
+        }
+
+        // Simulate data corruption: modify the oracle SLE in the open
+        // ledger to have a bogus sfPreviousTxnID that doesn't exist in
+        // any ledger. sfPreviousTxnLgrSeq still points to a valid closed
+        // ledger, so getLedgerBySeq succeeds but txRead returns null.
+        auto const oracleKeylet = keylet::oracle(owner, oracle.documentID());
+        uint256 const bogusTxnID{0xABCABCAB};
+        bool const modified = env.app().getOpenLedger().modify(
+            [&oracleKeylet, &bogusTxnID](OpenView& view, beast::Journal) -> bool {
+                auto const sle = view.read(oracleKeylet);
+                if (!sle)
+                    return false;
+                auto replacement = std::make_shared(*sle, sle->key());
+                replacement->setFieldH256(sfPreviousTxnID, bogusTxnID);
+                view.rawReplace(replacement);
+                return true;
+            });
+
+        // Confirm the injection actually took effect: modify must
+        // report success, and re-reading the SLE must show the
+        // bogus hash. Otherwise the failure-mode assertion below
+        // would not be exercising the null-txRead path at all.
+        BEAST_EXPECT(modified);
+        if (auto const sle = env.current()->read(oracleKeylet); BEAST_EXPECT(sle))
+            BEAST_EXPECT(sle->getFieldH256(sfPreviousTxnID) == bogusTxnID);
+
+        // Query for XRP/USD using the "current" (open) ledger.
+        // The oracle SLE now has a bogus sfPreviousTxnID. The current
+        // oracle only has EUR, so iteratePriceData will try to read
+        // history. txRead returns null for the bogus hash, and the
+        // null check should cause a graceful early return instead of
+        // a nullptr dereference.
+        auto const ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles);
+        BEAST_EXPECT(ret[jss::error].asString() == "objectNotFound");
+    }
+
     void
     run() override
     {
         testErrors();
         testRpc();
+        testNullTxReadMeta();
     }
 };
 
diff --git a/src/test/rpc/JSONRPC_test.cpp b/src/test/rpc/JSONRPC_test.cpp
index 10e4daf933..7f6b123533 100644
--- a/src/test/rpc/JSONRPC_test.cpp
+++ b/src/test/rpc/JSONRPC_test.cpp
@@ -12,12 +12,12 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -2375,8 +2375,9 @@ public:
         testcase("autofill escalated fees");
         using namespace test::jtx;
         Env env{*this, envconfig([](std::unique_ptr cfg) {
-                    cfg->loadFromString("[" SECTION_SIGNING_SUPPORT "]\ntrue");
-                    cfg->section("transaction_queue").set("minimum_txn_in_ledger_standalone", "3");
+                    cfg->loadFromString(std::string("[") + Sections::kSigningSupport + "]\ntrue");
+                    cfg->section(Sections::kTransactionQueue)
+                        .set(Keys::kMinimumTxnInLedgerStandalone, "3");
                     return cfg;
                 })};
         LoadFeeTrack const& feeTrackOuter = env.app().getFeeTrack();
@@ -2697,7 +2698,7 @@ public:
         testcase("autofill NetworkID");
         using namespace test::jtx;
         Env env{*this, envconfig([&](std::unique_ptr cfg) {
-                    cfg->NETWORK_ID = 1025;
+                    cfg->networkId = 1025;
                     return cfg;
                 })};
 
@@ -2743,7 +2744,7 @@ public:
         // "c" (phantom signer) is rPcNzota6B8YBokhYtcTNqQVCngtbnWfux.
 
         Env env(*this, envconfig([](std::unique_ptr cfg) {
-            cfg->FEES.reference_fee = 10;
+            cfg->fees.referenceFee = 10;
             return cfg;
         }));
         env.fund(XRP(100000), a, ed, g);
diff --git a/src/test/rpc/KeyGeneration_test.cpp b/src/test/rpc/KeyGeneration_test.cpp
index bb1f95bef1..aafe6f75a5 100644
--- a/src/test/rpc/KeyGeneration_test.cpp
+++ b/src/test/rpc/KeyGeneration_test.cpp
@@ -19,15 +19,15 @@ namespace xrpl::RPC {
 
 struct KeyStrings
 {
-    char const* account_id;
-    char const* master_key;
-    char const* master_seed;
-    char const* master_seed_hex;
-    char const* public_key;
-    char const* public_key_hex;
-    char const* secret_key_hex;
+    char const* accountId;
+    char const* masterKey;
+    char const* masterSeed;
+    char const* masterSeedHex;
+    char const* publicKey;
+    char const* publicKeyHex;
+    char const* secretKeyHex;
     char const* passphrase;
-    char const* passphrase_warning;
+    char const* passphraseWarning;
 };
 
 namespace common {
@@ -38,45 +38,45 @@ static char const* gMasterSeedHex = "BE6A670A19B209E112146D0A7ED2AAD7";
 }  // namespace common
 
 static KeyStrings const kSecP256K1Strings = {
-    .account_id = "r4Vtj2jrfmTVZGfSP3gH9hQPMqFPQFin8f",
-    .master_key = common::gMasterKey,
-    .master_seed = common::gMasterSeed,
-    .master_seed_hex = common::gMasterSeedHex,
-    .public_key = "aBQxK2YFNqzmAaXNczYcjqDjfiKkLsJUizsr1UBf44RCF8FHdrmX",
-    .public_key_hex = "038AAE247B2344B1837FBED8F57389C8C11774510A3F7D784F2A09F0CB6843236C",
-    .secret_key_hex = "1949ECD889EA71324BC7A30C8E81F4E93CB73EE19D59E9082111E78CC3DDABC2",
+    .accountId = "r4Vtj2jrfmTVZGfSP3gH9hQPMqFPQFin8f",
+    .masterKey = common::gMasterKey,
+    .masterSeed = common::gMasterSeed,
+    .masterSeedHex = common::gMasterSeedHex,
+    .publicKey = "aBQxK2YFNqzmAaXNczYcjqDjfiKkLsJUizsr1UBf44RCF8FHdrmX",
+    .publicKeyHex = "038AAE247B2344B1837FBED8F57389C8C11774510A3F7D784F2A09F0CB6843236C",
+    .secretKeyHex = "1949ECD889EA71324BC7A30C8E81F4E93CB73EE19D59E9082111E78CC3DDABC2",
     .passphrase = common::gPassphrase,
-    .passphrase_warning =
+    .passphraseWarning =
         "This wallet was generated using a user-supplied "
         "passphrase that has low entropy and is vulnerable "
         "to brute-force attacks.",
 };
 
 static KeyStrings const kED25519Strings = {
-    .account_id = "r4qV6xTXerqaZav3MJfSY79ynmc1BSBev1",
-    .master_key = common::gMasterKey,
-    .master_seed = common::gMasterSeed,
-    .master_seed_hex = common::gMasterSeedHex,
-    .public_key = "aKEQmgLMyZPMruJFejUuedp169LgW6DbJt1rej1DJ5hWUMH4pHJ7",
-    .public_key_hex = "ED54C3F5BEDA8BD588B203D23A27398FAD9D20F88A974007D6994659CD7273FE1D",
-    .secret_key_hex = "77AAED2698D56D6676323629160F4EEF21CFD9EE3D0745CC78FA291461F98278",
+    .accountId = "r4qV6xTXerqaZav3MJfSY79ynmc1BSBev1",
+    .masterKey = common::gMasterKey,
+    .masterSeed = common::gMasterSeed,
+    .masterSeedHex = common::gMasterSeedHex,
+    .publicKey = "aKEQmgLMyZPMruJFejUuedp169LgW6DbJt1rej1DJ5hWUMH4pHJ7",
+    .publicKeyHex = "ED54C3F5BEDA8BD588B203D23A27398FAD9D20F88A974007D6994659CD7273FE1D",
+    .secretKeyHex = "77AAED2698D56D6676323629160F4EEF21CFD9EE3D0745CC78FA291461F98278",
     .passphrase = common::gPassphrase,
-    .passphrase_warning =
+    .passphraseWarning =
         "This wallet was generated using a user-supplied "
         "passphrase that has low entropy and is vulnerable "
         "to brute-force attacks.",
 };
 
 static KeyStrings const kStrongBrainStrings = {
-    .account_id = "rBcvXmNb7KPkNdMkpckdWPpbvkWgcV3nir",
-    .master_key = "TED AVON CAVE HOUR BRAG JEFF RIFT NEAL TOLD FAT SEW SAN",
-    .master_seed = "shKdhWka8hS7Es3bpctCZXBiAwfUN",
-    .master_seed_hex = "74BA8389B44F98CF41E795CD91F9C93F",
-    .public_key = "aBRL2sqVuzrsM6zikPB4v8UBHGn1aKkrsxhYEffhcQxB2LKyywE5",
-    .public_key_hex = "03BD334FB9E06C58D69603E9922686528B18A754BC2F2E1ADA095FFE67DE952C64",
-    .secret_key_hex = "84262FB16AA25BE407174C7EDAB531220C30FA4D8A28AA9D564673FB3D34502C",
+    .accountId = "rBcvXmNb7KPkNdMkpckdWPpbvkWgcV3nir",
+    .masterKey = "TED AVON CAVE HOUR BRAG JEFF RIFT NEAL TOLD FAT SEW SAN",
+    .masterSeed = "shKdhWka8hS7Es3bpctCZXBiAwfUN",
+    .masterSeedHex = "74BA8389B44F98CF41E795CD91F9C93F",
+    .publicKey = "aBRL2sqVuzrsM6zikPB4v8UBHGn1aKkrsxhYEffhcQxB2LKyywE5",
+    .publicKeyHex = "03BD334FB9E06C58D69603E9922686528B18A754BC2F2E1ADA095FFE67DE952C64",
+    .secretKeyHex = "84262FB16AA25BE407174C7EDAB531220C30FA4D8A28AA9D564673FB3D34502C",
     .passphrase = "A4yKIRGdzrw0YQ$2%TFKYG9HP*&ok^!sy7E@RwICs",
-    .passphrase_warning =
+    .passphraseWarning =
         "This wallet was generated using a user-supplied "
         "passphrase. It may be vulnerable to brute-force "
         "attacks.",
@@ -120,11 +120,11 @@ public:
         json::Value result = walletPropose(params);
 
         BEAST_EXPECT(!containsError(result));
-        expectEquals(result[jss::account_id], s.account_id);
-        expectEquals(result[jss::master_seed], s.master_seed);
-        expectEquals(result[jss::master_seed_hex], s.master_seed_hex);
-        expectEquals(result[jss::public_key], s.public_key);
-        expectEquals(result[jss::public_key_hex], s.public_key_hex);
+        expectEquals(result[jss::account_id], s.accountId);
+        expectEquals(result[jss::master_seed], s.masterSeed);
+        expectEquals(result[jss::master_seed_hex], s.masterSeedHex);
+        expectEquals(result[jss::public_key], s.publicKey);
+        expectEquals(result[jss::public_key_hex], s.publicKeyHex);
         expectEquals(
             result[jss::key_type],
             params.isMember(jss::key_type) ? params[jss::key_type] : "secp256k1");
@@ -139,7 +139,7 @@ public:
         json::Value params;
         if (keyType)
             params[jss::key_type] = *keyType;
-        params[jss::seed] = strings.master_seed;
+        params[jss::seed] = strings.masterSeed;
 
         auto const wallet = testSecretWallet(params, strings);
         BEAST_EXPECT(!wallet.isMember(jss::warning));
@@ -153,7 +153,7 @@ public:
         json::Value params;
         if (keyType)
             params[jss::key_type] = *keyType;
-        params[jss::seed_hex] = strings.master_seed_hex;
+        params[jss::seed_hex] = strings.masterSeedHex;
 
         auto const wallet = testSecretWallet(params, strings);
         BEAST_EXPECT(!wallet.isMember(jss::warning));
@@ -173,7 +173,7 @@ public:
         auto const wallet = testSecretWallet(params, strings);
         if (value == strings.passphrase)
         {
-            BEAST_EXPECT(wallet[jss::warning] == strings.passphrase_warning);
+            BEAST_EXPECT(wallet[jss::warning] == strings.passphraseWarning);
         }
         else
         {
@@ -187,9 +187,9 @@ public:
         testcase("passphrase");
 
         testLegacyPassphrase(strings.passphrase, keyType, strings);
-        testLegacyPassphrase(strings.master_key, keyType, strings);
-        testLegacyPassphrase(strings.master_seed, keyType, strings);
-        testLegacyPassphrase(strings.master_seed_hex, keyType, strings);
+        testLegacyPassphrase(strings.masterKey, keyType, strings);
+        testLegacyPassphrase(strings.masterSeed, keyType, strings);
+        testLegacyPassphrase(strings.masterSeedHex, keyType, strings);
     }
 
     void
@@ -205,8 +205,8 @@ public:
         json::Value params;
         if (keyType)
             params[jss::key_type] = *keyType;
-        params[jss::seed] = strings.master_seed;
-        params[jss::seed_hex] = strings.master_seed_hex;
+        params[jss::seed] = strings.masterSeed;
+        params[jss::seed_hex] = strings.masterSeedHex;
 
         // Secret fields are mutually exclusive.
         BEAST_EXPECT(containsError(walletPropose(params)));
@@ -294,7 +294,7 @@ public:
     {
         testcase("keypairForSignature - " + (keyType ? *keyType : "no key_type"));
 
-        auto const publicKey = parseBase58(TokenType::AccountPublic, strings.public_key);
+        auto const publicKey = parseBase58(TokenType::AccountPublic, strings.publicKey);
         BEAST_EXPECT(publicKey);
 
         if (!keyType)
@@ -302,7 +302,7 @@ public:
             {
                 json::Value params;
                 json::Value error;
-                params[jss::secret] = strings.master_seed;
+                params[jss::secret] = strings.masterSeed;
 
                 auto ret = keypairForSignature(params, error);
                 BEAST_EXPECT(!containsError(error));
@@ -316,7 +316,7 @@ public:
             {
                 json::Value params;
                 json::Value error;
-                params[jss::secret] = strings.master_seed_hex;
+                params[jss::secret] = strings.masterSeedHex;
 
                 auto ret = keypairForSignature(params, error);
                 BEAST_EXPECT(!containsError(error));
@@ -330,7 +330,7 @@ public:
             {
                 json::Value params;
                 json::Value error;
-                params[jss::secret] = strings.master_key;
+                params[jss::secret] = strings.masterKey;
 
                 auto ret = keypairForSignature(params, error);
                 BEAST_EXPECT(!containsError(error));
@@ -349,7 +349,7 @@ public:
             json::Value error;
 
             params[jss::key_type] = *keyType;
-            params[jss::seed] = strings.master_seed;
+            params[jss::seed] = strings.masterSeed;
 
             auto ret = keypairForSignature(params, error);
             BEAST_EXPECT(!containsError(error));
@@ -365,7 +365,7 @@ public:
             json::Value error;
 
             params[jss::key_type] = *keyType;
-            params[jss::seed_hex] = strings.master_seed_hex;
+            params[jss::seed_hex] = strings.masterSeedHex;
 
             auto ret = keypairForSignature(params, error);
             BEAST_EXPECT(!containsError(error));
@@ -381,7 +381,7 @@ public:
             json::Value error;
 
             params[jss::key_type] = *keyType;
-            params[jss::passphrase] = strings.master_key;
+            params[jss::passphrase] = strings.masterKey;
 
             auto ret = keypairForSignature(params, error);
             BEAST_EXPECT(!containsError(error));
diff --git a/src/test/rpc/LedgerClosed_test.cpp b/src/test/rpc/LedgerClosed_test.cpp
index 81919e04d0..f02d23030d 100644
--- a/src/test/rpc/LedgerClosed_test.cpp
+++ b/src/test/rpc/LedgerClosed_test.cpp
@@ -23,7 +23,7 @@ public:
 
         // This test relies on ledger hash so must lock it to fee 10.
         auto p = envconfig();
-        p->FEES.reference_fee = 10;
+        p->fees.referenceFee = 10;
         Env env{*this, std::move(p), FeatureBitset{}};
         Account const alice{"alice"};
         env.fund(XRP(10000), alice);
diff --git a/src/test/rpc/LedgerEntry_test.cpp b/src/test/rpc/LedgerEntry_test.cpp
index 0f9421beb2..59dbf618f0 100644
--- a/src/test/rpc/LedgerEntry_test.cpp
+++ b/src/test/rpc/LedgerEntry_test.cpp
@@ -133,7 +133,6 @@ getTypeName(FieldType typeID)
         case FieldType::TwoAccountArrayField:
             return "length-2 array of Accounts";
         case FieldType::UInt32Field:
-            return "number";
         case FieldType::UInt64Field:
             return "number";
         default:
@@ -308,7 +307,6 @@ class LedgerEntry_test : public beast::unit_test::Suite
             case FieldType::TwoAccountArrayField:
                 return kTwoAccountArray;
             case FieldType::UInt32Field:
-                return 1;
             case FieldType::UInt64Field:
                 return 1;
             default:
@@ -555,7 +553,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
         using namespace test::jtx;
 
         auto cfg = envconfig();
-        cfg->FEES.reference_fee = 10;
+        cfg->fees.referenceFee = 10;
         Env env{*this, std::move(cfg)};
 
         Account const alice{"alice"};
@@ -784,8 +782,8 @@ class LedgerEntry_test : public beast::unit_test::Suite
                 env,
                 jss::amm,
                 {
-                    {jss::asset, "malformedRequest"},
-                    {jss::asset2, "malformedRequest"},
+                    {.fieldName = jss::asset, .malformedErrorMsg = "malformedRequest"},
+                    {.fieldName = jss::asset2, .malformedErrorMsg = "malformedRequest"},
                 });
         };
         auto getIOU = [&](Env& env) -> PrettyAsset { return alice["USD"]; };
@@ -900,9 +898,9 @@ class LedgerEntry_test : public beast::unit_test::Suite
                 env,
                 jss::credential,
                 {
-                    {jss::subject, "malformedRequest"},
-                    {jss::issuer, "malformedRequest"},
-                    {jss::credential_type, "malformedRequest"},
+                    {.fieldName = jss::subject, .malformedErrorMsg = "malformedRequest"},
+                    {.fieldName = jss::issuer, .malformedErrorMsg = "malformedRequest"},
+                    {.fieldName = jss::credential_type, .malformedErrorMsg = "malformedRequest"},
                 });
         }
     }
@@ -954,8 +952,8 @@ class LedgerEntry_test : public beast::unit_test::Suite
                 env,
                 jss::delegate,
                 {
-                    {jss::account, "malformedAddress"},
-                    {jss::authorize, "malformedAddress"},
+                    {.fieldName = jss::account, .malformedErrorMsg = "malformedAddress"},
+                    {.fieldName = jss::authorize, .malformedErrorMsg = "malformedAddress"},
                 });
         }
     }
@@ -1011,8 +1009,10 @@ class LedgerEntry_test : public beast::unit_test::Suite
                 env,
                 jss::deposit_preauth,
                 {
-                    {jss::owner, "malformedOwner"},
-                    {jss::authorized, "malformedAuthorized", false},
+                    {.fieldName = jss::owner, .malformedErrorMsg = "malformedOwner"},
+                    {.fieldName = jss::authorized,
+                     .malformedErrorMsg = "malformedAuthorized",
+                     .required = false},
                 });
         }
     }
@@ -1037,7 +1037,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
             // Setup Bob with DepositAuth
             env(fset(bob, asfDepositAuth));
             env.close();
-            env(deposit::authCredentials(bob, {{issuer, credType}}));
+            env(deposit::authCredentials(bob, {{.issuer = issuer, .credType = credType}}));
             env.close();
         }
 
@@ -1458,7 +1458,10 @@ class LedgerEntry_test : public beast::unit_test::Suite
         {
             // Malformed escrow fields
             runLedgerEntryTest(
-                env, jss::escrow, {{jss::owner, "malformedOwner"}, {jss::seq, "malformedSeq"}});
+                env,
+                jss::escrow,
+                {{.fieldName = jss::owner, .malformedErrorMsg = "malformedOwner"},
+                 {.fieldName = jss::seq, .malformedErrorMsg = "malformedSeq"}});
         }
     }
 
@@ -1471,7 +1474,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
 
         // positive test
         {
-            Keylet const keylet = keylet::fees();
+            Keylet const keylet = keylet::feeSettings();
             json::Value jvParams;
             jvParams[jss::fee] = to_string(keylet.key);
             json::Value const jrr =
@@ -1521,7 +1524,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
         uint256 const nftokenID0 = token::getNextID(env, issuer, 0, tfTransferable);
         env(token::mint(issuer, 0), Txflags(tfTransferable));
         env.close();
-        uint256 const offerID = keylet::nftoffer(issuer, env.seq(issuer)).key;
+        uint256 const offerID = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
         env(token::createOffer(issuer, nftokenID0, drops(1)),
             token::Destination(buyer),
             Txflags(tfSellNFToken));
@@ -1555,7 +1558,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
         env(token::mint(issuer, 0), Txflags(tfTransferable));
         env.close();
 
-        auto const nftpage = keylet::nftpageMax(issuer);
+        auto const nftpage = keylet::nftokenPageMax(issuer);
         BEAST_EXPECT(env.le(nftpage) != nullptr);
 
         {
@@ -1667,7 +1670,8 @@ class LedgerEntry_test : public beast::unit_test::Suite
             runLedgerEntryTest(
                 env,
                 jss::offer,
-                {{jss::account, "malformedAddress"}, {jss::seq, "malformedRequest"}});
+                {{.fieldName = jss::account, .malformedErrorMsg = "malformedAddress"},
+                 {.fieldName = jss::seq, .malformedErrorMsg = "malformedRequest"}});
         }
     }
 
@@ -1704,7 +1708,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
 
         std::string const ledgerHash{to_string(env.closed()->header().hash)};
 
-        uint256 const payChanIndex{keylet::payChan(alice, env.master, env.seq(alice) - 1).key};
+        uint256 const payChanIndex{keylet::payChannel(alice, env.master, env.seq(alice) - 1).key};
         {
             // Request the payment channel using its index.
             json::Value jvParams;
@@ -1774,8 +1778,8 @@ class LedgerEntry_test : public beast::unit_test::Suite
                     env,
                     fieldName,
                     {
-                        {jss::accounts, "malformedRequest"},
-                        {jss::currency, "malformedCurrency"},
+                        {.fieldName = jss::accounts, .malformedErrorMsg = "malformedRequest"},
+                        {.fieldName = jss::currency, .malformedErrorMsg = "malformedCurrency"},
                     });
             }
             {
@@ -1955,8 +1959,8 @@ class LedgerEntry_test : public beast::unit_test::Suite
                 env,
                 jss::ticket,
                 {
-                    {jss::account, "malformedAddress"},
-                    {jss::ticket_seq, "malformedRequest"},
+                    {.fieldName = jss::account, .malformedErrorMsg = "malformedAddress"},
+                    {.fieldName = jss::ticket_seq, .malformedErrorMsg = "malformedRequest"},
                 });
         }
     }
@@ -2034,8 +2038,9 @@ class LedgerEntry_test : public beast::unit_test::Suite
                 env,
                 jss::oracle,
                 {
-                    {jss::account, "malformedAccount"},
-                    {jss::oracle_document_id, "malformedDocumentID"},
+                    {.fieldName = jss::account, .malformedErrorMsg = "malformedAccount"},
+                    {.fieldName = jss::oracle_document_id,
+                     .malformedErrorMsg = "malformedDocumentID"},
                 });
         }
     }
@@ -2172,7 +2177,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
         env.close();
 
         auto const seq = env.seq(alice);
-        env(pdomain::setTx(alice, {{alice, "first credential"}}));
+        env(pdomain::setTx(alice, {{.issuer = alice, .credType = "first credential"}}));
         env.close();
         auto const objects = pdomain::getObjects(alice, env);
         if (!BEAST_EXPECT(objects.size() == 1))
@@ -2221,8 +2226,8 @@ class LedgerEntry_test : public beast::unit_test::Suite
                 env,
                 jss::permissioned_domain,
                 {
-                    {jss::account, "malformedAddress"},
-                    {jss::seq, "malformedRequest"},
+                    {.fieldName = jss::account, .malformedErrorMsg = "malformedAddress"},
+                    {.fieldName = jss::seq, .malformedErrorMsg = "malformedRequest"},
                 });
         }
     }
@@ -2237,7 +2242,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
         Account const bob{"bob"};
 
         Env env{*this, envconfig([](auto cfg) {
-                    cfg->START_UP = StartUpType::Fresh;
+                    cfg->startUp = StartUpType::Fresh;
                     return cfg;
                 })};
 
@@ -2414,7 +2419,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
         };
 
         test(jss::amendments, jss::Amendments, keylet::amendments(), true);
-        test(jss::fee, jss::FeeSettings, keylet::fees(), true);
+        test(jss::fee, jss::FeeSettings, keylet::feeSettings(), true);
         // There won't be an nunl
         test(jss::nunl, jss::NegativeUNL, keylet::negativeUNL(), false);
         // Can only get the short skip list this way
@@ -2430,7 +2435,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
         Account const bob{"bob"};
 
         Env env{*this, envconfig([](auto cfg) {
-                    cfg->START_UP = StartUpType::Fresh;
+                    cfg->startUp = StartUpType::Fresh;
                     return cfg;
                 })};
 
diff --git a/src/test/rpc/LedgerRPC_test.cpp b/src/test/rpc/LedgerRPC_test.cpp
index f8d7901ec0..af56a9e9ba 100644
--- a/src/test/rpc/LedgerRPC_test.cpp
+++ b/src/test/rpc/LedgerRPC_test.cpp
@@ -15,6 +15,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -268,7 +269,7 @@ class LedgerRPC_test : public beast::unit_test::Suite
         using namespace test::jtx;
 
         auto cfg = envconfig();
-        cfg->FEES.reference_fee = 10;
+        cfg->fees.referenceFee = 10;
         Env env{*this, std::move(cfg), FeatureBitset{}};  // hashes requested below
                                                           // assume no amendments
         env.fund(XRP(10000), "alice");
@@ -431,13 +432,13 @@ class LedgerRPC_test : public beast::unit_test::Suite
         testcase("Ledger with Queued Transactions");
         using namespace test::jtx;
         auto cfg = envconfig([](std::unique_ptr cfg) {
-            auto& section = cfg->section("transaction_queue");
-            section.set("minimum_txn_in_ledger_standalone", "3");
-            section.set("normal_consensus_increase_percent", "0");
+            auto& section = cfg->section(Sections::kTransactionQueue);
+            section.set(Keys::kMinimumTxnInLedgerStandalone, "3");
+            section.set(Keys::kNormalConsensusIncreasePercent, "0");
             return cfg;
         });
 
-        cfg->FEES.reference_fee = 10;
+        cfg->fees.referenceFee = 10;
         Env env(*this, std::move(cfg));
 
         json::Value jv;
diff --git a/src/test/rpc/LedgerRequest_test.cpp b/src/test/rpc/LedgerRequest_test.cpp
index 0a2b51dc8b..4759256b96 100644
--- a/src/test/rpc/LedgerRequest_test.cpp
+++ b/src/test/rpc/LedgerRequest_test.cpp
@@ -149,7 +149,7 @@ public:
         using namespace test::jtx;
 
         auto cfg = envconfig();
-        cfg->FEES.reference_fee = 10;
+        cfg->fees.referenceFee = 10;
         Env env{*this, std::move(cfg), FeatureBitset{}};  // the hashes being checked below
                                                           // assume no amendments
         Account const gw{"gateway"};
@@ -301,8 +301,8 @@ public:
         using namespace test::jtx;
         using namespace std::chrono_literals;
         Env env{*this, envconfig([](std::unique_ptr cfg) {
-                    cfg->FEES.reference_fee = 10;
-                    cfg->NODE_SIZE = 0;
+                    cfg->fees.referenceFee = 10;
+                    cfg->nodeSize = 0;
                     return cfg;
                 })};
         Account const gw{"gateway"};
diff --git a/src/test/rpc/ManifestRPC_test.cpp b/src/test/rpc/ManifestRPC_test.cpp
index c29f3d4e39..7e89cd029e 100644
--- a/src/test/rpc/ManifestRPC_test.cpp
+++ b/src/test/rpc/ManifestRPC_test.cpp
@@ -4,9 +4,9 @@
 #include 
 
 #include 
-#include 
 
 #include 
+#include 
 #include 
 
 #include 
@@ -48,7 +48,7 @@ public:
         using namespace jtx;
         std::string const key = "n949f75evCHwgyP4fPVgaHqNHxUVN15PsJEZ3B3HnXPcPjcZAoy7";
         Env env{*this, envconfig([&key](std::unique_ptr cfg) {
-                    cfg->section(SECTION_VALIDATORS).append(key);
+                    cfg->section(Sections::kValidators).append(key);
                     return cfg;
                 })};
         {
diff --git a/src/test/rpc/NoRippleCheck_test.cpp b/src/test/rpc/NoRippleCheck_test.cpp
index 3793efffcd..9c17d291a1 100644
--- a/src/test/rpc/NoRippleCheck_test.cpp
+++ b/src/test/rpc/NoRippleCheck_test.cpp
@@ -296,7 +296,7 @@ class NoRippleCheckLimits_test : public beast::unit_test::Suite
             if (c.balance() > kWarningThreshold)
             {
                 using ct = beast::AbstractClock;
-                c.entry().local_balance =
+                c.entry().localBalance =
                     DecayingSample{steady_clock::now()};
             }
         };
diff --git a/src/test/rpc/RPCCall_test.cpp b/src/test/rpc/RPCCall_test.cpp
index 66ddfde8e2..cd3cd3cb41 100644
--- a/src/test/rpc/RPCCall_test.cpp
+++ b/src/test/rpc/RPCCall_test.cpp
@@ -5843,7 +5843,7 @@ makeNetworkConfig(uint32_t networkID)
 {
     using namespace test::jtx;
     return envconfig([&](std::unique_ptr cfg) {
-        cfg->NETWORK_ID = networkID;
+        cfg->networkId = networkID;
         return cfg;
     });
 }
diff --git a/src/test/rpc/RPCOverload_test.cpp b/src/test/rpc/RPCOverload_test.cpp
index b1030ebea5..33bc84eab0 100644
--- a/src/test/rpc/RPCOverload_test.cpp
+++ b/src/test/rpc/RPCOverload_test.cpp
@@ -8,14 +8,15 @@
 #include 
 
 #include 
-#include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 
 #include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -29,7 +30,7 @@ public:
         testcase << "Overload " << (useWS ? "WS" : "HTTP") << " RPC client";
         using namespace jtx;
         Env env{*this, envconfig([](std::unique_ptr cfg) {
-                    cfg->loadFromString("[" SECTION_SIGNING_SUPPORT "]\ntrue");
+                    cfg->loadFromString(std::string("[") + Sections::kSigningSupport + "]\ntrue");
                     return noAdmin(std::move(cfg));
                 })};
 
diff --git a/src/test/rpc/ServerInfo_test.cpp b/src/test/rpc/ServerInfo_test.cpp
index 1d52f3b83e..52a1e6cdb0 100644
--- a/src/test/rpc/ServerInfo_test.cpp
+++ b/src/test/rpc/ServerInfo_test.cpp
@@ -3,9 +3,9 @@
 #include 
 
 #include 
-#include 
 
 #include 
+#include 
 #include 
 #include 
 
@@ -108,9 +108,9 @@ admin = 127.0.0.1
             Env env(*this, makeValidatorConfig());
             auto const& config = env.app().config();
 
-            auto const rpcPort = config["port_rpc"].get("port");
-            auto const grpcPort = config[SECTION_PORT_GRPC].get("port");
-            auto const wsPort = config["port_ws"].get("port");
+            auto const rpcPort = config[Sections::kPortRpc].get(Keys::kPort);
+            auto const grpcPort = config[Sections::kPortGrpc].get(Keys::kPort);
+            auto const wsPort = config[Sections::kPortWs].get(Keys::kPort);
             BEAST_EXPECT(grpcPort);
             BEAST_EXPECT(rpcPort);
             BEAST_EXPECT(wsPort);
diff --git a/src/test/rpc/Simulate_test.cpp b/src/test/rpc/Simulate_test.cpp
index b66b3dfe9f..5ea79c3996 100644
--- a/src/test/rpc/Simulate_test.cpp
+++ b/src/test/rpc/Simulate_test.cpp
@@ -21,6 +21,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -66,7 +67,7 @@ class Simulate_test : public beast::unit_test::Suite
         {
             auto const unHexed = strUnHex(result[jss::tx_blob].asString());
             SerialIter sitTrans(makeSlice(*unHexed));  // NOLINT(bugprone-unchecked-optional-access)
-            txJson = STObject(std::ref(sitTrans), kSfGeneric).getJson(JsonOptions::Values::None);
+            txJson = STObject(std::ref(sitTrans), sfGeneric).getJson(JsonOptions::Values::None);
         }
         BEAST_EXPECT(txJson[jss::TransactionType] == tx[jss::TransactionType]);
         BEAST_EXPECT(txJson[jss::Account] == tx[jss::Account]);
@@ -162,7 +163,7 @@ class Simulate_test : public beast::unit_test::Suite
         {
             auto unHexed = strUnHex(txResult[jss::meta_blob].asString());
             SerialIter sitTrans(makeSlice(*unHexed));  // NOLINT(bugprone-unchecked-optional-access)
-            return STObject(std::ref(sitTrans), kSfGeneric).getJson(JsonOptions::Values::None);
+            return STObject(std::ref(sitTrans), sfGeneric).getJson(JsonOptions::Values::None);
         }
 
         return txResult[jss::meta];
@@ -429,7 +430,7 @@ class Simulate_test : public beast::unit_test::Suite
         using namespace jtx;
 
         Env env(*this, envconfig([](std::unique_ptr cfg) {
-            cfg->section("transaction_queue").set("minimum_txn_in_ledger_standalone", "3");
+            cfg->section(Sections::kTransactionQueue).set(Keys::kMinimumTxnInLedgerStandalone, "3");
             return cfg;
         }));
 
@@ -492,7 +493,7 @@ class Simulate_test : public beast::unit_test::Suite
 
         using namespace jtx;
         Env env{*this, envconfig([&](std::unique_ptr cfg) {
-                    cfg->NETWORK_ID = 0;
+                    cfg->networkId = 0;
                     return cfg;
                 })};
         static auto const kNewDomain = "123ABC";
@@ -1032,7 +1033,7 @@ class Simulate_test : public beast::unit_test::Suite
 
         using namespace jtx;
         Env env{*this, envconfig([&](std::unique_ptr cfg) {
-                    cfg->NETWORK_ID = 1025;
+                    cfg->networkId = 1025;
                     return cfg;
                 })};
         static auto const kNewDomain = "123ABC";
@@ -1097,7 +1098,7 @@ class Simulate_test : public beast::unit_test::Suite
         using namespace jtx;
         using namespace std::chrono_literals;
         Env env{*this, envconfig([&](std::unique_ptr cfg) {
-                    cfg->NETWORK_ID = 1025;
+                    cfg->networkId = 1025;
                     return cfg;
                 })};
 
diff --git a/src/test/rpc/Subscribe_test.cpp b/src/test/rpc/Subscribe_test.cpp
index 3011eb4170..97c5290947 100644
--- a/src/test/rpc/Subscribe_test.cpp
+++ b/src/test/rpc/Subscribe_test.cpp
@@ -18,12 +18,12 @@
 
 #include 
 #include 
-#include 
 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -321,7 +321,7 @@ public:
         using namespace std::chrono_literals;
         using namespace jtx;
         Env env(*this, envconfig([](std::unique_ptr cfg) {
-            cfg->FEES.reference_fee = 10;
+            cfg->fees.referenceFee = 10;
             cfg = singleThreadIo(std::move(cfg));
             return cfg;
         }));
@@ -431,9 +431,10 @@ public:
 
         Env env{*this, singleThreadIo(envconfig(validator, "")), features};
         auto& cfg = env.app().config();
-        if (!BEAST_EXPECT(cfg.section(SECTION_VALIDATION_SEED).empty()))
+        if (!BEAST_EXPECT(cfg.section(Sections::kValidationSeed).empty()))
             return;
-        auto const parsedseed = parseBase58(cfg.section(SECTION_VALIDATION_SEED).values()[0]);
+        auto const parsedseed =
+            parseBase58(cfg.section(Sections::kValidationSeed).values()[0]);
         if (BEAST_EXPECT(parsedseed); not parsedseed.has_value())
             return;
 
@@ -1319,7 +1320,7 @@ public:
         auto const carol = permDex.carol;
         auto const domainID = permDex.domainID;
         auto const gw = permDex.gw;
-        auto const usd = permDex.USD;
+        auto const usd = permDex.usd;
 
         auto wsc = makeWSClient(env.app().config());
 
@@ -1451,12 +1452,12 @@ public:
             // Alice creates one sell offer for each NFT
             // Verify the offer indexes are correct in the NFTokenCreateOffer tx
             // meta
-            uint256 const aliceOfferIndex1 = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex1 = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftId1, drops(1)), Txflags(tfSellNFToken));
             BEAST_EXPECT(env.syncClose());
             verifyNFTokenOfferID(aliceOfferIndex1);
 
-            uint256 const aliceOfferIndex2 = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex2 = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftId2, drops(1)), Txflags(tfSellNFToken));
             BEAST_EXPECT(env.syncClose());
             verifyNFTokenOfferID(aliceOfferIndex2);
@@ -1470,7 +1471,7 @@ public:
 
             // Bobs creates a buy offer for nftId1
             // Verify the offer id is correct in the NFTokenCreateOffer tx meta
-            auto const bobBuyOfferIndex = keylet::nftoffer(bob, env.seq(bob)).key;
+            auto const bobBuyOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key;
             env(token::createOffer(bob, nftId1, drops(1)), token::Owner(alice));
             BEAST_EXPECT(env.syncClose());
             verifyNFTokenOfferID(bobBuyOfferIndex);
@@ -1491,7 +1492,7 @@ public:
             verifyNFTokenID(nftId);
 
             // Alice creates sell offer and set broker as destination
-            uint256 const offerAliceToBroker = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const offerAliceToBroker = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftId, drops(1)),
                 token::Destination(broker),
                 Txflags(tfSellNFToken));
@@ -1499,7 +1500,7 @@ public:
             verifyNFTokenOfferID(offerAliceToBroker);
 
             // Bob creates buy offer
-            uint256 const offerBobToBroker = keylet::nftoffer(bob, env.seq(bob)).key;
+            uint256 const offerBobToBroker = keylet::nftokenOffer(bob, env.seq(bob)).key;
             env(token::createOffer(bob, nftId, drops(1)), token::Owner(alice));
             BEAST_EXPECT(env.syncClose());
             verifyNFTokenOfferID(offerBobToBroker);
@@ -1520,12 +1521,12 @@ public:
             verifyNFTokenID(nftId);
 
             // Alice creates 2 sell offers for the same NFT
-            uint256 const aliceOfferIndex1 = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex1 = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftId, drops(1)), Txflags(tfSellNFToken));
             BEAST_EXPECT(env.syncClose());
             verifyNFTokenOfferID(aliceOfferIndex1);
 
-            uint256 const aliceOfferIndex2 = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex2 = keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::createOffer(alice, nftId, drops(1)), Txflags(tfSellNFToken));
             BEAST_EXPECT(env.syncClose());
             verifyNFTokenOfferID(aliceOfferIndex2);
@@ -1539,7 +1540,8 @@ public:
 
         if (features[featureNFTokenMintOffer])
         {
-            uint256 const aliceMintWithOfferIndex1 = keylet::nftoffer(alice, env.seq(alice)).key;
+            uint256 const aliceMintWithOfferIndex1 =
+                keylet::nftokenOffer(alice, env.seq(alice)).key;
             env(token::mint(alice), token::Amount(XRP(0)));
             BEAST_EXPECT(env.syncClose());
             verifyNFTokenOfferID(aliceMintWithOfferIndex1);
diff --git a/src/test/rpc/TransactionEntry_test.cpp b/src/test/rpc/TransactionEntry_test.cpp
index c5f562b2b2..8724e2974d 100644
--- a/src/test/rpc/TransactionEntry_test.cpp
+++ b/src/test/rpc/TransactionEntry_test.cpp
@@ -32,7 +32,7 @@ class TransactionEntry_test : public beast::unit_test::Suite
         testcase("Invalid request params");
         using namespace test::jtx;
         Env env{*this, envconfig([](std::unique_ptr cfg) {
-                    cfg->FEES.reference_fee = 10;
+                    cfg->fees.referenceFee = 10;
                     return cfg;
                 })};
 
@@ -139,7 +139,7 @@ class TransactionEntry_test : public beast::unit_test::Suite
         testcase("Basic request API version " + std::to_string(apiVersion));
         using namespace test::jtx;
         Env env{*this, envconfig([](std::unique_ptr cfg) {
-                    cfg->FEES.reference_fee = 10;
+                    cfg->fees.referenceFee = 10;
                     return cfg;
                 })};
 
diff --git a/src/test/rpc/Transaction_test.cpp b/src/test/rpc/Transaction_test.cpp
index 47cc3687b0..8c50736b85 100644
--- a/src/test/rpc/Transaction_test.cpp
+++ b/src/test/rpc/Transaction_test.cpp
@@ -47,7 +47,7 @@ class Transaction_test : public beast::unit_test::Suite
     {
         using namespace test::jtx;
         return envconfig([&](std::unique_ptr cfg) {
-            cfg->NETWORK_ID = networkID;
+            cfg->networkId = networkID;
             return cfg;
         });
     }
@@ -749,7 +749,7 @@ class Transaction_test : public beast::unit_test::Suite
         using std::to_string;
 
         Env env{*this, envconfig([](std::unique_ptr cfg) {
-                    cfg->FEES.reference_fee = 10;
+                    cfg->fees.referenceFee = 10;
                     return cfg;
                 })};
         Account const alice{"alice"};
@@ -827,7 +827,7 @@ class Transaction_test : public beast::unit_test::Suite
         using std::to_string;
 
         Env env{*this, envconfig([](std::unique_ptr cfg) {
-                    cfg->FEES.reference_fee = 10;
+                    cfg->fees.referenceFee = 10;
                     return cfg;
                 })};
         Account const alice{"alice"};
diff --git a/src/test/rpc/ValidatorInfo_test.cpp b/src/test/rpc/ValidatorInfo_test.cpp
index 8acdca8a1f..ece0aa1224 100644
--- a/src/test/rpc/ValidatorInfo_test.cpp
+++ b/src/test/rpc/ValidatorInfo_test.cpp
@@ -4,9 +4,9 @@
 #include 
 
 #include 
-#include 
 
 #include 
+#include 
 #include 
 
 #include 
@@ -68,7 +68,7 @@ public:
             "5AqDedFv5TJa2w0i21eq3MYywLVJZnFOr7C0kw2AiTzSCjIzditQ8=";
 
         Env env{*this, envconfig([&tokenBlob](std::unique_ptr cfg) {
-                    cfg->section(SECTION_VALIDATOR_TOKEN).append(tokenBlob);
+                    cfg->section(Sections::kValidatorToken).append(tokenBlob);
                     return cfg;
                 })};
         {
diff --git a/src/test/rpc/ValidatorRPC_test.cpp b/src/test/rpc/ValidatorRPC_test.cpp
index 1c6fb94fac..360cf13c75 100644
--- a/src/test/rpc/ValidatorRPC_test.cpp
+++ b/src/test/rpc/ValidatorRPC_test.cpp
@@ -5,12 +5,12 @@
 #include 
 #include 
 #include 
-#include 
 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -90,7 +90,7 @@ public:
             *this,
             envconfig([&keys](std::unique_ptr cfg) {
                 for (auto const& key : keys)
-                    cfg->section(SECTION_VALIDATORS).append(key);
+                    cfg->section(Sections::kValidators).append(key);
                 return cfg;
             }),
         };
@@ -200,8 +200,8 @@ public:
             Env env{
                 *this,
                 envconfig([&](std::unique_ptr cfg) {
-                    cfg->section(SECTION_VALIDATOR_LIST_SITES).append(siteURI);
-                    cfg->section(SECTION_VALIDATOR_LIST_KEYS)
+                    cfg->section(Sections::kValidatorListSites).append(siteURI);
+                    cfg->section(Sections::kValidatorListKeys)
                         .append(strHex(server->publisherPublic()));
                     return cfg;
                 }),
@@ -260,8 +260,8 @@ public:
             Env env{
                 *this,
                 envconfig([&](std::unique_ptr cfg) {
-                    cfg->section(SECTION_VALIDATOR_LIST_SITES).append(siteURI);
-                    cfg->section(SECTION_VALIDATOR_LIST_KEYS)
+                    cfg->section(Sections::kValidatorListSites).append(siteURI);
+                    cfg->section(Sections::kValidatorListKeys)
                         .append(strHex(server->publisherPublic()));
                     return cfg;
                 }),
@@ -323,8 +323,8 @@ public:
             Env env{
                 *this,
                 envconfig([&](std::unique_ptr cfg) {
-                    cfg->section(SECTION_VALIDATOR_LIST_SITES).append(siteURI);
-                    cfg->section(SECTION_VALIDATOR_LIST_KEYS)
+                    cfg->section(Sections::kValidatorListSites).append(siteURI);
+                    cfg->section(Sections::kValidatorListKeys)
                         .append(strHex(server->publisherPublic()));
                     return cfg;
                 }),
@@ -416,8 +416,8 @@ public:
             Env env{
                 *this,
                 envconfig([&](std::unique_ptr cfg) {
-                    cfg->section(SECTION_VALIDATOR_LIST_SITES).append(siteURI);
-                    cfg->section(SECTION_VALIDATOR_LIST_KEYS)
+                    cfg->section(Sections::kValidatorListSites).append(siteURI);
+                    cfg->section(Sections::kValidatorListKeys)
                         .append(strHex(server->publisherPublic()));
                     return cfg;
                 }),
diff --git a/src/test/rpc/Version_test.cpp b/src/test/rpc/Version_test.cpp
index 75d74b4155..20740bfe53 100644
--- a/src/test/rpc/Version_test.cpp
+++ b/src/test/rpc/Version_test.cpp
@@ -65,7 +65,7 @@ class Version_test : public beast::unit_test::Suite
             "{\"api_version\": " + std::to_string(RPC::kApiMinimumSupportedVersion - 1) + "}");
         BEAST_EXPECT(badVersion(re));
 
-        BEAST_EXPECT(env.app().config().BETA_RPC_API);
+        BEAST_EXPECT(env.app().config().betaRpcApi);
         re = env.rpc(
             "json",
             "version",
@@ -160,7 +160,7 @@ class Version_test : public beast::unit_test::Suite
         using namespace test::jtx;
         Env env{*this};
 
-        BEAST_EXPECT(env.app().config().BETA_RPC_API);
+        BEAST_EXPECT(env.app().config().betaRpcApi);
         auto const withoutApiVerion = std::string("{ ") +
             "\"jsonrpc\": \"2.0\", "
             "\"ripplerpc\": \"2.0\", "
@@ -194,19 +194,19 @@ class Version_test : public beast::unit_test::Suite
         testcase("config test");
         {
             Config const c;
-            BEAST_EXPECT(c.BETA_RPC_API == false);
+            BEAST_EXPECT(c.betaRpcApi == false);
         }
 
         {
             Config c;
             c.loadFromString("\n[beta_rpc_api]\n1\n");
-            BEAST_EXPECT(c.BETA_RPC_API == true);
+            BEAST_EXPECT(c.betaRpcApi == true);
         }
 
         {
             Config c;
             c.loadFromString("\n[beta_rpc_api]\n0\n");
-            BEAST_EXPECT(c.BETA_RPC_API == false);
+            BEAST_EXPECT(c.betaRpcApi == false);
         }
     }
 
@@ -220,7 +220,7 @@ class Version_test : public beast::unit_test::Suite
                     c->loadFromString("\n[beta_rpc_api]\n1\n");
                     return c;
                 })};
-        if (!BEAST_EXPECT(env.app().config().BETA_RPC_API == true))
+        if (!BEAST_EXPECT(env.app().config().betaRpcApi == true))
             return;
 
         auto jrr = env.rpc(
diff --git a/src/test/server/ServerStatus_test.cpp b/src/test/server/ServerStatus_test.cpp
index eba36462b6..4a9c12c96b 100644
--- a/src/test/server/ServerStatus_test.cpp
+++ b/src/test/server/ServerStatus_test.cpp
@@ -8,6 +8,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -55,22 +56,23 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
     static auto
     makeConfig(std::string const& proto, bool admin = true, bool credentials = false)
     {
-        auto const sectionName = boost::starts_with(proto, "h") ? "port_rpc" : "port_ws";
+        auto const sectionName =
+            boost::starts_with(proto, "h") ? Sections::kPortRpc : Sections::kPortWs;
         auto p = jtx::envconfig();
 
-        p->overwrite(sectionName, "protocol", proto);
+        p->overwrite(sectionName, Keys::kProtocol, proto);
         if (!admin)
-            p->overwrite(sectionName, "admin", "");
+            p->overwrite(sectionName, Keys::kAdmin, "");
 
         if (credentials)
         {
-            (*p)[sectionName].set("admin_password", "p");
-            (*p)[sectionName].set("admin_user", "u");
+            (*p)[sectionName].set(Keys::kAdminPassword, "p");
+            (*p)[sectionName].set(Keys::kAdminUser, "u");
         }
 
         p->overwrite(
-            boost::starts_with(proto, "h") ? "port_ws" : "port_rpc",
-            "protocol",
+            boost::starts_with(proto, "h") ? Sections::kPortWs : Sections::kPortRpc,
+            Keys::kProtocol,
             boost::starts_with(proto, "h") ? "ws" : "http");
 
         if (proto == "https")
@@ -78,11 +80,11 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
             // this port is here to allow the env to create its internal client,
             // which requires an http endpoint to talk to. In the connection
             // failure test, this endpoint should never be used
-            (*p)["server"].append("port_alt");
-            (*p)["port_alt"].set("ip", getEnvLocalhostAddr());
-            (*p)["port_alt"].set("port", "7099");
-            (*p)["port_alt"].set("protocol", "http");
-            (*p)["port_alt"].set("admin", getEnvLocalhostAddr());
+            (*p)[Sections::kServer].append("port_alt");
+            (*p)["port_alt"].set(Keys::kIp, getEnvLocalhostAddr());
+            (*p)["port_alt"].set(Keys::kPort, "7099");
+            (*p)["port_alt"].set(Keys::kProtocol, "http");
+            (*p)["port_alt"].set(Keys::kAdmin, getEnvLocalhostAddr());
         }
 
         return p;
@@ -212,8 +214,8 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         boost::beast::http::response& resp,
         boost::system::error_code& ec)
     {
-        auto const port = env.app().config()["port_ws"].get("port");
-        auto ip = env.app().config()["port_ws"].get("ip");
+        auto const port = env.app().config()[Sections::kPortWs].get(Keys::kPort);
+        auto ip = env.app().config()[Sections::kPortWs].get(Keys::kIp);
         // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
         doRequest(yield, makeWSUpgrade(*ip, *port), *ip, *port, secure, resp, ec);
         return;
@@ -229,8 +231,8 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         std::string const& body = "",
         MyFields const& fields = {})
     {
-        auto const port = env.app().config()["port_rpc"].get("port");
-        auto const ip = env.app().config()["port_rpc"].get("ip");
+        auto const port = env.app().config()[Sections::kPortRpc].get(Keys::kPort);
+        auto const ip = env.app().config()[Sections::kPortRpc].get(Keys::kIp);
         // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
         doRequest(yield, makeHTTPRequest(*ip, *port, body, fields), *ip, *port, secure, resp, ec);
         return;
@@ -298,12 +300,13 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
 
         if (admin && credentials)
         {
-            auto const user =
-                env.app().config()[protoWs ? "port_ws" : "port_rpc"].get("admin_user");
+            auto const user = env.app()
+                                  .config()[protoWs ? Sections::kPortWs : Sections::kPortRpc]
+                                  .get(Keys::kAdminUser);
 
-            auto const password =
-                env.app().config()[protoWs ? "port_ws" : "port_rpc"].get(
-                    "admin_password");
+            auto const password = env.app()
+                                      .config()[protoWs ? Sections::kPortWs : Sections::kPortRpc]
+                                      .get(Keys::kAdminPassword);
 
             // 1 - FAILS with wrong pass
             // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
@@ -368,7 +371,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         testcase("WS client to http server fails");
         using namespace jtx;
         Env env{*this, envconfig([](std::unique_ptr cfg) {
-                    cfg->section("port_ws").set("protocol", "http,https");
+                    cfg->section(Sections::kPortWs).set(Keys::kProtocol, "http,https");
                     return cfg;
                 })};
 
@@ -399,8 +402,8 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         testcase("Status request");
         using namespace jtx;
         Env env{*this, envconfig([](std::unique_ptr cfg) {
-                    cfg->section("port_rpc").set("protocol", "ws2,wss2");
-                    cfg->section("port_ws").set("protocol", "http");
+                    cfg->section(Sections::kPortRpc).set(Keys::kProtocol, "ws2,wss2");
+                    cfg->section(Sections::kPortWs).set(Keys::kProtocol, "http");
                     return cfg;
                 })};
 
@@ -433,12 +436,12 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         using namespace boost::asio;
         using namespace boost::beast::http;
         Env env{*this, envconfig([](std::unique_ptr cfg) {
-                    cfg->section("port_ws").set("protocol", "ws2");
+                    cfg->section(Sections::kPortWs).set(Keys::kProtocol, "ws2");
                     return cfg;
                 })};
 
-        auto const port = env.app().config()["port_ws"].get("port");
-        auto const ip = env.app().config()["port_ws"].get("ip");
+        auto const port = env.app().config()[Sections::kPortWs].get(Keys::kPort);
+        auto const ip = env.app().config()[Sections::kPortWs].get(Keys::kIp);
 
         boost::system::error_code ec;
         response resp;
@@ -505,11 +508,11 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
 
         using namespace test::jtx;
         Env env{*this, envconfig([secure](std::unique_ptr cfg) {
-                    (*cfg)["port_rpc"].set("user", "me");
-                    (*cfg)["port_rpc"].set("password", "secret");
-                    (*cfg)["port_rpc"].set("protocol", secure ? "https" : "http");
+                    (*cfg)[Sections::kPortRpc].set(Keys::kUser, "me");
+                    (*cfg)[Sections::kPortRpc].set(Keys::kPassword, "secret");
+                    (*cfg)[Sections::kPortRpc].set(Keys::kProtocol, secure ? "https" : "http");
                     if (secure)
-                        (*cfg)["port_ws"].set("protocol", "http,ws");
+                        (*cfg)[Sections::kPortWs].set(Keys::kProtocol, "http,ws");
                     return cfg;
                 })};
 
@@ -533,11 +536,11 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         doHTTPRequest(env, yield, secure, resp, ec, to_string(jr), auth);
         BEAST_EXPECT(resp.result() == boost::beast::http::status::forbidden);
 
-        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-        auto const user = env.app().config().section("port_rpc").get("user").value();
-        auto const pass =
-            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            env.app().config().section("port_rpc").get("password").value();
+        auto const section = env.app().config().section(Sections::kPortRpc);
+        // NOLINTBEGIN(bugprone-unchecked-optional-access)
+        auto const user = section.get(Keys::kUser).value();
+        auto const pass = section.get(Keys::kPassword).value();
+        // NOLINTEND(bugprone-unchecked-optional-access)
 
         // try with the correct user/pass, but not encoded
         auth.set("Authorization", "Basic " + user + ":" + pass);
@@ -560,15 +563,15 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         using namespace boost::asio;
         using namespace boost::beast::http;
         Env env{*this, envconfig([&](std::unique_ptr cfg) {
-                    (*cfg)["port_rpc"].set("limit", std::to_string(limit));
+                    (*cfg)[Sections::kPortRpc].set(Keys::kLimit, std::to_string(limit));
                     return cfg;
                 })};
 
-        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-        auto const port = env.app().config()["port_rpc"].get("port").value();
-
-        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-        auto const ip = env.app().config()["port_rpc"].get("ip").value();
+        auto const section = env.app().config().section(Sections::kPortRpc);
+        // NOLINTBEGIN(bugprone-unchecked-optional-access)
+        auto const port = section.get(Keys::kPort).value();
+        auto const ip = section.get(Keys::kIp).value();
+        // NOLINTEND(bugprone-unchecked-optional-access)
 
         boost::system::error_code ec;
         io_context& ios = getIoContext();
@@ -620,14 +623,15 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
 
         using namespace test::jtx;
         Env env{*this, envconfig([](std::unique_ptr cfg) {
-                    (*cfg)["port_ws"].set("protocol", "wss");
+                    (*cfg)[Sections::kPortWs].set(Keys::kProtocol, "wss");
                     return cfg;
                 })};
 
-        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-        auto const port = env.app().config()["port_ws"].get("port").value();
-        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-        auto const ip = env.app().config()["port_ws"].get("ip").value();
+        auto const section = env.app().config().section(Sections::kPortWs);
+        // NOLINTBEGIN(bugprone-unchecked-optional-access)
+        auto const port = section.get(Keys::kPort).value();
+        auto const ip = section.get(Keys::kIp).value();
+        // NOLINTEND(bugprone-unchecked-optional-access)
         boost::beast::http::response resp;
         boost::system::error_code ec;
         doRequest(yield, makeWSUpgrade(ip, port), ip, port, true, resp, ec);
@@ -644,10 +648,11 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         using namespace test::jtx;
         Env env{*this};
 
-        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-        auto const port = env.app().config()["port_ws"].get("port").value();
-        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-        auto const ip = env.app().config()["port_ws"].get("ip").value();
+        auto const section = env.app().config().section(Sections::kPortWs);
+        // NOLINTBEGIN(bugprone-unchecked-optional-access)
+        auto const port = section.get(Keys::kPort).value();
+        auto const ip = section.get(Keys::kIp).value();
+        // NOLINTEND(bugprone-unchecked-optional-access)
         boost::beast::http::response resp;
         boost::system::error_code ec;
         // body content is required here to avoid being
@@ -667,10 +672,11 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         using namespace boost::beast::http;
         Env env{*this};
 
-        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-        auto const port = env.app().config()["port_ws"].get("port").value();
-        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-        auto const ip = env.app().config()["port_ws"].get("ip").value();
+        auto const section = env.app().config().section(Sections::kPortWs);
+        // NOLINTBEGIN(bugprone-unchecked-optional-access)
+        auto const port = section.get(Keys::kPort).value();
+        auto const ip = section.get(Keys::kIp).value();
+        // NOLINTEND(bugprone-unchecked-optional-access)
         boost::system::error_code ec;
 
         io_context& ios = getIoContext();
@@ -746,7 +752,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
             *this,
             validator(
                 envconfig([](std::unique_ptr cfg) {
-                    cfg->section("port_rpc").set("protocol", "http");
+                    cfg->section(Sections::kPortRpc).set(Keys::kProtocol, "http");
                     return cfg;
                 }),
                 "")};
@@ -774,8 +780,8 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         BEAST_EXPECT(env.app().getOPs().getConsensusInfo()["validating"] == true);
         BEAST_EXPECT(!si[jss::state].isMember(jss::warnings));
 
-        auto const portWs = env.app().config()["port_ws"].get("port");
-        auto const ipWs = env.app().config()["port_ws"].get("ip");
+        auto const portWs = env.app().config()[Sections::kPortWs].get(Keys::kPort);
+        auto const ipWs = env.app().config()[Sections::kPortWs].get(Keys::kIp);
 
         boost::system::error_code ec;
         response resp;
@@ -795,7 +801,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         if (!BEAST_EXPECTS(!ec, ec.message()))
             return;
         BEAST_EXPECT(resp.result() == boost::beast::http::status::ok);
-        BEAST_EXPECT(resp.body().find("connectivity is working.") != std::string::npos);
+        BEAST_EXPECT(resp.body().contains("connectivity is working."));
 
         // mark the Network as having an Amendment Warning, but won't fail
         env.app().getOPs().setAmendmentWarned();
@@ -840,10 +846,10 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         if (!BEAST_EXPECTS(!ec, ec.message()))
             return;
         BEAST_EXPECT(resp.result() == boost::beast::http::status::ok);
-        BEAST_EXPECT(resp.body().find("connectivity is working.") != std::string::npos);
+        BEAST_EXPECT(resp.body().contains("connectivity is working."));
 
         // with ELB_SUPPORT, status still does not indicate a problem
-        env.app().config().ELB_SUPPORT = true;
+        env.app().config().elbSupport = true;
 
         doRequest(
             yield,
@@ -860,7 +866,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         if (!BEAST_EXPECTS(!ec, ec.message()))
             return;
         BEAST_EXPECT(resp.result() == boost::beast::http::status::ok);
-        BEAST_EXPECT(resp.body().find("connectivity is working.") != std::string::npos);
+        BEAST_EXPECT(resp.body().contains("connectivity is working."));
     }
 
     void
@@ -874,7 +880,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
             *this,
             validator(
                 envconfig([](std::unique_ptr cfg) {
-                    cfg->section("port_rpc").set("protocol", "http");
+                    cfg->section(Sections::kPortRpc).set(Keys::kProtocol, "http");
                     return cfg;
                 }),
                 "")};
@@ -902,8 +908,8 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         BEAST_EXPECT(env.app().getOPs().getConsensusInfo()["validating"] == true);
         BEAST_EXPECT(!si[jss::state].isMember(jss::warnings));
 
-        auto const portWs = env.app().config()["port_ws"].get("port");
-        auto const ipWs = env.app().config()["port_ws"].get("ip");
+        auto const portWs = env.app().config()[Sections::kPortWs].get(Keys::kPort);
+        auto const ipWs = env.app().config()[Sections::kPortWs].get(Keys::kIp);
 
         boost::system::error_code ec;
         response resp;
@@ -923,7 +929,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         if (!BEAST_EXPECTS(!ec, ec.message()))
             return;
         BEAST_EXPECT(resp.result() == boost::beast::http::status::ok);
-        BEAST_EXPECT(resp.body().find("connectivity is working.") != std::string::npos);
+        BEAST_EXPECT(resp.body().contains("connectivity is working."));
 
         // mark the Network as Amendment Blocked, but still won't fail until
         // ELB is enabled (next step)
@@ -971,9 +977,9 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         if (!BEAST_EXPECTS(!ec, ec.message()))
             return;
         BEAST_EXPECT(resp.result() == boost::beast::http::status::ok);
-        BEAST_EXPECT(resp.body().find("connectivity is working.") != std::string::npos);
+        BEAST_EXPECT(resp.body().contains("connectivity is working."));
 
-        env.app().config().ELB_SUPPORT = true;
+        env.app().config().elbSupport = true;
 
         doRequest(
             yield,
@@ -990,8 +996,8 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         if (!BEAST_EXPECTS(!ec, ec.message()))
             return;
         BEAST_EXPECT(resp.result() == boost::beast::http::status::internal_server_error);
-        BEAST_EXPECT(resp.body().find("cannot accept clients:") != std::string::npos);
-        BEAST_EXPECT(resp.body().find("Server version too old") != std::string::npos);
+        BEAST_EXPECT(resp.body().contains("cannot accept clients:"));
+        BEAST_EXPECT(resp.body().contains("Server version too old"));
     }
 
     void
@@ -1111,7 +1117,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
 
         using namespace test::jtx;
         Env env{*this, envconfig([](std::unique_ptr cfg) {
-                    cfg->ELB_SUPPORT = true;
+                    cfg->elbSupport = true;
                     return cfg;
                 })};
 
diff --git a/src/test/server/Server_test.cpp b/src/test/server/Server_test.cpp
index 1dc40f6337..9ff0015b5d 100644
--- a/src/test/server/Server_test.cpp
+++ b/src/test/server/Server_test.cpp
@@ -4,11 +4,11 @@
 #include 
 
 #include 
-#include 
 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -33,6 +33,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -48,15 +49,15 @@ public:
     class TestThread
     {
     private:
-        boost::asio::io_context io_context_;
+        boost::asio::io_context ioContext_;
         std::optional>
             work_;
         std::thread thread_;
 
     public:
         TestThread()
-            : work_(std::in_place, boost::asio::make_work_guard(io_context_))
-            , thread_([&]() { this->io_context_.run(); })
+            : work_(std::in_place, boost::asio::make_work_guard(ioContext_))
+            , thread_([&]() { this->ioContext_.run(); })
         {
         }
 
@@ -69,7 +70,7 @@ public:
         boost::asio::io_context&
         getIoContext()
         {
-            return io_context_;
+            return ioContext_;
         }
     };
 
@@ -133,7 +134,8 @@ public:
         static void
         onRequest(Session& session)
         {
-            session.write(std::string("Hello, world!\n"));
+            using namespace std::string_view_literals;
+            session.write("Hello, world!\n"sv);
             if (beast::rfc2616::isKeepAlive(session.request()))
             {
                 session.complete();
@@ -167,7 +169,7 @@ public:
     // Connect to an address
     template 
     bool
-    connect(Socket& s, typename Socket::endpoint_type const& ep)
+    connect(Socket& s, Socket::endpoint_type const& ep)
     {
         try
         {
@@ -394,64 +396,62 @@ public:
             Env const env{
                 *this,
                 envconfig([](std::unique_ptr cfg) {
-                    (*cfg).deprecatedClearSection("port_rpc");
+                    (*cfg).deprecatedClearSection(Sections::kPortRpc);
                     return cfg;
                 }),
                 std::make_unique(&messages)};
         });
-        BEAST_EXPECT(messages.find("Missing 'ip' in [port_rpc]") != std::string::npos);
+        BEAST_EXPECT(messages.contains("Missing 'ip' in [port_rpc]"));
 
         except([&] {
             Env const env{
                 *this,
                 envconfig([](std::unique_ptr cfg) {
-                    (*cfg).deprecatedClearSection("port_rpc");
-                    (*cfg)["port_rpc"].set("ip", getEnvLocalhostAddr());
+                    (*cfg).deprecatedClearSection(Sections::kPortRpc);
+                    (*cfg)[Sections::kPortRpc].set(Keys::kIp, getEnvLocalhostAddr());
                     return cfg;
                 }),
                 std::make_unique(&messages)};
         });
-        BEAST_EXPECT(messages.find("Missing 'port' in [port_rpc]") != std::string::npos);
+        BEAST_EXPECT(messages.contains("Missing 'port' in [port_rpc]"));
 
         except([&] {
             Env const env{
                 *this,
                 envconfig([](std::unique_ptr cfg) {
-                    (*cfg).deprecatedClearSection("port_rpc");
-                    (*cfg)["port_rpc"].set("ip", getEnvLocalhostAddr());
-                    (*cfg)["port_rpc"].set("port", "0");
+                    (*cfg).deprecatedClearSection(Sections::kPortRpc);
+                    (*cfg)[Sections::kPortRpc].set(Keys::kIp, getEnvLocalhostAddr());
+                    (*cfg)[Sections::kPortRpc].set(Keys::kPort, "0");
                     return cfg;
                 }),
                 std::make_unique(&messages)};
         });
-        BEAST_EXPECT(
-            messages.find("Invalid value '0' for key 'port' in [port_rpc]") == std::string::npos);
+        BEAST_EXPECT(!messages.contains("Invalid value '0' for key 'port' in [port_rpc]"));
 
         except([&] {
             Env const env{
                 *this,
                 envconfig([](std::unique_ptr cfg) {
-                    (*cfg)["server"].set("port", "0");
+                    (*cfg)[Sections::kServer].set(Keys::kPort, "0");
                     return cfg;
                 }),
                 std::make_unique(&messages)};
         });
-        BEAST_EXPECT(
-            messages.find("Invalid value '0' for key 'port' in [server]") != std::string::npos);
+        BEAST_EXPECT(messages.contains("Invalid value '0' for key 'port' in [server]"));
 
         except([&] {
             Env const env{
                 *this,
                 envconfig([](std::unique_ptr cfg) {
-                    (*cfg).deprecatedClearSection("port_rpc");
-                    (*cfg)["port_rpc"].set("ip", getEnvLocalhostAddr());
-                    (*cfg)["port_rpc"].set("port", "8081");
-                    (*cfg)["port_rpc"].set("protocol", "");
+                    (*cfg).deprecatedClearSection(Sections::kPortRpc);
+                    (*cfg)[Sections::kPortRpc].set(Keys::kIp, getEnvLocalhostAddr());
+                    (*cfg)[Sections::kPortRpc].set(Keys::kPort, "8081");
+                    (*cfg)[Sections::kPortRpc].set(Keys::kProtocol, "");
                     return cfg;
                 }),
                 std::make_unique(&messages)};
         });
-        BEAST_EXPECT(messages.find("Missing 'protocol' in [port_rpc]") != std::string::npos);
+        BEAST_EXPECT(messages.contains("Missing 'protocol' in [port_rpc]"));
 
         except([&]  // this creates a standard test config without the server
                     // section
@@ -460,27 +460,27 @@ public:
                        *this,
                        envconfig([](std::unique_ptr cfg) {
                            cfg = std::make_unique();
-                           cfg->overwrite(ConfigSection::nodeDatabase(), "type", "memory");
-                           cfg->overwrite(ConfigSection::nodeDatabase(), "path", "main");
-                           cfg->deprecatedClearSection(ConfigSection::importNodeDatabase());
-                           cfg->legacy("database_path", "");
+                           cfg->overwrite(Sections::kNodeDatabase, Keys::kType, "memory");
+                           cfg->overwrite(Sections::kNodeDatabase, Keys::kPath, "main");
+                           cfg->deprecatedClearSection(Sections::kImportNodeDatabase);
+                           cfg->legacy(Sections::kDatabasePath, "");
                            cfg->setupControl(true, true, true);
-                           (*cfg)["port_peer"].set("ip", getEnvLocalhostAddr());
-                           (*cfg)["port_peer"].set("port", "8080");
-                           (*cfg)["port_peer"].set("protocol", "peer");
-                           (*cfg)["port_rpc"].set("ip", getEnvLocalhostAddr());
-                           (*cfg)["port_rpc"].set("port", "8081");
-                           (*cfg)["port_rpc"].set("protocol", "http,ws2");
-                           (*cfg)["port_rpc"].set("admin", getEnvLocalhostAddr());
-                           (*cfg)["port_ws"].set("ip", getEnvLocalhostAddr());
-                           (*cfg)["port_ws"].set("port", "8082");
-                           (*cfg)["port_ws"].set("protocol", "ws");
-                           (*cfg)["port_ws"].set("admin", getEnvLocalhostAddr());
+                           (*cfg)[Sections::kPortPeer].set(Keys::kIp, getEnvLocalhostAddr());
+                           (*cfg)[Sections::kPortPeer].set(Keys::kPort, "8080");
+                           (*cfg)[Sections::kPortPeer].set(Keys::kProtocol, "peer");
+                           (*cfg)[Sections::kPortRpc].set(Keys::kIp, getEnvLocalhostAddr());
+                           (*cfg)[Sections::kPortRpc].set(Keys::kPort, "8081");
+                           (*cfg)[Sections::kPortRpc].set(Keys::kProtocol, "http,ws2");
+                           (*cfg)[Sections::kPortRpc].set(Keys::kAdmin, getEnvLocalhostAddr());
+                           (*cfg)[Sections::kPortWs].set(Keys::kIp, getEnvLocalhostAddr());
+                           (*cfg)[Sections::kPortWs].set(Keys::kPort, "8082");
+                           (*cfg)[Sections::kPortWs].set(Keys::kProtocol, "ws");
+                           (*cfg)[Sections::kPortWs].set(Keys::kAdmin, getEnvLocalhostAddr());
                            return cfg;
                        }),
                        std::make_unique(&messages)};
                });
-        BEAST_EXPECT(messages.find("Required section [server] is missing") != std::string::npos);
+        BEAST_EXPECT(messages.contains("Required section [server] is missing"));
 
         except([&]  // this creates a standard test config without some of the
                     // port sections
@@ -489,19 +489,19 @@ public:
                        *this,
                        envconfig([](std::unique_ptr cfg) {
                            cfg = std::make_unique();
-                           cfg->overwrite(ConfigSection::nodeDatabase(), "type", "memory");
-                           cfg->overwrite(ConfigSection::nodeDatabase(), "path", "main");
-                           cfg->deprecatedClearSection(ConfigSection::importNodeDatabase());
-                           cfg->legacy("database_path", "");
+                           cfg->overwrite(Sections::kNodeDatabase, Keys::kType, "memory");
+                           cfg->overwrite(Sections::kNodeDatabase, Keys::kPath, "main");
+                           cfg->deprecatedClearSection(Sections::kImportNodeDatabase);
+                           cfg->legacy(Sections::kDatabasePath, "");
                            cfg->setupControl(true, true, true);
-                           (*cfg)["server"].append("port_peer");
-                           (*cfg)["server"].append("port_rpc");
-                           (*cfg)["server"].append("port_ws");
+                           (*cfg)[Sections::kServer].append(Sections::kPortPeer);
+                           (*cfg)[Sections::kServer].append(Sections::kPortRpc);
+                           (*cfg)[Sections::kServer].append(Sections::kPortWs);
                            return cfg;
                        }),
                        std::make_unique(&messages)};
                });
-        BEAST_EXPECT(messages.find("Missing section: [port_peer]") != std::string::npos);
+        BEAST_EXPECT(messages.contains("Missing section: [port_peer]"));
     }
 
     void
diff --git a/src/test/shamap/common.h b/src/test/shamap/common.h
index 49f1c07741..0475acdf6d 100644
--- a/src/test/shamap/common.h
+++ b/src/test/shamap/common.h
@@ -1,6 +1,8 @@
 #pragma once
 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -33,8 +35,8 @@ public:
         , j_(j)
     {
         Section testSection;
-        testSection.set("type", "memory");
-        testSection.set("path", "SHAMap_test");
+        testSection.set(Keys::kType, "memory");
+        testSection.set(Keys::kPath, "SHAMap_test");
         db_ = NodeStore::Manager::instance().makeDatabase(
             megabytes(4), scheduler_, 1, testSection, j);
     }
diff --git a/src/test/unit_test/SuiteJournal.h b/src/test/unit_test/SuiteJournal.h
index bd54683031..174e9ff2ff 100644
--- a/src/test/unit_test/SuiteJournal.h
+++ b/src/test/unit_test/SuiteJournal.h
@@ -60,9 +60,8 @@ SuiteJournalSink::writeAlways(beast::Severity level, std::string const& text)
                 return "WRN:";
             case Severity::Error:
                 return "ERR:";
-            default:
-                break;
             case Severity::Fatal:
+            default:
                 break;
         }
         return "FTL:";
diff --git a/src/test/unit_test/multi_runner.cpp b/src/test/unit_test/multi_runner.cpp
index 60e979299d..71208313a4 100644
--- a/src/test/unit_test/multi_runner.cpp
+++ b/src/test/unit_test/multi_runner.cpp
@@ -69,9 +69,7 @@ Results::add(SuiteResults const& r)
             top.begin(),
             top.end(),
             elapsed,
-            [](run_time const& t1, typename clock_type::duration const& t2) {
-                return t1.second > t2;
-            });
+            [](run_time const& t1, clock_type::duration const& t2) { return t1.second > t2; });
 
         if (iter != top.end())
         {
@@ -139,28 +137,28 @@ template 
 std::size_t
 MultiRunnerBase::Inner::checkoutJobIndex()
 {
-    return job_index++;
+    return jobIndex++;
 }
 
 template 
 std::size_t
 MultiRunnerBase::Inner::checkoutTestIndex()
 {
-    return test_index++;
+    return testIndex++;
 }
 
 template 
 bool
 MultiRunnerBase::Inner::anyFailed() const
 {
-    return any_failed;
+    return anyFailedFlag;
 }
 
 template 
 void
 MultiRunnerBase::Inner::anyFailed(bool v)
 {
-    any_failed = any_failed || v;
+    anyFailedFlag = anyFailedFlag || v;
 }
 
 template 
@@ -183,14 +181,14 @@ template 
 void
 MultiRunnerBase::Inner::incKeepAliveCount()
 {
-    ++keep_alive;
+    ++keepAlive;
 }
 
 template 
 std::size_t
 MultiRunnerBase::Inner::getKeepAliveCount()
 {
-    return keep_alive;
+    return keepAlive;
 }
 
 template 
@@ -222,7 +220,7 @@ MultiRunnerBase::MultiRunnerBase()
             boost::interprocess::message_queue::remove(kMessageQueueName);
         }
 
-        shared_mem_ = boost::interprocess::shared_memory_object{
+        sharedMem_ = boost::interprocess::shared_memory_object{
             std::conditional_t<
                 IsParent,
                 boost::interprocess::create_only_t,
@@ -232,8 +230,8 @@ MultiRunnerBase::MultiRunnerBase()
 
         if (IsParent)
         {
-            shared_mem_.truncate(sizeof(Inner));
-            message_queue_ = std::make_unique(
+            sharedMem_.truncate(sizeof(Inner));
+            messageQueue_ = std::make_unique(
                 boost::interprocess::create_only,
                 kMessageQueueName,
                 /*max messages*/ 16,
@@ -241,11 +239,11 @@ MultiRunnerBase::MultiRunnerBase()
         }
         else
         {
-            message_queue_ = std::make_unique(
+            messageQueue_ = std::make_unique(
                 boost::interprocess::open_only, kMessageQueueName);
         }
 
-        region_ = boost::interprocess::mapped_region{shared_mem_, boost::interprocess::read_write};
+        region_ = boost::interprocess::mapped_region{sharedMem_, boost::interprocess::read_write};
         if (IsParent)
         {
             inner_ = new (region_.get_address()) Inner{};
@@ -340,8 +338,8 @@ MultiRunnerBase::messageQueueSend(MessageType mt, std::string const& s
 {
     // must use a mutex since the two "sends" must happen in order
     std::scoped_lock const l{inner_->m};
-    message_queue_->send(&mt, sizeof(mt), /*priority*/ 0);
-    message_queue_->send(s.c_str(), s.size(), /*priority*/ 0);
+    messageQueue_->send(&mt, sizeof(mt), /*priority*/ 0);
+    messageQueue_->send(s.c_str(), s.size(), /*priority*/ 0);
 }
 
 template 
@@ -376,13 +374,13 @@ namespace test {
 
 MultiRunnerParent::MultiRunnerParent() : os_(std::cout)
 {
-    message_queue_thread_ = std::thread([this] {
+    messageQueueThread_ = std::thread([this] {
         std::vector buf(1 << 20);
-        while (this->continue_message_queue_ || this->message_queue_->get_num_msg())
+        while (this->continueMessageQueue_ || this->messageQueue_->get_num_msg())
         {
             // let children know the parent is still alive
             this->incKeepAliveCount();
-            if (!this->message_queue_->get_num_msg())
+            if (!this->messageQueue_->get_num_msg())
             {
                 // If a child does not see the keep alive count incremented,
                 // it will assume the parent has died. This sleep time needs
@@ -395,13 +393,13 @@ MultiRunnerParent::MultiRunnerParent() : os_(std::cout)
             {
                 std::size_t recvdSize = 0;
                 unsigned int priority = 0;
-                this->message_queue_->receive(buf.data(), buf.size(), recvdSize, priority);
+                this->messageQueue_->receive(buf.data(), buf.size(), recvdSize, priority);
                 if (!recvdSize)
                     continue;
                 assert(recvdSize == 1);
                 MessageType const mt{*reinterpret_cast(buf.data())};
 
-                this->message_queue_->receive(buf.data(), buf.size(), recvdSize, priority);
+                this->messageQueue_->receive(buf.data(), buf.size(), recvdSize, priority);
                 if (recvdSize)
                 {
                     std::string s{buf.data(), recvdSize};
@@ -412,10 +410,10 @@ MultiRunnerParent::MultiRunnerParent() : os_(std::cout)
                             this->os_.flush();
                             break;
                         case MessageType::TestStart:
-                            running_suites_.insert(std::move(s));
+                            runningSuites_.insert(std::move(s));
                             break;
                         case MessageType::TestEnd:
-                            running_suites_.erase(s);
+                            runningSuites_.erase(s);
                             break;
                         default:
                             assert(0);  // unknown message type
@@ -440,14 +438,14 @@ MultiRunnerParent::~MultiRunnerParent()
 {
     using namespace beast::unit_test;
 
-    continue_message_queue_ = false;
-    message_queue_thread_.join();
+    continueMessageQueue_ = false;
+    messageQueueThread_.join();
 
-    addFailures(running_suites_.size());
+    addFailures(runningSuites_.size());
 
     printResults(os_);
 
-    for (auto const& s : running_suites_)
+    for (auto const& s : runningSuites_)
     {
         os_ << "\nSuite: " << s << " failed to complete. The child process may have crashed.\n";
     }
@@ -480,16 +478,13 @@ MultiRunnerParent::addFailures(std::size_t failures)
 //------------------------------------------------------------------------------
 
 MultiRunnerChild::MultiRunnerChild(std::size_t numJobs, bool quiet, bool printLog)
-    : job_index_{checkoutJobIndex()}
-    , num_jobs_{numJobs}
-    , quiet_{quiet}
-    , print_log_{!quiet || printLog}
+    : jobIndex_{checkoutJobIndex()}, numJobs_{numJobs}, quiet_{quiet}, printLog_{!quiet || printLog}
 {
-    if (num_jobs_ > 1)
+    if (numJobs_ > 1)
     {
-        keep_alive_thread_ = std::thread([this] {
+        keepAliveThread_ = std::thread([this] {
             std::size_t lastCount = getKeepAliveCount();
-            while (this->continue_keep_alive_)
+            while (this->continueKeepAlive_)
             {
                 // Use a small sleep time so in the normal case the child
                 // process may shutdown quickly. However, to protect against
@@ -504,7 +499,7 @@ MultiRunnerChild::MultiRunnerChild(std::size_t numJobs, bool quiet, bool printLo
                     if (curCount == lastCount)
                     {
                         // assume parent process is no longer alive
-                        std::cerr << "multi_runner_child " << job_index_
+                        std::cerr << "multi_runner_child " << jobIndex_
                                   << ": Assuming parent died, exiting.\n";
                         std::exit(EXIT_FAILURE);
                     }
@@ -517,10 +512,10 @@ MultiRunnerChild::MultiRunnerChild(std::size_t numJobs, bool quiet, bool printLo
 
 MultiRunnerChild::~MultiRunnerChild()
 {
-    if (num_jobs_ > 1)
+    if (numJobs_ > 1)
     {
-        continue_keep_alive_ = false;
-        keep_alive_thread_.join();
+        continueKeepAlive_ = false;
+        keepAliveThread_.join();
     }
 
     add(results_);
@@ -548,75 +543,74 @@ MultiRunnerChild::addFailures(std::size_t failures)
 void
 MultiRunnerChild::onSuiteBegin(beast::unit_test::SuiteInfo const& info)
 {
-    suite_results_ = detail::SuiteResults{info.fullName()};
-    messageQueueSend(MessageType::TestStart, suite_results_.name);
+    suiteResults_ = detail::SuiteResults{info.fullName()};
+    messageQueueSend(MessageType::TestStart, suiteResults_.name);
 }
 
 void
 MultiRunnerChild::onSuiteEnd()
 {
-    if (print_log_ || suite_results_.failed > 0)
+    if (printLog_ || suiteResults_.failed > 0)
     {
         std::stringstream s;
-        if (num_jobs_ > 1)
-            s << job_index_ << "> ";
-        s << (suite_results_.failed > 0 ? "failed: " : "") << suite_results_.name << " had "
-          << suite_results_.failed << " failures." << std::endl;
+        if (numJobs_ > 1)
+            s << jobIndex_ << "> ";
+        s << (suiteResults_.failed > 0 ? "failed: " : "") << suiteResults_.name << " had "
+          << suiteResults_.failed << " failures." << std::endl;
         messageQueueSend(MessageType::Log, s.str());
     }
-    results_.add(suite_results_);
-    messageQueueSend(MessageType::TestEnd, suite_results_.name);
+    results_.add(suiteResults_);
+    messageQueueSend(MessageType::TestEnd, suiteResults_.name);
 }
 
 void
 MultiRunnerChild::onCaseBegin(std::string const& name)
 {
-    case_results_ = detail::CaseResults(name);
+    caseResults_ = detail::CaseResults(name);
 
     if (quiet_)
         return;
 
     std::stringstream s;
-    if (num_jobs_ > 1)
-        s << job_index_ << "> ";
-    s << suite_results_.name << (case_results_.name.empty() ? "" : (" " + case_results_.name))
-      << '\n';
+    if (numJobs_ > 1)
+        s << jobIndex_ << "> ";
+    s << suiteResults_.name << (caseResults_.name.empty() ? "" : (" " + caseResults_.name)) << '\n';
     messageQueueSend(MessageType::Log, s.str());
 }
 
 void
 MultiRunnerChild::onCaseEnd()
 {
-    suite_results_.add(case_results_);
+    suiteResults_.add(caseResults_);
 }
 
 void
 MultiRunnerChild::onPass()
 {
-    ++case_results_.total;
+    ++caseResults_.total;
 }
 
 void
 MultiRunnerChild::onFail(std::string const& reason)
 {
-    ++case_results_.failed;
-    ++case_results_.total;
+    ++caseResults_.failed;
+    ++caseResults_.total;
     std::stringstream s;
-    if (num_jobs_ > 1)
-        s << job_index_ << "> ";
-    s << "#" << case_results_.total << " failed" << (reason.empty() ? "" : ": ") << reason << '\n';
+    if (numJobs_ > 1)
+        s << jobIndex_ << "> ";
+    s << "#" << caseResults_.total << " failed" << (reason.empty() ? "" : ": ") << reason << '\n';
     messageQueueSend(MessageType::Log, s.str());
 }
 
 void
 MultiRunnerChild::onLog(std::string const& msg)
 {
-    if (!print_log_)
+    if (!printLog_)
         return;
 
     std::stringstream s;
-    if (num_jobs_ > 1)
-        s << job_index_ << "> ";
+    if (numJobs_ > 1)
+        s << jobIndex_ << "> ";
     s << msg;
     messageQueueSend(MessageType::Log, s.str());
 }
diff --git a/src/test/unit_test/multi_runner.h b/src/test/unit_test/multi_runner.h
index 5ebc8ec0a8..55cf6c25fa 100644
--- a/src/test/unit_test/multi_runner.h
+++ b/src/test/unit_test/multi_runner.h
@@ -42,7 +42,7 @@ struct SuiteResults
     std::size_t cases = 0;
     std::size_t total = 0;
     std::size_t failed = 0;
-    typename clock_type::time_point start = clock_type::now();
+    clock_type::time_point start = clock_type::now();
 
     explicit SuiteResults(std::string name = "") : name(std::move(name))
     {
@@ -57,7 +57,7 @@ struct Results
     using static_string = boost::beast::static_string<256>;
     // results may be stored in shared memory. Use `static_string` to ensure
     // pointers from different memory spaces do not co-mingle
-    using run_time = std::pair;
+    using run_time = std::pair;
 
     static constexpr auto kMaxTop = 10;
 
@@ -66,7 +66,7 @@ struct Results
     std::size_t total = 0;
     std::size_t failed = 0;
     boost::container::static_vector top;
-    typename clock_type::time_point start = clock_type::now();
+    clock_type::time_point start = clock_type::now();
 
     void
     add(SuiteResults const& r);
@@ -87,14 +87,14 @@ class MultiRunnerBase
     // way they communicate is through message queues.
     struct Inner
     {
-        std::atomic job_index{0};
-        std::atomic test_index{0};
-        std::atomic any_failed{false};
-        // A parent process will periodically increment `keep_alive_`. The child
-        // processes will check if `keep_alive_` is being incremented. If it is
+        std::atomic jobIndex{0};
+        std::atomic testIndex{0};
+        std::atomic anyFailedFlag{false};
+        // A parent process will periodically increment `keepAlive`. The child
+        // processes will check if `keepAlive` is being incremented. If it is
         // not incremented for a sufficiently long time, the child will assume
         // the parent process has died.
-        std::atomic keep_alive{0};
+        std::atomic keepAlive{0};
 
         mutable boost::interprocess::interprocess_mutex m;
         detail::Results results;
@@ -139,11 +139,11 @@ class MultiRunnerBase
     // `inner_` will be created in shared memory
     Inner* inner_;
     // shared memory to use for the `inner` member
-    boost::interprocess::shared_memory_object shared_mem_;
+    boost::interprocess::shared_memory_object sharedMem_;
     boost::interprocess::mapped_region region_;
 
 protected:
-    std::unique_ptr message_queue_;
+    std::unique_ptr messageQueue_;
 
     enum class MessageType : std::uint8_t { TestStart, TestEnd, Log };
     void
@@ -201,10 +201,10 @@ class MultiRunnerParent : private detail::MultiRunnerBase
 private:
     // message_queue_ is used to collect log messages from the children
     std::ostream& os_;
-    std::atomic continue_message_queue_{true};
-    std::thread message_queue_thread_;
+    std::atomic continueMessageQueue_{true};
+    std::thread messageQueueThread_;
     // track running suites so if a child crashes the culprit can be flagged
-    std::set running_suites_;
+    std::set runningSuites_;
 
 public:
     MultiRunnerParent(MultiRunnerParent const&) = delete;
@@ -235,16 +235,16 @@ class MultiRunnerChild : public beast::unit_test::Runner,
                          private detail::MultiRunnerBase
 {
 private:
-    std::size_t job_index_;
+    std::size_t jobIndex_;
     detail::Results results_;
-    detail::SuiteResults suite_results_;
-    detail::CaseResults case_results_;
-    std::size_t num_jobs_{0};
+    detail::SuiteResults suiteResults_;
+    detail::CaseResults caseResults_;
+    std::size_t numJobs_{0};
     bool quiet_{false};
-    bool print_log_{true};
+    bool printLog_{true};
 
-    std::atomic continue_keep_alive_{true};
-    std::thread keep_alive_thread_;
+    std::atomic continueKeepAlive_{true};
+    std::thread keepAliveThread_;
 
 public:
     MultiRunnerChild(MultiRunnerChild const&) = delete;
@@ -318,12 +318,12 @@ MultiRunnerChild::runMulti(Pred pred)
         }
         catch (...)
         {
-            if (num_jobs_ <= 1)
+            if (numJobs_ <= 1)
                 throw;  // a single process can die
 
             // inform the parent
             std::stringstream s;
-            s << job_index_ << ">  failed Unhandled exception in test.\n";
+            s << jobIndex_ << ">  failed Unhandled exception in test.\n";
             messageQueueSend(MessageType::Log, s.str());
             failed = true;
         }
diff --git a/src/test/unit_test/utils.h b/src/test/unit_test/utils.h
index 028823c763..677bbff31b 100644
--- a/src/test/unit_test/utils.h
+++ b/src/test/unit_test/utils.h
@@ -1,3 +1,5 @@
+#pragma once
+
 #include 
 
 #include 
diff --git a/src/tests/libxrpl/CMakeLists.txt b/src/tests/libxrpl/CMakeLists.txt
index ee07698519..bd56028728 100644
--- a/src/tests/libxrpl/CMakeLists.txt
+++ b/src/tests/libxrpl/CMakeLists.txt
@@ -1,51 +1,57 @@
-include(XrplAddTest)
+include(GoogleTest)
+include(isolate_headers)
 
 # Test requirements.
 find_package(GTest REQUIRED)
 
-# Custom target for all tests defined in this file
-add_custom_target(xrpl.tests)
-
-# Test helpers
-add_library(xrpl.helpers.test STATIC)
-target_sources(
-    xrpl.helpers.test
-    PRIVATE helpers/Account.cpp helpers/TestSink.cpp helpers/TxTest.cpp
+# Single combined gtest binary built from the shared test helpers and all test
+# modules below.
+add_executable(
+    xrpl_tests
+    main.cpp
+    helpers/Account.cpp
+    helpers/TestSink.cpp
+    helpers/TxTest.cpp
 )
-target_include_directories(xrpl.helpers.test PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
-target_link_libraries(xrpl.helpers.test PUBLIC xrpl.libxrpl gtest::gtest)
-
-# Common library dependencies for the rest of the tests.
-add_library(xrpl.imports.test INTERFACE)
-target_link_libraries(
-    xrpl.imports.test
-    INTERFACE gtest::gtest xrpl.libxrpl xrpl.helpers.test
+patch_nix_binary(xrpl_tests)
+set_target_properties(
+    xrpl_tests
+    PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
 )
+# Lets test sources include the shared helpers as .
+target_include_directories(xrpl_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
+target_link_libraries(xrpl_tests PRIVATE GTest::gtest xrpl.libxrpl)
 
-# One test for each module.
-xrpl_add_test(basics)
-target_link_libraries(xrpl.test.basics PRIVATE xrpl.imports.test)
-add_dependencies(xrpl.tests xrpl.test.basics)
-
-xrpl_add_test(crypto)
-target_link_libraries(xrpl.test.crypto PRIVATE xrpl.imports.test)
-add_dependencies(xrpl.tests xrpl.test.crypto)
-
-xrpl_add_test(json)
-target_link_libraries(xrpl.test.json PRIVATE xrpl.imports.test)
-add_dependencies(xrpl.tests xrpl.test.json)
-
-xrpl_add_test(tx)
-target_link_libraries(xrpl.test.tx PRIVATE xrpl.imports.test)
-add_dependencies(xrpl.tests xrpl.test.tx)
-
-xrpl_add_test(protocol_autogen)
-target_link_libraries(xrpl.test.protocol_autogen PRIVATE xrpl.imports.test)
-add_dependencies(xrpl.tests xrpl.test.protocol_autogen)
-
-# Network unit tests are currently not supported on Windows
+# One source subdirectory per module. Network unit tests are currently not
+# supported on Windows.
+set(test_modules
+    basics
+    crypto
+    json
+    tx
+    protocol_autogen
+)
 if(NOT WIN32)
-    xrpl_add_test(net)
-    target_link_libraries(xrpl.test.net PRIVATE xrpl.imports.test)
-    add_dependencies(xrpl.tests xrpl.test.net)
+    list(APPEND test_modules net)
 endif()
+
+foreach(module IN LISTS test_modules)
+    # Append the module's sources (${module}/*.cpp and ${module}.cpp, if any).
+    file(
+        GLOB_RECURSE sources
+        CONFIGURE_DEPENDS
+        "${CMAKE_CURRENT_SOURCE_DIR}/${module}/*.cpp"
+        "${CMAKE_CURRENT_SOURCE_DIR}/${module}.cpp"
+    )
+    target_sources(xrpl_tests PRIVATE ${sources})
+
+    # Expose the module's private headers under their canonical include path.
+    isolate_headers(
+        xrpl_tests
+        "${CMAKE_SOURCE_DIR}"
+        "${CMAKE_SOURCE_DIR}/tests/${module}"
+        PRIVATE
+    )
+endforeach()
+
+gtest_discover_tests(xrpl_tests DISCOVERY_TIMEOUT 60)
diff --git a/src/tests/libxrpl/crypto/main.cpp b/src/tests/libxrpl/crypto/main.cpp
deleted file mode 100644
index 5142bbe08a..0000000000
--- a/src/tests/libxrpl/crypto/main.cpp
+++ /dev/null
@@ -1,8 +0,0 @@
-#include 
-
-int
-main(int argc, char** argv)
-{
-    ::testing::InitGoogleTest(&argc, argv);
-    return RUN_ALL_TESTS();
-}
diff --git a/src/tests/libxrpl/helpers/Account.cpp b/src/tests/libxrpl/helpers/Account.cpp
index 736ae0a24b..4862309c82 100644
--- a/src/tests/libxrpl/helpers/Account.cpp
+++ b/src/tests/libxrpl/helpers/Account.cpp
@@ -7,7 +7,7 @@
 
 namespace xrpl::test {
 
-Account const Account::master{"masterpassphrase"};
+Account const Account::kMaster{"masterpassphrase"};
 
 Account::Account(std::string_view name, KeyType type)
     : name_(name)
diff --git a/src/tests/libxrpl/helpers/Account.h b/src/tests/libxrpl/helpers/Account.h
index 4e2d6e547f..a92497f2c3 100644
--- a/src/tests/libxrpl/helpers/Account.h
+++ b/src/tests/libxrpl/helpers/Account.h
@@ -26,7 +26,7 @@ public:
      * This account is created in the genesis ledger with all 100 billion XRP.
      * It uses the well-known seed "masterpassphrase".
      */
-    static Account const master;
+    static Account const kMaster;
 
     /**
      * @brief Create an account from a name.
@@ -39,28 +39,28 @@ public:
     explicit Account(std::string_view name, KeyType type = KeyType::Secp256k1);
 
     /** @brief Return the human-readable name. */
-    std::string const&
+    [[nodiscard]] std::string const&
     name() const noexcept
     {
         return name_;
     }
 
     /** @brief Return the AccountID. */
-    AccountID const&
+    [[nodiscard]] AccountID const&
     id() const noexcept
     {
         return id_;
     }
 
     /** @brief Return the public key. */
-    PublicKey const&
+    [[nodiscard]] PublicKey const&
     pk() const noexcept
     {
         return keyPair_.first;
     }
 
     /** @brief Return the secret key. */
-    SecretKey const&
+    [[nodiscard]] SecretKey const&
     sk() const noexcept
     {
         return keyPair_.second;
diff --git a/src/tests/libxrpl/helpers/IOU.h b/src/tests/libxrpl/helpers/IOU.h
index 18bc69cf33..d80f962edf 100644
--- a/src/tests/libxrpl/helpers/IOU.h
+++ b/src/tests/libxrpl/helpers/IOU.h
@@ -49,8 +49,7 @@ public:
      * @param currency The Currency object.
      * @param issuer The account that issues this currency.
      */
-    IOU(Currency currency, Account const& issuer)
-        : currency_(std::move(currency)), issuer_(issuer.id())
+    IOU(Currency currency, Account const& issuer) : currency_(currency), issuer_(issuer.id())
     {
         XRPL_ASSERT(!isXRP(currency_), "IOU: currency code must not resolve to XRP");
     }
diff --git a/src/tests/libxrpl/helpers/TestFamily.h b/src/tests/libxrpl/helpers/TestFamily.h
index 2f69a26faf..50f514480a 100644
--- a/src/tests/libxrpl/helpers/TestFamily.h
+++ b/src/tests/libxrpl/helpers/TestFamily.h
@@ -1,14 +1,15 @@
 #pragma once
 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
 
 #include 
 
-namespace xrpl {
-namespace test {
+namespace xrpl::test {
 
 /** Test implementation of Family for unit tests.
 
@@ -38,8 +39,8 @@ public:
         , j_(j)
     {
         Section config;
-        config.set("type", "memory");
-        config.set("path", "TestFamily");
+        config.set(Keys::kType, "memory");
+        config.set(Keys::kPath, "TestFamily");
         db_ = NodeStore::Manager::instance().makeDatabase(megabytes(4), scheduler_, 1, config, j);
     }
 
@@ -49,7 +50,7 @@ public:
         return *db_;
     }
 
-    NodeStore::Database const&
+    [[nodiscard]] NodeStore::Database const&
     db() const override
     {
         return *db_;
@@ -95,8 +96,8 @@ public:
     void
     reset() override
     {
-        fbCache_->reset();
-        tnCache_->reset();
+        (*fbCache_).reset();
+        (*tnCache_).reset();
     }
 
     /** Access the test clock for time manipulation in tests. */
@@ -107,5 +108,4 @@ public:
     }
 };
 
-}  // namespace test
-}  // namespace xrpl
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/helpers/TestServiceRegistry.h b/src/tests/libxrpl/helpers/TestServiceRegistry.h
index 7070927842..0536176344 100644
--- a/src/tests/libxrpl/helpers/TestServiceRegistry.h
+++ b/src/tests/libxrpl/helpers/TestServiceRegistry.h
@@ -16,8 +16,7 @@
 #include 
 #include 
 
-namespace xrpl {
-namespace test {
+namespace xrpl::test {
 
 /** Logs implementation that creates TestSink instances. */
 class TestLogs : public Logs
@@ -63,7 +62,7 @@ private:
 class TestServiceRegistry : public ServiceRegistry
 {
     TestLogs logs_{beast::Severity::Warning};
-    boost::asio::io_context io_context_;
+    boost::asio::io_context ioContext_;
     TestFamily family_{logs_.journal("TestFamily")};
     LoadFeeTrack feeTrack_{logs_.journal("LoadFeeTrack")};
     TestNetworkIDService networkIDService_;
@@ -344,7 +343,7 @@ public:
     boost::asio::io_context&
     getIOContext() override
     {
-        return io_context_;
+        return ioContext_;
     }
 
     Logs&
@@ -374,5 +373,4 @@ public:
     }
 };
 
-}  // namespace test
-}  // namespace xrpl
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/helpers/TxTest.cpp b/src/tests/libxrpl/helpers/TxTest.cpp
index 32667ba13d..86e36b8abf 100644
--- a/src/tests/libxrpl/helpers/TxTest.cpp
+++ b/src/tests/libxrpl/helpers/TxTest.cpp
@@ -123,7 +123,7 @@ void
 TxTest::createAccount(Account const& account, XRPAmount xrp, uint32_t accountFlags)
 {
     auto const paymentTer =
-        submit(transactions::PaymentBuilder{Account::master, account, xrp}, Account::master).ter;
+        submit(transactions::PaymentBuilder{Account::kMaster, account, xrp}, Account::kMaster).ter;
 
     if (paymentTer != tesSUCCESS)
     {
@@ -231,13 +231,13 @@ TxTest::getCloseTime() const
 STAmount
 TxTest::getBalance(AccountID const& account, IOU const& iou) const
 {
-    auto const sle = openLedger_->read(keylet::line(account, iou.issue()));
+    auto const sle = openLedger_->read(keylet::trustLine(account, iou.issue()));
     if (!sle)
         return STAmount{iou.issue(), 0};
 
-    auto const rippleState = ledger_entries::RippleState{sle};
+    auto const trustLine = ledger_entries::RippleState{sle};
 
-    auto balance = rippleState.getBalance();
+    auto balance = trustLine.getBalance();
     if (iou.issue().account == account)
     {
         throw std::logic_error("TxTest::getBalance: account is issuer");
diff --git a/src/tests/libxrpl/helpers/TxTest.h b/src/tests/libxrpl/helpers/TxTest.h
index 0b578c7e7f..1b3ce460a2 100644
--- a/src/tests/libxrpl/helpers/TxTest.h
+++ b/src/tests/libxrpl/helpers/TxTest.h
@@ -44,7 +44,7 @@ namespace xrpl::test {
  */
 template 
 constexpr XRPAmount
-XRP(T xrp)
+XRP(T xrp)  // NOLINT(readability-identifier-naming)
 {
     return XRPAmount{static_cast(xrp) * kDropsPerXrp.drops()};
 }
diff --git a/src/tests/libxrpl/json/main.cpp b/src/tests/libxrpl/json/main.cpp
deleted file mode 100644
index 5142bbe08a..0000000000
--- a/src/tests/libxrpl/json/main.cpp
+++ /dev/null
@@ -1,8 +0,0 @@
-#include 
-
-int
-main(int argc, char** argv)
-{
-    ::testing::InitGoogleTest(&argc, argv);
-    return RUN_ALL_TESTS();
-}
diff --git a/src/tests/libxrpl/basics/main.cpp b/src/tests/libxrpl/main.cpp
similarity index 100%
rename from src/tests/libxrpl/basics/main.cpp
rename to src/tests/libxrpl/main.cpp
diff --git a/src/tests/libxrpl/net/main.cpp b/src/tests/libxrpl/net/main.cpp
deleted file mode 100644
index 5142bbe08a..0000000000
--- a/src/tests/libxrpl/net/main.cpp
+++ /dev/null
@@ -1,8 +0,0 @@
-#include 
-
-int
-main(int argc, char** argv)
-{
-    ::testing::InitGoogleTest(&argc, argv);
-    return RUN_ALL_TESTS();
-}
diff --git a/src/tests/libxrpl/protocol_autogen/TestHelpers.h b/src/tests/libxrpl/protocol_autogen/TestHelpers.h
index b8449f2bca..a32ab5e20c 100644
--- a/src/tests/libxrpl/protocol_autogen/TestHelpers.h
+++ b/src/tests/libxrpl/protocol_autogen/TestHelpers.h
@@ -159,7 +159,7 @@ canonical_ARRAY()
 inline STObject
 canonical_OBJECT()
 {
-    return STObject{kSfGeneric};
+    return STObject{sfGeneric};
 }
 
 inline STPathSet
diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp
index e74af94a5f..8dc5960ee0 100644
--- a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp
@@ -33,6 +33,10 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
     auto const previousTxnLgrSeqValue = canonical_UINT32();
     auto const domainIDValue = canonical_UINT256();
     auto const mutableFlagsValue = canonical_UINT32();
+    auto const referenceHoldingValue = canonical_UINT256();
+    auto const issuerEncryptionKeyValue = canonical_VL();
+    auto const auditorEncryptionKeyValue = canonical_VL();
+    auto const confidentialOutstandingAmountValue = canonical_UINT64();
 
     MPTokenIssuanceBuilder builder{
         issuerValue,
@@ -50,6 +54,10 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
     builder.setMPTokenMetadata(mPTokenMetadataValue);
     builder.setDomainID(domainIDValue);
     builder.setMutableFlags(mutableFlagsValue);
+    builder.setReferenceHolding(referenceHoldingValue);
+    builder.setIssuerEncryptionKey(issuerEncryptionKeyValue);
+    builder.setAuditorEncryptionKey(auditorEncryptionKeyValue);
+    builder.setConfidentialOutstandingAmount(confidentialOutstandingAmountValue);
 
     builder.setLedgerIndex(index);
     builder.setFlags(0x1u);
@@ -152,6 +160,38 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(entry.hasMutableFlags());
     }
 
+    {
+        auto const& expected = referenceHoldingValue;
+        auto const actualOpt = entry.getReferenceHolding();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfReferenceHolding");
+        EXPECT_TRUE(entry.hasReferenceHolding());
+    }
+
+    {
+        auto const& expected = issuerEncryptionKeyValue;
+        auto const actualOpt = entry.getIssuerEncryptionKey();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfIssuerEncryptionKey");
+        EXPECT_TRUE(entry.hasIssuerEncryptionKey());
+    }
+
+    {
+        auto const& expected = auditorEncryptionKeyValue;
+        auto const actualOpt = entry.getAuditorEncryptionKey();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfAuditorEncryptionKey");
+        EXPECT_TRUE(entry.hasAuditorEncryptionKey());
+    }
+
+    {
+        auto const& expected = confidentialOutstandingAmountValue;
+        auto const actualOpt = entry.getConfidentialOutstandingAmount();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfConfidentialOutstandingAmount");
+        EXPECT_TRUE(entry.hasConfidentialOutstandingAmount());
+    }
+
     EXPECT_TRUE(entry.hasLedgerIndex());
     auto const ledgerIndex = entry.getLedgerIndex();
     ASSERT_TRUE(ledgerIndex.has_value());
@@ -178,6 +218,10 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
     auto const previousTxnLgrSeqValue = canonical_UINT32();
     auto const domainIDValue = canonical_UINT256();
     auto const mutableFlagsValue = canonical_UINT32();
+    auto const referenceHoldingValue = canonical_UINT256();
+    auto const issuerEncryptionKeyValue = canonical_VL();
+    auto const auditorEncryptionKeyValue = canonical_VL();
+    auto const confidentialOutstandingAmountValue = canonical_UINT64();
 
     auto sle = std::make_shared(MPTokenIssuance::entryType, index);
 
@@ -194,6 +238,10 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
     sle->at(sfPreviousTxnLgrSeq) = previousTxnLgrSeqValue;
     sle->at(sfDomainID) = domainIDValue;
     sle->at(sfMutableFlags) = mutableFlagsValue;
+    sle->at(sfReferenceHolding) = referenceHoldingValue;
+    sle->at(sfIssuerEncryptionKey) = issuerEncryptionKeyValue;
+    sle->at(sfAuditorEncryptionKey) = auditorEncryptionKeyValue;
+    sle->at(sfConfidentialOutstandingAmount) = confidentialOutstandingAmountValue;
 
     MPTokenIssuanceBuilder builderFromSle{sle};
     EXPECT_TRUE(builderFromSle.validate());
@@ -355,6 +403,58 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
         expectEqualField(expected, *fromBuilderOpt, "sfMutableFlags");
     }
 
+    {
+        auto const& expected = referenceHoldingValue;
+
+        auto const fromSleOpt = entryFromSle.getReferenceHolding();
+        auto const fromBuilderOpt = entryFromBuilder.getReferenceHolding();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfReferenceHolding");
+        expectEqualField(expected, *fromBuilderOpt, "sfReferenceHolding");
+    }
+
+    {
+        auto const& expected = issuerEncryptionKeyValue;
+
+        auto const fromSleOpt = entryFromSle.getIssuerEncryptionKey();
+        auto const fromBuilderOpt = entryFromBuilder.getIssuerEncryptionKey();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfIssuerEncryptionKey");
+        expectEqualField(expected, *fromBuilderOpt, "sfIssuerEncryptionKey");
+    }
+
+    {
+        auto const& expected = auditorEncryptionKeyValue;
+
+        auto const fromSleOpt = entryFromSle.getAuditorEncryptionKey();
+        auto const fromBuilderOpt = entryFromBuilder.getAuditorEncryptionKey();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfAuditorEncryptionKey");
+        expectEqualField(expected, *fromBuilderOpt, "sfAuditorEncryptionKey");
+    }
+
+    {
+        auto const& expected = confidentialOutstandingAmountValue;
+
+        auto const fromSleOpt = entryFromSle.getConfidentialOutstandingAmount();
+        auto const fromBuilderOpt = entryFromBuilder.getConfidentialOutstandingAmount();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfConfidentialOutstandingAmount");
+        expectEqualField(expected, *fromBuilderOpt, "sfConfidentialOutstandingAmount");
+    }
+
     EXPECT_EQ(entryFromSle.getKey(), index);
     EXPECT_EQ(entryFromBuilder.getKey(), index);
 }
@@ -433,5 +533,13 @@ TEST(MPTokenIssuanceTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(entry.getDomainID().has_value());
     EXPECT_FALSE(entry.hasMutableFlags());
     EXPECT_FALSE(entry.getMutableFlags().has_value());
+    EXPECT_FALSE(entry.hasReferenceHolding());
+    EXPECT_FALSE(entry.getReferenceHolding().has_value());
+    EXPECT_FALSE(entry.hasIssuerEncryptionKey());
+    EXPECT_FALSE(entry.getIssuerEncryptionKey().has_value());
+    EXPECT_FALSE(entry.hasAuditorEncryptionKey());
+    EXPECT_FALSE(entry.getAuditorEncryptionKey().has_value());
+    EXPECT_FALSE(entry.hasConfidentialOutstandingAmount());
+    EXPECT_FALSE(entry.getConfidentialOutstandingAmount().has_value());
 }
 }
diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenTests.cpp
index c104e7b365..7db4b638a7 100644
--- a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenTests.cpp
@@ -27,6 +27,12 @@ TEST(MPTokenTests, BuilderSettersRoundTrip)
     auto const ownerNodeValue = canonical_UINT64();
     auto const previousTxnIDValue = canonical_UINT256();
     auto const previousTxnLgrSeqValue = canonical_UINT32();
+    auto const confidentialBalanceInboxValue = canonical_VL();
+    auto const confidentialBalanceSpendingValue = canonical_VL();
+    auto const confidentialBalanceVersionValue = canonical_UINT32();
+    auto const issuerEncryptedBalanceValue = canonical_VL();
+    auto const auditorEncryptedBalanceValue = canonical_VL();
+    auto const holderEncryptionKeyValue = canonical_VL();
 
     MPTokenBuilder builder{
         accountValue,
@@ -38,6 +44,12 @@ TEST(MPTokenTests, BuilderSettersRoundTrip)
 
     builder.setMPTAmount(mPTAmountValue);
     builder.setLockedAmount(lockedAmountValue);
+    builder.setConfidentialBalanceInbox(confidentialBalanceInboxValue);
+    builder.setConfidentialBalanceSpending(confidentialBalanceSpendingValue);
+    builder.setConfidentialBalanceVersion(confidentialBalanceVersionValue);
+    builder.setIssuerEncryptedBalance(issuerEncryptedBalanceValue);
+    builder.setAuditorEncryptedBalance(auditorEncryptedBalanceValue);
+    builder.setHolderEncryptionKey(holderEncryptionKeyValue);
 
     builder.setLedgerIndex(index);
     builder.setFlags(0x1u);
@@ -94,6 +106,54 @@ TEST(MPTokenTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(entry.hasLockedAmount());
     }
 
+    {
+        auto const& expected = confidentialBalanceInboxValue;
+        auto const actualOpt = entry.getConfidentialBalanceInbox();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfConfidentialBalanceInbox");
+        EXPECT_TRUE(entry.hasConfidentialBalanceInbox());
+    }
+
+    {
+        auto const& expected = confidentialBalanceSpendingValue;
+        auto const actualOpt = entry.getConfidentialBalanceSpending();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfConfidentialBalanceSpending");
+        EXPECT_TRUE(entry.hasConfidentialBalanceSpending());
+    }
+
+    {
+        auto const& expected = confidentialBalanceVersionValue;
+        auto const actualOpt = entry.getConfidentialBalanceVersion();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfConfidentialBalanceVersion");
+        EXPECT_TRUE(entry.hasConfidentialBalanceVersion());
+    }
+
+    {
+        auto const& expected = issuerEncryptedBalanceValue;
+        auto const actualOpt = entry.getIssuerEncryptedBalance();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfIssuerEncryptedBalance");
+        EXPECT_TRUE(entry.hasIssuerEncryptedBalance());
+    }
+
+    {
+        auto const& expected = auditorEncryptedBalanceValue;
+        auto const actualOpt = entry.getAuditorEncryptedBalance();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfAuditorEncryptedBalance");
+        EXPECT_TRUE(entry.hasAuditorEncryptedBalance());
+    }
+
+    {
+        auto const& expected = holderEncryptionKeyValue;
+        auto const actualOpt = entry.getHolderEncryptionKey();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfHolderEncryptionKey");
+        EXPECT_TRUE(entry.hasHolderEncryptionKey());
+    }
+
     EXPECT_TRUE(entry.hasLedgerIndex());
     auto const ledgerIndex = entry.getLedgerIndex();
     ASSERT_TRUE(ledgerIndex.has_value());
@@ -114,6 +174,12 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip)
     auto const ownerNodeValue = canonical_UINT64();
     auto const previousTxnIDValue = canonical_UINT256();
     auto const previousTxnLgrSeqValue = canonical_UINT32();
+    auto const confidentialBalanceInboxValue = canonical_VL();
+    auto const confidentialBalanceSpendingValue = canonical_VL();
+    auto const confidentialBalanceVersionValue = canonical_UINT32();
+    auto const issuerEncryptedBalanceValue = canonical_VL();
+    auto const auditorEncryptedBalanceValue = canonical_VL();
+    auto const holderEncryptionKeyValue = canonical_VL();
 
     auto sle = std::make_shared(MPToken::entryType, index);
 
@@ -124,6 +190,12 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip)
     sle->at(sfOwnerNode) = ownerNodeValue;
     sle->at(sfPreviousTxnID) = previousTxnIDValue;
     sle->at(sfPreviousTxnLgrSeq) = previousTxnLgrSeqValue;
+    sle->at(sfConfidentialBalanceInbox) = confidentialBalanceInboxValue;
+    sle->at(sfConfidentialBalanceSpending) = confidentialBalanceSpendingValue;
+    sle->at(sfConfidentialBalanceVersion) = confidentialBalanceVersionValue;
+    sle->at(sfIssuerEncryptedBalance) = issuerEncryptedBalanceValue;
+    sle->at(sfAuditorEncryptedBalance) = auditorEncryptedBalanceValue;
+    sle->at(sfHolderEncryptionKey) = holderEncryptionKeyValue;
 
     MPTokenBuilder builderFromSle{sle};
     EXPECT_TRUE(builderFromSle.validate());
@@ -210,6 +282,84 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip)
         expectEqualField(expected, *fromBuilderOpt, "sfLockedAmount");
     }
 
+    {
+        auto const& expected = confidentialBalanceInboxValue;
+
+        auto const fromSleOpt = entryFromSle.getConfidentialBalanceInbox();
+        auto const fromBuilderOpt = entryFromBuilder.getConfidentialBalanceInbox();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfConfidentialBalanceInbox");
+        expectEqualField(expected, *fromBuilderOpt, "sfConfidentialBalanceInbox");
+    }
+
+    {
+        auto const& expected = confidentialBalanceSpendingValue;
+
+        auto const fromSleOpt = entryFromSle.getConfidentialBalanceSpending();
+        auto const fromBuilderOpt = entryFromBuilder.getConfidentialBalanceSpending();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfConfidentialBalanceSpending");
+        expectEqualField(expected, *fromBuilderOpt, "sfConfidentialBalanceSpending");
+    }
+
+    {
+        auto const& expected = confidentialBalanceVersionValue;
+
+        auto const fromSleOpt = entryFromSle.getConfidentialBalanceVersion();
+        auto const fromBuilderOpt = entryFromBuilder.getConfidentialBalanceVersion();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfConfidentialBalanceVersion");
+        expectEqualField(expected, *fromBuilderOpt, "sfConfidentialBalanceVersion");
+    }
+
+    {
+        auto const& expected = issuerEncryptedBalanceValue;
+
+        auto const fromSleOpt = entryFromSle.getIssuerEncryptedBalance();
+        auto const fromBuilderOpt = entryFromBuilder.getIssuerEncryptedBalance();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfIssuerEncryptedBalance");
+        expectEqualField(expected, *fromBuilderOpt, "sfIssuerEncryptedBalance");
+    }
+
+    {
+        auto const& expected = auditorEncryptedBalanceValue;
+
+        auto const fromSleOpt = entryFromSle.getAuditorEncryptedBalance();
+        auto const fromBuilderOpt = entryFromBuilder.getAuditorEncryptedBalance();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfAuditorEncryptedBalance");
+        expectEqualField(expected, *fromBuilderOpt, "sfAuditorEncryptedBalance");
+    }
+
+    {
+        auto const& expected = holderEncryptionKeyValue;
+
+        auto const fromSleOpt = entryFromSle.getHolderEncryptionKey();
+        auto const fromBuilderOpt = entryFromBuilder.getHolderEncryptionKey();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfHolderEncryptionKey");
+        expectEqualField(expected, *fromBuilderOpt, "sfHolderEncryptionKey");
+    }
+
     EXPECT_EQ(entryFromSle.getKey(), index);
     EXPECT_EQ(entryFromBuilder.getKey(), index);
 }
@@ -276,5 +426,17 @@ TEST(MPTokenTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(entry.getMPTAmount().has_value());
     EXPECT_FALSE(entry.hasLockedAmount());
     EXPECT_FALSE(entry.getLockedAmount().has_value());
+    EXPECT_FALSE(entry.hasConfidentialBalanceInbox());
+    EXPECT_FALSE(entry.getConfidentialBalanceInbox().has_value());
+    EXPECT_FALSE(entry.hasConfidentialBalanceSpending());
+    EXPECT_FALSE(entry.getConfidentialBalanceSpending().has_value());
+    EXPECT_FALSE(entry.hasConfidentialBalanceVersion());
+    EXPECT_FALSE(entry.getConfidentialBalanceVersion().has_value());
+    EXPECT_FALSE(entry.hasIssuerEncryptedBalance());
+    EXPECT_FALSE(entry.getIssuerEncryptedBalance().has_value());
+    EXPECT_FALSE(entry.hasAuditorEncryptedBalance());
+    EXPECT_FALSE(entry.getAuditorEncryptedBalance().has_value());
+    EXPECT_FALSE(entry.hasHolderEncryptionKey());
+    EXPECT_FALSE(entry.getHolderEncryptionKey().has_value());
 }
 }
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTClawbackTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTClawbackTests.cpp
new file mode 100644
index 0000000000..611bc18465
--- /dev/null
+++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTClawbackTests.cpp
@@ -0,0 +1,194 @@
+// Auto-generated unit tests for transaction ConfidentialMPTClawback
+
+
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+namespace xrpl::transactions {
+
+// 1 & 4) Set fields via builder setters, build, then read them back via
+// wrapper getters. After build(), validate() should succeed.
+TEST(TransactionsConfidentialMPTClawbackTests, BuilderSettersRoundTrip)
+{
+    // Generate a deterministic keypair for signing
+    auto const [publicKey, secretKey] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTClawback"));
+
+    // Common transaction fields
+    auto const accountValue = calcAccountID(publicKey);
+    std::uint32_t const sequenceValue = 1;
+    auto const feeValue = canonical_AMOUNT();
+
+    // Transaction-specific field values
+    auto const mPTokenIssuanceIDValue = canonical_UINT192();
+    auto const holderValue = canonical_ACCOUNT();
+    auto const mPTAmountValue = canonical_UINT64();
+    auto const zKProofValue = canonical_VL();
+
+    ConfidentialMPTClawbackBuilder builder{
+        accountValue,
+        mPTokenIssuanceIDValue,
+        holderValue,
+        mPTAmountValue,
+        zKProofValue,
+        sequenceValue,
+        feeValue
+    };
+
+    // Set optional fields
+
+    auto tx = builder.build(publicKey, secretKey);
+
+    std::string reason;
+    EXPECT_TRUE(tx.validate(reason)) << reason;
+
+    // Verify signing was applied
+    EXPECT_FALSE(tx.getSigningPubKey().empty());
+    EXPECT_TRUE(tx.hasTxnSignature());
+
+    // Verify common fields
+    EXPECT_EQ(tx.getAccount(), accountValue);
+    EXPECT_EQ(tx.getSequence(), sequenceValue);
+    EXPECT_EQ(tx.getFee(), feeValue);
+
+    // Verify required fields
+    {
+        auto const& expected = mPTokenIssuanceIDValue;
+        auto const actual = tx.getMPTokenIssuanceID();
+        expectEqualField(expected, actual, "sfMPTokenIssuanceID");
+    }
+
+    {
+        auto const& expected = holderValue;
+        auto const actual = tx.getHolder();
+        expectEqualField(expected, actual, "sfHolder");
+    }
+
+    {
+        auto const& expected = mPTAmountValue;
+        auto const actual = tx.getMPTAmount();
+        expectEqualField(expected, actual, "sfMPTAmount");
+    }
+
+    {
+        auto const& expected = zKProofValue;
+        auto const actual = tx.getZKProof();
+        expectEqualField(expected, actual, "sfZKProof");
+    }
+
+    // Verify optional fields
+}
+
+// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
+// and verify all fields match.
+TEST(TransactionsConfidentialMPTClawbackTests, BuilderFromStTxRoundTrip)
+{
+    // Generate a deterministic keypair for signing
+    auto const [publicKey, secretKey] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTClawbackFromTx"));
+
+    // Common transaction fields
+    auto const accountValue = calcAccountID(publicKey);
+    std::uint32_t const sequenceValue = 2;
+    auto const feeValue = canonical_AMOUNT();
+
+    // Transaction-specific field values
+    auto const mPTokenIssuanceIDValue = canonical_UINT192();
+    auto const holderValue = canonical_ACCOUNT();
+    auto const mPTAmountValue = canonical_UINT64();
+    auto const zKProofValue = canonical_VL();
+
+    // Build an initial transaction
+    ConfidentialMPTClawbackBuilder initialBuilder{
+        accountValue,
+        mPTokenIssuanceIDValue,
+        holderValue,
+        mPTAmountValue,
+        zKProofValue,
+        sequenceValue,
+        feeValue
+    };
+
+
+    auto initialTx = initialBuilder.build(publicKey, secretKey);
+
+    // Create builder from existing STTx
+    ConfidentialMPTClawbackBuilder builderFromTx{initialTx.getSTTx()};
+
+    auto rebuiltTx = builderFromTx.build(publicKey, secretKey);
+
+    std::string reason;
+    EXPECT_TRUE(rebuiltTx.validate(reason)) << reason;
+
+    // Verify common fields
+    EXPECT_EQ(rebuiltTx.getAccount(), accountValue);
+    EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue);
+    EXPECT_EQ(rebuiltTx.getFee(), feeValue);
+
+    // Verify required fields
+    {
+        auto const& expected = mPTokenIssuanceIDValue;
+        auto const actual = rebuiltTx.getMPTokenIssuanceID();
+        expectEqualField(expected, actual, "sfMPTokenIssuanceID");
+    }
+
+    {
+        auto const& expected = holderValue;
+        auto const actual = rebuiltTx.getHolder();
+        expectEqualField(expected, actual, "sfHolder");
+    }
+
+    {
+        auto const& expected = mPTAmountValue;
+        auto const actual = rebuiltTx.getMPTAmount();
+        expectEqualField(expected, actual, "sfMPTAmount");
+    }
+
+    {
+        auto const& expected = zKProofValue;
+        auto const actual = rebuiltTx.getZKProof();
+        expectEqualField(expected, actual, "sfZKProof");
+    }
+
+    // Verify optional fields
+}
+
+// 3) Verify wrapper throws when constructed from wrong transaction type.
+TEST(TransactionsConfidentialMPTClawbackTests, WrapperThrowsOnWrongTxType)
+{
+    // Build a valid transaction of a different type
+    auto const [pk, sk] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType"));
+    auto const account = calcAccountID(pk);
+
+    AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
+    auto wrongTx = wrongBuilder.build(pk, sk);
+
+    EXPECT_THROW(ConfidentialMPTClawback{wrongTx.getSTTx()}, std::runtime_error);
+}
+
+// 4) Verify builder throws when constructed from wrong transaction type.
+TEST(TransactionsConfidentialMPTClawbackTests, BuilderThrowsOnWrongTxType)
+{
+    // Build a valid transaction of a different type
+    auto const [pk, sk] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder"));
+    auto const account = calcAccountID(pk);
+
+    AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
+    auto wrongTx = wrongBuilder.build(pk, sk);
+
+    EXPECT_THROW(ConfidentialMPTClawbackBuilder{wrongTx.getSTTx()}, std::runtime_error);
+}
+
+
+}
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTConvertBackTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTConvertBackTests.cpp
new file mode 100644
index 0000000000..1a5fb3a046
--- /dev/null
+++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTConvertBackTests.cpp
@@ -0,0 +1,303 @@
+// Auto-generated unit tests for transaction ConfidentialMPTConvertBack
+
+
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+namespace xrpl::transactions {
+
+// 1 & 4) Set fields via builder setters, build, then read them back via
+// wrapper getters. After build(), validate() should succeed.
+TEST(TransactionsConfidentialMPTConvertBackTests, BuilderSettersRoundTrip)
+{
+    // Generate a deterministic keypair for signing
+    auto const [publicKey, secretKey] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTConvertBack"));
+
+    // Common transaction fields
+    auto const accountValue = calcAccountID(publicKey);
+    std::uint32_t const sequenceValue = 1;
+    auto const feeValue = canonical_AMOUNT();
+
+    // Transaction-specific field values
+    auto const mPTokenIssuanceIDValue = canonical_UINT192();
+    auto const mPTAmountValue = canonical_UINT64();
+    auto const holderEncryptedAmountValue = canonical_VL();
+    auto const issuerEncryptedAmountValue = canonical_VL();
+    auto const auditorEncryptedAmountValue = canonical_VL();
+    auto const blindingFactorValue = canonical_UINT256();
+    auto const zKProofValue = canonical_VL();
+    auto const balanceCommitmentValue = canonical_VL();
+
+    ConfidentialMPTConvertBackBuilder builder{
+        accountValue,
+        mPTokenIssuanceIDValue,
+        mPTAmountValue,
+        holderEncryptedAmountValue,
+        issuerEncryptedAmountValue,
+        blindingFactorValue,
+        zKProofValue,
+        balanceCommitmentValue,
+        sequenceValue,
+        feeValue
+    };
+
+    // Set optional fields
+    builder.setAuditorEncryptedAmount(auditorEncryptedAmountValue);
+
+    auto tx = builder.build(publicKey, secretKey);
+
+    std::string reason;
+    EXPECT_TRUE(tx.validate(reason)) << reason;
+
+    // Verify signing was applied
+    EXPECT_FALSE(tx.getSigningPubKey().empty());
+    EXPECT_TRUE(tx.hasTxnSignature());
+
+    // Verify common fields
+    EXPECT_EQ(tx.getAccount(), accountValue);
+    EXPECT_EQ(tx.getSequence(), sequenceValue);
+    EXPECT_EQ(tx.getFee(), feeValue);
+
+    // Verify required fields
+    {
+        auto const& expected = mPTokenIssuanceIDValue;
+        auto const actual = tx.getMPTokenIssuanceID();
+        expectEqualField(expected, actual, "sfMPTokenIssuanceID");
+    }
+
+    {
+        auto const& expected = mPTAmountValue;
+        auto const actual = tx.getMPTAmount();
+        expectEqualField(expected, actual, "sfMPTAmount");
+    }
+
+    {
+        auto const& expected = holderEncryptedAmountValue;
+        auto const actual = tx.getHolderEncryptedAmount();
+        expectEqualField(expected, actual, "sfHolderEncryptedAmount");
+    }
+
+    {
+        auto const& expected = issuerEncryptedAmountValue;
+        auto const actual = tx.getIssuerEncryptedAmount();
+        expectEqualField(expected, actual, "sfIssuerEncryptedAmount");
+    }
+
+    {
+        auto const& expected = blindingFactorValue;
+        auto const actual = tx.getBlindingFactor();
+        expectEqualField(expected, actual, "sfBlindingFactor");
+    }
+
+    {
+        auto const& expected = zKProofValue;
+        auto const actual = tx.getZKProof();
+        expectEqualField(expected, actual, "sfZKProof");
+    }
+
+    {
+        auto const& expected = balanceCommitmentValue;
+        auto const actual = tx.getBalanceCommitment();
+        expectEqualField(expected, actual, "sfBalanceCommitment");
+    }
+
+    // Verify optional fields
+    {
+        auto const& expected = auditorEncryptedAmountValue;
+        auto const actualOpt = tx.getAuditorEncryptedAmount();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptedAmount should be present";
+        expectEqualField(expected, *actualOpt, "sfAuditorEncryptedAmount");
+        EXPECT_TRUE(tx.hasAuditorEncryptedAmount());
+    }
+
+}
+
+// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
+// and verify all fields match.
+TEST(TransactionsConfidentialMPTConvertBackTests, BuilderFromStTxRoundTrip)
+{
+    // Generate a deterministic keypair for signing
+    auto const [publicKey, secretKey] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTConvertBackFromTx"));
+
+    // Common transaction fields
+    auto const accountValue = calcAccountID(publicKey);
+    std::uint32_t const sequenceValue = 2;
+    auto const feeValue = canonical_AMOUNT();
+
+    // Transaction-specific field values
+    auto const mPTokenIssuanceIDValue = canonical_UINT192();
+    auto const mPTAmountValue = canonical_UINT64();
+    auto const holderEncryptedAmountValue = canonical_VL();
+    auto const issuerEncryptedAmountValue = canonical_VL();
+    auto const auditorEncryptedAmountValue = canonical_VL();
+    auto const blindingFactorValue = canonical_UINT256();
+    auto const zKProofValue = canonical_VL();
+    auto const balanceCommitmentValue = canonical_VL();
+
+    // Build an initial transaction
+    ConfidentialMPTConvertBackBuilder initialBuilder{
+        accountValue,
+        mPTokenIssuanceIDValue,
+        mPTAmountValue,
+        holderEncryptedAmountValue,
+        issuerEncryptedAmountValue,
+        blindingFactorValue,
+        zKProofValue,
+        balanceCommitmentValue,
+        sequenceValue,
+        feeValue
+    };
+
+    initialBuilder.setAuditorEncryptedAmount(auditorEncryptedAmountValue);
+
+    auto initialTx = initialBuilder.build(publicKey, secretKey);
+
+    // Create builder from existing STTx
+    ConfidentialMPTConvertBackBuilder builderFromTx{initialTx.getSTTx()};
+
+    auto rebuiltTx = builderFromTx.build(publicKey, secretKey);
+
+    std::string reason;
+    EXPECT_TRUE(rebuiltTx.validate(reason)) << reason;
+
+    // Verify common fields
+    EXPECT_EQ(rebuiltTx.getAccount(), accountValue);
+    EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue);
+    EXPECT_EQ(rebuiltTx.getFee(), feeValue);
+
+    // Verify required fields
+    {
+        auto const& expected = mPTokenIssuanceIDValue;
+        auto const actual = rebuiltTx.getMPTokenIssuanceID();
+        expectEqualField(expected, actual, "sfMPTokenIssuanceID");
+    }
+
+    {
+        auto const& expected = mPTAmountValue;
+        auto const actual = rebuiltTx.getMPTAmount();
+        expectEqualField(expected, actual, "sfMPTAmount");
+    }
+
+    {
+        auto const& expected = holderEncryptedAmountValue;
+        auto const actual = rebuiltTx.getHolderEncryptedAmount();
+        expectEqualField(expected, actual, "sfHolderEncryptedAmount");
+    }
+
+    {
+        auto const& expected = issuerEncryptedAmountValue;
+        auto const actual = rebuiltTx.getIssuerEncryptedAmount();
+        expectEqualField(expected, actual, "sfIssuerEncryptedAmount");
+    }
+
+    {
+        auto const& expected = blindingFactorValue;
+        auto const actual = rebuiltTx.getBlindingFactor();
+        expectEqualField(expected, actual, "sfBlindingFactor");
+    }
+
+    {
+        auto const& expected = zKProofValue;
+        auto const actual = rebuiltTx.getZKProof();
+        expectEqualField(expected, actual, "sfZKProof");
+    }
+
+    {
+        auto const& expected = balanceCommitmentValue;
+        auto const actual = rebuiltTx.getBalanceCommitment();
+        expectEqualField(expected, actual, "sfBalanceCommitment");
+    }
+
+    // Verify optional fields
+    {
+        auto const& expected = auditorEncryptedAmountValue;
+        auto const actualOpt = rebuiltTx.getAuditorEncryptedAmount();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptedAmount should be present";
+        expectEqualField(expected, *actualOpt, "sfAuditorEncryptedAmount");
+    }
+
+}
+
+// 3) Verify wrapper throws when constructed from wrong transaction type.
+TEST(TransactionsConfidentialMPTConvertBackTests, WrapperThrowsOnWrongTxType)
+{
+    // Build a valid transaction of a different type
+    auto const [pk, sk] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType"));
+    auto const account = calcAccountID(pk);
+
+    AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
+    auto wrongTx = wrongBuilder.build(pk, sk);
+
+    EXPECT_THROW(ConfidentialMPTConvertBack{wrongTx.getSTTx()}, std::runtime_error);
+}
+
+// 4) Verify builder throws when constructed from wrong transaction type.
+TEST(TransactionsConfidentialMPTConvertBackTests, BuilderThrowsOnWrongTxType)
+{
+    // Build a valid transaction of a different type
+    auto const [pk, sk] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder"));
+    auto const account = calcAccountID(pk);
+
+    AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
+    auto wrongTx = wrongBuilder.build(pk, sk);
+
+    EXPECT_THROW(ConfidentialMPTConvertBackBuilder{wrongTx.getSTTx()}, std::runtime_error);
+}
+
+// 5) Build with only required fields and verify optional fields return nullopt.
+TEST(TransactionsConfidentialMPTConvertBackTests, OptionalFieldsReturnNullopt)
+{
+    // Generate a deterministic keypair for signing
+    auto const [publicKey, secretKey] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTConvertBackNullopt"));
+
+    // Common transaction fields
+    auto const accountValue = calcAccountID(publicKey);
+    std::uint32_t const sequenceValue = 3;
+    auto const feeValue = canonical_AMOUNT();
+
+    // Transaction-specific required field values
+    auto const mPTokenIssuanceIDValue = canonical_UINT192();
+    auto const mPTAmountValue = canonical_UINT64();
+    auto const holderEncryptedAmountValue = canonical_VL();
+    auto const issuerEncryptedAmountValue = canonical_VL();
+    auto const blindingFactorValue = canonical_UINT256();
+    auto const zKProofValue = canonical_VL();
+    auto const balanceCommitmentValue = canonical_VL();
+
+    ConfidentialMPTConvertBackBuilder builder{
+        accountValue,
+        mPTokenIssuanceIDValue,
+        mPTAmountValue,
+        holderEncryptedAmountValue,
+        issuerEncryptedAmountValue,
+        blindingFactorValue,
+        zKProofValue,
+        balanceCommitmentValue,
+        sequenceValue,
+        feeValue
+    };
+
+    // Do NOT set optional fields
+
+    auto tx = builder.build(publicKey, secretKey);
+
+    // Verify optional fields are not present
+    EXPECT_FALSE(tx.hasAuditorEncryptedAmount());
+    EXPECT_FALSE(tx.getAuditorEncryptedAmount().has_value());
+}
+
+}
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTConvertTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTConvertTests.cpp
new file mode 100644
index 0000000000..6110196353
--- /dev/null
+++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTConvertTests.cpp
@@ -0,0 +1,309 @@
+// Auto-generated unit tests for transaction ConfidentialMPTConvert
+
+
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+namespace xrpl::transactions {
+
+// 1 & 4) Set fields via builder setters, build, then read them back via
+// wrapper getters. After build(), validate() should succeed.
+TEST(TransactionsConfidentialMPTConvertTests, BuilderSettersRoundTrip)
+{
+    // Generate a deterministic keypair for signing
+    auto const [publicKey, secretKey] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTConvert"));
+
+    // Common transaction fields
+    auto const accountValue = calcAccountID(publicKey);
+    std::uint32_t const sequenceValue = 1;
+    auto const feeValue = canonical_AMOUNT();
+
+    // Transaction-specific field values
+    auto const mPTokenIssuanceIDValue = canonical_UINT192();
+    auto const mPTAmountValue = canonical_UINT64();
+    auto const holderEncryptionKeyValue = canonical_VL();
+    auto const holderEncryptedAmountValue = canonical_VL();
+    auto const issuerEncryptedAmountValue = canonical_VL();
+    auto const auditorEncryptedAmountValue = canonical_VL();
+    auto const blindingFactorValue = canonical_UINT256();
+    auto const zKProofValue = canonical_VL();
+
+    ConfidentialMPTConvertBuilder builder{
+        accountValue,
+        mPTokenIssuanceIDValue,
+        mPTAmountValue,
+        holderEncryptedAmountValue,
+        issuerEncryptedAmountValue,
+        blindingFactorValue,
+        sequenceValue,
+        feeValue
+    };
+
+    // Set optional fields
+    builder.setHolderEncryptionKey(holderEncryptionKeyValue);
+    builder.setAuditorEncryptedAmount(auditorEncryptedAmountValue);
+    builder.setZKProof(zKProofValue);
+
+    auto tx = builder.build(publicKey, secretKey);
+
+    std::string reason;
+    EXPECT_TRUE(tx.validate(reason)) << reason;
+
+    // Verify signing was applied
+    EXPECT_FALSE(tx.getSigningPubKey().empty());
+    EXPECT_TRUE(tx.hasTxnSignature());
+
+    // Verify common fields
+    EXPECT_EQ(tx.getAccount(), accountValue);
+    EXPECT_EQ(tx.getSequence(), sequenceValue);
+    EXPECT_EQ(tx.getFee(), feeValue);
+
+    // Verify required fields
+    {
+        auto const& expected = mPTokenIssuanceIDValue;
+        auto const actual = tx.getMPTokenIssuanceID();
+        expectEqualField(expected, actual, "sfMPTokenIssuanceID");
+    }
+
+    {
+        auto const& expected = mPTAmountValue;
+        auto const actual = tx.getMPTAmount();
+        expectEqualField(expected, actual, "sfMPTAmount");
+    }
+
+    {
+        auto const& expected = holderEncryptedAmountValue;
+        auto const actual = tx.getHolderEncryptedAmount();
+        expectEqualField(expected, actual, "sfHolderEncryptedAmount");
+    }
+
+    {
+        auto const& expected = issuerEncryptedAmountValue;
+        auto const actual = tx.getIssuerEncryptedAmount();
+        expectEqualField(expected, actual, "sfIssuerEncryptedAmount");
+    }
+
+    {
+        auto const& expected = blindingFactorValue;
+        auto const actual = tx.getBlindingFactor();
+        expectEqualField(expected, actual, "sfBlindingFactor");
+    }
+
+    // Verify optional fields
+    {
+        auto const& expected = holderEncryptionKeyValue;
+        auto const actualOpt = tx.getHolderEncryptionKey();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfHolderEncryptionKey should be present";
+        expectEqualField(expected, *actualOpt, "sfHolderEncryptionKey");
+        EXPECT_TRUE(tx.hasHolderEncryptionKey());
+    }
+
+    {
+        auto const& expected = auditorEncryptedAmountValue;
+        auto const actualOpt = tx.getAuditorEncryptedAmount();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptedAmount should be present";
+        expectEqualField(expected, *actualOpt, "sfAuditorEncryptedAmount");
+        EXPECT_TRUE(tx.hasAuditorEncryptedAmount());
+    }
+
+    {
+        auto const& expected = zKProofValue;
+        auto const actualOpt = tx.getZKProof();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfZKProof should be present";
+        expectEqualField(expected, *actualOpt, "sfZKProof");
+        EXPECT_TRUE(tx.hasZKProof());
+    }
+
+}
+
+// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
+// and verify all fields match.
+TEST(TransactionsConfidentialMPTConvertTests, BuilderFromStTxRoundTrip)
+{
+    // Generate a deterministic keypair for signing
+    auto const [publicKey, secretKey] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTConvertFromTx"));
+
+    // Common transaction fields
+    auto const accountValue = calcAccountID(publicKey);
+    std::uint32_t const sequenceValue = 2;
+    auto const feeValue = canonical_AMOUNT();
+
+    // Transaction-specific field values
+    auto const mPTokenIssuanceIDValue = canonical_UINT192();
+    auto const mPTAmountValue = canonical_UINT64();
+    auto const holderEncryptionKeyValue = canonical_VL();
+    auto const holderEncryptedAmountValue = canonical_VL();
+    auto const issuerEncryptedAmountValue = canonical_VL();
+    auto const auditorEncryptedAmountValue = canonical_VL();
+    auto const blindingFactorValue = canonical_UINT256();
+    auto const zKProofValue = canonical_VL();
+
+    // Build an initial transaction
+    ConfidentialMPTConvertBuilder initialBuilder{
+        accountValue,
+        mPTokenIssuanceIDValue,
+        mPTAmountValue,
+        holderEncryptedAmountValue,
+        issuerEncryptedAmountValue,
+        blindingFactorValue,
+        sequenceValue,
+        feeValue
+    };
+
+    initialBuilder.setHolderEncryptionKey(holderEncryptionKeyValue);
+    initialBuilder.setAuditorEncryptedAmount(auditorEncryptedAmountValue);
+    initialBuilder.setZKProof(zKProofValue);
+
+    auto initialTx = initialBuilder.build(publicKey, secretKey);
+
+    // Create builder from existing STTx
+    ConfidentialMPTConvertBuilder builderFromTx{initialTx.getSTTx()};
+
+    auto rebuiltTx = builderFromTx.build(publicKey, secretKey);
+
+    std::string reason;
+    EXPECT_TRUE(rebuiltTx.validate(reason)) << reason;
+
+    // Verify common fields
+    EXPECT_EQ(rebuiltTx.getAccount(), accountValue);
+    EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue);
+    EXPECT_EQ(rebuiltTx.getFee(), feeValue);
+
+    // Verify required fields
+    {
+        auto const& expected = mPTokenIssuanceIDValue;
+        auto const actual = rebuiltTx.getMPTokenIssuanceID();
+        expectEqualField(expected, actual, "sfMPTokenIssuanceID");
+    }
+
+    {
+        auto const& expected = mPTAmountValue;
+        auto const actual = rebuiltTx.getMPTAmount();
+        expectEqualField(expected, actual, "sfMPTAmount");
+    }
+
+    {
+        auto const& expected = holderEncryptedAmountValue;
+        auto const actual = rebuiltTx.getHolderEncryptedAmount();
+        expectEqualField(expected, actual, "sfHolderEncryptedAmount");
+    }
+
+    {
+        auto const& expected = issuerEncryptedAmountValue;
+        auto const actual = rebuiltTx.getIssuerEncryptedAmount();
+        expectEqualField(expected, actual, "sfIssuerEncryptedAmount");
+    }
+
+    {
+        auto const& expected = blindingFactorValue;
+        auto const actual = rebuiltTx.getBlindingFactor();
+        expectEqualField(expected, actual, "sfBlindingFactor");
+    }
+
+    // Verify optional fields
+    {
+        auto const& expected = holderEncryptionKeyValue;
+        auto const actualOpt = rebuiltTx.getHolderEncryptionKey();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfHolderEncryptionKey should be present";
+        expectEqualField(expected, *actualOpt, "sfHolderEncryptionKey");
+    }
+
+    {
+        auto const& expected = auditorEncryptedAmountValue;
+        auto const actualOpt = rebuiltTx.getAuditorEncryptedAmount();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptedAmount should be present";
+        expectEqualField(expected, *actualOpt, "sfAuditorEncryptedAmount");
+    }
+
+    {
+        auto const& expected = zKProofValue;
+        auto const actualOpt = rebuiltTx.getZKProof();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfZKProof should be present";
+        expectEqualField(expected, *actualOpt, "sfZKProof");
+    }
+
+}
+
+// 3) Verify wrapper throws when constructed from wrong transaction type.
+TEST(TransactionsConfidentialMPTConvertTests, WrapperThrowsOnWrongTxType)
+{
+    // Build a valid transaction of a different type
+    auto const [pk, sk] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType"));
+    auto const account = calcAccountID(pk);
+
+    AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
+    auto wrongTx = wrongBuilder.build(pk, sk);
+
+    EXPECT_THROW(ConfidentialMPTConvert{wrongTx.getSTTx()}, std::runtime_error);
+}
+
+// 4) Verify builder throws when constructed from wrong transaction type.
+TEST(TransactionsConfidentialMPTConvertTests, BuilderThrowsOnWrongTxType)
+{
+    // Build a valid transaction of a different type
+    auto const [pk, sk] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder"));
+    auto const account = calcAccountID(pk);
+
+    AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
+    auto wrongTx = wrongBuilder.build(pk, sk);
+
+    EXPECT_THROW(ConfidentialMPTConvertBuilder{wrongTx.getSTTx()}, std::runtime_error);
+}
+
+// 5) Build with only required fields and verify optional fields return nullopt.
+TEST(TransactionsConfidentialMPTConvertTests, OptionalFieldsReturnNullopt)
+{
+    // Generate a deterministic keypair for signing
+    auto const [publicKey, secretKey] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTConvertNullopt"));
+
+    // Common transaction fields
+    auto const accountValue = calcAccountID(publicKey);
+    std::uint32_t const sequenceValue = 3;
+    auto const feeValue = canonical_AMOUNT();
+
+    // Transaction-specific required field values
+    auto const mPTokenIssuanceIDValue = canonical_UINT192();
+    auto const mPTAmountValue = canonical_UINT64();
+    auto const holderEncryptedAmountValue = canonical_VL();
+    auto const issuerEncryptedAmountValue = canonical_VL();
+    auto const blindingFactorValue = canonical_UINT256();
+
+    ConfidentialMPTConvertBuilder builder{
+        accountValue,
+        mPTokenIssuanceIDValue,
+        mPTAmountValue,
+        holderEncryptedAmountValue,
+        issuerEncryptedAmountValue,
+        blindingFactorValue,
+        sequenceValue,
+        feeValue
+    };
+
+    // Do NOT set optional fields
+
+    auto tx = builder.build(publicKey, secretKey);
+
+    // Verify optional fields are not present
+    EXPECT_FALSE(tx.hasHolderEncryptionKey());
+    EXPECT_FALSE(tx.getHolderEncryptionKey().has_value());
+    EXPECT_FALSE(tx.hasAuditorEncryptedAmount());
+    EXPECT_FALSE(tx.getAuditorEncryptedAmount().has_value());
+    EXPECT_FALSE(tx.hasZKProof());
+    EXPECT_FALSE(tx.getZKProof().has_value());
+}
+
+}
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMergeInboxTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMergeInboxTests.cpp
new file mode 100644
index 0000000000..bfc3ec4f0d
--- /dev/null
+++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMergeInboxTests.cpp
@@ -0,0 +1,146 @@
+// Auto-generated unit tests for transaction ConfidentialMPTMergeInbox
+
+
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+namespace xrpl::transactions {
+
+// 1 & 4) Set fields via builder setters, build, then read them back via
+// wrapper getters. After build(), validate() should succeed.
+TEST(TransactionsConfidentialMPTMergeInboxTests, BuilderSettersRoundTrip)
+{
+    // Generate a deterministic keypair for signing
+    auto const [publicKey, secretKey] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTMergeInbox"));
+
+    // Common transaction fields
+    auto const accountValue = calcAccountID(publicKey);
+    std::uint32_t const sequenceValue = 1;
+    auto const feeValue = canonical_AMOUNT();
+
+    // Transaction-specific field values
+    auto const mPTokenIssuanceIDValue = canonical_UINT192();
+
+    ConfidentialMPTMergeInboxBuilder builder{
+        accountValue,
+        mPTokenIssuanceIDValue,
+        sequenceValue,
+        feeValue
+    };
+
+    // Set optional fields
+
+    auto tx = builder.build(publicKey, secretKey);
+
+    std::string reason;
+    EXPECT_TRUE(tx.validate(reason)) << reason;
+
+    // Verify signing was applied
+    EXPECT_FALSE(tx.getSigningPubKey().empty());
+    EXPECT_TRUE(tx.hasTxnSignature());
+
+    // Verify common fields
+    EXPECT_EQ(tx.getAccount(), accountValue);
+    EXPECT_EQ(tx.getSequence(), sequenceValue);
+    EXPECT_EQ(tx.getFee(), feeValue);
+
+    // Verify required fields
+    {
+        auto const& expected = mPTokenIssuanceIDValue;
+        auto const actual = tx.getMPTokenIssuanceID();
+        expectEqualField(expected, actual, "sfMPTokenIssuanceID");
+    }
+
+    // Verify optional fields
+}
+
+// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
+// and verify all fields match.
+TEST(TransactionsConfidentialMPTMergeInboxTests, BuilderFromStTxRoundTrip)
+{
+    // Generate a deterministic keypair for signing
+    auto const [publicKey, secretKey] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTMergeInboxFromTx"));
+
+    // Common transaction fields
+    auto const accountValue = calcAccountID(publicKey);
+    std::uint32_t const sequenceValue = 2;
+    auto const feeValue = canonical_AMOUNT();
+
+    // Transaction-specific field values
+    auto const mPTokenIssuanceIDValue = canonical_UINT192();
+
+    // Build an initial transaction
+    ConfidentialMPTMergeInboxBuilder initialBuilder{
+        accountValue,
+        mPTokenIssuanceIDValue,
+        sequenceValue,
+        feeValue
+    };
+
+
+    auto initialTx = initialBuilder.build(publicKey, secretKey);
+
+    // Create builder from existing STTx
+    ConfidentialMPTMergeInboxBuilder builderFromTx{initialTx.getSTTx()};
+
+    auto rebuiltTx = builderFromTx.build(publicKey, secretKey);
+
+    std::string reason;
+    EXPECT_TRUE(rebuiltTx.validate(reason)) << reason;
+
+    // Verify common fields
+    EXPECT_EQ(rebuiltTx.getAccount(), accountValue);
+    EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue);
+    EXPECT_EQ(rebuiltTx.getFee(), feeValue);
+
+    // Verify required fields
+    {
+        auto const& expected = mPTokenIssuanceIDValue;
+        auto const actual = rebuiltTx.getMPTokenIssuanceID();
+        expectEqualField(expected, actual, "sfMPTokenIssuanceID");
+    }
+
+    // Verify optional fields
+}
+
+// 3) Verify wrapper throws when constructed from wrong transaction type.
+TEST(TransactionsConfidentialMPTMergeInboxTests, WrapperThrowsOnWrongTxType)
+{
+    // Build a valid transaction of a different type
+    auto const [pk, sk] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType"));
+    auto const account = calcAccountID(pk);
+
+    AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
+    auto wrongTx = wrongBuilder.build(pk, sk);
+
+    EXPECT_THROW(ConfidentialMPTMergeInbox{wrongTx.getSTTx()}, std::runtime_error);
+}
+
+// 4) Verify builder throws when constructed from wrong transaction type.
+TEST(TransactionsConfidentialMPTMergeInboxTests, BuilderThrowsOnWrongTxType)
+{
+    // Build a valid transaction of a different type
+    auto const [pk, sk] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder"));
+    auto const account = calcAccountID(pk);
+
+    AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
+    auto wrongTx = wrongBuilder.build(pk, sk);
+
+    EXPECT_THROW(ConfidentialMPTMergeInboxBuilder{wrongTx.getSTTx()}, std::runtime_error);
+}
+
+
+}
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTSendTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTSendTests.cpp
new file mode 100644
index 0000000000..ca7793dece
--- /dev/null
+++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTSendTests.cpp
@@ -0,0 +1,363 @@
+// Auto-generated unit tests for transaction ConfidentialMPTSend
+
+
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+namespace xrpl::transactions {
+
+// 1 & 4) Set fields via builder setters, build, then read them back via
+// wrapper getters. After build(), validate() should succeed.
+TEST(TransactionsConfidentialMPTSendTests, BuilderSettersRoundTrip)
+{
+    // Generate a deterministic keypair for signing
+    auto const [publicKey, secretKey] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTSend"));
+
+    // Common transaction fields
+    auto const accountValue = calcAccountID(publicKey);
+    std::uint32_t const sequenceValue = 1;
+    auto const feeValue = canonical_AMOUNT();
+
+    // Transaction-specific field values
+    auto const mPTokenIssuanceIDValue = canonical_UINT192();
+    auto const destinationValue = canonical_ACCOUNT();
+    auto const destinationTagValue = canonical_UINT32();
+    auto const senderEncryptedAmountValue = canonical_VL();
+    auto const destinationEncryptedAmountValue = canonical_VL();
+    auto const issuerEncryptedAmountValue = canonical_VL();
+    auto const auditorEncryptedAmountValue = canonical_VL();
+    auto const zKProofValue = canonical_VL();
+    auto const amountCommitmentValue = canonical_VL();
+    auto const balanceCommitmentValue = canonical_VL();
+    auto const credentialIDsValue = canonical_VECTOR256();
+
+    ConfidentialMPTSendBuilder builder{
+        accountValue,
+        mPTokenIssuanceIDValue,
+        destinationValue,
+        senderEncryptedAmountValue,
+        destinationEncryptedAmountValue,
+        issuerEncryptedAmountValue,
+        zKProofValue,
+        amountCommitmentValue,
+        balanceCommitmentValue,
+        sequenceValue,
+        feeValue
+    };
+
+    // Set optional fields
+    builder.setDestinationTag(destinationTagValue);
+    builder.setAuditorEncryptedAmount(auditorEncryptedAmountValue);
+    builder.setCredentialIDs(credentialIDsValue);
+
+    auto tx = builder.build(publicKey, secretKey);
+
+    std::string reason;
+    EXPECT_TRUE(tx.validate(reason)) << reason;
+
+    // Verify signing was applied
+    EXPECT_FALSE(tx.getSigningPubKey().empty());
+    EXPECT_TRUE(tx.hasTxnSignature());
+
+    // Verify common fields
+    EXPECT_EQ(tx.getAccount(), accountValue);
+    EXPECT_EQ(tx.getSequence(), sequenceValue);
+    EXPECT_EQ(tx.getFee(), feeValue);
+
+    // Verify required fields
+    {
+        auto const& expected = mPTokenIssuanceIDValue;
+        auto const actual = tx.getMPTokenIssuanceID();
+        expectEqualField(expected, actual, "sfMPTokenIssuanceID");
+    }
+
+    {
+        auto const& expected = destinationValue;
+        auto const actual = tx.getDestination();
+        expectEqualField(expected, actual, "sfDestination");
+    }
+
+    {
+        auto const& expected = senderEncryptedAmountValue;
+        auto const actual = tx.getSenderEncryptedAmount();
+        expectEqualField(expected, actual, "sfSenderEncryptedAmount");
+    }
+
+    {
+        auto const& expected = destinationEncryptedAmountValue;
+        auto const actual = tx.getDestinationEncryptedAmount();
+        expectEqualField(expected, actual, "sfDestinationEncryptedAmount");
+    }
+
+    {
+        auto const& expected = issuerEncryptedAmountValue;
+        auto const actual = tx.getIssuerEncryptedAmount();
+        expectEqualField(expected, actual, "sfIssuerEncryptedAmount");
+    }
+
+    {
+        auto const& expected = zKProofValue;
+        auto const actual = tx.getZKProof();
+        expectEqualField(expected, actual, "sfZKProof");
+    }
+
+    {
+        auto const& expected = amountCommitmentValue;
+        auto const actual = tx.getAmountCommitment();
+        expectEqualField(expected, actual, "sfAmountCommitment");
+    }
+
+    {
+        auto const& expected = balanceCommitmentValue;
+        auto const actual = tx.getBalanceCommitment();
+        expectEqualField(expected, actual, "sfBalanceCommitment");
+    }
+
+    // Verify optional fields
+    {
+        auto const& expected = destinationTagValue;
+        auto const actualOpt = tx.getDestinationTag();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfDestinationTag should be present";
+        expectEqualField(expected, *actualOpt, "sfDestinationTag");
+        EXPECT_TRUE(tx.hasDestinationTag());
+    }
+
+    {
+        auto const& expected = auditorEncryptedAmountValue;
+        auto const actualOpt = tx.getAuditorEncryptedAmount();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptedAmount should be present";
+        expectEqualField(expected, *actualOpt, "sfAuditorEncryptedAmount");
+        EXPECT_TRUE(tx.hasAuditorEncryptedAmount());
+    }
+
+    {
+        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,
+// and verify all fields match.
+TEST(TransactionsConfidentialMPTSendTests, BuilderFromStTxRoundTrip)
+{
+    // Generate a deterministic keypair for signing
+    auto const [publicKey, secretKey] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTSendFromTx"));
+
+    // Common transaction fields
+    auto const accountValue = calcAccountID(publicKey);
+    std::uint32_t const sequenceValue = 2;
+    auto const feeValue = canonical_AMOUNT();
+
+    // Transaction-specific field values
+    auto const mPTokenIssuanceIDValue = canonical_UINT192();
+    auto const destinationValue = canonical_ACCOUNT();
+    auto const destinationTagValue = canonical_UINT32();
+    auto const senderEncryptedAmountValue = canonical_VL();
+    auto const destinationEncryptedAmountValue = canonical_VL();
+    auto const issuerEncryptedAmountValue = canonical_VL();
+    auto const auditorEncryptedAmountValue = canonical_VL();
+    auto const zKProofValue = canonical_VL();
+    auto const amountCommitmentValue = canonical_VL();
+    auto const balanceCommitmentValue = canonical_VL();
+    auto const credentialIDsValue = canonical_VECTOR256();
+
+    // Build an initial transaction
+    ConfidentialMPTSendBuilder initialBuilder{
+        accountValue,
+        mPTokenIssuanceIDValue,
+        destinationValue,
+        senderEncryptedAmountValue,
+        destinationEncryptedAmountValue,
+        issuerEncryptedAmountValue,
+        zKProofValue,
+        amountCommitmentValue,
+        balanceCommitmentValue,
+        sequenceValue,
+        feeValue
+    };
+
+    initialBuilder.setDestinationTag(destinationTagValue);
+    initialBuilder.setAuditorEncryptedAmount(auditorEncryptedAmountValue);
+    initialBuilder.setCredentialIDs(credentialIDsValue);
+
+    auto initialTx = initialBuilder.build(publicKey, secretKey);
+
+    // Create builder from existing STTx
+    ConfidentialMPTSendBuilder builderFromTx{initialTx.getSTTx()};
+
+    auto rebuiltTx = builderFromTx.build(publicKey, secretKey);
+
+    std::string reason;
+    EXPECT_TRUE(rebuiltTx.validate(reason)) << reason;
+
+    // Verify common fields
+    EXPECT_EQ(rebuiltTx.getAccount(), accountValue);
+    EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue);
+    EXPECT_EQ(rebuiltTx.getFee(), feeValue);
+
+    // Verify required fields
+    {
+        auto const& expected = mPTokenIssuanceIDValue;
+        auto const actual = rebuiltTx.getMPTokenIssuanceID();
+        expectEqualField(expected, actual, "sfMPTokenIssuanceID");
+    }
+
+    {
+        auto const& expected = destinationValue;
+        auto const actual = rebuiltTx.getDestination();
+        expectEqualField(expected, actual, "sfDestination");
+    }
+
+    {
+        auto const& expected = senderEncryptedAmountValue;
+        auto const actual = rebuiltTx.getSenderEncryptedAmount();
+        expectEqualField(expected, actual, "sfSenderEncryptedAmount");
+    }
+
+    {
+        auto const& expected = destinationEncryptedAmountValue;
+        auto const actual = rebuiltTx.getDestinationEncryptedAmount();
+        expectEqualField(expected, actual, "sfDestinationEncryptedAmount");
+    }
+
+    {
+        auto const& expected = issuerEncryptedAmountValue;
+        auto const actual = rebuiltTx.getIssuerEncryptedAmount();
+        expectEqualField(expected, actual, "sfIssuerEncryptedAmount");
+    }
+
+    {
+        auto const& expected = zKProofValue;
+        auto const actual = rebuiltTx.getZKProof();
+        expectEqualField(expected, actual, "sfZKProof");
+    }
+
+    {
+        auto const& expected = amountCommitmentValue;
+        auto const actual = rebuiltTx.getAmountCommitment();
+        expectEqualField(expected, actual, "sfAmountCommitment");
+    }
+
+    {
+        auto const& expected = balanceCommitmentValue;
+        auto const actual = rebuiltTx.getBalanceCommitment();
+        expectEqualField(expected, actual, "sfBalanceCommitment");
+    }
+
+    // Verify optional fields
+    {
+        auto const& expected = destinationTagValue;
+        auto const actualOpt = rebuiltTx.getDestinationTag();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfDestinationTag should be present";
+        expectEqualField(expected, *actualOpt, "sfDestinationTag");
+    }
+
+    {
+        auto const& expected = auditorEncryptedAmountValue;
+        auto const actualOpt = rebuiltTx.getAuditorEncryptedAmount();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptedAmount should be present";
+        expectEqualField(expected, *actualOpt, "sfAuditorEncryptedAmount");
+    }
+
+    {
+        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.
+TEST(TransactionsConfidentialMPTSendTests, WrapperThrowsOnWrongTxType)
+{
+    // Build a valid transaction of a different type
+    auto const [pk, sk] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType"));
+    auto const account = calcAccountID(pk);
+
+    AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
+    auto wrongTx = wrongBuilder.build(pk, sk);
+
+    EXPECT_THROW(ConfidentialMPTSend{wrongTx.getSTTx()}, std::runtime_error);
+}
+
+// 4) Verify builder throws when constructed from wrong transaction type.
+TEST(TransactionsConfidentialMPTSendTests, BuilderThrowsOnWrongTxType)
+{
+    // Build a valid transaction of a different type
+    auto const [pk, sk] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder"));
+    auto const account = calcAccountID(pk);
+
+    AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
+    auto wrongTx = wrongBuilder.build(pk, sk);
+
+    EXPECT_THROW(ConfidentialMPTSendBuilder{wrongTx.getSTTx()}, std::runtime_error);
+}
+
+// 5) Build with only required fields and verify optional fields return nullopt.
+TEST(TransactionsConfidentialMPTSendTests, OptionalFieldsReturnNullopt)
+{
+    // Generate a deterministic keypair for signing
+    auto const [publicKey, secretKey] =
+        generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTSendNullopt"));
+
+    // Common transaction fields
+    auto const accountValue = calcAccountID(publicKey);
+    std::uint32_t const sequenceValue = 3;
+    auto const feeValue = canonical_AMOUNT();
+
+    // Transaction-specific required field values
+    auto const mPTokenIssuanceIDValue = canonical_UINT192();
+    auto const destinationValue = canonical_ACCOUNT();
+    auto const senderEncryptedAmountValue = canonical_VL();
+    auto const destinationEncryptedAmountValue = canonical_VL();
+    auto const issuerEncryptedAmountValue = canonical_VL();
+    auto const zKProofValue = canonical_VL();
+    auto const amountCommitmentValue = canonical_VL();
+    auto const balanceCommitmentValue = canonical_VL();
+
+    ConfidentialMPTSendBuilder builder{
+        accountValue,
+        mPTokenIssuanceIDValue,
+        destinationValue,
+        senderEncryptedAmountValue,
+        destinationEncryptedAmountValue,
+        issuerEncryptedAmountValue,
+        zKProofValue,
+        amountCommitmentValue,
+        balanceCommitmentValue,
+        sequenceValue,
+        feeValue
+    };
+
+    // Do NOT set optional fields
+
+    auto tx = builder.build(publicKey, secretKey);
+
+    // Verify optional fields are not present
+    EXPECT_FALSE(tx.hasDestinationTag());
+    EXPECT_FALSE(tx.getDestinationTag().has_value());
+    EXPECT_FALSE(tx.hasAuditorEncryptedAmount());
+    EXPECT_FALSE(tx.getAuditorEncryptedAmount().has_value());
+    EXPECT_FALSE(tx.hasCredentialIDs());
+    EXPECT_FALSE(tx.getCredentialIDs().has_value());
+}
+
+}
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/LedgerStateFixTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/LedgerStateFixTests.cpp
index 60034255c7..5b78f0cfdd 100644
--- a/src/tests/libxrpl/protocol_autogen/transactions/LedgerStateFixTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/transactions/LedgerStateFixTests.cpp
@@ -31,6 +31,7 @@ TEST(TransactionsLedgerStateFixTests, BuilderSettersRoundTrip)
     // Transaction-specific field values
     auto const ledgerFixTypeValue = canonical_UINT16();
     auto const ownerValue = canonical_ACCOUNT();
+    auto const bookDirectoryValue = canonical_UINT256();
 
     LedgerStateFixBuilder builder{
         accountValue,
@@ -41,6 +42,7 @@ TEST(TransactionsLedgerStateFixTests, BuilderSettersRoundTrip)
 
     // Set optional fields
     builder.setOwner(ownerValue);
+    builder.setBookDirectory(bookDirectoryValue);
 
     auto tx = builder.build(publicKey, secretKey);
 
@@ -72,6 +74,14 @@ TEST(TransactionsLedgerStateFixTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(tx.hasOwner());
     }
 
+    {
+        auto const& expected = bookDirectoryValue;
+        auto const actualOpt = tx.getBookDirectory();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfBookDirectory should be present";
+        expectEqualField(expected, *actualOpt, "sfBookDirectory");
+        EXPECT_TRUE(tx.hasBookDirectory());
+    }
+
 }
 
 // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
@@ -90,6 +100,7 @@ TEST(TransactionsLedgerStateFixTests, BuilderFromStTxRoundTrip)
     // Transaction-specific field values
     auto const ledgerFixTypeValue = canonical_UINT16();
     auto const ownerValue = canonical_ACCOUNT();
+    auto const bookDirectoryValue = canonical_UINT256();
 
     // Build an initial transaction
     LedgerStateFixBuilder initialBuilder{
@@ -100,6 +111,7 @@ TEST(TransactionsLedgerStateFixTests, BuilderFromStTxRoundTrip)
     };
 
     initialBuilder.setOwner(ownerValue);
+    initialBuilder.setBookDirectory(bookDirectoryValue);
 
     auto initialTx = initialBuilder.build(publicKey, secretKey);
 
@@ -131,6 +143,13 @@ TEST(TransactionsLedgerStateFixTests, BuilderFromStTxRoundTrip)
         expectEqualField(expected, *actualOpt, "sfOwner");
     }
 
+    {
+        auto const& expected = bookDirectoryValue;
+        auto const actualOpt = rebuiltTx.getBookDirectory();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfBookDirectory should be present";
+        expectEqualField(expected, *actualOpt, "sfBookDirectory");
+    }
+
 }
 
 // 3) Verify wrapper throws when constructed from wrong transaction type.
@@ -190,6 +209,8 @@ TEST(TransactionsLedgerStateFixTests, OptionalFieldsReturnNullopt)
     // Verify optional fields are not present
     EXPECT_FALSE(tx.hasOwner());
     EXPECT_FALSE(tx.getOwner().has_value());
+    EXPECT_FALSE(tx.hasBookDirectory());
+    EXPECT_FALSE(tx.getBookDirectory().has_value());
 }
 
 }
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp
index 5c97ae8871..e7b34590b2 100644
--- a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp
@@ -35,6 +35,8 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip)
     auto const mPTokenMetadataValue = canonical_VL();
     auto const transferFeeValue = canonical_UINT16();
     auto const mutableFlagsValue = canonical_UINT32();
+    auto const issuerEncryptionKeyValue = canonical_VL();
+    auto const auditorEncryptionKeyValue = canonical_VL();
 
     MPTokenIssuanceSetBuilder builder{
         accountValue,
@@ -49,6 +51,8 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip)
     builder.setMPTokenMetadata(mPTokenMetadataValue);
     builder.setTransferFee(transferFeeValue);
     builder.setMutableFlags(mutableFlagsValue);
+    builder.setIssuerEncryptionKey(issuerEncryptionKeyValue);
+    builder.setAuditorEncryptionKey(auditorEncryptionKeyValue);
 
     auto tx = builder.build(publicKey, secretKey);
 
@@ -112,6 +116,22 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(tx.hasMutableFlags());
     }
 
+    {
+        auto const& expected = issuerEncryptionKeyValue;
+        auto const actualOpt = tx.getIssuerEncryptionKey();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfIssuerEncryptionKey should be present";
+        expectEqualField(expected, *actualOpt, "sfIssuerEncryptionKey");
+        EXPECT_TRUE(tx.hasIssuerEncryptionKey());
+    }
+
+    {
+        auto const& expected = auditorEncryptionKeyValue;
+        auto const actualOpt = tx.getAuditorEncryptionKey();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptionKey should be present";
+        expectEqualField(expected, *actualOpt, "sfAuditorEncryptionKey");
+        EXPECT_TRUE(tx.hasAuditorEncryptionKey());
+    }
+
 }
 
 // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
@@ -134,6 +154,8 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip)
     auto const mPTokenMetadataValue = canonical_VL();
     auto const transferFeeValue = canonical_UINT16();
     auto const mutableFlagsValue = canonical_UINT32();
+    auto const issuerEncryptionKeyValue = canonical_VL();
+    auto const auditorEncryptionKeyValue = canonical_VL();
 
     // Build an initial transaction
     MPTokenIssuanceSetBuilder initialBuilder{
@@ -148,6 +170,8 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip)
     initialBuilder.setMPTokenMetadata(mPTokenMetadataValue);
     initialBuilder.setTransferFee(transferFeeValue);
     initialBuilder.setMutableFlags(mutableFlagsValue);
+    initialBuilder.setIssuerEncryptionKey(issuerEncryptionKeyValue);
+    initialBuilder.setAuditorEncryptionKey(auditorEncryptionKeyValue);
 
     auto initialTx = initialBuilder.build(publicKey, secretKey);
 
@@ -207,6 +231,20 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip)
         expectEqualField(expected, *actualOpt, "sfMutableFlags");
     }
 
+    {
+        auto const& expected = issuerEncryptionKeyValue;
+        auto const actualOpt = rebuiltTx.getIssuerEncryptionKey();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfIssuerEncryptionKey should be present";
+        expectEqualField(expected, *actualOpt, "sfIssuerEncryptionKey");
+    }
+
+    {
+        auto const& expected = auditorEncryptionKeyValue;
+        auto const actualOpt = rebuiltTx.getAuditorEncryptionKey();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptionKey should be present";
+        expectEqualField(expected, *actualOpt, "sfAuditorEncryptionKey");
+    }
+
 }
 
 // 3) Verify wrapper throws when constructed from wrong transaction type.
@@ -274,6 +312,10 @@ TEST(TransactionsMPTokenIssuanceSetTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(tx.getTransferFee().has_value());
     EXPECT_FALSE(tx.hasMutableFlags());
     EXPECT_FALSE(tx.getMutableFlags().has_value());
+    EXPECT_FALSE(tx.hasIssuerEncryptionKey());
+    EXPECT_FALSE(tx.getIssuerEncryptionKey().has_value());
+    EXPECT_FALSE(tx.hasAuditorEncryptionKey());
+    EXPECT_FALSE(tx.getAuditorEncryptionKey().has_value());
 }
 
 }
diff --git a/src/tests/libxrpl/tx/AccountSet.cpp b/src/tests/libxrpl/tx/AccountSet.cpp
index d00df152ae..87d00c58bf 100644
--- a/src/tests/libxrpl/tx/AccountSet.cpp
+++ b/src/tests/libxrpl/tx/AccountSet.cpp
@@ -432,13 +432,13 @@ TEST(AccountSet, TransferRate)
 
     // Test data: {rate to set, expected TER, expected stored rate}
     std::vector const testData = {
-        {1.0, tesSUCCESS, 1.0},
-        {1.1, tesSUCCESS, 1.1},
-        {2.0, tesSUCCESS, 2.0},
-        {2.1, temBAD_TRANSFER_RATE, 2.0},  // > 2.0 is invalid
-        {0.0, tesSUCCESS, 1.0},            // 0 clears the rate (default = 1.0)
-        {2.0, tesSUCCESS, 2.0},
-        {0.9, temBAD_TRANSFER_RATE, 2.0},  // < 1.0 is invalid
+        {.set = 1.0, .code = tesSUCCESS, .get = 1.0},
+        {.set = 1.1, .code = tesSUCCESS, .get = 1.1},
+        {.set = 2.0, .code = tesSUCCESS, .get = 2.0},
+        {.set = 2.1, .code = temBAD_TRANSFER_RATE, .get = 2.0},  // > 2.0 is invalid
+        {.set = 0.0, .code = tesSUCCESS, .get = 1.0},            // 0 clears; default rate is 1.0
+        {.set = 2.0, .code = tesSUCCESS, .get = 2.0},
+        {.set = 0.9, .code = temBAD_TRANSFER_RATE, .get = 2.0},  // < 1.0 is invalid
     };
 
     TxTest env;
@@ -619,7 +619,7 @@ TEST(AccountSet, Ticket)
     // Verify alice has 1 owner object (the ticket)
     EXPECT_EQ(env.getAccountRoot(alice.id()).getOwnerCount(), 1u);
     // Verify ticket exists
-    EXPECT_TRUE(env.getClosedLedger().exists(keylet::kTicket(alice.id(), ticketSeq)));
+    EXPECT_TRUE(env.getClosedLedger().exists(keylet::ticket(alice.id(), ticketSeq)));
 
     // Try using a ticket that alice doesn't have
     EXPECT_EQ(
@@ -629,7 +629,7 @@ TEST(AccountSet, Ticket)
     env.close();
 
     // Verify ticket still exists
-    EXPECT_TRUE(env.getClosedLedger().exists(keylet::kTicket(alice.id(), ticketSeq)));
+    EXPECT_TRUE(env.getClosedLedger().exists(keylet::ticket(alice.id(), ticketSeq)));
 
     // Get alice's sequence before using the ticket
     std::uint32_t const aliceSeq = env.getAccountRoot(alice.id()).getSequence();
@@ -642,7 +642,7 @@ TEST(AccountSet, Ticket)
 
     // Verify ticket is consumed (no owner objects)
     EXPECT_EQ(env.getAccountRoot(alice.id()).getOwnerCount(), 0u);
-    EXPECT_FALSE(env.getClosedLedger().exists(keylet::kTicket(alice.id(), ticketSeq)));
+    EXPECT_FALSE(env.getClosedLedger().exists(keylet::ticket(alice.id(), ticketSeq)));
 
     // Verify alice's sequence did NOT advance (ticket use doesn't increment seq)
     EXPECT_EQ(env.getAccountRoot(alice.id()).getSequence(), aliceSeq);
@@ -685,6 +685,7 @@ TEST(AccountSet, Gateway)
     IOU const usd("USD", gw);
 
     // Test gateway with a variety of allowed transfer rates
+    // NOLINTNEXTLINE(bugprone-float-loop-counter)
     for (double transferRate = 1.0; transferRate <= 2.0; transferRate += 0.03125)
     {
         TxTest env;
diff --git a/src/tests/libxrpl/tx/main.cpp b/src/tests/libxrpl/tx/main.cpp
deleted file mode 100644
index 5142bbe08a..0000000000
--- a/src/tests/libxrpl/tx/main.cpp
+++ /dev/null
@@ -1,8 +0,0 @@
-#include 
-
-int
-main(int argc, char** argv)
-{
-    ::testing::InitGoogleTest(&argc, argv);
-    return RUN_ALL_TESTS();
-}
diff --git a/src/xrpld/app/consensus/RCLConsensus.cpp b/src/xrpld/app/consensus/RCLConsensus.cpp
index a474c9c339..a7c2b26c04 100644
--- a/src/xrpld/app/consensus/RCLConsensus.cpp
+++ b/src/xrpld/app/consensus/RCLConsensus.cpp
@@ -580,7 +580,9 @@ RCLConsensus::Adaptor::doAccept(
         JLOG(j_.info()) << "CNF Val " << newLCLHash;
     }
     else
+    {
         JLOG(j_.info()) << "CNF buildLCL " << newLCLHash;
+    }
 
     // See if we can accept a ledger as fully-validated
     ledgerMaster_.consensusBuilt(built.ledger, result.txns.id(), std::move(consensusJson));
@@ -796,7 +798,9 @@ RCLConsensus::Adaptor::buildLCL(
         JLOG(j_.debug()) << "Consensus built ledger we were acquiring";
     }
     else
+    {
         JLOG(j_.debug()) << "Consensus built new ledger";
+    }
     return RCLCxLedger{std::move(built)};
 }
 
diff --git a/src/xrpld/app/consensus/RCLCxLedger.h b/src/xrpld/app/consensus/RCLCxLedger.h
index 3d6ed4912b..9f7984aaa6 100644
--- a/src/xrpld/app/consensus/RCLCxLedger.h
+++ b/src/xrpld/app/consensus/RCLCxLedger.h
@@ -32,7 +32,7 @@ public:
 
         @param l The ledger to wrap.
     */
-    RCLCxLedger(std::shared_ptr const& l) : ledger{l}
+    RCLCxLedger(std::shared_ptr l) : ledger{std::move(l)}
     {
     }
 
diff --git a/src/xrpld/app/consensus/RCLValidations.cpp b/src/xrpld/app/consensus/RCLValidations.cpp
index 02ff86b23d..9d40e60b00 100644
--- a/src/xrpld/app/consensus/RCLValidations.cpp
+++ b/src/xrpld/app/consensus/RCLValidations.cpp
@@ -46,8 +46,10 @@ RCLValidatedLedger::RCLValidatedLedger(
         ancestors_ = hashIndex->getFieldV256(sfHashes).value();
     }
     else
+    {
         JLOG(j_.warn()) << "Ledger " << ledgerSeq_ << ":" << ledgerID_
                         << " missing recent ancestor hashes";
+    }
 }
 
 auto
diff --git a/src/xrpld/app/consensus/RCLValidations.h b/src/xrpld/app/consensus/RCLValidations.h
index 16f6e15d4d..37a6f6c743 100644
--- a/src/xrpld/app/consensus/RCLValidations.h
+++ b/src/xrpld/app/consensus/RCLValidations.h
@@ -32,7 +32,7 @@ public:
 
         @param v The validation to wrap.
     */
-    RCLValidation(std::shared_ptr const& v) : val_{v}
+    RCLValidation(std::shared_ptr v) : val_{std::move(v)}
     {
     }
 
diff --git a/src/xrpld/app/ledger/AcceptedLedger.cpp b/src/xrpld/app/ledger/AcceptedLedger.cpp
index ea594308bd..6da869198b 100644
--- a/src/xrpld/app/ledger/AcceptedLedger.cpp
+++ b/src/xrpld/app/ledger/AcceptedLedger.cpp
@@ -5,23 +5,18 @@
 
 #include 
 #include 
+#include 
 
 namespace xrpl {
 
-AcceptedLedger::AcceptedLedger(std::shared_ptr const& ledger) : ledger_(ledger)
+AcceptedLedger::AcceptedLedger(std::shared_ptr ledger) : ledger_(std::move(ledger))
 {
     transactions_.reserve(256);
-
-    auto insertAll = [&](auto const& txns) {
-        for (auto const& item : txns)
-        {
-            transactions_.emplace_back(
-                std::make_unique(ledger, item.first, item.second));
-        }
-    };
-
-    transactions_.reserve(256);
-    insertAll(ledger->txs);
+    for (auto const& item : ledger_->txs)
+    {
+        transactions_.emplace_back(
+            std::make_unique(ledger_, item.first, item.second));
+    }
 
     std::ranges::sort(transactions_, [](auto const& a, auto const& b) {
         return a->getTxnSeq() < b->getTxnSeq();
diff --git a/src/xrpld/app/ledger/AcceptedLedger.h b/src/xrpld/app/ledger/AcceptedLedger.h
index 621bea9e0d..b05af1f18a 100644
--- a/src/xrpld/app/ledger/AcceptedLedger.h
+++ b/src/xrpld/app/ledger/AcceptedLedger.h
@@ -25,7 +25,7 @@ namespace xrpl {
 class AcceptedLedger : public CountedObject
 {
 public:
-    AcceptedLedger(std::shared_ptr const& ledger);
+    AcceptedLedger(std::shared_ptr ledger);
 
     [[nodiscard]] std::shared_ptr const&
     getLedger() const
diff --git a/src/xrpld/app/ledger/InboundLedger.h b/src/xrpld/app/ledger/InboundLedger.h
index d155c5902c..b82e2f69cd 100644
--- a/src/xrpld/app/ledger/InboundLedger.h
+++ b/src/xrpld/app/ledger/InboundLedger.h
@@ -128,13 +128,13 @@ private:
     pmDowncast() override;
 
     int
-    processData(std::shared_ptr peer, protocol::TMLedgerData& data);
+    processData(std::shared_ptr peer, protocol::TMLedgerData const& data);
 
     bool
     takeHeader(std::string const& data);
 
     void
-    receiveNode(protocol::TMLedgerData& packet, SHAMapAddNode&);
+    receiveNode(protocol::TMLedgerData const& packet, SHAMapAddNode&);
 
     bool
     takeTxRootNode(Slice const& data, SHAMapAddNode&);
@@ -143,10 +143,10 @@ private:
     takeAsRootNode(Slice const& data, SHAMapAddNode&);
 
     std::vector
-    neededTxHashes(int max, SHAMapSyncFilter* filter) const;
+    neededTxHashes(int max, SHAMapSyncFilter const* filter) const;
 
     std::vector
-    neededStateHashes(int max, SHAMapSyncFilter* filter) const;
+    neededStateHashes(int max, SHAMapSyncFilter const* filter) const;
 
     clock_type& clock_;
     clock_type::time_point lastAction_;
diff --git a/src/xrpld/app/ledger/LedgerHistory.cpp b/src/xrpld/app/ledger/LedgerHistory.cpp
index f251f25620..b7e1772942 100644
--- a/src/xrpld/app/ledger/LedgerHistory.cpp
+++ b/src/xrpld/app/ledger/LedgerHistory.cpp
@@ -36,14 +36,14 @@ namespace xrpl {
 LedgerHistory::LedgerHistory(beast::insight::Collector::ptr const& collector, Application& app)
     : app_(app)
     , collector_(collector)
-    , mismatch_counter_(collector->makeCounter("ledger.history", "mismatch"))
-    , ledgers_by_hash_(
+    , mismatchCounter_(collector->makeCounter("ledger.history", "mismatch"))
+    , ledgersByHash_(
           "LedgerCache",
           app_.config().getValueFor(SizedItem::LedgerSize),
           std::chrono::seconds{app_.config().getValueFor(SizedItem::LedgerAge)},
           stopwatch(),
           app_.getJournal("TaggedCache"))
-    , consensus_validated_(
+    , consensusValidated_(
           "ConsensusValidated",
           64,
           std::chrono::minutes{5},
@@ -62,10 +62,9 @@ LedgerHistory::insert(std::shared_ptr const& ledger, bool validate
     XRPL_ASSERT(
         ledger->stateMap().getHash().isNonZero(), "xrpl::LedgerHistory::insert : nonzero hash");
 
-    std::unique_lock const sl(ledgers_by_hash_.peekMutex());
+    std::unique_lock const sl(ledgersByHash_.peekMutex());
 
-    bool const alreadyHad =
-        ledgers_by_hash_.canonicalizeReplaceCache(ledger->header().hash, ledger);
+    bool const alreadyHad = ledgersByHash_.canonicalizeReplaceCache(ledger->header().hash, ledger);
     if (validated)
         ledgersByIndex_[ledger->header().seq] = ledger->header().hash;
 
@@ -75,7 +74,7 @@ LedgerHistory::insert(std::shared_ptr const& ledger, bool validate
 LedgerHash
 LedgerHistory::getLedgerHash(LedgerIndex index)
 {
-    std::unique_lock const sl(ledgers_by_hash_.peekMutex());
+    std::unique_lock const sl(ledgersByHash_.peekMutex());
     if (auto it = ledgersByIndex_.find(index); it != ledgersByIndex_.end())
         return it->second;
     return {};
@@ -85,7 +84,7 @@ std::shared_ptr
 LedgerHistory::getLedgerBySeq(LedgerIndex index)
 {
     {
-        std::unique_lock sl(ledgers_by_hash_.peekMutex());
+        std::unique_lock sl(ledgersByHash_.peekMutex());
         auto it = ledgersByIndex_.find(index);
 
         if (it != ledgersByIndex_.end())
@@ -97,7 +96,7 @@ LedgerHistory::getLedgerBySeq(LedgerIndex index)
     }
 
     Rules const rules{app_.config().features};
-    Fees const fees = app_.config().FEES.toFees();
+    Fees const fees = app_.config().fees.toFees();
     std::shared_ptr ret = loadByIndex(index, rules, fees, app_);
 
     if (!ret)
@@ -108,11 +107,11 @@ LedgerHistory::getLedgerBySeq(LedgerIndex index)
 
     {
         // Add this ledger to the local tracking by index
-        std::unique_lock const sl(ledgers_by_hash_.peekMutex());
+        std::unique_lock const sl(ledgersByHash_.peekMutex());
 
         XRPL_ASSERT(
             ret->isImmutable(), "xrpl::LedgerHistory::getLedgerBySeq : immutable result ledger");
-        ledgers_by_hash_.canonicalizeReplaceClient(ret->header().hash, ret);
+        ledgersByHash_.canonicalizeReplaceClient(ret->header().hash, ret);
         ledgersByIndex_[ret->header().seq] = ret->header().hash;
         return (ret->header().seq == index) ? ret : nullptr;
     }
@@ -121,7 +120,7 @@ LedgerHistory::getLedgerBySeq(LedgerIndex index)
 std::shared_ptr
 LedgerHistory::getLedgerByHash(LedgerHash const& hash)
 {
-    auto ret = ledgers_by_hash_.fetch(hash);
+    auto ret = ledgersByHash_.fetch(hash);
 
     if (ret)
     {
@@ -137,7 +136,7 @@ LedgerHistory::getLedgerByHash(LedgerHash const& hash)
     }
 
     Rules const rules{app_.config().features};
-    Fees const fees = app_.config().FEES.toFees();
+    Fees const fees = app_.config().fees.toFees();
     ret = loadByHash(hash, rules, fees, app_);
 
     if (!ret)
@@ -148,7 +147,7 @@ LedgerHistory::getLedgerByHash(LedgerHash const& hash)
     XRPL_ASSERT(
         ret->header().hash == hash,
         "xrpl::LedgerHistory::getLedgerByHash : loaded ledger hash match");
-    ledgers_by_hash_.canonicalizeReplaceClient(ret->header().hash, ret);
+    ledgersByHash_.canonicalizeReplaceClient(ret->header().hash, ret);
     XRPL_ASSERT(
         ret->header().hash == hash, "xrpl::LedgerHistory::getLedgerByHash : result hash match");
 
@@ -319,7 +318,7 @@ LedgerHistory::handleMismatch(
     json::Value const& consensus)
 {
     XRPL_ASSERT(built != valid, "xrpl::LedgerHistory::handleMismatch : unequal hashes");
-    ++mismatch_counter_;
+    ++mismatchCounter_;
 
     auto builtLedger = getLedgerByHash(built);
     auto validLedger = getLedgerByHash(valid);
@@ -369,8 +368,10 @@ LedgerHistory::handleMismatch(
                              << " validated: " << to_string(*validatedConsensusHash);
         }
         else
+        {
             JLOG(j_.error()) << "MISMATCH with same consensus transaction set: "
                              << to_string(*builtConsensusHash);
+        }
     }
 
     // Find differences between built and valid ledgers
@@ -382,8 +383,10 @@ LedgerHistory::handleMismatch(
         JLOG(j_.error()) << "MISMATCH with same " << builtTx.size() << " transactions";
     }
     else
+    {
         JLOG(j_.error()) << "MISMATCH with " << builtTx.size() << " built and " << validTx.size()
                          << " valid transactions.";
+    }
 
     JLOG(j_.error()) << "built\n" << getJson({*builtLedger, {}});
     JLOG(j_.error()) << "valid\n" << getJson({*validLedger, {}});
@@ -430,10 +433,10 @@ LedgerHistory::builtLedger(
     LedgerHash const hash = ledger->header().hash;
     XRPL_ASSERT(!hash.isZero(), "xrpl::LedgerHistory::builtLedger : nonzero hash");
 
-    std::unique_lock const sl(consensus_validated_.peekMutex());
+    std::unique_lock const sl(consensusValidated_.peekMutex());
 
     auto entry = std::make_shared();
-    consensus_validated_.canonicalizeReplaceClient(index, entry);
+    consensusValidated_.canonicalizeReplaceClient(index, entry);
 
     if (entry->validated && !entry->built)
     {
@@ -469,10 +472,10 @@ LedgerHistory::validatedLedger(
     LedgerHash const hash = ledger->header().hash;
     XRPL_ASSERT(!hash.isZero(), "xrpl::LedgerHistory::validatedLedger : nonzero hash");
 
-    std::unique_lock const sl(consensus_validated_.peekMutex());
+    std::unique_lock const sl(consensusValidated_.peekMutex());
 
     auto entry = std::make_shared();
-    consensus_validated_.canonicalizeReplaceClient(index, entry);
+    consensusValidated_.canonicalizeReplaceClient(index, entry);
 
     if (entry->built && !entry->validated)
     {
@@ -504,7 +507,7 @@ LedgerHistory::validatedLedger(
 bool
 LedgerHistory::fixIndex(LedgerIndex ledgerIndex, LedgerHash const& ledgerHash)
 {
-    std::unique_lock const sl(ledgers_by_hash_.peekMutex());
+    std::unique_lock const sl(ledgersByHash_.peekMutex());
     auto it = ledgersByIndex_.find(ledgerIndex);
 
     if ((it != ledgersByIndex_.end()) && (it->second != ledgerHash))
@@ -518,11 +521,11 @@ LedgerHistory::fixIndex(LedgerIndex ledgerIndex, LedgerHash const& ledgerHash)
 void
 LedgerHistory::clearLedgerCachePrior(LedgerIndex seq)
 {
-    for (LedgerHash const it : ledgers_by_hash_.getKeys())
+    for (LedgerHash const it : ledgersByHash_.getKeys())
     {
         auto const ledger = getLedgerByHash(it);
         if (!ledger || ledger->header().seq < seq)
-            ledgers_by_hash_.del(it, false);
+            ledgersByHash_.del(it, false);
     }
 }
 
diff --git a/src/xrpld/app/ledger/LedgerHistory.h b/src/xrpld/app/ledger/LedgerHistory.h
index 01c88b1517..057de7b1bc 100644
--- a/src/xrpld/app/ledger/LedgerHistory.h
+++ b/src/xrpld/app/ledger/LedgerHistory.h
@@ -30,7 +30,7 @@ public:
     float
     getCacheHitRate()
     {
-        return ledgers_by_hash_.getHitRate();
+        return ledgersByHash_.getHitRate();
     }
 
     /** Get a ledger given its sequence number */
@@ -53,8 +53,8 @@ public:
     void
     sweep()
     {
-        ledgers_by_hash_.sweep();
-        consensus_validated_.sweep();
+        ledgersByHash_.sweep();
+        consensusValidated_.sweep();
     }
 
     /** Report that we have locally built a particular ledger */
@@ -99,11 +99,11 @@ private:
 
     Application& app_;
     beast::insight::Collector::ptr collector_;
-    beast::insight::Counter mismatch_counter_;
+    beast::insight::Counter mismatchCounter_;
 
     using LedgersByHash = TaggedCache;
 
-    LedgersByHash ledgers_by_hash_;
+    LedgersByHash ledgersByHash_;
 
     // Maps ledger indexes to the corresponding hashes
     // For debug and logging purposes
@@ -121,7 +121,7 @@ private:
         std::optional consensus;
     };
     using ConsensusValidated = TaggedCache;
-    ConsensusValidated consensus_validated_;
+    ConsensusValidated consensusValidated_;
 
     // Maps ledger indexes to the corresponding hash.
     std::map ledgersByIndex_;  // validated ledgers
diff --git a/src/xrpld/app/ledger/LedgerMaster.h b/src/xrpld/app/ledger/LedgerMaster.h
index 0b3310ea79..885ab6db25 100644
--- a/src/xrpld/app/ledger/LedgerMaster.h
+++ b/src/xrpld/app/ledger/LedgerMaster.h
@@ -347,20 +347,20 @@ private:
     bool const standalone_;
 
     // How many ledgers before the current ledger do we allow peers to request?
-    std::uint32_t const fetch_depth_;
+    std::uint32_t const fetchDepth_;
 
     // How much history do we want to keep
-    std::uint32_t const ledger_history_;
+    std::uint32_t const ledgerHistorySize_;
 
-    std::uint32_t const ledger_fetch_size_;
+    std::uint32_t const ledgerFetchSize_;
 
-    TaggedCache fetch_packs_;
+    TaggedCache fetchPacks_;
 
-    std::uint32_t fetch_seq_{0};
+    std::uint32_t fetchSeq_{0};
 
     // Try to keep a validator from switching from test to live network
     // without first wiping the database.
-    LedgerIndex const max_ledger_difference_{1000000};
+    LedgerIndex const maxLedgerDifference_{1000000};
 
     // Time that the previous upgrade warning was issued.
     TimeKeeper::time_point upgradeWarningPrevTime_;
diff --git a/src/xrpld/app/ledger/OpenLedger.h b/src/xrpld/app/ledger/OpenLedger.h
index 884b6b02db..554002d6af 100644
--- a/src/xrpld/app/ledger/OpenLedger.h
+++ b/src/xrpld/app/ledger/OpenLedger.h
@@ -12,6 +12,7 @@
 #include 
 
 #include 
+#include 
 
 namespace xrpl {
 
@@ -33,8 +34,8 @@ class OpenLedger
 private:
     beast::Journal const j_;
     CachedSLEs& cache_;
-    std::mutex mutable modify_mutex_;
-    std::mutex mutable current_mutex_;
+    std::mutex mutable modifyMutex_;
+    std::mutex mutable currentMutex_;
     std::shared_ptr current_;
 
 public:
@@ -149,7 +150,7 @@ public:
         bool retriesFirst,
         OrderedTxs& retries,
         ApplyFlags flags,
-        std::string const& suffix = "",
+        std::string_view suffix = "",
         modify_type const& f = {});
 
 private:
diff --git a/src/xrpld/app/ledger/OrderBookDB.h b/src/xrpld/app/ledger/OrderBookDB.h
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/xrpld/app/ledger/OrderBookDBImpl.cpp b/src/xrpld/app/ledger/OrderBookDBImpl.cpp
index 658ec4ea7a..1e474ef949 100644
--- a/src/xrpld/app/ledger/OrderBookDBImpl.cpp
+++ b/src/xrpld/app/ledger/OrderBookDBImpl.cpp
@@ -9,19 +9,17 @@
 #include 
 #include 
 #include 
-#include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -307,55 +305,10 @@ OrderBookDBImpl::isBookToXRP(Asset const& asset, std::optional const& do
     return xrpBooks_.contains(asset);
 }
 
-BookListeners::pointer
-OrderBookDBImpl::makeBookListeners(Book const& book)
+hash_set
+affectedBooks(AcceptedLedgerTx const& alTx, beast::Journal const& j)
 {
-    std::scoped_lock const sl(lock_);
-    auto ret = getBookListeners(book);
-
-    if (!ret)
-    {
-        ret = std::make_shared();
-
-        listeners_[book] = ret;
-        XRPL_ASSERT(
-            getBookListeners(book) == ret,
-            "xrpl::OrderBookDB::makeBookListeners : result roundtrip "
-            "lookup");
-    }
-
-    return ret;
-}
-
-BookListeners::pointer
-OrderBookDBImpl::getBookListeners(Book const& book)
-{
-    BookListeners::pointer ret;
-    std::scoped_lock const sl(lock_);
-
-    auto it0 = listeners_.find(book);
-    if (it0 != listeners_.end())
-        ret = it0->second;
-
-    return ret;
-}
-
-// Based on the meta, send the meta to the streams that are listening.
-// We need to determine which streams a given meta effects.
-void
-OrderBookDBImpl::processTxn(
-    std::shared_ptr const& ledger,
-    AcceptedLedgerTx const& alTx,
-    MultiApiJson const& jvObj)
-{
-    std::scoped_lock const sl(lock_);
-
-    // For this particular transaction, maintain the set of unique
-    // subscriptions that have already published it.  This prevents sending
-    // the transaction multiple times if it touches multiple ltOFFER
-    // entries for the same book, or if it touches multiple books and a
-    // single client has subscribed to those books.
-    hash_set havePublished;
+    hash_set result;
 
     for (auto const& node : alTx.getMeta().getNodes())
     {
@@ -363,40 +316,41 @@ OrderBookDBImpl::processTxn(
         {
             if (node.getFieldU16(sfLedgerEntryType) == ltOFFER)
             {
-                auto process = [&, this](SField const& field) {
+                auto extract = [&](SField const& field) {
                     if (auto data = dynamic_cast(node.peekAtPField(field)); data &&
                         data->isFieldPresent(sfTakerPays) && data->isFieldPresent(sfTakerGets))
                     {
-                        auto listeners = getBookListeners(
-                            {data->getFieldAmount(sfTakerGets).asset(),
-                             data->getFieldAmount(sfTakerPays).asset(),
-                             (*data)[~sfDomainID]});
-                        if (listeners)
-                            listeners->publish(jvObj, havePublished);
+                        result.emplace(
+                            data->getFieldAmount(sfTakerGets).asset(),
+                            data->getFieldAmount(sfTakerPays).asset(),
+                            (*data)[~sfDomainID]);
                     }
                 };
 
-                // We need a field that contains the TakerGets and TakerPays
-                // parameters.
                 if (node.getFName() == sfModifiedNode)
                 {
-                    process(sfPreviousFields);
+                    extract(sfPreviousFields);
                 }
                 else if (node.getFName() == sfCreatedNode)
                 {
-                    process(sfNewFields);
+                    extract(sfNewFields);
                 }
                 else if (node.getFName() == sfDeletedNode)
                 {
-                    process(sfFinalFields);
+                    extract(sfFinalFields);
                 }
             }
         }
         catch (std::exception const& ex)
         {
-            JLOG(j_.info()) << "processTxn: field not found (" << ex.what() << ")";
+            // The bad node is skipped; other affected books in the same
+            // transaction are still returned. Logged at warn so a malformed
+            // offer node is visible to operators.
+            JLOG(j.warn()) << "affectedBooks: skipping malformed node (" << ex.what() << ")";
         }
     }
+
+    return result;
 }
 
 }  // namespace xrpl
diff --git a/src/xrpld/app/ledger/OrderBookDBImpl.h b/src/xrpld/app/ledger/OrderBookDBImpl.h
index a50f512441..a68f63c043 100644
--- a/src/xrpld/app/ledger/OrderBookDBImpl.h
+++ b/src/xrpld/app/ledger/OrderBookDBImpl.h
@@ -1,10 +1,7 @@
 #pragma once
 
 #include 
-#include 
-#include 
 #include 
-#include 
 #include 
 
 #include 
@@ -54,18 +51,6 @@ public:
     void
     update(std::shared_ptr const& ledger);
 
-    // see if this txn effects any orderbook
-    void
-    processTxn(
-        std::shared_ptr const& ledger,
-        AcceptedLedgerTx const& alTx,
-        MultiApiJson const& jvObj) override;
-
-    BookListeners::pointer
-    getBookListeners(Book const&) override;
-    BookListeners::pointer
-    makeBookListeners(Book const&) override;
-
 private:
     std::reference_wrapper registry_;
     int const pathSearchMax_;
@@ -84,10 +69,6 @@ private:
 
     std::recursive_mutex lock_;
 
-    using BookToListenersMap = hash_map;
-
-    BookToListenersMap listeners_;
-
     std::atomic seq_;
 
     beast::Journal const j_;
diff --git a/src/xrpld/app/ledger/detail/BuildLedger.cpp b/src/xrpld/app/ledger/detail/BuildLedger.cpp
index a77c1c9c50..2eb64ecaf9 100644
--- a/src/xrpld/app/ledger/detail/BuildLedger.cpp
+++ b/src/xrpld/app/ledger/detail/BuildLedger.cpp
@@ -73,7 +73,7 @@ buildLedgerImpl(
 
     // Accept ledger
     XRPL_ASSERT(
-        built->header().seq < kXrpLedgerEarliestFees || built->read(keylet::fees()),
+        built->header().seq < kXrpLedgerEarliestFees || built->read(keylet::feeSettings()),
         "xrpl::buildLedgerImpl : valid ledger fees");
     built->setAccepted(closeTime, closeResolution, closeTimeCorrect);
 
@@ -204,9 +204,11 @@ buildLedger(
                                 << accum.txCount();
             }
             else
+            {
                 JLOG(j.debug()) << "Applied " << applied << " transactions. "
                                 << "Total transactions in ledger (including Inner Batch): "
                                 << accum.txCount();
+            }
         });
 }
 
diff --git a/src/xrpld/app/ledger/detail/InboundLedger.cpp b/src/xrpld/app/ledger/detail/InboundLedger.cpp
index 9ba7bdf22e..4affffd1c1 100644
--- a/src/xrpld/app/ledger/detail/InboundLedger.cpp
+++ b/src/xrpld/app/ledger/detail/InboundLedger.cpp
@@ -109,7 +109,7 @@ InboundLedger::init(ScopedLockType& collectionLock)
     JLOG(journal_.debug()) << "Acquiring ledger we already have in "
                            << " local store. " << hash_;
     XRPL_ASSERT(
-        ledger_->header().seq < kXrpLedgerEarliestFees || ledger_->read(keylet::fees()),
+        ledger_->header().seq < kXrpLedgerEarliestFees || ledger_->read(keylet::feeSettings()),
         "xrpl::InboundLedger::init : valid ledger fees");
     ledger_->setImmutable();
 
@@ -127,9 +127,8 @@ std::size_t
 InboundLedger::getPeerCount() const
 {
     auto const& peerIds = peerSet_->getPeerIds();
-    return std::count_if(peerIds.begin(), peerIds.end(), [this](auto id) {
-        return (app_.getOverlay().findPeerByShortID(id) != nullptr);
-    });
+    return std::ranges::count_if(
+        peerIds, [this](auto id) { return (app_.getOverlay().findPeerByShortID(id) != nullptr); });
 }
 
 void
@@ -188,7 +187,7 @@ InboundLedger::~InboundLedger()
 }
 
 static std::vector
-neededHashes(uint256 const& root, SHAMap& map, int max, SHAMapSyncFilter* filter)
+neededHashes(uint256 const& root, SHAMap& map, int max, SHAMapSyncFilter const* filter)
 {
     std::vector ret;
 
@@ -211,13 +210,13 @@ neededHashes(uint256 const& root, SHAMap& map, int max, SHAMapSyncFilter* filter
 }
 
 std::vector
-InboundLedger::neededTxHashes(int max, SHAMapSyncFilter* filter) const
+InboundLedger::neededTxHashes(int max, SHAMapSyncFilter const* filter) const
 {
     return neededHashes(ledger_->header().txHash, ledger_->txMap(), max, filter);
 }
 
 std::vector
-InboundLedger::neededStateHashes(int max, SHAMapSyncFilter* filter) const
+InboundLedger::neededStateHashes(int max, SHAMapSyncFilter const* filter) const
 {
     return neededHashes(ledger_->header().accountHash, ledger_->stateMap(), max, filter);
 }
@@ -331,7 +330,7 @@ InboundLedger::tryDB(NodeStore::Database& srcDB)
         JLOG(journal_.debug()) << "Had everything locally";
         complete_ = true;
         XRPL_ASSERT(
-            ledger_->header().seq < kXrpLedgerEarliestFees || ledger_->read(keylet::fees()),
+            ledger_->header().seq < kXrpLedgerEarliestFees || ledger_->read(keylet::feeSettings()),
             "xrpl::InboundLedger::tryDB : valid ledger fees");
         ledger_->setImmutable();
     }
@@ -427,7 +426,7 @@ InboundLedger::done()
     if (complete_ && !failed_ && ledger_)
     {
         XRPL_ASSERT(
-            ledger_->header().seq < kXrpLedgerEarliestFees || ledger_->read(keylet::fees()),
+            ledger_->header().seq < kXrpLedgerEarliestFees || ledger_->read(keylet::feeSettings()),
             "xrpl::InboundLedger::done : valid ledger fees");
         ledger_->setImmutable();
         switch (reason_)
@@ -820,7 +819,7 @@ InboundLedger::takeHeader(std::string const& data)
     Call with a lock
 */
 void
-InboundLedger::receiveNode(protocol::TMLedgerData& packet, SHAMapAddNode& san)
+InboundLedger::receiveNode(protocol::TMLedgerData const& packet, SHAMapAddNode& san)
 {
     if (!haveHeader_)
     {
@@ -1026,7 +1025,7 @@ InboundLedger::gotData(
 //        TODO Change peer to Consumer
 //
 int
-InboundLedger::processData(std::shared_ptr peer, protocol::TMLedgerData& packet)
+InboundLedger::processData(std::shared_ptr peer, protocol::TMLedgerData const& packet)
 {
     if (packet.type() == protocol::liBASE)
     {
diff --git a/src/xrpld/app/ledger/detail/LedgerCleaner.cpp b/src/xrpld/app/ledger/detail/LedgerCleaner.cpp
index 7613aab85a..b96f01e577 100644
--- a/src/xrpld/app/ledger/detail/LedgerCleaner.cpp
+++ b/src/xrpld/app/ledger/detail/LedgerCleaner.cpp
@@ -286,7 +286,7 @@ private:
         }
 
         Rules const rules{app_.config().features};
-        Fees const fees = app_.config().FEES.toFees();
+        Fees const fees = app_.config().fees.toFees();
         auto const dbLedger = loadByIndex(ledgerIndex, rules, fees, app_);
         if (!dbLedger || (dbLedger->header().hash != ledgerHash) ||
             (dbLedger->header().parentHash != nodeLedger->header().parentHash))
@@ -366,7 +366,9 @@ private:
             }
         }
         else
+        {
             JLOG(j_.warn()) << "Validated ledger is prior to target ledger";
+        }
 
         return ledgerHash;
     }
diff --git a/src/xrpld/app/ledger/detail/LedgerMaster.cpp b/src/xrpld/app/ledger/detail/LedgerMaster.cpp
index 50a3a6289c..31510b44d2 100644
--- a/src/xrpld/app/ledger/detail/LedgerMaster.cpp
+++ b/src/xrpld/app/ledger/detail/LedgerMaster.cpp
@@ -127,10 +127,10 @@ LedgerMaster::LedgerMaster(
     , journal_(journal)
     , ledgerHistory_(collector, app)
     , standalone_(app_.config().standalone())
-    , fetch_depth_(app_.getSHAMapStore().clampFetchDepth(app_.config().FETCH_DEPTH))
-    , ledger_history_(app_.config().LEDGER_HISTORY)
-    , ledger_fetch_size_(app_.config().getValueFor(SizedItem::LedgerFetch))
-    , fetch_packs_(
+    , fetchDepth_(app_.getSHAMapStore().clampFetchDepth(app_.config().fetchDepth))
+    , ledgerHistorySize_(app_.config().ledgerHistory)
+    , ledgerFetchSize_(app_.config().getValueFor(SizedItem::LedgerFetch))
+    , fetchPacks_(
           "FetchPack",
           65536,
           std::chrono::seconds{45},
@@ -286,9 +286,9 @@ LedgerMaster::setValidLedger(std::shared_ptr const& l)
     validLedgerSign_ = signTime.time_since_epoch().count();
     XRPL_ASSERT(
         validLedgerSeq_ || !app_.getMaxDisallowedLedger() ||
-            l->header().seq + max_ledger_difference_ > app_.getMaxDisallowedLedger(),
+            l->header().seq + maxLedgerDifference_ > app_.getMaxDisallowedLedger(),
         "xrpl::LedgerMaster::setValidLedger : valid ledger sequence");
-    (void)max_ledger_difference_;
+    (void)maxLedgerDifference_;
     validLedgerSeq_ = l->header().seq;
 
     app_.getOPs().updateLocalTx(*l);
@@ -629,9 +629,9 @@ LedgerMaster::getEarliestFetch()
     // unless that creates a larger range than allowed
     std::uint32_t e = getClosedLedger()->header().seq;
 
-    if (e > fetch_depth_)
+    if (e > fetchDepth_)
     {
-        e -= fetch_depth_;
+        e -= fetchDepth_;
     }
     else
     {
@@ -755,7 +755,9 @@ LedgerMaster::getFetchPack(LedgerIndex missing, InboundLedger::Reason reason)
         JLOG(journal_.trace()) << "Requested fetch pack for " << missing;
     }
     else
+    {
         JLOG(journal_.debug()) << "No peer for fetch pack";
+    }
 }
 
 void
@@ -1277,10 +1279,10 @@ LedgerMaster::findNewLedgersToPublish(std::unique_lock& sl
                 ledger = ledgerHistory_.getLedgerByHash(*hash);
             }
 
-            if (!app_.config().LEDGER_REPLAY)
+            if (!app_.config().ledgerReplay)
             {
                 // Can we try to acquire the ledger we need?
-                if (!ledger && (++acqCount < ledger_fetch_size_))
+                if (!ledger && (++acqCount < ledgerFetchSize_))
                 {
                     ledger = app_.getInboundLedgers().acquire(
                         *hash, seq, InboundLedger::Reason::GENERIC);
@@ -1304,7 +1306,7 @@ LedgerMaster::findNewLedgersToPublish(std::unique_lock& sl
                                << ex.what();
     }
 
-    if (app_.config().LEDGER_REPLAY)
+    if (app_.config().ledgerReplay)
     {
         /* Narrow down the gap of ledgers, and try to replay them.
          * When replaying a ledger gap, if the local node has
@@ -1737,7 +1739,7 @@ void
 LedgerMaster::sweep()
 {
     ledgerHistory_.sweep();
-    fetch_packs_.sweep();
+    fetchPacks_.sweep();
 }
 
 float
@@ -1789,18 +1791,22 @@ LedgerMaster::fetchForHistory(
             if (!app_.getInboundLedgers().isFailure(*hash))
             {
                 ledger = app_.getInboundLedgers().acquire(*hash, missing, reason);
-                if (!ledger && missing != fetch_seq_ &&
+                if (!ledger && missing != fetchSeq_ &&
                     missing > app_.getNodeStore().earliestLedgerSeq())
                 {
                     JLOG(journal_.trace()) << "fetchForHistory want fetch pack " << missing;
-                    fetch_seq_ = missing;
+                    fetchSeq_ = missing;
                     getFetchPack(missing, reason);
                 }
                 else
+                {
                     JLOG(journal_.trace()) << "fetchForHistory no fetch pack for " << missing;
+                }
             }
             else
+            {
                 JLOG(journal_.debug()) << "fetchForHistory found failed acquire";
+            }
         }
         if (ledger)
         {
@@ -1833,8 +1839,7 @@ LedgerMaster::fetchForHistory(
             // Do not fetch ledger sequences lower
             // than the earliest ledger sequence
             fetchSz = app_.getNodeStore().earliestLedgerSeq();
-            fetchSz =
-                missing >= fetchSz ? std::min(ledger_fetch_size_, (missing - fetchSz) + 1) : 0;
+            fetchSz = missing >= fetchSz ? std::min(ledgerFetchSize_, (missing - fetchSz) + 1) : 0;
             try
             {
                 for (std::uint32_t i = 0; i < fetchSz; ++i)
@@ -1903,7 +1908,7 @@ LedgerMaster::doAdvance(std::unique_lock& sl)
                     if ((fillInProgress_ == 0 || *missing > fillInProgress_) &&
                         shouldAcquire(
                             validLedgerSeq_,
-                            ledger_history_,
+                            ledgerHistorySize_,
                             app_.getSHAMapStore().minimumOnline(),
                             *missing,
                             journal_))
@@ -1962,16 +1967,16 @@ LedgerMaster::doAdvance(std::unique_lock& sl)
 void
 LedgerMaster::addFetchPack(uint256 const& hash, std::shared_ptr data)
 {
-    fetch_packs_.canonicalizeReplaceClient(hash, data);
+    fetchPacks_.canonicalizeReplaceClient(hash, data);
 }
 
 std::optional
 LedgerMaster::getFetchPack(uint256 const& hash)
 {
     Blob data;
-    if (fetch_packs_.retrieve(hash, data))
+    if (fetchPacks_.retrieve(hash, data))
     {
-        fetch_packs_.del(hash, false);
+        fetchPacks_.del(hash, false);
         if (hash == sha512Half(makeSlice(data)))
             return data;
     }
@@ -2170,7 +2175,7 @@ LedgerMaster::makeFetchPack(
 std::size_t
 LedgerMaster::getFetchPackCacheSize() const
 {
-    return fetch_packs_.getCacheSize();
+    return fetchPacks_.getCacheSize();
 }
 
 // Returns the minimum ledger sequence in SQL database, if any.
diff --git a/src/xrpld/app/ledger/detail/LedgerPersistence.cpp b/src/xrpld/app/ledger/detail/LedgerPersistence.cpp
index 579f66063c..3561b66951 100644
--- a/src/xrpld/app/ledger/detail/LedgerPersistence.cpp
+++ b/src/xrpld/app/ledger/detail/LedgerPersistence.cpp
@@ -122,7 +122,7 @@ finishLoadByIndexOrHash(std::shared_ptr const& ledger, beast::Journal j)
         return;
 
     XRPL_ASSERT(
-        ledger->header().seq < kXrpLedgerEarliestFees || ledger->read(keylet::fees()),
+        ledger->header().seq < kXrpLedgerEarliestFees || ledger->read(keylet::feeSettings()),
         "xrpl::finishLoadByIndexOrHash : valid ledger fees");
     ledger->setImmutable();
 
diff --git a/src/xrpld/app/ledger/detail/LocalTxs.cpp b/src/xrpld/app/ledger/detail/LocalTxs.cpp
index d843acfe44..5bfe8684f0 100644
--- a/src/xrpld/app/ledger/detail/LocalTxs.cpp
+++ b/src/xrpld/app/ledger/detail/LocalTxs.cpp
@@ -163,7 +163,7 @@ public:
 
             // Ticket should have been created by now.  Remove if ticket
             // does not exist.
-            return !view.exists(keylet::kTicket(acctID, seqProx));
+            return !view.exists(keylet::ticket(acctID, seqProx));
         });
     }
 
diff --git a/src/xrpld/app/ledger/detail/OpenLedger.cpp b/src/xrpld/app/ledger/detail/OpenLedger.cpp
index 0694e425e9..60599c80d3 100644
--- a/src/xrpld/app/ledger/detail/OpenLedger.cpp
+++ b/src/xrpld/app/ledger/detail/OpenLedger.cpp
@@ -32,6 +32,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -48,26 +49,26 @@ OpenLedger::OpenLedger(
 bool
 OpenLedger::empty() const
 {
-    std::scoped_lock const lock(modify_mutex_);
+    std::scoped_lock const lock(modifyMutex_);
     return current_->txCount() == 0;
 }
 
 std::shared_ptr
 OpenLedger::current() const
 {
-    std::scoped_lock const lock(current_mutex_);
+    std::scoped_lock const lock(currentMutex_);
     return current_;
 }
 
 bool
 OpenLedger::modify(modify_type const& f)
 {
-    std::scoped_lock const lock1(modify_mutex_);
+    std::scoped_lock const lock1(modifyMutex_);
     auto next = std::make_shared(*current_);
     auto const changed = f(*next, j_);
     if (changed)
     {
-        std::scoped_lock const lock2(current_mutex_);
+        std::scoped_lock const lock2(currentMutex_);
         current_ = std::move(next);
     }
     return changed;
@@ -82,7 +83,7 @@ OpenLedger::accept(
     bool retriesFirst,
     OrderedTxs& retries,
     ApplyFlags flags,
-    std::string const& suffix,
+    std::string_view suffix,
     modify_type const& f)
 {
     JLOG(j_.trace()) << "accept ledger " << ledger->seq() << " " << suffix;
@@ -96,7 +97,7 @@ OpenLedger::accept(
     // Block calls to modify, otherwise
     // new tx going into the open ledger
     // would get lost.
-    std::scoped_lock const lock1(modify_mutex_);
+    std::scoped_lock const lock1(modifyMutex_);
     // Apply tx from the current open view
     if (!current_->txs.empty())
     {
@@ -154,7 +155,7 @@ OpenLedger::accept(
     }
 
     // Switch to the new open view
-    std::scoped_lock const lock2(current_mutex_);
+    std::scoped_lock const lock2(currentMutex_);
     current_ = std::move(next);
 }
 
diff --git a/src/xrpld/app/ledger/detail/SkipListAcquire.h b/src/xrpld/app/ledger/detail/SkipListAcquire.h
index da62578d41..6600b495c9 100644
--- a/src/xrpld/app/ledger/detail/SkipListAcquire.h
+++ b/src/xrpld/app/ledger/detail/SkipListAcquire.h
@@ -35,8 +35,8 @@ public:
         std::uint32_t const ledgerSeq;
         std::vector const skipList;
 
-        SkipListData(std::uint32_t const ledgerSeq, std::vector const& skipList)
-            : ledgerSeq(ledgerSeq), skipList(skipList)
+        SkipListData(std::uint32_t const ledgerSeq, std::vector skipList)
+            : ledgerSeq(ledgerSeq), skipList(std::move(skipList))
         {
         }
     };
diff --git a/src/xrpld/app/main/Application.cpp b/src/xrpld/app/main/Application.cpp
index 23fa9865ed..08d84636f6 100644
--- a/src/xrpld/app/main/Application.cpp
+++ b/src/xrpld/app/main/Application.cpp
@@ -27,7 +27,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -40,7 +39,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -56,6 +54,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -286,14 +286,14 @@ public:
         return 1;
 #else
 
-        if (config.IO_WORKERS > 0)
-            return config.IO_WORKERS;
+        if (config.ioWorkers > 0)
+            return config.ioWorkers;
 
         auto const cores = std::thread::hardware_concurrency();
 
         // Use a single thread when running on under-provisioned systems
         // or if we are configured to use minimal resources.
-        if ((cores == 1) || ((config.NODE_SIZE == 0) && (cores == 2)))
+        if ((cores == 1) || ((config.nodeSize == 0) && (cores == 2)))
             return 1;
 
         // Otherwise, prefer six threads.
@@ -316,32 +316,33 @@ public:
         // PerfLog must be started before any other threads are launched.
         , perfLog_(
               perf::makePerfLog(
-                  perf::setupPerfLog(config_->section("perf"), config_->CONFIG_DIR),
+                  perf::setupPerfLog(config_->section(Sections::kPerf), config_->configDir),
                   *this,
                   logs_->journal("PerfLog"),
                   [this] { signalStop("PerfLog"); }))
         , txMaster_(*this)
-        , collectorManager_(
-              makeCollectorManager(config_->section(SECTION_INSIGHT), logs_->journal("Collector")))
+        , collectorManager_(makeCollectorManager(
+              config_->section(Sections::kInsight),
+              logs_->journal("Collector")))
         , jobQueue_(
               std::make_unique(
                   [](std::unique_ptr const& config) {
-                      if (config->standalone() && !config->FORCE_MULTI_THREAD)
+                      if (config->standalone() && !config->forceMultiThread)
                           return 1;
 
-                      if (config->WORKERS)
-                          return config->WORKERS;
+                      if (config->workers)
+                          return config->workers;
 
                       auto count = static_cast(std::thread::hardware_concurrency());
 
                       // Be more aggressive about the number of threads to use
                       // for the job queue if the server is configured as
                       // "large" or "huge" if there are enough cores.
-                      if (config->NODE_SIZE >= 4 && count >= 16)
+                      if (config->nodeSize >= 4 && count >= 16)
                       {
                           count = 6 + std::min(count, 8);
                       }
-                      else if (config->NODE_SIZE >= 3 && count >= 8)
+                      else if (config->nodeSize >= 3 && count >= 8)
                       {
                           count = 4 + std::min(count, 6);
                       }
@@ -370,16 +371,16 @@ public:
               std::chrono::minutes(1),
               stopwatch(),
               logs_->journal("CachedSLEs"))
-        , networkIDService_(std::make_unique(config_->NETWORK_ID))
+        , networkIDService_(std::make_unique(config_->networkId))
         , validatorKeys_(*config_, journal_)
         , resourceManager_(
               Resource::makeManager(collectorManager_->collector(), logs_->journal("Resource")))
         , nodeStore_(shaMapStore_->makeNodeStore(
-              config_->PREFETCH_WORKERS > 0 ? config_->PREFETCH_WORKERS : 4))
+              config_->prefetchWorkers > 0 ? config_->prefetchWorkers : 4))
         , nodeFamily_(*this, *collectorManager_)
         , orderBookDB_(makeOrderBookDb(
               *this,
-              {.pathSearchMax = config_->PATH_SEARCH_MAX, .standalone = config_->standalone()}))
+              {.pathSearchMax = config_->pathSearchMax, .standalone = config_->standalone()}))
         , pathRequestManager_(
               std::make_unique(
                   *this,
@@ -415,8 +416,8 @@ public:
               *this,
               stopwatch(),
               config_->standalone(),
-              config_->NETWORK_QUORUM,
-              config_->START_VALID,
+              config_->networkQuorum,
+              config_->startValid,
               *jobQueue_,
               *ledgerMaster_,
               validatorKeys_,
@@ -435,7 +436,7 @@ public:
                   *timeKeeper_,
                   config_->legacy("database_path"),
                   logs_->journal("ValidatorList"),
-                  config_->VALIDATION_QUORUM))
+                  config_->validationQuorum))
         , validatorSites_(std::make_unique(*this))
         , serverHandler_(makeServerHandler(
               *this,
@@ -492,7 +493,7 @@ public:
     void
     run() override;
     void
-    signalStop(std::string msg) override;
+    signalStop(std::string const& msg) override;
     bool
     checkSigs() const override;
     void
@@ -864,7 +865,7 @@ public:
                     megabytes(config_->getValueFor(SizedItem::BurstSize, std::nullopt)),
                     dummyScheduler,
                     0,
-                    config_->section(ConfigSection::importNodeDatabase()),
+                    config_->section(Sections::kImportNodeDatabase),
                     j);
 
             JLOG(j.warn()) << "Starting node import from '" << source->getName() << "' to '"
@@ -918,7 +919,7 @@ public:
         {
             using namespace std::chrono;
             sweepTimer_.expires_after(
-                seconds{config_->SWEEP_INTERVAL.value_or(
+                seconds{config_->sweepInterval.value_or(
                     config_->getValueFor(SizedItem::SweepInterval))});
             sweepTimer_.async_wait(std::move(*optionalCountedHandler));
         }
@@ -998,6 +999,10 @@ public:
             JLOG(journal_.debug()) << "MasterTransaction sweep.  Size before: " << oldMasterTxSize
                                    << "; size after: " << masterTxCache.size();
         }
+        {
+            // Sweep NodeStore database cache(s), if enabled.
+            getNodeStore().sweep();
+        }
         {
             std::size_t const oldLedgerMasterCacheSize = getLedgerMaster().getFetchPackCacheSize();
 
@@ -1220,13 +1225,13 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
             }
             return supported;
         }();
-        Section const& downVoted = config_->section(SECTION_VETO_AMENDMENTS);
+        Section const& downVoted = config_->section(Sections::kVetoAmendments);
 
-        Section const& upVoted = config_->section(SECTION_AMENDMENTS);
+        Section const& upVoted = config_->section(Sections::kAmendments);
 
         amendmentTable_ = makeAmendmentTable(
             *this,
-            config().AMENDMENT_MAJORITY_TIME,
+            config().amendmentMajorityTime,
             supported,
             upVoted,
             downVoted,
@@ -1235,7 +1240,7 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
 
     Pathfinder::initPathTable();
 
-    auto const startUp = config_->START_UP;
+    auto const startUp = config_->startUp;
     JLOG(journal_.debug()) << "startUp: " << startUp;
     if (startUp == StartUpType::Fresh)
     {
@@ -1250,13 +1255,13 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
         JLOG(journal_.info()) << "Loading specified Ledger";
 
         if (!loadOldLedger(
-                config_->START_LEDGER,
+                config_->startLedger,
                 startUp == StartUpType::Replay,
                 startUp == StartUpType::LoadFile,
-                config_->TRAP_TX_HASH))
+                config_->trapTxHash))
         {
             JLOG(journal_.error()) << "The specified ledger could not be loaded.";
-            if (config_->FAST_LOAD)
+            if (config_->fastLoad)
             {
                 // Fall back to syncing from the network, such as
                 // when there's no existing data.
@@ -1282,7 +1287,7 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
         startGenesisLedger();
     }
 
-    if (auto const& forcedRange = config().FORCED_LEDGER_RANGE_PRESENT)
+    if (auto const& forcedRange = config().forcedLedgerRangePresent)
     {
         ledgerMaster_->setLedgerRangePresent(forcedRange->first, forcedRange->second);
     }
@@ -1291,7 +1296,7 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
 
     nodeIdentity_ = getNodeIdentity(*this, cmdline);
 
-    if (!cluster_->load(config().section(SECTION_CLUSTER_NODES)))
+    if (!cluster_->load(config().section(Sections::kClusterNodes)))
     {
         JLOG(journal_.fatal()) << "Invalid entry in cluster configuration.";
         return false;
@@ -1305,7 +1310,7 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
                 getWalletDB(),
                 "ValidatorManifests",
                 validatorKeys_.manifest,
-                config().section(SECTION_VALIDATOR_KEY_REVOCATION).values()))
+                config().section(Sections::kValidatorKeyRevocation).values()))
         {
             JLOG(journal_.fatal()) << "Invalid configured validator manifest.";
             return false;
@@ -1316,7 +1321,7 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
         // It is possible to have a valid ValidatorKeys object without
         // setting the signingKey or masterKey. This occurs if the
         // configuration file does not have either
-        // SECTION_VALIDATOR_TOKEN or SECTION_VALIDATION_SEED section.
+        // Sections::kValidatorToken or Sections::kValidationSeed section.
 
         // masterKey for the configuration-file specified validator keys
         std::optional localSigningKey;
@@ -1326,18 +1331,18 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
         // Setup trusted validators
         if (!validators_->load(
                 localSigningKey,
-                config().section(SECTION_VALIDATORS).values(),
-                config().section(SECTION_VALIDATOR_LIST_KEYS).values(),
-                config().VALIDATOR_LIST_THRESHOLD))
+                config().section(Sections::kValidators).values(),
+                config().section(Sections::kValidatorListKeys).values(),
+                config().validatorListThreshold))
         {
             JLOG(journal_.fatal()) << "Invalid entry in validator configuration.";
             return false;
         }
     }
 
-    if (!validatorSites_->load(config().section(SECTION_VALIDATOR_LIST_SITES).values()))
+    if (!validatorSites_->load(config().section(Sections::kValidatorListSites).values()))
     {
-        JLOG(journal_.fatal()) << "Invalid entry in [" << SECTION_VALIDATOR_LIST_SITES << "]";
+        JLOG(journal_.fatal()) << "Invalid entry in [" << Sections::kValidatorListSites << "]";
         return false;
     }
 
@@ -1399,7 +1404,7 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
     {
         // Should this message be here, conceptually? In theory this sort
         // of message, if displayed, should be displayed from PeerFinder.
-        if (config_->PEER_PRIVATE && config_->IPS_FIXED.empty())
+        if (config_->peerPrivate && config_->ipsFixed.empty())
         {
             JLOG(journal_.warn()) << "No outbound peer connections will be made";
         }
@@ -1435,7 +1440,7 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
     //
     // Execute start up rpc commands.
     //
-    for (auto const& cmd : config_->section(SECTION_RPC_STARTUP).lines())
+    for (auto const& cmd : config_->section(Sections::kRpcStartup).lines())
     {
         json::Reader jrReader;
         json::Value jvCommand;
@@ -1443,7 +1448,7 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
         if (!jrReader.parse(cmd, jvCommand))
         {
             JLOG(journal_.fatal())
-                << "Couldn't parse entry in [" << SECTION_RPC_STARTUP << "]: '" << cmd;
+                << "Couldn't parse entry in [" << Sections::kRpcStartup << "]: '" << cmd;
         }
 
         if (!config_->quiet())
@@ -1499,7 +1504,7 @@ ApplicationImp::start(bool withTimers)
         overlay_->start();
 
     if (grpcServer_->start())
-        fixConfigPorts(*config_, {{SECTION_PORT_GRPC, grpcServer_->getEndpoint()}});
+        fixConfigPorts(*config_, {{Sections::kPortGrpc, grpcServer_->getEndpoint()}});
 
     ledgerCleaner_->start();
     perfLog_->start();
@@ -1598,7 +1603,7 @@ ApplicationImp::run()
 }
 
 void
-ApplicationImp::signalStop(std::string msg)
+ApplicationImp::signalStop(std::string const& msg)
 {
     if (!isTimeToStop.test_and_set(std::memory_order_acquire))
     {
@@ -1607,7 +1612,9 @@ ApplicationImp::signalStop(std::string msg)
             JLOG(journal_.warn()) << "Server stopping";
         }
         else
+        {
             JLOG(journal_.warn()) << "Server stopping: " << msg;
+        }
 
         isTimeToStop.notify_all();
     }
@@ -1659,14 +1666,14 @@ ApplicationImp::fdRequired() const
 void
 ApplicationImp::startGenesisLedger()
 {
-    std::vector const initialAmendments = (config_->START_UP == StartUpType::Fresh)
+    std::vector const initialAmendments = (config_->startUp == StartUpType::Fresh)
         ? amendmentTable_->getDesired()
         : std::vector{};
 
     std::shared_ptr const genesis = std::make_shared(
         kCreateGenesis,
         Rules{config_->features},
-        config_->FEES.toFees(),
+        config_->fees.toFees(),
         initialAmendments,
         nodeFamily_);
     ledgerMaster_->storeLedger(genesis);
@@ -1674,7 +1681,7 @@ ApplicationImp::startGenesisLedger()
     auto const next = std::make_shared(*genesis, getTimeKeeper().closeTime());
     next->updateSkipList();
     XRPL_ASSERT(
-        next->header().seq < kXrpLedgerEarliestFees || next->read(keylet::fees()),
+        next->header().seq < kXrpLedgerEarliestFees || next->read(keylet::feeSettings()),
         "xrpl::ApplicationImp::startGenesisLedger : valid ledger fees");
     next->setImmutable();
     openLedger_.emplace(next, cachedSLEs_, logs_->journal("OpenLedger"));
@@ -1690,13 +1697,13 @@ ApplicationImp::getLastFullLedger()
     try
     {
         auto const [ledger, seq, hash] =
-            getLatestLedger(Rules{config_->features}, config_->FEES.toFees(), *this);
+            getLatestLedger(Rules{config_->features}, config_->fees.toFees(), *this);
 
         if (!ledger)
             return ledger;
 
         XRPL_ASSERT(
-            ledger->header().seq < kXrpLedgerEarliestFees || ledger->read(keylet::fees()),
+            ledger->header().seq < kXrpLedgerEarliestFees || ledger->read(keylet::feeSettings()),
             "xrpl::ApplicationImp::getLastFullLedger : valid ledger fees");
         ledger->setImmutable();
 
@@ -1802,7 +1809,7 @@ ApplicationImp::loadLedgerFromFile(std::string const& name)
         }
 
         auto loadLedger = std::make_shared(
-            seq, closeTime, Rules{config_->features}, config_->FEES.toFees(), nodeFamily_);
+            seq, closeTime, Rules{config_->features}, config_->fees.toFees(), nodeFamily_);
         loadLedger->setTotalDrops(totalDrops);
 
         for (json::UInt index = 0; index < ledger.get().size(); ++index)
@@ -1847,7 +1854,8 @@ ApplicationImp::loadLedgerFromFile(std::string const& name)
         loadLedger->stateMap().flushDirty(NodeObjectType::AccountNode);
 
         XRPL_ASSERT(
-            loadLedger->header().seq < kXrpLedgerEarliestFees || loadLedger->read(keylet::fees()),
+            loadLedger->header().seq < kXrpLedgerEarliestFees ||
+                loadLedger->read(keylet::feeSettings()),
             "xrpl::ApplicationImp::loadLedgerFromFile : valid ledger fees");
         loadLedger->setAccepted(closeTime, closeTimeResolution, !closeTimeEstimated);
 
@@ -1883,7 +1891,7 @@ ApplicationImp::loadOldLedger(
             if (hash.parseHex(ledgerID))
             {
                 loadLedger =
-                    loadByHash(hash, Rules{config_->features}, config_->FEES.toFees(), *this);
+                    loadByHash(hash, Rules{config_->features}, config_->fees.toFees(), *this);
 
                 if (!loadLedger)
                 {
@@ -1912,7 +1920,7 @@ ApplicationImp::loadOldLedger(
             if (beast::lexicalCastChecked(index, ledgerID))
             {
                 loadLedger =
-                    loadByIndex(index, Rules{config_->features}, config_->FEES.toFees(), *this);
+                    loadByIndex(index, Rules{config_->features}, config_->fees.toFees(), *this);
             }
         }
 
@@ -1931,7 +1939,7 @@ ApplicationImp::loadOldLedger(
             loadLedger = loadByHash(
                 replayLedger->header().parentHash,
                 Rules{config_->features},
-                config_->FEES.toFees(),
+                config_->fees.toFees(),
                 *this);
             if (!loadLedger)
             {
@@ -2078,7 +2086,7 @@ ApplicationImp::loadOldLedger(
 bool
 ApplicationImp::serverOkay(std::string& reason)
 {
-    if (!config().ELB_SUPPORT)
+    if (!config().elbSupport)
         return true;
 
     if (isStopping())
@@ -2166,12 +2174,12 @@ fixConfigPorts(Config& config, Endpoints const& endpoints)
             continue;
 
         auto& section = config[name];
-        auto const optPort = section.get("port");
+        auto const optPort = section.get(Keys::kPort);
         if (optPort)
         {
             std::uint16_t const port = beast::lexicalCast(*optPort);
             if (port == 0u)
-                section.set("port", std::to_string(ep.port()));
+                section.set(Keys::kPort, std::to_string(ep.port()));
         }
     }
 }
diff --git a/src/xrpld/app/main/Application.h b/src/xrpld/app/main/Application.h
index 200fed7cf9..08e41e2c4c 100644
--- a/src/xrpld/app/main/Application.h
+++ b/src/xrpld/app/main/Application.h
@@ -111,7 +111,7 @@ public:
     virtual void
     run() = 0;
     virtual void
-    signalStop(std::string msg) = 0;
+    signalStop(std::string const& msg) = 0;
     [[nodiscard]] virtual bool
     checkSigs() const = 0;
     virtual void
diff --git a/src/xrpld/app/main/BasicApp.cpp b/src/xrpld/app/main/BasicApp.cpp
index 71138c6517..c530c170cb 100644
--- a/src/xrpld/app/main/BasicApp.cpp
+++ b/src/xrpld/app/main/BasicApp.cpp
@@ -9,14 +9,14 @@
 
 BasicApp::BasicApp(std::size_t numberOfThreads)
 {
-    work_.emplace(boost::asio::make_work_guard(io_context_));
+    work_.emplace(boost::asio::make_work_guard(ioContext_));
     threads_.reserve(numberOfThreads);
 
     for (std::size_t i = 0; i < numberOfThreads; ++i)
     {
         threads_.emplace_back([this, i]() {
             beast::setCurrentThreadName("io svc #" + std::to_string(i));
-            this->io_context_.run();
+            this->ioContext_.run();
         });
     }
 }
diff --git a/src/xrpld/app/main/BasicApp.h b/src/xrpld/app/main/BasicApp.h
index d55ab858db..5ba7c719e3 100644
--- a/src/xrpld/app/main/BasicApp.h
+++ b/src/xrpld/app/main/BasicApp.h
@@ -12,7 +12,7 @@ class BasicApp
 private:
     std::optional> work_;
     std::vector threads_;
-    boost::asio::io_context io_context_;
+    boost::asio::io_context ioContext_;
 
 public:
     BasicApp(std::size_t numberOfThreads);
@@ -21,7 +21,7 @@ public:
     boost::asio::io_context&
     getIoContext()
     {
-        return io_context_;
+        return ioContext_;
     }
 
     [[nodiscard]] size_t
diff --git a/src/xrpld/app/main/CollectorManager.cpp b/src/xrpld/app/main/CollectorManager.cpp
index 6cdbca8d8a..9e1278607f 100644
--- a/src/xrpld/app/main/CollectorManager.cpp
+++ b/src/xrpld/app/main/CollectorManager.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -8,6 +7,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 
 #include 
 #include 
@@ -25,13 +26,13 @@ public:
 
     CollectorManagerImp(Section const& params, beast::Journal journal) : journal_(journal)
     {
-        std::string const& server = get(params, "server");
+        std::string const& server = get(params, Keys::kServer);
 
         if (server == "statsd")
         {
             beast::IP::Endpoint const address(
-                beast::IP::Endpoint::fromString(get(params, "address")));
-            std::string const& prefix(get(params, "prefix"));
+                beast::IP::Endpoint::fromString(get(params, Keys::kAddress)));
+            std::string const& prefix(get(params, Keys::kPrefix));
 
             collector_ = beast::insight::StatsDCollector::make(address, prefix, journal);
         }
diff --git a/src/xrpld/app/main/CollectorManager.h b/src/xrpld/app/main/CollectorManager.h
index e736ae57db..d07da15353 100644
--- a/src/xrpld/app/main/CollectorManager.h
+++ b/src/xrpld/app/main/CollectorManager.h
@@ -1,7 +1,7 @@
 #pragma once
 
-#include 
 #include 
+#include 
 
 namespace xrpl {
 
diff --git a/src/xrpld/app/main/GRPCServer.cpp b/src/xrpld/app/main/GRPCServer.cpp
index 7a622f632f..1af1343fc5 100644
--- a/src/xrpld/app/main/GRPCServer.cpp
+++ b/src/xrpld/app/main/GRPCServer.cpp
@@ -1,13 +1,11 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -15,6 +13,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -335,15 +335,15 @@ GRPCServerImpl::GRPCServerImpl(Application& app)
     : app_(app), journal_(app_.getJournal("gRPC Server"))
 {
     // if present, get endpoint from config
-    if (app_.config().exists(SECTION_PORT_GRPC))
+    if (app_.config().exists(Sections::kPortGrpc))
     {
-        Section const& section = app_.config().section(SECTION_PORT_GRPC);
+        Section const& section = app_.config().section(Sections::kPortGrpc);
 
-        auto const optIp = section.get("ip");
+        auto const optIp = section.get(Keys::kIp);
         if (!optIp)
             return;
 
-        auto const optPort = section.get("port");
+        auto const optPort = section.get(Keys::kPort);
         if (!optPort)
             return;
         try
@@ -361,7 +361,7 @@ GRPCServerImpl::GRPCServerImpl(Application& app)
             Throw("Error setting grpc server address");
         }
 
-        auto const optSecureGateway = section.get("secure_gateway");
+        auto const optSecureGateway = section.get(Keys::kSecureGateway);
         if (optSecureGateway)
         {
             try
@@ -391,10 +391,10 @@ GRPCServerImpl::GRPCServerImpl(Application& app)
         }
 
         // Read TLS certificate configuration (optional)
-        sslCertPath_ = section.get("ssl_cert");
-        sslKeyPath_ = section.get("ssl_key");
-        sslCertChainPath_ = section.get("ssl_cert_chain");
-        sslClientCAPath_ = section.get("ssl_client_ca");
+        sslCertPath_ = section.get(Keys::kSslCert);
+        sslKeyPath_ = section.get(Keys::kSslKey);
+        sslCertChainPath_ = section.get(Keys::kSslCertChain);
+        sslClientCAPath_ = section.get(Keys::kSslClientCa);
 
         // If cert or key is specified, both must be specified
         if (sslCertPath_.has_value() || sslKeyPath_.has_value())
diff --git a/src/xrpld/app/main/Main.cpp b/src/xrpld/app/main/Main.cpp
index ed1d3333d8..d0b40efce8 100644
--- a/src/xrpld/app/main/Main.cpp
+++ b/src/xrpld/app/main/Main.cpp
@@ -1,6 +1,5 @@
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -12,6 +11,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -362,10 +362,10 @@ run(int argc, char** argv)
     std::string importText;
     {
         importText += "Import an existing node database (specified in the [";
-        importText += ConfigSection::importNodeDatabase();
+        importText += Sections::kImportNodeDatabase;
         importText += "] configuration file section) into the current ";
         importText += "node database (specified in the [";
-        importText += ConfigSection::nodeDatabase();
+        importText += Sections::kNodeDatabase;
         importText += "] configuration file section).";
     }
 
@@ -627,7 +627,7 @@ run(int argc, char** argv)
                 {
                     throw std::runtime_error("Invalid force_ledger_present_range parameter");
                 }
-                config->FORCED_LEDGER_RANGE_PRESENT.emplace(r[0], r[1]);
+                config->forcedLedgerRangePresent.emplace(r[0], r[1]);
             }
             else
             {
@@ -646,7 +646,7 @@ run(int argc, char** argv)
 
     if (vm.contains("start"))
     {
-        config->START_UP = StartUpType::Fresh;
+        config->startUp = StartUpType::Fresh;
     }
 
     if (vm.contains("import"))
@@ -654,17 +654,17 @@ run(int argc, char** argv)
 
     if (vm.contains("ledger"))
     {
-        config->START_LEDGER = vm["ledger"].as();
+        config->startLedger = vm["ledger"].as();
         if (vm.contains("replay"))
         {
-            config->START_UP = StartUpType::Replay;
+            config->startUp = StartUpType::Replay;
             if (vm.contains("trap_tx_hash"))
             {
                 uint256 tmp = {};
                 auto hash = vm["trap_tx_hash"].as();
                 if (tmp.parseHex(hash))
                 {
-                    config->TRAP_TX_HASH = tmp;
+                    config->trapTxHash = tmp;
                 }
                 else
                 {
@@ -677,17 +677,17 @@ run(int argc, char** argv)
         }
         else
         {
-            config->START_UP = StartUpType::Load;
+            config->startUp = StartUpType::Load;
         }
     }
     else if (vm.contains("ledgerfile"))
     {
-        config->START_LEDGER = vm["ledgerfile"].as();
-        config->START_UP = StartUpType::LoadFile;
+        config->startLedger = vm["ledgerfile"].as();
+        config->startUp = StartUpType::LoadFile;
     }
-    else if (vm.contains("load") || config->FAST_LOAD)
+    else if (vm.contains("load") || config->fastLoad)
     {
-        config->START_UP = StartUpType::Load;
+        config->startUp = StartUpType::Load;
     }
 
     if (vm.contains("trap_tx_hash") && !vm.contains("replay"))
@@ -696,20 +696,20 @@ run(int argc, char** argv)
         return -1;
     }
 
-    if (vm.contains("net") && !config->FAST_LOAD)
+    if (vm.contains("net") && !config->fastLoad)
     {
-        if ((config->START_UP == StartUpType::Load) || (config->START_UP == StartUpType::Replay))
+        if ((config->startUp == StartUpType::Load) || (config->startUp == StartUpType::Replay))
         {
             std::cerr << "Net and load/replay options are incompatible" << std::endl;
             return -1;
         }
 
-        config->START_UP = StartUpType::Network;
+        config->startUp = StartUpType::Network;
     }
 
     if (vm.contains("valid"))
     {
-        config->START_VALID = true;
+        config->startValid = true;
     }
 
     // Override the RPC destination IP address. This must
@@ -747,15 +747,15 @@ run(int argc, char** argv)
             }
         }
 
-        config->rpc_ip = std::move(*endpoint);
+        config->rpcIp = std::move(*endpoint);
     }
 
     if (vm.contains("quorum"))
     {
         try
         {
-            config->VALIDATION_QUORUM = vm["quorum"].as();
-            if (config->VALIDATION_QUORUM == std::size_t{})
+            config->validationQuorum = vm["quorum"].as();
+            if (config->validationQuorum == std::size_t{})
             {
                 throw std::domain_error("0");
             }
diff --git a/src/xrpld/app/main/NodeIdentity.cpp b/src/xrpld/app/main/NodeIdentity.cpp
index 656e2b91a2..8198c43af7 100644
--- a/src/xrpld/app/main/NodeIdentity.cpp
+++ b/src/xrpld/app/main/NodeIdentity.cpp
@@ -2,9 +2,9 @@
 
 #include 
 #include 
-#include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -31,12 +31,15 @@ getNodeIdentity(Application& app, boost::program_options::variables_map const& c
         if (!seed)
             Throw("Invalid 'nodeid' in command line");
     }
-    else if (app.config().exists(SECTION_NODE_SEED))
+    else if (app.config().exists(Sections::kNodeSeed))
     {
-        seed = parseBase58(app.config().section(SECTION_NODE_SEED).lines().front());
+        seed = parseBase58(app.config().section(Sections::kNodeSeed).lines().front());
 
         if (!seed)
-            Throw("Invalid [" SECTION_NODE_SEED "] in configuration file");
+        {
+            Throw(
+                std::string("Invalid [") + Sections::kNodeSeed + "] in configuration file");
+        }
     }
 
     if (seed)
diff --git a/src/xrpld/app/misc/FeeVoteImpl.cpp b/src/xrpld/app/misc/FeeVoteImpl.cpp
index 0a55c20d29..c4e6c3cdc2 100644
--- a/src/xrpld/app/misc/FeeVoteImpl.cpp
+++ b/src/xrpld/app/misc/FeeVoteImpl.cpp
@@ -135,13 +135,10 @@ FeeVoteImpl::doValidation(Fees const& lastFees, Rules const& rules, STValidation
                     v[sfield] = target;
                 }
             };
-        vote(lastFees.base, target_.reference_fee, "base fee", sfBaseFeeDrops);
-        vote(lastFees.reserve, target_.account_reserve, "base reserve", sfReserveBaseDrops);
+        vote(lastFees.base, target_.referenceFee, "base fee", sfBaseFeeDrops);
+        vote(lastFees.reserve, target_.accountReserve, "base reserve", sfReserveBaseDrops);
         vote(
-            lastFees.increment,
-            target_.owner_reserve,
-            "reserve increment",
-            sfReserveIncrementDrops);
+            lastFees.increment, target_.ownerReserve, "reserve increment", sfReserveIncrementDrops);
     }
     else
     {
@@ -162,11 +159,11 @@ FeeVoteImpl::doValidation(Fees const& lastFees, Rules const& rules, STValidation
             }
         };
 
-        vote(lastFees.base, target_.reference_fee, to64, "base fee", sfBaseFee);
-        vote(lastFees.reserve, target_.account_reserve, to32, "base reserve", sfReserveBase);
+        vote(lastFees.base, target_.referenceFee, to64, "base fee", sfBaseFee);
+        vote(lastFees.reserve, target_.accountReserve, to32, "base reserve", sfReserveBase);
         vote(
             lastFees.increment,
-            target_.owner_reserve,
+            target_.ownerReserve,
             to32,
             "reserve increment",
             sfReserveIncrement);
@@ -184,11 +181,11 @@ FeeVoteImpl::doVoting(
         lastClosedLedger && isFlagLedger(lastClosedLedger->seq()),
         "xrpl::FeeVoteImpl::doVoting : has a flag ledger");
 
-    detail::VotableValue baseFeeVote(lastClosedLedger->fees().base, target_.reference_fee);
+    detail::VotableValue baseFeeVote(lastClosedLedger->fees().base, target_.referenceFee);
 
-    detail::VotableValue baseReserveVote(lastClosedLedger->fees().reserve, target_.account_reserve);
+    detail::VotableValue baseReserveVote(lastClosedLedger->fees().reserve, target_.accountReserve);
 
-    detail::VotableValue incReserveVote(lastClosedLedger->fees().increment, target_.owner_reserve);
+    detail::VotableValue incReserveVote(lastClosedLedger->fees().increment, target_.ownerReserve);
 
     auto const& rules = lastClosedLedger->rules();
     if (rules.enabled(featureXRPFees))
diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp
index d6f9d10f05..1802441a66 100644
--- a/src/xrpld/app/misc/NetworkOPs.cpp
+++ b/src/xrpld/app/misc/NetworkOPs.cpp
@@ -24,7 +24,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -54,6 +53,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -313,7 +313,7 @@ public:
         , consensus_(
               registry_.get().getApp(),
               makeFeeVote(
-                  setupFeeVote(registry_.get().getApp().config().section("voting")),
+                  setupFeeVote(registry_.get().getApp().config().section(Sections::kVoting)),
                   registry_.get().getJournal("FeeVote")),
               ledgerMaster,
               *localTX_,
@@ -327,7 +327,7 @@ public:
               validatorKeys.keys ? validatorKeys.keys->masterPublicKey
                                  : decltype(validatorMasterPK_){})
         , ledgerMaster_(ledgerMaster)
-        , job_queue_(jobQueue)
+        , jobQueue_(jobQueue)
         , standalone_(standalone)
         , minPeerCount_(startValid ? 0 : minPeerCount)
         , stats_(std::bind(&NetworkOPsImp::collectMetrics, this), collector)
@@ -527,6 +527,8 @@ public:
     updateLocalTx(ReadView const& view) override;
     std::size_t
     getLocalTxCount() override;
+    std::size_t
+    getBookSubscribersCount() override;
 
     //
     // Monitoring: publisher side.
@@ -586,7 +588,9 @@ public:
     bool
     subBook(InfoSub::ref ispListener, Book const&) override;
     bool
-    unsubBook(std::uint64_t uListener, Book const&) override;
+    unsubBook(InfoSub::ref ispListener, Book const&) override;
+    bool
+    unsubBookInternal(std::uint64_t uListener, Book const&) override;
 
     bool
     subManifests(InfoSub::ref ispListener) override;
@@ -629,6 +633,12 @@ public:
     bool
     tryRemoveRpcSub(std::string const& strUrl) override;
 
+    beast::Journal const&
+    journal() const override
+    {
+        return journal_;
+    }
+
     void
     stop() override
     {
@@ -705,6 +715,32 @@ private:
         AcceptedLedgerTx const& transaction,
         bool last);
 
+    /**
+     * Fan transaction notifications out to all book subscribers.
+     *
+     * Extracts the set of order books affected by @p transaction, then
+     * delivers @p jvObj to every live subscriber of those books.
+     *
+     * Uses a two-pass design to keep subLock_ hold time short:
+     *   1. Under subLock_, collect strong InfoSub pointers for all live
+     *      subscribers and prune any expired weak_ptrs encountered.
+     *   2. Release subLock_, then call send() on each collected pointer.
+     *
+     * @param transaction The accepted ledger transaction to inspect.
+     * @param jvObj JSON representation of the transaction to deliver.
+     *
+     * @note Thread-safety: acquires subLock_ for the collection pass only.
+     *       send() is intentionally called outside the lock to avoid blocking
+     *       all other sub/unsub/publish paths while I/O is in progress.
+     * @note Contention: subLock_ is shared with all other subscription types.
+     *       On high-throughput nodes processing multi-hop payments that touch
+     *       many offer nodes, this pass holds subLock_ longer than the old
+     *       per-book BookListeners locks did. This is an accepted trade-off
+     *       for lock-domain simplicity.
+     */
+    void
+    pubBookTransaction(AcceptedLedgerTx const& transaction, MultiApiJson const& jvObj);
+
     void
     pubProposedAccountTransaction(
         std::shared_ptr const& ledger,
@@ -802,8 +838,19 @@ private:
 
     LedgerMaster& ledgerMaster_;
 
+    /** Maps each order book to its current set of subscribers.
+     *  Outer key: the Book (currency pair + optional domain).
+     *  Inner key: InfoSub::seq (unique per connection).
+     *  Inner value: weak_ptr so that a dropped connection does not prevent
+     *  the InfoSub from being destroyed; expired entries are pruned lazily
+     *  by pubBookTransaction and eagerly by unsubBookInternal (~InfoSub path).
+     *  Guarded by subLock_.
+     */
+    using SubBookMapType = hash_map;
+
     SubInfoMapType subAccount_;
     SubInfoMapType subRTAccount_;
+    SubBookMapType subBook_;  ///< Guarded by subLock_.
 
     subRpcMapType rpcSubMap_;
 
@@ -828,7 +875,7 @@ private:
 
     ServerFeeSummary lastFeeSummary_;
 
-    JobQueue& job_queue_;
+    JobQueue& jobQueue_;
 
     // Whether we are in standalone mode.
     bool const standalone_;
@@ -853,34 +900,34 @@ private:
         template 
         Stats(Handler const& handler, beast::insight::Collector::ptr const& collector)
             : hook(collector->makeHook(handler))
-            , disconnected_duration(
+            , disconnectedDuration(
                   collector->makeGauge("State_Accounting", "Disconnected_duration"))
-            , connected_duration(collector->makeGauge("State_Accounting", "Connected_duration"))
-            , syncing_duration(collector->makeGauge("State_Accounting", "Syncing_duration"))
-            , tracking_duration(collector->makeGauge("State_Accounting", "Tracking_duration"))
-            , full_duration(collector->makeGauge("State_Accounting", "Full_duration"))
-            , disconnected_transitions(
+            , connectedDuration(collector->makeGauge("State_Accounting", "Connected_duration"))
+            , syncingDuration(collector->makeGauge("State_Accounting", "Syncing_duration"))
+            , trackingDuration(collector->makeGauge("State_Accounting", "Tracking_duration"))
+            , fullDuration(collector->makeGauge("State_Accounting", "Full_duration"))
+            , disconnectedTransitions(
                   collector->makeGauge("State_Accounting", "Disconnected_transitions"))
-            , connected_transitions(
+            , connectedTransitions(
                   collector->makeGauge("State_Accounting", "Connected_transitions"))
-            , syncing_transitions(collector->makeGauge("State_Accounting", "Syncing_transitions"))
-            , tracking_transitions(collector->makeGauge("State_Accounting", "Tracking_transitions"))
-            , full_transitions(collector->makeGauge("State_Accounting", "Full_transitions"))
+            , syncingTransitions(collector->makeGauge("State_Accounting", "Syncing_transitions"))
+            , trackingTransitions(collector->makeGauge("State_Accounting", "Tracking_transitions"))
+            , fullTransitions(collector->makeGauge("State_Accounting", "Full_transitions"))
         {
         }
 
         beast::insight::Hook hook;
-        beast::insight::Gauge disconnected_duration;
-        beast::insight::Gauge connected_duration;
-        beast::insight::Gauge syncing_duration;
-        beast::insight::Gauge tracking_duration;
-        beast::insight::Gauge full_duration;
+        beast::insight::Gauge disconnectedDuration;
+        beast::insight::Gauge connectedDuration;
+        beast::insight::Gauge syncingDuration;
+        beast::insight::Gauge trackingDuration;
+        beast::insight::Gauge fullDuration;
 
-        beast::insight::Gauge disconnected_transitions;
-        beast::insight::Gauge connected_transitions;
-        beast::insight::Gauge syncing_transitions;
-        beast::insight::Gauge tracking_transitions;
-        beast::insight::Gauge full_transitions;
+        beast::insight::Gauge disconnectedTransitions;
+        beast::insight::Gauge connectedTransitions;
+        beast::insight::Gauge syncingTransitions;
+        beast::insight::Gauge trackingTransitions;
+        beast::insight::Gauge fullTransitions;
     };
 
     std::mutex statsMutex_;  // Mutex to lock stats_
@@ -990,7 +1037,7 @@ NetworkOPsImp::setTimer(
     // Only start the timer if waitHandlerCounter_ is not yet joined.
     if (auto optionalCountedHandler =
             waitHandlerCounter_.wrap([this, onExpire, onError](boost::system::error_code const& e) {
-                if ((e.value() == boost::system::errc::success) && (!job_queue_.isStopped()))
+                if ((e.value() == boost::system::errc::success) && (!jobQueue_.isStopped()))
                 {
                     onExpire();
                 }
@@ -1017,7 +1064,7 @@ NetworkOPsImp::setHeartbeatTimer()
         heartbeatTimer_,
         consensus_.parms().ledgerGRANULARITY,
         [this]() {
-            job_queue_.addJob(JtNetopTimer, "NetHeart", [this]() { processHeartbeatTimer(); });
+            jobQueue_.addJob(JtNetopTimer, "NetHeart", [this]() { processHeartbeatTimer(); });
         },
         [this]() { setHeartbeatTimer(); });
 }
@@ -1031,7 +1078,7 @@ NetworkOPsImp::setClusterTimer()
         clusterTimer_,
         10s,
         [this]() {
-            job_queue_.addJob(JtNetopCluster, "NetCluster", [this]() { processClusterTimer(); });
+            jobQueue_.addJob(JtNetopCluster, "NetCluster", [this]() { processClusterTimer(); });
         },
         [this]() { setClusterTimer(); });
 }
@@ -1247,7 +1294,7 @@ NetworkOPsImp::submitTransaction(std::shared_ptr const& iTrans)
 
     auto tx = std::make_shared(trans, reason, registry_.get().getApp());
 
-    job_queue_.addJob(JtTransaction, "SubmitTxn", [this, tx]() {
+    jobQueue_.addJob(JtTransaction, "SubmitTxn", [this, tx]() {
         auto t = tx;
         processTransaction(t, false, false, FailHard::No);
     });
@@ -1312,7 +1359,7 @@ NetworkOPsImp::processTransaction(
     bool bLocal,
     FailHard failType)
 {
-    auto ev = job_queue_.makeLoadEvent(JtTxnProc, "ProcessTXN");
+    auto ev = jobQueue_.makeLoadEvent(JtTxnProc, "ProcessTXN");
 
     // preProcessTransaction can change our pointer
     if (!preProcessTransaction(transaction))
@@ -1344,7 +1391,7 @@ NetworkOPsImp::doTransactionAsync(
 
     if (dispatchState_ == DispatchState::None)
     {
-        if (job_queue_.addJob(JtBatch, "TxBatchAsync", [this]() { transactionBatch(); }))
+        if (jobQueue_.addJob(JtBatch, "TxBatchAsync", [this]() { transactionBatch(); }))
         {
             dispatchState_ = DispatchState::Scheduled;
         }
@@ -1389,7 +1436,7 @@ NetworkOPsImp::doTransactionSyncBatch(
             if (!transactions_.empty())
             {
                 // More transactions need to be applied, but by another job.
-                if (job_queue_.addJob(JtBatch, "TxBatchSync", [this]() { transactionBatch(); }))
+                if (jobQueue_.addJob(JtBatch, "TxBatchSync", [this]() { transactionBatch(); }))
                 {
                     dispatchState_ = DispatchState::Scheduled;
                 }
@@ -1401,7 +1448,7 @@ NetworkOPsImp::doTransactionSyncBatch(
 void
 NetworkOPsImp::processTransactionSet(CanonicalTXSet const& set)
 {
-    auto ev = job_queue_.makeLoadEvent(JtTxnProc, "ProcessTXNSet");
+    auto ev = jobQueue_.makeLoadEvent(JtTxnProc, "ProcessTXNSet");
     std::vector> candidates;
     candidates.reserve(set.size());
     for (auto const& [_, tx] : set)
@@ -1640,11 +1687,13 @@ NetworkOPsImp::apply(std::unique_lock& batchLock)
                         e.transaction->setKept();
                     }
                     else
+                    {
                         JLOG(journal_.debug())
                             << "Not holding transaction " << e.transaction->getID() << ": "
                             << (e.local ? "local" : "network") << ", "
                             << "result: " << e.result << " ledgers left: "
                             << (ledgersLeft ? to_string(*ledgersLeft) : "unspecified");
+                    }
                 }
             }
             else
@@ -2571,7 +2620,7 @@ NetworkOPsImp::recvValidation(std::shared_ptr const& val, std::str
 
     // We will always relay trusted validations; if configured, we will
     // also relay all untrusted validations.
-    return registry_.get().getApp().config().RELAY_UNTRUSTED_VALIDATIONS == 1 || val->isTrusted();
+    return registry_.get().getApp().config().relayUntrustedValidations == 1 || val->isTrusted();
 }
 
 json::Value
@@ -2631,8 +2680,8 @@ NetworkOPsImp::getServerInfo(bool human, bool admin, bool counters)
         info[jss::hostid] = getHostId(admin);
 
     // domain: if configured with a domain, report it:
-    if (!registry_.get().getApp().config().SERVER_DOMAIN.empty())
-        info[jss::server_domain] = registry_.get().getApp().config().SERVER_DOMAIN;
+    if (!registry_.get().getApp().config().serverDomain.empty())
+        info[jss::server_domain] = registry_.get().getApp().config().serverDomain;
 
     info[jss::build_version] = BuildInfo::getVersionString();
 
@@ -2652,7 +2701,7 @@ NetworkOPsImp::getServerInfo(bool human, bool admin, bool counters)
         // Note: By default the node size is "tiny". When parsing it's an error if the final
         // NODE_SIZE is over 4 so below code should be safe.
         // NOLINTNEXTLINE(bugprone-switch-missing-default-case)
-        switch (registry_.get().getApp().config().NODE_SIZE)
+        switch (registry_.get().getApp().config().nodeSize)
         {
             case 0:
                 info[jss::node_size] = "tiny";
@@ -2787,7 +2836,7 @@ NetworkOPsImp::getServerInfo(bool human, bool admin, bool counters)
     //  info[jss::consensus] = consensus_.getJson();
 
     if (admin)
-        info[jss::load] = job_queue_.getJson();
+        info[jss::load] = jobQueue_.getJson();
 
     if (auto const netid = registry_.get().getOverlay().networkID())
         info[jss::network_id] = static_cast(*netid);
@@ -2952,8 +3001,8 @@ NetworkOPsImp::getServerInfo(bool human, bool admin, bool counters)
         {
             // Don't publish admin ports for non-admin users
             if (!admin &&
-                !(port.admin_nets_v4.empty() && port.admin_nets_v6.empty() &&
-                  port.admin_user.empty() && port.admin_password.empty()))
+                !(port.adminNetsV4.empty() && port.adminNetsV6.empty() && port.adminUser.empty() &&
+                  port.adminPassword.empty()))
                 continue;
             std::vector proto;
             // NOLINTNEXTLINE(modernize-use-ranges)
@@ -2973,11 +3022,12 @@ NetworkOPsImp::getServerInfo(bool human, bool admin, bool counters)
             }
         }
 
-        if (registry_.get().getApp().config().exists(SECTION_PORT_GRPC))
+        if (registry_.get().getApp().config().exists(Sections::kPortGrpc))
         {
-            auto const& grpcSection = registry_.get().getApp().config().section(SECTION_PORT_GRPC);
-            auto const optPort = grpcSection.get("port");
-            if (optPort && grpcSection.get("ip"))
+            auto const& grpcSection =
+                registry_.get().getApp().config().section(Sections::kPortGrpc);
+            auto const optPort = grpcSection.get(Keys::kPort);
+            if (optPort && grpcSection.get(Keys::kIp))
             {
                 auto& jv = ports.append(json::Value(json::ValueType::Object));
                 jv[jss::port] = *optPort;
@@ -3170,14 +3220,14 @@ NetworkOPsImp::reportFeeChange()
     // only schedule the job if something has changed
     if (f != lastFeeSummary_)
     {
-        job_queue_.addJob(JtClientFeeChange, "PubFee", [this]() { pubServer(); });
+        jobQueue_.addJob(JtClientFeeChange, "PubFee", [this]() { pubServer(); });
     }
 }
 
 void
 NetworkOPsImp::reportConsensusStateChange(ConsensusPhase phase)
 {
-    job_queue_.addJob(JtClientConsensus, "PubCons", [this, phase]() { pubConsensus(phase); });
+    jobQueue_.addJob(JtClientConsensus, "PubCons", [this, phase]() { pubConsensus(phase); });
 }
 
 inline void
@@ -3191,6 +3241,16 @@ NetworkOPsImp::getLocalTxCount()
     return localTX_->size();
 }
 
+std::size_t
+NetworkOPsImp::getBookSubscribersCount()
+{
+    std::scoped_lock const sl(subLock_);
+    std::size_t total = 0;
+    for (auto const& [_, subs] : subBook_)
+        total += subs.size();
+    return total;
+}
+
 // This routine should only be used to publish accepted or validated
 // transactions.
 MultiApiJson
@@ -3352,11 +3412,89 @@ NetworkOPsImp::pubValidatedTransaction(
     }
 
     if (transaction.getResult() == tesSUCCESS)
-        registry_.get().getOrderBookDB().processTxn(ledger, transaction, jvObj);
+        pubBookTransaction(transaction, jvObj);
 
     pubAccountTransaction(ledger, transaction, last);
 }
 
+void
+NetworkOPsImp::pubBookTransaction(AcceptedLedgerTx const& alTx, MultiApiJson const& jvObj)
+{
+    auto const books = affectedBooks(alTx, journal_);
+    if (books.empty())
+        return;
+
+    // Two-pass design:
+    //
+    //   1. Under subLock_, walk subBook_, collect a strong pointer for each
+    //      unique listener (and prune any expired weak_ptrs we encounter).
+    //   2. Release subLock_, then send to each collected listener.
+    //
+    // Reasoning:
+    //   * send() can be slow / blocking, so holding subLock_ across it would
+    //     stall every other sub/unsub/pub path on this server (see the matching
+    //     TODO above pubServer at line ~2275).
+    //   * A strong pointer destructed while subLock_ is held risks running
+    //     ~InfoSub() in-line, which re-enters unsubBook() and mutates the very
+    //     subBook_/SubMapType being iterated -> dangling iterator UB.
+    //
+    // Releasing subLock_ before any InfoSub::pointer can decay solves both.
+    // ~InfoSub() reacquires subLock_ via unsubBook() on its own and serializes
+    // safely with concurrent traffic.
+
+    std::vector listeners;
+    hash_set seen;
+
+    // Sized for the common case where every affected book has at most
+    // one subscriber. Multi-subscriber books trigger reallocation, but
+    // that is rare and the upper-bound estimate (sum of per-book sizes)
+    // would itself require walking subBook_ twice.
+    listeners.reserve(books.size());
+    seen.reserve(books.size());
+
+    {
+        std::scoped_lock const sl(subLock_);
+
+        for (auto const& book : books)
+        {
+            auto it = subBook_.find(book);
+            if (it == subBook_.end())
+                continue;
+
+            for (auto sit = it->second.begin(); sit != it->second.end();)
+            {
+                if (auto p = sit->second.lock())
+                {
+                    // Defensive: subBook_ entries are normally cleared by
+                    // ~InfoSub() -> unsubBook(), so we rarely see expired
+                    // weak_ptrs here. The else branch covers the narrow race
+                    // where the last strong ref is dropped between insertion
+                    // and our lock() call.
+                    if (seen.emplace(p->getSeq()).second)
+                        listeners.emplace_back(std::move(p));
+                    ++sit;
+                }
+                else
+                {
+                    JLOG(journal_.debug())
+                        << "pubBookTransaction: pruning expired weak_ptr for seq=" << sit->first;
+                    sit = it->second.erase(sit);
+                }
+            }
+
+            if (it->second.empty())
+                subBook_.erase(it);
+        }
+    }
+
+    for (auto const& p : listeners)
+    {
+        jvObj.visit(p->getApiVersion(), [&](json::Value const& jv) { p->send(jv, true); });
+    }
+    // listeners destructs here, outside subLock_; ~InfoSub (if any fires)
+    // will reacquire subLock_ via unsubBook with no iterator hazard.
+}
+
 void
 NetworkOPsImp::pubAccountTransaction(
     std::shared_ptr const& ledger,
@@ -4010,26 +4148,39 @@ NetworkOPsImp::unsubAccountHistoryInternal(
 bool
 NetworkOPsImp::subBook(InfoSub::ref isrListener, Book const& book)
 {
-    if (auto listeners = registry_.get().getOrderBookDB().makeBookListeners(book))
+    // Server-side insert first, then InfoSub bookkeeping. If the InfoSub-side
+    // insert throws, the orphan in subBook_ is cleared by the expired-weak_ptr
+    // prune in pubBookTransaction. With the reverse ordering, ~InfoSub would
+    // call unsubBookInternal for a key that was never inserted server-side.
     {
-        listeners->addSubscriber(isrListener);
-    }
-    else
-    {
-        // LCOV_EXCL_START
-        UNREACHABLE("xrpl::NetworkOPsImp::subBook : null book listeners");
-        // LCOV_EXCL_STOP
+        std::scoped_lock const sl(subLock_);
+        subBook_[book].try_emplace(isrListener->getSeq(), isrListener);
     }
+    isrListener->insertBookSubscription(book);
     return true;
 }
 
 bool
-NetworkOPsImp::unsubBook(std::uint64_t uSeq, Book const& book)
+NetworkOPsImp::unsubBook(InfoSub::ref isrListener, Book const& book)
 {
-    if (auto listeners = registry_.get().getOrderBookDB().getBookListeners(book))
-        listeners->removeSubscriber(uSeq);
+    // Mirrors unsubAccount: clear the per-subscriber tracking set first so
+    // ~InfoSub does not re-issue an unsubBookInternal for a book the caller
+    // already removed, then erase the server-side entry.
+    isrListener->deleteBookSubscription(book);
+    return unsubBookInternal(isrListener->getSeq(), book);
+}
 
-    return true;
+bool
+NetworkOPsImp::unsubBookInternal(std::uint64_t uSeq, Book const& book)
+{
+    std::scoped_lock const sl(subLock_);
+    auto it = subBook_.find(book);
+    if (it == subBook_.end())
+        return false;
+    bool const erased = it->second.erase(uSeq) != 0u;
+    if (it->second.empty())
+        subBook_.erase(it);
+    return erased;
 }
 
 std::uint32_t
@@ -4316,7 +4467,7 @@ NetworkOPsImp::getBookPage(
     bool bDone = false;
     bool bDirectAdvance = true;
 
-    std::shared_ptr sleOfferDir;
+    SLE::const_pointer sleOfferDir;
     uint256 offerIndex;
     unsigned int uBookEntry = 0;
     STAmount saDirRate;
@@ -4626,26 +4777,25 @@ NetworkOPsImp::collectMetrics()
     counters[static_cast(mode)].dur += current;
 
     std::scoped_lock const lock(statsMutex_);
-    stats_.disconnected_duration.set(
+    stats_.disconnectedDuration.set(
         counters[static_cast(OperatingMode::DISCONNECTED)].dur.count());
-    stats_.connected_duration.set(
+    stats_.connectedDuration.set(
         counters[static_cast(OperatingMode::CONNECTED)].dur.count());
-    stats_.syncing_duration.set(
+    stats_.syncingDuration.set(
         counters[static_cast(OperatingMode::SYNCING)].dur.count());
-    stats_.tracking_duration.set(
+    stats_.trackingDuration.set(
         counters[static_cast(OperatingMode::TRACKING)].dur.count());
-    stats_.full_duration.set(counters[static_cast(OperatingMode::FULL)].dur.count());
+    stats_.fullDuration.set(counters[static_cast(OperatingMode::FULL)].dur.count());
 
-    stats_.disconnected_transitions.set(
+    stats_.disconnectedTransitions.set(
         counters[static_cast(OperatingMode::DISCONNECTED)].transitions);
-    stats_.connected_transitions.set(
+    stats_.connectedTransitions.set(
         counters[static_cast(OperatingMode::CONNECTED)].transitions);
-    stats_.syncing_transitions.set(
+    stats_.syncingTransitions.set(
         counters[static_cast(OperatingMode::SYNCING)].transitions);
-    stats_.tracking_transitions.set(
+    stats_.trackingTransitions.set(
         counters[static_cast(OperatingMode::TRACKING)].transitions);
-    stats_.full_transitions.set(
-        counters[static_cast(OperatingMode::FULL)].transitions);
+    stats_.fullTransitions.set(counters[static_cast(OperatingMode::FULL)].transitions);
 }
 
 void
diff --git a/src/xrpld/app/misc/SHAMapStoreImp.cpp b/src/xrpld/app/misc/SHAMapStoreImp.cpp
index 556c6f9239..33bbf1c613 100644
--- a/src/xrpld/app/misc/SHAMapStoreImp.cpp
+++ b/src/xrpld/app/misc/SHAMapStoreImp.cpp
@@ -4,15 +4,15 @@
 #include 
 #include 
 #include 
-#include 
 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -101,44 +101,45 @@ SHAMapStoreImp::SHAMapStoreImp(
 {
     Config& config{app.config()};
 
-    Section& section{config.section(ConfigSection::nodeDatabase())};
+    Section& section{config.section(Sections::kNodeDatabase)};
     if (section.empty())
     {
         Throw(
-            "Missing [" + ConfigSection::nodeDatabase() + "] entry in configuration file");
+            std::string("Missing [") + Sections::kNodeDatabase + "] entry in configuration file");
     }
 
     // RocksDB only. Use sensible defaults if no values specified.
-    if (boost::iequals(get(section, "type"), "RocksDB"))
+    if (boost::iequals(get(section, Keys::kType), "RocksDB"))
     {
-        if (!section.exists("cache_mb"))
+        if (!section.exists(Keys::kCacheMb))
         {
-            section.set("cache_mb", std::to_string(config.getValueFor(SizedItem::HashNodeDbCache)));
+            section.set(
+                Keys::kCacheMb, std::to_string(config.getValueFor(SizedItem::HashNodeDbCache)));
         }
 
-        if (!section.exists("filter_bits") && (config.NODE_SIZE >= 2))
-            section.set("filter_bits", "10");
+        if (!section.exists(Keys::kFilterBits) && (config.nodeSize >= 2))
+            section.set(Keys::kFilterBits, "10");
     }
 
-    getIfExists(section, "online_delete", deleteInterval_);
+    getIfExists(section, Keys::kOnlineDelete, deleteInterval_);
 
     if (deleteInterval_ != 0u)
     {
         // Configuration that affects the behavior of online delete
-        getIfExists(section, "delete_batch", deleteBatch_);
+        getIfExists(section, Keys::kDeleteBatch, deleteBatch_);
         std::uint32_t temp = 0;
-        if (getIfExists(section, "back_off_milliseconds", temp) ||
+        if (getIfExists(section, Keys::kBackOffMilliseconds, temp) ||
             // Included for backward compatibility with an undocumented setting
-            getIfExists(section, "backOff", temp))
+            getIfExists(section, Keys::kBackOff, temp))
         {
             backOff_ = std::chrono::milliseconds{temp};
         }
-        if (getIfExists(section, "age_threshold_seconds", temp))
+        if (getIfExists(section, Keys::kAgeThresholdSeconds, temp))
             ageThreshold_ = std::chrono::seconds{temp};
-        if (getIfExists(section, "recovery_wait_seconds", temp))
+        if (getIfExists(section, Keys::kRecoveryWaitSeconds, temp))
             recoveryWaitTime_ = std::chrono::seconds{temp};
 
-        getIfExists(section, "advisory_delete", advisoryDelete_);
+        getIfExists(section, Keys::kAdvisoryDelete, advisoryDelete_);
 
         auto const minInterval =
             config.standalone() ? kMinimumDeletionIntervalSa : kMinimumDeletionInterval;
@@ -148,15 +149,15 @@ SHAMapStoreImp::SHAMapStoreImp(
                 "online_delete must be at least " + std::to_string(minInterval));
         }
 
-        if (config.LEDGER_HISTORY > deleteInterval_)
+        if (config.ledgerHistory > deleteInterval_)
         {
             Throw(
                 "online_delete must not be less than ledger_history "
                 "(currently " +
-                std::to_string(config.LEDGER_HISTORY) + ")");
+                std::to_string(config.ledgerHistory) + ")");
         }
 
-        state_db_.init(config, dbName_);
+        stateDb_.init(config, dbName_);
         dbPaths();
     }
 }
@@ -164,19 +165,35 @@ SHAMapStoreImp::SHAMapStoreImp(
 std::unique_ptr
 SHAMapStoreImp::makeNodeStore(int readThreads)
 {
-    auto nscfg = app_.config().section(ConfigSection::nodeDatabase());
+    auto nscfg = app_.config().section(Sections::kNodeDatabase);
+
+    // Provide default values.
+    if (!nscfg.exists(Keys::kCacheSize))
+    {
+        nscfg.set(
+            Keys::kCacheSize,
+            std::to_string(app_.config().getValueFor(SizedItem::TreeCacheSize, std::nullopt)));
+    }
+
+    if (!nscfg.exists(Keys::kCacheAge))
+    {
+        nscfg.set(
+            Keys::kCacheAge,
+            std::to_string(app_.config().getValueFor(SizedItem::TreeCacheAge, std::nullopt)));
+    }
+
     std::unique_ptr db;
 
     if (deleteInterval_ != 0u)
     {
-        SavedState state = state_db_.getState();
+        SavedState state = stateDb_.getState();
         auto writableBackend = makeBackendRotating(state.writableDb);
         auto archiveBackend = makeBackendRotating(state.archiveDb);
         if (state.writableDb.empty())
         {
             state.writableDb = writableBackend->getName();
             state.archiveDb = archiveBackend->getName();
-            state_db_.setState(state);
+            stateDb_.setState(state);
         }
 
         // Create NodeStore with two backends to allow online deletion of
@@ -251,12 +268,14 @@ void
 SHAMapStoreImp::run()
 {
     beast::setCurrentThreadName("SHAMapStore");
-    LedgerIndex lastRotated = state_db_.getState().lastRotated;
+    LedgerIndex lastRotated = stateDb_.getState().lastRotated;
     netOPs_ = &app_.getOPs();
     ledgerMaster_ = &app_.getLedgerMaster();
+    fullBelowCache_ = &(*app_.getNodeFamily().getFullBelowCache());
+    treeNodeCache_ = &(*app_.getNodeFamily().getTreeNodeCache());
 
     if (advisoryDelete_)
-        canDelete_ = state_db_.getCanDelete();
+        canDelete_ = stateDb_.getCanDelete();
 
     while (true)
     {
@@ -286,7 +305,7 @@ SHAMapStoreImp::run()
         if (lastRotated == 0u)
         {
             lastRotated = validatedSeq;
-            state_db_.setLastRotated(lastRotated);
+            stateDb_.setLastRotated(lastRotated);
         }
 
         bool const readyToRotate = validatedSeq >= lastRotated + deleteInterval_ &&
@@ -354,7 +373,7 @@ SHAMapStoreImp::run()
                     savedState.writableDb = writableName;
                     savedState.archiveDb = archiveName;
                     savedState.lastRotated = lastRotated;
-                    state_db_.setState(savedState);
+                    stateDb_.setState(savedState);
 
                     clearCaches(validatedSeq);
                 });
@@ -367,9 +386,13 @@ SHAMapStoreImp::run()
 void
 SHAMapStoreImp::dbPaths()
 {
-    Section const section{app_.config().section(ConfigSection::nodeDatabase())};
-    boost::filesystem::path dbPath = get(section, "path");
+    Section const section{app_.config().section(Sections::kNodeDatabase)};
 
+    // Skip creating the directory when an in-memory database is used.
+    if (boost::iequals(get(section, Keys::kType), "memory"))
+        return;
+
+    boost::filesystem::path dbPath = get(section, Keys::kPath);
     if (boost::filesystem::exists(dbPath))
     {
         if (!boost::filesystem::is_directory(dbPath))
@@ -383,7 +406,7 @@ SHAMapStoreImp::dbPaths()
         boost::filesystem::create_directories(dbPath);
     }
 
-    SavedState state = state_db_.getState();
+    SavedState state = stateDb_.getState();
 
     {
         auto update = [&dbPath](std::string& sPath) {
@@ -403,7 +426,7 @@ SHAMapStoreImp::dbPaths()
         if (update(state.writableDb))
         {
             update(state.archiveDb);
-            state_db_.setState(state);
+            stateDb_.setState(state);
         }
     }
 
@@ -433,7 +456,7 @@ SHAMapStoreImp::dbPaths()
         (!archiveDbExists && !state.archiveDb.empty()) || (writableDbExists != archiveDbExists) ||
         state.writableDb.empty() != state.archiveDb.empty())
     {
-        boost::filesystem::path stateDbPathName = app_.config().legacy("database_path");
+        boost::filesystem::path stateDbPathName = app_.config().legacy(Sections::kDatabasePath);
         stateDbPathName /= dbName_;
         stateDbPathName += "*";
 
@@ -445,7 +468,7 @@ SHAMapStoreImp::dbPaths()
                          << "The existing data is in a corrupted state.\n"
                          << "To resume operation, remove the files matching "
                          << stateDbPathName.string() << " and contents of the directory "
-                         << get(section, "path") << '\n'
+                         << get(section, Keys::kPath) << '\n'
                          << "Optionally, you can move those files to another\n"
                          << "location if you wish to analyze or back up the data.\n"
                          << "However, there is no guarantee that the data in its\n"
@@ -462,7 +485,7 @@ SHAMapStoreImp::dbPaths()
 std::unique_ptr
 SHAMapStoreImp::makeBackendRotating(std::string path)
 {
-    Section section{app_.config().section(ConfigSection::nodeDatabase())};
+    Section section{app_.config().section(Sections::kNodeDatabase)};
     boost::filesystem::path newPath;
 
     if (!path.empty())
@@ -471,12 +494,12 @@ SHAMapStoreImp::makeBackendRotating(std::string path)
     }
     else
     {
-        boost::filesystem::path p = get(section, "path");
+        boost::filesystem::path p = get(section, Keys::kPath);
         p /= dbPrefix_;
         p += ".%%%%";
         newPath = boost::filesystem::unique_path(p);
     }
-    section.set("path", newPath.string());
+    section.set(Keys::kPath, newPath.string());
 
     auto backend{NodeStore::Manager::instance().makeBackend(
         section,
@@ -542,16 +565,16 @@ SHAMapStoreImp::clearCaches(LedgerIndex validatedSeq)
     // Also clear the FullBelowCache so its generation counter is bumped.
     // This prevents stale "full below" markers from persisting across
     // backend rotation/online deletion and interfering with SHAMap sync.
-    app_.getNodeFamily().getFullBelowCache()->clear();
+    fullBelowCache_->clear();
 }
 
 void
 SHAMapStoreImp::freshenCaches()
 {
-    if (freshenCache(*app_.getNodeFamily().getTreeNodeCache()))
+    if (freshenCache(*treeNodeCache_))
+        return;
+    if (freshenCache(app_.getMasterTransaction().getCache()))
         return;
-
-    freshenCache(app_.getMasterTransaction().getCache());
 }
 
 void
diff --git a/src/xrpld/app/misc/SHAMapStoreImp.h b/src/xrpld/app/misc/SHAMapStoreImp.h
index 1eb2beb561..1803c15e71 100644
--- a/src/xrpld/app/misc/SHAMapStoreImp.h
+++ b/src/xrpld/app/misc/SHAMapStoreImp.h
@@ -7,6 +7,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 
 #include 
 #include 
@@ -66,7 +68,7 @@ private:
     NodeStore::Scheduler& scheduler_;
     beast::Journal const journal_;
     NodeStore::DatabaseRotating* dbRotating_ = nullptr;
-    SavedStateDB state_db_;
+    SavedStateDB stateDb_;
     std::thread thread_;
     bool stop_ = false;
     bool healthy_ = true;
@@ -93,6 +95,8 @@ private:
     // as of run() or before
     NetworkOPs* netOPs_ = nullptr;
     LedgerMaster* ledgerMaster_ = nullptr;
+    FullBelowCache* fullBelowCache_ = nullptr;
+    TreeNodeCache* treeNodeCache_ = nullptr;
 
     static constexpr auto kNodeStoreName = "NodeStore";
 
@@ -113,7 +117,7 @@ public:
     {
         if (advisoryDelete_)
             canDelete_ = seq;
-        return state_db_.setCanDelete(seq);
+        return stateDb_.setCanDelete(seq);
     }
 
     bool
@@ -127,7 +131,7 @@ public:
     LedgerIndex
     getLastRotated() override
     {
-        return state_db_.getState().lastRotated;
+        return stateDb_.getState().lastRotated;
     }
 
     // All ledgers before and including this are unprotected
diff --git a/src/xrpld/app/misc/TxQ.h b/src/xrpld/app/misc/TxQ.h
index ad689abed4..d3caec55cf 100644
--- a/src/xrpld/app/misc/TxQ.h
+++ b/src/xrpld/app/misc/TxQ.h
@@ -288,7 +288,7 @@ public:
 
     /** Return the next sequence that would go in the TxQ for an account. */
     SeqProxy
-    nextQueuableSeq(std::shared_ptr const& sleAccount) const;
+    nextQueuableSeq(SLE::const_ref sleAccount) const;
 
     /** Returns fee metrics in reference fee level units.
      */
@@ -342,9 +342,7 @@ public:
 private:
     // Implementation for nextQueuableSeq().  The passed lock must be held.
     SeqProxy
-    nextQueuableSeqImpl(
-        std::shared_ptr const& sleAccount,
-        std::scoped_lock const&) const;
+    nextQueuableSeqImpl(SLE::const_ref sleAccount, std::scoped_lock const&) const;
 
     /**
         Track and use the fee escalation metrics of the
@@ -782,7 +780,7 @@ private:
         STTx const&,
         ApplyFlags const,
         OpenView const&,
-        std::shared_ptr const& sleAccount,
+        SLE::const_ref sleAccount,
         AccountMap::iterator const&,
         std::optional const&,
         std::scoped_lock const& lock);
diff --git a/src/xrpld/app/misc/ValidatorList.h b/src/xrpld/app/misc/ValidatorList.h
index dcd7a24499..3f1823f930 100644
--- a/src/xrpld/app/misc/ValidatorList.h
+++ b/src/xrpld/app/misc/ValidatorList.h
@@ -233,8 +233,8 @@ class ValidatorList
     std::optional localPubKey_;
 
     // The below variable contains the Publisher list specified in the local
-    // config file under the title of SECTION_VALIDATORS or [validators].
-    // This list is not associated with the masterKey of any publisher.
+    // config file under the title of [validators]. This list is not associated
+    // with the masterKey of any publisher.
 
     // Apropos PublisherListCollection fields, localPublisherList does not
     // have any "remaining" manifests. It is assumed to be perennially
diff --git a/src/xrpld/app/misc/ValidatorSite.h b/src/xrpld/app/misc/ValidatorSite.h
index c4d2545217..55f060baf1 100644
--- a/src/xrpld/app/misc/ValidatorSite.h
+++ b/src/xrpld/app/misc/ValidatorSite.h
@@ -96,10 +96,10 @@ private:
     Application& app_;
     beast::Journal const j_;
 
-    // If both mutex are to be locked at the same time, `sites_mutex_` must be
-    // locked before `state_mutex_` or we may deadlock.
-    std::mutex mutable sites_mutex_;
-    std::mutex mutable state_mutex_;
+    // If both mutex are to be locked at the same time, `sitesMutex_` must be
+    // locked before `stateMutex_` or we may deadlock.
+    std::mutex mutable sitesMutex_;
+    std::mutex mutable stateMutex_;
 
     std::condition_variable cv_;
     std::weak_ptr work_;
diff --git a/src/xrpld/app/misc/detail/AmendmentTable.cpp b/src/xrpld/app/misc/detail/AmendmentTable.cpp
index 65771f6aa3..4f331b0781 100644
--- a/src/xrpld/app/misc/detail/AmendmentTable.cpp
+++ b/src/xrpld/app/misc/detail/AmendmentTable.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -8,6 +7,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
diff --git a/src/xrpld/app/misc/detail/Transaction.cpp b/src/xrpld/app/misc/detail/Transaction.cpp
index 425a5723fb..2c55c474eb 100644
--- a/src/xrpld/app/misc/detail/Transaction.cpp
+++ b/src/xrpld/app/misc/detail/Transaction.cpp
@@ -73,7 +73,7 @@ Transaction::setStatus(
 TransStatus
 Transaction::sqlTransactionStatus(boost::optional const& status)
 {
-    auto const c = (status) ? safeCast((*status)[0]) : TxnSql::Unknown;
+    auto const c = status ? safeCast((*status)[0]) : TxnSql::Unknown;
 
     switch (static_cast(c))
     {
diff --git a/src/xrpld/app/misc/detail/TxQ.cpp b/src/xrpld/app/misc/detail/TxQ.cpp
index 0f70f17046..ec5ac569d6 100644
--- a/src/xrpld/app/misc/detail/TxQ.cpp
+++ b/src/xrpld/app/misc/detail/TxQ.cpp
@@ -3,20 +3,19 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
-#include 
-#include 
 #include 
 #include 
 #include 
@@ -150,7 +149,7 @@ TxQ::FeeMetrics::update(
             // current size limit, use a limit that is
             // 90% of the way from max_element to the
             // current size limit.
-            return (txnsExpected_ * 9 + *iter) / 10;
+            return ((txnsExpected_ * 9) + *iter) / 10;
         }();
         // Ledgers are processing in a timely manner,
         // so keep the limit high, but don't let it
@@ -218,7 +217,7 @@ sumOfFirstSquares(std::size_t xIn)
     // in a ledger, this is the least of our problems.
     if (x >= (1 << 21))
         return {false, std::numeric_limits::max()};
-    return {true, (x * (x + 1) * (2 * x + 1)) / 6};
+    return {true, (x * (x + 1) * ((2 * x) + 1)) / 6};
 }
 
 // Unit tests for sumOfSquares()
@@ -305,7 +304,6 @@ TxQ::MaybeTx::apply(Application& app, OpenView& view, beast::Journal j)
 {
     // If the rules or flags change, preflight again
     XRPL_ASSERT(pfResult, "xrpl::TxQ::MaybeTx::apply : preflight result is set");
-    NumberSO const stNumberSO{view.rules().enabled(fixUniversalNumber)};
 
     // NOLINTBEGIN(bugprone-unchecked-optional-access) assert above
     if (pfResult->rules != view.rules() || pfResult->flags != flags)
@@ -387,7 +385,7 @@ TxQ::canBeHeld(
     STTx const& tx,
     ApplyFlags const flags,
     OpenView const& view,
-    std::shared_ptr const& sleAccount,
+    SLE::const_ref sleAccount,
     AccountMap::iterator const& accountIter,
     std::optional const& replacementIter,
     std::scoped_lock const& lock)
@@ -400,6 +398,10 @@ TxQ::canBeHeld(
         ((flags & TapFailHard) != 0u))
         return telCAN_NOT_QUEUE;
 
+    // Disallow delegated transactions from being queued.
+    if (tx.isFieldPresent(sfDelegate))
+        return telCAN_NOT_QUEUE;
+
     {
         // To be queued and relayed, the transaction needs to
         // promise to stick around for long enough that it has
@@ -730,8 +732,6 @@ TxQ::apply(
     ApplyFlags flags,
     beast::Journal j)
 {
-    NumberSO const stNumberSO{view.rules().enabled(fixUniversalNumber)};
-
     // See if the transaction is valid, properly formed,
     // etc. before doing potentially expensive queue
     // replace and multi-transaction operations.
@@ -764,7 +764,7 @@ TxQ::apply(
     // If the transaction needs a Ticket is that Ticket in the ledger?
     SeqProxy const acctSeqProx = SeqProxy::sequence((*sleAccount)[sfSequence]);
     SeqProxy const txSeqProx = tx->getSeqProxy();
-    if (txSeqProx.isTicket() && !view.exists(keylet::kTicket(account, txSeqProx)))
+    if (txSeqProx.isTicket() && !view.exists(keylet::ticket(account, txSeqProx)))
     {
         if (txSeqProx.value() < acctSeqProx.value())
         {
@@ -1576,7 +1576,7 @@ TxQ::accept(Application& app, OpenView& view)
 //
 // Acquires a lock and calls the implementation.
 SeqProxy
-TxQ::nextQueuableSeq(std::shared_ptr const& sleAccount) const
+TxQ::nextQueuableSeq(SLE::const_ref sleAccount) const
 {
     std::scoped_lock const lock(mutex_);
     return nextQueuableSeqImpl(sleAccount, lock);
@@ -1589,9 +1589,7 @@ TxQ::nextQueuableSeq(std::shared_ptr const& sleAccount) const
 // sequence number, that is not used by a transaction in the queue, must
 // be found and returned.
 SeqProxy
-TxQ::nextQueuableSeqImpl(
-    std::shared_ptr const& sleAccount,
-    std::scoped_lock const&) const
+TxQ::nextQueuableSeqImpl(SLE::const_ref sleAccount, std::scoped_lock const&) const
 {
     // If the account is not in the ledger or a non-account was passed
     // then return zero.  We have no idea.
@@ -1874,16 +1872,16 @@ TxQ::Setup
 setupTxQ(Config const& config)
 {
     TxQ::Setup setup;
-    auto const& section = config.section("transaction_queue");
-    set(setup.ledgersInQueue, "ledgers_in_queue", section);
-    set(setup.queueSizeMin, "minimum_queue_size", section);
-    set(setup.retrySequencePercent, "retry_sequence_percent", section);
-    set(setup.minimumEscalationMultiplier, "minimum_escalation_multiplier", section);
-    set(setup.minimumTxnInLedger, "minimum_txn_in_ledger", section);
-    set(setup.minimumTxnInLedgerSA, "minimum_txn_in_ledger_standalone", section);
-    set(setup.targetTxnInLedger, "target_txn_in_ledger", section);
+    auto const& section = config.section(Sections::kTransactionQueue);
+    set(setup.ledgersInQueue, Keys::kLedgersInQueue, section);
+    set(setup.queueSizeMin, Keys::kMinimumQueueSize, section);
+    set(setup.retrySequencePercent, Keys::kRetrySequencePercent, section);
+    set(setup.minimumEscalationMultiplier, Keys::kMinimumEscalationMultiplier, section);
+    set(setup.minimumTxnInLedger, Keys::kMinimumTxnInLedger, section);
+    set(setup.minimumTxnInLedgerSA, Keys::kMinimumTxnInLedgerStandalone, section);
+    set(setup.targetTxnInLedger, Keys::kTargetTxnInLedger, section);
     std::uint32_t max = 0;
-    if (set(max, "maximum_txn_in_ledger", section))
+    if (set(max, Keys::kMaximumTxnInLedger, section))
     {
         if (max < setup.minimumTxnInLedger)
         {
@@ -1911,7 +1909,7 @@ setupTxQ(Config const& config)
        moot. (There are other ways to do that, including
        minimum_txn_in_ledger_.)
     */
-    set(setup.normalConsensusIncreasePercent, "normal_consensus_increase_percent", section);
+    set(setup.normalConsensusIncreasePercent, Keys::kNormalConsensusIncreasePercent, section);
     setup.normalConsensusIncreasePercent =
         std::clamp(setup.normalConsensusIncreasePercent, 0u, 1000u);
 
@@ -1919,11 +1917,11 @@ setupTxQ(Config const& config)
        are nonsensical (uint overflows happen, so the limit grows
        instead of shrinking). 0 is not recommended.
     */
-    set(setup.slowConsensusDecreasePercent, "slow_consensus_decrease_percent", section);
+    set(setup.slowConsensusDecreasePercent, Keys::kSlowConsensusDecreasePercent, section);
     setup.slowConsensusDecreasePercent = std::clamp(setup.slowConsensusDecreasePercent, 0u, 100u);
 
-    set(setup.maximumTxnPerAccount, "maximum_txn_per_account", section);
-    set(setup.minimumLastLedgerBuffer, "minimum_last_ledger_buffer", section);
+    set(setup.maximumTxnPerAccount, Keys::kMaximumTxnPerAccount, section);
+    set(setup.minimumLastLedgerBuffer, Keys::kMinimumLastLedgerBuffer, section);
 
     setup.standAlone = config.standalone();
     return setup;
diff --git a/src/xrpld/app/misc/detail/ValidatorKeys.cpp b/src/xrpld/app/misc/detail/ValidatorKeys.cpp
index 0380e2e294..e439eca936 100644
--- a/src/xrpld/app/misc/detail/ValidatorKeys.cpp
+++ b/src/xrpld/app/misc/detail/ValidatorKeys.cpp
@@ -1,11 +1,11 @@
 #include 
 
 #include 
-#include 
 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -17,18 +17,18 @@
 namespace xrpl {
 ValidatorKeys::ValidatorKeys(Config const& config, beast::Journal j)
 {
-    if (config.exists(SECTION_VALIDATOR_TOKEN) && config.exists(SECTION_VALIDATION_SEED))
+    if (config.exists(Sections::kValidatorToken) && config.exists(Sections::kValidationSeed))
     {
         configInvalid_ = true;
-        JLOG(j.fatal()) << "Cannot specify both [" SECTION_VALIDATION_SEED
-                           "] and [" SECTION_VALIDATOR_TOKEN "]";
+        JLOG(j.fatal()) << "Cannot specify both [" << Sections::kValidationSeed << "] and ["
+                        << Sections::kValidatorToken << "]";
         return;
     }
 
-    if (config.exists(SECTION_VALIDATOR_TOKEN))
+    if (config.exists(Sections::kValidatorToken))
     {
         // token is non-const so it can be moved from
-        if (auto token = loadValidatorToken(config.section(SECTION_VALIDATOR_TOKEN).lines()))
+        if (auto token = loadValidatorToken(config.section(Sections::kValidatorToken).lines()))
         {
             auto const pk = derivePublicKey(KeyType::Secp256k1, token->validationSecret);
             auto const m = deserializeManifest(base64Decode(token->manifest));
@@ -36,7 +36,8 @@ ValidatorKeys::ValidatorKeys(Config const& config, beast::Journal j)
             if (!m || pk != m->signingKey)
             {
                 configInvalid_ = true;
-                JLOG(j.fatal()) << "Invalid token specified in [" SECTION_VALIDATOR_TOKEN "]";
+                JLOG(j.fatal()) << "Invalid token specified in [" << Sections::kValidatorToken
+                                << "]";
             }
             else
             {
@@ -49,17 +50,17 @@ ValidatorKeys::ValidatorKeys(Config const& config, beast::Journal j)
         else
         {
             configInvalid_ = true;
-            JLOG(j.fatal()) << "Invalid token specified in [" SECTION_VALIDATOR_TOKEN "]";
+            JLOG(j.fatal()) << "Invalid token specified in [" << Sections::kValidatorToken << "]";
         }
     }
-    else if (config.exists(SECTION_VALIDATION_SEED))
+    else if (config.exists(Sections::kValidationSeed))
     {
         auto const seed =
-            parseBase58(config.section(SECTION_VALIDATION_SEED).lines().front());
+            parseBase58(config.section(Sections::kValidationSeed).lines().front());
         if (!seed)
         {
             configInvalid_ = true;
-            JLOG(j.fatal()) << "Invalid seed specified in [" SECTION_VALIDATION_SEED "]";
+            JLOG(j.fatal()) << "Invalid seed specified in [" << Sections::kValidationSeed << "]";
         }
         else
         {
diff --git a/src/xrpld/app/misc/detail/ValidatorList.cpp b/src/xrpld/app/misc/detail/ValidatorList.cpp
index 57b65814e1..a9e7156158 100644
--- a/src/xrpld/app/misc/detail/ValidatorList.cpp
+++ b/src/xrpld/app/misc/detail/ValidatorList.cpp
@@ -455,7 +455,7 @@ ValidatorList::parseBlobs(std::uint32_t version, json::Value const& body)
 std::vector
 ValidatorList::parseBlobs(protocol::TMValidatorList const& body)
 {
-    return {{body.blob(), body.signature(), {}}};
+    return {{.blob = body.blob(), .signature = body.signature(), .manifest = {}}};
 }
 
 // static
@@ -1777,7 +1777,7 @@ ValidatorList::getAvailable(
 {
     std::shared_lock const readLock{mutex_};
 
-    auto const keyBlob = strViewUnHex(pubKey);
+    auto const keyBlob = strUnHex(pubKey);
 
     if (!keyBlob || !publicKeyType(makeSlice(*keyBlob)))
     {
diff --git a/src/xrpld/app/misc/detail/ValidatorSite.cpp b/src/xrpld/app/misc/detail/ValidatorSite.cpp
index 29ad5326b0..0f83fb7d3a 100644
--- a/src/xrpld/app/misc/detail/ValidatorSite.cpp
+++ b/src/xrpld/app/misc/detail/ValidatorSite.cpp
@@ -114,7 +114,7 @@ ValidatorSite::ValidatorSite(
 
 ValidatorSite::~ValidatorSite()
 {
-    std::unique_lock lock{state_mutex_};
+    std::unique_lock lock{stateMutex_};
     if (timer_.expiry() > clock_type::time_point{})
     {
         if (!stopping_)
@@ -141,7 +141,7 @@ ValidatorSite::load(std::vector const& siteURIs)
 {
     JLOG(j_.debug()) << "Loading configured validator list sites";
 
-    std::scoped_lock const lock{sites_mutex_};
+    std::scoped_lock const lock{sitesMutex_};
 
     return load(siteURIs, lock);
 }
@@ -182,8 +182,8 @@ ValidatorSite::load(
 void
 ValidatorSite::start()
 {
-    std::scoped_lock const l0{sites_mutex_};
-    std::scoped_lock const l1{state_mutex_};
+    std::scoped_lock const l0{sitesMutex_};
+    std::scoped_lock const l1{stateMutex_};
     if (timer_.expiry() == clock_type::time_point{})
         setTimer(l0, l1);
 }
@@ -191,14 +191,14 @@ ValidatorSite::start()
 void
 ValidatorSite::join()
 {
-    std::unique_lock lock{state_mutex_};
+    std::unique_lock lock{stateMutex_};
     cv_.wait(lock, [&] { return !pending_; });
 }
 
 void
 ValidatorSite::stop()
 {
-    std::unique_lock lock{state_mutex_};
+    std::unique_lock lock{stateMutex_};
     stopping_ = true;
     // work::cancel() must be called before the
     // cv wait in order to kick any asio async operations
@@ -260,7 +260,7 @@ ValidatorSite::makeRequest(
     sites_[siteIdx].activeResource = resource;
     std::shared_ptr sp;
     auto timeoutCancel = [this]() {
-        std::scoped_lock const lockState{state_mutex_};
+        std::scoped_lock const lockState{stateMutex_};
         // docs indicate cancel_one() can throw, but this
         // should be reconsidered if it changes to noexcept
         try
@@ -326,7 +326,7 @@ ValidatorSite::makeRequest(
     sp->run();
     // start a timer for the request, which shouldn't take more
     // than requestTimeout_ to complete
-    std::scoped_lock const lockState{state_mutex_};
+    std::scoped_lock const lockState{stateMutex_};
     timer_.expires_after(requestTimeout_);
     timer_.async_wait([this, siteIdx](boost::system::error_code const& ec) {
         this->onRequestTimeout(siteIdx, ec);
@@ -340,7 +340,7 @@ ValidatorSite::onRequestTimeout(std::size_t siteIdx, error_code const& ec)
         return;
 
     {
-        std::scoped_lock const lockSite{sites_mutex_};
+        std::scoped_lock const lockSite{sitesMutex_};
         // In some circumstances, both this function and the response
         // handler (onSiteFetch or onTextFetch) can get queued and
         // processed. In all observed cases, the response handler
@@ -353,14 +353,16 @@ ValidatorSite::onRequestTimeout(std::size_t siteIdx, error_code const& ec)
             JLOG(j_.warn()) << "Request for " << site.activeResource->uri << " took too long";
         }
         else
+        {
             JLOG(j_.error()) << "Request took too long, but a response has "
                                 "already been processed";
+        }
         if (!site.lastRefreshStatus)
             site.lastRefreshStatus.emplace(
                 Site::Status{clock_type::now(), ListDisposition::invalid, "timeout"});
     }
 
-    std::scoped_lock const lockState{state_mutex_};
+    std::scoped_lock const lockState{stateMutex_};
     if (auto sp = work_.lock())
         sp->cancel();
 }
@@ -379,7 +381,7 @@ ValidatorSite::onTimer(std::size_t siteIdx, error_code const& ec)
 
     try
     {
-        std::scoped_lock const lock{sites_mutex_};
+        std::scoped_lock const lock{sitesMutex_};
         sites_[siteIdx].nextRefresh = clock_type::now() + sites_[siteIdx].refreshInterval;
         sites_[siteIdx].redirCount = 0;
         // the WorkSSL client ctor can throw if SSL init fails
@@ -562,7 +564,7 @@ ValidatorSite::onSiteFetch(
     detail::response_type const& res,
     std::size_t siteIdx)
 {
-    std::scoped_lock lockSites{sites_mutex_};
+    std::scoped_lock lockSites{sitesMutex_};
     {
         if (endpoint != endpoint_type{})
             sites_[siteIdx].lastRequestEndpoint = endpoint;
@@ -634,7 +636,7 @@ ValidatorSite::onSiteFetch(
         sites_[siteIdx].activeResource.reset();
     }
 
-    std::scoped_lock const lockState{state_mutex_};
+    std::scoped_lock const lockState{stateMutex_};
     fetching_ = false;
     if (!stopping_)
         setTimer(lockSites, lockState);
@@ -647,7 +649,7 @@ ValidatorSite::onTextFetch(
     std::string const& res,
     std::size_t siteIdx)
 {
-    std::scoped_lock const lockSites{sites_mutex_};
+    std::scoped_lock const lockSites{sitesMutex_};
     {
         try
         {
@@ -674,7 +676,7 @@ ValidatorSite::onTextFetch(
         sites_[siteIdx].activeResource.reset();
     }
 
-    std::scoped_lock const lockState{state_mutex_};
+    std::scoped_lock const lockState{stateMutex_};
     fetching_ = false;
     if (!stopping_)
         setTimer(lockSites, lockState);
@@ -690,7 +692,7 @@ ValidatorSite::getJson() const
     json::Value jrr(json::ValueType::Object);
     json::Value& jSites = (jrr[jss::validator_sites] = json::ValueType::Array);
     {
-        std::scoped_lock const lock{sites_mutex_};
+        std::scoped_lock const lock{sitesMutex_};
         for (Site const& site : sites_)
         {
             json::Value& v = jSites.append(json::ValueType::Object);
diff --git a/src/xrpld/app/misc/detail/WorkSSL.cpp b/src/xrpld/app/misc/detail/WorkSSL.cpp
index 0a8d53b1a2..e1b864e81f 100644
--- a/src/xrpld/app/misc/detail/WorkSSL.cpp
+++ b/src/xrpld/app/misc/detail/WorkSSL.cpp
@@ -30,9 +30,9 @@ WorkSSL::WorkSSL(
     callback_type cb)
     : WorkBase(host, path, port, ios, lastEndpoint, lastStatus, cb)
     , context_(
-          config.SSL_VERIFY_DIR,
-          config.SSL_VERIFY_FILE,
-          config.SSL_VERIFY,
+          config.sslVerifyDir,
+          config.sslVerifyFile,
+          config.sslVerify,
           j,
           boost::asio::ssl::context::tlsv12_client)
     , stream_(socket_, context_.context())
diff --git a/src/xrpld/app/misc/detail/setup_HashRouter.cpp b/src/xrpld/app/misc/detail/setup_HashRouter.cpp
index 9727f5d733..97aa501698 100644
--- a/src/xrpld/app/misc/detail/setup_HashRouter.cpp
+++ b/src/xrpld/app/misc/detail/setup_HashRouter.cpp
@@ -2,8 +2,9 @@
 
 #include 
 
-#include 
 #include 
+#include 
+#include 
 #include 
 
 #include 
@@ -18,11 +19,11 @@ setupHashRouter(Config const& config)
     using namespace std::chrono;
 
     HashRouter::Setup setup;
-    auto const& section = config.section("hashrouter");
+    auto const& section = config.section(Sections::kHashrouter);
 
     std::int32_t tmp{};
 
-    if (set(tmp, "hold_time", section))
+    if (set(tmp, Keys::kHoldTime, section))
     {
         if (tmp < 12)
         {
@@ -32,7 +33,7 @@ setupHashRouter(Config const& config)
         }
         setup.holdTime = seconds(tmp);
     }
-    if (set(tmp, "relay_time", section))
+    if (set(tmp, Keys::kRelayTime, section))
     {
         if (tmp < 8)
         {
diff --git a/src/xrpld/app/rdb/backend/detail/Node.cpp b/src/xrpld/app/rdb/backend/detail/Node.cpp
index 1fd3136420..dcc146397b 100644
--- a/src/xrpld/app/rdb/backend/detail/Node.cpp
+++ b/src/xrpld/app/rdb/backend/detail/Node.cpp
@@ -17,6 +17,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include   // IWYU pragma: keep
@@ -124,7 +125,7 @@ makeLedgerDBs(
             std::size_t notnull = 0, dfltValue = 0, pk = 0;
             soci::indicator ind = soci::i_null;
             soci::statement st =
-                (tx->getSession().prepare << ("PRAGMA table_info(AccountTransactions);"),
+                (tx->getSession().prepare << "PRAGMA table_info(AccountTransactions);",
                  soci::into(cid),
                  soci::into(name),
                  soci::into(type),
@@ -1064,10 +1065,10 @@ accountTxPage(
     if (findLedger == 0)
     {
         sql = boost::str(
-            boost::format(kPrefix + (R"(AccountTransactions.LedgerSeq BETWEEN %u AND %u
+            boost::format(kPrefix + R"(AccountTransactions.LedgerSeq BETWEEN %u AND %u
              ORDER BY AccountTransactions.LedgerSeq %s,
              AccountTransactions.TxnSeq %s
-             LIMIT %u;)")) %
+             LIMIT %u;)") %
             toBase58(options.account) % options.ledgerRange.min % options.ledgerRange.max % order %
             order % queryLimit);
     }
@@ -1079,7 +1080,7 @@ accountTxPage(
 
         auto b58acct = toBase58(options.account);
         sql = boost::str(
-            boost::format((
+            boost::format(
                 R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq,
             Status,RawTxn,TxnMeta
             FROM AccountTransactions, Transactions WHERE
@@ -1096,7 +1097,7 @@ accountTxPage(
             ORDER BY AccountTransactions.LedgerSeq %s,
             AccountTransactions.TxnSeq %s
             LIMIT %u;
-            )")) %
+            )") %
             b58acct % minLedger % maxLedger % b58acct % findLedger % compare % findSeq % order %
             order % queryLimit);
     }
@@ -1291,7 +1292,7 @@ bool
 dbHasSpace(soci::session& session, Config const& config, beast::Journal j)
 {
     boost::filesystem::space_info const space =
-        boost::filesystem::space(config.legacy("database_path"));
+        boost::filesystem::space(config.legacy(Sections::kDatabasePath));
 
     if (space.available < megabytes(512))
     {
diff --git a/src/xrpld/app/rdb/detail/PeerFinder.cpp b/src/xrpld/app/rdb/detail/PeerFinder.cpp
index 2481b63d2b..9452c3af99 100644
--- a/src/xrpld/app/rdb/detail/PeerFinder.cpp
+++ b/src/xrpld/app/rdb/detail/PeerFinder.cpp
@@ -2,11 +2,11 @@
 
 #include 
 
-#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include   // IWYU pragma: keep
diff --git a/src/xrpld/consensus/Consensus.cpp b/src/xrpld/consensus/Consensus.cpp
index 5498f7cf79..d529ab2e44 100644
--- a/src/xrpld/consensus/Consensus.cpp
+++ b/src/xrpld/consensus/Consensus.cpp
@@ -35,7 +35,7 @@ shouldCloseLedger(
                << ", timeSincePrevClose: " << timeSincePrevClose.count() << "ms"
                << ", openTime: " << openTime.count() << "ms"
                << ", idleInterval: " << idleInterval.count() << "ms"
-               << ", ledgerMIN_CLOSE: " << parms.ledgerMIN_CLOSE.count() << "ms"
+               << ", ledgerMIN_CLOSE: " << parms.ledgerMinClose.count() << "ms"
                << ". ";
     using namespace std::chrono_literals;
     if ((prevRoundTime < -1s) || (prevRoundTime > 10min) || (timeSincePrevClose > 10min))
@@ -67,7 +67,7 @@ shouldCloseLedger(
     }
 
     // Preserve minimum ledger open time
-    if (openTime < parms.ledgerMIN_CLOSE)
+    if (openTime < parms.ledgerMinClose)
     {
         JLOG(j.debug()) << "Must wait minimum time before closing";
         CLOG(clog) << "not closing because under ledgerMIN_CLOSE. ";
@@ -175,12 +175,12 @@ checkConsensus(
                << " agree=" << currentAgree << " validated=" << currentFinished
                << " time=" << currentAgreeTime.count() << "/" << previousAgreeTime.count()
                << " proposing? " << proposing
-               << " minimum duration to reach consensus: " << parms.ledgerMIN_CONSENSUS.count()
+               << " minimum duration to reach consensus: " << parms.ledgerMinConsensus.count()
                << "ms"
-               << " max consensus time " << parms.ledgerMAX_CONSENSUS.count() << "ms"
-               << " minimum consensus percentage: " << parms.minCONSENSUS_PCT << ". ";
+               << " max consensus time " << parms.ledgerMaxConsensus.count() << "ms"
+               << " minimum consensus percentage: " << parms.minConsensusPct << ". ";
 
-    if (currentAgreeTime <= parms.ledgerMIN_CONSENSUS)
+    if (currentAgreeTime <= parms.ledgerMinConsensus)
     {
         CLOG(clog) << "Not reached. ";
         return ConsensusState::No;
@@ -190,7 +190,7 @@ checkConsensus(
     {
         // Less than 3/4 of the last ledger's proposers are present; don't
         // rush: we may need more time.
-        if (currentAgreeTime < (previousAgreeTime + parms.ledgerMIN_CONSENSUS))
+        if (currentAgreeTime < (previousAgreeTime + parms.ledgerMinConsensus))
         {
             JLOG(j.trace()) << "too fast, not enough proposers";
             CLOG(clog) << "Too fast, not enough proposers. Not reached. ";
@@ -204,8 +204,8 @@ checkConsensus(
             currentAgree,
             currentProposers,
             proposing,
-            parms.minCONSENSUS_PCT,
-            currentAgreeTime > parms.ledgerMAX_CONSENSUS,
+            parms.minConsensusPct,
+            currentAgreeTime > parms.ledgerMaxConsensus,
             stalled,
             clog))
     {
@@ -221,8 +221,8 @@ checkConsensus(
             currentFinished,
             currentProposers,
             false,
-            parms.minCONSENSUS_PCT,
-            currentAgreeTime > parms.ledgerMAX_CONSENSUS,
+            parms.minConsensusPct,
+            currentAgreeTime > parms.ledgerMaxConsensus,
             false,
             clog))
     {
@@ -232,9 +232,9 @@ checkConsensus(
     }
 
     std::chrono::milliseconds const maxAgreeTime =
-        previousAgreeTime * parms.ledgerABANDON_CONSENSUS_FACTOR;
+        previousAgreeTime * parms.ledgerAbandonConsensusFactor;
     if (currentAgreeTime >
-        std::clamp(maxAgreeTime, parms.ledgerMAX_CONSENSUS, parms.ledgerABANDON_CONSENSUS))
+        std::clamp(maxAgreeTime, parms.ledgerMaxConsensus, parms.ledgerAbandonConsensus))
     {
         JLOG(j.warn()) << "consensus taken too long";
         CLOG(clog) << "Consensus taken too long. ";
diff --git a/src/xrpld/consensus/Consensus.h b/src/xrpld/consensus/Consensus.h
index 8b4e971440..b8d04e18b5 100644
--- a/src/xrpld/consensus/Consensus.h
+++ b/src/xrpld/consensus/Consensus.h
@@ -276,11 +276,11 @@ checkConsensus(
 template 
 class Consensus
 {
-    using Ledger_t = typename Adaptor::Ledger_t;
-    using TxSet_t = typename Adaptor::TxSet_t;
-    using NodeID_t = typename Adaptor::NodeID_t;
-    using Tx_t = typename TxSet_t::Tx;
-    using PeerPosition_t = typename Adaptor::PeerPosition_t;
+    using Ledger_t = Adaptor::Ledger_t;
+    using TxSet_t = Adaptor::TxSet_t;
+    using NodeID_t = Adaptor::NodeID_t;
+    using Tx_t = TxSet_t::Tx;
+    using PeerPosition_t = Adaptor::PeerPosition_t;
     using Proposal_t = ConsensusProposal;
 
     using Result = ConsensusResult;
@@ -341,7 +341,7 @@ public:
     void
     startRound(
         NetClock::time_point const& now,
-        typename Ledger_t::ID const& prevLedgerID,
+        Ledger_t::ID const& prevLedgerID,
         Ledger_t prevLedger,
         hash_set const& nowUntrusted,
         bool proposing,
@@ -402,7 +402,7 @@ public:
 
         @return ID of previous ledger
     */
-    typename Ledger_t::ID
+    Ledger_t::ID
     prevLedgerID() const
     {
         return prevLedgerID_;
@@ -428,16 +428,14 @@ private:
     void
     startRoundInternal(
         NetClock::time_point const& now,
-        typename Ledger_t::ID const& prevLedgerID,
+        Ledger_t::ID const& prevLedgerID,
         Ledger_t const& prevLedger,
         ConsensusMode mode,
         std::unique_ptr const& clog);
 
     // Change our view of the previous ledger
     void
-    handleWrongLedger(
-        typename Ledger_t::ID const& lgrId,
-        std::unique_ptr const& clog);
+    handleWrongLedger(Ledger_t::ID const& lgrId, std::unique_ptr const& clog);
 
     /** Check if our previous ledger matches the network's.
 
@@ -568,7 +566,7 @@ private:
     // Non-peer (self) consensus data
 
     // Last validated ledger ID provided to consensus
-    typename Ledger_t::ID prevLedgerID_;
+    Ledger_t::ID prevLedgerID_;
     // Last validated ledger seen by consensus
     Ledger_t previousLedger_;
 
@@ -616,7 +614,7 @@ template 
 void
 Consensus::startRound(
     NetClock::time_point const& now,
-    typename Ledger_t::ID const& prevLedgerID,
+    Ledger_t::ID const& prevLedgerID,
     Ledger_t prevLedger,
     hash_set const& nowUntrusted,
     bool proposing,
@@ -625,7 +623,7 @@ Consensus::startRound(
     if (firstRound_)
     {
         // take our initial view of closeTime_ from the seed ledger
-        prevRoundTime_ = adaptor_.parms().ledgerIDLE_INTERVAL;
+        prevRoundTime_ = adaptor_.parms().ledgerIdleInterval;
         prevCloseTime_ = prevLedger.closeTime();
         firstRound_ = false;
     }
@@ -661,7 +659,7 @@ template 
 void
 Consensus::startRoundInternal(
     NetClock::time_point const& now,
-    typename Ledger_t::ID const& prevLedgerID,
+    Ledger_t::ID const& prevLedgerID,
     Ledger_t const& prevLedger,
     ConsensusMode mode,
     std::unique_ptr const& clog)
@@ -811,7 +809,9 @@ Consensus::peerProposalInternal(
                 gotTxSet(now_, *set);
             }
             else
+            {
                 JLOG(j_.debug()) << "Don't have tx set for peer";
+            }
         }
         else if (result_)
         {
@@ -1025,7 +1025,7 @@ Consensus::getJson(bool full) const
 template 
 void
 Consensus::handleWrongLedger(
-    typename Ledger_t::ID const& lgrId,
+    Ledger_t::ID const& lgrId,
     std::unique_ptr const& clog)
 {
     CLOG(clog) << "handleWrongLedger. ";
@@ -1169,9 +1169,9 @@ Consensus::phaseOpen(std::unique_ptr const& clog)
     }
 
     auto const idleInterval = std::max(
-        adaptor_.parms().ledgerIDLE_INTERVAL, 2 * previousLedger_.closeTimeResolution());
+        adaptor_.parms().ledgerIdleInterval, 2 * previousLedger_.closeTimeResolution());
     CLOG(clog) << "idle interval set to " << idleInterval.count() << "ms based on "
-               << "ledgerIDLE_INTERVAL: " << adaptor_.parms().ledgerIDLE_INTERVAL.count()
+               << "ledgerIDLE_INTERVAL: " << adaptor_.parms().ledgerIdleInterval.count()
                << ", previous ledger close time resolution: "
                << previousLedger_.closeTimeResolution().count() << "ms. ";
 
@@ -1218,7 +1218,7 @@ Consensus::shouldPause(std::unique_ptr const& clog)
          << "roundTime: " << result_->roundTime.read().count()
          << ", "
          // NOLINTEND(bugprone-unchecked-optional-access)
-         << "max consensus time: " << parms.ledgerMAX_CONSENSUS.count() << ", "
+         << "max consensus time: " << parms.ledgerMaxConsensus.count() << ", "
          << "validators: " << totalValidators << ", "
          << "laggards: " << laggards << ", "
          << "offline: " << offline << ", "
@@ -1227,7 +1227,7 @@ Consensus::shouldPause(std::unique_ptr const& clog)
     if ((ahead == 0u) || (laggards == 0u) || (totalValidators == 0u) || !adaptor_.validator() ||
         !adaptor_.haveValidated() ||
         // NOLINTNEXTLINE(bugprone-unchecked-optional-access) result_ set as shouldPause called
-        result_->roundTime.read() > parms.ledgerMAX_CONSENSUS)
+        result_->roundTime.read() > parms.ledgerMaxConsensus)
     {
         j_.debug() << "not pausing (early)" << vars.str();
         CLOG(clog) << "Not pausing (early). ";
@@ -1337,17 +1337,17 @@ Consensus::phaseEstablish(std::unique_ptr const& clo
     result_->proposers = currPeerPositions_.size();
 
     convergePercent_ = result_->roundTime.read() * 100 /
-        std::max(prevRoundTime_, parms.avMIN_CONSENSUS_TIME);
+        std::max(prevRoundTime_, parms.avMinConsensusTime);
     CLOG(clog) << "convergePercent_ " << convergePercent_
                << " is based on round duration so far: " << result_->roundTime.read().count()
                << "ms, "
                << "previous round duration: " << prevRoundTime_.count() << "ms, "
-               << "avMIN_CONSENSUS_TIME: " << parms.avMIN_CONSENSUS_TIME.count() << "ms. ";
+               << "avMIN_CONSENSUS_TIME: " << parms.avMinConsensusTime.count() << "ms. ";
 
     // Give everyone a chance to take an initial position
-    if (result_->roundTime.read() < parms.ledgerMIN_CONSENSUS)
+    if (result_->roundTime.read() < parms.ledgerMinConsensus)
     {
-        CLOG(clog) << "ledgerMIN_CONSENSUS not reached: " << parms.ledgerMIN_CONSENSUS.count()
+        CLOG(clog) << "ledgerMIN_CONSENSUS not reached: " << parms.ledgerMinConsensus.count()
                    << "ms. ";
         return;
     }
@@ -1542,7 +1542,7 @@ Consensus::updateOurPositions(std::unique_ptr const&
         int threshVote = participantsNeeded(participants, neededWeight);
 
         // Threshold to declare consensus
-        int const threshConsensus = participantsNeeded(participants, parms.avCT_CONSENSUS_PCT);
+        int const threshConsensus = participantsNeeded(participants, parms.avCtConsensusPct);
 
         std::stringstream ss;
         ss << "Proposers:" << currPeerPositions_.size() << " nw:" << neededWeight
@@ -1696,7 +1696,7 @@ Consensus::haveConsensus(std::unique_ptr const& clog
     // Consensus has taken far too long. Drop out of the round.
     if (result_->state == ConsensusState::Expired)
     {
-        static auto const kMinimumCounter = parms.avalancheCutoffs.size() * parms.avMIN_ROUNDS;
+        static auto const kMinimumCounter = parms.avalancheCutoffs.size() * parms.avMinRounds;
         std::stringstream ss;
         if (establishCounter_ < kMinimumCounter)
         {
diff --git a/src/xrpld/consensus/ConsensusParms.h b/src/xrpld/consensus/ConsensusParms.h
index 88a6318b3c..e6dd7f046e 100644
--- a/src/xrpld/consensus/ConsensusParms.h
+++ b/src/xrpld/consensus/ConsensusParms.h
@@ -28,7 +28,7 @@ struct ConsensusParms
         This is a safety to protect against very old validations and the time
         it takes to adjust the close time accuracy window.
     */
-    std::chrono::seconds const validationVALID_WALL = std::chrono::minutes{5};
+    std::chrono::seconds const validationValidWall = std::chrono::minutes{5};
 
     /** Duration a validation remains current after first observed.
 
@@ -36,14 +36,14 @@ struct ConsensusParms
        first saw it. This provides faster recovery in very rare cases where the
        number of validations produced by the network is lower than normal
     */
-    std::chrono::seconds const validationVALID_LOCAL = std::chrono::minutes{3};
+    std::chrono::seconds const validationValidLocal = std::chrono::minutes{3};
 
     /**  Duration pre-close in which validations are acceptable.
 
         The number of seconds before a close time that we consider a validation
         acceptable. This protects against extreme clock errors
     */
-    std::chrono::seconds const validationVALID_EARLY = std::chrono::minutes{3};
+    std::chrono::seconds const validationValidEarly = std::chrono::minutes{3};
 
     //! How long we consider a proposal fresh
     std::chrono::seconds const proposeFRESHNESS = std::chrono::seconds{20};
@@ -56,13 +56,13 @@ struct ConsensusParms
     // millisecond resolution.
 
     //! The percentage threshold above which we can declare consensus.
-    std::size_t const minCONSENSUS_PCT = 80;
+    std::size_t const minConsensusPct = 80;
 
     //! The duration a ledger may remain idle before closing
-    std::chrono::milliseconds const ledgerIDLE_INTERVAL = std::chrono::seconds{15};
+    std::chrono::milliseconds const ledgerIdleInterval = std::chrono::seconds{15};
 
     //! The number of seconds we wait minimum to ensure participation
-    std::chrono::milliseconds const ledgerMIN_CONSENSUS = std::chrono::milliseconds{1950};
+    std::chrono::milliseconds const ledgerMinConsensus = std::chrono::milliseconds{1950};
 
     /** The maximum amount of time to spend pausing for laggards.
      *
@@ -70,16 +70,16 @@ struct ConsensusParms
      *  validators don't appear to be offline that are merely waiting for
      *  laggards.
      */
-    std::chrono::milliseconds const ledgerMAX_CONSENSUS = std::chrono::seconds{15};
+    std::chrono::milliseconds const ledgerMaxConsensus = std::chrono::seconds{15};
 
     //! Minimum number of seconds to wait to ensure others have computed the LCL
-    std::chrono::milliseconds const ledgerMIN_CLOSE = std::chrono::seconds{2};
+    std::chrono::milliseconds const ledgerMinClose = std::chrono::seconds{2};
 
     //! How often we check state or change positions
     std::chrono::milliseconds const ledgerGRANULARITY = std::chrono::seconds{1};
 
     //! How long to wait before completely abandoning consensus
-    std::size_t const ledgerABANDON_CONSENSUS_FACTOR = 10;
+    std::size_t const ledgerAbandonConsensusFactor = 10;
 
     /**
      * Maximum amount of time to give a consensus round
@@ -87,7 +87,7 @@ struct ConsensusParms
      * Does not include the time to build the LCL, so there is no reason for a
      * round to go this long, regardless of how big the ledger is.
      */
-    std::chrono::milliseconds const ledgerABANDON_CONSENSUS = std::chrono::seconds{120};
+    std::chrono::milliseconds const ledgerAbandonConsensus = std::chrono::seconds{120};
 
     /** The minimum amount of time to consider the previous round
         to have taken.
@@ -99,7 +99,7 @@ struct ConsensusParms
         twice the interval between proposals (0.7s) divided by
         the interval between mid and late consensus ([85-50]/100).
     */
-    std::chrono::milliseconds const avMIN_CONSENSUS_TIME = std::chrono::seconds{5};
+    std::chrono::milliseconds const avMinConsensusTime = std::chrono::seconds{5};
 
     //------------------------------------------------------------------------------
     // Avalanche tuning
@@ -136,16 +136,16 @@ struct ConsensusParms
     };
 
     //! Percentage of nodes required to reach agreement on ledger close time
-    std::size_t const avCT_CONSENSUS_PCT = 75;
+    std::size_t const avCtConsensusPct = 75;
 
     //! Number of rounds before certain actions can happen.
     // (Moving to the next avalanche level, considering that votes are stalled
     // without consensus.)
-    std::size_t const avMIN_ROUNDS = 2;
+    std::size_t const avMinRounds = 2;
 
     //! Number of rounds before a stuck vote is considered unlikely to change
     //! because voting stalled
-    std::size_t const avSTALLED_ROUNDS = 4;
+    std::size_t const avStalledRounds = 4;
 };
 
 inline std::pair>
diff --git a/src/xrpld/consensus/ConsensusTypes.h b/src/xrpld/consensus/ConsensusTypes.h
index 64a7f5fdea..f043fc0663 100644
--- a/src/xrpld/consensus/ConsensusTypes.h
+++ b/src/xrpld/consensus/ConsensusTypes.h
@@ -183,11 +183,11 @@ enum class ConsensusState {
 template 
 struct ConsensusResult
 {
-    using Ledger_t = typename Traits::Ledger_t;
-    using TxSet_t = typename Traits::TxSet_t;
-    using NodeID_t = typename Traits::NodeID_t;
+    using Ledger_t = Traits::Ledger_t;
+    using TxSet_t = Traits::TxSet_t;
+    using NodeID_t = Traits::NodeID_t;
 
-    using Tx_t = typename TxSet_t::Tx;
+    using Tx_t = TxSet_t::Tx;
     using Proposal_t = ConsensusProposal;
     using Dispute_t = DisputedTx;
 
diff --git a/src/xrpld/consensus/DisputedTx.h b/src/xrpld/consensus/DisputedTx.h
index e888b95ed6..ba8329714b 100644
--- a/src/xrpld/consensus/DisputedTx.h
+++ b/src/xrpld/consensus/DisputedTx.h
@@ -29,7 +29,7 @@ namespace xrpl {
 template 
 class DisputedTx
 {
-    using TxID_t = typename Tx::ID;
+    using TxID_t = Tx::ID;
     using Map_t = boost::container::flat_map;
 
 public:
@@ -79,20 +79,20 @@ public:
         // enough, so there's room for change. Check the times in case the state
         // machine is altered to allow states to loop.
         if (nextCutoff.consensusTime > currentCutoff.consensusTime ||
-            avalancheCounter_ < p.avMIN_ROUNDS)
+            avalancheCounter_ < p.avMinRounds)
             return false;
 
         // We've haven't had this vote for minimum rounds yet. Things could
         // change.
-        if (proposing && currentVoteCounter_ < p.avMIN_ROUNDS)
+        if (proposing && currentVoteCounter_ < p.avMinRounds)
             return false;
 
         // If we or any peers have changed a vote in several rounds, then
         // things could still change. But if _either_ has not changed in that
         // long, we're unlikely to change our vote any time soon. (This prevents
         // a malicious peer from flip-flopping a vote to prevent consensus.)
-        if (peersUnchanged < p.avSTALLED_ROUNDS &&
-            (proposing && currentVoteCounter_ < p.avSTALLED_ROUNDS))
+        if (peersUnchanged < p.avStalledRounds &&
+            (proposing && currentVoteCounter_ < p.avStalledRounds))
             return false;
 
         // Does this transaction have more than 80% agreement
@@ -108,7 +108,7 @@ public:
         int const weight = support / total;
         // Returns true if the tx has more than minCONSENSUS_PCT (80) percent
         // agreement. Either voting for _or_ voting against the tx.
-        bool const stalled = weight > p.minCONSENSUS_PCT || weight < (100 - p.minCONSENSUS_PCT);
+        bool const stalled = weight > p.minConsensusPct || weight < (100 - p.minConsensusPct);
 
         if (stalled)
         {
@@ -276,7 +276,7 @@ DisputedTx::updateVote(int percentTime, bool proposing, ConsensusPar
     // Proposing or not, we need to keep track of which state we've reached so
     // we can determine if the vote has stalled.
     auto const [requiredPct, newState] =
-        getNeededWeight(p, avalancheState_, percentTime, ++avalancheCounter_, p.avMIN_ROUNDS);
+        getNeededWeight(p, avalancheState_, percentTime, ++avalancheCounter_, p.avMinRounds);
     if (newState)
     {
         avalancheState_ = *newState;
@@ -286,7 +286,7 @@ DisputedTx::updateVote(int percentTime, bool proposing, ConsensusPar
     if (proposing)  // give ourselves full weight
     {
         // This is basically the percentage of nodes voting 'yes' (including us)
-        weight = (yays_ * 100 + (ourVote_ ? 100 : 0)) / (nays_ + yays_ + 1);
+        weight = ((yays_ * 100) + (ourVote_ ? 100 : 0)) / (nays_ + yays_ + 1);
 
         newPosition = weight > requiredPct;
     }
diff --git a/src/xrpld/consensus/LedgerTrie.h b/src/xrpld/consensus/LedgerTrie.h
index 9d76c7f283..b11a69a641 100644
--- a/src/xrpld/consensus/LedgerTrie.h
+++ b/src/xrpld/consensus/LedgerTrie.h
@@ -21,8 +21,8 @@ template 
 class SpanTip
 {
 public:
-    using Seq = typename Ledger::Seq;
-    using ID = typename Ledger::ID;
+    using Seq = Ledger::Seq;
+    using ID = Ledger::ID;
 
     SpanTip(Seq s, ID i, Ledger const lgr) : seq{s}, id{i}, ledger_{std::move(lgr)}
     {
@@ -58,8 +58,8 @@ namespace ledger_trie_detail {
 template 
 class Span
 {
-    using Seq = typename Ledger::Seq;
-    using ID = typename Ledger::ID;
+    using Seq = Ledger::Seq;
+    using ID = Ledger::ID;
 
     // The span is the half-open interval [start,end) of ledger_
     Seq start_{0};
@@ -204,10 +204,8 @@ struct Node
     void
     erase(Node const* child)
     {
-        auto it = std::find_if(
-            children.begin(), children.end(), [child](std::unique_ptr const& curr) {
-                return curr.get() == child;
-            });
+        auto it = std::ranges::find_if(
+            children, [child](std::unique_ptr const& curr) { return curr.get() == child; });
         XRPL_ASSERT(it != children.end(), "xrpl::Node::erase : valid input");
         std::swap(*it, children.back());
         children.pop_back();
@@ -323,8 +321,8 @@ struct Node
 template 
 class LedgerTrie
 {
-    using Seq = typename Ledger::Seq;
-    using ID = typename Ledger::ID;
+    using Seq = Ledger::Seq;
+    using ID = Ledger::ID;
 
     using Node = ledger_trie_detail::Node;
     using Span = ledger_trie_detail::Span;
diff --git a/src/xrpld/consensus/Validations.h b/src/xrpld/consensus/Validations.h
index fd32b52518..d4da8a2887 100644
--- a/src/xrpld/consensus/Validations.h
+++ b/src/xrpld/consensus/Validations.h
@@ -33,7 +33,7 @@ struct ValidationParms
         This is a safety to protect against very old validations and the time
         it takes to adjust the close time accuracy window.
     */
-    std::chrono::seconds validationCURRENT_WALL = std::chrono::minutes{5};
+    std::chrono::seconds validationCurrentWall = std::chrono::minutes{5};
 
     /** Duration a validation remains current after first observed.
 
@@ -41,14 +41,14 @@ struct ValidationParms
         first saw it. This provides faster recovery in very rare cases where the
         number of validations produced by the network is lower than normal
     */
-    std::chrono::seconds validationCURRENT_LOCAL = std::chrono::minutes{3};
+    std::chrono::seconds validationCurrentLocal = std::chrono::minutes{3};
 
     /** Duration pre-close in which validations are acceptable.
 
         The number of seconds before a close time that we consider a validation
         acceptable. This protects against extreme clock errors
     */
-    std::chrono::seconds validationCURRENT_EARLY = std::chrono::minutes{3};
+    std::chrono::seconds validationCurrentEarly = std::chrono::minutes{3};
 
     /** Duration a set of validations for a given ledger hash remain valid
 
@@ -56,7 +56,7 @@ struct ValidationParms
         hash can expire.  This keeps validations for recent ledgers available
         for a reasonable interval.
     */
-    std::chrono::seconds validationSET_EXPIRES = std::chrono::minutes{10};
+    std::chrono::seconds validationSetExpires = std::chrono::minutes{10};
 
     /** How long we consider a validation fresh.
      *
@@ -98,7 +98,7 @@ public:
     bool
     operator()(time_point now, Seq s, ValidationParms const& p)
     {
-        if (now > (when_ + p.validationSET_EXPIRES))
+        if (now > (when_ + p.validationSetExpires))
             seq_ = Seq{0};
         if (s <= seq_)
             return false;
@@ -139,9 +139,9 @@ isCurrent(
     // promoted from unsigned 32 bit to signed 64 bit prior
     // to computation.
 
-    return (signTime > (now - p.validationCURRENT_EARLY)) &&
-        (signTime < (now + p.validationCURRENT_WALL)) &&
-        ((seenTime == NetClock::time_point{}) || (seenTime < (now + p.validationCURRENT_LOCAL)));
+    return (signTime > (now - p.validationCurrentEarly)) &&
+        (signTime < (now + p.validationCurrentWall)) &&
+        ((seenTime == NetClock::time_point{}) || (seenTime < (now + p.validationCurrentLocal)));
 }
 
 /** Status of validation we received */
@@ -267,13 +267,13 @@ to_string(ValStatus m)
 template 
 class Validations
 {
-    using Mutex = typename Adaptor::Mutex;
-    using Validation = typename Adaptor::Validation;
-    using Ledger = typename Adaptor::Ledger;
-    using ID = typename Ledger::ID;
-    using Seq = typename Ledger::Seq;
-    using NodeID = typename Validation::NodeID;
-    using NodeKey = typename Validation::NodeKey;
+    using Mutex = Adaptor::Mutex;
+    using Validation = Adaptor::Validation;
+    using Ledger = Adaptor::Ledger;
+    using ID = Ledger::ID;
+    using Seq = Ledger::Seq;
+    using NodeID = Validation::NodeID;
+    using NodeKey = Validation::NodeKey;
 
     using WrappedValidationType =
         std::decay_t>;
@@ -610,7 +610,7 @@ public:
                 auto const diff = std::max(seqit->second.signTime(), val.signTime()) -
                     std::min(seqit->second.signTime(), val.signTime());
 
-                if (diff > parms_.validationCURRENT_WALL &&
+                if (diff > parms_.validationCurrentWall &&
                     val.signTime() > seqit->second.signTime())
                     seqit->second = val;
             }
@@ -693,7 +693,7 @@ public:
         validationSET_EXPIRES ago and were not asked to keep.
     */
     void
-    expire(beast::Journal& j)
+    expire(beast::Journal const& j)
     {
         auto const start = std::chrono::steady_clock::now();
         {
@@ -707,7 +707,7 @@ public:
                 {
                     // The next refresh time is shortly before the expiration
                     // time from now.
-                    kRefreshTime = now + parms_.validationSET_EXPIRES - parms_.validationFRESHNESS;
+                    kRefreshTime = now + parms_.validationSetExpires - parms_.validationFRESHNESS;
 
                     for (auto i = byLedger_.begin(); i != byLedger_.end(); ++i)
                     {
@@ -732,8 +732,8 @@ public:
                 }
             }
 
-            beast::expire(byLedger_, parms_.validationSET_EXPIRES);
-            beast::expire(bySequence_, parms_.validationSET_EXPIRES);
+            beast::expire(byLedger_, parms_.validationSetExpires);
+            beast::expire(bySequence_, parms_.validationSetExpires);
         }
         JLOG(j.debug()) << "Validations sets sweep lock duration "
                         << std::chrono::duration_cast(
@@ -817,16 +817,15 @@ public:
         if (!preferred)
         {
             // fall back to majority over acquiring ledgers
-            auto it = std::max_element(
-                acquiring_.begin(), acquiring_.end(), [](auto const& a, auto const& b) {
-                    std::pair const& aKey = a.first;
-                    typename hash_set::size_type const& aSize = a.second.size();
-                    std::pair const& bKey = b.first;
-                    typename hash_set::size_type const& bSize = b.second.size();
-                    // order by number of trusted peers validating that ledger
-                    // break ties with ledger ID
-                    return std::tie(aSize, aKey.second) < std::tie(bSize, bKey.second);
-                });
+            auto it = std::ranges::max_element(acquiring_, [](auto const& a, auto const& b) {
+                std::pair const& aKey = a.first;
+                typename hash_set::size_type const& aSize = a.second.size();
+                std::pair const& bKey = b.first;
+                typename hash_set::size_type const& bSize = b.second.size();
+                // order by number of trusted peers validating that ledger
+                // break ties with ledger ID
+                return std::tie(aSize, aKey.second) < std::tie(bSize, bKey.second);
+            });
             if (it != acquiring_.end())
                 return it->first;
             return std::nullopt;
@@ -896,7 +895,7 @@ public:
             return (preferred->first >= minSeq) ? preferred->second : lcl.id();
 
         // Otherwise, rely on peer ledgers
-        auto it = std::max_element(peerCounts.begin(), peerCounts.end(), [](auto& a, auto& b) {
+        auto it = std::ranges::max_element(peerCounts, [](auto const& a, auto const& b) {
             // Prefer larger counts, then larger ids on ties
             // (max_element expects this to return true if a < b)
             return std::tie(a.second, a.first) < std::tie(b.second, b.first);
@@ -932,7 +931,7 @@ public:
         }
 
         // Count parent ledgers as fallback
-        return std::count_if(lastLedger_.begin(), lastLedger_.end(), [&ledgerID](auto const& it) {
+        return std::ranges::count_if(lastLedger_, [&ledgerID](auto const& it) {
             auto const& curr = it.second;
             return curr.seq() > Seq{0} && curr[curr.seq() - Seq{1}] == ledgerID;
         });
diff --git a/src/xrpld/core/Config.h b/src/xrpld/core/Config.h
index 322683e119..45b36808b2 100644
--- a/src/xrpld/core/Config.h
+++ b/src/xrpld/core/Config.h
@@ -1,9 +1,9 @@
 #pragma once
 
-#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include   // VFALCO Breaks levelization
@@ -48,13 +48,13 @@ A default-constructed Setup contains recommended values.
 struct FeeSetup
 {
     /** The cost of a reference transaction in drops. */
-    XRPAmount reference_fee{10};
+    XRPAmount referenceFee{10};
 
     /** The account reserve requirement in drops. */
-    XRPAmount account_reserve{10 * kDropsPerXrp};
+    XRPAmount accountReserve{10 * kDropsPerXrp};
 
     /** The per-owned item reserve requirement in drops. */
-    XRPAmount owner_reserve{2 * kDropsPerXrp};
+    XRPAmount ownerReserve{2 * kDropsPerXrp};
 
     /* (Remember to update the example cfg files when changing any of these
      * values.) */
@@ -63,7 +63,7 @@ struct FeeSetup
     [[nodiscard]] Fees
     toFees() const
     {
-        return Fees{reference_fee, account_reserve, owner_reserve};
+        return Fees{referenceFee, accountReserve, ownerReserve};
     }
 };
 
@@ -86,20 +86,20 @@ public:
     getDebugLogFile() const;
 
 private:
-    boost::filesystem::path CONFIG_FILE_;
+    boost::filesystem::path configFile_;
 
 public:
-    boost::filesystem::path CONFIG_DIR;
+    boost::filesystem::path configDir;
 
 private:
-    boost::filesystem::path DEBUG_LOGFILE_;
+    boost::filesystem::path debugLogfile_;
 
     void
     load();
     beast::Journal const j_;
 
-    bool QUIET_ = false;   // Minimize logging verbosity.
-    bool SILENT_ = false;  // No output to console after startup.
+    bool quiet_ = false;   // Minimize logging verbosity.
+    bool silent_ = false;  // No output to console after startup.
     /** Operate in stand-alone mode.
 
         In stand alone mode:
@@ -109,9 +109,9 @@ private:
         - If no ledger is loaded, the default ledger with the root
           account is created.
     */
-    bool RUN_STANDALONE_ = false;
+    bool runStandalone_ = false;
 
-    bool USE_TX_TABLES_ = true;
+    bool useTxTables_ = true;
 
     /** Determines if the server will sign a tx, given an account's secret seed.
 
@@ -126,45 +126,45 @@ private:
 
 public:
     bool doImport = false;
-    bool ELB_SUPPORT = false;
+    bool elbSupport = false;
 
     // Entries from [ips] config stanza
-    std::vector IPS;
+    std::vector ips;
 
     // Entries from [ips_fixed] config stanza
-    std::vector IPS_FIXED;
+    std::vector ipsFixed;
 
-    StartUpType START_UP = StartUpType::Normal;
+    StartUpType startUp = StartUpType::Normal;
 
-    bool START_VALID = false;
+    bool startValid = false;
 
-    std::string START_LEDGER;
+    std::string startLedger;
 
-    std::optional TRAP_TX_HASH;
+    std::optional trapTxHash;
 
     // Network parameters
-    uint32_t NETWORK_ID = 0;
+    uint32_t networkId = 0;
 
     // Note: The following parameters do not relate to the UNL or trust at all
     // Minimum number of nodes to consider the network present
-    std::size_t NETWORK_QUORUM = 1;
+    std::size_t networkQuorum = 1;
 
     // Peer networking parameters
     // 1 = relay, 0 = do not relay (but process), -1 = drop completely (do NOT
     // process)
-    int RELAY_UNTRUSTED_VALIDATIONS = 1;
-    int RELAY_UNTRUSTED_PROPOSALS = 0;
+    int relayUntrustedValidations = 1;
+    int relayUntrustedProposals = 0;
 
     // True to ask peers not to relay current IP.
-    bool PEER_PRIVATE = false;
+    bool peerPrivate = false;
     // peers_max is a legacy configuration, which is going to be replaced
     // with individual inbound peers peers_in_max and outbound peers
     // peers_out_max configuration. for now we support both the legacy and
     // the new configuration. if peers_max is configured then peers_in_max and
     // peers_out_max are ignored.
-    std::size_t PEERS_MAX = 0;
-    std::size_t PEERS_OUT_MAX = 0;
-    std::size_t PEERS_IN_MAX = 0;
+    std::size_t peersMax = 0;
+    std::size_t peersOutMax = 0;
+    std::size_t peersInMax = 0;
 
     // Path searching: these were reasonable default values at some point but
     //                 further research is needed to decide if they still are
@@ -178,110 +178,110 @@ public:
     //                 Servers operating as validators disable path finding by
     //                 default by setting the `PATH_SEARCH_MAX` option to 0
     //                 unless it is explicitly set in the configuration file.
-    int PATH_SEARCH_OLD = 2;
-    int PATH_SEARCH = 2;
-    int PATH_SEARCH_FAST = 2;
-    int PATH_SEARCH_MAX = 3;
+    int pathSearchOld = 2;
+    int pathSearch = 2;
+    int pathSearchFast = 2;
+    int pathSearchMax = 3;
 
     // Validation
-    std::optional VALIDATION_QUORUM;  // validations to consider ledger authoritative
+    std::optional validationQuorum;  // validations to consider ledger authoritative
 
-    FeeSetup FEES;
+    FeeSetup fees;
 
     // Node storage configuration
-    std::uint32_t LEDGER_HISTORY = 256;
-    std::uint32_t FETCH_DEPTH = 1000000000;
+    std::uint32_t ledgerHistory = 256;
+    std::uint32_t fetchDepth = 1000000000;
 
     // Tunable that adjusts various parameters, typically associated
     // with hardware parameters (RAM size and CPU cores). The default
     // is 'tiny'.
-    std::size_t NODE_SIZE = 0;
+    std::size_t nodeSize = 0;
 
-    bool SSL_VERIFY = true;
-    std::string SSL_VERIFY_FILE;
-    std::string SSL_VERIFY_DIR;
+    bool sslVerify = true;
+    std::string sslVerifyFile;
+    std::string sslVerifyDir;
 
     // Compression
-    bool COMPRESSION = false;
+    bool compression = false;
 
     // Enable the experimental Ledger Replay functionality
-    bool LEDGER_REPLAY = false;
+    bool ledgerReplay = false;
 
     // Work queue limits
-    int MAX_TRANSACTIONS = 250;
+    int maxTransactions = 250;
     static constexpr int kMaxJobQueueTx = 1000;
     static constexpr int kMinJobQueueTx = 100;
 
     // Amendment majority time
-    std::chrono::seconds AMENDMENT_MAJORITY_TIME = kDefaultAmendmentMajorityTime;
+    std::chrono::seconds amendmentMajorityTime = kDefaultAmendmentMajorityTime;
 
     // Thread pool configuration (0 = choose for me)
-    int WORKERS = 0;           // jobqueue thread count. default: upto 6
-    int IO_WORKERS = 0;        // io svc thread count. default: 2
-    int PREFETCH_WORKERS = 0;  // prefetch thread count. default: 4
+    int workers = 0;          // jobqueue thread count. default: upto 6
+    int ioWorkers = 0;        // io svc thread count. default: 2
+    int prefetchWorkers = 0;  // prefetch thread count. default: 4
 
     // Can only be set in code, specifically unit tests
-    bool FORCE_MULTI_THREAD = false;
+    bool forceMultiThread = false;
 
     // Normally the sweep timer is automatically deduced based on the node
     // size, but we allow admins to explicitly set it in the config.
-    std::optional SWEEP_INTERVAL;
+    std::optional sweepInterval;
 
     // Reduce-relay - Experimental parameters to control p2p routing algorithms
 
     // Enable base squelching of duplicate validation/proposal messages
-    bool VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE = false;
+    bool vpReduceRelayBaseSquelchEnable = false;
 
     /////////////////////  !!TEMPORARY CODE BLOCK!! ////////////////////////
     // Temporary squelching config for the peers selected as a source of  //
     // validator messages. The config must be removed once squelching is  //
     // made the default routing algorithm                                 //
-    std::size_t VP_REDUCE_RELAY_SQUELCH_MAX_SELECTED_PEERS = 5;
+    std::size_t vpReduceRelaySquelchMaxSelectedPeers = 5;
     /////////////////    END OF TEMPORARY CODE BLOCK    /////////////////////
 
     // Transaction reduce-relay feature
-    bool TX_REDUCE_RELAY_ENABLE = false;
+    bool txReduceRelayEnable = false;
     // If tx reduce-relay feature is disabled
     // and this flag is enabled then some
     // tx-related metrics is collected. It
     // is ignored if tx reduce-relay feature is
     // enabled. It is used in debugging to compare
     // metrics with the feature disabled/enabled.
-    bool TX_REDUCE_RELAY_METRICS = false;
+    bool txReduceRelayMetrics = false;
     // Minimum peers a server should have before
     // selecting random peers
-    std::size_t TX_REDUCE_RELAY_MIN_PEERS = 20;
+    std::size_t txReduceRelayMinPeers = 20;
     // Percentage of peers with the tx reduce-relay feature enabled
     // to relay to out of total active peers
-    std::size_t TX_RELAY_PERCENTAGE = 25;
+    std::size_t txRelayPercentage = 25;
 
     // These override the command line client settings
-    std::optional rpc_ip;
+    std::optional rpcIp;
 
     std::unordered_set> features;
 
-    std::string SERVER_DOMAIN;
+    std::string serverDomain;
 
     // How long can a peer remain in the "unknown" state
-    std::chrono::seconds MAX_UNKNOWN_TIME{600};
+    std::chrono::seconds maxUnknownTime{600};
 
     // How long can a peer remain in the "diverged" state
-    std::chrono::seconds MAX_DIVERGED_TIME{300};
+    std::chrono::seconds maxDivergedTime{300};
 
     // Enable the beta API version
-    bool BETA_RPC_API = false;
+    bool betaRpcApi = false;
 
     // First, attempt to load the latest ledger directly from disk.
-    bool FAST_LOAD = false;
+    bool fastLoad = false;
     // When starting xrpld with existing database it do not know it has those
     // ledgers locally until the server naturally tries to backfill. This makes
     // is difficult to test some functionality (in particular performance
     // testing sidechains). With this variable the user is able to force xrpld
     // to consider the ledger range to be present. It should be used for testing
     // only.
-    std::optional> FORCED_LEDGER_RANGE_PRESENT;
+    std::optional> forcedLedgerRangePresent;
 
-    std::optional VALIDATOR_LIST_THRESHOLD;
+    std::optional validatorListThreshold;
 
 public:
     Config();
@@ -305,23 +305,23 @@ public:
     [[nodiscard]] bool
     quiet() const
     {
-        return QUIET_;
+        return quiet_;
     }
     [[nodiscard]] bool
     silent() const
     {
-        return SILENT_;
+        return silent_;
     }
     [[nodiscard]] bool
     standalone() const
     {
-        return RUN_STANDALONE_;
+        return runStandalone_;
     }
 
     [[nodiscard]] bool
     useTxTables() const
     {
-        return USE_TX_TABLES_;
+        return useTxTables_;
     }
 
     [[nodiscard]] bool
diff --git a/src/xrpld/core/ConfigSections.h b/src/xrpld/core/ConfigSections.h
deleted file mode 100644
index 7f22dd59c1..0000000000
--- a/src/xrpld/core/ConfigSections.h
+++ /dev/null
@@ -1,80 +0,0 @@
-#pragma once
-
-#include 
-
-namespace xrpl {
-
-// VFALCO DEPRECATED in favor of the BasicConfig interface
-struct ConfigSection
-{
-    explicit ConfigSection() = default;
-
-    static std::string
-    nodeDatabase()
-    {
-        return "node_db";
-    }
-    static std::string
-    importNodeDatabase()
-    {
-        return "import_db";
-    }
-};
-
-// VFALCO TODO Rename and replace these macros with variables.
-#define SECTION_AMENDMENTS "amendments"
-#define SECTION_AMENDMENT_MAJORITY_TIME "amendment_majority_time"
-#define SECTION_BETA_RPC_API "beta_rpc_api"
-#define SECTION_CLUSTER_NODES "cluster_nodes"
-#define SECTION_COMPRESSION "compression"
-#define SECTION_DEBUG_LOGFILE "debug_logfile"
-#define SECTION_ELB_SUPPORT "elb_support"
-#define SECTION_FEE_DEFAULT "fee_default"
-#define SECTION_FETCH_DEPTH "fetch_depth"
-#define SECTION_INSIGHT "insight"
-#define SECTION_IO_WORKERS "io_workers"
-#define SECTION_IPS "ips"
-#define SECTION_IPS_FIXED "ips_fixed"
-#define SECTION_LEDGER_HISTORY "ledger_history"
-#define SECTION_LEDGER_REPLAY "ledger_replay"
-#define SECTION_MAX_TRANSACTIONS "max_transactions"
-#define SECTION_NETWORK_ID "network_id"
-#define SECTION_NETWORK_QUORUM "network_quorum"
-#define SECTION_NODE_SEED "node_seed"
-#define SECTION_NODE_SIZE "node_size"
-#define SECTION_OVERLAY "overlay"
-#define SECTION_PATH_SEARCH_OLD "path_search_old"
-#define SECTION_PATH_SEARCH "path_search"
-#define SECTION_PATH_SEARCH_FAST "path_search_fast"
-#define SECTION_PATH_SEARCH_MAX "path_search_max"
-#define SECTION_PEER_PRIVATE "peer_private"
-#define SECTION_PEERS_MAX "peers_max"
-#define SECTION_PEERS_IN_MAX "peers_in_max"
-#define SECTION_PEERS_OUT_MAX "peers_out_max"
-#define SECTION_PORT_GRPC "port_grpc"
-#define SECTION_PREFETCH_WORKERS "prefetch_workers"
-#define SECTION_REDUCE_RELAY "reduce_relay"
-#define SECTION_RELATIONAL_DB "relational_db"
-#define SECTION_RELAY_PROPOSALS "relay_proposals"
-#define SECTION_RELAY_VALIDATIONS "relay_validations"
-#define SECTION_RPC_STARTUP "rpc_startup"
-#define SECTION_SIGNING_SUPPORT "signing_support"
-#define SECTION_SNTP "sntp_servers"
-#define SECTION_SSL_VERIFY "ssl_verify"
-#define SECTION_SSL_VERIFY_FILE "ssl_verify_file"
-#define SECTION_SSL_VERIFY_DIR "ssl_verify_dir"
-#define SECTION_SERVER_DOMAIN "server_domain"
-#define SECTION_SWEEP_INTERVAL "sweep_interval"
-#define SECTION_VALIDATORS_FILE "validators_file"
-#define SECTION_VALIDATION_SEED "validation_seed"
-#define SECTION_VALIDATOR_KEYS "validator_keys"
-#define SECTION_VALIDATOR_KEY_REVOCATION "validator_key_revocation"
-#define SECTION_VALIDATOR_LIST_KEYS "validator_list_keys"
-#define SECTION_VALIDATOR_LIST_SITES "validator_list_sites"
-#define SECTION_VALIDATOR_LIST_THRESHOLD "validator_list_threshold"
-#define SECTION_VALIDATORS "validators"
-#define SECTION_VALIDATOR_TOKEN "validator_token"
-#define SECTION_VETO_AMENDMENTS "veto_amendments"
-#define SECTION_WORKERS "workers"
-
-}  // namespace xrpl
diff --git a/src/xrpld/core/detail/Config.cpp b/src/xrpld/core/detail/Config.cpp
index d8789795c0..523cde743a 100644
--- a/src/xrpld/core/detail/Config.cpp
+++ b/src/xrpld/core/detail/Config.cpp
@@ -1,8 +1,5 @@
 #include 
 
-#include 
-
-#include 
 #include 
 #include 
 #include 
@@ -11,6 +8,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -272,11 +271,11 @@ Config::Config()
 void
 Config::setupControl(bool bQuiet, bool bSilent, bool bStandalone)
 {
-    XRPL_ASSERT(NODE_SIZE == 0, "xrpl::Config::setupControl : node size not set");
+    XRPL_ASSERT(nodeSize == 0, "xrpl::Config::setupControl : node size not set");
 
-    QUIET_ = bQuiet || bSilent;
-    SILENT_ = bSilent;
-    RUN_STANDALONE_ = bStandalone;
+    quiet_ = bQuiet || bSilent;
+    silent_ = bSilent;
+    runStandalone_ = bStandalone;
 
     // We try to autodetect the appropriate node size by checking available
     // RAM and CPU resources. We default to "tiny" for standalone mode.
@@ -293,15 +292,15 @@ Config::setupControl(bool bQuiet, bool bSilent, bool bStandalone)
         XRPL_ASSERT(ns != threshold.second.end(), "xrpl::Config::setupControl : valid node size");
 
         if (ns != threshold.second.end())
-            NODE_SIZE = std::distance(threshold.second.begin(), ns);
+            nodeSize = std::distance(threshold.second.begin(), ns);
 
         // Adjust the size based on the number of hardware threads of
         // execution available to us:
         if (auto const hc = std::thread::hardware_concurrency(); hc != 0)
-            NODE_SIZE = std::min(hc / 2, NODE_SIZE);
+            nodeSize = std::min(hc / 2, nodeSize);
     }
 
-    XRPL_ASSERT(NODE_SIZE <= 4, "xrpl::Config::setupControl : node size is set");
+    XRPL_ASSERT(nodeSize <= 4, "xrpl::Config::setupControl : node size is set");
 }
 
 void
@@ -319,10 +318,10 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
     if (!strConf.empty())
     {
         // --conf= : everything is relative that file.
-        CONFIG_FILE_ = strConf;
-        CONFIG_DIR = boost::filesystem::absolute(CONFIG_FILE_);
-        CONFIG_DIR.remove_filename();
-        dataDir = CONFIG_DIR / kDatabaseDirName;
+        configFile_ = strConf;
+        configDir = boost::filesystem::absolute(configFile_);
+        configDir.remove_filename();
+        dataDir = configDir / kDatabaseDirName;
     }
     else
     {
@@ -331,13 +330,13 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
             // Check if either of the config files exist in the current working
             // directory, in which case the databases will be stored in a
             // subdirectory.
-            CONFIG_DIR = boost::filesystem::current_path();
-            dataDir = CONFIG_DIR / kDatabaseDirName;
-            CONFIG_FILE_ = CONFIG_DIR / kConfigFileName;
-            if (boost::filesystem::exists(CONFIG_FILE_))
+            configDir = boost::filesystem::current_path();
+            dataDir = configDir / kDatabaseDirName;
+            configFile_ = configDir / kConfigFileName;
+            if (boost::filesystem::exists(configFile_))
                 break;
-            CONFIG_FILE_ = CONFIG_DIR / kConfigLegacyName;
-            if (boost::filesystem::exists(CONFIG_FILE_))
+            configFile_ = configDir / kConfigLegacyName;
+            if (boost::filesystem::exists(configFile_))
                 break;
 
             // Check if the home directory is set, and optionally the XDG config
@@ -362,22 +361,22 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
                 // Check if either of the config files exist in the XDG config
                 // dir.
                 dataDir = strXdgDataHome + "/" + systemName();
-                CONFIG_DIR = strXdgConfigHome + "/" + systemName();
-                CONFIG_FILE_ = CONFIG_DIR / kConfigFileName;
-                if (boost::filesystem::exists(CONFIG_FILE_))
+                configDir = strXdgConfigHome + "/" + systemName();
+                configFile_ = configDir / kConfigFileName;
+                if (boost::filesystem::exists(configFile_))
                     break;
-                CONFIG_FILE_ = CONFIG_DIR / kConfigLegacyName;
-                if (boost::filesystem::exists(CONFIG_FILE_))
+                configFile_ = configDir / kConfigLegacyName;
+                if (boost::filesystem::exists(configFile_))
                     break;
             }
 
             // As a last resort, check the system config directory.
             dataDir = "/var/lib/" + systemName();
-            CONFIG_DIR = "/etc/" + systemName();
-            CONFIG_FILE_ = CONFIG_DIR / kConfigFileName;
-            if (boost::filesystem::exists(CONFIG_FILE_))
+            configDir = "/etc/" + systemName();
+            configFile_ = configDir / kConfigFileName;
+            if (boost::filesystem::exists(configFile_))
                 break;
-            CONFIG_FILE_ = CONFIG_DIR / kConfigLegacyName;
+            configFile_ = configDir / kConfigLegacyName;
         } while (false);
     }
 
@@ -385,12 +384,12 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
     load();
     {
         // load() may have set a new value for the dataDir
-        std::string const dbPath(legacy("database_path"));
+        std::string const dbPath(legacy(Sections::kDatabasePath));
         if (!dbPath.empty())
         {
             dataDir = boost::filesystem::path(dbPath);
         }
-        else if (RUN_STANDALONE_)
+        else if (runStandalone_)
         {
             dataDir.clear();
         }
@@ -404,20 +403,19 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
         if (ec)
             Throw(boost::str(boost::format("Can not create %s") % dataDir));
 
-        legacy("database_path", boost::filesystem::absolute(dataDir).string());
+        legacy(Sections::kDatabasePath, boost::filesystem::absolute(dataDir).string());
     }
 
-    HTTPClient::initializeSSLContext(
-        this->SSL_VERIFY_DIR, this->SSL_VERIFY_FILE, this->SSL_VERIFY, j_);
+    HTTPClient::initializeSSLContext(this->sslVerifyDir, this->sslVerifyFile, this->sslVerify, j_);
 
-    if (RUN_STANDALONE_)
-        LEDGER_HISTORY = 0;
+    if (runStandalone_)
+        ledgerHistory = 0;
 
-    Section const ledgerTxTablesSection = section("ledger_tx_tables");
-    getIfExists(ledgerTxTablesSection, "use_tx_tables", USE_TX_TABLES_);
+    Section const ledgerTxTablesSection = section(Sections::kLedgerTxTables);
+    getIfExists(ledgerTxTablesSection, Keys::kUseTxTables, useTxTables_);
 
-    Section const& nodeDbSection{section(ConfigSection::nodeDatabase())};
-    getIfExists(nodeDbSection, "fast_load", FAST_LOAD);
+    Section const& nodeDbSection{section(Sections::kNodeDatabase)};
+    getIfExists(nodeDbSection, Keys::kFastLoad, fastLoad);
 }
 
 // 0 ports are allowed for unit tests, but still not allowed to be present in
@@ -425,16 +423,16 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
 static void
 checkZeroPorts(Config const& config)
 {
-    if (!config.exists("server"))
+    if (!config.exists(Sections::kServer))
         return;
 
-    for (auto const& name : config.section("server").values())
+    for (auto const& name : config.section(Sections::kServer).values())
     {
         if (!config.exists(name))
             return;
 
         auto const& section = config[name];
-        auto const optResult = section.get("port");
+        auto const optResult = section.get(Keys::kPort);
         if (optResult)
         {
             auto const port = beast::lexicalCast(*optResult);
@@ -454,16 +452,16 @@ Config::load()
     // NOTE: this writes to cerr because we want cout to be reserved
     // for the writing of the json response (so that stdout can be part of a
     // pipeline, for instance)
-    if (!QUIET_)
-        std::cerr << "Loading: " << CONFIG_FILE_ << "\n";
+    if (!quiet_)
+        std::cerr << "Loading: " << configFile_ << "\n";
 
     boost::system::error_code ec;
-    auto const fileContents = getFileContents(ec, CONFIG_FILE_);
+    auto const fileContents = getFileContents(ec, configFile_);
 
     if (ec)
     {
-        std::cerr << "Failed to read '" << CONFIG_FILE_ << "'." << ec.value() << ": "
-                  << ec.message() << std::endl;
+        std::cerr << "Failed to read '" << configFile_ << "'." << ec.value() << ": " << ec.message()
+                  << std::endl;
         return;
     }
 
@@ -478,11 +476,11 @@ Config::loadFromString(std::string const& fileContents)
 
     build(secConfig);
 
-    if (auto s = getIniFileSection(secConfig, SECTION_IPS))
-        IPS = *s;
+    if (auto s = getIniFileSection(secConfig, Sections::kIps))
+        ips = *s;
 
-    if (auto s = getIniFileSection(secConfig, SECTION_IPS_FIXED))
-        IPS_FIXED = *s;
+    if (auto s = getIniFileSection(secConfig, Sections::kIpsFixed))
+        ipsFixed = *s;
 
     // if the user has specified ip:port then replace : with a space.
     {
@@ -502,289 +500,298 @@ Config::loadFromString(std::string const& fileContents)
             }
         };
 
-        replaceColons(IPS_FIXED);
-        replaceColons(IPS);
+        replaceColons(ipsFixed);
+        replaceColons(ips);
     }
 
     {
         std::string dbPath;
-        if (getSingleSection(secConfig, "database_path", dbPath, j_))
+        if (getSingleSection(secConfig, Sections::kDatabasePath, dbPath, j_))
         {
             boost::filesystem::path const p(dbPath);
-            legacy("database_path", boost::filesystem::absolute(p).string());
+            legacy(Sections::kDatabasePath, boost::filesystem::absolute(p).string());
         }
     }
 
     std::string strTemp;
 
-    if (getSingleSection(secConfig, SECTION_NETWORK_ID, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kNetworkId, strTemp, j_))
     {
         if (strTemp == "main")
         {
-            NETWORK_ID = 0;
+            networkId = 0;
         }
         else if (strTemp == "testnet")
         {
-            NETWORK_ID = 1;
+            networkId = 1;
         }
         else if (strTemp == "devnet")
         {
-            NETWORK_ID = 2;
+            networkId = 2;
         }
         else
         {
-            NETWORK_ID = beast::lexicalCastThrow(strTemp);
+            networkId = beast::lexicalCastThrow(strTemp);
         }
     }
 
-    if (getSingleSection(secConfig, SECTION_PEER_PRIVATE, strTemp, j_))
-        PEER_PRIVATE = beast::lexicalCastThrow(strTemp);
+    if (getSingleSection(secConfig, Sections::kPeerPrivate, strTemp, j_))
+        peerPrivate = beast::lexicalCastThrow(strTemp);
 
-    if (getSingleSection(secConfig, SECTION_PEERS_MAX, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kPeersMax, strTemp, j_))
     {
-        PEERS_MAX = beast::lexicalCastThrow(strTemp);
+        peersMax = beast::lexicalCastThrow(strTemp);
     }
     else
     {
-        std::optional peersInMax{};
-        if (getSingleSection(secConfig, SECTION_PEERS_IN_MAX, strTemp, j_))
+        std::optional peersInMaxOpt{};
+        if (getSingleSection(secConfig, Sections::kPeersInMax, strTemp, j_))
         {
-            peersInMax = beast::lexicalCastThrow(strTemp);
-            if (*peersInMax > 1000)
+            peersInMaxOpt = beast::lexicalCastThrow(strTemp);
+            if (*peersInMaxOpt > 1000)
             {
-                Throw("Invalid value specified in [" SECTION_PEERS_IN_MAX
-                                          "] section; the value must be less or equal than 1000");
+                Throw(
+                    std::string("Invalid value specified in [") + Sections::kPeersInMax +
+                    "] section; the value must be less or equal than 1000");
             }
         }
 
-        std::optional peersOutMax{};
-        if (getSingleSection(secConfig, SECTION_PEERS_OUT_MAX, strTemp, j_))
+        std::optional peersOutMaxOpt{};
+        if (getSingleSection(secConfig, Sections::kPeersOutMax, strTemp, j_))
         {
-            peersOutMax = beast::lexicalCastThrow(strTemp);
-            if (*peersOutMax < 10 || *peersOutMax > 1000)
+            peersOutMaxOpt = beast::lexicalCastThrow(strTemp);
+            if (*peersOutMaxOpt < 10 || *peersOutMaxOpt > 1000)
             {
-                Throw("Invalid value specified in [" SECTION_PEERS_OUT_MAX
-                                          "] section; the value must be in range 10-1000");
+                Throw(
+                    std::string("Invalid value specified in [") + Sections::kPeersOutMax +
+                    "] section; the value must be in range 10-1000");
             }
         }
 
         // if one section is configured then the other must be configured too
-        if ((peersInMax && !peersOutMax) || (peersOutMax && !peersInMax))
+        if ((peersInMaxOpt && !peersOutMaxOpt) || (peersOutMaxOpt && !peersInMaxOpt))
         {
-            Throw("Both sections [" SECTION_PEERS_IN_MAX
-                                      "]"
-                                      "and [" SECTION_PEERS_OUT_MAX "] must be configured");
+            Throw(
+                std::string("Both sections [") + Sections::kPeersInMax + "]" + " and [" +
+                Sections::kPeersOutMax + "] must be configured");
         }
 
-        if (peersInMax && peersOutMax)
+        if (peersInMaxOpt && peersOutMaxOpt)
         {
-            PEERS_IN_MAX = *peersInMax;
-            PEERS_OUT_MAX = *peersOutMax;
+            peersInMax = *peersInMaxOpt;
+            peersOutMax = *peersOutMaxOpt;
         }
     }
 
-    if (getSingleSection(secConfig, SECTION_NODE_SIZE, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kNodeSize, strTemp, j_))
     {
         if (boost::iequals(strTemp, "tiny"))
         {
-            NODE_SIZE = 0;
+            nodeSize = 0;
         }
         else if (boost::iequals(strTemp, "small"))
         {
-            NODE_SIZE = 1;
+            nodeSize = 1;
         }
         else if (boost::iequals(strTemp, "medium"))
         {
-            NODE_SIZE = 2;
+            nodeSize = 2;
         }
         else if (boost::iequals(strTemp, "large"))
         {
-            NODE_SIZE = 3;
+            nodeSize = 3;
         }
         else if (boost::iequals(strTemp, "huge"))
         {
-            NODE_SIZE = 4;
+            nodeSize = 4;
         }
         else
         {
-            NODE_SIZE = std::min(4, beast::lexicalCastThrow(strTemp));
+            nodeSize = std::min(4, beast::lexicalCastThrow(strTemp));
         }
     }
 
-    if (getSingleSection(secConfig, SECTION_SIGNING_SUPPORT, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kSigningSupport, strTemp, j_))
         signingEnabled_ = beast::lexicalCastThrow(strTemp);
 
-    if (getSingleSection(secConfig, SECTION_ELB_SUPPORT, strTemp, j_))
-        ELB_SUPPORT = beast::lexicalCastThrow(strTemp);
+    if (getSingleSection(secConfig, Sections::kElbSupport, strTemp, j_))
+        elbSupport = beast::lexicalCastThrow(strTemp);
 
-    getSingleSection(secConfig, SECTION_SSL_VERIFY_FILE, SSL_VERIFY_FILE, j_);
-    getSingleSection(secConfig, SECTION_SSL_VERIFY_DIR, SSL_VERIFY_DIR, j_);
+    getSingleSection(secConfig, Sections::kSslVerifyFile, sslVerifyFile, j_);
+    getSingleSection(secConfig, Sections::kSslVerifyDir, sslVerifyDir, j_);
 
-    if (getSingleSection(secConfig, SECTION_SSL_VERIFY, strTemp, j_))
-        SSL_VERIFY = beast::lexicalCastThrow(strTemp);
+    if (getSingleSection(secConfig, Sections::kSslVerify, strTemp, j_))
+        sslVerify = beast::lexicalCastThrow(strTemp);
 
-    if (getSingleSection(secConfig, SECTION_RELAY_VALIDATIONS, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kRelayValidations, strTemp, j_))
     {
         if (boost::iequals(strTemp, "all"))
         {
-            RELAY_UNTRUSTED_VALIDATIONS = 1;
+            relayUntrustedValidations = 1;
         }
         else if (boost::iequals(strTemp, "trusted"))
         {
-            RELAY_UNTRUSTED_VALIDATIONS = 0;
+            relayUntrustedValidations = 0;
         }
         else if (boost::iequals(strTemp, "drop_untrusted"))
         {
-            RELAY_UNTRUSTED_VALIDATIONS = -1;
+            relayUntrustedValidations = -1;
         }
         else
         {
-            Throw("Invalid value specified in [" SECTION_RELAY_VALIDATIONS
-                                      "] section");
+            Throw(
+                std::string("Invalid value specified in [") + Sections::kRelayValidations +
+                "] section");
         }
     }
 
-    if (getSingleSection(secConfig, SECTION_RELAY_PROPOSALS, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kRelayProposals, strTemp, j_))
     {
         if (boost::iequals(strTemp, "all"))
         {
-            RELAY_UNTRUSTED_PROPOSALS = 1;
+            relayUntrustedProposals = 1;
         }
         else if (boost::iequals(strTemp, "trusted"))
         {
-            RELAY_UNTRUSTED_PROPOSALS = 0;
+            relayUntrustedProposals = 0;
         }
         else if (boost::iequals(strTemp, "drop_untrusted"))
         {
-            RELAY_UNTRUSTED_PROPOSALS = -1;
+            relayUntrustedProposals = -1;
         }
         else
         {
-            Throw("Invalid value specified in [" SECTION_RELAY_PROPOSALS
-                                      "] section");
+            Throw(
+                std::string("Invalid value specified in [") + Sections::kRelayProposals +
+                "] section");
         }
     }
 
-    if (exists(SECTION_VALIDATION_SEED) && exists(SECTION_VALIDATOR_TOKEN))
+    if (exists(Sections::kValidationSeed) && exists(Sections::kValidatorToken))
     {
-        Throw("Cannot have both [" SECTION_VALIDATION_SEED
-                                  "] and [" SECTION_VALIDATOR_TOKEN "] config sections");
+        Throw(
+            std::string("Cannot have both [") + Sections::kValidationSeed + "] and [" +
+            Sections::kValidatorToken + "] config sections");
     }
 
-    if (getSingleSection(secConfig, SECTION_NETWORK_QUORUM, strTemp, j_))
-        NETWORK_QUORUM = beast::lexicalCastThrow(strTemp);
+    if (getSingleSection(secConfig, Sections::kNetworkQuorum, strTemp, j_))
+        networkQuorum = beast::lexicalCastThrow(strTemp);
 
-    FEES = setupFeeVote(section("voting"));
+    fees = setupFeeVote(section(Sections::kVoting));
     /* [fee_default] is documented in the example config files as useful for
      * things like offline transaction signing. Until that's completely
      * deprecated, allow it to override the [voting] section. */
-    if (getSingleSection(secConfig, SECTION_FEE_DEFAULT, strTemp, j_))
-        FEES.reference_fee = beast::lexicalCastThrow(strTemp);
+    if (getSingleSection(secConfig, Sections::kFeeDefault, strTemp, j_))
+        fees.referenceFee = beast::lexicalCastThrow(strTemp);
 
-    if (getSingleSection(secConfig, SECTION_LEDGER_HISTORY, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kLedgerHistory, strTemp, j_))
     {
         if (boost::iequals(strTemp, "full"))
         {
-            LEDGER_HISTORY = std::numeric_limits::max();
+            ledgerHistory = std::numeric_limits::max();
         }
         else if (boost::iequals(strTemp, "none"))
         {
-            LEDGER_HISTORY = 0;
+            ledgerHistory = 0;
         }
         else
         {
-            LEDGER_HISTORY = beast::lexicalCastThrow(strTemp);
+            ledgerHistory = beast::lexicalCastThrow(strTemp);
         }
     }
 
-    if (getSingleSection(secConfig, SECTION_FETCH_DEPTH, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kFetchDepth, strTemp, j_))
     {
         if (boost::iequals(strTemp, "none"))
         {
-            FETCH_DEPTH = 0;
+            fetchDepth = 0;
         }
         else if (boost::iequals(strTemp, "full"))
         {
-            FETCH_DEPTH = std::numeric_limits::max();
+            fetchDepth = std::numeric_limits::max();
         }
         else
         {
-            FETCH_DEPTH = beast::lexicalCastThrow(strTemp);
+            fetchDepth = beast::lexicalCastThrow(strTemp);
         }
 
-        FETCH_DEPTH = std::max(FETCH_DEPTH, 10);
+        fetchDepth = std::max(fetchDepth, 10);
     }
 
     // By default, validators don't have pathfinding enabled, unless it is
     // explicitly requested by the server's admin.
-    if (exists(SECTION_VALIDATION_SEED) || exists(SECTION_VALIDATOR_TOKEN))
-        PATH_SEARCH_MAX = 0;
+    if (exists(Sections::kValidationSeed) || exists(Sections::kValidatorToken))
+        pathSearchMax = 0;
 
-    if (getSingleSection(secConfig, SECTION_PATH_SEARCH_OLD, strTemp, j_))
-        PATH_SEARCH_OLD = beast::lexicalCastThrow(strTemp);
-    if (getSingleSection(secConfig, SECTION_PATH_SEARCH, strTemp, j_))
-        PATH_SEARCH = beast::lexicalCastThrow(strTemp);
-    if (getSingleSection(secConfig, SECTION_PATH_SEARCH_FAST, strTemp, j_))
-        PATH_SEARCH_FAST = beast::lexicalCastThrow(strTemp);
-    if (getSingleSection(secConfig, SECTION_PATH_SEARCH_MAX, strTemp, j_))
-        PATH_SEARCH_MAX = beast::lexicalCastThrow(strTemp);
+    if (getSingleSection(secConfig, Sections::kPathSearchOld, strTemp, j_))
+        pathSearchOld = beast::lexicalCastThrow(strTemp);
+    if (getSingleSection(secConfig, Sections::kPathSearch, strTemp, j_))
+        pathSearch = beast::lexicalCastThrow(strTemp);
+    if (getSingleSection(secConfig, Sections::kPathSearchFast, strTemp, j_))
+        pathSearchFast = beast::lexicalCastThrow(strTemp);
+    if (getSingleSection(secConfig, Sections::kPathSearchMax, strTemp, j_))
+        pathSearchMax = beast::lexicalCastThrow(strTemp);
 
-    if (getSingleSection(secConfig, SECTION_DEBUG_LOGFILE, strTemp, j_))
-        DEBUG_LOGFILE_ = strTemp;
+    if (getSingleSection(secConfig, Sections::kDebugLogfile, strTemp, j_))
+        debugLogfile_ = strTemp;
 
-    if (getSingleSection(secConfig, SECTION_SWEEP_INTERVAL, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kSweepInterval, strTemp, j_))
     {
-        SWEEP_INTERVAL = beast::lexicalCastThrow(strTemp);
+        sweepInterval = beast::lexicalCastThrow(strTemp);
 
-        if (SWEEP_INTERVAL < 10 || SWEEP_INTERVAL > 600)
+        if (sweepInterval < 10 || sweepInterval > 600)
         {
-            Throw("Invalid " SECTION_SWEEP_INTERVAL
-                                      ": must be between 10 and 600 inclusive");
+            Throw(
+                std::string("Invalid ") + Sections::kSweepInterval +
+                ": must be between 10 and 600 inclusive");
         }
     }
 
-    if (getSingleSection(secConfig, SECTION_WORKERS, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kWorkers, strTemp, j_))
     {
-        WORKERS = beast::lexicalCastThrow(strTemp);
+        workers = beast::lexicalCastThrow(strTemp);
 
-        if (WORKERS < 1 || WORKERS > 1024)
+        if (workers < 1 || workers > 1024)
         {
-            Throw("Invalid " SECTION_WORKERS
-                                      ": must be between 1 and 1024 inclusive.");
+            Throw(
+                std::string("Invalid ") + Sections::kWorkers +
+                ": must be between 1 and 1024 inclusive.");
         }
     }
 
-    if (getSingleSection(secConfig, SECTION_IO_WORKERS, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kIoWorkers, strTemp, j_))
     {
-        IO_WORKERS = beast::lexicalCastThrow(strTemp);
+        ioWorkers = beast::lexicalCastThrow(strTemp);
 
-        if (IO_WORKERS < 1 || IO_WORKERS > 1024)
+        if (ioWorkers < 1 || ioWorkers > 1024)
         {
-            Throw("Invalid " SECTION_IO_WORKERS
-                                      ": must be between 1 and 1024 inclusive.");
+            Throw(
+                std::string("Invalid ") + Sections::kIoWorkers +
+                ": must be between 1 and 1024 inclusive.");
         }
     }
 
-    if (getSingleSection(secConfig, SECTION_PREFETCH_WORKERS, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kPrefetchWorkers, strTemp, j_))
     {
-        PREFETCH_WORKERS = beast::lexicalCastThrow(strTemp);
+        prefetchWorkers = beast::lexicalCastThrow(strTemp);
 
-        if (PREFETCH_WORKERS < 1 || PREFETCH_WORKERS > 1024)
+        if (prefetchWorkers < 1 || prefetchWorkers > 1024)
         {
-            Throw("Invalid " SECTION_PREFETCH_WORKERS
-                                      ": must be between 1 and 1024 inclusive.");
+            Throw(
+                std::string("Invalid ") + Sections::kPrefetchWorkers +
+                ": must be between 1 and 1024 inclusive.");
         }
     }
 
-    if (getSingleSection(secConfig, SECTION_COMPRESSION, strTemp, j_))
-        COMPRESSION = beast::lexicalCastThrow(strTemp);
+    if (getSingleSection(secConfig, Sections::kCompression, strTemp, j_))
+        compression = beast::lexicalCastThrow(strTemp);
 
-    if (getSingleSection(secConfig, SECTION_LEDGER_REPLAY, strTemp, j_))
-        LEDGER_REPLAY = beast::lexicalCastThrow(strTemp);
+    if (getSingleSection(secConfig, Sections::kLedgerReplay, strTemp, j_))
+        ledgerReplay = beast::lexicalCastThrow(strTemp);
 
-    if (exists(SECTION_REDUCE_RELAY))
+    if (exists(Sections::kReduceRelay))
     {
-        auto sec = section(SECTION_REDUCE_RELAY);
+        auto sec = section(Sections::kReduceRelay);
 
         /////////////////////  !!TEMPORARY CODE BLOCK!! ////////////////////////
         // vp_enable config option is deprecated by vp_base_squelch_enable    //
@@ -792,26 +799,27 @@ Config::loadFromString(std::string const& fileContents)
         // is the default algorithm, it must be replaced with:                //
         //  VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE =                             //
         //  sec.value_or("vp_base_squelch_enable", true);                     //
-        if (sec.exists("vp_base_squelch_enable") && sec.exists("vp_enable"))
+        if (sec.exists(Keys::kVpBaseSquelchEnable) && sec.exists(Keys::kVpEnable))
         {
-            Throw("Invalid " SECTION_REDUCE_RELAY
-                                      " cannot specify both vp_base_squelch_enable and vp_enable "
-                                      "options. "
-                                      "vp_enable was deprecated and replaced by "
-                                      "vp_base_squelch_enable");
+            Throw(
+                std::string("Invalid ") + Sections::kReduceRelay +
+                " cannot specify both vp_base_squelch_enable and vp_enable "
+                "options. "
+                "vp_enable was deprecated and replaced by "
+                "vp_base_squelch_enable");
         }
 
-        if (sec.exists("vp_base_squelch_enable"))
+        if (sec.exists(Keys::kVpBaseSquelchEnable))
         {
-            VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE = sec.valueOr("vp_base_squelch_enable", false);
+            vpReduceRelayBaseSquelchEnable = sec.valueOr(Keys::kVpBaseSquelchEnable, false);
         }
-        else if (sec.exists("vp_enable"))
+        else if (sec.exists(Keys::kVpEnable))
         {
-            VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE = sec.valueOr("vp_enable", false);
+            vpReduceRelayBaseSquelchEnable = sec.valueOr(Keys::kVpEnable, false);
         }
         else
         {
-            VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE = false;
+            vpReduceRelayBaseSquelchEnable = false;
         }
         /////////////////  !!END OF TEMPORARY CODE BLOCK!! /////////////////////
 
@@ -819,132 +827,138 @@ Config::loadFromString(std::string const& fileContents)
         // Temporary squelching config for the peers selected as a source of //
         // validator messages. The config must be removed once squelching is //
         // made the default routing algorithm.                               //
-        VP_REDUCE_RELAY_SQUELCH_MAX_SELECTED_PEERS =
-            sec.valueOr("vp_base_squelch_max_selected_peers", 5);
-        if (VP_REDUCE_RELAY_SQUELCH_MAX_SELECTED_PEERS < 3)
+        vpReduceRelaySquelchMaxSelectedPeers = sec.valueOr(Keys::kVpBaseSquelchMaxSelectedPeers, 5);
+        if (vpReduceRelaySquelchMaxSelectedPeers < 3)
         {
-            Throw("Invalid " SECTION_REDUCE_RELAY
-                                      " vp_base_squelch_max_selected_peers must be "
-                                      "greater than or equal to 3");
+            Throw(
+                std::string("Invalid ") + Sections::kReduceRelay +
+                " vp_base_squelch_max_selected_peers must be "
+                "greater than or equal to 3");
         }
         /////////////////  !!END OF TEMPORARY CODE BLOCK!! /////////////////////
 
-        TX_REDUCE_RELAY_ENABLE = sec.valueOr("tx_enable", false);
-        TX_REDUCE_RELAY_METRICS = sec.valueOr("tx_metrics", false);
-        TX_REDUCE_RELAY_MIN_PEERS = sec.valueOr("tx_min_peers", 20);
-        TX_RELAY_PERCENTAGE = sec.valueOr("tx_relay_percentage", 25);
-        if (TX_RELAY_PERCENTAGE < 10 || TX_RELAY_PERCENTAGE > 100 || TX_REDUCE_RELAY_MIN_PEERS < 10)
+        txReduceRelayEnable = sec.valueOr(Keys::kTxEnable, false);
+        txReduceRelayMetrics = sec.valueOr(Keys::kTxMetrics, false);
+        txReduceRelayMinPeers = sec.valueOr(Keys::kTxMinPeers, 20);
+        txRelayPercentage = sec.valueOr(Keys::kTxRelayPercentage, 25);
+        if (txRelayPercentage < 10 || txRelayPercentage > 100 || txReduceRelayMinPeers < 10)
         {
-            Throw("Invalid " SECTION_REDUCE_RELAY
-                                      ", tx_min_peers must be greater than or equal to 10"
-                                      ", tx_relay_percentage must be greater than or equal to 10 "
-                                      "and less than or equal to 100");
+            Throw(
+                std::string("Invalid ") + Sections::kReduceRelay +
+                ", tx_min_peers must be greater than or equal to 10"
+                ", tx_relay_percentage must be greater than or equal to 10 "
+                "and less than or equal to 100");
         }
     }
 
-    if (getSingleSection(secConfig, SECTION_MAX_TRANSACTIONS, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kMaxTransactions, strTemp, j_))
     {
-        MAX_TRANSACTIONS =
+        maxTransactions =
             std::clamp(beast::lexicalCastThrow(strTemp), kMinJobQueueTx, kMaxJobQueueTx);
     }
 
-    if (getSingleSection(secConfig, SECTION_SERVER_DOMAIN, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kServerDomain, strTemp, j_))
     {
         if (!isProperlyFormedTomlDomain(strTemp))
         {
             Throw(
-                "Invalid " SECTION_SERVER_DOMAIN
+                std::string("Invalid ") + Sections::kServerDomain +
                 ": the domain name does not appear to meet the requirements.");
         }
 
-        SERVER_DOMAIN = strTemp;
+        serverDomain = strTemp;
     }
 
-    if (exists(SECTION_OVERLAY))
+    if (exists(Sections::kOverlay))
     {
-        auto const sec = section(SECTION_OVERLAY);
+        auto const sec = section(Sections::kOverlay);
 
         using namespace std::chrono;
 
         try
         {
-            if (auto val = sec.get("max_unknown_time"))
-                MAX_UNKNOWN_TIME = seconds{beast::lexicalCastThrow(*val)};
+            if (auto val = sec.get(Keys::kMaxUnknownTime))
+                maxUnknownTime = seconds{beast::lexicalCastThrow(*val)};
         }
         catch (...)
         {
-            Throw("Invalid value 'max_unknown_time' in " SECTION_OVERLAY
-                                      ": must be of the form '' representing seconds.");
+            Throw(
+                std::string("Invalid value 'max_unknown_time' in ") + Sections::kOverlay +
+                ": must be of the form '' representing seconds.");
         }
 
-        if (MAX_UNKNOWN_TIME < seconds{300} || MAX_UNKNOWN_TIME > seconds{1800})
+        if (maxUnknownTime < seconds{300} || maxUnknownTime > seconds{1800})
         {
             Throw(
-                "Invalid value 'max_unknown_time' in " SECTION_OVERLAY
+                std::string("Invalid value 'max_unknown_time' in ") + Sections::kOverlay +
                 ": the time must be between 300 and 1800 seconds, inclusive.");
         }
 
         try
         {
-            if (auto val = sec.get("max_diverged_time"))
-                MAX_DIVERGED_TIME = seconds{beast::lexicalCastThrow(*val)};
+            if (auto val = sec.get(Keys::kMaxDivergedTime))
+                maxDivergedTime = seconds{beast::lexicalCastThrow(*val)};
         }
         catch (...)
         {
-            Throw("Invalid value 'max_diverged_time' in " SECTION_OVERLAY
-                                      ": must be of the form '' representing seconds.");
+            Throw(
+                std::string("Invalid value 'max_diverged_time' in ") + Sections::kOverlay +
+                ": must be of the form '' representing seconds.");
         }
 
-        if (MAX_DIVERGED_TIME < seconds{60} || MAX_DIVERGED_TIME > seconds{900})
+        if (maxDivergedTime < seconds{60} || maxDivergedTime > seconds{900})
         {
-            Throw("Invalid value 'max_diverged_time' in " SECTION_OVERLAY
-                                      ": the time must be between 60 and 900 seconds, inclusive.");
+            Throw(
+                std::string("Invalid value 'max_diverged_time' in ") + Sections::kOverlay +
+                ": the time must be between 60 and 900 seconds, inclusive.");
         }
     }
 
-    if (getSingleSection(secConfig, SECTION_AMENDMENT_MAJORITY_TIME, strTemp, j_))
+    if (getSingleSection(secConfig, Sections::kAmendmentMajorityTime, strTemp, j_))
     {
         using namespace std::chrono;
         boost::regex const re("^\\s*(\\d+)\\s*(minutes|hours|days|weeks)\\s*(\\s+.*)?$");
         boost::smatch match;
         if (!boost::regex_match(strTemp, match, re))
         {
-            Throw("Invalid " SECTION_AMENDMENT_MAJORITY_TIME
-                                      ", must be: [0-9]+ [minutes|hours|days|weeks]");
+            Throw(
+                std::string("Invalid ") + Sections::kAmendmentMajorityTime +
+                ", must be: [0-9]+ [minutes|hours|days|weeks]");
         }
 
         std::uint32_t const duration = beast::lexicalCastThrow(match[1].str());
 
         if (boost::iequals(match[2], "minutes"))
         {
-            AMENDMENT_MAJORITY_TIME = minutes(duration);
+            amendmentMajorityTime = minutes(duration);
         }
         else if (boost::iequals(match[2], "hours"))
         {
-            AMENDMENT_MAJORITY_TIME = hours(duration);
+            amendmentMajorityTime = hours(duration);
         }
         else if (boost::iequals(match[2], "days"))
         {
-            AMENDMENT_MAJORITY_TIME = days(duration);
+            amendmentMajorityTime = days(duration);
         }
         else if (boost::iequals(match[2], "weeks"))
         {
-            AMENDMENT_MAJORITY_TIME = weeks(duration);
+            amendmentMajorityTime = weeks(duration);
         }
 
-        if (AMENDMENT_MAJORITY_TIME < minutes(15))
+        if (amendmentMajorityTime < minutes(15))
         {
-            Throw("Invalid " SECTION_AMENDMENT_MAJORITY_TIME
-                                      ", the minimum amount of time an amendment must hold a "
-                                      "majority is 15 minutes");
+            Throw(
+                std::string("Invalid ") + Sections::kAmendmentMajorityTime +
+                ", the minimum amount of time an amendment must hold a "
+                "majority is 15 minutes");
         }
     }
 
-    if (getSingleSection(secConfig, SECTION_BETA_RPC_API, strTemp, j_))
-        BETA_RPC_API = beast::lexicalCastThrow(strTemp);
+    if (getSingleSection(secConfig, Sections::kBetaRpcApi, strTemp, j_))
+        betaRpcApi = beast::lexicalCastThrow(strTemp);
 
     // Do not load trusted validator configuration for standalone mode
-    if (!RUN_STANDALONE_)
+    if (!runStandalone_)
     {
         // If a file was explicitly specified, then throw if the
         // path is malformed or if the file does not exist or is
@@ -956,23 +970,23 @@ Config::loadFromString(std::string const& fileContents)
         // if we can't find it.
         boost::filesystem::path validatorsFile;
 
-        if (getSingleSection(secConfig, SECTION_VALIDATORS_FILE, strTemp, j_))
+        if (getSingleSection(secConfig, Sections::kValidatorsFile, strTemp, j_))
         {
             validatorsFile = strTemp;
 
             if (validatorsFile.empty())
             {
-                Throw("Invalid path specified in [" SECTION_VALIDATORS_FILE
-                                          "]");
+                Throw(
+                    std::string("Invalid path specified in [") + Sections::kValidatorsFile + "]");
             }
 
-            if (!validatorsFile.is_absolute() && !CONFIG_DIR.empty())
-                validatorsFile = CONFIG_DIR / validatorsFile;
+            if (!validatorsFile.is_absolute() && !configDir.empty())
+                validatorsFile = configDir / validatorsFile;
 
             if (!boost::filesystem::exists(validatorsFile))
             {
                 Throw(
-                    "The file specified in [" SECTION_VALIDATORS_FILE
+                    std::string("The file specified in [") + Sections::kValidatorsFile +
                     "] "
                     "does not exist: " +
                     validatorsFile.string());
@@ -982,23 +996,19 @@ Config::loadFromString(std::string const& fileContents)
                 !boost::filesystem::is_symlink(validatorsFile))
             {
                 Throw(
-                    "Invalid file specified in [" SECTION_VALIDATORS_FILE "]: " +
-                    validatorsFile.string());
+                    std::string("Invalid file specified in [") + Sections::kValidatorsFile +
+                    "]: " + validatorsFile.string());
             }
         }
-        else if (!CONFIG_DIR.empty())
+        else if (!configDir.empty())
         {
-            validatorsFile = CONFIG_DIR / kValidatorsFileName;
+            validatorsFile = configDir / kValidatorsFileName;
 
             if (!validatorsFile.empty())
             {
-                if (!boost::filesystem::exists(validatorsFile))
-                {
-                    validatorsFile.clear();
-                }
-                else if (
-                    !boost::filesystem::is_regular_file(validatorsFile) &&
-                    !boost::filesystem::is_symlink(validatorsFile))
+                if (!boost::filesystem::exists(validatorsFile) ||
+                    (!boost::filesystem::is_regular_file(validatorsFile) &&
+                     !boost::filesystem::is_symlink(validatorsFile)))
                 {
                     validatorsFile.clear();
                 }
@@ -1020,49 +1030,52 @@ Config::loadFromString(std::string const& fileContents)
 
             auto iniFile = parseIniFile(data, true);
 
-            auto entries = getIniFileSection(iniFile, SECTION_VALIDATORS);
+            auto entries = getIniFileSection(iniFile, Sections::kValidators);
 
             if (entries != nullptr)
-                section(SECTION_VALIDATORS).append(*entries);
+                section(Sections::kValidators).append(*entries);
 
-            auto valKeyEntries = getIniFileSection(iniFile, SECTION_VALIDATOR_KEYS);
+            auto valKeyEntries = getIniFileSection(iniFile, Sections::kValidatorKeys);
 
             if (valKeyEntries != nullptr)
-                section(SECTION_VALIDATOR_KEYS).append(*valKeyEntries);
+                section(Sections::kValidatorKeys).append(*valKeyEntries);
 
-            auto valSiteEntries = getIniFileSection(iniFile, SECTION_VALIDATOR_LIST_SITES);
+            auto valSiteEntries = getIniFileSection(iniFile, Sections::kValidatorListSites);
 
             if (valSiteEntries != nullptr)
-                section(SECTION_VALIDATOR_LIST_SITES).append(*valSiteEntries);
+                section(Sections::kValidatorListSites).append(*valSiteEntries);
 
-            auto valListKeys = getIniFileSection(iniFile, SECTION_VALIDATOR_LIST_KEYS);
+            auto valListKeys = getIniFileSection(iniFile, Sections::kValidatorListKeys);
 
             if (valListKeys != nullptr)
-                section(SECTION_VALIDATOR_LIST_KEYS).append(*valListKeys);
+                section(Sections::kValidatorListKeys).append(*valListKeys);
 
-            auto valListThreshold = getIniFileSection(iniFile, SECTION_VALIDATOR_LIST_THRESHOLD);
+            auto valListThreshold = getIniFileSection(iniFile, Sections::kValidatorListThreshold);
 
             if (valListThreshold != nullptr)
-                section(SECTION_VALIDATOR_LIST_THRESHOLD).append(*valListThreshold);
+                section(Sections::kValidatorListThreshold).append(*valListThreshold);
 
             if ((entries == nullptr) && (valKeyEntries == nullptr) && (valListKeys == nullptr))
             {
                 Throw(
-                    "The file specified in [" SECTION_VALIDATORS_FILE
+                    std::string("The file specified in [") + Sections::kValidatorsFile +
                     "] "
-                    "does not contain a [" SECTION_VALIDATORS
+                    "does not contain a [" +
+                    Sections::kValidators +
                     "], "
-                    "[" SECTION_VALIDATOR_KEYS
+                    "[" +
+                    Sections::kValidatorKeys +
                     "] or "
-                    "[" SECTION_VALIDATOR_LIST_KEYS
+                    "[" +
+                    Sections::kValidatorListKeys +
                     "]"
                     " section: " +
                     validatorsFile.string());
             }
         }
 
-        VALIDATOR_LIST_THRESHOLD = [&]() -> std::optional {
-            auto const& listThreshold = section(SECTION_VALIDATOR_LIST_THRESHOLD);
+        validatorListThreshold = [&]() -> std::optional {
+            auto const& listThreshold = section(Sections::kValidatorListThreshold);
             if (listThreshold.lines().empty())
             {
                 return std::nullopt;
@@ -1075,34 +1088,38 @@ Config::loadFromString(std::string const& fileContents)
                 {
                     return std::nullopt;  // NOTE: Explicitly ask for computed
                 }
-                if (listThreshold > section(SECTION_VALIDATOR_LIST_KEYS).values().size())
+                if (listThreshold > section(Sections::kValidatorListKeys).values().size())
                 {
                     Throw(
-                        "Value in config section "
-                        "[" SECTION_VALIDATOR_LIST_THRESHOLD
+                        std::string(
+                            "Value in config section "
+                            "[") +
+                        Sections::kValidatorListThreshold +
                         "] exceeds the number of configured list keys");
                 }
                 return listThreshold;
             }
 
             Throw(
-                "Config section "
-                "[" SECTION_VALIDATOR_LIST_THRESHOLD "] should contain single value only");
+                std::string(
+                    "Config section "
+                    "[") +
+                Sections::kValidatorListThreshold + "] should contain single value only");
         }();
 
         // Consolidate [validator_keys] and [validators]
-        section(SECTION_VALIDATORS).append(section(SECTION_VALIDATOR_KEYS).lines());
+        section(Sections::kValidators).append(section(Sections::kValidatorKeys).lines());
 
-        if (!section(SECTION_VALIDATOR_LIST_SITES).lines().empty() &&
-            section(SECTION_VALIDATOR_LIST_KEYS).lines().empty())
+        if (!section(Sections::kValidatorListSites).lines().empty() &&
+            section(Sections::kValidatorListKeys).lines().empty())
         {
             Throw(
-                "[" + std::string(SECTION_VALIDATOR_LIST_KEYS) + "] config section is missing");
+                "[" + std::string(Sections::kValidatorListKeys) + "] config section is missing");
         }
     }
 
     {
-        auto const part = section("features");
+        auto const part = section(Sections::kFeatures);
         for (auto const& s : part.values())
         {
             if (auto const f = getRegisteredFeature(s))
@@ -1119,14 +1136,14 @@ Config::loadFromString(std::string const& fileContents)
     // This doesn't properly belong here, but check to make sure that the
     // value specified for network_quorum is achievable:
     {
-        auto pm = PEERS_MAX;
+        auto pm = peersMax;
 
         // FIXME this apparently magic value is actually defined as a constant
         //       elsewhere (see defaultMaxPeers) but we handle this check here.
         if (pm == 0)
             pm = 21;
 
-        if (NETWORK_QUORUM > pm)
+        if (networkQuorum > pm)
         {
             Throw(
                 "The minimum number of required peers (network_quorum) exceeds "
@@ -1138,13 +1155,13 @@ Config::loadFromString(std::string const& fileContents)
 boost::filesystem::path
 Config::getDebugLogFile() const
 {
-    auto logFile = DEBUG_LOGFILE_;
+    auto logFile = debugLogfile_;
 
     if (!logFile.empty() && !logFile.is_absolute())
     {
         // Unless an absolute path for the log file is specified, the
         // path is relative to the config file directory.
-        logFile = boost::filesystem::absolute(logFile, CONFIG_DIR);
+        logFile = boost::filesystem::absolute(logFile, configDir);
     }
 
     if (!logFile.empty())
@@ -1175,7 +1192,7 @@ Config::getValueFor(SizedItem item, std::optional node) const
     auto const index = static_cast>(item);
     XRPL_ASSERT(index < kSizedItems.size(), "xrpl::Config::getValueFor : valid index input");
     XRPL_ASSERT(!node || *node <= 4, "xrpl::Config::getValueFor : unset or valid node");
-    return kSizedItems.at(index).second.at(node.value_or(NODE_SIZE));
+    return kSizedItems.at(index).second.at(node.value_or(nodeSize));
 }
 
 FeeSetup
@@ -1184,16 +1201,16 @@ setupFeeVote(Section const& section)
     FeeSetup setup;
     {
         std::uint64_t temp = 0;
-        if (set(temp, "reference_fee", section) &&
+        if (set(temp, Keys::kReferenceFee, section) &&
             temp <= std::numeric_limits::max())
-            setup.reference_fee = temp;
+            setup.referenceFee = temp;
     }
     {
         std::uint32_t temp = 0;
-        if (set(temp, "account_reserve", section))
-            setup.account_reserve = temp;
-        if (set(temp, "owner_reserve", section))
-            setup.owner_reserve = temp;
+        if (set(temp, Keys::kAccountReserve, section))
+            setup.accountReserve = temp;
+        if (set(temp, Keys::kOwnerReserve, section))
+            setup.ownerReserve = temp;
     }
     return setup;
 }
@@ -1203,9 +1220,9 @@ setupDatabaseCon(Config const& c, std::optional j)
 {
     DatabaseCon::Setup setup;
 
-    setup.startUp = c.START_UP;
+    setup.startUp = c.startUp;
     setup.standAlone = c.standalone();
-    setup.dataDir = c.legacy("database_path");
+    setup.dataDir = c.legacy(Sections::kDatabasePath);
     if (!setup.standAlone && setup.dataDir.empty())
     {
         Throw("database_path must be set.");
@@ -1213,7 +1230,7 @@ setupDatabaseCon(Config const& c, std::optional j)
 
     if (!setup.globalPragma)
     {
-        auto const& sqlite = c.section("sqlite");
+        auto const& sqlite = c.section(Sections::kSqlite);
         auto result = std::make_unique>();
         result->reserve(3);
 
@@ -1307,7 +1324,7 @@ setupDatabaseCon(Config const& c, std::optional j)
             }
         }
 
-        if (showRiskWarning && j && c.LEDGER_HISTORY > kSqliteTuningCutoff)
+        if (showRiskWarning && j && c.ledgerHistory > kSqliteTuningCutoff)
         {
             JLOG(j->warn()) << "reducing the data integrity guarantees from the "
                                "default [sqlite] behavior is not recommended for "
@@ -1330,11 +1347,11 @@ setupDatabaseCon(Config const& c, std::optional j)
     // TX Pragma
     int64_t pageSize = 4096;
     int64_t journalSizeLimit = 1582080;
-    if (c.exists("sqlite"))
+    if (c.exists(Sections::kSqlite))
     {
-        auto& s = c.section("sqlite");
-        set(journalSizeLimit, "journal_size_limit", s);
-        set(pageSize, "page_size", s);
+        auto& s = c.section(Sections::kSqlite);
+        set(journalSizeLimit, Keys::kJournalSizeLimit, s);
+        set(pageSize, Keys::kPageSize, s);
         if (pageSize < 512 || pageSize > 65536)
             Throw("Invalid page_size. Must be between 512 and 65536.");
 
diff --git a/src/xrpld/overlay/Cluster.h b/src/xrpld/overlay/Cluster.h
index 982f11aaae..a8c2083fbc 100644
--- a/src/xrpld/overlay/Cluster.h
+++ b/src/xrpld/overlay/Cluster.h
@@ -2,9 +2,9 @@
 
 #include 
 
-#include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
diff --git a/src/xrpld/overlay/Message.h b/src/xrpld/overlay/Message.h
index 30d4b01cda..cd21ca40c6 100644
--- a/src/xrpld/overlay/Message.h
+++ b/src/xrpld/overlay/Message.h
@@ -80,7 +80,7 @@ private:
     std::vector buffer_;
     std::vector bufferCompressed_;
     std::size_t category_;
-    std::once_flag once_flag_;
+    std::once_flag onceFlag_;
     std::optional validatorKey_;
 
     /** Set the payload header
diff --git a/src/xrpld/overlay/Overlay.h b/src/xrpld/overlay/Overlay.h
index ef97ea7f24..87c6ff132a 100644
--- a/src/xrpld/overlay/Overlay.h
+++ b/src/xrpld/overlay/Overlay.h
@@ -117,11 +117,11 @@ public:
 
     /** Broadcast a proposal. */
     virtual void
-    broadcast(protocol::TMProposeSet& m) = 0;
+    broadcast(protocol::TMProposeSet const& m) = 0;
 
     /** Broadcast a validation. */
     virtual void
-    broadcast(protocol::TMValidation& m) = 0;
+    broadcast(protocol::TMValidation const& m) = 0;
 
     /** Relay a proposal.
      * @param m the serialized proposal
@@ -130,7 +130,7 @@ public:
      * @return the set of peers which have already sent us this proposal
      */
     virtual std::set
-    relay(protocol::TMProposeSet& m, uint256 const& uid, PublicKey const& validator) = 0;
+    relay(protocol::TMProposeSet const& m, uint256 const& uid, PublicKey const& validator) = 0;
 
     /** Relay a validation.
      * @param m the serialized validation
@@ -139,7 +139,7 @@ public:
      * @return the set of peers which have already sent us this validation
      */
     virtual std::set
-    relay(protocol::TMValidation& m, uint256 const& uid, PublicKey const& validator) = 0;
+    relay(protocol::TMValidation const& m, uint256 const& uid, PublicKey const& validator) = 0;
 
     /** Relay a transaction. If the tx reduce-relay feature is enabled then
      * randomly select peers to relay to and queue transaction's hash
diff --git a/src/xrpld/overlay/Slot.h b/src/xrpld/overlay/Slot.h
index 12fecbbf42..7490798787 100644
--- a/src/xrpld/overlay/Slot.h
+++ b/src/xrpld/overlay/Slot.h
@@ -82,7 +82,7 @@ class Slot final
 private:
     friend class Slots;
     using id_t = Peer::id_t;
-    using time_point = typename ClockType::time_point;
+    using time_point = ClockType::time_point;
 
     // a callback to report ignored squelches
     using ignored_squelch_callback = std::function;
@@ -217,7 +217,7 @@ private:
     std::uint16_t reachedThreshold_{0};
 
     // last time peers were selected, used to age the slot
-    typename ClockType::time_point lastSelected_;
+    ClockType::time_point lastSelected_;
 
     SlotState state_{SlotState::Counting};  // slot's state
     SquelchHandler const& handler_;         // squelch/unsquelch handler
@@ -483,7 +483,7 @@ Slot::notInState(PeerState state) const
 }
 
 template 
-std::set
+std::set
 Slot::getSelected() const
 {
     std::set r;
@@ -496,7 +496,7 @@ Slot::getSelected() const
 }
 
 template 
-std::unordered_map>
+std::unordered_map>
 Slot::getPeers() const
 {
     using namespace std::chrono;
@@ -526,8 +526,8 @@ Slot::getPeers() const
 template 
 class Slots final
 {
-    using time_point = typename ClockType::time_point;
-    using id_t = typename Peer::id_t;
+    using time_point = ClockType::time_point;
+    using id_t = Peer::id_t;
     using messages = beast::aged_unordered_map<
         uint256,
         std::unordered_set,
@@ -544,8 +544,8 @@ public:
         : handler_(handler)
         , logs_(registry.getLogs())
         , journal_(registry.getJournal("Slots"))
-        , baseSquelchEnabled_(config.VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE)
-        , maxSelectedPeers_(config.VP_REDUCE_RELAY_SQUELCH_MAX_SELECTED_PEERS)
+        , baseSquelchEnabled_(config.vpReduceRelayBaseSquelchEnable)
+        , maxSelectedPeers_(config.vpReduceRelaySquelchMaxSelectedPeers)
     {
     }
     ~Slots() = default;
@@ -600,7 +600,7 @@ public:
         PublicKey const& validator,
         id_t id,
         protocol::MessageType type,
-        typename Slot::ignored_squelch_callback callback);
+        Slot::ignored_squelch_callback callback);
 
     /** Check if peers stopped relaying messages
      * and if slots stopped receiving messages from the validator.
@@ -651,9 +651,8 @@ public:
     /** Get peers info. Return map of peer's state, count, and squelch
      * expiration milliseconds.
      */
-    std::
-        unordered_map>
-        getPeers(PublicKey const& validator)
+    std::unordered_map>
+    getPeers(PublicKey const& validator)
     {
         auto const& it = slots_.find(validator);
         if (it != slots_.end())
@@ -742,7 +741,7 @@ Slots::updateSlotAndSquelch(
     PublicKey const& validator,
     id_t id,
     protocol::MessageType type,
-    typename Slot::ignored_squelch_callback callback)
+    Slot::ignored_squelch_callback callback)
 {
     if (!addPeerMessage(key, id))
         return;
diff --git a/src/xrpld/overlay/Squelch.h b/src/xrpld/overlay/Squelch.h
index b509f293c2..96d8c26f1d 100644
--- a/src/xrpld/overlay/Squelch.h
+++ b/src/xrpld/overlay/Squelch.h
@@ -14,7 +14,7 @@ namespace xrpl::reduce_relay {
 template 
 class Squelch
 {
-    using time_point = typename ClockType::time_point;
+    using time_point = ClockType::time_point;
 
 public:
     explicit Squelch(beast::Journal journal) : journal_(journal)
diff --git a/src/xrpld/overlay/detail/Cluster.cpp b/src/xrpld/overlay/detail/Cluster.cpp
index dcb40a54f5..15c8fa9c66 100644
--- a/src/xrpld/overlay/detail/Cluster.cpp
+++ b/src/xrpld/overlay/detail/Cluster.cpp
@@ -2,11 +2,11 @@
 
 #include 
 
-#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -19,6 +19,7 @@
 #include 
 #include 
 #include 
+#include 
 
 namespace xrpl {
 
@@ -67,7 +68,7 @@ Cluster::update(
         iter = nodes_.erase(iter);
     }
 
-    nodes_.emplace_hint(iter, identity, name, loadFee, reportTime);
+    nodes_.emplace_hint(iter, identity, std::move(name), loadFee, reportTime);
     return true;
 }
 
diff --git a/src/xrpld/overlay/detail/ConnectAttempt.cpp b/src/xrpld/overlay/detail/ConnectAttempt.cpp
index ad58cc0d2c..295f4f5497 100644
--- a/src/xrpld/overlay/detail/ConnectAttempt.cpp
+++ b/src/xrpld/overlay/detail/ConnectAttempt.cpp
@@ -2,6 +2,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -28,19 +29,17 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
 #include 
 
 #include 
-#include 
 #include 
 #include 
 #include 
 #include 
-#include 
+#include 
 #include 
 #include 
 
@@ -52,7 +51,7 @@ ConnectAttempt::ConnectAttempt(
     endpoint_type remoteEndpoint,
     Resource::Consumer usage,
     shared_context const& context,
-    std::uint32_t id,
+    Peer::id_t id,
     std::shared_ptr const& slot,
     beast::Journal journal,
     OverlayImpl& overlay)
@@ -65,7 +64,6 @@ ConnectAttempt::ConnectAttempt(
     , usage_(usage)
     , strand_(boost::asio::make_strand(ioContext))
     , timer_(ioContext)
-    , stepTimer_(ioContext)
     , streamPtr_(
           std::make_unique(
               socket_type(std::forward(ioContext)),
@@ -78,10 +76,9 @@ ConnectAttempt::ConnectAttempt(
 
 ConnectAttempt::~ConnectAttempt()
 {
-    // slot_ will be null if we successfully connected
-    // and transferred ownership to a PeerImp
     if (slot_ != nullptr)
         overlay_.peerFinder().onClosed(slot_);
+    JLOG(journal_.trace()) << "~ConnectAttempt";
 }
 
 void
@@ -92,30 +89,17 @@ ConnectAttempt::stop()
         boost::asio::post(strand_, std::bind(&ConnectAttempt::stop, shared_from_this()));
         return;
     }
-
-    if (!socket_.is_open())
-        return;
-
-    JLOG(journal_.debug()) << "stop: Stop";
-
-    shutdown();
+    if (socket_.is_open())
+    {
+        JLOG(journal_.debug()) << "Stop";
+    }
+    close();
 }
 
 void
 ConnectAttempt::run()
 {
-    if (!strand_.running_in_this_thread())
-    {
-        boost::asio::post(strand_, std::bind(&ConnectAttempt::run, shared_from_this()));
-        return;
-    }
-
-    JLOG(journal_.debug()) << "run: connecting to " << remoteEndpoint_;
-
-    ioPending_ = true;
-
-    // Allow up to connectTimeout_ seconds to establish remote peer connection
-    setTimer(ConnectionStep::TcpConnect);
+    setTimer();
 
     stream_.next_layer().async_connect(
         remoteEndpoint_,
@@ -126,73 +110,6 @@ ConnectAttempt::run()
 
 //------------------------------------------------------------------------------
 
-void
-ConnectAttempt::shutdown()
-{
-    XRPL_ASSERT(
-        strand_.running_in_this_thread(), "xrpl::ConnectAttempt::shutdown: strand in this thread");
-
-    if (!socket_.is_open())
-        return;
-
-    shutdown_ = true;
-    boost::beast::get_lowest_layer(stream_).cancel();
-
-    tryAsyncShutdown();
-}
-
-void
-ConnectAttempt::tryAsyncShutdown()
-{
-    XRPL_ASSERT(
-        strand_.running_in_this_thread(),
-        "xrpl::ConnectAttempt::tryAsyncShutdown : strand in this thread");
-
-    if (!shutdown_ || currentStep_ == ConnectionStep::ShutdownStarted)
-        return;
-
-    if (ioPending_)
-        return;
-
-    // gracefully shutdown the SSL socket, performing a shutdown handshake
-    if (currentStep_ != ConnectionStep::TcpConnect && currentStep_ != ConnectionStep::TlsHandshake)
-    {
-        setTimer(ConnectionStep::ShutdownStarted);
-        stream_.async_shutdown(bind_executor(
-            strand_,
-            std::bind(&ConnectAttempt::onShutdown, shared_from_this(), std::placeholders::_1)));
-        return;
-    }
-
-    close();
-}
-
-void
-ConnectAttempt::onShutdown(error_code ec)
-{
-    cancelTimer();
-
-    if (ec)
-    {
-        // - eof: the stream was cleanly closed
-        // - operation_aborted: an expired timer (slow shutdown)
-        // - stream_truncated: the tcp connection closed (no handshake) it could
-        // occur if a peer does not perform a graceful disconnect
-        // - broken_pipe: the peer is gone
-        // - application data after close notify: benign SSL shutdown condition
-        bool const shouldLog =
-            (ec != boost::asio::error::eof && ec != boost::asio::error::operation_aborted &&
-             ec.message().find("application data after close notify") == std::string::npos);
-
-        if (shouldLog)
-        {
-            JLOG(journal_.debug()) << "onShutdown: " << ec.message();
-        }
-    }
-
-    close();
-}
-
 void
 ConnectAttempt::close()
 {
@@ -201,93 +118,50 @@ ConnectAttempt::close()
     if (!socket_.is_open())
         return;
 
-    cancelTimer();
+    try
+    {
+        timer_.cancel();
+        socket_.close();
+    }
+    catch (boost::system::system_error const&)  // NOLINT(bugprone-empty-catch)
+    {
+        // ignored
+    }
 
-    error_code ec;
-    socket_.close(ec);  // NOLINT(bugprone-unused-return-value)
+    JLOG(journal_.debug()) << "Closed";
 }
 
 void
 ConnectAttempt::fail(std::string const& reason)
 {
     JLOG(journal_.debug()) << reason;
-    shutdown();
+    close();
 }
 
 void
 ConnectAttempt::fail(std::string const& name, error_code ec)
 {
     JLOG(journal_.debug()) << name << ": " << ec.message();
-    shutdown();
+    close();
 }
 
 void
-ConnectAttempt::setTimer(ConnectionStep step)
+ConnectAttempt::setTimer()
 {
-    currentStep_ = step;
-
-    // Set global timer (only if not already set)
-    if (timer_.expiry() == std::chrono::steady_clock::time_point{})
-    {
-        try
-        {
-            timer_.expires_after(kConnectTimeout);
-            timer_.async_wait(
-                boost::asio::bind_executor(
-                    strand_,
-                    std::bind(
-                        &ConnectAttempt::onTimer, shared_from_this(), std::placeholders::_1)));
-        }
-        catch (std::exception const& ex)
-        {
-            JLOG(journal_.error()) << "setTimer (global): " << ex.what();
-            close();
-            return;
-        }
-    }
-
-    // Set step-specific timer
     try
     {
-        std::chrono::seconds stepTimeout;
-        switch (step)
-        {
-            case ConnectionStep::TcpConnect:
-                stepTimeout = StepTimeouts::kTcpConnect;
-                break;
-            case ConnectionStep::TlsHandshake:
-                stepTimeout = StepTimeouts::kTlsHandshake;
-                break;
-            case ConnectionStep::HttpWrite:
-                stepTimeout = StepTimeouts::kHttpWrite;
-                break;
-            case ConnectionStep::HttpRead:
-                stepTimeout = StepTimeouts::kHttpRead;
-                break;
-            case ConnectionStep::ShutdownStarted:
-                stepTimeout = StepTimeouts::kTlsShutdown;
-                break;
-            case ConnectionStep::Complete:
-            case ConnectionStep::Init:
-                return;  // No timer needed for init or complete step
-        }
-
-        // call to expires_after cancels previous timer
-        stepTimer_.expires_after(stepTimeout);
-        stepTimer_.async_wait(
-            boost::asio::bind_executor(
-                strand_,
-                std::bind(&ConnectAttempt::onTimer, shared_from_this(), std::placeholders::_1)));
-
-        JLOG(journal_.trace()) << "setTimer: " << stepToString(step)
-                               << " timeout=" << stepTimeout.count() << "s";
+        timer_.expires_after(std::chrono::seconds(15));
     }
-    catch (std::exception const& ex)
+    catch (boost::system::system_error const& e)
     {
-        JLOG(journal_.error()) << "setTimer (step " << stepToString(step) << "): " << ex.what();
-        close();
+        JLOG(journal_.error()) << "setTimer: " << e.code();
         return;
     }
+
+    timer_.async_wait(
+        boost::asio::bind_executor(
+            strand_,
+            std::bind(&ConnectAttempt::onTimer, shared_from_this(), std::placeholders::_1)));
 }
 
 void
@@ -296,7 +170,6 @@ ConnectAttempt::cancelTimer()
     try
     {
         timer_.cancel();
-        stepTimer_.cancel();
     }
     catch (boost::system::system_error const&)  // NOLINT(bugprone-empty-catch)
     {
@@ -321,40 +194,18 @@ ConnectAttempt::onTimer(error_code ec)
         close();
         return;
     }
-
-    // Determine which timer expired by checking their expiry times
-    auto const now = std::chrono::steady_clock::now();
-    bool const globalExpired = (timer_.expiry() <= now);
-    bool const stepExpired = (stepTimer_.expiry() <= now);
-
-    if (globalExpired)
-    {
-        JLOG(journal_.debug()) << "onTimer: Global timeout; step: " << stepToString(currentStep_);
-    }
-    else if (stepExpired)
-    {
-        JLOG(journal_.debug()) << "onTimer: Step timeout; step: " << stepToString(currentStep_);
-    }
-    else
-    {
-        JLOG(journal_.warn()) << "onTimer: Unexpected timer callback";
-    }
-
-    close();
+    fail("Timeout");
 }
 
 void
 ConnectAttempt::onConnect(error_code ec)
 {
-    ioPending_ = false;
+    cancelTimer();
 
     if (ec)
     {
         if (ec == boost::asio::error::operation_aborted)
-        {
-            tryAsyncShutdown();
             return;
-        }
 
         fail("onConnect", ec);
         return;
@@ -371,15 +222,7 @@ ConnectAttempt::onConnect(error_code ec)
         return;
     }
 
-    if (shutdown_)
-    {
-        tryAsyncShutdown();
-        return;
-    }
-
-    ioPending_ = true;
-
-    setTimer(ConnectionStep::TlsHandshake);
+    setTimer();
 
     stream_.set_verify_mode(boost::asio::ssl::verify_none);
     stream_.async_handshake(
@@ -392,15 +235,14 @@ ConnectAttempt::onConnect(error_code ec)
 void
 ConnectAttempt::onHandshake(error_code ec)
 {
-    ioPending_ = false;
+    cancelTimer();
+    if (!socket_.is_open())
+        return;
 
     if (ec)
     {
         if (ec == boost::asio::error::operation_aborted)
-        {
-            tryAsyncShutdown();
             return;
-        }
 
         fail("onHandshake", ec);
         return;
@@ -413,29 +255,26 @@ ConnectAttempt::onHandshake(error_code ec)
         return;
     }
 
-    setTimer(ConnectionStep::HttpWrite);
-
-    // check if we connected to ourselves
     if (!overlay_.peerFinder().onConnected(
             slot_, beast::IPAddressConversion::fromAsio(localEndpoint)))
     {
-        fail("Self connection");
+        fail("Duplicate connection");
         return;
     }
 
     auto const sharedValue = makeSharedValue(*streamPtr_, journal_);
     if (!sharedValue)
     {
-        shutdown();
-        return;  // makeSharedValue logs
+        close();  // makeSharedValue logs
+        return;
     }
 
     req_ = makeRequest(
         !overlay_.peerFinder().config().peerPrivate,
-        app_.config().COMPRESSION,
-        app_.config().LEDGER_REPLAY,
-        app_.config().TX_REDUCE_RELAY_ENABLE,
-        app_.config().VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE);
+        app_.config().compression,
+        app_.config().ledgerReplay,
+        app_.config().txReduceRelayEnable,
+        app_.config().vpReduceRelayBaseSquelchEnable);
 
     buildHandshake(
         req_,
@@ -445,14 +284,7 @@ ConnectAttempt::onHandshake(error_code ec)
         remoteEndpoint_.address(),
         app_);
 
-    if (shutdown_)
-    {
-        tryAsyncShutdown();
-        return;
-    }
-
-    ioPending_ = true;
-
+    setTimer();
     boost::beast::http::async_write(
         stream_,
         req_,
@@ -464,30 +296,20 @@ ConnectAttempt::onHandshake(error_code ec)
 void
 ConnectAttempt::onWrite(error_code ec)
 {
-    ioPending_ = false;
+    cancelTimer();
+
+    if (!socket_.is_open())
+        return;
 
     if (ec)
     {
         if (ec == boost::asio::error::operation_aborted)
-        {
-            tryAsyncShutdown();
             return;
-        }
 
         fail("onWrite", ec);
         return;
     }
 
-    if (shutdown_)
-    {
-        tryAsyncShutdown();
-        return;
-    }
-
-    ioPending_ = true;
-
-    setTimer(ConnectionStep::HttpRead);
-
     boost::beast::http::async_read(
         stream_,
         readBuf_,
@@ -501,21 +323,24 @@ void
 ConnectAttempt::onRead(error_code ec)
 {
     cancelTimer();
-    ioPending_ = false;
-    currentStep_ = ConnectionStep::Complete;
+
+    if (!socket_.is_open())
+        return;
 
     if (ec)
     {
+        if (ec == boost::asio::error::operation_aborted)
+            return;
+
         if (ec == boost::asio::error::eof)
         {
             JLOG(journal_.debug()) << "EOF";
-            shutdown();
-            return;
-        }
-
-        if (ec == boost::asio::error::operation_aborted)
-        {
-            tryAsyncShutdown();
+            setTimer();
+            stream_.async_shutdown(
+                boost::asio::bind_executor(
+                    strand_,
+                    std::bind(
+                        &ConnectAttempt::onShutdown, shared_from_this(), std::placeholders::_1)));
             return;
         }
 
@@ -523,13 +348,25 @@ ConnectAttempt::onRead(error_code ec)
         return;
     }
 
-    if (shutdown_)
+    processResponse();
+}
+
+void
+ConnectAttempt::onShutdown(error_code ec)
+{
+    cancelTimer();
+    if (!ec)
     {
-        tryAsyncShutdown();
+        close();
         return;
     }
 
-    processResponse();
+    if (ec != boost::asio::error::eof)
+    {
+        fail("onShutdown", ec);
+        return;
+    }
+    close();
 }
 
 //--------------------------------------------------------------------------
@@ -537,71 +374,47 @@ ConnectAttempt::onRead(error_code ec)
 void
 ConnectAttempt::processResponse()
 {
-    if (!OverlayImpl::isPeerUpgrade(response_))
+    if (response_.result() == boost::beast::http::status::service_unavailable)
     {
-        // A peer may respond with service_unavailable and a list of alternative
-        // peers to connect to, a differing status code is unexpected
-        if (response_.result() != boost::beast::http::status::service_unavailable)
-        {
-            JLOG(journal_.warn()) << "Unable to upgrade to peer protocol: " << response_.result()
-                                  << " (" << response_.reason() << ")";
-            shutdown();
-            return;
-        }
-
-        // Parse response body to determine if this is a redirect or other
-        // service unavailable
-        std::string responseBody;
-        responseBody.reserve(boost::asio::buffer_size(response_.body().data()));
+        json::Value json;
+        json::Reader r;
+        std::string s;
+        s.reserve(boost::asio::buffer_size(response_.body().data()));
         for (auto const buffer : response_.body().data())
         {
-            responseBody.append(
-                static_cast(buffer.data()), boost::asio::buffer_size(buffer));
+            s.append(static_cast(buffer.data()), boost::asio::buffer_size(buffer));
         }
-
-        json::Value json;
-        json::Reader reader;
-        auto const isValidJson = reader.parse(responseBody, json);
-
-        // Check if this is a redirect response (contains peer-ips field)
-        auto const isRedirect = isValidJson && json.isObject() && json.isMember("peer-ips");
-
-        if (!isRedirect)
+        auto const success = r.parse(s, json);
+        if (success)
         {
-            JLOG(journal_.warn()) << "processResponse: " << remoteEndpoint_
-                                  << " failed to upgrade to peer protocol: " << response_.result()
-                                  << " (" << response_.reason() << ")";
-
-            shutdown();
-            return;
+            if (json.isObject() && json.isMember("peer-ips"))
+            {
+                json::Value const& ips = json["peer-ips"];
+                if (ips.isArray())
+                {
+                    std::vector eps;
+                    eps.reserve(ips.size());
+                    for (auto const& v : ips)
+                    {
+                        if (v.isString())
+                        {
+                            error_code ec;
+                            auto const ep = parseEndpoint(v.asString(), ec);
+                            if (!ec)
+                                eps.push_back(ep);
+                        }
+                    }
+                    overlay_.peerFinder().onRedirects(remoteEndpoint_, eps);
+                }
+            }
         }
+    }
 
-        json::Value const& peerIps = json["peer-ips"];
-        if (!peerIps.isArray())
-        {
-            fail("processResponse: invalid peer-ips format");
-            return;
-        }
-
-        // Extract and validate peer endpoints
-        std::vector redirectEndpoints;
-        redirectEndpoints.reserve(peerIps.size());
-
-        for (auto const& ipValue : peerIps)
-        {
-            if (!ipValue.isString())
-                continue;
-
-            error_code ec;
-            auto const endpoint = parseEndpoint(ipValue.asString(), ec);
-            if (!ec)
-                redirectEndpoints.push_back(endpoint);
-        }
-
-        // Notify PeerFinder about the redirect redirectEndpoints may be empty
-        overlay_.peerFinder().onRedirects(remoteEndpoint_, redirectEndpoints);
-
-        fail("processResponse: failed to connect to peer: redirected");
+    if (!OverlayImpl::isPeerUpgrade(response_))
+    {
+        JLOG(journal_.info()) << "Unable to upgrade to peer protocol: " << response_.result()
+                              << " (" << response_.reason() << ")";
+        close();
         return;
     }
 
@@ -625,8 +438,8 @@ ConnectAttempt::processResponse()
     auto const sharedValue = makeSharedValue(*streamPtr_, journal_);
     if (!sharedValue)
     {
-        shutdown();
-        return;  // makeSharedValue logs
+        close();  // makeSharedValue logs
+        return;
     }
 
     try
@@ -641,30 +454,21 @@ ConnectAttempt::processResponse()
 
         usage_.setPublicKey(publicKey);
 
-        JLOG(journal_.debug()) << "Protocol: " << to_string(*negotiatedProtocol);
         JLOG(journal_.info()) << "Public Key: " << toBase58(TokenType::NodePublic, publicKey);
 
+        JLOG(journal_.debug()) << "Protocol: " << to_string(*negotiatedProtocol);
+
         auto const member = app_.getCluster().member(publicKey);
         if (member)
         {
             JLOG(journal_.info()) << "Cluster name: " << *member;
         }
 
-        auto const result = overlay_.peerFinder().activate(slot_, publicKey, member.has_value());
+        auto const result =
+            overlay_.peerFinder().activate(slot_, publicKey, static_cast(member));
         if (result != PeerFinder::Result::Success)
         {
-            std::stringstream ss;
-            ss << "Outbound Connect Attempt " << remoteEndpoint_ << " " << to_string(result);
-            fail(ss.str());
-            return;
-        }
-
-        if (!socket_.is_open())
-            return;
-
-        if (shutdown_)
-        {
-            tryAsyncShutdown();
+            fail("Outbound " + std::string(to_string(result)));
             return;
         }
 
diff --git a/src/xrpld/overlay/detail/ConnectAttempt.h b/src/xrpld/overlay/detail/ConnectAttempt.h
index 949e6accbf..aba224d5c7 100644
--- a/src/xrpld/overlay/detail/ConnectAttempt.h
+++ b/src/xrpld/overlay/detail/ConnectAttempt.h
@@ -1,42 +1,13 @@
 #pragma once
 
+#include 
 #include 
 
-#include 
+#include 
 
 namespace xrpl {
 
-/**
- * @class ConnectAttempt
- * @brief Manages outbound peer connection attempts with comprehensive timeout
- * handling
- *
- * The ConnectAttempt class handles the complete lifecycle of establishing an
- * outbound connection to a peer in the XRPL network. It implements a
- * sophisticated dual-timer system that provides both global timeout protection
- * and per-step timeout diagnostics.
- *
- * The connection establishment follows these steps:
- * 1. **TCP Connect**: Establish basic network connection
- * 2. **TLS Handshake**: Negotiate SSL/TLS encryption
- * 3. **HTTP Write**: Send peer handshake request
- * 4. **HTTP Read**: Receive and validate peer response
- * 5. **Complete**: Connection successfully established
- *
- * Uses a hybrid timeout approach:
- * - **Global Timer**: Hard limit (20s) for entire connection process
- * - **Step Timers**: Individual timeouts for each connection phase
- *
- * - All errors result in connection termination
- *
- * All operations are serialized using boost::asio::strand to ensure thread
- * safety. The class is designed to be used exclusively within the ASIO event
- * loop.
- *
- * @note This class should not be used directly. It is managed by OverlayImpl
- *       as part of the peer discovery and connection management system.
- *
- */
+/** Manages an outbound connection attempt. */
 class ConnectAttempt : public OverlayImpl::Child,
                        public std::enable_shared_from_this
 {
@@ -45,95 +16,29 @@ private:
     using endpoint_type = boost::asio::ip::tcp::endpoint;
     using request_type = boost::beast::http::request;
     using response_type = boost::beast::http::response;
+
     using socket_type = boost::asio::ip::tcp::socket;
     using middle_type = boost::beast::tcp_stream;
     using stream_type = boost::beast::ssl_stream;
     using shared_context = std::shared_ptr;
 
-    /**
-     * @enum ConnectionStep
-     * @brief Represents the current phase of the connection establishment
-     * process
-     *
-     * Used for tracking progress and providing detailed timeout diagnostics.
-     * Each step has its own timeout value defined in StepTimeouts.
-     */
-    enum class ConnectionStep {
-        Init,            // Initial state, nothing started
-        TcpConnect,      // Establishing TCP connection to remote peer
-        TlsHandshake,    // Performing SSL/TLS handshake
-        HttpWrite,       // Sending HTTP upgrade request
-        HttpRead,        // Reading HTTP upgrade response
-        Complete,        // Connection successfully established
-        ShutdownStarted  // Connection shutdown has started
-    };
-
-    // A timeout for connection process, greater than all step timeouts
-    static constexpr std::chrono::seconds kConnectTimeout{25};
-
-    /**
-     * @struct StepTimeouts
-     * @brief Defines timeout values for each connection step
-     *
-     * These timeouts are designed to detect slow individual phases while
-     * allowing the global timeout to enforce the overall time limit.
-     */
-    struct StepTimeouts
-    {
-        // TCP connection timeout
-        static constexpr std::chrono::seconds kTcpConnect{8};
-        // SSL handshake timeout
-        static constexpr std::chrono::seconds kTlsHandshake{8};
-        // HTTP write timeout
-        static constexpr std::chrono::seconds kHttpWrite{3};
-        // HTTP read timeout
-        static constexpr std::chrono::seconds kHttpRead{3};
-        // SSL shutdown timeout
-        static constexpr std::chrono::seconds kTlsShutdown{2};
-    };
-
-    // Core application and networking components
     Application& app_;
-    Peer::id_t const id_;
+    std::uint32_t const id_;
     beast::WrappedSink sink_;
     beast::Journal const journal_;
     endpoint_type remoteEndpoint_;
     Resource::Consumer usage_;
-
     boost::asio::strand strand_;
     boost::asio::basic_waitable_timer timer_;
-    boost::asio::basic_waitable_timer stepTimer_;
-
-    std::unique_ptr streamPtr_;  // SSL stream (owned)
+    std::unique_ptr streamPtr_;
     socket_type& socket_;
     stream_type& stream_;
     boost::beast::multi_buffer readBuf_;
-
     response_type response_;
     std::shared_ptr slot_;
     request_type req_;
 
-    bool shutdown_ = false;   // Shutdown has been initiated
-    bool ioPending_ = false;  // Async I/O operation in progress
-    ConnectionStep currentStep_ = ConnectionStep::Init;
-
 public:
-    /**
-     * @brief Construct a new ConnectAttempt object
-     *
-     * @param app Application context providing configuration and services
-     * @param ioContext ASIO I/O context for async operations
-     * @param remoteEndpoint Target peer endpoint to connect to
-     * @param usage Resource usage tracker for rate limiting
-     * @param context Shared SSL context for encryption
-     * @param id Unique peer identifier for this connection attempt
-     * @param slot PeerFinder slot representing this connection
-     * @param journal Logging interface for diagnostics
-     * @param overlay Parent overlay manager
-     *
-     * @note The constructor only initializes the object. Call run() to begin
-     *       the actual connection attempt.
-     */
     ConnectAttempt(
         Application& app,
         boost::asio::io_context& ioContext,
@@ -147,111 +52,38 @@ public:
 
     ~ConnectAttempt() override;
 
-    /**
-     * @brief Stop the connection attempt
-     *
-     * This method is thread-safe and can be called from any thread.
-     */
     void
     stop() override;
 
-    /**
-     * @brief Begin the connection attempt
-     *
-     * This method is thread-safe and posts to the strand if needed.
-     */
     void
     run();
 
 private:
-    /**
-     * @brief Set timers for the specified connection step
-     *
-     * @param step The connection step to set timers for
-     *
-     * Sets both the step-specific timer and the global timer (if not already
-     * set).
-     */
     void
-    setTimer(ConnectionStep step);
-
-    /**
-     * @brief Cancel both global and step timers
-     *
-     * Used during cleanup and when connection completes successfully.
-     * Exceptions from timer cancellation are safely ignored.
-     */
+    close();
+    void
+    fail(std::string const& reason);
+    void
+    fail(std::string const& name, error_code ec);
+    void
+    setTimer();
     void
     cancelTimer();
-
-    /**
-     * @brief Handle timer expiration events
-     *
-     * @param ec Error code from timer operation
-     *
-     * Determines which timer expired (global vs step) and logs appropriate
-     * diagnostic information before terminating the connection.
-     */
     void
     onTimer(error_code ec);
-
-    // Connection phase handlers
     void
-    onConnect(error_code ec);  // TCP connection completion handler
+    onConnect(error_code ec);
     void
-    onHandshake(error_code ec);  // TLS handshake completion handler
+    onHandshake(error_code ec);
     void
-    onWrite(error_code ec);  // HTTP write completion handler
+    onWrite(error_code ec);
     void
-    onRead(error_code ec);  // HTTP read completion handler
-
-    // Error and cleanup handlers
+    onRead(error_code ec);
     void
-    fail(std::string const& reason);  // Fail with custom reason
-    void
-    fail(std::string const& name, error_code ec);  // Fail with system error
-    void
-    shutdown();  // Initiate graceful shutdown
-    void
-    tryAsyncShutdown();  // Attempt async SSL shutdown
-    void
-    onShutdown(error_code ec);  // SSL shutdown completion handler
-    void
-    close();  // Force close socket
-
-    /**
-     * @brief Process the HTTP upgrade response from peer
-     *
-     * Validates the peer's response, extracts protocol information,
-     * verifies handshake, and either creates a PeerImp or handles
-     * redirect responses.
-     */
+    onShutdown(error_code ec);
     void
     processResponse();
 
-    static std::string
-    stepToString(ConnectionStep step)
-    {
-        switch (step)
-        {
-            case ConnectionStep::Init:
-                return "Init";
-            case ConnectionStep::TcpConnect:
-                return "TcpConnect";
-            case ConnectionStep::TlsHandshake:
-                return "TlsHandshake";
-            case ConnectionStep::HttpWrite:
-                return "HttpWrite";
-            case ConnectionStep::HttpRead:
-                return "HttpRead";
-            case ConnectionStep::Complete:
-                return "Complete";
-            case ConnectionStep::ShutdownStarted:
-                return "ShutdownStarted";
-        }
-        return "Unknown";
-    };
-
     template 
     static boost::asio::ip::tcp::endpoint
     parseEndpoint(std::string const& s, boost::system::error_code& ec)
diff --git a/src/xrpld/overlay/detail/Handshake.cpp b/src/xrpld/overlay/detail/Handshake.cpp
index 3465dbb816..39fd93f1d4 100644
--- a/src/xrpld/overlay/detail/Handshake.cpp
+++ b/src/xrpld/overlay/detail/Handshake.cpp
@@ -209,8 +209,8 @@ buildHandshake(
 
     h.insert("Instance-Cookie", std::to_string(app.instanceID()));
 
-    if (!app.config().SERVER_DOMAIN.empty())
-        h.insert("Server-Domain", app.config().SERVER_DOMAIN);
+    if (!app.config().serverDomain.empty())
+        h.insert("Server-Domain", app.config().serverDomain);
 
     if (beast::IP::isPublic(remoteIp))
         h.insert("Remote-IP", remoteIp.to_string());
@@ -408,10 +408,10 @@ makeResponse(
         "X-Protocol-Ctl",
         makeFeaturesResponseHeader(
             req,
-            app.config().COMPRESSION,
-            app.config().LEDGER_REPLAY,
-            app.config().TX_REDUCE_RELAY_ENABLE,
-            app.config().VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE));
+            app.config().compression,
+            app.config().ledgerReplay,
+            app.config().txReduceRelayEnable,
+            app.config().vpReduceRelayBaseSquelchEnable));
 
     buildHandshake(resp, sharedValue, networkID, publicIp, remoteIp, app);
 
diff --git a/src/xrpld/overlay/detail/Message.cpp b/src/xrpld/overlay/detail/Message.cpp
index 68629ce9a4..120b34c78c 100644
--- a/src/xrpld/overlay/detail/Message.cpp
+++ b/src/xrpld/overlay/detail/Message.cpp
@@ -203,7 +203,7 @@ Message::getBuffer(Compressed tryCompressed)
     if (tryCompressed == Compressed::Off)
         return buffer_;
 
-    std::call_once(once_flag_, &Message::compress, this);
+    std::call_once(onceFlag_, &Message::compress, this);
 
     if (!bufferCompressed_.empty())
     {
diff --git a/src/xrpld/overlay/detail/OverlayImpl.cpp b/src/xrpld/overlay/detail/OverlayImpl.cpp
index a39002bd51..89c7dfe5eb 100644
--- a/src/xrpld/overlay/detail/OverlayImpl.cpp
+++ b/src/xrpld/overlay/detail/OverlayImpl.cpp
@@ -16,7 +16,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -36,6 +35,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -96,7 +97,7 @@ static constexpr auto kDisabled = 0;
 static constexpr auto kOverlay = (1 << 0);
 static constexpr auto kServerInfo = (1 << 1);
 static constexpr auto kServerCounts = (1 << 2);
-static constexpr auto kUNL = (1 << 3);
+static constexpr auto kUnl = (1 << 3);
 }  // namespace CrawlOptions
 
 //------------------------------------------------------------------------------
@@ -112,7 +113,7 @@ OverlayImpl::Child::~Child()
 
 //------------------------------------------------------------------------------
 
-OverlayImpl::Timer::Timer(OverlayImpl& overlay) : Child(overlay), timer(overlay_.io_context_)
+OverlayImpl::Timer::Timer(OverlayImpl& overlay) : Child(overlay), timer(overlay_.ioContext_)
 {
 }
 
@@ -150,10 +151,10 @@ OverlayImpl::Timer::onTimer(error_code ec)
     overlay_.peerFinder_->oncePerSecond();
     overlay_.sendEndpoints();
     overlay_.autoConnect();
-    if (overlay_.app_.config().TX_REDUCE_RELAY_ENABLE)
+    if (overlay_.app_.config().txReduceRelayEnable)
         overlay_.sendTxQueue();
 
-    if ((++overlay_.timer_count_ % Tuning::kCheckIdlePeers) == 0)
+    if ((++overlay_.timerCount_ % Tuning::kCheckIdlePeers) == 0)
         overlay_.deleteIdlePeers();
 
     asyncWait();
@@ -171,9 +172,9 @@ OverlayImpl::OverlayImpl(
     BasicConfig const& config,
     beast::insight::Collector::ptr const& collector)
     : app_(app)
-    , io_context_(ioContext)
-    , work_(std::in_place, boost::asio::make_work_guard(io_context_))
-    , strand_(boost::asio::make_strand(io_context_))
+    , ioContext_(ioContext)
+    , work_(std::in_place, boost::asio::make_work_guard(ioContext_))
+    , strand_(boost::asio::make_strand(ioContext_))
     , setup_(std::move(setup))
     , journal_(app_.getJournal("Overlay"))
     , serverHandler_(serverHandler)
@@ -186,7 +187,7 @@ OverlayImpl::OverlayImpl(
               config,
               collector))
     , resolver_(resolver)
-    , next_id_(1)
+    , nextId_(1)
     , slots_(app, *this, app.config())
     , stats_(
           std::bind(&OverlayImpl::collectMetrics, this),
@@ -209,7 +210,7 @@ OverlayImpl::onHandoff(
     http_request_type&& request,
     endpoint_type remoteEndpoint)
 {
-    auto const id = next_id_++;
+    auto const id = nextId_++;
     auto peerJournal = app_.getJournal("Peer");
     beast::WrappedSink sink(peerJournal.sink(), makePrefix(id));
     beast::Journal const journal(sink);
@@ -259,7 +260,7 @@ OverlayImpl::onHandoff(
         {
             handoff.moved = false;
             handoff.response = makeRedirectResponse(slot, request, remoteEndpoint.address());
-            handoff.keep_alive = beast::rfc2616::isKeepAlive(request);
+            handoff.keepAlive = beast::rfc2616::isKeepAlive(request);
             return handoff;
         }
     }
@@ -271,7 +272,7 @@ OverlayImpl::onHandoff(
         handoff.moved = false;
         handoff.response = makeErrorResponse(
             slot, request, remoteEndpoint.address(), "Unable to agree on a protocol version");
-        handoff.keep_alive = false;
+        handoff.keepAlive = false;
         return handoff;
     }
 
@@ -282,7 +283,7 @@ OverlayImpl::onHandoff(
         handoff.moved = false;
         handoff.response =
             makeErrorResponse(slot, request, remoteEndpoint.address(), "Incorrect security cookie");
-        handoff.keep_alive = false;
+        handoff.keepAlive = false;
         return handoff;
     }
 
@@ -311,7 +312,7 @@ OverlayImpl::onHandoff(
                     << "Peer " << remoteEndpoint << " redirected, " << to_string(result);
                 handoff.moved = false;
                 handoff.response = makeRedirectResponse(slot, request, remoteEndpoint.address());
-                handoff.keep_alive = false;
+                handoff.keepAlive = false;
                 return handoff;
             }
         }
@@ -351,7 +352,7 @@ OverlayImpl::onHandoff(
         peerFinder_->onClosed(slot);
         handoff.moved = false;
         handoff.response = makeErrorResponse(slot, request, remoteEndpoint.address(), e.what());
-        handoff.keep_alive = false;
+        handoff.keepAlive = false;
         return handoff;
     }
 }
@@ -407,7 +408,7 @@ OverlayImpl::makeErrorResponse(
     std::shared_ptr const& slot,
     http_request_type const& request,
     address_type remoteAddress,
-    std::string text)
+    std::string const& text)
 {
     boost::beast::http::response msg;
     msg.version(request.version());
@@ -444,11 +445,11 @@ OverlayImpl::connect(beast::IP::Endpoint const& remoteEndpoint)
 
     auto const p = std::make_shared(
         app_,
-        io_context_,
+        ioContext_,
         beast::IPAddressConversion::toAsioEndpoint(remoteEndpoint),
         usage,
         setup_.context,
-        next_id_++,
+        nextId_++,
         slot,
         app_.getJournal("Peer"),
         *this);
@@ -516,7 +517,7 @@ OverlayImpl::start()
 
     // Populate our boot cache: if there are no entries in [ips] then we use
     // the entries in [ips_fixed].
-    auto bootstrapIps = app_.config().IPS.empty() ? app_.config().IPS_FIXED : app_.config().IPS;
+    auto bootstrapIps = app_.config().ips.empty() ? app_.config().ipsFixed : app_.config().ips;
 
     // If nothing is specified, default to several well-known high-capacity
     // servers to serve as bootstrap:
@@ -558,10 +559,10 @@ OverlayImpl::start()
         });
 
     // Add the ips_fixed from the xrpld.cfg file
-    if (!app_.config().standalone() && !app_.config().IPS_FIXED.empty())
+    if (!app_.config().standalone() && !app_.config().ipsFixed.empty())
     {
         resolver_.resolve(
-            app_.config().IPS_FIXED,
+            app_.config().ipsFixed,
             [this](std::string const& name, std::vector const& addresses) {
                 std::vector ips;
                 ips.reserve(addresses.size());
@@ -885,7 +886,7 @@ OverlayImpl::processCrawl(http_request_type const& req, Handoff& handoff)
     {
         msg.body()["counts"] = getServerCounts();
     }
-    if ((setup_.crawlOptions & CrawlOptions::kUNL) != 0u)
+    if ((setup_.crawlOptions & CrawlOptions::kUnl) != 0u)
     {
         msg.body()["unl"] = getUnlInfo();
     }
@@ -1157,14 +1158,14 @@ OverlayImpl::findPeerByPublicKey(PublicKey const& pubKey)
 }
 
 void
-OverlayImpl::broadcast(protocol::TMProposeSet& m)
+OverlayImpl::broadcast(protocol::TMProposeSet const& m)
 {
     auto const sm = std::make_shared(m, protocol::mtPROPOSE_LEDGER);
     forEach([&](std::shared_ptr const& p) { p->send(sm); });
 }
 
 std::set
-OverlayImpl::relay(protocol::TMProposeSet& m, uint256 const& uid, PublicKey const& validator)
+OverlayImpl::relay(protocol::TMProposeSet const& m, uint256 const& uid, PublicKey const& validator)
 {
     if (auto const toSkip = app_.getHashRouter().shouldRelay(uid))
     {
@@ -1179,14 +1180,14 @@ OverlayImpl::relay(protocol::TMProposeSet& m, uint256 const& uid, PublicKey cons
 }
 
 void
-OverlayImpl::broadcast(protocol::TMValidation& m)
+OverlayImpl::broadcast(protocol::TMValidation const& m)
 {
     auto const sm = std::make_shared(m, protocol::mtVALIDATION);
     forEach([sm](std::shared_ptr const& p) { p->send(sm); });
 }
 
 std::set
-OverlayImpl::relay(protocol::TMValidation& m, uint256 const& uid, PublicKey const& validator)
+OverlayImpl::relay(protocol::TMValidation const& m, uint256 const& uid, PublicKey const& validator)
 {
     if (auto const toSkip = app_.getHashRouter().shouldRelay(uid))
     {
@@ -1257,7 +1258,7 @@ OverlayImpl::relay(
 
     if (!relay)
     {
-        if (!app_.config().TX_REDUCE_RELAY_ENABLE)
+        if (!app_.config().txReduceRelayEnable)
             return;
 
         peers = getActivePeers(toSkip, total, disabled, enabledInSkip);
@@ -1270,13 +1271,13 @@ OverlayImpl::relay(
     auto& txn = tx->get();
     auto const sm = std::make_shared(txn, protocol::mtTRANSACTION);
     peers = getActivePeers(toSkip, total, disabled, enabledInSkip);
-    auto const minRelay = app_.config().TX_REDUCE_RELAY_MIN_PEERS + disabled;
+    auto const minRelay = app_.config().txReduceRelayMinPeers + disabled;
 
-    if (!app_.config().TX_REDUCE_RELAY_ENABLE || total <= minRelay)
+    if (!app_.config().txReduceRelayEnable || total <= minRelay)
     {
         for (auto const& p : peers)
             p->send(sm);
-        if (app_.config().TX_REDUCE_RELAY_ENABLE || app_.config().TX_REDUCE_RELAY_METRICS)
+        if (app_.config().txReduceRelayEnable || app_.config().txReduceRelayMetrics)
             txMetrics_.addMetrics(total, toSkip.size(), 0);
         return;
     }
@@ -1284,8 +1285,8 @@ OverlayImpl::relay(
     // We have more peers than the minimum (disabled + minimum enabled),
     // relay to all disabled and some randomly selected enabled that
     // do not have the transaction.
-    auto const enabledTarget = app_.config().TX_REDUCE_RELAY_MIN_PEERS +
-        ((total - minRelay) * app_.config().TX_RELAY_PERCENTAGE / 100);
+    auto const enabledTarget = app_.config().txReduceRelayMinPeers +
+        ((total - minRelay) * app_.config().txRelayPercentage / 100);
 
     txMetrics_.addMetrics(enabledTarget, toSkip.size(), disabled);
 
@@ -1516,7 +1517,7 @@ setupOverlay(BasicConfig const& config, beast::Journal j)
     Overlay::Setup setup;
 
     {
-        auto const& section = config.section("overlay");
+        auto const& section = config.section(Sections::kOverlay);
         setup.context = makeSslContext("");
 
         set(setup.ipLimit, "ip_limit", section);
@@ -1543,7 +1544,7 @@ setupOverlay(BasicConfig const& config, beast::Journal j)
     }
 
     {
-        auto const& section = config.section("crawl");
+        auto const& section = config.section(Sections::kCrawl);
         auto const& values = section.values();
 
         if (values.size() > 1)
@@ -1569,33 +1570,33 @@ setupOverlay(BasicConfig const& config, beast::Journal j)
 
         if (crawlEnabled)
         {
-            if (get(section, "overlay", true))
+            if (get(section, Keys::kOverlay, true))
             {
                 setup.crawlOptions |= CrawlOptions::kOverlay;
             }
-            if (get(section, "server", true))
+            if (get(section, Keys::kServer, true))
             {
                 setup.crawlOptions |= CrawlOptions::kServerInfo;
             }
-            if (get(section, "counts", false))
+            if (get(section, Keys::kCounts, false))
             {
                 setup.crawlOptions |= CrawlOptions::kServerCounts;
             }
-            if (get(section, "unl", true))
+            if (get(section, Keys::kUnl, true))
             {
-                setup.crawlOptions |= CrawlOptions::kUNL;
+                setup.crawlOptions |= CrawlOptions::kUnl;
             }
         }
     }
     {
-        auto const& section = config.section("vl");
+        auto const& section = config.section(Sections::kVl);
 
         set(setup.vlEnabled, "enabled", section);
     }
 
     try
     {
-        auto id = config.legacy("network_id");
+        auto id = config.legacy(Sections::kNetworkId);
 
         if (!id.empty())
         {
diff --git a/src/xrpld/overlay/detail/OverlayImpl.h b/src/xrpld/overlay/detail/OverlayImpl.h
index 65c93a5845..545d9eb75c 100644
--- a/src/xrpld/overlay/detail/OverlayImpl.h
+++ b/src/xrpld/overlay/detail/OverlayImpl.h
@@ -80,7 +80,7 @@ private:
     };
 
     Application& app_;
-    boost::asio::io_context& io_context_;
+    boost::asio::io_context& ioContext_;
     std::optional> work_;
     boost::asio::strand strand_;
     mutable std::recursive_mutex mutex_;  // VFALCO use std::mutex
@@ -96,8 +96,8 @@ private:
     hash_map, std::weak_ptr> peers_;
     hash_map> ids_;
     Resolver& resolver_;
-    std::atomic next_id_;
-    int timer_count_{0};
+    std::atomic nextId_;
+    int timerCount_{0};
     std::atomic jqTransOverflow_{0};
     std::atomic peerDisconnects_{0};
     std::atomic peerDisconnectsCharges_{0};
@@ -202,16 +202,16 @@ public:
     findPeerByPublicKey(PublicKey const& pubKey) override;
 
     void
-    broadcast(protocol::TMProposeSet& m) override;
+    broadcast(protocol::TMProposeSet const& m) override;
 
     void
-    broadcast(protocol::TMValidation& m) override;
+    broadcast(protocol::TMValidation const& m) override;
 
     std::set
-    relay(protocol::TMProposeSet& m, uint256 const& uid, PublicKey const& validator) override;
+    relay(protocol::TMProposeSet const& m, uint256 const& uid, PublicKey const& validator) override;
 
     std::set
-    relay(protocol::TMValidation& m, uint256 const& uid, PublicKey const& validator) override;
+    relay(protocol::TMValidation const& m, uint256 const& uid, PublicKey const& validator) override;
 
     void
     relay(
@@ -433,7 +433,7 @@ private:
         std::shared_ptr const& slot,
         http_request_type const& request,
         address_type remoteAddress,
-        std::string msg);
+        std::string const& msg);
 
     /** Handles crawl requests. Crawl returns information about the
         node and its peers so crawlers can map the network.
diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp
index 070f91c0bf..e1d7d23215 100644
--- a/src/xrpld/overlay/detail/PeerImp.cpp
+++ b/src/xrpld/overlay/detail/PeerImp.cpp
@@ -22,6 +22,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -58,7 +59,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -68,19 +68,20 @@
 #include 
 #include 
 #include 
+#include 
 #include 
-#include 
 #include 
 #include 
 #include 
 #include 
-#include 
+#include 
 
 #include 
 
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -110,9 +111,6 @@ constexpr std::chrono::milliseconds kPeerHighLatency{300};
 /** How often we PING the peer to check for latency and sendq probe */
 constexpr std::chrono::seconds kPeerTimerInterval{60};
 
-/** The timeout for a shutdown timer */
-constexpr std::chrono::seconds kShutdownTimerInterval{5};
-
 }  // namespace
 
 // TODO: Remove this exclusion once unit tests are added after the hotfix
@@ -158,22 +156,20 @@ PeerImp::PeerImp(
     , request_(std::move(request))
     , headers_(request_)
     , compressionEnabled_(
-          peerFeatureEnabled(headers_, kFeatureCompr, "lz4", app_.config().COMPRESSION)
+          peerFeatureEnabled(headers_, kFeatureCompr, "lz4", app_.config().compression)
               ? Compressed::On
               : Compressed::Off)
     , txReduceRelayEnabled_(
-          peerFeatureEnabled(headers_, kFeatureTxrr, app_.config().TX_REDUCE_RELAY_ENABLE))
+          peerFeatureEnabled(headers_, kFeatureTxrr, app_.config().txReduceRelayEnable))
     , ledgerReplayEnabled_(
-          peerFeatureEnabled(headers_, kFeatureLedgerReplay, app_.config().LEDGER_REPLAY))
+          peerFeatureEnabled(headers_, kFeatureLedgerReplay, app_.config().ledgerReplay))
     , ledgerReplayMsgHandler_(app, app.getLedgerReplayer())
 {
-    JLOG(journal_.info()) << "compression enabled " << (compressionEnabled_ == Compressed::On)
-                          << " vp reduce-relay base squelch enabled "
-                          << peerFeatureEnabled(
-                                 headers_,
-                                 kFeatureVprr,
-                                 app_.config().VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE)
-                          << " tx reduce-relay enabled " << txReduceRelayEnabled_;
+    JLOG(journal_.info())
+        << "compression enabled " << (compressionEnabled_ == Compressed::On)
+        << " vp reduce-relay base squelch enabled "
+        << peerFeatureEnabled(headers_, kFeatureVprr, app_.config().vpReduceRelayBaseSquelchEnable)
+        << " tx reduce-relay enabled " << txReduceRelayEnabled_;
 }
 
 PeerImp::~PeerImp()
@@ -201,84 +197,70 @@ stringIsUInt256Sized(std::string const& pBuffStr)
 void
 PeerImp::run()
 {
-    if (!strand_.running_in_this_thread())
-    {
-        post(strand_, std::bind(&PeerImp::run, shared_from_this()));
-        return;
-    }
+    dispatch(strand_, [self = shared_from_this()]() {
+        auto parseLedgerHash = [](std::string_view value) -> std::optional {
+            if (uint256 ret; ret.parseHex(value))
+                return ret;
 
-    auto parseLedgerHash = [](std::string_view value) -> std::optional {
-        if (uint256 ret; ret.parseHex(value))
-            return ret;
+            if (auto const s = base64Decode(value); s.size() == uint256::size())
+                return uint256::fromRaw(s);
 
-        if (auto const s = base64Decode(value); s.size() == uint256::size())
-            return uint256::fromRaw(s);
+            return std::nullopt;
+        };
 
-        return std::nullopt;
-    };
+        std::optional closed;
+        std::optional previous;
 
-    std::optional closed;
-    std::optional previous;
+        if (auto const iter = self->headers_.find("Closed-Ledger"); iter != self->headers_.end())
+        {
+            closed = parseLedgerHash(iter->value());
 
-    if (auto const iter = headers_.find("Closed-Ledger"); iter != headers_.end())
-    {
-        closed = parseLedgerHash(iter->value());
+            if (!closed)
+                self->fail("Malformed handshake data (1)");
+        }
 
-        if (!closed)
-            fail("Malformed handshake data (1)");
-    }
+        if (auto const iter = self->headers_.find("Previous-Ledger"); iter != self->headers_.end())
+        {
+            previous = parseLedgerHash(iter->value());
 
-    if (auto const iter = headers_.find("Previous-Ledger"); iter != headers_.end())
-    {
-        previous = parseLedgerHash(iter->value());
+            if (!previous)
+                self->fail("Malformed handshake data (2)");
+        }
 
-        if (!previous)
-            fail("Malformed handshake data (2)");
-    }
+        if (previous && !closed)
+            self->fail("Malformed handshake data (3)");
 
-    if (previous && !closed)
-        fail("Malformed handshake data (3)");
+        {
+            std::scoped_lock const sl(self->recentLock_);
+            if (closed)
+                self->closedLedgerHash_ = *closed;
+            if (previous)
+                self->previousLedgerHash_ = *previous;
+        }
 
-    {
-        std::scoped_lock const sl(recentLock_);
-        if (closed)
-            closedLedgerHash_ = *closed;
-        if (previous)
-            previousLedgerHash_ = *previous;
-    }
+        if (self->inbound_)
+        {
+            self->doAccept();
+        }
+        else
+        {
+            self->doProtocolStart();
+        }
 
-    if (inbound_)
-    {
-        doAccept();
-    }
-    else
-    {
-        doProtocolStart();
-    }
-
-    // Anything else that needs to be done with the connection should be
-    // done in doProtocolStart
+        // Anything else that needs to be done with the connection should be
+        // done in doProtocolStart
+    });
 }
 
 void
 PeerImp::stop()
 {
-    if (!strand_.running_in_this_thread())
-    {
-        post(strand_, std::bind(&PeerImp::stop, shared_from_this()));
-        return;
-    }
+    dispatch(strand_, [self = shared_from_this()]() {
+        if (!self->socket_.is_open())
+            return;
 
-    if (!socket_.is_open())
-        return;
-
-    // The rationale for using different severity levels is that
-    // outbound connections are under our control and may be logged
-    // at a higher level, but inbound connections are more numerous and
-    // uncontrolled so to prevent log flooding the severity is reduced.
-    JLOG(journal_.debug()) << "stop: Stop";
-
-    shutdown();
+        self->close();
+    });
 }
 
 //------------------------------------------------------------------------------
@@ -286,135 +268,127 @@ PeerImp::stop()
 void
 PeerImp::send(std::shared_ptr const& m)
 {
-    if (!strand_.running_in_this_thread())
-    {
-        post(strand_, std::bind(&PeerImp::send, shared_from_this(), m));
-        return;
-    }
+    dispatch(strand_, [self = shared_from_this(), m]() {
+        if (self->gracefulClose_)
+            return;
+        if (self->detaching_)
+            return;
+        if (!self->socket_.is_open())
+            return;
 
-    if (!socket_.is_open())
-        return;
+        auto validator = m->getValidatorKey();
+        if (validator && !self->squelch_.expireSquelch(*validator))
+        {
+            self->overlay_.reportOutboundTraffic(
+                TrafficCount::Category::SquelchSuppressed,
+                static_cast(m->getBuffer(self->compressionEnabled_).size()));
+            return;
+        }
 
-    // we are in progress of closing the connection
-    if (shutdown_)
-    {
-        tryAsyncShutdown();
-        return;
-    }
+        // report categorized outgoing traffic
+        self->overlay_.reportOutboundTraffic(
+            safeCast(m->getCategory()),
+            static_cast(m->getBuffer(self->compressionEnabled_).size()));
 
-    auto validator = m->getValidatorKey();
-    if (validator && !squelch_.expireSquelch(*validator))
-    {
-        overlay_.reportOutboundTraffic(
-            TrafficCount::Category::SquelchSuppressed,
-            static_cast(m->getBuffer(compressionEnabled_).size()));
-        return;
-    }
+        // report total outgoing traffic
+        self->overlay_.reportOutboundTraffic(
+            TrafficCount::Category::Total,
+            static_cast(m->getBuffer(self->compressionEnabled_).size()));
 
-    // report categorized outgoing traffic
-    overlay_.reportOutboundTraffic(
-        safeCast(m->getCategory()),
-        static_cast(m->getBuffer(compressionEnabled_).size()));
+        auto sendqSize = self->sendQueue_.size();
 
-    // report total outgoing traffic
-    overlay_.reportOutboundTraffic(
-        TrafficCount::Category::Total, static_cast(m->getBuffer(compressionEnabled_).size()));
+        if (sendqSize < Tuning::kTargetSendQueue)
+        {
+            // To detect a peer that does not read from their
+            // side of the connection, we expect a peer to have
+            // a small sendq periodically
+            self->largeSendq_ = 0;
+        }
+        else if (
+            auto sink = self->journal_.debug();
+            sink && (sendqSize % Tuning::kSendQueueLogFreq) == 0)
+        {
+            std::string const n = self->name();
+            sink << n << " sendq: " << sendqSize;
+        }
 
-    auto sendqSize = sendQueue_.size();
+        self->sendQueue_.push(m);
 
-    if (sendqSize < Tuning::kTargetSendQueue)
-    {
-        // To detect a peer that does not read from their
-        // side of the connection, we expect a peer to have
-        // a small senq periodically
-        largeSendq_ = 0;
-    }
-    else if (auto sink = journal_.debug(); sink && (sendqSize % Tuning::kSendQueueLogFreq) == 0)
-    {
-        std::string const n = name();
-        sink << n << " sendq: " << sendqSize;
-    }
+        if (sendqSize != 0)
+            return;
 
-    sendQueue_.push(m);
-
-    if (sendqSize != 0)
-        return;
-
-    writePending_ = true;
-    boost::asio::async_write(
-        stream_,
-        boost::asio::buffer(sendQueue_.front()->getBuffer(compressionEnabled_)),
-        bind_executor(
-            strand_,
-            std::bind(
-                &PeerImp::onWriteMessage,
-                shared_from_this(),
-                std::placeholders::_1,
-                std::placeholders::_2)));
+        boost::asio::async_write(
+            self->stream_,
+            boost::asio::buffer(self->sendQueue_.front()->getBuffer(self->compressionEnabled_)),
+            bind_executor(
+                self->strand_,
+                std::bind(
+                    &PeerImp::onWriteMessage, self, std::placeholders::_1, std::placeholders::_2)));
+    });
 }
 
 void
 PeerImp::sendTxQueue()
 {
-    if (!strand_.running_in_this_thread())
-    {
-        post(strand_, std::bind(&PeerImp::sendTxQueue, shared_from_this()));
-        return;
-    }
-
-    if (!txQueue_.empty())
-    {
-        protocol::TMHaveTransactions ht;
-        std::ranges::for_each(
-            txQueue_, [&](auto const& hash) { ht.add_hashes(hash.data(), hash.size()); });
-        JLOG(pJournal_.trace()) << "sendTxQueue " << txQueue_.size();
-        txQueue_.clear();
-        send(std::make_shared(ht, protocol::mtHAVE_TRANSACTIONS));
-    }
+    dispatch(strand_, [self = shared_from_this()]() {
+        if (!self->txQueue_.empty())
+        {
+            protocol::TMHaveTransactions ht;
+            std::ranges::for_each(
+                self->txQueue_, [&](auto const& hash) { ht.add_hashes(hash.data(), hash.size()); });
+            JLOG(self->pJournal_.trace()) << "sendTxQueue " << self->txQueue_.size();
+            self->txQueue_.clear();
+            self->send(std::make_shared(ht, protocol::mtHAVE_TRANSACTIONS));
+        }
+    });
 }
 
 void
 PeerImp::addTxQueue(uint256 const& hash)
 {
-    if (!strand_.running_in_this_thread())
-    {
-        post(strand_, std::bind(&PeerImp::addTxQueue, shared_from_this(), hash));
-        return;
-    }
+    dispatch(strand_, [self = shared_from_this(), hash]() {
+        if (self->txQueue_.size() == reduce_relay::kMaxTxQueueSize)
+        {
+            JLOG(self->pJournal_.warn()) << "addTxQueue exceeds the cap";
+            self->sendTxQueue();
+        }
 
-    if (txQueue_.size() == reduce_relay::kMaxTxQueueSize)
-    {
-        JLOG(pJournal_.warn()) << "addTxQueue exceeds the cap";
-        sendTxQueue();
-    }
-
-    txQueue_.insert(hash);
-    JLOG(pJournal_.trace()) << "addTxQueue " << txQueue_.size();
+        self->txQueue_.insert(hash);
+        JLOG(self->pJournal_.trace()) << "addTxQueue " << self->txQueue_.size();
+    });
 }
 
 void
 PeerImp::removeTxQueue(uint256 const& hash)
 {
-    if (!strand_.running_in_this_thread())
-    {
-        post(strand_, std::bind(&PeerImp::removeTxQueue, shared_from_this(), hash));
-        return;
-    }
-
-    auto removed = txQueue_.erase(hash);
-    JLOG(pJournal_.trace()) << "removeTxQueue " << removed;
+    dispatch(strand_, [self = shared_from_this(), hash]() {
+        auto removed = self->txQueue_.erase(hash);
+        JLOG(self->pJournal_.trace()) << "removeTxQueue " << removed;
+    });
 }
 
 void
 PeerImp::charge(Resource::Charge const& fee, std::string const& context)
 {
-    if ((usage_.charge(fee, context) == Resource::Disposition::Drop) &&
-        usage_.disconnect(pJournal_) && strand_.running_in_this_thread())
-    {
-        // Sever the connection
-        overlay_.incPeerDisconnectCharges();
-        fail("charge: Resources");
-    }
+    dispatch(strand_, [self = shared_from_this(), fee, context]() {
+        if ((self->usage_.charge(fee, context) == Resource::Disposition::Drop) &&
+            self->usage_.disconnect(self->pJournal_))
+        {
+            // Idempotent: only the first worker to observe Drop counts the
+            // metric and posts fail().  Without the guard, several queued
+            // workers can all see Drop before fail() lands on the strand,
+            // overcounting peerDisconnectsCharges_ and posting duplicate
+            // shutdowns.  fail(std::string const&) self-posts to strand_
+            // when invoked off-strand.
+            bool expected = false;
+            if (self->chargeDisconnectFired_.compare_exchange_strong(
+                    expected, true, std::memory_order_acq_rel))
+            {
+                self->overlay_.incPeerDisconnectCharges();
+                self->fail("charge: Resources");
+            }
+        }
+    });
 }
 
 //------------------------------------------------------------------------------
@@ -623,146 +597,92 @@ PeerImp::hasRange(std::uint32_t uMin, std::uint32_t uMax)
 //------------------------------------------------------------------------------
 
 void
-PeerImp::fail(std::string const& name, error_code ec)
+PeerImp::close()
 {
-    XRPL_ASSERT(strand_.running_in_this_thread(), "xrpl::PeerImp::fail : strand in this thread");
-
+    XRPL_ASSERT(strand_.running_in_this_thread(), "xrpl::PeerImp::close : strand in this thread");
     if (!socket_.is_open())
         return;
 
-    JLOG(journal_.warn()) << name << ": " << ec.message();
+    detaching_ = true;  // DEPRECATED
 
-    shutdown();
+    cancelTimer();
+    error_code ec;
+    socket_.close(ec);  // NOLINT(bugprone-unused-return-value)
+
+    overlay_.incPeerDisconnect();
+    JLOG((inbound_ ? journal_.debug() : journal_.info())) << "close: Closed";
 }
 
 void
 PeerImp::fail(std::string const& reason)
 {
-    if (!strand_.running_in_this_thread())
-    {
-        post(
-            strand_,
-            std::bind(
-                (void (Peer::*)(std::string const&))&PeerImp::fail, shared_from_this(), reason));
-        return;
-    }
+    dispatch(strand_, [self = shared_from_this(), reason]() {
+        if (self->journal_.active(beast::Severity::Warning) && self->socket_.is_open())
+        {
+            std::string const n = self->name();
+            JLOG(self->journal_.warn()) << n << " failed: " << reason;
+        }
+        self->close();
+    });
+}
 
+void
+PeerImp::fail(std::string const& name, error_code ec)
+{
+    XRPL_ASSERT(strand_.running_in_this_thread(), "xrpl::PeerImp::fail : strand in this thread");
     if (!socket_.is_open())
         return;
 
-    // Call to name() locks, log only if the message will be outputted
-    if (journal_.active(beast::Severity::Warning))
-    {
-        std::string const n = name();
-        JLOG(journal_.warn()) << n << " failed: " << reason;
-    }
-
-    shutdown();
-}
-
-void
-PeerImp::tryAsyncShutdown()
-{
-    XRPL_ASSERT(
-        strand_.running_in_this_thread(),
-        "xrpl::PeerImp::tryAsyncShutdown : strand in this thread");
-
-    if (!shutdown_ || shutdownStarted_)
-        return;
-
-    if (readPending_ || writePending_)
-        return;
-
-    shutdownStarted_ = true;
-
-    setTimer(kShutdownTimerInterval);
-
-    // gracefully shutdown the SSL socket, performing a shutdown handshake
-    stream_.async_shutdown(bind_executor(
-        strand_, std::bind(&PeerImp::onShutdown, shared_from_this(), std::placeholders::_1)));
-}
-
-void
-PeerImp::shutdown()
-{
-    XRPL_ASSERT(strand_.running_in_this_thread(), "xrpl::PeerImp::shutdown: strand in this thread");
-
-    if (!socket_.is_open() || shutdown_)
-        return;
-
-    shutdown_ = true;
-
-    boost::beast::get_lowest_layer(stream_).cancel();
-
-    tryAsyncShutdown();
-}
-
-void
-PeerImp::onShutdown(error_code ec)
-{
-    cancelTimer();
-    if (ec)
-    {
-        // - eof: the stream was cleanly closed
-        // - operation_aborted: an expired timer (slow shutdown)
-        // - stream_truncated: the tcp connection closed (no handshake) it could
-        // occur if a peer does not perform a graceful disconnect
-        // - broken_pipe: the peer is gone
-        bool const shouldLog =
-            (ec != boost::asio::error::eof && ec != boost::asio::error::operation_aborted &&
-             ec.message().find("application data after close notify") == std::string::npos);
-
-        if (shouldLog)
-        {
-            JLOG(journal_.debug()) << "onShutdown: " << ec.message();
-        }
-    }
+    JLOG(journal_.warn()) << name << ": " << ec.message();
 
     close();
 }
 
 void
-PeerImp::close()
+PeerImp::gracefulClose()
 {
-    XRPL_ASSERT(strand_.running_in_this_thread(), "xrpl::PeerImp::close : strand in this thread");
-
-    if (!socket_.is_open())
+    XRPL_ASSERT(
+        strand_.running_in_this_thread(), "xrpl::PeerImp::gracefulClose : strand in this thread");
+    XRPL_ASSERT(socket_.is_open(), "xrpl::PeerImp::gracefulClose : socket is open");
+    XRPL_ASSERT(!gracefulClose_, "xrpl::PeerImp::gracefulClose : socket is not closing");
+    gracefulClose_ = true;
+    if (!sendQueue_.empty())
         return;
-
-    cancelTimer();
-
-    error_code ec;
-    socket_.close(ec);  // NOLINT(bugprone-unused-return-value)
-
-    overlay_.incPeerDisconnect();
-
-    // The rationale for using different severity levels is that
-    // outbound connections are under our control and may be logged
-    // at a higher level, but inbound connections are more numerous and
-    // uncontrolled so to prevent log flooding the severity is reduced.
-    JLOG((inbound_ ? journal_.debug() : journal_.info())) << "close: Closed";
+    setTimer();
+    stream_.async_shutdown(bind_executor(
+        strand_, std::bind(&PeerImp::onShutdown, shared_from_this(), std::placeholders::_1)));
 }
 
-//------------------------------------------------------------------------------
-
 void
-PeerImp::setTimer(std::chrono::seconds interval)
+PeerImp::setTimer()
 {
     try
     {
-        timer_.expires_after(interval);
+        timer_.expires_after(kPeerTimerInterval);
     }
-    catch (std::exception const& ex)
+    catch (boost::system::system_error const& e)
     {
-        JLOG(journal_.error()) << "setTimer: " << ex.what();
-        shutdown();
+        JLOG(journal_.error()) << "setTimer: " << e.code();
         return;
     }
-
     timer_.async_wait(bind_executor(
         strand_, std::bind(&PeerImp::onTimer, shared_from_this(), std::placeholders::_1)));
 }
 
+// convenience for ignoring the error code
+void
+PeerImp::cancelTimer() noexcept
+{
+    try
+    {
+        timer_.cancel();
+    }
+    catch (boost::system::system_error const&)  // NOLINT(bugprone-empty-catch)
+    {
+        // ignored
+    }
+}
+
 //------------------------------------------------------------------------------
 
 std::string
@@ -776,14 +696,11 @@ PeerImp::makePrefix(std::string const& fingerprint)
 void
 PeerImp::onTimer(error_code const& ec)
 {
-    XRPL_ASSERT(strand_.running_in_this_thread(), "xrpl::PeerImp::onTimer : strand in this thread");
-
     if (!socket_.is_open())
         return;
 
     if (ec)
     {
-        // do not initiate shutdown, timers are frequently cancelled
         if (ec == boost::asio::error::operation_aborted)
             return;
 
@@ -793,15 +710,6 @@ PeerImp::onTimer(error_code const& ec)
         return;
     }
 
-    // the timer expired before the shutdown completed
-    // force close the connection
-    if (shutdown_)
-    {
-        JLOG(journal_.debug()) << "onTimer: shutdown timer expired";
-        close();
-        return;
-    }
-
     if (largeSendq_++ >= Tuning::kSendqIntervals)
     {
         fail("Large send queue");
@@ -817,8 +725,8 @@ PeerImp::onTimer(error_code const& ec)
             duration = clock_type::now() - trackingTime_;
         }
 
-        if ((t == Tracking::Diverged && (duration > app_.config().MAX_DIVERGED_TIME)) ||
-            (t == Tracking::Unknown && (duration > app_.config().MAX_UNKNOWN_TIME)))
+        if ((t == Tracking::Diverged && (duration > app_.config().maxDivergedTime)) ||
+            (t == Tracking::Unknown && (duration > app_.config().maxUnknownTime)))
         {
             overlay_.peerFinder().onFailure(slot_);
             fail("Not useful");
@@ -842,20 +750,32 @@ PeerImp::onTimer(error_code const& ec)
 
     send(std::make_shared(message, protocol::mtPING));
 
-    setTimer(kPeerTimerInterval);
+    setTimer();
 }
 
 void
-PeerImp::cancelTimer() noexcept
+PeerImp::onShutdown(error_code ec)
 {
-    try
+    cancelTimer();
+
+    if (ec)
     {
-        timer_.cancel();
-    }
-    catch (std::exception const& ex)
-    {
-        JLOG(journal_.error()) << "cancelTimer: " << ex.what();
+        // - eof: the stream was cleanly closed
+        // - operation_aborted: an expired timer (slow shutdown)
+        // - stream_truncated: the tcp connection closed (no handshake) it could
+        // occur if a peer does not perform a graceful disconnect
+        // - broken_pipe: the peer is gone
+        bool const shouldLog =
+            (ec != boost::asio::error::eof && ec != boost::asio::error::operation_aborted &&
+             !ec.message().contains("application data after close notify"));
+
+        if (shouldLog)
+        {
+            JLOG(journal_.debug()) << "onShutdown: " << ec.message();
+        }
     }
+
+    close();
 }
 
 //------------------------------------------------------------------------------
@@ -864,15 +784,6 @@ PeerImp::doAccept()
 {
     XRPL_ASSERT(readBuffer_.size() == 0, "xrpl::PeerImp::doAccept : empty read buffer");
 
-    JLOG(journal_.debug()) << "doAccept";
-
-    // a shutdown was initiated before the handshake, there is nothing to do
-    if (shutdown_)
-    {
-        tryAsyncShutdown();
-        return;
-    }
-
     auto const sharedValue = makeSharedValue(*streamPtr_, journal_);
 
     // This shouldn't fail since we already computed
@@ -883,7 +794,7 @@ PeerImp::doAccept()
         return;
     }
 
-    JLOG(journal_.debug()) << "Protocol: " << to_string(protocol_);
+    JLOG(journal_.info()) << "Protocol: " << to_string(protocol_);
 
     if (auto member = app_.getCluster().member(publicKey_))
     {
@@ -923,16 +834,15 @@ PeerImp::doAccept()
                 error_code ec, std::size_t bytesTransferred) {
                 if (!socket_.is_open())
                     return;
-                if (ec == boost::asio::error::operation_aborted)
-                {
-                    tryAsyncShutdown();
-                    return;
-                }
                 if (ec)
                 {
+                    if (ec == boost::asio::error::operation_aborted)
+                        return;
+
                     fail("onWriteResponse", ec);
                     return;
                 }
+
                 if (writeBuffer->size() == bytesTransferred)
                 {
                     doProtocolStart();
@@ -963,13 +873,6 @@ PeerImp::domain() const
 void
 PeerImp::doProtocolStart()
 {
-    // a shutdown was initiated before the handshare, there is nothing to do
-    if (shutdown_)
-    {
-        tryAsyncShutdown();
-        return;
-    }
-
     onReadMessage(error_code(), 0);
 
     // Send all the validator lists that have been loaded
@@ -1001,45 +904,31 @@ PeerImp::doProtocolStart()
     if (auto m = overlay_.getManifestsMessage())
         send(m);
 
-    setTimer(kPeerTimerInterval);
+    setTimer();
 }
 
 // Called repeatedly with protocol message data
 void
 PeerImp::onReadMessage(error_code ec, std::size_t bytesTransferred)
 {
-    XRPL_ASSERT(
-        strand_.running_in_this_thread(), "xrpl::PeerImp::onReadMessage : strand in this thread");
-
-    readPending_ = false;
-
     if (!socket_.is_open())
         return;
 
     if (ec)
     {
+        if (ec == boost::asio::error::operation_aborted)
+            return;
+
         if (ec == boost::asio::error::eof)
         {
-            JLOG(journal_.debug()) << "EOF";
-            shutdown();
-            return;
-        }
-
-        if (ec == boost::asio::error::operation_aborted)
-        {
-            tryAsyncShutdown();
+            JLOG(journal_.info()) << "EOF";
+            gracefulClose();
             return;
         }
 
         fail("onReadMessage", ec);
         return;
     }
-    // we started shutdown, no reason to process further data
-    if (shutdown_)
-    {
-        tryAsyncShutdown();
-        return;
-    }
 
     if (auto stream = journal_.trace())
     {
@@ -1064,34 +953,23 @@ PeerImp::onReadMessage(error_code ec, std::size_t bytesTransferred)
             350ms,
             journal_);
 
-        if (!socket_.is_open())
-            return;
-
-        // the error_code is produced by invokeProtocolMessage
-        // it could be due to a bad message
         if (ec)
         {
             fail("onReadMessage", ec);
             return;
         }
 
+        if (!socket_.is_open())
+            return;
+
+        if (gracefulClose_)
+            return;
+
         if (bytesConsumed == 0)
             break;
-
         readBuffer_.consume(bytesConsumed);
     }
 
-    // check if a shutdown was initiated while processing messages
-    if (shutdown_)
-    {
-        tryAsyncShutdown();
-        return;
-    }
-
-    readPending_ = true;
-
-    XRPL_ASSERT(!shutdownStarted_, "xrpl::PeerImp::onReadMessage : shutdown started");
-
     // Timeout on writes only
     stream_.async_read_some(
         readBuffer_.prepare(std::max(Tuning::kReadBufferBytes, hint)),
@@ -1107,26 +985,17 @@ PeerImp::onReadMessage(error_code ec, std::size_t bytesTransferred)
 void
 PeerImp::onWriteMessage(error_code ec, std::size_t bytesTransferred)
 {
-    XRPL_ASSERT(
-        strand_.running_in_this_thread(), "xrpl::PeerImp::onWriteMessage : strand in this thread");
-
-    writePending_ = false;
-
     if (!socket_.is_open())
         return;
 
     if (ec)
     {
         if (ec == boost::asio::error::operation_aborted)
-        {
-            tryAsyncShutdown();
             return;
-        }
 
         fail("onWriteMessage", ec);
         return;
     }
-
     if (auto stream = journal_.trace())
     {
         stream << "onWriteMessage: "
@@ -1137,18 +1006,8 @@ PeerImp::onWriteMessage(error_code ec, std::size_t bytesTransferred)
 
     XRPL_ASSERT(!sendQueue_.empty(), "xrpl::PeerImp::onWriteMessage : non-empty send buffer");
     sendQueue_.pop();
-
-    if (shutdown_)
-    {
-        tryAsyncShutdown();
-        return;
-    }
-
     if (!sendQueue_.empty())
     {
-        writePending_ = true;
-        XRPL_ASSERT(!shutdownStarted_, "xrpl::PeerImp::onWriteMessage : shutdown started");
-
         // Timeout on writes only
         boost::asio::async_write(
             stream_,
@@ -1162,6 +1021,13 @@ PeerImp::onWriteMessage(error_code ec, std::size_t bytesTransferred)
                     std::placeholders::_2)));
         return;
     }
+
+    if (gracefulClose_)
+    {
+        stream_.async_shutdown(bind_executor(
+            strand_, std::bind(&PeerImp::onShutdown, shared_from_this(), std::placeholders::_1)));
+        return;
+    }
 }
 
 //------------------------------------------------------------------------------
@@ -1208,7 +1074,7 @@ PeerImp::onMessageBegin(
          // LEDGER_DATA
          category == TrafficCount::Category::GlTscShare ||
          category == TrafficCount::Category::GlTscGet) &&
-        (txReduceRelayEnabled() || app_.config().TX_REDUCE_RELAY_METRICS))
+        (txReduceRelayEnabled() || app_.config().txReduceRelayMetrics))
     {
         overlay_.addTxMetrics(static_cast(type), static_cast(size));
     }
@@ -1518,7 +1384,7 @@ PeerImp::handleTransaction(
         {
             JLOG(pJournal_.trace()) << "No new transactions until synchronized";
         }
-        else if (app_.getJobQueue().getJobCount(JtTransaction) > app_.config().MAX_TRANSACTIONS)
+        else if (app_.getJobQueue().getJobCount(JtTransaction) > app_.config().maxTransactions)
         {
             overlay_.incJqTransOverflow();
             JLOG(pJournal_.info()) << "Transaction queue is full";
@@ -1889,7 +1755,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
         overlay_.reportInboundTraffic(
             TrafficCount::Category::ProposalUntrusted, Message::messageSize(*m));
 
-        if (app_.config().RELAY_UNTRUSTED_PROPOSALS == -1)
+        if (app_.config().relayUntrustedProposals == -1)
             return;
     }
 
@@ -2150,7 +2016,7 @@ PeerImp::checkTracking(std::uint32_t validationSeq)
 void
 PeerImp::checkTracking(std::uint32_t seq1, std::uint32_t seq2)
 {
-    int const diff = std::max(seq1, seq2) - std::min(seq1, seq2);
+    std::uint32_t const diff = std::max(seq1, seq2) - std::min(seq1, seq2);
 
     if (diff < Tuning::kConvergedLedgerLimit)
     {
@@ -2496,7 +2362,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
             overlay_.reportInboundTraffic(
                 TrafficCount::Category::ValidationUntrusted, Message::messageSize(*m));
 
-            if (app_.config().RELAY_UNTRUSTED_VALIDATIONS == -1)
+            if (app_.config().relayUntrustedValidations == -1)
                 return;
         }
 
@@ -2591,63 +2457,63 @@ PeerImp::onMessage(std::shared_ptr const& m)
             return;
         }
 
-        protocol::TMGetObjectByHash reply;
-
-        reply.set_query(false);
-
-        reply.set_type(packet.type());
-
         if (packet.has_ledgerhash())
         {
             if (!stringIsUInt256Sized(packet.ledgerhash()))
             {
-                fee_.update(Resource::kFeeMalformedRequest, "ledger hash");
+                JLOG(pJournal_.debug()) << "GetObj: malformed ledgerhash from peer " << id_;
+                fee_.update(Resource::kFeeMalformedRequest, "get object ledger hash");
                 return;
             }
-
-            reply.set_ledgerhash(packet.ledgerhash());
         }
-
-        fee_.update(Resource::kFeeModerateBurdenPeer, " received a get object by hash request");
-
-        // This is a very minimal implementation
-        for (int i = 0; i < packet.objects_size(); ++i)
+        // Reject oversized requests before touching the NodeStore.
+        // The legitimate upper bound (InboundLedger::getNeededHashes())
+        // is 8 hashes; anything beyond kHardMaxReplyNodes is non-conforming.
+        if (packet.objects_size() > Tuning::kHardMaxReplyNodes)
         {
-            auto const& obj = packet.objects(i);
-            if (obj.has_hash() && stringIsUInt256Sized(obj.hash()))
-            {
-                uint256 const hash = uint256::fromRaw(obj.hash());
-                // VFALCO TODO Move this someplace more sensible so we dont
-                //             need to inject the NodeStore interfaces.
-                std::uint32_t const seq{obj.has_ledgerseq() ? obj.ledgerseq() : 0};
-                auto nodeObject{app_.getNodeStore().fetchNodeObject(hash, seq)};
-                if (nodeObject)
-                {
-                    protocol::TMIndexedObject& newObj = *reply.add_objects();
-                    newObj.set_hash(hash.begin(), hash.size());
-                    newObj.set_data(&nodeObject->getData().front(), nodeObject->getData().size());
-
-                    if (obj.has_nodeid())
-                        newObj.set_index(obj.nodeid());
-                    if (obj.has_ledgerseq())
-                        newObj.set_ledgerseq(obj.ledgerseq());
-
-                    // Check if by adding this object, reply has reached its
-                    // limit
-                    if (reply.objects_size() >= Tuning::kHardMaxReplyNodes)
-                    {
-                        fee_.update(
-                            Resource::kFeeModerateBurdenPeer,
-                            "Reply limit reached. Truncating reply.");
-                        break;
-                    }
-                }
-            }
+            JLOG(pJournal_.warn())
+                << "GetObj: oversized request from peer " << id_ << " (" << packet.objects_size()
+                << " > " << Tuning::kHardMaxReplyNodes << ")";
+            fee_.update(Resource::kFeeInvalidData, "oversized get object request");
+            return;
         }
 
-        JLOG(pJournal_.trace()) << "GetObj: " << reply.objects_size() << " of "
-                                << packet.objects_size();
-        send(std::make_shared(reply, protocol::mtGET_OBJECTS));
+        // Dispatch heavy synchronous NodeStore lookups off the peer's
+        // I/O strand and onto the bounded job queue, mirroring the pattern
+        // used by processLedgerRequest.
+        std::weak_ptr const weak = shared_from_this();
+        bool const queued = app_.getJobQueue().addJob(JtLedgerReq, "RcvGetObjByHash", [weak, m]() {
+            auto peer = weak.lock();
+            if (!peer)
+                return;
+            try
+            {
+                peer->processGetObjectByHash(m);
+            }
+            catch (std::exception const& e)
+            {
+                // Surface backend failures (NodeStore I/O, allocation)
+                // back through the resource model so a misbehaving peer
+                // is still accountable rather than silently dropped.
+                JLOG(peer->pJournal_.warn()) << "GetObj: handler threw: " << e.what();
+                peer->charge(Resource::kFeeRequestNoReply, "get object handler exception");
+            }
+        });
+        if (!queued)
+        {
+            // The JobQueue is no longer accepting new work (typically
+            // because it is shutting down / has been joined).
+            JLOG(pJournal_.warn()) << "GetObj: job queue refused request from peer " << id_;
+            return;
+        }
+
+        // Admission-time charge: a peer that floods enqueues would
+        // otherwise be billed only the trivial onMessageEnd fee per
+        // message until the JobQueue catches up, re-creating an
+        // uncharged DoS window. Charge the base burden up-front (after
+        // a successful enqueue); the per-lookup differential is added
+        // in the worker.
+        fee_.update(Resource::kFeeModerateBurdenPeer, "received a get object by hash request");
     }
     else
     {
@@ -2703,6 +2569,69 @@ PeerImp::onMessage(std::shared_ptr const& m)
     }
 }
 
+void
+PeerImp::processGetObjectByHash(std::shared_ptr const& m)
+{
+    protocol::TMGetObjectByHash const& packet = *m;
+
+    protocol::TMGetObjectByHash reply;
+    reply.set_query(false);
+    reply.set_type(packet.type());
+
+    if (packet.has_ledgerhash())
+    {
+        reply.set_ledgerhash(packet.ledgerhash());
+    }
+
+    // Defense in depth: caller (onMessage) already validates cheap
+    // structural properties of the request before dispatching here:
+    //   - objects_size() <= kHardMaxReplyNodes (oversize gate)
+    //   - if has_ledgerhash() then ledgerhash is uint256-sized
+    // The iteration cap below mirrors the oversize gate so this method
+    // remains safe if invoked directly by tests or future callers, and
+    // a peer cannot drive unbounded NodeStore lookups by sending
+    // non-existent hashes.
+    int const requested = packet.objects_size();
+    int const iterLimit = std::min(requested, Tuning::kHardMaxReplyNodes);
+
+    for (int i = 0; i < iterLimit; ++i)
+    {
+        auto const& obj = packet.objects(i);
+        if (!obj.has_hash() || !stringIsUInt256Sized(obj.hash()))
+            continue;
+
+        uint256 const hash = uint256::fromRaw(obj.hash());
+        // VFALCO TODO Move this someplace more sensible so we don't
+        //             need to inject the NodeStore interfaces.
+        std::uint32_t const seq{obj.has_ledgerseq() ? obj.ledgerseq() : 0};
+        auto const nodeObject = app_.getNodeStore().fetchNodeObject(hash, seq);
+        if (!nodeObject)
+            continue;
+
+        protocol::TMIndexedObject& newObj = *reply.add_objects();
+        newObj.set_hash(hash.begin(), hash.size());
+        auto const& data = nodeObject->getData();
+        newObj.set_data(data.data(), data.size());
+        if (obj.has_nodeid())
+            newObj.set_index(obj.nodeid());
+        if (obj.has_ledgerseq())
+            newObj.set_ledgerseq(obj.ledgerseq());
+    }
+
+    // Apply work-proportional charge. `charge()` posts the disconnect
+    // step (if any) back to strand_, so it is safe to call from this
+    // JobQueue worker thread.
+    charge(
+        // We pass `requested` directly here, instead of actual lookups done. Which could be
+        // std::min(packet.objects_size(), static_cast(Tuning::kHardMaxReplyNodes));
+        // Because we want to charge as per the request size, to discourage large requests.
+        computeGetObjectByHashFee(requested, reply.objects_size()),
+        "processed get object by hash request");
+
+    JLOG(pJournal_.trace()) << "GetObj: " << reply.objects_size() << " of " << requested;
+    send(std::make_shared(reply, protocol::mtGET_OBJECTS));
+}
+
 void
 PeerImp::onMessage(std::shared_ptr const& m)
 {
@@ -2793,45 +2722,42 @@ PeerImp::onMessage(std::shared_ptr const& m)
 void
 PeerImp::onMessage(std::shared_ptr const& m)
 {
-    using on_message_fn = void (PeerImp::*)(std::shared_ptr const&);
-    if (!strand_.running_in_this_thread())
-    {
-        post(strand_, std::bind((on_message_fn)&PeerImp::onMessage, shared_from_this(), m));
-        return;
-    }
+    dispatch(strand_, [self = shared_from_this(), m]() {
+        if (!m->has_validatorpubkey())
+        {
+            self->fee_.update(Resource::kFeeInvalidData, "squelch no pubkey");
+            return;
+        }
+        auto validator = m->validatorpubkey();
+        auto const slice{makeSlice(validator)};
+        if (!publicKeyType(slice))
+        {
+            self->fee_.update(Resource::kFeeInvalidData, "squelch bad pubkey");
+            return;
+        }
+        PublicKey const key(slice);
 
-    if (!m->has_validatorpubkey())
-    {
-        fee_.update(Resource::kFeeInvalidData, "squelch no pubkey");
-        return;
-    }
-    auto validator = m->validatorpubkey();
-    auto const slice{makeSlice(validator)};
-    if (!publicKeyType(slice))
-    {
-        fee_.update(Resource::kFeeInvalidData, "squelch bad pubkey");
-        return;
-    }
-    PublicKey const key(slice);
+        // Ignore the squelch for validator's own messages.
+        if (key == self->app_.getValidationPublicKey())
+        {
+            JLOG(self->pJournal_.debug())
+                << "onMessage: TMSquelch discarding validator's squelch " << slice;
+            return;
+        }
 
-    // Ignore the squelch for validator's own messages.
-    if (key == app_.getValidationPublicKey())
-    {
-        JLOG(pJournal_.debug()) << "onMessage: TMSquelch discarding validator's squelch " << slice;
-        return;
-    }
+        std::uint32_t const duration = m->has_squelchduration() ? m->squelchduration() : 0;
+        if (!m->squelch())
+        {
+            self->squelch_.removeSquelch(key);
+        }
+        else if (!self->squelch_.addSquelch(key, std::chrono::seconds{duration}))
+        {
+            self->fee_.update(Resource::kFeeInvalidData, "squelch duration");
+        }
 
-    std::uint32_t const duration = m->has_squelchduration() ? m->squelchduration() : 0;
-    if (!m->squelch())
-    {
-        squelch_.removeSquelch(key);
-    }
-    else if (!squelch_.addSquelch(key, std::chrono::seconds{duration}))
-    {
-        fee_.update(Resource::kFeeInvalidData, "squelch duration");
-    }
-
-    JLOG(pJournal_.debug()) << "onMessage: TMSquelch " << slice << " " << id() << " " << duration;
+        JLOG(self->pJournal_.debug())
+            << "onMessage: TMSquelch " << slice << " " << self->id() << " " << duration;
+    });
 }
 
 //--------------------------------------------------------------------------
@@ -3093,7 +3019,7 @@ PeerImp::checkPropose(
     }
     else
     {
-        relay = app_.config().RELAY_UNTRUSTED_PROPOSALS == 1 || cluster();
+        relay = app_.config().relayUntrustedProposals == 1 || cluster();
     }
 
     if (relay)
@@ -3530,6 +3456,53 @@ PeerImp::processLedgerRequest(std::shared_ptr const& m)
     send(std::make_shared(ledgerData, protocol::mtLEDGER_DATA));
 }
 
+// Differential pricing helper.  Returns only the *dynamic* component
+// of the per-message charge — the base `kFeeModerateBurdenPeer` is
+// applied at admission time in `onMessage(TMGetObjectByHash)` so a
+// high traffic client pays for the message regardless of when (or
+// whether) the worker runs.
+//
+// Dynamic charge model:
+//
+//   billable       = max(0, requested - kFreeObjectsPerRequest)
+//   missed         = max(0, requested - found)
+//   billableMisses = min(missed, billable)        // misses billed first
+//   billableHits   = billable - billableMisses
+//   sizeBand       = (requested >  kBandMediumMax) ? kCostBandLarge
+//                  : (requested >  kBandSmallMax)  ? kCostBandMedium
+//                                                  : kCostBandSmall
+//   dynamic        = billableHits   * kCostPerLookupHit
+//                  + billableMisses * kCostPerLookupMiss
+//                  + sizeBand
+//
+// Misses are billed first against the billable budget because a node store
+// seek dominates a cache hit and because invalid hashes are ~100% miss by construction.
+Resource::Charge
+PeerImp::computeGetObjectByHashFee(int const requested, int const found)
+{
+    int const billable = std::max(0, requested - static_cast(Tuning::kFreeObjectsPerRequest));
+    // Clamp `missed` so a future caller passing found > requested cannot
+    // produce a negative value that flips the hits/misses split.
+    int const missed = std::max(0, requested - found);
+    int const billableMisses = std::min(missed, billable);
+    int const billableHits = billable - billableMisses;
+
+    int sizeBand = Tuning::kCostBandSmall;
+    if (requested > Tuning::kBandMediumMax)
+    {
+        sizeBand = Tuning::kCostBandLarge;
+    }
+    else if (requested > Tuning::kBandSmallMax)
+    {
+        sizeBand = Tuning::kCostBandMedium;
+    }
+
+    int const dynamic = (billableHits * Tuning::kCostPerLookupHit) +
+        (billableMisses * Tuning::kCostPerLookupMiss) + sizeBand;
+
+    return Resource::Charge(dynamic, "GetObject differential");
+}
+
 int
 PeerImp::getScore(bool haveItem) const
 {
diff --git a/src/xrpld/overlay/detail/PeerImp.h b/src/xrpld/overlay/detail/PeerImp.h
index 9c66347810..28fb6b33a4 100644
--- a/src/xrpld/overlay/detail/PeerImp.h
+++ b/src/xrpld/overlay/detail/PeerImp.h
@@ -30,68 +30,6 @@ namespace xrpl {
 struct ValidatorBlobInfo;
 class SHAMap;
 
-/**
- * @class PeerImp
- * @brief This class manages established peer-to-peer connections, handles
- message exchange, monitors connection health, and graceful shutdown.
- *
-
- * The PeerImp shutdown mechanism is a multi-stage process
- * designed to ensure graceful connection termination while handling ongoing
- * I/O operations safely. The shutdown can be initiated from multiple points
- * and follows a deterministic state machine.
- *
- * The shutdown process can be triggered from several entry points:
- * - **External requests**: `stop()` method called by overlay management
- * - **Error conditions**: `fail(error_code)` or `fail(string)` on protocol
- * violations
- * - **Timer expiration**: Various timeout scenarios (ping timeout, large send
- * queue)
- * - **Connection health**: Peer tracking divergence or unknown state timeouts
- *
- * The shutdown follows this progression:
- *
- * Normal Operation → shutdown() → tryAsyncShutdown() → onShutdown() → close()
- *                      ↓              ↓                 ↓              ↓
- *                 Set shutdown_   SSL graceful      Timer cancel   Socket close
- *                 Cancel timer    shutdown start    & cleanup      & metrics
- *                 5s safety timer Set shutdownStarted_              update
- *
- * Two primary flags coordinate the shutdown process:
- * - `shutdown_`: Set when shutdown is requested
- * - `shutdownStarted_`: Set when SSL shutdown begins
- *
- * The shutdown mechanism carefully coordinates with ongoing read/write
- * operations:
- *
- * **Read Operations (`onReadMessage`)**:
- * - Checks `shutdown_` flag after processing each message batch
- * - If shutdown initiated during processing, calls `tryAsyncShutdown()`
- *
- * **Write Operations (`onWriteMessage`)**:
- * - Checks `shutdown_` flag before queuing new writes
- * - Calls `tryAsyncShutdown()` when shutdown flag detected
- *
- * Multiple timers require coordination during shutdown:
- * 1. **Peer Timer**: Regular ping/pong timer cancelled immediately in
- * `shutdown()`
- * 2. **Shutdown Timer**: 5-second safety timer ensures shutdown completion
- * 3. **Operation Cancellation**: All pending async operations are cancelled
- *
- * The shutdown implements fallback mechanisms:
- * - **Graceful Path**: SSL shutdown → Socket close → Cleanup
- * - **Forced Path**: If SSL shutdown fails or times out, proceeds to socket
- * close
- * - **Safety Timer**: 5-second timeout prevents hanging shutdowns
- *
- * All shutdown operations are serialized through the boost::asio::strand to
- * ensure thread safety. The strand guarantees that shutdown state changes
- * and I/O operation callbacks are executed sequentially.
- *
- * @note This class requires careful coordination between async operations,
- * timer management, and shutdown procedures to ensure no resource leaks
- * or hanging connections in high-throughput networking scenarios.
- */
 class PeerImp : public Peer, public std::enable_shared_from_this, public OverlayImpl::Child
 {
 public:
@@ -121,8 +59,6 @@ private:
     socket_type& socket_;
     stream_type& stream_;
     boost::asio::strand strand_;
-
-    // Multi-purpose timer for peer activity monitoring and shutdown safety
     waitable_timer timer_;
 
     // Updated at each stage of the connection process to reflect
@@ -139,6 +75,7 @@ private:
 
     std::atomic tracking_;
     clock_type::time_point trackingTime_;
+    bool detaching_ = false;
     // Node public key of peer.
     PublicKey const publicKey_;
     std::string name_;
@@ -210,25 +147,19 @@ private:
     protocol::TMStatusChange lastStatus_;
     Resource::Consumer usage_;
     ChargeWithContext fee_;
+
+    // One-shot guard so concurrent JobQueue workers cannot double-count
+    // the per-connection peer-disconnect-by-charge metric (and cannot
+    // post duplicate fail() calls) when several queued requests cross
+    // kDropThreshold before the first fail() lands on the strand.
+    std::atomic chargeDisconnectFired_{false};
     std::shared_ptr const slot_;
     boost::beast::multi_buffer readBuffer_;
     http_request_type request_;
     http_response_type response_;
     boost::beast::http::fields const& headers_;
     std::queue> sendQueue_;
-
-    // Primary shutdown flag set when shutdown is requested
-    bool shutdown_ = false;
-
-    // SSL shutdown coordination flag
-    bool shutdownStarted_ = false;
-
-    // Indicates a read operation is currently pending
-    bool readPending_ = false;
-
-    // Indicates a write operation is currently pending
-    bool writePending_ = false;
-
+    bool gracefulClose_ = false;
     int largeSendq_ = 0;
     std::unique_ptr loadEvent_;
     // The highest sequence of each PublisherList that has
@@ -362,7 +293,7 @@ public:
     /** Send a set of PeerFinder endpoints as a protocol message. */
     template <
         class FwdIt,
-        class = typename std::enable_if_t<
+        class = std::enable_if_t<
             std::is_same_v::value_type, PeerFinder::Endpoint>>>
     void
     sendEndpoints(FwdIt first, FwdIt last);
@@ -476,6 +407,9 @@ public:
     bool
     isHighLatency() const override;
 
+    void
+    fail(std::string const& reason);
+
     bool
     compressionEnabled() const override
     {
@@ -489,129 +423,32 @@ public:
     }
 
 private:
-    /**
-     * @brief Handles a failure associated with a specific error code.
-     *
-     * This function is called when an operation fails with an error code. It
-     * logs the warning message and gracefully shutdowns the connection.
-     *
-     * The function will do nothing if the connection is already closed or if a
-     * shutdown is already in progress.
-     *
-     * @param name The name of the operation that failed (e.g., "read",
-     * "write").
-     * @param ec The error code associated with the failure.
-     * @note This function must be called from within the object's strand.
-     */
-    void
-    fail(std::string const& name, error_code ec);
-
-    /**
-     * @brief Handles a failure described by a reason string.
-     *
-     * This overload is used for logical errors or protocol violations not
-     * associated with a specific error code. It logs a warning with the
-     * given reason, then initiates a graceful shutdown.
-     *
-     * The function will do nothing if the connection is already closed or if a
-     * shutdown is already in progress.
-     *
-     * @param reason A descriptive string explaining the reason for the failure.
-     * @note This function must be called from within the object's strand.
-     */
-    void
-    fail(std::string const& reason);
-
-    /** @brief Initiates the peer disconnection sequence.
-     *
-     * This is the primary entry point to start closing a peer connection. It
-     * marks the peer for shutdown and cancels any outstanding asynchronous
-     * operations. This cancellation allows the graceful shutdown to proceed
-     * once the handlers for the cancelled operations have completed.
-     *
-     * @note This method must be called on the peer's strand.
-     */
-    void
-    shutdown();
-
-    /** @brief Attempts to perform a graceful SSL shutdown if conditions are
-     * met.
-     *
-     * This helper function checks if the peer is in a state where a graceful
-     * SSL shutdown can be performed (i.e., shutdown has been requested and no
-     * I/O operations are currently in progress).
-     *
-     * @note This method must be called on the peer's strand.
-     */
-    void
-    tryAsyncShutdown();
-
-    /**
-     * @brief Handles the completion of the asynchronous SSL shutdown.
-     *
-     * This function is the callback for the `async_shutdown` operation started
-     * in `shutdown()`. Its first action is to cancel the timer. It
-     * then inspects the error code to determine the outcome.
-     *
-     * Regardless of the result, this function proceeds to call `close()` to
-     * ensure the underlying socket is fully closed.
-     *
-     * @param ec The error code resulting from the `async_shutdown` operation.
-     */
-    void
-    onShutdown(error_code ec);
-
-    /**
-     * @brief Forcibly closes the underlying socket connection.
-     *
-     * This function provides the final, non-graceful shutdown of the peer
-     * connection. It ensures any pending timers are cancelled and then
-     * immediately closes the TCP socket, bypassing the SSL shutdown handshake.
-     *
-     * After closing, it notifies the overlay manager of the disconnection.
-     *
-     * @note This function must be called from within the object's strand.
-     */
     void
     close();
 
-    /**
-     * @brief Sets and starts the peer timer.
-     *
-     * This function starts timer, which is used to detect inactivity
-     * and prevent stalled connections. It sets the timer to expire after the
-     * predefined `peerTimerInterval`.
-     *
-     * @note This function will terminate the connection in case of any errors.
-     */
     void
-    setTimer(std::chrono::seconds interval);
+    fail(std::string const& name, error_code ec);
 
-    /**
-     * @brief Handles the expiration of the peer activity timer.
-     *
-     * This callback is invoked when the timer set by `setTimer` expires. It
-     * watches the peer connection, checking for various timeout and health
-     * conditions.
-     *
-     * @param ec The error code associated with the timer's expiration.
-     * `operation_aborted` is expected if the timer was cancelled.
-     */
     void
-    onTimer(error_code const& ec);
+    gracefulClose();
+
+    void
+    setTimer();
 
-    /**
-     * @brief Cancels any pending wait on the peer activity timer.
-     *
-     * This function is called to stop the timer. It gracefully manages any
-     * errors that might occur during the cancellation process.
-     */
     void
     cancelTimer() noexcept;
 
     static std::string
     makePrefix(std::string const& fingerprint);
 
+    // Called when the timer wait completes
+    void
+    onTimer(boost::system::error_code const& ec);
+
+    // Called when SSL shutdown completes
+    void
+    onShutdown(error_code ec);
+
     void
     doAccept();
 
@@ -793,6 +630,67 @@ private:
 
     void
     processLedgerRequest(std::shared_ptr const& m);
+
+protected:
+    // Kept `protected` so test subclasses (see
+    // TMGetObjectByHash_test) can drive the
+    // synchronous processor and the differential-pricing helper without
+    // routing through the JobQueue or going through `friend` plumbing.
+    // Production callers reach these members only via
+    // `onMessage(TMGetObjectByHash)` → JobQueue → `processGetObjectByHash`.
+
+    /** Process a generic-query TMGetObjectByHash message.
+
+        Dispatched from `onMessage(TMGetObjectByHash)` to the JobQueue
+        (`JtLedgerReq`) so synchronous NodeStore lookups do not block the
+        peer's I/O strand. Caps iteration at `Tuning::kHardMaxReplyNodes`
+        regardless of hit/miss outcome and applies differential pricing
+        via `computeGetObjectByHashFee()` after the fetch loop completes.
+
+        @param m The protocol message containing requested object hashes.
+     */
+    void
+    processGetObjectByHash(std::shared_ptr const& m);
+
+    /** Compute the per-message resource charge for a TMGetObjectByHash
+        request based on how much work was actually performed.
+
+        The charge has three components on top of the base
+        `Resource::kFeeModerateBurdenPeer`:
+          - per-hit lookup cost (cheap; usually served from cache)
+          - per-miss lookup cost (expensive node store seeks)
+          - request-size band surcharge (escalates abusive batch sizes)
+
+        The first `Tuning::kFreeObjectsPerRequest` objects are free so
+        that legitimate `InboundLedger::getNeededHashes()` traffic
+        (at most 8 objects) is unaffected.
+
+        @param requested Number of objects requested by the message. This
+                          value is used for request-size pricing and may
+                          exceed `Tuning::kHardMaxReplyNodes` when this
+                          helper is called directly, even though processing
+                          caps the iterations to `Tuning::kHardMaxReplyNodes`.
+        @param found     Number of objects successfully returned in the
+                         reply.
+        @return A `Resource::Charge` whose cost reflects the work performed.
+     */
+    static Resource::Charge
+    computeGetObjectByHashFee(int const requested, int const found);
+
+    /** Read-only accessor for the accumulated peer-message charge.
+
+        Exposed at `protected` scope so test subclasses can verify the
+        oversized-request rejection path (Layer 1) without invoking the
+        full JobQueue handler. Production callers should never read this back —
+        the value is consumed by `charge()`/`disconnect()` internally.
+
+        @return The current `Resource::Charge` accumulated on `fee_`.
+     */
+    Resource::Charge
+    currentFeeCharge() const
+    {
+        return fee_.fee;
+    }
 };
 
 //------------------------------------------------------------------------------
@@ -839,25 +737,23 @@ PeerImp::PeerImp(
     , response_(std::move(response))
     , headers_(response_)
     , compressionEnabled_(
-          peerFeatureEnabled(headers_, kFeatureCompr, "lz4", app_.config().COMPRESSION)
+          peerFeatureEnabled(headers_, kFeatureCompr, "lz4", app_.config().compression)
               ? Compressed::On
               : Compressed::Off)
     , txReduceRelayEnabled_(
-          peerFeatureEnabled(headers_, kFeatureTxrr, app_.config().TX_REDUCE_RELAY_ENABLE))
+          peerFeatureEnabled(headers_, kFeatureTxrr, app_.config().txReduceRelayEnable))
     , ledgerReplayEnabled_(
-          peerFeatureEnabled(headers_, kFeatureLedgerReplay, app_.config().LEDGER_REPLAY))
+          peerFeatureEnabled(headers_, kFeatureLedgerReplay, app_.config().ledgerReplay))
     , ledgerReplayMsgHandler_(app, app.getLedgerReplayer())
 {
     readBuffer_.commit(
         boost::asio::buffer_copy(readBuffer_.prepare(boost::asio::buffer_size(buffers)), buffers));
-    JLOG(journal_.info()) << "compression enabled " << (compressionEnabled_ == Compressed::On)
-                          << " vp reduce-relay base squelch enabled "
-                          << peerFeatureEnabled(
-                                 headers_,
-                                 kFeatureVprr,
-                                 app_.config().VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE)
-                          << " tx reduce-relay enabled " << txReduceRelayEnabled_ << " on "
-                          << remoteAddress_ << " " << id_;
+    JLOG(journal_.info())
+        << "compression enabled " << (compressionEnabled_ == Compressed::On)
+        << " vp reduce-relay base squelch enabled "
+        << peerFeatureEnabled(headers_, kFeatureVprr, app_.config().vpReduceRelayBaseSquelchEnable)
+        << " tx reduce-relay enabled " << txReduceRelayEnabled_ << " on " << remoteAddress_ << " "
+        << id_;
 }
 
 template 
diff --git a/src/xrpld/overlay/detail/ProtocolMessage.h b/src/xrpld/overlay/detail/ProtocolMessage.h
index ab41cbe337..b1a30bad10 100644
--- a/src/xrpld/overlay/detail/ProtocolMessage.h
+++ b/src/xrpld/overlay/detail/ProtocolMessage.h
@@ -98,19 +98,19 @@ struct MessageHeader
 
         @note This is the sum of sizes of the header and the payload.
     */
-    std::uint32_t total_wire_size = 0;
+    std::uint32_t totalWireSize = 0;
 
     /** The size of the header associated with this message. */
-    std::uint32_t header_size = 0;
+    std::uint32_t headerSize = 0;
 
     /** The size of the payload on the wire. */
-    std::uint32_t payload_wire_size = 0;
+    std::uint32_t payloadWireSize = 0;
 
     /** Uncompressed message size if the message is compressed. */
-    std::uint32_t uncompressed_size = 0;
+    std::uint32_t uncompressedSize = 0;
 
     /** The type of the message. */
-    std::uint16_t message_type = 0;
+    std::uint16_t messageType = 0;
 
     /** Indicates which compression algorithm the payload is compressed with.
      * Currently only lz4 is supported. If None then the message is not
@@ -159,10 +159,10 @@ parseMessageHeader(boost::system::error_code& ec, BufferSequence const& bufs, st
     // - 32 bits are the uncompressed data size
     if (*iter & 0x80)
     {
-        hdr.header_size = kHeaderBytesCompressed;
+        hdr.headerSize = kHeaderBytesCompressed;
 
         // not enough bytes to parse the header
-        if (size < hdr.header_size)
+        if (size < hdr.headerSize)
         {
             ec = make_error_code(boost::system::errc::success);
             return std::nullopt;
@@ -183,18 +183,18 @@ parseMessageHeader(boost::system::error_code& ec, BufferSequence const& bufs, st
         }
 
         for (int i = 0; i != 4; ++i)
-            hdr.payload_wire_size = (hdr.payload_wire_size << 8) + *iter++;
+            hdr.payloadWireSize = (hdr.payloadWireSize << 8) + *iter++;
 
         // clear the top four bits (the compression bits).
-        hdr.payload_wire_size &= 0x0FFFFFFF;
+        hdr.payloadWireSize &= 0x0FFFFFFF;
 
-        hdr.total_wire_size = hdr.header_size + hdr.payload_wire_size;
+        hdr.totalWireSize = hdr.headerSize + hdr.payloadWireSize;
 
         for (int i = 0; i != 2; ++i)
-            hdr.message_type = (hdr.message_type << 8) + *iter++;
+            hdr.messageType = (hdr.messageType << 8) + *iter++;
 
         for (int i = 0; i != 4; ++i)
-            hdr.uncompressed_size = (hdr.uncompressed_size << 8) + *iter++;
+            hdr.uncompressedSize = (hdr.uncompressedSize << 8) + *iter++;
 
         return hdr;
     }
@@ -204,9 +204,9 @@ parseMessageHeader(boost::system::error_code& ec, BufferSequence const& bufs, st
     // - 26 bits are the payload size
     if ((*iter & 0xFC) == 0)
     {
-        hdr.header_size = kHeaderBytes;
+        hdr.headerSize = kHeaderBytes;
 
-        if (size < hdr.header_size)
+        if (size < hdr.headerSize)
         {
             ec = make_error_code(boost::system::errc::success);
             return std::nullopt;
@@ -215,13 +215,13 @@ parseMessageHeader(boost::system::error_code& ec, BufferSequence const& bufs, st
         hdr.algorithm = Algorithm::None;
 
         for (int i = 0; i != 4; ++i)
-            hdr.payload_wire_size = (hdr.payload_wire_size << 8) + *iter++;
+            hdr.payloadWireSize = (hdr.payloadWireSize << 8) + *iter++;
 
-        hdr.uncompressed_size = hdr.payload_wire_size;
-        hdr.total_wire_size = hdr.header_size + hdr.payload_wire_size;
+        hdr.uncompressedSize = hdr.payloadWireSize;
+        hdr.totalWireSize = hdr.headerSize + hdr.payloadWireSize;
 
         for (int i = 0; i != 2; ++i)
-            hdr.message_type = (hdr.message_type << 8) + *iter++;
+            hdr.messageType = (hdr.messageType << 8) + *iter++;
 
         return hdr;
     }
@@ -240,18 +240,18 @@ parseMessageContent(MessageHeader const& header, Buffers const& buffers)
     auto m = std::make_shared();
 
     ZeroCopyInputStream stream(buffers);
-    stream.Skip(header.header_size);
+    stream.Skip(header.headerSize);
 
     if (header.algorithm != compression::Algorithm::None)
     {
         std::vector payload;
-        payload.resize(header.uncompressed_size);
+        payload.resize(header.uncompressedSize);
 
         auto const payloadSize = xrpl::compression::decompress(
             stream,
-            header.payload_wire_size,
+            header.payloadWireSize,
             payload.data(),
-            header.uncompressed_size,
+            header.uncompressedSize,
             header.algorithm);
 
         if (payloadSize == 0 || !m->ParseFromArray(payload.data(), payloadSize))
@@ -279,13 +279,13 @@ invoke(MessageHeader const& header, Buffers const& buffers, Handler& handler)
 
     using namespace xrpl::compression;
     handler.onMessageBegin(
-        header.message_type,
+        header.messageType,
         m,
-        header.payload_wire_size,
-        header.uncompressed_size,
+        header.payloadWireSize,
+        header.uncompressedSize,
         header.algorithm != Algorithm::None);
     handler.onMessage(m);
-    handler.onMessageEnd(header.message_type, m);
+    handler.onMessageEnd(header.messageType, m);
 
     return true;
 }
@@ -329,8 +329,8 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin
     // whose size exceeds this may result in the connection being dropped. A
     // larger message size may be supported in the future or negotiated as
     // part of a protocol upgrade.
-    if (header->payload_wire_size > kMaximumMessageSize ||
-        header->uncompressed_size > kMaximumMessageSize)
+    if (header->payloadWireSize > kMaximumMessageSize ||
+        header->uncompressedSize > kMaximumMessageSize)
     {
         result.second = make_error_code(boost::system::errc::message_size);
         return result;
@@ -345,15 +345,15 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin
 
     // We don't have the whole message yet. This isn't an error but we have
     // nothing to do.
-    if (header->total_wire_size > size)
+    if (header->totalWireSize > size)
     {
-        hint = header->total_wire_size - size;
+        hint = header->totalWireSize - size;
         return result;
     }
 
     bool success = false;
 
-    switch (header->message_type)
+    switch (header->messageType)
     {
         case protocol::mtMANIFESTS:
             success = detail::invoke(*header, buffers, handler);
@@ -420,12 +420,12 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin
             success = detail::invoke(*header, buffers, handler);
             break;
         default:
-            handler.onMessageUnknown(header->message_type);
+            handler.onMessageUnknown(header->messageType);
             success = true;
             break;
     }
 
-    result.first = header->total_wire_size;
+    result.first = header->totalWireSize;
 
     if (!success)
         result.second = make_error_code(boost::system::errc::bad_message);
diff --git a/src/xrpld/overlay/detail/Tuning.h b/src/xrpld/overlay/detail/Tuning.h
index a0f57ec3d7..8357fcd130 100644
--- a/src/xrpld/overlay/detail/Tuning.h
+++ b/src/xrpld/overlay/detail/Tuning.h
@@ -1,14 +1,19 @@
 #pragma once
 
+#include 
+
+#include 
+#include 
+
 namespace xrpl::Tuning {
 
 /** How many ledgers off a server can be and we will
     still consider it converged */
-static constexpr auto kConvergedLedgerLimit = 24;
+static constexpr std::uint32_t kConvergedLedgerLimit = 24;
 
 /** How many ledgers off a server has to be before we
     consider it diverged */
-static constexpr auto kDivergedLedgerLimit = 128;
+static constexpr std::uint32_t kDivergedLedgerLimit = 128;
 
 /** The soft cap on the number of ledger entries in a single reply. */
 static constexpr auto kSoftMaxReplyNodes = 8192;
@@ -37,4 +42,92 @@ static constexpr auto kMaxQueryDepth = 3;
 /** Size of buffer used to read from the socket. */
 constexpr std::size_t kReadBufferBytes = 16384;
 
+/** TMGetObjectByHash differential pricing.
+
+    Honest peers ask for at most 8 hashes per call (the header, or up to
+    4 state + 4 tx hashes from `InboundLedger::getNeededHashes()`). The
+    free tier covers them at zero cost. Beyond that, each lookup is billed:
+    'misses' cost much more than 'hits' because a miss does a node store seek
+    while a hit is usually served from cache. On top of that, a size-band
+    surcharge kicks in for larger requests so an attacker who crams a
+    single message with thousands of hashes blows past
+    `Resource::kDropThreshold` and gets disconnected.
+
+    The numbers below are picked to keep three things true given
+    `kDropThreshold = 25000`:
+
+      - Honest traffic (<= 8 objects per request) is free.
+      - A single all-miss request at `kHardMaxReplyNodes` (12288) costs
+        more than the drop threshold, so an attacker gets dropped in one
+        message.
+      - A peer spamming 1024-object hit-only requests gets dropped in
+        ~19 messages — fast enough to be useful, slow enough that an
+        honest peer momentarily sending oversized requests has time to
+        back off. */
+
+/** How many objects a request can ask for before per-lookup billing
+    begins?
+    Twice the honest peak (8) so a peer that occasionally retries a hash
+    never trips pricing. Same value as `SHAMapInnerNode::kBranchFactor`;
+    that's a coincidence, not a requirement. */
+static constexpr auto kFreeObjectsPerRequest = 16;
+
+/** Cost of one cache-hit lookup. The unit; everything else is a
+    multiple of this. */
+static constexpr auto kCostPerLookupHit = 1;
+
+/** Cost of one node-store miss, in units of `kCostPerLookupHit`.
+
+    A miss does a node store disk seek; a hit usually comes from cache.
+    The 8x ratio is an order-of-magnitude guess at the latency gap on
+    SSD-backed nodes, not a measured number. The math only requires this
+    to be at least 2 — any smaller and a full-miss request at the hard
+    cap wouldn't trip the drop threshold. 8 leaves headroom: if
+    `kDropThreshold` goes up or `kHardMaxReplyNodes` comes down, the
+    drop-on-attack property still holds without a code change. */
+static constexpr auto kCostPerLookupMiss = 8;
+
+/** Size-band surcharges. Whichever band a request's size falls into,
+    its surcharge is added once on top of the per-lookup cost.
+
+    The job of the surcharge is to make crossing a band edge feel like
+    a step, not a slope. With these values, the cost roughly doubles or triples at each cliff:
+
+        n=64: costs 48 => n=65 costs 149 (~3x jump)
+        n=1024: costs 1108 => n=1025 costs 2009 (~2x jump)
+
+    The 10x step between medium and large mirrors the ~16x step
+    between the band edges (64 -> 1024) so the cliff feels comparable
+    at both scales.
+ */
+static constexpr auto kCostBandSmall = 0;
+static constexpr auto kCostBandMedium = 100;
+static constexpr auto kCostBandLarge = 1000;
+
+/** How many hashes per type an honest peer asks for at a time.
+
+    Matches the `4` passed to `neededStateHashes(4)` and
+    `neededTxHashes(4)` in `InboundLedger::getNeededHashes()`. Kept here
+    instead of imported from the ledger module so overlay stays
+    self-contained; if that `4` ever changes, update this in lockstep or
+    the band thresholds below will start charging honest peers. */
+static constexpr auto kLegitHashesPerType = 4;
+
+/** Cutoffs that decide which size band a request falls into.
+
+    A SHAMap inner node has 16 children; an honest peer asks for 4
+    hashes per type. So:
+
+      kBandSmallMax  = 4 * 16    =   64  // one inner node's worth
+      kBandMediumMax = 4 * 16^2  = 1024  // a depth-2 subtree's worth
+
+    A request up to 64 objects is small (no surcharge); up to 1024 is
+    medium; anything larger is large. The bounds are inclusive: a
+    request of exactly 64 is small, 65 is medium. Anything past 1024 is
+    well beyond what the honest sync path produces, so it's billed at
+    the large rate to drive attack-shaped traffic over the drop
+    threshold quickly. */
+static constexpr auto kBandSmallMax = kLegitHashesPerType * SHAMapInnerNode::kBranchFactor;
+static constexpr auto kBandMediumMax = kBandSmallMax * SHAMapInnerNode::kBranchFactor;
+
 }  // namespace xrpl::Tuning
diff --git a/src/xrpld/overlay/detail/TxMetrics.cpp b/src/xrpld/overlay/detail/TxMetrics.cpp
index 2df072ef0c..d2597cb19f 100644
--- a/src/xrpld/overlay/detail/TxMetrics.cpp
+++ b/src/xrpld/overlay/detail/TxMetrics.cpp
@@ -77,13 +77,13 @@ SingleMetrics::addMetrics(std::uint32_t val)
 {
     using namespace std::chrono_literals;
     accum += val;
-    N++;
+    n++;
     auto const timeElapsed = clock_type::now() - intervalStart;
     auto const timeElapsedInSecs = std::chrono::duration_cast(timeElapsed);
 
     if (timeElapsedInSecs >= 1s)
     {
-        auto const avg = accum / (perTimeUnit ? timeElapsedInSecs.count() : N);
+        auto const avg = accum / (perTimeUnit ? timeElapsedInSecs.count() : n);
         rollingAvgAggregate.push_back(avg);
 
         auto const total =
@@ -92,7 +92,7 @@ SingleMetrics::addMetrics(std::uint32_t val)
 
         intervalStart = clock_type::now();
         accum = 0;
-        N = 0;
+        n = 0;
     }
 }
 
diff --git a/src/xrpld/overlay/detail/TxMetrics.h b/src/xrpld/overlay/detail/TxMetrics.h
index 7b8b4bae1b..50df7a65cc 100644
--- a/src/xrpld/overlay/detail/TxMetrics.h
+++ b/src/xrpld/overlay/detail/TxMetrics.h
@@ -29,7 +29,7 @@ struct SingleMetrics
     clock_type::time_point intervalStart{clock_type::now()};
     std::uint64_t accum{0};
     std::uint64_t rollingAvg{0};
-    std::uint32_t N{0};
+    std::uint32_t n{0};
     bool perTimeUnit{true};
     boost::circular_buffer rollingAvgAggregate{30, 0ull};
     /** Add metrics value
diff --git a/src/xrpld/overlay/detail/ZeroCopyStream.h b/src/xrpld/overlay/detail/ZeroCopyStream.h
index d8d311105d..f77f266321 100644
--- a/src/xrpld/overlay/detail/ZeroCopyStream.h
+++ b/src/xrpld/overlay/detail/ZeroCopyStream.h
@@ -17,7 +17,7 @@ template 
 class ZeroCopyInputStream : public ::google::protobuf::io::ZeroCopyInputStream
 {
 private:
-    using iterator = typename Buffers::const_iterator;
+    using iterator = Buffers::const_iterator;
     using const_buffer = boost::asio::const_buffer;
 
     google::protobuf::int64 count_ = 0;
@@ -110,8 +110,8 @@ template 
 class ZeroCopyOutputStream : public ::google::protobuf::io::ZeroCopyOutputStream
 {
 private:
-    using buffers_type = typename Streambuf::mutable_buffers_type;
-    using iterator = typename buffers_type::const_iterator;
+    using buffers_type = Streambuf::mutable_buffers_type;
+    using iterator = buffers_type::const_iterator;
     using mutable_buffer = boost::asio::mutable_buffer;
 
     Streambuf& streambuf_;
diff --git a/src/xrpld/peerfinder/PeerfinderManager.h b/src/xrpld/peerfinder/PeerfinderManager.h
index f88b1b637c..ec4beb2db4 100644
--- a/src/xrpld/peerfinder/PeerfinderManager.h
+++ b/src/xrpld/peerfinder/PeerfinderManager.h
@@ -201,7 +201,7 @@ public:
         file, along with the set of corresponding IP addresses.
     */
     virtual void
-    addFixedPeer(std::string const& name, std::vector const& addresses) = 0;
+    addFixedPeer(std::string_view name, std::vector const& addresses) = 0;
 
     /** Add a set of strings as fallback IP::Endpoint sources.
         @param name A label used for diagnostics.
diff --git a/src/xrpld/peerfinder/detail/Checker.h b/src/xrpld/peerfinder/detail/Checker.h
index 8ab084830c..2f324bf8b6 100644
--- a/src/xrpld/peerfinder/detail/Checker.h
+++ b/src/xrpld/peerfinder/detail/Checker.h
@@ -34,8 +34,8 @@ private:
     template 
     struct AsyncOp : BasicAsyncOp
     {
-        using socket_type = typename Protocol::socket;
-        using endpoint_type = typename Protocol::endpoint;
+        using socket_type = Protocol::socket;
+        using endpoint_type = Protocol::endpoint;
 
         Checker& checker;
         socket_type socket;
@@ -57,8 +57,8 @@ private:
 
     //--------------------------------------------------------------------------
 
-    using list_type = typename boost::intrusive::
-        make_list>::type;
+    using list_type =
+        boost::intrusive::make_list>::type;
 
     std::mutex mutex_;
     std::condition_variable cond_;
diff --git a/src/xrpld/peerfinder/detail/Counts.h b/src/xrpld/peerfinder/detail/Counts.h
index ff88421e09..67b8370996 100644
--- a/src/xrpld/peerfinder/detail/Counts.h
+++ b/src/xrpld/peerfinder/detail/Counts.h
@@ -42,9 +42,9 @@ public:
             return true;
 
         if (s.inbound())
-            return in_active_ < in_max_;
+            return inActive_ < inMax_;
 
-        return out_active_ < out_max_;
+        return outActive_ < outMax_;
     }
 
     /** Returns the number of attempts needed to bring us to the max. */
@@ -67,7 +67,7 @@ public:
     [[nodiscard]] int
     outMax() const
     {
-        return out_max_;
+        return outMax_;
     }
 
     /** Returns the number of outbound peers assigned an open slot.
@@ -76,7 +76,7 @@ public:
     [[nodiscard]] int
     outActive() const
     {
-        return out_active_;
+        return outActive_;
     }
 
     /** Returns the number of fixed connections. */
@@ -90,7 +90,7 @@ public:
     [[nodiscard]] std::size_t
     fixedActive() const
     {
-        return fixed_active_;
+        return fixedActive_;
     }
 
     //--------------------------------------------------------------------------
@@ -99,9 +99,9 @@ public:
     void
     onConfig(Config const& config)
     {
-        out_max_ = config.outPeers;
+        outMax_ = config.outPeers;
         if (config.wantIncoming)
-            in_max_ = config.inPeers;
+            inMax_ = config.inPeers;
     }
 
     /** Returns the number of accepted connections that haven't handshaked. */
@@ -129,21 +129,21 @@ public:
     [[nodiscard]] int
     inMax() const
     {
-        return in_max_;
+        return inMax_;
     }
 
     /** Returns the number of inbound peers assigned an open slot. */
     [[nodiscard]] int
     inboundActive() const
     {
-        return in_active_;
+        return inActive_;
     }
 
     /** Returns the total number of active peers excluding fixed peers. */
     [[nodiscard]] int
     totalActive() const
     {
-        return in_active_ + out_active_;
+        return inActive_ + outActive_;
     }
 
     /** Returns the number of unused inbound slots.
@@ -152,8 +152,8 @@ public:
     [[nodiscard]] int
     inboundSlotsFree() const
     {
-        if (in_active_ < in_max_)
-            return in_max_ - in_active_;
+        if (inActive_ < inMax_)
+            return inMax_ - inActive_;
         return 0;
     }
 
@@ -163,8 +163,8 @@ public:
     [[nodiscard]] int
     outboundSlotsFree() const
     {
-        if (out_active_ < out_max_)
-            return out_max_ - out_active_;
+        if (outActive_ < outMax_)
+            return outMax_ - outActive_;
         return 0;
     }
 
@@ -181,7 +181,7 @@ public:
         //
         // Fixed peers do not count towards the active outgoing total.
 
-        return out_max_ <= 0;
+        return outMax_ <= 0;
     }
 
     /** Output statistics. */
@@ -191,9 +191,9 @@ public:
         map["accept"] = acceptCount();
         map["connect"] = connectCount();
         map["close"] = closingCount();
-        map["in"] << in_active_ << "/" << in_max_;
-        map["out"] << out_active_ << "/" << out_max_;
-        map["fixed"] = fixed_active_;
+        map["in"] << inActive_ << "/" << inMax_;
+        map["out"] << outActive_ << "/" << outMax_;
+        map["fixed"] = fixedActive_;
         map["reserved"] = reserved_;
         map["total"] = active_;
     }
@@ -203,7 +203,7 @@ public:
     stateString() const
     {
         std::stringstream ss;
-        ss << out_active_ << "/" << out_max_ << " out, " << in_active_ << "/" << in_max_ << " in, "
+        ss << outActive_ << "/" << outMax_ << " out, " << inActive_ << "/" << inMax_ << " in, "
            << connectCount() << " connecting, " << closingCount() << " closing";
         return ss.str();
     }
@@ -262,16 +262,16 @@ private:
 
             case Slot::State::Active:
                 if (s.fixed())
-                    adjustCounter(fixed_active_, dir);
+                    adjustCounter(fixedActive_, dir);
                 if (!s.fixed() && !s.reserved())
                 {
                     if (s.inbound())
                     {
-                        adjustCounter(in_active_, dir);
+                        adjustCounter(inActive_, dir);
                     }
                     else
                     {
-                        adjustCounter(out_active_, dir);
+                        adjustCounter(outActive_, dir);
                     }
                 }
                 adjustCounter(active_, dir);
@@ -297,22 +297,22 @@ private:
     std::size_t active_{0};
 
     /** Total number of inbound slots. */
-    std::size_t in_max_{0};
+    std::size_t inMax_{0};
 
     /** Number of inbound slots assigned to active peers. */
-    std::size_t in_active_{0};
+    std::size_t inActive_{0};
 
     /** Maximum desired outbound slots. */
-    std::size_t out_max_{0};
+    std::size_t outMax_{0};
 
     /** Active outbound slots. */
-    std::size_t out_active_{0};
+    std::size_t outActive_{0};
 
     /** Fixed connections. */
     std::size_t fixed_{0};
 
     /** Active fixed connections. */
-    std::size_t fixed_active_{0};
+    std::size_t fixedActive_{0};
 
     /** Reserved connections. */
     std::size_t reserved_{0};
diff --git a/src/xrpld/peerfinder/detail/Handouts.h b/src/xrpld/peerfinder/detail/Handouts.h
index 19a367f8c1..7523197c40 100644
--- a/src/xrpld/peerfinder/detail/Handouts.h
+++ b/src/xrpld/peerfinder/detail/Handouts.h
@@ -143,7 +143,7 @@ RedirectHandouts::tryInsert(Endpoint const& ep)
         return false;
 
     // Make sure the address isn't already in our list
-    if (std::any_of(list_.begin(), list_.end(), [&ep](Endpoint const& other) {
+    if (std::ranges::any_of(list_, [&ep](Endpoint const& other) {
             // Ignore port for security reasons
             return other.address.address() == ep.address.address();
         }))
@@ -222,7 +222,7 @@ SlotHandouts::tryInsert(Endpoint const& ep)
         return false;
 
     // Make sure the address isn't already in our list
-    if (std::any_of(list_.begin(), list_.end(), [&ep](Endpoint const& other) {
+    if (std::ranges::any_of(list_, [&ep](Endpoint const& other) {
             // Ignore port for security reasons
             return other.address.address() == ep.address.address();
         }))
@@ -311,7 +311,7 @@ ConnectHandouts::tryInsert(beast::IP::Endpoint const& endpoint)
         return false;
 
     // Make sure the address isn't already in our list
-    if (std::any_of(list_.begin(), list_.end(), [&endpoint](beast::IP::Endpoint const& other) {
+    if (std::ranges::any_of(list_, [&endpoint](beast::IP::Endpoint const& other) {
             // Ignore port for security reasons
             return other.address() == endpoint.address();
         }))
diff --git a/src/xrpld/peerfinder/detail/Livecache.h b/src/xrpld/peerfinder/detail/Livecache.h
index 84efcb7bd1..c5f04be90a 100644
--- a/src/xrpld/peerfinder/detail/Livecache.h
+++ b/src/xrpld/peerfinder/detail/Livecache.h
@@ -65,12 +65,12 @@ public:
         };
 
     public:
-        using iterator = boost::transform_iterator;
+        using iterator = boost::transform_iterator;
 
         using const_iterator = iterator;
 
         using reverse_iterator =
-            boost::transform_iterator;
+            boost::transform_iterator;
 
         using const_reverse_iterator = reverse_iterator;
 
@@ -132,7 +132,7 @@ public:
         }
 
     private:
-        explicit Hop(typename beast::MaybeConst::type& list) : list_(list)
+        explicit Hop(beast::MaybeConst::type& list) : list_(list)
         {
         }
 
@@ -145,7 +145,7 @@ protected:
     // Work-around to call Hop's private constructor from Livecache
     template 
     static Hop
-    makeHop(typename beast::MaybeConst::type& list)
+    makeHop(beast::MaybeConst::type& list)
     {
         return Hop(list);
     }
@@ -208,30 +208,29 @@ public:
         template 
         struct Transform
         {
-            using first_argument = typename lists_type::value_type;
+            using first_argument = lists_type::value_type;
             using result_type = Hop;
 
             explicit Transform() = default;
 
             Hop
-            operator()(typename beast::MaybeConst::type&
-                           list) const
+            operator()(beast::MaybeConst::type& list) const
             {
                 return makeHop(list);
             }
         };
 
     public:
-        using iterator = boost::transform_iterator, typename lists_type::iterator>;
+        using iterator = boost::transform_iterator, lists_type::iterator>;
 
         using const_iterator =
-            boost::transform_iterator, typename lists_type::const_iterator>;
+            boost::transform_iterator, lists_type::const_iterator>;
 
         using reverse_iterator =
-            boost::transform_iterator, typename lists_type::reverse_iterator>;
+            boost::transform_iterator, lists_type::reverse_iterator>;
 
         using const_reverse_iterator =
-            boost::transform_iterator, typename lists_type::const_reverse_iterator>;
+            boost::transform_iterator, lists_type::const_reverse_iterator>;
 
         iterator
         begin()
@@ -338,7 +337,7 @@ public:
     }
 
     /** Returns the number of entries in the cache. */
-    typename cache_type::size_type
+    cache_type::size_type
     size() const
     {
         return cache_.size();
diff --git a/src/xrpld/peerfinder/detail/Logic.h b/src/xrpld/peerfinder/detail/Logic.h
index 3ebe0cf2f4..b74643f7a5 100644
--- a/src/xrpld/peerfinder/detail/Logic.h
+++ b/src/xrpld/peerfinder/detail/Logic.h
@@ -148,13 +148,13 @@ public:
     }
 
     void
-    addFixedPeer(std::string const& name, beast::IP::Endpoint const& ep)
+    addFixedPeer(std::string_view name, beast::IP::Endpoint const& ep)
     {
         addFixedPeer(name, std::vector{ep});
     }
 
     void
-    addFixedPeer(std::string const& name, std::vector const& addresses)
+    addFixedPeer(std::string_view name, std::vector const& addresses)
     {
         std::scoped_lock const _(lock);
 
@@ -573,19 +573,17 @@ public:
                 // build list of active slots
                 std::vector activeSlots;
                 activeSlots.reserve(slots.size());
-                std::for_each(
-                    slots.cbegin(), slots.cend(), [&activeSlots](Slots::value_type const& value) {
-                        if (value.second->state() == Slot::State::Active)
-                            activeSlots.emplace_back(value.second);
-                    });
+                std::ranges::for_each(slots, [&activeSlots](Slots::value_type const& value) {
+                    if (value.second->state() == Slot::State::Active)
+                        activeSlots.emplace_back(value.second);
+                });
                 std::shuffle(activeSlots.begin(), activeSlots.end(), defaultPrng());
 
                 // build target vector
                 targets.reserve(activeSlots.size());
-                std::for_each(
-                    activeSlots.cbegin(), activeSlots.cend(), [&targets](SlotImp::ptr const& slot) {
-                        targets.emplace_back(slot);
-                    });
+                std::ranges::for_each(activeSlots, [&targets](SlotImp::ptr const& slot) {
+                    targets.emplace_back(slot);
+                });
             }
 
             /* VFALCO NOTE
@@ -980,14 +978,14 @@ public:
     /** Adds eligible Fixed addresses for outbound attempts. */
     template 
     void
-    getFixed(std::size_t needed, Container& c, typename ConnectHandouts::Squelches& squelches)
+    getFixed(std::size_t needed, Container& c, ConnectHandouts::Squelches& squelches)
     {
         auto const now(clock.now());
         for (auto iter = fixed_.begin(); needed && iter != fixed_.end(); ++iter)
         {
             auto const& address(iter->first.address());
             if (iter->second.when() <= now && squelches.find(address) == squelches.end() &&
-                std::none_of(slots.cbegin(), slots.cend(), [address](Slots::value_type const& v) {
+                std::ranges::none_of(slots, [address](Slots::value_type const& v) {
                     return address == v.first.address();
                 }))
             {
diff --git a/src/xrpld/peerfinder/detail/PeerfinderConfig.cpp b/src/xrpld/peerfinder/detail/PeerfinderConfig.cpp
index bbaeeff8e6..5d276dc9c5 100644
--- a/src/xrpld/peerfinder/detail/PeerfinderConfig.cpp
+++ b/src/xrpld/peerfinder/detail/PeerfinderConfig.cpp
@@ -18,7 +18,8 @@ Config::Config() : outPeers(calcOutPeers())
 std::size_t
 Config::calcOutPeers() const
 {
-    return std::max((maxPeers * Tuning::kOutPercent + 50) / 100, std::size_t(Tuning::kMinOutCount));
+    return std::max(
+        ((maxPeers * Tuning::kOutPercent) + 50) / 100, std::size_t(Tuning::kMinOutCount));
 }
 
 void
@@ -63,15 +64,15 @@ Config::makeConfig(
 {
     PeerFinder::Config config;
 
-    config.peerPrivate = cfg.PEER_PRIVATE;
+    config.peerPrivate = cfg.peerPrivate;
 
     // Servers with peer privacy don't want to allow incoming connections
     config.wantIncoming = (!config.peerPrivate) && (port != 0);
 
-    if ((cfg.PEERS_OUT_MAX == 0u) && (cfg.PEERS_IN_MAX == 0u))
+    if ((cfg.peersOutMax == 0u) && (cfg.peersInMax == 0u))
     {
-        if (cfg.PEERS_MAX != 0)
-            config.maxPeers = cfg.PEERS_MAX;
+        if (cfg.peersMax != 0)
+            config.maxPeers = cfg.peersMax;
 
         config.maxPeers = std::max(config.maxPeers, Tuning::kMinOutCount);
         config.outPeers = config.calcOutPeers();
@@ -94,8 +95,8 @@ Config::makeConfig(
     }
     else
     {
-        config.outPeers = cfg.PEERS_OUT_MAX;
-        config.inPeers = cfg.PEERS_IN_MAX;
+        config.outPeers = cfg.peersOutMax;
+        config.inPeers = cfg.peersInMax;
         config.maxPeers = 0;
     }
 
@@ -108,7 +109,7 @@ Config::makeConfig(
 
     // if it's a private peer or we are running as standalone
     // automatic connections would defeat the purpose.
-    config.autoConnect = !cfg.standalone() && !cfg.PEER_PRIVATE;
+    config.autoConnect = !cfg.standalone() && !cfg.peerPrivate;
     config.listeningPort = port;
     config.features = "";
     config.ipLimit = ipLimit;
diff --git a/src/xrpld/peerfinder/detail/PeerfinderManager.cpp b/src/xrpld/peerfinder/detail/PeerfinderManager.cpp
index 873c18aad9..9dbedfe4f1 100644
--- a/src/xrpld/peerfinder/detail/PeerfinderManager.cpp
+++ b/src/xrpld/peerfinder/detail/PeerfinderManager.cpp
@@ -7,13 +7,13 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
@@ -25,6 +25,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -99,8 +100,7 @@ public:
     }
 
     void
-    addFixedPeer(std::string const& name, std::vector const& addresses)
-        override
+    addFixedPeer(std::string_view name, std::vector const& addresses) override
     {
         logic_.addFixedPeer(name, addresses);
     }
diff --git a/src/xrpld/peerfinder/detail/SlotImp.cpp b/src/xrpld/peerfinder/detail/SlotImp.cpp
index f6ff24e6a6..a54ddb56e7 100644
--- a/src/xrpld/peerfinder/detail/SlotImp.cpp
+++ b/src/xrpld/peerfinder/detail/SlotImp.cpp
@@ -23,9 +23,9 @@ SlotImp::SlotImp(
     , fixed_(fixed)
     , reserved_(false)
     , state_(State::Accept)
-    , remote_endpoint_(std::move(remoteEndpoint))
-    , local_endpoint_(localEndpoint)
-    , listening_port_(kUnknownPort)
+    , remoteEndpoint_(std::move(remoteEndpoint))
+    , localEndpoint_(localEndpoint)
+    , listeningPort_(kUnknownPort)
     , checked(false)
     , canAccept(false)
     , connectivityCheckInProgress(false)
@@ -38,8 +38,8 @@ SlotImp::SlotImp(beast::IP::Endpoint remoteEndpoint, bool fixed, clock_type& clo
     , fixed_(fixed)
     , reserved_(false)
     , state_(State::Connect)
-    , remote_endpoint_(std::move(remoteEndpoint))
-    , listening_port_(kUnknownPort)
+    , remoteEndpoint_(std::move(remoteEndpoint))
+    , listeningPort_(kUnknownPort)
     , checked(true)
     , canAccept(true)
     , connectivityCheckInProgress(false)
diff --git a/src/xrpld/peerfinder/detail/SlotImp.h b/src/xrpld/peerfinder/detail/SlotImp.h
index 0d09a37539..0cf4d7a3c8 100644
--- a/src/xrpld/peerfinder/detail/SlotImp.h
+++ b/src/xrpld/peerfinder/detail/SlotImp.h
@@ -52,19 +52,19 @@ public:
     beast::IP::Endpoint const&
     remoteEndpoint() const override
     {
-        return remote_endpoint_;
+        return remoteEndpoint_;
     }
 
     std::optional const&
     localEndpoint() const override
     {
-        return local_endpoint_;
+        return localEndpoint_;
     }
 
     std::optional const&
     publicKey() const override
     {
-        return public_key_;
+        return publicKey_;
     }
 
     std::string
@@ -76,7 +76,7 @@ public:
     std::optional
     listeningPort() const override
     {
-        std::uint32_t const value = listening_port_;
+        std::uint32_t const value = listeningPort_;
         if (value == kUnknownPort)
             return std::nullopt;
         return value;
@@ -85,25 +85,25 @@ public:
     void
     setListeningPort(std::uint16_t port)
     {
-        listening_port_ = port;
+        listeningPort_ = port;
     }
 
     void
     localEndpoint(beast::IP::Endpoint const& endpoint)
     {
-        local_endpoint_ = endpoint;
+        localEndpoint_ = endpoint;
     }
 
     void
     remoteEndpoint(beast::IP::Endpoint const& endpoint)
     {
-        remote_endpoint_ = endpoint;
+        remoteEndpoint_ = endpoint;
     }
 
     void
     publicKey(PublicKey const& key)
     {
-        public_key_ = key;
+        publicKey_ = key;
     }
 
     void
@@ -160,12 +160,12 @@ private:
     bool const fixed_;
     bool reserved_;
     State state_;
-    beast::IP::Endpoint remote_endpoint_;
-    std::optional local_endpoint_;
-    std::optional public_key_;
+    beast::IP::Endpoint remoteEndpoint_;
+    std::optional localEndpoint_;
+    std::optional publicKey_;
 
     static constexpr std::int32_t kUnknownPort = -1;
-    std::atomic listening_port_;
+    std::atomic listeningPort_;
 
 public:
     // DEPRECATED public data members
diff --git a/src/xrpld/perflog/detail/PerfLogImp.cpp b/src/xrpld/perflog/detail/PerfLogImp.cpp
index 60b6efc0a9..5ace4d8c8b 100644
--- a/src/xrpld/perflog/detail/PerfLogImp.cpp
+++ b/src/xrpld/perflog/detail/PerfLogImp.cpp
@@ -1,11 +1,12 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -489,7 +490,7 @@ setupPerfLog(Section const& section, boost::filesystem::path const& configDir)
     }
 
     std::uint64_t logInterval = 0;
-    if (getIfExists(section, "log_interval", logInterval))
+    if (getIfExists(section, Keys::kLogInterval, logInterval))
         setup.logInterval = std::chrono::seconds(logInterval);
     return setup;
 }
diff --git a/src/xrpld/rpc/ServerHandler.h b/src/xrpld/rpc/ServerHandler.h
index 6b4cc34cc5..0aac05ea44 100644
--- a/src/xrpld/rpc/ServerHandler.h
+++ b/src/xrpld/rpc/ServerHandler.h
@@ -46,8 +46,8 @@ public:
             std::uint16_t port = 0;
             std::string user;
             std::string password;
-            std::string admin_user;
-            std::string admin_password;
+            std::string adminUser;
+            std::string adminPassword;
         };
 
         // Configuration when acting in client role
@@ -72,9 +72,9 @@ private:
     Setup setup_;
     Endpoints endpoints_;
     JobQueue& jobQueue_;
-    beast::insight::Counter rpc_requests_;
-    beast::insight::Event rpc_size_;
-    beast::insight::Event rpc_time_;
+    beast::insight::Counter rpcRequests_;
+    beast::insight::Event rpcSize_;
+    beast::insight::Event rpcTime_;
     std::mutex mutex_;
     std::condition_variable condition_;
     bool stopped_{false};
diff --git a/src/xrpld/rpc/detail/AssetCache.cpp b/src/xrpld/rpc/detail/AssetCache.cpp
index a0743a2303..e29f5659f9 100644
--- a/src/xrpld/rpc/detail/AssetCache.cpp
+++ b/src/xrpld/rpc/detail/AssetCache.cpp
@@ -22,8 +22,8 @@
 
 namespace xrpl {
 
-AssetCache::AssetCache(std::shared_ptr const& ledger, beast::Journal j)
-    : ledger_(ledger), journal_(j)
+AssetCache::AssetCache(std::shared_ptr ledger, beast::Journal j)
+    : ledger_(std::move(ledger)), journal_(j)
 {
     JLOG(journal_.debug()) << "created for ledger " << ledger_->header().seq;
 }
@@ -124,7 +124,7 @@ AssetCache::getMPTs(xrpl::AccountID const& account)
 
     std::vector mpts;
     // Get issued/authorized tokens
-    forEachItem(*ledger_, account, [&](std::shared_ptr const& sle) {
+    forEachItem(*ledger_, account, [&](SLE::const_ref sle) {
         if (sle->getType() == ltMPTOKEN_ISSUANCE)
         {
             auto const mptID = makeMptID(sle->getFieldU32(sfSequence), account);
@@ -136,7 +136,7 @@ AssetCache::getMPTs(xrpl::AccountID const& account)
             auto const mptID = sle->getFieldH192(sfMPTokenIssuanceID);
             bool const zeroBalance = sle->at(sfMPTAmount) == 0;
             bool const maxedOut = [&] {
-                if (auto const sleIssuance = ledger_->read(keylet::mptIssuance(mptID)))
+                if (auto const sleIssuance = ledger_->read(keylet::mptokenIssuance(mptID)))
                 {
                     return sleIssuance->at(sfOutstandingAmount) == maxMPTAmount(*sleIssuance);
                 }
diff --git a/src/xrpld/rpc/detail/AssetCache.h b/src/xrpld/rpc/detail/AssetCache.h
index 9112d78715..e53bc3ff94 100644
--- a/src/xrpld/rpc/detail/AssetCache.h
+++ b/src/xrpld/rpc/detail/AssetCache.h
@@ -17,7 +17,7 @@ namespace xrpl {
 class AssetCache final : public CountedObject
 {
 public:
-    explicit AssetCache(std::shared_ptr const& l, beast::Journal j);
+    explicit AssetCache(std::shared_ptr l, beast::Journal j);
     ~AssetCache();
 
     [[nodiscard]] std::shared_ptr const&
@@ -56,10 +56,10 @@ private:
     {
         AccountID account;
         LineDirection direction;
-        std::size_t hash_value;
+        std::size_t hashValue;
 
         AccountKey(AccountID const& account, LineDirection direction, std::size_t hash)
-            : account(account), direction(direction), hash_value(hash)
+            : account(account), direction(direction), hashValue(hash)
         {
         }
 
@@ -71,14 +71,14 @@ private:
         bool
         operator==(AccountKey const& lhs) const
         {
-            return hash_value == lhs.hash_value && account == lhs.account &&
+            return hashValue == lhs.hashValue && account == lhs.account &&
                 direction == lhs.direction;
         }
 
         [[nodiscard]] std::size_t
         getHash() const
         {
-            return hash_value;
+            return hashValue;
         }
 
         struct Hash
diff --git a/src/xrpld/rpc/detail/PathRequest.cpp b/src/xrpld/rpc/detail/PathRequest.cpp
index 6207b3393f..3c09917dad 100644
--- a/src/xrpld/rpc/detail/PathRequest.cpp
+++ b/src/xrpld/rpc/detail/PathRequest.cpp
@@ -72,7 +72,7 @@ PathRequest::PathRequest(
 
 PathRequest::PathRequest(
     Application& app,
-    std::function const& completion,
+    std::function completion,
     Resource::Consumer& consumer,
     int id,
     PathRequestManager& owner,
@@ -80,7 +80,7 @@ PathRequest::PathRequest(
     : app_(app)
     , journal_(journal)
     , owner_(owner)
-    , fCompletion_(completion)
+    , fCompletion_(std::move(completion))
     , consumer_(consumer)
     , jvStatus_(json::ValueType::Object)
     , lastIndex_(0)
@@ -101,16 +101,16 @@ PathRequest::~PathRequest()
         return;
 
     std::string fast, full;
-    if (quick_reply_ != steady_clock::time_point{})
+    if (quickReply_ != steady_clock::time_point{})
     {
         fast = " fast:";
-        fast += std::to_string(duration_cast(quick_reply_ - created_).count());
+        fast += std::to_string(duration_cast(quickReply_ - created_).count());
         fast += "ms";
     }
-    if (full_reply_ != steady_clock::time_point{})
+    if (fullReply_ != steady_clock::time_point{})
     {
         full = " full:";
-        full += std::to_string(duration_cast(full_reply_ - created_).count());
+        full += std::to_string(duration_cast(fullReply_ - created_).count());
         full += "ms";
     }
     stream << iIdentifier_ << " complete:" << fast << full
@@ -180,7 +180,7 @@ PathRequest::isValid(std::shared_ptr const& crCache)
     if (!raSrcAccount_ || !raDstAccount_)
         return false;
 
-    if (!convert_all_ && (saSendMax_ || saDstAmount_ <= beast::kZero))
+    if (!convertAll_ && (saSendMax_ || saDstAmount_ <= beast::kZero))
     {
         // If send max specified, dst amt must be -1.
         jvStatus_ = rpcError(RpcDstAmtMalformed);
@@ -210,7 +210,7 @@ PathRequest::isValid(std::shared_ptr const& crCache)
             return false;
         }
 
-        if (!convert_all_ && saDstAmount_ < STAmount(lrLedger->fees().reserve))
+        if (!convertAll_ && saDstAmount_ < STAmount(lrLedger->fees().reserve))
         {
             // Payment must meet reserve.
             jvStatus_ = rpcError(RpcDstAmtMalformed);
@@ -312,9 +312,9 @@ PathRequest::parseJson(json::Value const& jvParams)
         return PFR_PJ_INVALID;
     }
 
-    convert_all_ = saDstAmount_ == STAmount(saDstAmount_.asset(), 1u, 0, true);
+    convertAll_ = saDstAmount_ == STAmount(saDstAmount_.asset(), 1u, 0, true);
 
-    if (!validAsset(saDstAmount_.asset()) || (!convert_all_ && saDstAmount_ <= beast::kZero))
+    if (!validAsset(saDstAmount_.asset()) || (!convertAll_ && saDstAmount_ <= beast::kZero))
     {
         jvStatus_ = rpcError(RpcDstAmtMalformed);
         return PFR_PJ_INVALID;
@@ -323,7 +323,7 @@ PathRequest::parseJson(json::Value const& jvParams)
     if (jvParams.isMember(jss::send_max))
     {
         // Send_max requires destination amount to be -1.
-        if (!convert_all_)
+        if (!convertAll_)
         {
             jvStatus_ = rpcError(RpcDstAmtMalformed);
             return PFR_PJ_INVALID;
@@ -577,7 +577,7 @@ PathRequest::findPaths(
         }
     }
 
-    auto const dstAmount = convertAmount(saDstAmount_, convert_all_);
+    auto const dstAmount = convertAmount(saDstAmount_, convertAll_);
     hash_map> currencyMap;
     for (auto const& asset : sourceAssets)
     {
@@ -622,7 +622,7 @@ PathRequest::findPaths(
         JLOG(journal_.debug()) << iIdentifier_ << " Paths found, calling rippleCalc";
 
         path::RippleCalc::Input rcInput;
-        if (convert_all_)
+        if (convertAll_)
             rcInput.partialPaymentAllowed = true;
         auto sandbox = std::make_unique(&*cache->getLedger(), TapNone);
         auto rc = path::RippleCalc::rippleCalculate(
@@ -639,7 +639,7 @@ PathRequest::findPaths(
             app_,
             &rcInput);
 
-        if (!convert_all_ && !fullLiquidityPath.empty() &&
+        if (!convertAll_ && !fullLiquidityPath.empty() &&
             (rc.result() == terNO_LINE || rc.result() == tecPATH_PARTIAL))
         {
             JLOG(journal_.debug()) << iIdentifier_ << " Trying with an extra path element";
@@ -679,7 +679,7 @@ PathRequest::findPaths(
             jvEntry[jss::source_amount] = rc.actualAmountIn.getJson(JsonOptions::Values::None);
             jvEntry[jss::paths_computed] = ps.getJson(JsonOptions::Values::None);
 
-            if (convert_all_)
+            if (convertAll_)
             {
                 jvEntry[jss::destination_amount] =
                     rc.actualAmountOut.getJson(JsonOptions::Values::None);
@@ -754,33 +754,33 @@ PathRequest::doUpdate(
         // first pass
         if (loaded || fast)
         {
-            iLevel_ = app_.config().PATH_SEARCH_FAST;
+            iLevel_ = app_.config().pathSearchFast;
         }
         else
         {
-            iLevel_ = app_.config().PATH_SEARCH;
+            iLevel_ = app_.config().pathSearch;
         }
     }
-    else if ((iLevel_ == app_.config().PATH_SEARCH_FAST) && !fast)
+    else if ((iLevel_ == app_.config().pathSearchFast) && !fast)
     {
         // leaving fast pathfinding
-        iLevel_ = app_.config().PATH_SEARCH;
-        if (loaded && (iLevel_ > app_.config().PATH_SEARCH_FAST))
+        iLevel_ = app_.config().pathSearch;
+        if (loaded && (iLevel_ > app_.config().pathSearchFast))
             --iLevel_;
     }
     else if (bLastSuccess_)
     {
         // decrement, if possible
-        if (iLevel_ > app_.config().PATH_SEARCH ||
-            (loaded && (iLevel_ > app_.config().PATH_SEARCH_FAST)))
+        if (iLevel_ > app_.config().pathSearch ||
+            (loaded && (iLevel_ > app_.config().pathSearchFast)))
             --iLevel_;
     }
     else
     {
         // adjust as needed
-        if (!loaded && (iLevel_ < app_.config().PATH_SEARCH_MAX))
+        if (!loaded && (iLevel_ < app_.config().pathSearchMax))
             ++iLevel_;
-        if (loaded && (iLevel_ > app_.config().PATH_SEARCH_FAST))
+        if (loaded && (iLevel_ > app_.config().pathSearchFast))
             --iLevel_;
     }
 
@@ -798,15 +798,15 @@ PathRequest::doUpdate(
         newStatus = rpcError(RpcInternal);
     }
 
-    if (fast && quick_reply_ == steady_clock::time_point{})
+    if (fast && quickReply_ == steady_clock::time_point{})
     {
-        quick_reply_ = steady_clock::now();
-        owner_.reportFast(duration_cast(quick_reply_ - created_));
+        quickReply_ = steady_clock::now();
+        owner_.reportFast(duration_cast(quickReply_ - created_));
     }
-    else if (!fast && full_reply_ == steady_clock::time_point{})
+    else if (!fast && fullReply_ == steady_clock::time_point{})
     {
-        full_reply_ = steady_clock::now();
-        owner_.reportFull(duration_cast(full_reply_ - created_));
+        fullReply_ = steady_clock::now();
+        owner_.reportFull(duration_cast(fullReply_ - created_));
     }
 
     {
diff --git a/src/xrpld/rpc/detail/PathRequest.h b/src/xrpld/rpc/detail/PathRequest.h
index 17381c5ea5..372223e99f 100644
--- a/src/xrpld/rpc/detail/PathRequest.h
+++ b/src/xrpld/rpc/detail/PathRequest.h
@@ -51,7 +51,7 @@ public:
     // Completion function is called after path update is complete
     PathRequest(
         Application& app,
-        std::function const& completion,
+        std::function completion,
         Resource::Consumer& consumer,
         int id,
         PathRequestManager&,
@@ -140,7 +140,7 @@ private:
 
     std::optional domain_;
 
-    bool convert_all_{};
+    bool convertAll_{};
 
     std::recursive_mutex indexLock_;
     LedgerIndex lastIndex_;
@@ -152,8 +152,8 @@ private:
     int const iIdentifier_;
 
     std::chrono::steady_clock::time_point const created_;
-    std::chrono::steady_clock::time_point quick_reply_;
-    std::chrono::steady_clock::time_point full_reply_;
+    std::chrono::steady_clock::time_point quickReply_;
+    std::chrono::steady_clock::time_point fullReply_;
 
     static unsigned int const kMaxPaths = 4;
 };
diff --git a/src/xrpld/rpc/detail/PathRequestManager.cpp b/src/xrpld/rpc/detail/PathRequestManager.cpp
index ba42a0122f..7013353cb1 100644
--- a/src/xrpld/rpc/detail/PathRequestManager.cpp
+++ b/src/xrpld/rpc/detail/PathRequestManager.cpp
@@ -125,7 +125,8 @@ PathRequestManager::updateAll(std::shared_ptr const& inLedger)
                             json::Value update = request->doUpdate(cache, false, continueCallback);
                             request->updateComplete();
                             update[jss::type] = "path_find";
-                            if ((ipSub = getSubscriber(request)))
+                            ipSub = getSubscriber(request);
+                            if (ipSub)
                             {
                                 ipSub->send(update, false);
                                 remove = false;
diff --git a/src/xrpld/rpc/detail/PathRequestManager.h b/src/xrpld/rpc/detail/PathRequestManager.h
index 5a5cfde402..c8e272a97d 100644
--- a/src/xrpld/rpc/detail/PathRequestManager.h
+++ b/src/xrpld/rpc/detail/PathRequestManager.h
@@ -3,7 +3,6 @@
 #include 
 #include 
 #include 
-#include 
 
 #include 
 #include 
diff --git a/src/xrpld/rpc/detail/Pathfinder.cpp b/src/xrpld/rpc/detail/Pathfinder.cpp
index e949c9b6ff..fc788dea7d 100644
--- a/src/xrpld/rpc/detail/Pathfinder.cpp
+++ b/src/xrpld/rpc/detail/Pathfinder.cpp
@@ -3,7 +3,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 
 #include 
@@ -226,7 +225,7 @@ Pathfinder::Pathfinder(
     , srcPathAsset_(uSrcPathAsset)
     , srcIssuer_(uSrcIssuer)
     , srcAmount_(amountFromPathAsset(uSrcPathAsset, uSrcIssuer, uSrcAccount))
-    , convert_all_(convertAllCheck(dstAmount_))
+    , convertAll_(convertAllCheck(dstAmount_))
     , domain_(domain)
     , ledger_(cache->getLedger())
     , rLCache_(cache)
@@ -398,7 +397,7 @@ Pathfinder::getPathLiquidity(
     try
     {
         // Compute a path that provides at least the minimum liquidity.
-        if (convert_all_)
+        if (convertAll_)
             rcInput.partialPaymentAllowed = true;
 
         auto rc = path::RippleCalc::rippleCalculate(
@@ -418,7 +417,7 @@ Pathfinder::getPathLiquidity(
         qualityOut = getRate(rc.actualAmountOut, rc.actualAmountIn);
         amountOut = rc.actualAmountOut;
 
-        if (!convert_all_)
+        if (!convertAll_)
         {
             // Now try to compute the remaining liquidity.
             rcInput.partialPaymentAllowed = true;
@@ -451,7 +450,7 @@ Pathfinder::getPathLiquidity(
 void
 Pathfinder::computePathRanks(int maxPaths, std::function const& continueCallback)
 {
-    remainingAmount_ = convertAmount(dstAmount_, convert_all_);
+    remainingAmount_ = convertAmount(dstAmount_, convertAll_);
 
     // Must subtract liquidity in default path from remaining amount.
     try
@@ -536,7 +535,7 @@ Pathfinder::rankPaths(
     rankedPaths.reserve(paths.size());
 
     auto const saMinDstAmount = [&]() -> STAmount {
-        if (!convert_all_)
+        if (!convertAll_)
         {
             // Ignore paths that move only very small amounts.
             return smallestUsefulAmount(dstAmount_, maxPaths);
@@ -568,7 +567,11 @@ Pathfinder::rankPaths(
                 JLOG(j_.debug()) << "findPaths: quality: " << uQuality << ": "
                                  << currentPath.getJson(JsonOptions::Values::None);
 
-                rankedPaths.push_back({uQuality, currentPath.size(), liquidity, i});
+                rankedPaths.push_back(
+                    {.quality = uQuality,
+                     .length = currentPath.size(),
+                     .liquidity = liquidity,
+                     .index = i});
             }
         }
     }
@@ -581,7 +584,7 @@ Pathfinder::rankPaths(
     std::ranges::sort(
         rankedPaths, [&](Pathfinder::PathRank const& a, Pathfinder::PathRank const& b) {
             // 1) Higher quality (lower cost) is better
-            if (!convert_all_ && a.quality != b.quality)
+            if (!convertAll_ && a.quality != b.quality)
                 return a.quality < b.quality;
 
             // 2) More liquidity (higher volume) is better
@@ -643,21 +646,17 @@ Pathfinder::getBestPaths(
         {
             usePath = true;
         }
-        else if (extraPathsIterator->quality < pathsIterator->quality)
+        else if (extraPathsIterator->quality != pathsIterator->quality)
         {
-            useExtraPath = true;
+            // Prefer the lower (better) quality value
+            useExtraPath = extraPathsIterator->quality < pathsIterator->quality;
+            usePath = !useExtraPath;
         }
-        else if (extraPathsIterator->quality > pathsIterator->quality)
+        else if (extraPathsIterator->liquidity != pathsIterator->liquidity)
         {
-            usePath = true;
-        }
-        else if (extraPathsIterator->liquidity > pathsIterator->liquidity)
-        {
-            useExtraPath = true;
-        }
-        else if (extraPathsIterator->liquidity < pathsIterator->liquidity)
-        {
-            usePath = true;
+            // Equal quality: prefer the higher liquidity
+            useExtraPath = extraPathsIterator->liquidity > pathsIterator->liquidity;
+            usePath = !useExtraPath;
         }
         else
         {
@@ -792,31 +791,22 @@ Pathfinder::getPathsOut(
                     for (auto const& rspEntry : *lines)
                     {
                         if (pathAsset.get() != rspEntry.getLimit().get().currency)
-                        {
-                        }
-                        else if (
-                            rspEntry.getBalance() <= beast::kZero &&
+                            continue;
+                        if (rspEntry.getBalance() <= beast::kZero &&
                             (!rspEntry.getLimitPeer() ||
                              -rspEntry.getBalance() >= rspEntry.getLimitPeer() ||
                              (bAuthRequired && !rspEntry.getAuth())))
-                        {
-                        }
-                        else if (isDstAsset && dstAccount == rspEntry.getAccountIDPeer())
+                            continue;
+                        if (isDstAsset && dstAccount == rspEntry.getAccountIDPeer())
                         {
                             count += 10000;  // count a path to the destination extra
+                            continue;
                         }
-                        else if (rspEntry.getNoRipplePeer())
-                        {
-                            // This probably isn't a useful path out
-                        }
-                        else if (rspEntry.getFreezePeer())
-                        {
-                            // Not a useful path out
-                        }
-                        else
-                        {
-                            ++count;
-                        }
+                        if (rspEntry.getNoRipplePeer())
+                            continue;  // This probably isn't a useful path out
+                        if (rspEntry.getFreezePeer())
+                            continue;  // Not a useful path out
+                        ++count;
                     }
                 }
             },
@@ -825,26 +815,17 @@ Pathfinder::getPathsOut(
                 {
                     for (auto const& mpt : *mpts)
                     {
-                        if (pathAsset.get() != mpt.getMptID())
-                        {
-                        }
-                        else if (mpt.isZeroBalance() || mpt.isMaxedOut())
-                        {
-                        }
-                        else if (bAuthRequired)
-                        {
-                        }
-                        else if (isDstAsset && dstAccount == getMPTIssuer(mpt))
+                        if (pathAsset.get() != mpt.getMptID() || mpt.isZeroBalance() ||
+                            mpt.isMaxedOut() || bAuthRequired)
+                            continue;
+                        if (isDstAsset && dstAccount == getMPTIssuer(mpt))
                         {
                             count += 10000;
+                            continue;
                         }
-                        else if (isIndividualFrozen(*ledger_, account, MPTIssue{mpt.getMptID()}))
-                        {
-                        }
-                        else
-                        {
-                            ++count;
-                        }
+                        if (isIndividualFrozen(*ledger_, account, MPTIssue{mpt.getMptID()}))
+                            continue;
+                        ++count;
                     }
                 }
             });
@@ -948,7 +929,7 @@ Pathfinder::isNoRipple(
     AccountID const& toAccount,
     Currency const& currency)
 {
-    auto sleRipple = ledger_->read(keylet::line(toAccount, fromAccount, currency));
+    auto sleRipple = ledger_->read(keylet::trustLine(toAccount, fromAccount, currency));
 
     auto const flag((toAccount > fromAccount) ? lsfHighNoRipple : lsfLowNoRipple);
 
@@ -1114,8 +1095,9 @@ Pathfinder::addLink(
                             if (checkAsset())
                             {
                                 // Can't leave on this path
+                                continue;
                             }
-                            else if (bToDestination)
+                            if (bToDestination)
                             {
                                 // destination is always worth trying
                                 if (uEndPathAsset == dstAmount_.asset())
@@ -1373,7 +1355,7 @@ fillPaths(Pathfinder::PaymentType type, PathCostList const& costs)
     auto& list = gPathTable[type];
     XRPL_ASSERT(list.empty(), "xrpl::fillPaths : empty paths");
     for (auto& cost : costs)
-        list.push_back({cost.cost, makePath(cost.path)});
+        list.push_back({.searchLevel = cost.cost, .type = makePath(cost.path)});
 }
 
 }  // namespace
@@ -1396,58 +1378,58 @@ Pathfinder::initPathTable()
 
     fillPaths(
         PaymentType::XrpToNonXrp,
-        {{1, "sfd"},    // source -> book -> gateway
-         {3, "sfad"},   // source -> book -> account -> destination
-         {5, "sfaad"},  // source -> book -> account -> account -> destination
-         {6, "sbfd"},   // source -> book -> book -> destination
-         {8, "sbafd"},  // source -> book -> account -> book -> destination
-         {9, "sbfad"},  // source -> book -> book -> account -> destination
-         {10, "sbafad"}});
+        {{.cost = 1, .path = "sfd"},    // source -> book -> gateway
+         {.cost = 3, .path = "sfad"},   // source -> book -> account -> destination
+         {.cost = 5, .path = "sfaad"},  // source -> book -> account -> account -> destination
+         {.cost = 6, .path = "sbfd"},   // source -> book -> book -> destination
+         {.cost = 8, .path = "sbafd"},  // source -> book -> account -> book -> destination
+         {.cost = 9, .path = "sbfad"},  // source -> book -> book -> account -> destination
+         {.cost = 10, .path = "sbafad"}});
 
     fillPaths(
         PaymentType::NonXrpToXrp,
-        {{1, "sxd"},   // gateway buys XRP
-         {2, "saxd"},  // source -> gateway -> book(XRP) -> dest
-         {6, "saaxd"},
-         {7, "sbxd"},
-         {8, "sabxd"},
-         {9, "sabaxd"}});
+        {{.cost = 1, .path = "sxd"},   // gateway buys XRP
+         {.cost = 2, .path = "saxd"},  // source -> gateway -> book(XRP) -> dest
+         {.cost = 6, .path = "saaxd"},
+         {.cost = 7, .path = "sbxd"},
+         {.cost = 8, .path = "sabxd"},
+         {.cost = 9, .path = "sabaxd"}});
 
     // non-XRP to non-XRP (same currency)
     fillPaths(
         PaymentType::NonXrpToSame,
         {
-            {1, "sad"},   // source -> gateway -> destination
-            {1, "sfd"},   // source -> book -> destination
-            {4, "safd"},  // source -> gateway -> book -> destination
-            {4, "sfad"},
-            {5, "saad"},
-            {5, "sbfd"},
-            {6, "sxfad"},
-            {6, "safad"},
-            {6, "saxfd"},  // source -> gateway -> book to XRP -> book ->
-                           // destination
-            {6, "saxfad"},
-            {6, "sabfd"},  // source -> gateway -> book -> book -> destination
-            {7, "saaad"},
+            {.cost = 1, .path = "sad"},   // source -> gateway -> destination
+            {.cost = 1, .path = "sfd"},   // source -> book -> destination
+            {.cost = 4, .path = "safd"},  // source -> gateway -> book -> destination
+            {.cost = 4, .path = "sfad"},
+            {.cost = 5, .path = "saad"},
+            {.cost = 5, .path = "sbfd"},
+            {.cost = 6, .path = "sxfad"},
+            {.cost = 6, .path = "safad"},
+            {.cost = 6, .path = "saxfd"},  // source -> gateway -> book to XRP -> book ->
+                                           // destination
+            {.cost = 6, .path = "saxfad"},
+            {.cost = 6, .path = "sabfd"},  // source -> gateway -> book -> book -> destination
+            {.cost = 7, .path = "saaad"},
         });
 
     // non-XRP to non-XRP (different currency)
     fillPaths(
         PaymentType::NonXrpToNonXrp,
         {
-            {1, "sfad"},
-            {1, "safd"},
-            {3, "safad"},
-            {4, "sxfd"},
-            {5, "saxfd"},
-            {5, "sxfad"},
-            {5, "sbfd"},
-            {6, "saxfad"},
-            {6, "sabfd"},
-            {7, "saafd"},
-            {8, "saafad"},
-            {9, "safaad"},
+            {.cost = 1, .path = "sfad"},
+            {.cost = 1, .path = "safd"},
+            {.cost = 3, .path = "safad"},
+            {.cost = 4, .path = "sxfd"},
+            {.cost = 5, .path = "saxfd"},
+            {.cost = 5, .path = "sxfad"},
+            {.cost = 5, .path = "sbfd"},
+            {.cost = 6, .path = "saxfad"},
+            {.cost = 6, .path = "sabfd"},
+            {.cost = 7, .path = "saafd"},
+            {.cost = 8, .path = "saafad"},
+            {.cost = 9, .path = "safaad"},
         });
     /* cspell: enable */
 }
diff --git a/src/xrpld/rpc/detail/Pathfinder.h b/src/xrpld/rpc/detail/Pathfinder.h
index de2c2d83e6..36caab308e 100644
--- a/src/xrpld/rpc/detail/Pathfinder.h
+++ b/src/xrpld/rpc/detail/Pathfinder.h
@@ -178,7 +178,7 @@ private:
     /** The amount remaining from srcAccount_ after the default liquidity has
         been removed. */
     STAmount remainingAmount_;
-    bool convert_all_;
+    bool convertAll_;
     std::optional domain_;
 
     std::shared_ptr ledger_;
diff --git a/src/xrpld/rpc/detail/RPCCall.cpp b/src/xrpld/rpc/detail/RPCCall.cpp
index 24fcc652df..123b9fc7a5 100644
--- a/src/xrpld/rpc/detail/RPCCall.cpp
+++ b/src/xrpld/rpc/detail/RPCCall.cpp
@@ -1589,7 +1589,7 @@ struct RPCCallImp
 
             jvResult["result"] = jvReply;
 
-            (callbackFuncP)(jvResult);
+            callbackFuncP(jvResult);
         }
 
         return false;
@@ -1702,19 +1702,19 @@ rpcClient(
                 // line client works without a config file
             }
 
-            if (config.rpc_ip)
+            if (config.rpcIp)
             {
-                setup.client.ip = config.rpc_ip->address().to_string();
-                setup.client.port = config.rpc_ip->port();
+                setup.client.ip = config.rpcIp->address().to_string();
+                setup.client.port = config.rpcIp->port();
             }
 
             json::Value jvParams(json::ValueType::Array);
 
-            if (!setup.client.admin_user.empty())
-                jvRequest["admin_user"] = setup.client.admin_user;
+            if (!setup.client.adminUser.empty())
+                jvRequest["admin_user"] = setup.client.adminUser;
 
-            if (!setup.client.admin_password.empty())
-                jvRequest["admin_password"] = setup.client.admin_password;
+            if (!setup.client.adminPassword.empty())
+                jvRequest["admin_password"] = setup.client.adminPassword;
 
             if (jvRequest.isObject())
             {
diff --git a/src/xrpld/rpc/detail/RPCHandler.cpp b/src/xrpld/rpc/detail/RPCHandler.cpp
index 6b032317d3..7e30b81fa6 100644
--- a/src/xrpld/rpc/detail/RPCHandler.cpp
+++ b/src/xrpld/rpc/detail/RPCHandler.cpp
@@ -135,7 +135,7 @@ fillHandler(JsonContext& context, Handler const*& result)
 
     JLOG(context.j.trace()) << "COMMAND:" << strCommand;
     JLOG(context.j.trace()) << "REQUEST:" << context.params;
-    auto handler = getHandler(context.apiVersion, context.app.config().BETA_RPC_API, strCommand);
+    auto handler = getHandler(context.apiVersion, context.app.config().betaRpcApi, strCommand);
 
     if (handler == nullptr)
         return RpcUnknownCommand;
diff --git a/src/xrpld/rpc/detail/RPCHelpers.cpp b/src/xrpld/rpc/detail/RPCHelpers.cpp
index d48057a0a8..1764375812 100644
--- a/src/xrpld/rpc/detail/RPCHelpers.cpp
+++ b/src/xrpld/rpc/detail/RPCHelpers.cpp
@@ -38,7 +38,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -46,7 +45,7 @@
 namespace xrpl::RPC {
 
 std::uint64_t
-getStartHint(std::shared_ptr const& sle, AccountID const& accountID)
+getStartHint(SLE::const_ref sle, AccountID const& accountID)
 {
     if (sle->getType() == ltRIPPLE_STATE)
     {
@@ -67,10 +66,7 @@ getStartHint(std::shared_ptr const& sle, AccountID const& accountID)
 }
 
 bool
-isRelatedToAccount(
-    ReadView const& ledger,
-    std::shared_ptr const& sle,
-    AccountID const& accountID)
+isRelatedToAccount(ReadView const& ledger, SLE::const_ref sle, AccountID const& accountID)
 {
     if (sle->getType() == ltRIPPLE_STATE)
     {
@@ -90,7 +86,7 @@ isRelatedToAccount(
     }
     if (sle->getType() == ltSIGNER_LIST)
     {
-        Keylet const accountSignerList = keylet::signers(accountID);
+        Keylet const accountSignerList = keylet::signerList(accountID);
         return sle->key() == accountSignerList.key;
     }
     if (sle->getType() == ltNFTOKEN_OFFER)
diff --git a/src/xrpld/rpc/detail/RPCHelpers.h b/src/xrpld/rpc/detail/RPCHelpers.h
index 781db1b8a5..bbc101a072 100644
--- a/src/xrpld/rpc/detail/RPCHelpers.h
+++ b/src/xrpld/rpc/detail/RPCHelpers.h
@@ -33,7 +33,7 @@ struct JsonContext;
  * @return A 64-bit unsigned integer representing the start hint for traversal.
  */
 std::uint64_t
-getStartHint(std::shared_ptr const& sle, AccountID const& accountID);
+getStartHint(SLE::const_ref sle, AccountID const& accountID);
 
 /**
  * @brief Tests if a ledger entry (SLE) is owned by the specified account.
@@ -47,10 +47,7 @@ getStartHint(std::shared_ptr const& sle, AccountID const& accountID);
  * @return true if the SLE is owned by the account, false otherwise.
  */
 bool
-isRelatedToAccount(
-    ReadView const& ledger,
-    std::shared_ptr const& sle,
-    AccountID const& accountID);
+isRelatedToAccount(ReadView const& ledger, SLE::const_ref sle, AccountID const& accountID);
 
 /**
  * @brief Parses an array of account IDs from a JSON value.
diff --git a/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp b/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp
index 4d0d10a66b..38827dc93c 100644
--- a/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp
+++ b/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp
@@ -8,7 +8,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -24,6 +23,7 @@
 #include 
 
 #include 
+#include 
 #include 
 
 namespace xrpl::RPC {
@@ -381,7 +381,7 @@ lookupLedger(std::shared_ptr& ledger, JsonContext const& context
     return result;
 }
 
-Expected, json::Value>
+std::expected, json::Value>
 getOrAcquireLedger(RPC::JsonContext const& context)
 {
     auto const hasHash = context.params.isMember(jss::ledger_hash);
@@ -393,7 +393,7 @@ getOrAcquireLedger(RPC::JsonContext const& context)
 
     if ((static_cast(hasHash) + static_cast(hasIndex)) != 1)
     {
-        return Unexpected(
+        return std::unexpected(
             RPC::makeParamError(
                 "Exactly one of 'ledger_hash' or "
                 "'ledger_index' can be specified."));
@@ -403,29 +403,29 @@ getOrAcquireLedger(RPC::JsonContext const& context)
     {
         auto const& jsonHash = context.params.get(jss::ledger_hash, json::ValueType::Null);
         if (!jsonHash.isString() || !ledgerHash.parseHex(jsonHash.asString()))
-            return Unexpected(RPC::expectedFieldError(jss::ledger_hash, "hex string"));
+            return std::unexpected(RPC::expectedFieldError(jss::ledger_hash, "hex string"));
     }
     else
     {
         auto const& jsonIndex = context.params.get(jss::ledger_index, json::ValueType::Null);
         if (!jsonIndex.isInt() && !jsonIndex.isUInt())
-            return Unexpected(RPC::expectedFieldError(jss::ledger_index, "number"));
+            return std::unexpected(RPC::expectedFieldError(jss::ledger_index, "number"));
 
         // We need a validated ledger to get the hash from the sequence
         if (ledgerMaster.getValidatedLedgerAge() > RPC::Tuning::kMaxValidatedLedgerAge)
         {
             if (context.apiVersion == 1)
-                return Unexpected(rpcError(RpcNoCurrent));
-            return Unexpected(rpcError(RpcNotSynced));
+                return std::unexpected(rpcError(RpcNoCurrent));
+            return std::unexpected(rpcError(RpcNotSynced));
         }
 
         ledgerIndex = jsonIndex.asInt();
         auto ledger = ledgerMaster.getValidatedLedger();
 
         if (ledgerIndex >= ledger->header().seq)
-            return Unexpected(RPC::makeParamError("Ledger index too large"));
+            return std::unexpected(RPC::makeParamError("Ledger index too large"));
         if (ledgerIndex <= 0)
-            return Unexpected(RPC::makeParamError("Ledger index too small"));
+            return std::unexpected(RPC::makeParamError("Ledger index too small"));
 
         auto const j = context.app.getJournal("RPCHandler");
         // Try to get the hash of the desired ledger from the validated
@@ -452,7 +452,7 @@ getOrAcquireLedger(RPC::JsonContext const& context)
                     json::Value jvResult = RPC::makeError(
                         RpcLgrNotFound, "acquiring ledger containing requested index");
                     jvResult[jss::acquiring] = getJson(LedgerFill(*il, &context));
-                    return Unexpected(jvResult);
+                    return std::unexpected(jvResult);
                 }
 
                 if (auto il = context.app.getInboundLedgers().find(*refHash))
@@ -461,11 +461,11 @@ getOrAcquireLedger(RPC::JsonContext const& context)
                     json::Value jvResult = RPC::makeError(
                         RpcLgrNotFound, "acquiring ledger containing requested index");
                     jvResult[jss::acquiring] = il->getJson(0);
-                    return Unexpected(jvResult);
+                    return std::unexpected(jvResult);
                 }
 
                 // Likely the app is shutting down
-                return Unexpected(json::Value());
+                return std::unexpected(json::Value());
             }
 
             neededHash = hashOfSeq(*ledger, ledgerIndex, j);
@@ -487,9 +487,10 @@ getOrAcquireLedger(RPC::JsonContext const& context)
         return ledger;
 
     if (auto il = context.app.getInboundLedgers().find(ledgerHash))
-        return Unexpected(il->getJson(0));
+        return std::unexpected(il->getJson(0));
 
-    return Unexpected(RPC::makeError(RpcNotReady, "findCreate failed to return an inbound ledger"));
+    return std::unexpected(
+        RPC::makeError(RpcNotReady, "findCreate failed to return an inbound ledger"));
 }
 
 }  // namespace xrpl::RPC
diff --git a/src/xrpld/rpc/detail/RPCLedgerHelpers.h b/src/xrpld/rpc/detail/RPCLedgerHelpers.h
index faec4ae069..9ec2a6673c 100644
--- a/src/xrpld/rpc/detail/RPCLedgerHelpers.h
+++ b/src/xrpld/rpc/detail/RPCLedgerHelpers.h
@@ -10,6 +10,7 @@
 #include 
 #include 
 
+#include 
 #include 
 
 namespace xrpl {
@@ -163,11 +164,11 @@ ledgerFromSpecifier(
  *
  * @param context The RPC JsonContext containing request parameters and
  * environment.
- * @return Expected, json::Value>
+ * @return std::expected, json::Value>
  *         On success, contains a shared pointer to the requested Ledger.
  *         On failure, contains a json::Value describing the error.
  */
-Expected, json::Value>
+std::expected, json::Value>
 getOrAcquireLedger(RPC::JsonContext const& context);
 
 }  // namespace RPC
diff --git a/src/xrpld/rpc/detail/RPCSub.cpp b/src/xrpld/rpc/detail/RPCSub.cpp
index 56291ab34b..92e9849939 100644
--- a/src/xrpld/rpc/detail/RPCSub.cpp
+++ b/src/xrpld/rpc/detail/RPCSub.cpp
@@ -39,7 +39,7 @@ public:
         std::string strPassword,
         ServiceRegistry& registry)
         : RPCSub(source)
-        , io_context_(ioContext)
+        , ioContext_(ioContext)
         , jobQueue_(jobQueue)
         , url_(strUrl)
         , username_(std::move(strUsername))
@@ -159,7 +159,7 @@ private:
                     JLOG(j_.info()) << "RPCCall::fromNetwork: " << ip_;
 
                     RPCCall::fromNetwork(
-                        io_context_,
+                        ioContext_,
                         ip_,
                         port_,
                         username_,
@@ -180,7 +180,7 @@ private:
     }
 
 private:
-    boost::asio::io_context& io_context_;
+    boost::asio::io_context& ioContext_;
     JobQueue& jobQueue_;
 
     std::string url_;
diff --git a/src/xrpld/rpc/detail/RippleLineCache.cpp b/src/xrpld/rpc/detail/RippleLineCache.cpp
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/xrpld/rpc/detail/RippleLineCache.h b/src/xrpld/rpc/detail/RippleLineCache.h
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/xrpld/rpc/detail/Role.cpp b/src/xrpld/rpc/detail/Role.cpp
index dcc6965d58..68c5fcc484 100644
--- a/src/xrpld/rpc/detail/Role.cpp
+++ b/src/xrpld/rpc/detail/Role.cpp
@@ -28,14 +28,14 @@ bool
 passwordUnrequiredOrSentCorrect(Port const& port, json::Value const& params)
 {
     XRPL_ASSERT(
-        !(port.admin_nets_v4.empty() && port.admin_nets_v6.empty()),
+        !(port.adminNetsV4.empty() && port.adminNetsV6.empty()),
         "xrpl::passwordUnrequiredOrSentCorrect : non-empty admin nets");
-    bool const passwordRequired = (!port.admin_user.empty() || !port.admin_password.empty());
+    bool const passwordRequired = (!port.adminUser.empty() || !port.adminPassword.empty());
 
     return !passwordRequired ||
         ((params["admin_password"].isString() &&
-          params["admin_password"].asString() == port.admin_password) &&
-         (params["admin_user"].isString() && params["admin_user"].asString() == port.admin_user));
+          params["admin_password"].asString() == port.adminPassword) &&
+         (params["admin_user"].isString() && params["admin_user"].asString() == port.adminUser));
 }
 
 bool
@@ -80,7 +80,7 @@ ipAllowed(
 bool
 isAdmin(Port const& port, json::Value const& params, beast::IP::Address const& remoteIp)
 {
-    return ipAllowed(remoteIp, port.admin_nets_v4, port.admin_nets_v6) &&
+    return ipAllowed(remoteIp, port.adminNetsV4, port.adminNetsV6) &&
         passwordUnrequiredOrSentCorrect(port, params);
 }
 
@@ -98,7 +98,7 @@ requestRole(
     if (required == Role::ADMIN)
         return Role::FORBID;
 
-    if (ipAllowed(remoteIp.address(), port.secure_gateway_nets_v4, port.secure_gateway_nets_v6))
+    if (ipAllowed(remoteIp.address(), port.secureGatewayNetsV4, port.secureGatewayNetsV6))
     {
         if (!user.empty())
             return Role::IDENTIFIED;
diff --git a/src/xrpld/rpc/detail/ServerHandler.cpp b/src/xrpld/rpc/detail/ServerHandler.cpp
index 2ed6630af2..768cbf0dc0 100644
--- a/src/xrpld/rpc/detail/ServerHandler.cpp
+++ b/src/xrpld/rpc/detail/ServerHandler.cpp
@@ -1,7 +1,6 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -16,6 +15,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -139,9 +139,9 @@ ServerHandler::ServerHandler(
     , jobQueue_(jobQueue)
 {
     auto const& group(cm.group("rpc"));
-    rpc_requests_ = group->makeCounter("requests");
-    rpc_size_ = group->makeEvent("size");
-    rpc_time_ = group->makeEvent("time");
+    rpcRequests_ = group->makeCounter("requests");
+    rpcSize_ = group->makeEvent("size");
+    rpcTime_ = group->makeEvent("time");
 }
 
 ServerHandler::~ServerHandler()
@@ -165,10 +165,10 @@ ServerHandler::setup(Setup const& setup, beast::Journal journal)
                 port.port = endpointPort;
 
             if ((setup_.client.port == 0u) &&
-                (port.protocol.count("http") > 0 || port.protocol.count("https") > 0))
+                (port.protocol.contains("http") || port.protocol.contains("https")))
                 setup_.client.port = endpointPort;
 
-            if ((setup_.overlay.port() == 0u) && (port.protocol.count("peer") > 0))
+            if ((setup_.overlay.port() == 0u) && (port.protocol.contains("peer")))
                 setup_.overlay.port(endpointPort);
         }
     }
@@ -217,7 +217,7 @@ ServerHandler::onHandoff(
     using namespace boost::beast;
     auto const& p{session.port().protocol};
     bool const isWs{
-        p.count("ws") > 0 || p.count("ws2") > 0 || p.count("wss") > 0 || p.count("wss2") > 0};
+        p.contains("ws") || p.contains("ws2") || p.contains("wss") || p.contains("wss2")};
 
     if (websocket::is_upgrade(request))
     {
@@ -251,7 +251,7 @@ ServerHandler::onHandoff(
         return handoff;
     }
 
-    if (bundle && p.count("peer") > 0)
+    if (bundle && p.contains("peer"))
         return app_.getOverlay().onHandoff(std::move(bundle), std::move(request), remoteAddress);
 
     if (isWs && isStatusRequest(request))
@@ -301,7 +301,7 @@ void
 ServerHandler::onRequest(Session& session)
 {
     // Make sure RPC is enabled on the port
-    if (session.port().protocol.count("http") == 0 && session.port().protocol.count("https") == 0)
+    if (!session.port().protocol.contains("http") && !session.port().protocol.contains("https"))
     {
         httpReply(403, "Forbidden", makeOutput(session), app_.getJournal("RPC"));
         session.close(true);
@@ -429,7 +429,7 @@ ServerHandler::processSession(
     Resource::Charge loadType = Resource::kFeeReferenceRpc;
     try
     {
-        auto apiVersion = RPC::getAPIVersionNumber(jv, app_.config().BETA_RPC_API);
+        auto apiVersion = RPC::getAPIVersionNumber(jv, app_.config().betaRpcApi);
         if (apiVersion == RPC::kApiInvalidVersion ||
             (!jv.isMember(jss::command) && !jv.isMember(jss::method)) ||
             (jv.isMember(jss::command) && !jv[jss::command].isString()) ||
@@ -457,7 +457,7 @@ ServerHandler::processSession(
 
         auto required = RPC::roleRequired(
             apiVersion,
-            app_.config().BETA_RPC_API,
+            app_.config().betaRpcApi,
             jv.isMember(jss::command) ? jv[jss::command].asString() : jv[jss::method].asString());
         auto role = requestRole(
             required,
@@ -657,13 +657,13 @@ ServerHandler::processRequest(
             jsonRPC[jss::params].size() > 0 && jsonRPC[jss::params][0u].isObject())
         {
             apiVersion = RPC::getAPIVersionNumber(
-                jsonRPC[jss::params][json::UInt(0)], app_.config().BETA_RPC_API);
+                jsonRPC[jss::params][json::UInt(0)], app_.config().betaRpcApi);
         }
 
         if (apiVersion == RPC::kApiVersionIfUnspecified && batch)
         {
             // for batch request, api_version may be at a different level
-            apiVersion = RPC::getAPIVersionNumber(jsonRPC, app_.config().BETA_RPC_API);
+            apiVersion = RPC::getAPIVersionNumber(jsonRPC, app_.config().betaRpcApi);
         }
 
         if (apiVersion == RPC::kApiInvalidVersion)
@@ -686,7 +686,7 @@ ServerHandler::processRequest(
         if (jsonRPC.isMember(jss::method) && jsonRPC[jss::method].isString())
         {
             required = RPC::roleRequired(
-                apiVersion, app_.config().BETA_RPC_API, jsonRPC[jss::method].asString());
+                apiVersion, app_.config().betaRpcApi, jsonRPC[jss::method].asString());
         }
 
         if (jsonRPC.isMember(jss::params) && jsonRPC[jss::params].isArray() &&
@@ -993,11 +993,11 @@ ServerHandler::processRequest(
 
     auto response = to_string(reply);
 
-    rpc_time_.notify(
+    rpcTime_.notify(
         std::chrono::duration_cast(
             std::chrono::high_resolution_clock::now() - start));
-    ++rpc_requests_;
-    rpc_size_.notify(beast::insight::Event::value_type{response.size()});
+    ++rpcRequests_;
+    rpcSize_.notify(beast::insight::Event::value_type{response.size()});
 
     response += '\n';
 
@@ -1060,13 +1060,13 @@ ServerHandler::Setup::makeContexts()
     {
         if (p.secure())
         {
-            if (p.ssl_key.empty() && p.ssl_cert.empty() && p.ssl_chain.empty())
+            if (p.sslKey.empty() && p.sslCert.empty() && p.sslChain.empty())
             {
-                p.context = makeSslContext(p.ssl_ciphers);
+                p.context = makeSslContext(p.sslCiphers);
             }
             else
             {
-                p.context = makeSslContextAuthed(p.ssl_key, p.ssl_cert, p.ssl_chain, p.ssl_ciphers);
+                p.context = makeSslContextAuthed(p.sslKey, p.sslCert, p.sslChain, p.sslCiphers);
             }
         }
         else
@@ -1106,19 +1106,19 @@ toPort(ParsedPort const& parsed, std::ostream& log)
 
     p.user = parsed.user;
     p.password = parsed.password;
-    p.admin_user = parsed.admin_user;
-    p.admin_password = parsed.admin_password;
-    p.ssl_key = parsed.ssl_key;
-    p.ssl_cert = parsed.ssl_cert;
-    p.ssl_chain = parsed.ssl_chain;
-    p.ssl_ciphers = parsed.ssl_ciphers;
-    p.pmd_options = parsed.pmd_options;
-    p.ws_queue_limit = parsed.ws_queue_limit;
+    p.adminUser = parsed.adminUser;
+    p.adminPassword = parsed.adminPassword;
+    p.sslKey = parsed.sslKey;
+    p.sslCert = parsed.sslCert;
+    p.sslChain = parsed.sslChain;
+    p.sslCiphers = parsed.sslCiphers;
+    p.pmdOptions = parsed.pmdOptions;
+    p.wsQueueLimit = parsed.wsQueueLimit;
     p.limit = parsed.limit;
-    p.admin_nets_v4 = parsed.admin_nets_v4;
-    p.admin_nets_v6 = parsed.admin_nets_v6;
-    p.secure_gateway_nets_v4 = parsed.secure_gateway_nets_v4;
-    p.secure_gateway_nets_v6 = parsed.secure_gateway_nets_v6;
+    p.adminNetsV4 = parsed.adminNetsV4;
+    p.adminNetsV6 = parsed.adminNetsV6;
+    p.secureGatewayNetsV4 = parsed.secureGatewayNetsV4;
+    p.secureGatewayNetsV6 = parsed.secureGatewayNetsV6;
 
     return p;
 }
@@ -1128,16 +1128,16 @@ parsePorts(Config const& config, std::ostream& log)
 {
     std::vector result;
 
-    if (!config.exists("server"))
+    if (!config.exists(Sections::kServer))
     {
         log << "Required section [server] is missing";
         Throw();
     }
 
     ParsedPort common;
-    parsePort(common, config["server"], log);
+    parsePort(common, config[Sections::kServer], log);
 
-    auto const& names = config.section("server").values();
+    auto const& names = config.section(Sections::kServer).values();
     result.reserve(names.size());
     for (auto const& name : names)
     {
@@ -1149,7 +1149,7 @@ parsePorts(Config const& config, std::ostream& log)
 
         // grpc ports are parsed by GRPCServer class. Do not validate
         // grpc port information in this file.
-        if (name == SECTION_PORT_GRPC)
+        if (name == Sections::kPortGrpc)
             continue;
 
         ParsedPort parsed = common;
@@ -1179,9 +1179,8 @@ parsePorts(Config const& config, std::ostream& log)
     }
     else
     {
-        auto const count = std::count_if(result.cbegin(), result.cend(), [](Port const& p) {
-            return p.protocol.count("peer") != 0;
-        });
+        auto const count = std::ranges::count_if(
+            result, [](Port const& p) { return p.protocol.contains("peer"); });
 
         if (count > 1)
         {
@@ -1203,12 +1202,12 @@ setupClient(ServerHandler::Setup& setup)
     decltype(setup.ports)::const_iterator iter;
     for (iter = setup.ports.cbegin(); iter != setup.ports.cend(); ++iter)
     {
-        if (iter->protocol.count("http") > 0 || iter->protocol.count("https") > 0)
+        if (iter->protocol.contains("http") || iter->protocol.contains("https"))
             break;
     }
     if (iter == setup.ports.cend())
         return;
-    setup.client.secure = iter->protocol.count("https") > 0;
+    setup.client.secure = iter->protocol.contains("https");
     if (beast::IP::isUnspecified(iter->ip))
     {
         // VFALCO HACK! to make localhost work
@@ -1221,8 +1220,8 @@ setupClient(ServerHandler::Setup& setup)
     setup.client.port = iter->port;
     setup.client.user = iter->user;
     setup.client.password = iter->password;
-    setup.client.admin_user = iter->admin_user;
-    setup.client.admin_password = iter->admin_password;
+    setup.client.adminUser = iter->adminUser;
+    setup.client.adminPassword = iter->adminPassword;
 }
 
 // Fill out the overlay portion of the Setup
@@ -1230,7 +1229,7 @@ static void
 setupOverlay(ServerHandler::Setup& setup)
 {
     auto const iter = std::ranges::find_if(
-        setup.ports, [](Port const& port) { return port.protocol.count("peer") != 0; });
+        setup.ports, [](Port const& port) { return port.protocol.contains("peer"); });
     if (iter == setup.ports.cend())
     {
         setup.overlay = {};
diff --git a/src/xrpld/rpc/detail/TransactionSign.cpp b/src/xrpld/rpc/detail/TransactionSign.cpp
index 4c47914d66..8c3a5ea245 100644
--- a/src/xrpld/rpc/detail/TransactionSign.cpp
+++ b/src/xrpld/rpc/detail/TransactionSign.cpp
@@ -14,7 +14,6 @@
 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -57,6 +56,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -165,7 +165,7 @@ public:
 
 static ErrorCodeI
 acctMatchesPubKey(
-    std::shared_ptr accountState,
+    SLE::const_pointer accountState,
     AccountID const& accountID,
     PublicKey const& publicKey)
 {
@@ -312,7 +312,7 @@ checkPayment(
                     std::nullopt,
                     domain,
                     app);
-                if (pf.findPaths(app.config().PATH_SEARCH_OLD))
+                if (pf.findPaths(app.config().pathSearchOld))
                 {
                     // 4 is the maximum paths
                     pf.computePathRanks(4);
@@ -407,23 +407,23 @@ checkTxJsonFields(
     return ret;
 }
 
-static Expected
+static std::expected
 checkNetworkID(json::Value const& txJson, uint32_t appNetworkId)
 {
     if (appNetworkId > 1024)
     {
         if (!txJson.isMember(jss::NetworkID))
         {
-            return Unexpected(
+            return std::unexpected(
                 RPC::makeError(RpcInvalidParams, RPC::missingFieldMessage("tx_json.NetworkID")));
         }
         if (!txJson[jss::NetworkID].isIntegral() || txJson[jss::NetworkID].asUInt() != appNetworkId)
         {
-            return Unexpected(
+            return std::unexpected(
                 RPC::makeError(RpcInvalidParams, RPC::invalidFieldMessage("tx_json.NetworkID")));
         }
     }
-    return Expected();
+    return std::expected();
 }
 
 //------------------------------------------------------------------------------
@@ -508,7 +508,7 @@ transactionPreProcessImpl(
         validatedLedgerAge,
         app.config(),
         app.getFeeTrack(),
-        getAPIVersionNumber(params, app.config().BETA_RPC_API));
+        getAPIVersionNumber(params, app.config().betaRpcApi));
 
     if (RPC::containsError(txJsonResult))
         return std::move(txJsonResult);
@@ -519,7 +519,7 @@ transactionPreProcessImpl(
     if (!verify && !txJson.isMember(jss::Sequence))
         return RPC::missingFieldError("tx_json.Sequence");
 
-    std::shared_ptr sle;
+    SLE::const_pointer sle;
     if (verify)
         sle = app.getOpenLedger().current()->read(keylet::account(srcAddressID));
 
@@ -846,16 +846,16 @@ getTxFee(Application const& app, Config const& config, json::Value tx)
     if (tx.isMember(jss::Signers))
     {
         if (!tx[jss::Signers].isArray())
-            return config.FEES.reference_fee;
+            return config.fees.referenceFee;
 
         if (tx[jss::Signers].size() > STTx::kMaxMultiSigners)
-            return config.FEES.reference_fee;
+            return config.fees.referenceFee;
 
         // check multi-signed signers
         for (auto& signer : tx[jss::Signers])
         {
             if (!signer.isMember(jss::Signer) || !signer[jss::Signer].isObject())
-                return config.FEES.reference_fee;
+                return config.fees.referenceFee;
             if (!signer[jss::Signer].isMember(jss::SigningPubKey))
             {
                 // autofill SigningPubKey
@@ -872,7 +872,7 @@ getTxFee(Application const& app, Config const& config, json::Value tx)
     STParsedJSONObject parsed(std::string(jss::tx_json), tx);
     if (!parsed.object.has_value())
     {
-        return config.FEES.reference_fee;
+        return config.fees.referenceFee;
     }
 
     try
@@ -880,13 +880,13 @@ getTxFee(Application const& app, Config const& config, json::Value tx)
         STTx const& stTx = STTx(std::move(parsed.object.value()));
         std::string reason;
         if (!passesLocalChecks(stTx, reason))
-            return config.FEES.reference_fee;
+            return config.fees.referenceFee;
 
         return calculateBaseFee(*app.getOpenLedger().current(), stTx);
     }
     catch (std::exception& e)
     {
-        return config.FEES.reference_fee;
+        return config.fees.referenceFee;
     }
 }
 
@@ -1222,8 +1222,7 @@ transactionSignFor(
         signForParams.validMultiSign(), "xrpl::RPC::transactionSignFor : valid multi-signature");
 
     {
-        std::shared_ptr const accountState =
-            ledger->read(keylet::account(*signerAccountID));
+        SLE::const_pointer const accountState = ledger->read(keylet::account(*signerAccountID));
         // Make sure the account and secret belong together.
         auto const err =
             acctMatchesPubKey(accountState, *signerAccountID, signForParams.getPublicKey());
@@ -1255,7 +1254,9 @@ transactionSignFor(
         signers.emplaceBack(std::move(signer));
 
         // The array must be sorted and validated.
-        auto err = sortAndValidateSigners(signers, (*sttx)[sfAccount]);
+        // For delegated transactions, the delegate account is
+        // the one forbidden from appearing in its own Signers array.
+        auto err = sortAndValidateSigners(signers, sttx->getFeePayer());
         if (RPC::containsError(err))
             return err;
     }
@@ -1303,12 +1304,12 @@ transactionSubmitMultiSigned(
         validatedLedgerAge,
         app.config(),
         app.getFeeTrack(),
-        getAPIVersionNumber(jvRequest, app.config().BETA_RPC_API));
+        getAPIVersionNumber(jvRequest, app.config().betaRpcApi));
 
     if (RPC::containsError(txJsonResult))
         return std::move(txJsonResult);
 
-    std::shared_ptr const sle = ledger->read(keylet::account(srcAddressID));
+    SLE::const_pointer const sle = ledger->read(keylet::account(srcAddressID));
 
     if (!sle)
     {
@@ -1422,7 +1423,9 @@ transactionSubmitMultiSigned(
     }
 
     // The array must be sorted and validated.
-    auto err = sortAndValidateSigners(signers, srcAddressID);
+    // For delegated transactions, getFeePayer() returns sfDelegate,
+    // that account is the one forbidden from appearing in its own Signers array.
+    auto err = sortAndValidateSigners(signers, stTx->getFeePayer());
     if (RPC::containsError(err))
         return err;
 
diff --git a/src/xrpld/rpc/detail/TrustLine.cpp b/src/xrpld/rpc/detail/TrustLine.cpp
index f7293d0816..77a2b36d56 100644
--- a/src/xrpld/rpc/detail/TrustLine.cpp
+++ b/src/xrpld/rpc/detail/TrustLine.cpp
@@ -9,13 +9,12 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 
 namespace xrpl {
 
-TrustLineBase::TrustLineBase(std::shared_ptr const& sle, AccountID const& viewAccount)
+TrustLineBase::TrustLineBase(SLE::const_ref sle, AccountID const& viewAccount)
     : key_(sle->key())
     , lowLimit_(sle->getFieldAmount(sfLowLimit))
     , highLimit_(sle->getFieldAmount(sfHighLimit))
@@ -37,7 +36,7 @@ TrustLineBase::getJson(int)
 }
 
 std::optional
-PathFindTrustLine::makeItem(AccountID const& accountID, std::shared_ptr const& sle)
+PathFindTrustLine::makeItem(AccountID const& accountID, SLE::const_ref sle)
 {
     if (!sle || sle->getType() != ltRIPPLE_STATE)
         return {};
@@ -53,14 +52,11 @@ getTrustLineItems(
     LineDirection direction = LineDirection::Outgoing)
 {
     std::vector items;
-    forEachItem(
-        view,
-        accountID,
-        [&items, &accountID, &direction](std::shared_ptr const& sleCur) {
-            auto ret = T::makeItem(accountID, sleCur);
-            if (ret && (direction == LineDirection::Outgoing || !ret->getNoRipple()))
-                items.push_back(std::move(*ret));
-        });
+    forEachItem(view, accountID, [&items, &accountID, &direction](SLE::const_ref sleCur) {
+        auto ret = T::makeItem(accountID, sleCur);
+        if (ret && (direction == LineDirection::Outgoing || !ret->getNoRipple()))
+            items.push_back(std::move(*ret));
+    });
     // This list may be around for a while, so free up any unneeded
     // capacity
     items.shrink_to_fit();
@@ -78,7 +74,7 @@ PathFindTrustLine::getItems(
     return detail::getTrustLineItems(accountID, view, direction);
 }
 
-RPCTrustLine::RPCTrustLine(std::shared_ptr const& sle, AccountID const& viewAccount)
+RPCTrustLine::RPCTrustLine(SLE::const_ref sle, AccountID const& viewAccount)
     : TrustLineBase(sle, viewAccount)
     , lowQualityIn_(sle->getFieldU32(sfLowQualityIn))
     , lowQualityOut_(sle->getFieldU32(sfLowQualityOut))
@@ -88,7 +84,7 @@ RPCTrustLine::RPCTrustLine(std::shared_ptr const& sle, AccountID cons
 }
 
 std::optional
-RPCTrustLine::makeItem(AccountID const& accountID, std::shared_ptr const& sle)
+RPCTrustLine::makeItem(AccountID const& accountID, SLE::const_ref sle)
 {
     if (!sle || sle->getType() != ltRIPPLE_STATE)
         return {};
diff --git a/src/xrpld/rpc/detail/TrustLine.h b/src/xrpld/rpc/detail/TrustLine.h
index 72d4d44ae3..7a0a01d744 100644
--- a/src/xrpld/rpc/detail/TrustLine.h
+++ b/src/xrpld/rpc/detail/TrustLine.h
@@ -39,7 +39,7 @@ public:
 protected:
     // This class should not be instantiated directly. Use one of the derived
     // classes.
-    TrustLineBase(std::shared_ptr const& sle, AccountID const& viewAccount);
+    TrustLineBase(SLE::const_ref sle, AccountID const& viewAccount);
 
     ~TrustLineBase() = default;
     TrustLineBase(TrustLineBase const&) = default;
@@ -175,7 +175,7 @@ public:
     PathFindTrustLine() = delete;
 
     static std::optional
-    makeItem(AccountID const& accountID, std::shared_ptr const& sle);
+    makeItem(AccountID const& accountID, SLE::const_ref sle);
 
     static std::vector
     getItems(AccountID const& accountID, ReadView const& view, LineDirection direction);
@@ -190,7 +190,7 @@ class RPCTrustLine final : public TrustLineBase, public CountedObject const& sle, AccountID const& viewAccount);
+    RPCTrustLine(SLE::const_ref sle, AccountID const& viewAccount);
 
     [[nodiscard]] Rate const&
     getQualityIn() const
@@ -205,7 +205,7 @@ public:
     }
 
     static std::optional
-    makeItem(AccountID const& accountID, std::shared_ptr const& sle);
+    makeItem(AccountID const& accountID, SLE::const_ref sle);
 
     static std::vector
     getItems(AccountID const& accountID, ReadView const& view);
diff --git a/src/xrpld/rpc/detail/WSInfoSub.h b/src/xrpld/rpc/detail/WSInfoSub.h
index 336f35957f..c224b93e0e 100644
--- a/src/xrpld/rpc/detail/WSInfoSub.h
+++ b/src/xrpld/rpc/detail/WSInfoSub.h
@@ -24,8 +24,8 @@ public:
         auto const& h = ws->request();
         if (ipAllowed(
                 beast::IPAddressConversion::fromAsio(ws->remoteEndpoint()).address(),
-                ws->port().secure_gateway_nets_v4,
-                ws->port().secure_gateway_nets_v6))
+                ws->port().secureGatewayNetsV4,
+                ws->port().secureGatewayNetsV6))
         {
             auto it = h.find("X-User");
             if (it != h.end())
diff --git a/src/xrpld/rpc/handlers/VaultInfo.cpp b/src/xrpld/rpc/handlers/VaultInfo.cpp
index 2c00205131..b6d2fe259f 100644
--- a/src/xrpld/rpc/handlers/VaultInfo.cpp
+++ b/src/xrpld/rpc/handlers/VaultInfo.cpp
@@ -79,7 +79,7 @@ doVaultInfo(RPC::JsonContext& context)
     auto const sleVault = lpLedger->read(keylet::vault(uNodeIndex));
     auto const sleIssuance = sleVault == nullptr  //
         ? nullptr
-        : lpLedger->read(keylet::mptIssuance(sleVault->at(sfShareMPTID)));
+        : lpLedger->read(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
     if (!sleVault || !sleIssuance)
     {
         jvResult[jss::error] = "entryNotFound";
diff --git a/src/xrpld/rpc/handlers/account/AccountChannels.cpp b/src/xrpld/rpc/handlers/account/AccountChannels.cpp
index 6d5876322c..8a5c7dc6e3 100644
--- a/src/xrpld/rpc/handlers/account/AccountChannels.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountChannels.cpp
@@ -114,7 +114,7 @@ doAccountChannels(RPC::JsonContext& context)
     json::Value jsonChannels{json::ValueType::Array};
     struct VisitData
     {
-        std::vector> items;
+        std::vector items;
         AccountID const& accountID;
         std::optional const& raDstAccount;
     };
@@ -170,8 +170,7 @@ doAccountChannels(RPC::JsonContext& context)
             startAfter,
             startHint,
             limit + 1,
-            [&visitData, &accountID, &count, &limit, &marker, &nextHint](
-                std::shared_ptr const& sleCur) {
+            [&visitData, &accountID, &count, &limit, &marker, &nextHint](SLE::const_ref sleCur) {
                 if (!sleCur)
                 {
                     // LCOV_EXCL_START
diff --git a/src/xrpld/rpc/handlers/account/AccountInfo.cpp b/src/xrpld/rpc/handlers/account/AccountInfo.cpp
index d0f45cbc6f..3a96593452 100644
--- a/src/xrpld/rpc/handlers/account/AccountInfo.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountInfo.cpp
@@ -169,11 +169,8 @@ doAccountInfo(RPC::JsonContext& context)
         for (auto const& lsf : kDisallowIncomingFlags)
             acctFlags[lsf.first.data()] = sleAccepted->isFlag(lsf.second);
 
-        if (ledger->rules().enabled(featureClawback))
-        {
-            acctFlags[kAllowTrustLineClawbackFlag.first.data()] =
-                sleAccepted->isFlag(kAllowTrustLineClawbackFlag.second);
-        }
+        acctFlags[kAllowTrustLineClawbackFlag.first.data()] =
+            sleAccepted->isFlag(kAllowTrustLineClawbackFlag.second);
 
         if (ledger->rules().enabled(featureTokenEscrow))
         {
@@ -222,7 +219,7 @@ doAccountInfo(RPC::JsonContext& context)
 
             // This code will need to be revisited if in the future we support
             // multiple SignerLists on one account.
-            auto const sleSigners = ledger->read(keylet::signers(accountID));
+            auto const sleSigners = ledger->read(keylet::signerList(accountID));
             if (sleSigners)
                 jvSignerList.append(sleSigners->getJson(JsonOptions::Values::None));
 
diff --git a/src/xrpld/rpc/handlers/account/AccountLines.cpp b/src/xrpld/rpc/handlers/account/AccountLines.cpp
index c60ce90201..e69f70ca5a 100644
--- a/src/xrpld/rpc/handlers/account/AccountLines.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountLines.cpp
@@ -195,8 +195,7 @@ doAccountLines(RPC::JsonContext& context)
                 startAfter,
                 startHint,
                 limit + 1,
-                [&visitData, &count, &marker, &limit, &nextHint](
-                    std::shared_ptr const& sleCur) {
+                [&visitData, &count, &marker, &limit, &nextHint](SLE::const_ref sleCur) {
                     if (!sleCur)
                     {
                         // LCOV_EXCL_START
diff --git a/src/xrpld/rpc/handlers/account/AccountNFTs.cpp b/src/xrpld/rpc/handlers/account/AccountNFTs.cpp
index 0eb91206f3..0249963908 100644
--- a/src/xrpld/rpc/handlers/account/AccountNFTs.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountNFTs.cpp
@@ -74,8 +74,8 @@ doAccountNFTs(RPC::JsonContext& context)
             return RPC::invalidFieldError(jss::marker);
     }
 
-    auto const first = keylet::nftpage(keylet::nftpageMin(accountID), marker);
-    auto const last = keylet::nftpageMax(accountID);
+    auto const first = keylet::nftokenPage(keylet::nftokenPageMin(accountID), marker);
+    auto const last = keylet::nftokenPageMax(accountID);
 
     auto cp = ledger->read(
         Keylet(ltNFTOKEN_PAGE, ledger->succ(first.key, last.key.next()).value_or(last.key)));
@@ -134,7 +134,7 @@ doAccountNFTs(RPC::JsonContext& context)
                 obj[sfFlags.jsonName] = nft::getFlags(nftokenID);
                 obj[sfIssuer.jsonName] = to_string(nft::getIssuer(nftokenID));
                 obj[sfNFTokenTaxon.jsonName] = nft::toUInt32(nft::getTaxon(nftokenID));
-                obj[jss::nft_serial] = nft::getSerial(nftokenID);
+                obj[jss::nft_serial] = nft::getSequence(nftokenID);
                 if (std::uint16_t const xferFee = {nft::getTransferFee(nftokenID)})
                     obj[sfTransferFee.jsonName] = xferFee;
             }
diff --git a/src/xrpld/rpc/handlers/account/AccountObjects.cpp b/src/xrpld/rpc/handlers/account/AccountObjects.cpp
index 08a7fbe44a..8375feb4e7 100644
--- a/src/xrpld/rpc/handlers/account/AccountObjects.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountObjects.cpp
@@ -61,7 +61,7 @@ getAccountObjects(
         (!typeFilter.has_value() || typeMatchesFilter(typeFilter.value(), ltNFTOKEN_PAGE)) &&
         dirIndex.isZero();
 
-    Keylet const firstNFTPage = keylet::nftpageMin(account);
+    Keylet const firstNFTPage = keylet::nftokenPageMin(account);
 
     // we need to check the marker to see if it is an NFTTokenPage index.
     if (iterateNFTPages && entryIndex.isNonZero())
@@ -85,7 +85,7 @@ getAccountObjects(
         Keylet const first =
             entryIndex.isZero() ? firstNFTPage : Keylet{ltNFTOKEN_PAGE, entryIndex};
 
-        Keylet const last = keylet::nftpageMax(account);
+        Keylet const last = keylet::nftokenPageMax(account);
 
         auto currentKey = ledger.succ(first.key, last.key.next()).value_or(last.key);
 
diff --git a/src/xrpld/rpc/handlers/account/AccountOffers.cpp b/src/xrpld/rpc/handlers/account/AccountOffers.cpp
index 85d9470b75..4829ff56b1 100644
--- a/src/xrpld/rpc/handlers/account/AccountOffers.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountOffers.cpp
@@ -33,7 +33,7 @@
 namespace xrpl {
 
 void
-appendOfferJson(std::shared_ptr const& offer, json::Value& offers)
+appendOfferJson(SLE::const_ref offer, json::Value& offers)
 {
     STAmount const dirRate = amountFromQuality(getQuality(offer->getFieldH256(sfBookDirectory)));
     json::Value& obj(offers.append(json::ValueType::Object));
@@ -87,7 +87,7 @@ doAccountOffers(RPC::JsonContext& context)
         return *err;
 
     json::Value& jsonOffers(result[jss::offers] = json::ValueType::Array);
-    std::vector> offers;
+    std::vector offers;
     uint256 startAfter = beast::kZero;
     std::uint64_t startHint = 0;
 
@@ -138,8 +138,7 @@ doAccountOffers(RPC::JsonContext& context)
             startAfter,
             startHint,
             limit + 1,
-            [&offers, &count, &marker, &limit, &nextHint, &accountID](
-                std::shared_ptr const& sle) {
+            [&offers, &count, &marker, &limit, &nextHint, &accountID](SLE::const_ref sle) {
                 if (!sle)
                 {
                     // LCOV_EXCL_START
diff --git a/src/xrpld/rpc/handlers/account/GatewayBalances.cpp b/src/xrpld/rpc/handlers/account/GatewayBalances.cpp
index 146b9ead5c..bd1681172c 100644
--- a/src/xrpld/rpc/handlers/account/GatewayBalances.cpp
+++ b/src/xrpld/rpc/handlers/account/GatewayBalances.cpp
@@ -144,7 +144,7 @@ doGatewayBalances(RPC::JsonContext& context)
 
     // Traverse the cold wallet's trust lines
     {
-        forEachItem(*ledger, accountID, [&](std::shared_ptr const& sle) {
+        forEachItem(*ledger, accountID, [&](SLE::const_ref sle) {
             if (sle->getType() == ltESCROW)
             {
                 auto const& escrow = sle->getFieldAmount(sfAmount);
diff --git a/src/xrpld/rpc/handlers/account/NoRippleCheck.cpp b/src/xrpld/rpc/handlers/account/NoRippleCheck.cpp
index bb48d3ebd5..d8bb65aba9 100644
--- a/src/xrpld/rpc/handlers/account/NoRippleCheck.cpp
+++ b/src/xrpld/rpc/handlers/account/NoRippleCheck.cpp
@@ -137,52 +137,50 @@ doNoRippleCheck(RPC::JsonContext& context)
         }
     }
 
-    forEachItemAfter(
-        *ledger, accountID, uint256(), 0, limit, [&](std::shared_ptr const& ownedItem) {
-            if (ownedItem->getType() == ltRIPPLE_STATE)
+    forEachItemAfter(*ledger, accountID, uint256(), 0, limit, [&](SLE::const_ref ownedItem) {
+        if (ownedItem->getType() == ltRIPPLE_STATE)
+        {
+            bool const bLow = accountID == ownedItem->getFieldAmount(sfLowLimit).getIssuer();
+
+            bool const bNoRipple = ownedItem->isFlag(bLow ? lsfLowNoRipple : lsfHighNoRipple);
+
+            std::string problem;
+            bool needFix = false;
+            if (bNoRipple && roleGateway)
             {
-                bool const bLow = accountID == ownedItem->getFieldAmount(sfLowLimit).getIssuer();
-
-                bool const bNoRipple = ownedItem->isFlag(bLow ? lsfLowNoRipple : lsfHighNoRipple);
-
-                std::string problem;
-                bool needFix = false;
-                if (bNoRipple && roleGateway)
-                {
-                    problem = "You should clear the no ripple flag on your ";
-                    needFix = true;
-                }
-                else if (!roleGateway && !bNoRipple)
-                {
-                    problem = "You should probably set the no ripple flag on your ";
-                    needFix = true;
-                }
-                if (needFix)
-                {
-                    AccountID const peer =
-                        ownedItem->getFieldAmount(bLow ? sfHighLimit : sfLowLimit).getIssuer();
-                    STAmount const peerLimit =
-                        ownedItem->getFieldAmount(bLow ? sfHighLimit : sfLowLimit);
-                    problem += to_string(peerLimit.get().currency);
-                    problem += " line to ";
-                    problem += to_string(peerLimit.getIssuer());
-                    problems.append(problem);
-
-                    STAmount limitAmount(
-                        ownedItem->getFieldAmount(bLow ? sfLowLimit : sfHighLimit));
-                    limitAmount.get().account = peer;
-
-                    json::Value& tx = jvTransactions.append(json::ValueType::Object);
-                    tx["TransactionType"] = jss::TrustSet;
-                    tx["LimitAmount"] = limitAmount.getJson(JsonOptions::Values::None);
-                    tx["Flags"] = bNoRipple ? tfClearNoRipple : tfSetNoRipple;
-                    fillTransaction(context, tx, accountID, seq, *ledger);
-
-                    return true;
-                }
+                problem = "You should clear the no ripple flag on your ";
+                needFix = true;
             }
-            return false;
-        });
+            else if (!roleGateway && !bNoRipple)
+            {
+                problem = "You should probably set the no ripple flag on your ";
+                needFix = true;
+            }
+            if (needFix)
+            {
+                AccountID const peer =
+                    ownedItem->getFieldAmount(bLow ? sfHighLimit : sfLowLimit).getIssuer();
+                STAmount const peerLimit =
+                    ownedItem->getFieldAmount(bLow ? sfHighLimit : sfLowLimit);
+                problem += to_string(peerLimit.get().currency);
+                problem += " line to ";
+                problem += to_string(peerLimit.getIssuer());
+                problems.append(problem);
+
+                STAmount limitAmount(ownedItem->getFieldAmount(bLow ? sfLowLimit : sfHighLimit));
+                limitAmount.get().account = peer;
+
+                json::Value& tx = jvTransactions.append(json::ValueType::Object);
+                tx["TransactionType"] = jss::TrustSet;
+                tx["LimitAmount"] = limitAmount.getJson(JsonOptions::Values::None);
+                tx["Flags"] = bNoRipple ? tfClearNoRipple : tfSetNoRipple;
+                fillTransaction(context, tx, accountID, seq, *ledger);
+
+                return true;
+            }
+        }
+        return false;
+    });
 
     return result;
 }
diff --git a/src/xrpld/rpc/handlers/admin/keygen/WalletPropose.cpp b/src/xrpld/rpc/handlers/admin/keygen/WalletPropose.cpp
index c783319049..4b5f1821e3 100644
--- a/src/xrpld/rpc/handlers/admin/keygen/WalletPropose.cpp
+++ b/src/xrpld/rpc/handlers/admin/keygen/WalletPropose.cpp
@@ -39,7 +39,7 @@ estimateEntropy(std::string const& input)
     {
         (void)_;
         auto x = f / input.length();
-        se += (x)*log2(x);
+        se += x * log2(x);
     }
 
     // We multiply it by the length, to get an estimate of
diff --git a/src/xrpld/rpc/handlers/ledger/Ledger.cpp b/src/xrpld/rpc/handlers/ledger/Ledger.cpp
index 1b096002bd..23a97a5026 100644
--- a/src/xrpld/rpc/handlers/ledger/Ledger.cpp
+++ b/src/xrpld/rpc/handlers/ledger/Ledger.cpp
@@ -8,7 +8,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -28,8 +27,10 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl {
@@ -44,14 +45,14 @@ LedgerHandler::check()
 {
     auto const& params = context_.params;
 
-    auto getBool = [&](json::StaticString const& field) -> Expected {
+    auto getBool = [&](json::StaticString const& field) -> std::expected {
         if (!params.isMember(field))
         {
             return false;
         }
         if (!params[field].isBool())
         {
-            return Unexpected(RpcInvalidParams);
+            return std::unexpected(RpcInvalidParams);
         }
 
         return params[field].asBool();
@@ -349,13 +350,15 @@ doLedgerGrpc(RPC::GRPCContext& context)
     auto end = std::chrono::system_clock::now();
     auto duration =
         std::chrono::duration_cast(end - begin).count() * 1.0;
+    // Guard the per-item rates: an empty ledger has zero objects and/or zero
+    // transactions, and dividing by zero is undefined for these doubles.
+    auto const numObjects = response.ledger_objects().objects_size();
+    auto const numTxns = response.transactions_list().transactions_size();
+    std::string const msPerObj = numObjects > 0 ? std::to_string(duration / numObjects) : "n/a";
+    std::string const msPerTxn = numTxns > 0 ? std::to_string(duration / numTxns) : "n/a";
     JLOG(context.j.warn()) << __func__ << " - Extract time = " << duration
-                           << " - num objects = " << response.ledger_objects().objects_size()
-                           << " - num txns = " << response.transactions_list().transactions_size()
-                           << " - ms per obj "
-                           << duration / response.ledger_objects().objects_size()
-                           << " - ms per txn "
-                           << duration / response.transactions_list().transactions_size();
+                           << " - num objects = " << numObjects << " - num txns = " << numTxns
+                           << " - ms per obj " << msPerObj << " - ms per txn " << msPerTxn;
 
     return {response, status};
 }
diff --git a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp
index 9a9119d2ba..2dd7ae34d4 100644
--- a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp
+++ b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp
@@ -3,7 +3,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -27,6 +26,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -34,12 +34,12 @@
 
 namespace xrpl {
 
-using FunctionType = std::function(
+using FunctionType = std::function(
     json::Value const&,
     json::StaticString const,
     unsigned const apiVersion)>;
 
-static Expected
+static std::expected
 parseFixed(
     Keylet const& keylet,
     json::Value const& params,
@@ -55,12 +55,12 @@ fixed(Keylet const& keylet)
     return [keylet](
                json::Value const& params,
                json::StaticString const fieldName,
-               unsigned const apiVersion) -> Expected {
+               unsigned const apiVersion) -> std::expected {
         return parseFixed(keylet, params, fieldName, apiVersion);
     };
 }
 
-static Expected
+static std::expected
 parseObjectID(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -73,7 +73,7 @@ parseObjectID(
     return LedgerEntryHelpers::invalidFieldError("malformedRequest", fieldName, expectedType);
 }
 
-static Expected
+static std::expected
 parseIndex(json::Value const& params, json::StaticString const fieldName, unsigned const apiVersion)
 {
     if (apiVersion > 2u && params.isString())
@@ -82,7 +82,7 @@ parseIndex(json::Value const& params, json::StaticString const fieldName, unsign
         if (index == jss::amendments.cStr())
             return keylet::amendments().key;
         if (index == jss::fee.cStr())
-            return keylet::fees().key;
+            return keylet::feeSettings().key;
         if (index == jss::nunl)
             return keylet::negativeUNL().key;
         if (index == jss::hashes)
@@ -95,7 +95,7 @@ parseIndex(json::Value const& params, json::StaticString const fieldName, unsign
     return parseObjectID(params, fieldName, "hex string");
 }
 
-static Expected
+static std::expected
 parseAccountRoot(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -111,7 +111,7 @@ parseAccountRoot(
 
 auto const parseAmendments = fixed(keylet::amendments());
 
-static Expected
+static std::expected
 parseAMM(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -125,21 +125,21 @@ parseAMM(
     if (auto const value = LedgerEntryHelpers::hasRequired(params, {jss::asset, jss::asset2});
         !value)
     {
-        return Unexpected(value.error());
+        return std::unexpected(value.error());
     }
 
     auto const asset = LedgerEntryHelpers::requiredAsset(params, jss::asset, "malformedRequest");
     if (!asset)
-        return Unexpected(asset.error());
+        return std::unexpected(asset.error());
 
     auto const asset2 = LedgerEntryHelpers::requiredAsset(params, jss::asset2, "malformedRequest");
     if (!asset2)
-        return Unexpected(asset2.error());
+        return std::unexpected(asset2.error());
 
     return keylet::amm(*asset, *asset2).key;
 }
 
-static Expected
+static std::expected
 parseBridge(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -147,7 +147,7 @@ parseBridge(
 {
     if (!params.isMember(jss::bridge))
     {
-        return Unexpected(LedgerEntryHelpers::missingFieldError(jss::bridge));
+        return std::unexpected(LedgerEntryHelpers::missingFieldError(jss::bridge));
     }
 
     if (params[jss::bridge].isString())
@@ -157,12 +157,12 @@ parseBridge(
 
     auto const bridge = LedgerEntryHelpers::parseBridgeFields(params[jss::bridge]);
     if (!bridge)
-        return Unexpected(bridge.error());
+        return std::unexpected(bridge.error());
 
     auto const account = LedgerEntryHelpers::requiredAccountID(
         params, jss::bridge_account, "malformedBridgeAccount");
     if (!account)
-        return Unexpected(account.error());
+        return std::unexpected(account.error());
 
     STXChainBridge::ChainType const chainType =
         STXChainBridge::srcChain(account.value() == bridge->lockingChainDoor());
@@ -172,7 +172,7 @@ parseBridge(
     return keylet::bridge(*bridge, chainType).key;
 }
 
-static Expected
+static std::expected
 parseCheck(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -181,7 +181,7 @@ parseCheck(
     return parseObjectID(params, fieldName, "hex string");
 }
 
-static Expected
+static std::expected
 parseCredential(
     json::Value const& cred,
     json::StaticString const fieldName,
@@ -195,22 +195,22 @@ parseCredential(
     auto const subject =
         LedgerEntryHelpers::requiredAccountID(cred, jss::subject, "malformedRequest");
     if (!subject)
-        return Unexpected(subject.error());
+        return std::unexpected(subject.error());
 
     auto const issuer =
         LedgerEntryHelpers::requiredAccountID(cred, jss::issuer, "malformedRequest");
     if (!issuer)
-        return Unexpected(issuer.error());
+        return std::unexpected(issuer.error());
 
     auto const credType = LedgerEntryHelpers::requiredHexBlob(
         cred, jss::credential_type, kMaxCredentialTypeLength, "malformedRequest");
     if (!credType)
-        return Unexpected(credType.error());
+        return std::unexpected(credType.error());
 
     return keylet::credential(*subject, *issuer, Slice(credType->data(), credType->size())).key;
 }
 
-static Expected
+static std::expected
 parseDelegate(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -224,17 +224,17 @@ parseDelegate(
     auto const account =
         LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAddress");
     if (!account)
-        return Unexpected(account.error());
+        return std::unexpected(account.error());
 
     auto const authorize =
         LedgerEntryHelpers::requiredAccountID(params, jss::authorize, "malformedAddress");
     if (!authorize)
-        return Unexpected(authorize.error());
+        return std::unexpected(authorize.error());
 
     return keylet::delegate(*account, *authorize).key;
 }
 
-static Expected
+static std::expected
 parseAuthorizeCredentials(json::Value const& jv)
 {
     if (!jv.isArray())
@@ -246,7 +246,7 @@ parseAuthorizeCredentials(json::Value const& jv)
     std::uint32_t const n = jv.size();
     if (n > kMaxCredentialsArraySize)
     {
-        return Unexpected(
+        return std::unexpected(
             LedgerEntryHelpers::malformedError(
                 "malformedAuthorizedCredentials",
                 "Invalid field '" + std::string(jss::authorized_credentials) +
@@ -255,7 +255,7 @@ parseAuthorizeCredentials(json::Value const& jv)
 
     if (n == 0)
     {
-        return Unexpected(
+        return std::unexpected(
             LedgerEntryHelpers::malformedError(
                 "malformedAuthorizedCredentials",
                 "Invalid field '" + std::string(jss::authorized_credentials) + "', array empty."));
@@ -274,18 +274,18 @@ parseAuthorizeCredentials(json::Value const& jv)
                 jo, {jss::issuer, jss::credential_type}, "malformedAuthorizedCredentials");
             !value)
         {
-            return Unexpected(value.error());
+            return std::unexpected(value.error());
         }
 
         auto const issuer = LedgerEntryHelpers::requiredAccountID(
             jo, jss::issuer, "malformedAuthorizedCredentials");
         if (!issuer)
-            return Unexpected(issuer.error());
+            return std::unexpected(issuer.error());
 
         auto const credentialType = LedgerEntryHelpers::requiredHexBlob(
             jo, jss::credential_type, kMaxCredentialTypeLength, "malformedAuthorizedCredentials");
         if (!credentialType)
-            return Unexpected(credentialType.error());
+            return std::unexpected(credentialType.error());
 
         auto credential = STObject::makeInnerObject(sfCredential);
         credential.setAccountID(sfIssuer, *issuer);
@@ -296,7 +296,7 @@ parseAuthorizeCredentials(json::Value const& jv)
     return arr;
 }
 
-static Expected
+static std::expected
 parseDepositPreauth(
     json::Value const& dp,
     json::StaticString const fieldName,
@@ -318,7 +318,7 @@ parseDepositPreauth(
     auto const owner = LedgerEntryHelpers::requiredAccountID(dp, jss::owner, "malformedOwner");
     if (!owner)
     {
-        return Unexpected(owner.error());
+        return std::unexpected(owner.error());
     }
 
     if (dp.isMember(jss::authorized))
@@ -334,7 +334,7 @@ parseDepositPreauth(
     auto const& ac(dp[jss::authorized_credentials]);
     auto const arr = parseAuthorizeCredentials(ac);
     if (!arr.has_value())
-        return Unexpected(arr.error());
+        return std::unexpected(arr.error());
 
     auto const& sorted = credentials::makeSorted(arr.value());
     if (sorted.empty())
@@ -347,7 +347,7 @@ parseDepositPreauth(
     return keylet::depositPreauth(*owner, sorted).key;
 }
 
-static Expected
+static std::expected
 parseDID(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -362,7 +362,7 @@ parseDID(
     return keylet::did(*account).key;
 }
 
-static Expected
+static std::expected
 parseDirectoryNode(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -413,7 +413,7 @@ parseDirectoryNode(
     return LedgerEntryHelpers::malformedError("malformedRequest", "");
 }
 
-static Expected
+static std::expected
 parseEscrow(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -426,17 +426,17 @@ parseEscrow(
 
     auto const id = LedgerEntryHelpers::requiredAccountID(params, jss::owner, "malformedOwner");
     if (!id)
-        return Unexpected(id.error());
+        return std::unexpected(id.error());
     auto const seq = LedgerEntryHelpers::requiredUInt32(params, jss::seq, "malformedSeq");
     if (!seq)
-        return Unexpected(seq.error());
+        return std::unexpected(seq.error());
 
     return keylet::escrow(*id, *seq).key;
 }
 
-auto const parseFeeSettings = fixed(keylet::fees());
+auto const parseFeeSettings = fixed(keylet::feeSettings());
 
-static Expected
+static std::expected
 parseFixed(
     Keylet const& keylet,
     json::Value const& params,
@@ -455,7 +455,7 @@ parseFixed(
     return keylet.key;
 }
 
-static Expected
+static std::expected
 parseLedgerHashes(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -475,7 +475,7 @@ parseLedgerHashes(
     return parseFixed(keylet::skip(), params, fieldName, apiVersion);
 }
 
-static Expected
+static std::expected
 parseLoanBroker(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -488,15 +488,15 @@ parseLoanBroker(
 
     auto const id = LedgerEntryHelpers::requiredAccountID(params, jss::owner, "malformedOwner");
     if (!id)
-        return Unexpected(id.error());
+        return std::unexpected(id.error());
     auto const seq = LedgerEntryHelpers::requiredUInt32(params, jss::seq, "malformedSeq");
     if (!seq)
-        return Unexpected(seq.error());
+        return std::unexpected(seq.error());
 
-    return keylet::loanbroker(*id, *seq).key;
+    return keylet::loanBroker(*id, *seq).key;
 }
 
-static Expected
+static std::expected
 parseLoan(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -510,15 +510,15 @@ parseLoan(
     auto const id =
         LedgerEntryHelpers::requiredUInt256(params, jss::loan_broker_id, "malformedBroker");
     if (!id)
-        return Unexpected(id.error());
+        return std::unexpected(id.error());
     auto const seq = LedgerEntryHelpers::requiredUInt32(params, jss::loan_seq, "malformedSeq");
     if (!seq)
-        return Unexpected(seq.error());
+        return std::unexpected(seq.error());
 
     return keylet::loan(*id, *seq).key;
 }
 
-static Expected
+static std::expected
 parseMPToken(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -532,17 +532,17 @@ parseMPToken(
     auto const mptIssuanceID =
         LedgerEntryHelpers::requiredUInt192(params, jss::mpt_issuance_id, "malformedMPTIssuanceID");
     if (!mptIssuanceID)
-        return Unexpected(mptIssuanceID.error());
+        return std::unexpected(mptIssuanceID.error());
 
     auto const account =
         LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAccount");
     if (!account)
-        return Unexpected(account.error());
+        return std::unexpected(account.error());
 
     return keylet::mptoken(*mptIssuanceID, *account).key;
 }
 
-static Expected
+static std::expected
 parseMPTokenIssuance(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -555,10 +555,10 @@ parseMPTokenIssuance(
             "malformedMPTokenIssuance", fieldName, "Hash192");
     }
 
-    return keylet::mptIssuance(*mptIssuanceID).key;
+    return keylet::mptokenIssuance(*mptIssuanceID).key;
 }
 
-static Expected
+static std::expected
 parseNFTokenOffer(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -567,7 +567,7 @@ parseNFTokenOffer(
     return parseObjectID(params, fieldName, "hex string");
 }
 
-static Expected
+static std::expected
 parseNFTokenPage(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -578,7 +578,7 @@ parseNFTokenPage(
 
 auto const parseNegativeUNL = fixed(keylet::negativeUNL());
 
-static Expected
+static std::expected
 parseOffer(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -591,16 +591,16 @@ parseOffer(
 
     auto const id = LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAddress");
     if (!id)
-        return Unexpected(id.error());
+        return std::unexpected(id.error());
 
     auto const seq = LedgerEntryHelpers::requiredUInt32(params, jss::seq, "malformedRequest");
     if (!seq)
-        return Unexpected(seq.error());
+        return std::unexpected(seq.error());
 
     return keylet::offer(*id, *seq).key;
 }
 
-static Expected
+static std::expected
 parseOracle(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -613,17 +613,17 @@ parseOracle(
 
     auto const id = LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAccount");
     if (!id)
-        return Unexpected(id.error());
+        return std::unexpected(id.error());
 
     auto const seq =
         LedgerEntryHelpers::requiredUInt32(params, jss::oracle_document_id, "malformedDocumentID");
     if (!seq)
-        return Unexpected(seq.error());
+        return std::unexpected(seq.error());
 
     return keylet::oracle(*id, *seq).key;
 }
 
-static Expected
+static std::expected
 parsePayChannel(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -632,7 +632,7 @@ parsePayChannel(
     return parseObjectID(params, fieldName, "hex string");
 }
 
-static Expected
+static std::expected
 parsePermissionedDomain(
     json::Value const& pd,
     json::StaticString const fieldName,
@@ -652,16 +652,16 @@ parsePermissionedDomain(
     auto const account =
         LedgerEntryHelpers::requiredAccountID(pd, jss::account, "malformedAddress");
     if (!account)
-        return Unexpected(account.error());
+        return std::unexpected(account.error());
 
     auto const seq = LedgerEntryHelpers::requiredUInt32(pd, jss::seq, "malformedRequest");
     if (!seq)
-        return Unexpected(seq.error());
+        return std::unexpected(seq.error());
 
     return keylet::permissionedDomain(*account, pd[jss::seq].asUInt()).key;
 }
 
-static Expected
+static std::expected
 parseRippleState(
     json::Value const& jvRippleState,
     json::StaticString const fieldName,
@@ -678,7 +678,7 @@ parseRippleState(
             LedgerEntryHelpers::hasRequired(jvRippleState, {jss::currency, jss::accounts});
         !value)
     {
-        return Unexpected(value.error());
+        return std::unexpected(value.error());
     }
 
     if (!jvRippleState[jss::accounts].isArray() || jvRippleState[jss::accounts].size() != 2)
@@ -707,10 +707,10 @@ parseRippleState(
             "malformedCurrency", jss::currency, "Currency");
     }
 
-    return keylet::line(*id1, *id2, uCurrency).key;
+    return keylet::trustLine(*id1, *id2, uCurrency).key;
 }
 
-static Expected
+static std::expected
 parseSignerList(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -719,7 +719,7 @@ parseSignerList(
     return parseObjectID(params, fieldName, "hex string");
 }
 
-static Expected
+static std::expected
 parseTicket(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -732,17 +732,17 @@ parseTicket(
 
     auto const id = LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAddress");
     if (!id)
-        return Unexpected(id.error());
+        return std::unexpected(id.error());
 
     auto const seq =
         LedgerEntryHelpers::requiredUInt32(params, jss::ticket_seq, "malformedRequest");
     if (!seq)
-        return Unexpected(seq.error());
+        return std::unexpected(seq.error());
 
     return getTicketIndex(*id, *seq);
 }
 
-static Expected
+static std::expected
 parseVault(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -755,16 +755,16 @@ parseVault(
 
     auto const id = LedgerEntryHelpers::requiredAccountID(params, jss::owner, "malformedOwner");
     if (!id)
-        return Unexpected(id.error());
+        return std::unexpected(id.error());
 
     auto const seq = LedgerEntryHelpers::requiredUInt32(params, jss::seq, "malformedRequest");
     if (!seq)
-        return Unexpected(seq.error());
+        return std::unexpected(seq.error());
 
     return keylet::vault(*id, *seq).key;
 }
 
-static Expected
+static std::expected
 parseXChainOwnedClaimID(
     json::Value const& claimId,
     json::StaticString const fieldName,
@@ -777,20 +777,20 @@ parseXChainOwnedClaimID(
 
     auto const bridgeSpec = LedgerEntryHelpers::parseBridgeFields(claimId);
     if (!bridgeSpec)
-        return Unexpected(bridgeSpec.error());
+        return std::unexpected(bridgeSpec.error());
 
     auto const seq = LedgerEntryHelpers::requiredUInt32(
         claimId, jss::xchain_owned_claim_id, "malformedXChainOwnedClaimID");
     if (!seq)
     {
-        return Unexpected(seq.error());
+        return std::unexpected(seq.error());
     }
 
-    Keylet keylet = keylet::xChainClaimID(*bridgeSpec, *seq);
+    Keylet const keylet = keylet::xChainClaimID(*bridgeSpec, *seq);
     return keylet.key;
 }
 
-static Expected
+static std::expected
 parseXChainOwnedCreateAccountClaimID(
     json::Value const& claimId,
     json::StaticString const fieldName,
@@ -803,7 +803,7 @@ parseXChainOwnedCreateAccountClaimID(
 
     auto const bridgeSpec = LedgerEntryHelpers::parseBridgeFields(claimId);
     if (!bridgeSpec)
-        return Unexpected(bridgeSpec.error());
+        return std::unexpected(bridgeSpec.error());
 
     auto const seq = LedgerEntryHelpers::requiredUInt32(
         claimId,
@@ -811,10 +811,10 @@ parseXChainOwnedCreateAccountClaimID(
         "malformedXChainOwnedCreateAccountClaimID");
     if (!seq)
     {
-        return Unexpected(seq.error());
+        return std::unexpected(seq.error());
     }
 
-    Keylet keylet = keylet::xChainCreateAccountClaimID(*bridgeSpec, *seq);
+    Keylet const keylet = keylet::xChainCreateAccountClaimID(*bridgeSpec, *seq);
     return keylet.key;
 }
 
diff --git a/src/xrpld/rpc/handlers/ledger/LedgerEntryHelpers.h b/src/xrpld/rpc/handlers/ledger/LedgerEntryHelpers.h
index e8e295ca2c..11f6553dfa 100644
--- a/src/xrpld/rpc/handlers/ledger/LedgerEntryHelpers.h
+++ b/src/xrpld/rpc/handlers/ledger/LedgerEntryHelpers.h
@@ -1,3 +1,5 @@
+#pragma once
+
 #include 
 
 #include 
@@ -10,41 +12,42 @@
 #include 
 #include 
 
+#include 
 #include 
 
 namespace xrpl::LedgerEntryHelpers {
 
-inline Unexpected
+inline std::unexpected
 missingFieldError(json::StaticString const field, std::optional err = std::nullopt)
 {
     json::Value json = json::ValueType::Object;
     json[jss::error] = err.value_or("malformedRequest");
     json[jss::error_code] = RpcInvalidParams;
     json[jss::error_message] = RPC::missingFieldMessage(std::string(field.cStr()));
-    return Unexpected(json);
+    return std::unexpected(json);
 }
 
-inline Unexpected
+inline std::unexpected
 invalidFieldError(std::string const& err, json::StaticString const field, std::string const& type)
 {
     json::Value json = json::ValueType::Object;
     json[jss::error] = err;
     json[jss::error_code] = RpcInvalidParams;
     json[jss::error_message] = RPC::expectedFieldMessage(field, type);
-    return Unexpected(json);
+    return std::unexpected(json);
 }
 
-inline Unexpected
+inline std::unexpected
 malformedError(std::string const& err, std::string const& message)
 {
     json::Value json = json::ValueType::Object;
     json[jss::error] = err;
     json[jss::error_code] = RpcInvalidParams;
     json[jss::error_message] = message;
-    return Unexpected(json);
+    return std::unexpected(json);
 }
 
-inline Expected
+inline std::expected
 hasRequired(
     json::Value const& params,
     std::initializer_list fields,
@@ -65,7 +68,7 @@ std::optional
 parse(json::Value const& param);
 
 template 
-Expected
+std::expected
 required(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -99,7 +102,7 @@ parse(json::Value const& param)
     return account;
 }
 
-inline Expected
+inline std::expected
 requiredAccountID(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -121,7 +124,7 @@ parseHexBlob(json::Value const& param, std::size_t maxLength)
     return blob;
 }
 
-inline Expected
+inline std::expected
 requiredHexBlob(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -156,7 +159,7 @@ parse(json::Value const& param)
     return std::nullopt;
 }
 
-inline Expected
+inline std::expected
 requiredUInt32(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -178,7 +181,7 @@ parse(json::Value const& param)
     return uNodeIndex;
 }
 
-inline Expected
+inline std::expected
 requiredUInt256(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -200,7 +203,7 @@ parse(json::Value const& param)
     return field;
 }
 
-inline Expected
+inline std::expected
 requiredUInt192(
     json::Value const& params,
     json::StaticString const fieldName,
@@ -223,13 +226,13 @@ parse(json::Value const& param)
     }
 }
 
-inline Expected
+inline std::expected
 requiredAsset(json::Value const& params, json::StaticString const fieldName, std::string const& err)
 {
     return required(params, fieldName, err, "Asset");
 }
 
-inline Expected
+inline std::expected
 parseBridgeFields(json::Value const& params)
 {
     if (auto const value = hasRequired(
@@ -240,21 +243,21 @@ parseBridgeFields(json::Value const& params)
              jss::IssuingChainIssue});
         !value)
     {
-        return Unexpected(value.error());
+        return std::unexpected(value.error());
     }
 
     auto const lockingChainDoor =
         requiredAccountID(params, jss::LockingChainDoor, "malformedLockingChainDoor");
     if (!lockingChainDoor)
     {
-        return Unexpected(lockingChainDoor.error());
+        return std::unexpected(lockingChainDoor.error());
     }
 
     auto const issuingChainDoor =
         requiredAccountID(params, jss::IssuingChainDoor, "malformedIssuingChainDoor");
     if (!issuingChainDoor)
     {
-        return Unexpected(issuingChainDoor.error());
+        return std::unexpected(issuingChainDoor.error());
     }
 
     Issue lockingChainIssue;
diff --git a/src/xrpld/rpc/handlers/orderbook/AMMInfo.cpp b/src/xrpld/rpc/handlers/orderbook/AMMInfo.cpp
index df6772e4c0..043fdd2d7b 100644
--- a/src/xrpld/rpc/handlers/orderbook/AMMInfo.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/AMMInfo.cpp
@@ -2,7 +2,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -18,7 +17,7 @@
 #include 
 #include 
 #include 
-#include 
+#include   // IWYU pragma: keep
 #include 
 #include 
 #include 
@@ -27,6 +26,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -35,7 +35,7 @@
 
 namespace xrpl {
 
-Expected
+std::expected
 getAsset(json::Value const& v, beast::Journal j)
 {
     try
@@ -46,7 +46,7 @@ getAsset(json::Value const& v, beast::Journal j)
     {
         JLOG(j.debug()) << "getAsset " << ex.what();
     }
-    return Unexpected(RpcIssueMalformed);
+    return std::unexpected(RpcIssueMalformed);
 }
 
 std::string
@@ -76,10 +76,10 @@ doAMMInfo(RPC::JsonContext& context)
         std::optional accountID;
         Asset asset1;
         Asset asset2;
-        std::shared_ptr amm;
+        SLE::const_pointer amm;
     };
 
-    auto getValuesFromContextParams = [&]() -> Expected {
+    auto getValuesFromContextParams = [&]() -> std::expected {
         std::optional accountID;
         std::optional asset1;
         std::optional asset2;
@@ -92,7 +92,7 @@ doAMMInfo(RPC::JsonContext& context)
 
         // NOTE, identical check for apVersion >= 3 below
         if (context.apiVersion < 3 && kInvalid(params))
-            return Unexpected(RpcInvalidParams);
+            return std::unexpected(RpcInvalidParams);
 
         if (params.isMember(jss::asset))
         {
@@ -102,7 +102,7 @@ doAMMInfo(RPC::JsonContext& context)
             }
             else
             {
-                return Unexpected(i.error());
+                return std::unexpected(i.error());
             }
         }
 
@@ -114,33 +114,39 @@ doAMMInfo(RPC::JsonContext& context)
             }
             else
             {
-                return Unexpected(i.error());
+                return std::unexpected(i.error());
             }
         }
 
         if (params.isMember(jss::amm_account))
         {
-            auto const id = parseBase58((params[jss::amm_account].asString()));
+            auto const& ammAccount = params[jss::amm_account];
+            if (!ammAccount.isString())
+                return std::unexpected(RpcActMalformed);
+            auto const id = parseBase58(ammAccount.asString());
             if (!id)
-                return Unexpected(RpcActMalformed);
+                return std::unexpected(RpcActMalformed);
             auto const sle = ledger->read(keylet::account(*id));
             if (!sle)
-                return Unexpected(RpcActMalformed);
+                return std::unexpected(RpcActMalformed);
             ammID = sle->getFieldH256(sfAMMID);
             if (ammID->isZero())
-                return Unexpected(RpcActNotFound);
+                return std::unexpected(RpcActNotFound);
         }
 
         if (params.isMember(jss::account))
         {
-            accountID = parseBase58(params[jss::account].asString());
+            auto const& localAccount = params[jss::account];
+            if (!localAccount.isString())
+                return std::unexpected(RpcActMalformed);
+            accountID = parseBase58(localAccount.asString());
             if (!accountID || !ledger->read(keylet::account(*accountID)))
-                return Unexpected(RpcActMalformed);
+                return std::unexpected(RpcActMalformed);
         }
 
         // NOTE, identical check for apVersion < 3 above
         if (context.apiVersion >= 3 && kInvalid(params))
-            return Unexpected(RpcInvalidParams);
+            return std::unexpected(RpcInvalidParams);
 
         XRPL_ASSERT(
             (asset1.has_value() == asset2.has_value()) && (asset1.has_value() != ammID.has_value()),
@@ -154,7 +160,7 @@ doAMMInfo(RPC::JsonContext& context)
         }();
         auto const amm = ledger->read(ammKeylet);
         if (!amm)
-            return Unexpected(RpcActNotFound);
+            return std::unexpected(RpcActNotFound);
         if (!asset1 && !asset2)
         {
             asset1 = (*amm)[sfAsset];
diff --git a/src/xrpld/rpc/handlers/orderbook/DepositAuthorized.cpp b/src/xrpld/rpc/handlers/orderbook/DepositAuthorized.cpp
index cc176aaadb..343d539277 100644
--- a/src/xrpld/rpc/handlers/orderbook/DepositAuthorized.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/DepositAuthorized.cpp
@@ -90,7 +90,7 @@ doDepositAuthorized(RPC::JsonContext& context)
     bool const credentialsPresent = params.isMember(jss::credentials);
 
     std::set> sorted;
-    std::vector> lifeExtender;
+    std::vector lifeExtender;
     if (credentialsPresent)
     {
         auto const& creds(params[jss::credentials]);
@@ -128,7 +128,7 @@ doDepositAuthorized(RPC::JsonContext& context)
                         jss::credentials, "an array of CredentialID(hash256)"));
             }
 
-            std::shared_ptr sleCred = ledger->read(keylet::credential(credH));
+            SLE::const_pointer sleCred = ledger->read(keylet::credential(credH));
             if (!sleCred)
             {
                 RPC::injectError(RpcBadCredentials, "credentials don't exist", result);
diff --git a/src/xrpld/rpc/handlers/orderbook/GetAggregatePrice.cpp b/src/xrpld/rpc/handlers/orderbook/GetAggregatePrice.cpp
index e6c7a160ad..6a75277b1b 100644
--- a/src/xrpld/rpc/handlers/orderbook/GetAggregatePrice.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/GetAggregatePrice.cpp
@@ -48,10 +48,9 @@ using Prices =
 static void
 iteratePriceData(
     RPC::JsonContext& context,
-    std::shared_ptr const& sle,
+    SLE::const_ref sle,
     std::function const& f)
 {
-    using Meta = std::shared_ptr;
     static constexpr std::uint8_t kMaxHistory = 3;
     bool isNew = false;
     std::uint8_t history = 0;
@@ -73,7 +72,7 @@ iteratePriceData(
     // for the Oracle is not found in the inner loop
     STObject const* prevChain = nullptr;
 
-    Meta meta = nullptr;
+    std::shared_ptr meta = nullptr;
     while (true)
     {
         if (prevChain == chain)
@@ -93,6 +92,8 @@ iteratePriceData(
             return;  // LCOV_EXCL_LINE
 
         meta = ledger->txRead(prevTx).second;
+        if (!meta)
+            return;
 
         prevChain = chain;
         for (STObject const& node : meta->getFieldArray(sfAffectedNodes))
diff --git a/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h b/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h
index b94e431117..42b99a0009 100644
--- a/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h
+++ b/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h
@@ -17,10 +17,7 @@
 namespace xrpl {
 
 inline void
-appendNftOfferJson(
-    Application const& app,
-    std::shared_ptr const& offer,
-    json::Value& offers)
+appendNftOfferJson(Application const& app, SLE::const_ref offer, json::Value& offers)
 {
     json::Value& obj(offers.append(json::ValueType::Object));
 
@@ -64,7 +61,7 @@ enumerateNFTOffers(RPC::JsonContext& context, uint256 const& nftId, Keylet const
 
     json::Value& jsonOffers(result[jss::offers] = json::ValueType::Array);
 
-    std::vector> offers;
+    std::vector offers;
     unsigned int reserve(limit);
     uint256 startAfter;
     std::uint64_t startHint = 0;
@@ -81,7 +78,7 @@ enumerateNFTOffers(RPC::JsonContext& context, uint256 const& nftId, Keylet const
         if (!startAfter.parseHex(marker.asString()))
             return rpcError(RpcInvalidParams);
 
-        auto const sle = ledger->read(keylet::nftoffer(startAfter));
+        auto const sle = ledger->read(keylet::nftokenOffer(startAfter));
 
         if (!sle || nftId != sle->getFieldH256(sfNFTokenID))
             return rpcError(RpcInvalidParams);
@@ -97,12 +94,7 @@ enumerateNFTOffers(RPC::JsonContext& context, uint256 const& nftId, Keylet const
     }
 
     if (!forEachItemAfter(
-            *ledger,
-            directory,
-            startAfter,
-            startHint,
-            reserve,
-            [&offers](std::shared_ptr const& offer) {
+            *ledger, directory, startAfter, startHint, reserve, [&offers](SLE::const_ref offer) {
                 if (offer->getType() == ltNFTOKEN_OFFER)
                 {
                     offers.emplace_back(offer);
diff --git a/src/xrpld/rpc/handlers/orderbook/PathFind.cpp b/src/xrpld/rpc/handlers/orderbook/PathFind.cpp
index 7955a93eb4..3a6b52ee98 100644
--- a/src/xrpld/rpc/handlers/orderbook/PathFind.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/PathFind.cpp
@@ -15,7 +15,7 @@ namespace xrpl {
 json::Value
 doPathFind(RPC::JsonContext& context)
 {
-    if (context.app.config().PATH_SEARCH_MAX == 0)
+    if (context.app.config().pathSearchMax == 0)
         return rpcError(RpcNotSupported);
 
     auto lpLedger = context.ledgerMaster.getClosedLedger();
diff --git a/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp b/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp
index 956d50add7..b7edfb6dbe 100644
--- a/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp
@@ -23,7 +23,7 @@ namespace xrpl {
 json::Value
 doRipplePathFind(RPC::JsonContext& context)
 {
-    if (context.app.config().PATH_SEARCH_MAX == 0)
+    if (context.app.config().pathSearchMax == 0)
         return rpcError(RpcNotSupported);
 
     context.loadType = Resource::kFeeHeavyBurdenRpc;
diff --git a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp
index aa23a7af26..1e9e0ddc14 100644
--- a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp
+++ b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp
@@ -65,7 +65,7 @@ ServerDefinitions::translate(std::string const& inp)
     };
 
     // TODO: use string::contains with C++23
-    auto contains = [&](std::string_view s) -> bool { return inp.find(s) != std::string::npos; };
+    auto contains = [&](std::string_view s) -> bool { return inp.contains(s); };
 
     if (contains("UINT"))
     {
diff --git a/src/xrpld/rpc/handlers/server_info/Version.h b/src/xrpld/rpc/handlers/server_info/Version.h
index 64c150162e..1868df5d40 100644
--- a/src/xrpld/rpc/handlers/server_info/Version.h
+++ b/src/xrpld/rpc/handlers/server_info/Version.h
@@ -8,7 +8,7 @@ class VersionHandler
 {
 public:
     explicit VersionHandler(JsonContext& c)
-        : apiVersion_(c.apiVersion), betaEnabled_(c.app.config().BETA_RPC_API)
+        : apiVersion_(c.apiVersion), betaEnabled_(c.app.config().betaRpcApi)
     {
     }
 
diff --git a/src/xrpld/rpc/handlers/subscribe/Unsubscribe.cpp b/src/xrpld/rpc/handlers/subscribe/Unsubscribe.cpp
index 36dae615b3..af42af2a55 100644
--- a/src/xrpld/rpc/handlers/subscribe/Unsubscribe.cpp
+++ b/src/xrpld/rpc/handlers/subscribe/Unsubscribe.cpp
@@ -186,13 +186,23 @@ doUnsubscribe(RPC::JsonContext& context)
                 book.domain = domain;
             }
 
-            context.netOps.unsubBook(ispSub->getSeq(), book);
+            if (!context.netOps.unsubBook(ispSub, book))
+            {
+                JLOG(context.j.debug())
+                    << "doUnsubscribe: book not subscribed (no-op for seq=" << ispSub->getSeq()
+                    << ")";
+            }
 
             // both_sides is deprecated.
             if ((jv.isMember(jss::both) && jv[jss::both].asBool()) ||
                 (jv.isMember(jss::both_sides) && jv[jss::both_sides].asBool()))
             {
-                context.netOps.unsubBook(ispSub->getSeq(), reversed(book));
+                if (!context.netOps.unsubBook(ispSub, reversed(book)))
+                {
+                    JLOG(context.j.debug())
+                        << "doUnsubscribe: reversed book not subscribed (no-op for seq="
+                        << ispSub->getSeq() << ")";
+                }
             }
         }
     }
diff --git a/src/xrpld/rpc/handlers/transaction/Simulate.cpp b/src/xrpld/rpc/handlers/transaction/Simulate.cpp
index ba108eb5b2..7ee28c4886 100644
--- a/src/xrpld/rpc/handlers/transaction/Simulate.cpp
+++ b/src/xrpld/rpc/handlers/transaction/Simulate.cpp
@@ -7,7 +7,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -33,6 +32,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -42,7 +42,7 @@
 
 namespace xrpl {
 
-static Expected
+static std::expected
 getAutofillSequence(json::Value const& txJson, RPC::JsonContext& context)
 {
     // autofill Sequence
@@ -52,16 +52,16 @@ getAutofillSequence(json::Value const& txJson, RPC::JsonContext& context)
     {
         // sanity check, should fail earlier
         // LCOV_EXCL_START
-        return Unexpected(RPC::invalidFieldError("tx.Account"));
+        return std::unexpected(RPC::invalidFieldError("tx.Account"));
         // LCOV_EXCL_STOP
     }
     auto const srcAddressID = parseBase58(accountStr.asString());
     if (!srcAddressID.has_value())
     {
-        return Unexpected(
+        return std::unexpected(
             RPC::makeError(RpcSrcActMalformed, RPC::invalidFieldMessage("tx.Account")));
     }
-    std::shared_ptr const sle =
+    SLE::const_pointer const sle =
         context.app.getOpenLedger().current()->read(keylet::account(*srcAddressID));
     if (!hasTicketSeq && !sle)
     {
@@ -69,7 +69,7 @@ getAutofillSequence(json::Value const& txJson, RPC::JsonContext& context)
             << "Failed to find source account "
             << "in current ledger: " << toBase58(*srcAddressID);
 
-        return Unexpected(rpcError(RpcSrcActNotFound));
+        return std::unexpected(rpcError(RpcSrcActNotFound));
     }
 
     return hasTicketSeq ? 0 : context.app.getTxQ().nextQueuableSeq(sle).value();
@@ -194,7 +194,7 @@ getTxJsonFromParams(json::Value const& params)
         try
         {
             SerialIter sitTrans(makeSlice(*unHexed));
-            txJson = STObject(std::ref(sitTrans), kSfGeneric).getJson(JsonOptions::Values::None);
+            txJson = STObject(std::ref(sitTrans), sfGeneric).getJson(JsonOptions::Values::None);
         }
         catch (std::runtime_error const&)
         {
diff --git a/src/xrpld/rpc/handlers/transaction/Submit.cpp b/src/xrpld/rpc/handlers/transaction/Submit.cpp
index f0c4cb2391..79f3680684 100644
--- a/src/xrpld/rpc/handlers/transaction/Submit.cpp
+++ b/src/xrpld/rpc/handlers/transaction/Submit.cpp
@@ -4,7 +4,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -21,17 +20,18 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 
 namespace xrpl {
 
-static Expected
+static std::expected
 getFailHard(RPC::JsonContext const& context)
 {
     if (context.params.isMember(jss::fail_hard) && !context.params[jss::fail_hard].isBool())
     {
-        return Unexpected(RPC::expectedFieldError(jss::fail_hard, "boolean"));
+        return std::unexpected(RPC::expectedFieldError(jss::fail_hard, "boolean"));
     }
     return NetworkOPs::doFailHard(
         context.params.isMember(jss::fail_hard) && context.params[jss::fail_hard].asBool());
diff --git a/src/xrpld/rpc/json_body.h b/src/xrpld/rpc/json_body.h
index 9f881cacbc..5c4c56cef8 100644
--- a/src/xrpld/rpc/json_body.h
+++ b/src/xrpld/rpc/json_body.h
@@ -22,7 +22,7 @@ struct JsonBody
         dynamic_buffer_type buffer_;
 
     public:
-        using const_buffers_type = typename dynamic_buffer_type::const_buffers_type;
+        using const_buffers_type = dynamic_buffer_type::const_buffers_type;
 
         using is_deferred = std::false_type;
 
@@ -56,7 +56,7 @@ struct JsonBody
 
     class writer  // NOLINT(readability-identifier-naming) -- Boost.Beast body concept name
     {
-        std::string body_string_;
+        std::string bodyString_;
 
     public:
         using const_buffers_type = boost::asio::const_buffer;
@@ -65,7 +65,7 @@ struct JsonBody
         explicit writer(
             boost::beast::http::header const& fields,
             value_type const& value)
-            : body_string_(to_string(value))
+            : bodyString_(to_string(value))
         {
         }
 
@@ -81,7 +81,7 @@ struct JsonBody
         get(boost::beast::error_code& ec)
         {
             ec.assign(0, ec.category());
-            return {{const_buffers_type{body_string_.data(), body_string_.size()}, false}};
+            return {{const_buffers_type{bodyString_.data(), bodyString_.size()}, false}};
         }
     };
 };