Compare commits

..

3 Commits

Author SHA1 Message Date
Jingchen
058b38a488 Merge branch 'develop' into a1q123456/add-levelization-python-script 2026-03-06 11:40:39 +00:00
JCW
f638cbae3e Fixed errors
Signed-off-by: JCW <a1q123456@users.noreply.github.com>
2026-02-17 17:08:37 +00:00
JCW
94737f399a Replace levelization shell script with the python version to optimise the performance 2026-02-17 17:08:03 +00:00
13 changed files with 400 additions and 1162 deletions

View File

@@ -1,98 +0,0 @@
# Custom CMake command definitions for gersemi formatting.
# These stubs teach gersemi the signatures of project-specific commands
# so it can format their invocations correctly.
function(git_branch branch_val)
endfunction()
function(isolate_headers target A B scope)
endfunction()
function(create_symbolic_link target link)
endfunction()
function(xrpl_add_test name)
endfunction()
macro(exclude_from_default target_)
endmacro()
macro(exclude_if_included target_)
endmacro()
function(target_protobuf_sources target prefix)
set(options APPEND_PATH DESCRIPTORS)
set(oneValueArgs
LANGUAGE
OUT_VAR
EXPORT_MACRO
TARGET
PROTOC_OUT_DIR
PLUGIN
PLUGIN_OPTIONS
PROTOC_EXE
)
set(multiValueArgs
PROTOS
IMPORT_DIRS
GENERATE_EXTENSIONS
PROTOC_OPTIONS
DEPENDENCIES
)
cmake_parse_arguments(
THIS_FUNCTION_PREFIX
"${options}"
"${oneValueArgs}"
"${multiValueArgs}"
${ARGN}
)
endfunction()
function(add_module parent name)
endfunction()
function(target_link_modules parent scope)
endfunction()
function(setup_target_for_coverage_gcovr)
set(options NONE)
set(oneValueArgs BASE_DIRECTORY NAME FORMAT)
set(multiValueArgs EXCLUDE EXECUTABLE EXECUTABLE_ARGS DEPENDENCIES)
cmake_parse_arguments(
THIS_FUNCTION_PREFIX
"${options}"
"${oneValueArgs}"
"${multiValueArgs}"
${ARGN}
)
endfunction()
function(add_code_coverage_to_target name scope)
endfunction()
function(verbose_find_path variable name)
set(options
NO_CACHE
REQUIRED
OPTIONAL
NO_DEFAULT_PATH
NO_PACKAGE_ROOT_PATH
NO_CMAKE_PATH
NO_CMAKE_ENVIRONMENT_PATH
NO_SYSTEM_ENVIRONMENT_PATH
NO_CMAKE_SYSTEM_PATH
NO_CMAKE_INSTALL_PREFIX
CMAKE_FIND_ROOT_PATH_BOTH
ONLY_CMAKE_FIND_ROOT_PATH
NO_CMAKE_FIND_ROOT_PATH
)
set(oneValueArgs REGISTRY_VIEW VALIDATOR DOC)
set(multiValueArgs NAMES HINTS PATHS PATH_SUFFIXES)
cmake_parse_arguments(
THIS_FUNCTION_PREFIX
"${options}"
"${oneValueArgs}"
"${multiValueArgs}"
${ARGN}
)
endfunction()

View File

@@ -1 +0,0 @@
definitions: [.gersemi]

View File

@@ -70,7 +70,7 @@ that `test` code should _never_ be included in `xrpl` or `xrpld` code.)
## Validation
The [levelization](generate.sh) script takes no parameters,
The [levelization](generate.py) script takes no parameters,
reads no environment variables, and can be run from any directory,
as long as it is in the expected location in the rippled repo.
It can be run at any time from within a checked out repo, and will
@@ -104,7 +104,7 @@ It generates many files of [results](results):
Github Actions workflow to test that levelization loops haven't
changed. Unfortunately, if changes are detected, it can't tell if
they are improvements or not, so if you have resolved any issues or
done anything else to improve levelization, run `levelization.sh`,
done anything else to improve levelization, run `generate.py`,
and commit the updated results.
The `loops.txt` and `ordering.txt` files relate the modules
@@ -128,7 +128,7 @@ The committed files hide the detailed values intentionally, to
prevent false alarms and merging issues, and because it's easy to
get those details locally.
1. Run `levelization.sh`
1. Run `generate.py`
2. Grep the modules in `paths.txt`.
- For example, if a cycle is found `A ~= B`, simply `grep -w
A .github/scripts/levelization/results/paths.txt | grep -w B`

369
.github/scripts/levelization/generate.py vendored Normal file
View File

@@ -0,0 +1,369 @@
#!/usr/bin/env python3
"""
Usage: generate.py
This script takes no parameters, and can be run from any directory,
as long as it is in the expected.
location in the repo.
"""
import os
import re
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Tuple, Set, Optional
# Compile regex patterns once at module level
INCLUDE_PATTERN = re.compile(r"^\s*#include.*/.*\.h")
INCLUDE_PATH_PATTERN = re.compile(r'[<"]([^>"]+)[>"]')
def dictionary_sort_key(s: str) -> str:
"""
Create a sort key that mimics 'sort -d' (dictionary order).
Dictionary order only considers blanks and alphanumeric characters.
This means punctuation like '.' is ignored during sorting.
"""
# Keep only alphanumeric characters and spaces
return "".join(c for c in s if c.isalnum() or c.isspace())
def get_level(file_path: str) -> str:
"""
Extract the level from a file path (second and third directory components).
Equivalent to bash: cut -d/ -f 2,3
Examples:
src/xrpld/app/main.cpp -> xrpld.app
src/libxrpl/protocol/STObject.cpp -> libxrpl.protocol
include/xrpl/basics/base_uint.h -> xrpl.basics
"""
parts = file_path.split("/")
# Get fields 2 and 3 (indices 1 and 2 in 0-based indexing)
if len(parts) >= 3:
level = f"{parts[1]}/{parts[2]}"
elif len(parts) >= 2:
level = f"{parts[1]}/toplevel"
else:
level = file_path
# If the "level" indicates a file, cut off the filename
if "." in level.split("/")[-1]: # Avoid Path object creation
# Use the "toplevel" label as a workaround for `sort`
# inconsistencies between different utility versions
level = level.rsplit("/", 1)[0] + "/toplevel"
return level.replace("/", ".")
def extract_include_level(include_line: str) -> Optional[str]:
"""
Extract the include path from an #include directive.
Gets the first two directory components from the include path.
Equivalent to bash: cut -d/ -f 1,2
Examples:
#include <xrpl/basics/base_uint.h> -> xrpl.basics
#include "xrpld/app/main/Application.h" -> xrpld.app
"""
# Remove everything before the quote or angle bracket
match = INCLUDE_PATH_PATTERN.search(include_line)
if not match:
return None
include_path = match.group(1)
parts = include_path.split("/")
# Get first two fields (indices 0 and 1)
if len(parts) >= 2:
include_level = f"{parts[0]}/{parts[1]}"
else:
include_level = include_path
# If the "includelevel" indicates a file, cut off the filename
if "." in include_level.split("/")[-1]: # Avoid Path object creation
include_level = include_level.rsplit("/", 1)[0] + "/toplevel"
return include_level.replace("/", ".")
def find_repo_root(start_path: Path, depth_limit: int = 10) -> Path:
"""
Find the repository root by looking for .git directory or src/include folders.
Walks up the directory tree from the start path.
"""
current = start_path.resolve()
# Walk up the directory tree
for _ in range(depth_limit): # Limit search depth to prevent infinite loops
# Check if this directory has src or include folders
has_src = (current / "src").exists()
has_include = (current / "include").exists()
if has_src or has_include:
return current
# Check if this is a git repository root
if (current / ".git").exists():
# Check if it has src or include nearby
if has_src or has_include:
return current
# Move up one level
parent = current.parent
if parent == current: # Reached filesystem root
break
current = parent
# If we couldn't find it, raise an error
raise RuntimeError(
"Could not find repository root. "
"Expected to find a directory containing 'src' and/or 'include' folders."
)
def get_scan_directories(repo_root: Path) -> List[Path]:
"""
Get the list of directories to scan for include files.
Returns paths that actually exist.
"""
directories = []
for dir_name in ["include", "src"]:
dir_path = repo_root / dir_name
if dir_path.exists() and dir_path.is_dir():
directories.append(dir_path)
if not directories:
raise RuntimeError(f"No 'src' or 'include' directories found in {repo_root}")
return directories
def main():
# Change to the script's directory
script_dir = Path(__file__).parent.resolve()
os.chdir(script_dir)
# If the shell is interactive, clean up any flotsam before analyzing
# Match bash behavior: check if PS1 is set (indicates interactive shell)
# When running a script, PS1 is not set even if stdin/stdout are TTYs
if os.environ.get("PS1"):
try:
subprocess.run(["git", "clean", "-ix"], check=False, timeout=30)
except (subprocess.TimeoutExpired, KeyboardInterrupt):
print("Skipping git clean...")
except Exception:
# If git clean fails for any reason, just continue
pass
# Clean up and create results directory
results_dir = script_dir / "results"
if results_dir.exists():
import shutil
shutil.rmtree(results_dir)
results_dir.mkdir()
# Find the repository root by searching for src and include directories
try:
repo_root = find_repo_root(script_dir)
scan_dirs = get_scan_directories(repo_root)
print(f"Found repository root: {repo_root}")
print(f"Scanning directories:")
for scan_dir in scan_dirs:
print(f" - {scan_dir.relative_to(repo_root)}")
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
print("\nScanning for raw includes...")
# Find all #include directives
raw_includes: List[Tuple[str, str]] = []
rawincludes_file = results_dir / "rawincludes.txt"
# Write to file as we go to avoid storing everything in memory
with open(rawincludes_file, "w", buffering=8192) as raw_f:
for dir_path in scan_dirs:
print(f" Scanning {dir_path.relative_to(repo_root)}...")
for file_path in dir_path.rglob("*"):
if not file_path.is_file():
continue
try:
rel_path_str = str(file_path.relative_to(repo_root))
# Read file with larger buffer for better performance
with open(
file_path,
"r",
encoding="utf-8",
errors="ignore",
buffering=8192,
) as f:
for line in f:
# Quick check before regex
if "#include" not in line or "boost" in line:
continue
if INCLUDE_PATTERN.match(line):
line_stripped = line.strip()
entry = f"{rel_path_str}:{line_stripped}\n"
print(entry, end="")
raw_f.write(entry)
raw_includes.append((rel_path_str, line_stripped))
except Exception as e:
print(f"Error reading {file_path}: {e}", file=sys.stderr)
# Build levelization paths and count directly (no need to sort first)
print("Build levelization paths")
path_counts: Dict[Tuple[str, str], int] = defaultdict(int)
for file_path, include_line in raw_includes:
level = get_level(file_path)
include_level = extract_include_level(include_line)
if include_level and level != include_level:
path_counts[(level, include_level)] += 1
# Sort and deduplicate paths (using dictionary order like bash 'sort -d')
print("Sort and deduplicate paths")
paths_file = results_dir / "paths.txt"
with open(paths_file, "w") as f:
# Sort using dictionary order: only alphanumeric and spaces matter
sorted_items = sorted(
path_counts.items(),
key=lambda x: (dictionary_sort_key(x[0][0]), dictionary_sort_key(x[0][1])),
)
for (level, include_level), count in sorted_items:
line = f"{count:7} {level} {include_level}\n"
print(line.rstrip())
f.write(line)
# Split into flat-file database
print("Split into flat-file database")
includes_dir = results_dir / "includes"
included_by_dir = results_dir / "included_by"
includes_dir.mkdir()
included_by_dir.mkdir()
# Batch writes by grouping data first to avoid repeated file opens
includes_data: Dict[str, List[Tuple[str, int]]] = defaultdict(list)
included_by_data: Dict[str, List[Tuple[str, int]]] = defaultdict(list)
# Process in sorted order to match bash script behavior (dictionary order)
sorted_items = sorted(
path_counts.items(),
key=lambda x: (dictionary_sort_key(x[0][0]), dictionary_sort_key(x[0][1])),
)
for (level, include_level), count in sorted_items:
includes_data[level].append((include_level, count))
included_by_data[include_level].append((level, count))
# Write all includes files in sorted order (dictionary order)
for level in sorted(includes_data.keys(), key=dictionary_sort_key):
entries = includes_data[level]
with open(includes_dir / level, "w") as f:
for include_level, count in entries:
line = f"{include_level} {count}\n"
print(line.rstrip())
f.write(line)
# Write all included_by files in sorted order (dictionary order)
for include_level in sorted(included_by_data.keys(), key=dictionary_sort_key):
entries = included_by_data[include_level]
with open(included_by_dir / include_level, "w") as f:
for level, count in entries:
line = f"{level} {count}\n"
print(line.rstrip())
f.write(line)
# Search for loops
print("Search for loops")
loops_file = results_dir / "loops.txt"
ordering_file = results_dir / "ordering.txt"
loops_found: Set[Tuple[str, str]] = set()
# Pre-load all include files into memory to avoid repeated I/O
# This is the biggest optimization - we were reading files repeatedly in nested loops
# Use list of tuples to preserve file order
includes_cache: Dict[str, List[Tuple[str, int]]] = {}
includes_lookup: Dict[str, Dict[str, int]] = {} # For fast lookup
# Note: bash script uses 'for source in *' which uses standard glob sorting,
# NOT dictionary order. So we use standard sorted() here, not dictionary_sort_key.
for include_file in sorted(includes_dir.iterdir(), key=lambda p: p.name):
if not include_file.is_file():
continue
includes_cache[include_file.name] = []
includes_lookup[include_file.name] = {}
with open(include_file, "r") as f:
for line in f:
parts = line.strip().split()
if len(parts) >= 2:
include_name = parts[0]
include_count = int(parts[1])
includes_cache[include_file.name].append(
(include_name, include_count)
)
includes_lookup[include_file.name][include_name] = include_count
with open(loops_file, "w", buffering=8192) as loops_f, open(
ordering_file, "w", buffering=8192
) as ordering_f:
# Use standard sorting to match bash glob expansion 'for source in *'
for source in sorted(includes_cache.keys()):
source_includes = includes_cache[source]
for include, include_freq in source_includes:
# Check if include file exists and references source
if include not in includes_lookup:
continue
source_freq = includes_lookup[include].get(source)
if source_freq is not None:
# Found a loop
loop_key = tuple(sorted([source, include]))
if loop_key in loops_found:
continue
loops_found.add(loop_key)
loops_f.write(f"Loop: {source} {include}\n")
# If the counts are close, indicate that the two modules are
# on the same level, though they shouldn't be
diff = include_freq - source_freq
if diff > 3:
loops_f.write(f" {source} > {include}\n\n")
elif diff < -3:
loops_f.write(f" {include} > {source}\n\n")
elif source_freq == include_freq:
loops_f.write(f" {include} == {source}\n\n")
else:
loops_f.write(f" {include} ~= {source}\n\n")
else:
ordering_f.write(f"{source} > {include}\n")
# Print results
print("\nOrdering:")
with open(ordering_file, "r") as f:
print(f.read(), end="")
print("\nLoops:")
with open(loops_file, "r") as f:
print(f.read(), end="")
if __name__ == "__main__":
main()

View File

@@ -1,130 +0,0 @@
#!/bin/bash
# Usage: generate.sh
# This script takes no parameters, reads no environment variables,
# and can be run from any directory, as long as it is in the expected
# location in the repo.
pushd $( dirname $0 )
if [ -v PS1 ]
then
# if the shell is interactive, clean up any flotsam before analyzing
git clean -ix
fi
# Ensure all sorting is ASCII-order consistently across platforms.
export LANG=C
rm -rfv results
mkdir results
includes="$( pwd )/results/rawincludes.txt"
pushd ../../..
echo Raw includes:
grep -r '^[ ]*#include.*/.*\.h' include src | \
grep -v boost | tee ${includes}
popd
pushd results
oldifs=${IFS}
IFS=:
mkdir includes
mkdir included_by
echo Build levelization paths
exec 3< ${includes} # open rawincludes.txt for input
while read -r -u 3 file include
do
level=$( echo ${file} | cut -d/ -f 2,3 )
# If the "level" indicates a file, cut off the filename
if [[ "${level##*.}" != "${level}" ]]
then
# Use the "toplevel" label as a workaround for `sort`
# inconsistencies between different utility versions
level="$( dirname ${level} )/toplevel"
fi
level=$( echo ${level} | tr '/' '.' )
includelevel=$( echo ${include} | sed 's/.*["<]//; s/[">].*//' | \
cut -d/ -f 1,2 )
if [[ "${includelevel##*.}" != "${includelevel}" ]]
then
# Use the "toplevel" label as a workaround for `sort`
# inconsistencies between different utility versions
includelevel="$( dirname ${includelevel} )/toplevel"
fi
includelevel=$( echo ${includelevel} | tr '/' '.' )
if [[ "$level" != "$includelevel" ]]
then
echo $level $includelevel | tee -a paths.txt
fi
done
echo Sort and deduplicate paths
sort -ds paths.txt | uniq -c | tee sortedpaths.txt
mv sortedpaths.txt paths.txt
exec 3>&- #close fd 3
IFS=${oldifs}
unset oldifs
echo Split into flat-file database
exec 4<paths.txt # open paths.txt for input
while read -r -u 4 count level include
do
echo ${include} ${count} | tee -a includes/${level}
echo ${level} ${count} | tee -a included_by/${include}
done
exec 4>&- #close fd 4
loops="$( pwd )/loops.txt"
ordering="$( pwd )/ordering.txt"
pushd includes
echo Search for loops
# Redirect stdout to a file
exec 4>&1
exec 1>"${loops}"
for source in *
do
if [[ -f "$source" ]]
then
exec 5<"${source}" # open for input
while read -r -u 5 include includefreq
do
if [[ -f $include ]]
then
if grep -q -w $source $include
then
if grep -q -w "Loop: $include $source" "${loops}"
then
continue
fi
sourcefreq=$( grep -w $source $include | cut -d\ -f2 )
echo "Loop: $source $include"
# If the counts are close, indicate that the two modules are
# on the same level, though they shouldn't be
if [[ $(( $includefreq - $sourcefreq )) -gt 3 ]]
then
echo -e " $source > $include\n"
elif [[ $(( $sourcefreq - $includefreq )) -gt 3 ]]
then
echo -e " $include > $source\n"
elif [[ $sourcefreq -eq $includefreq ]]
then
echo -e " $include == $source\n"
else
echo -e " $include ~= $source\n"
fi
else
echo "$source > $include" >> "${ordering}"
fi
fi
done
exec 5>&- #close fd 5
fi
done
exec 1>&4 #close fd 1
exec 4>&- #close fd 4
cat "${ordering}"
cat "${loops}"
popd
popd
popd

View File

@@ -20,7 +20,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check levelization
run: .github/scripts/levelization/generate.sh
run: python .github/scripts/levelization/generate.py
- name: Check for differences
env:
MESSAGE: |
@@ -32,7 +32,7 @@ jobs:
removed from loops.txt, it's probably an improvement, while if
something was added, it's probably a regression.
Run '.github/scripts/levelization/generate.sh' in your repo, commit
Run '.github/scripts/levelization/generate.py' in your repo, commit
and push the changes. See .github/scripts/levelization/README.md for
more info.
run: |

3
.gitignore vendored
View File

@@ -75,6 +75,9 @@ DerivedData
/.claude
/CLAUDE.md
# Python
__pycache__
# Direnv's directory
/.direnv

View File

@@ -11,12 +11,7 @@ function(xrpl_add_test name)
)
add_executable(${target} ${ARGN} ${sources})
isolate_headers(
${target}
"${CMAKE_SOURCE_DIR}"
"${CMAKE_SOURCE_DIR}/tests/${name}"
PRIVATE
)
isolate_headers(${target} "${CMAKE_SOURCE_DIR}" "${CMAKE_SOURCE_DIR}/tests/${name}" PRIVATE)
add_test(NAME ${target} COMMAND ${target})
endfunction()

View File

@@ -10,25 +10,16 @@ include(target_protobuf_sources)
# so we just build them as a separate library.
add_library(xrpl.libpb)
set_target_properties(xrpl.libpb PROPERTIES UNITY_BUILD OFF)
target_protobuf_sources(
xrpl.libpb
xrpl/proto
LANGUAGE cpp
IMPORT_DIRS include/xrpl/proto
PROTOS include/xrpl/proto/xrpl.proto
target_protobuf_sources(xrpl.libpb xrpl/proto LANGUAGE cpp IMPORT_DIRS include/xrpl/proto
PROTOS include/xrpl/proto/xrpl.proto
)
file(GLOB_RECURSE protos "include/xrpl/proto/org/*.proto")
target_protobuf_sources(
xrpl.libpb
xrpl/proto
LANGUAGE cpp
IMPORT_DIRS include/xrpl/proto
PROTOS "${protos}"
target_protobuf_sources(xrpl.libpb xrpl/proto LANGUAGE cpp IMPORT_DIRS include/xrpl/proto
PROTOS "${protos}"
)
target_protobuf_sources(
xrpl.libpb
xrpl/proto
xrpl.libpb xrpl/proto
LANGUAGE grpc
IMPORT_DIRS include/xrpl/proto
PROTOS "${protos}"

View File

@@ -39,15 +39,19 @@ list(
)
setup_target_for_coverage_gcovr(
NAME coverage
FORMAT ${coverage_format}
NAME
coverage
FORMAT
${coverage_format}
EXCLUDE
"src/test"
"src/tests"
"include/xrpl/beast/test"
"include/xrpl/beast/unit_test"
"${CMAKE_BINARY_DIR}/pb-xrpl.libpb"
DEPENDENCIES xrpld xrpl.tests
"src/test"
"src/tests"
"include/xrpl/beast/test"
"include/xrpl/beast/unit_test"
"${CMAKE_BINARY_DIR}/pb-xrpl.libpb"
DEPENDENCIES
xrpld
xrpl.tests
)
add_code_coverage_to_target(opts INTERFACE)

View File

@@ -42,11 +42,7 @@ function(verbose_find_path variable name)
endif()
endfunction()
verbose_find_path(
doxygen_plantuml_jar_path
plantuml.jar
PATH_SUFFIXES share/plantuml
)
verbose_find_path(doxygen_plantuml_jar_path plantuml.jar PATH_SUFFIXES share/plantuml)
verbose_find_path(doxygen_dot_path dot)
# https://en.cppreference.com/w/Cppreference:Archives

View File

@@ -25,16 +25,10 @@ function(add_module parent name)
${target}
PUBLIC "$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>"
)
isolate_headers(
${target}
"${CMAKE_CURRENT_SOURCE_DIR}/include"
"${CMAKE_CURRENT_SOURCE_DIR}/include/${parent}/${name}"
PUBLIC
isolate_headers(${target} "${CMAKE_CURRENT_SOURCE_DIR}/include"
"${CMAKE_CURRENT_SOURCE_DIR}/include/${parent}/${name}" PUBLIC
)
isolate_headers(
${target}
"${CMAKE_CURRENT_SOURCE_DIR}/src"
"${CMAKE_CURRENT_SOURCE_DIR}/src/lib${parent}/${name}"
PRIVATE
isolate_headers(${target} "${CMAKE_CURRENT_SOURCE_DIR}/src"
"${CMAKE_CURRENT_SOURCE_DIR}/src/lib${parent}/${name}" PRIVATE
)
endfunction()

View File

@@ -1,885 +0,0 @@
# Proposal: Separating Domain and Transaction Invariants
## Problem
XRPL on-chain protocols are implemented as semantically cohesive groups of transactions (see
`include/xrpl/tx/transactors/` — vault, dex, lending, etc.). Each protocol also has invariant
checks that run after every transaction to catch bugs before they reach the ledger.
The current invariant system conflates two distinct concerns in a single class:
1. **Domain invariants** — properties that must hold regardless of which transaction ran.
Example: "a vault with zero shares must have zero assets."
2. **Transaction invariants** — properties specific to a particular transaction type.
Example: "VaultDeposit must increase vault balance by the deposit amount."
This leads to:
- **Monolithic switch statements**: `ValidVault::finalize` is 800+ lines with a switch
dispatching per transaction type. `ValidAMM::finalize` has a similar pattern.
- **Scattered rules**: the invariant rules for `VaultDeposit` are split between
`VaultDeposit.cpp` (business logic) and `VaultInvariant.cpp` (validation), making it hard
to reason about a transaction holistically.
- **No isolation**: transaction-specific invariant logic cannot be unit-tested without the
full invariant infrastructure.
- **Unbounded growth**: every new transaction type adds another case to the switch.
---
## Current Architecture
### Two-Phase Invariant Checking
After a transaction is applied, `ApplyContext::checkInvariantsHelper` runs all invariant
checkers in two phases:
```
Phase 1 — State Collection
For each modified ledger entry:
call visitEntry() on every checker in the InvariantChecks tuple
Phase 2 — Validation
For each checker:
call finalize() → returns true (pass) or false (fail)
```
This is implemented via a `std::tuple` of checker classes, iterated at compile time with
`std::index_sequence`. Each checker implements `visitEntry` and `finalize` as duck-typed
methods — no base class, no virtual dispatch.
### Existing Invariant Categories
The 24 current checkers fall into three informal categories:
| Category | Description | Examples |
| ------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------- |
| **Universal** | Always run, no tx-type awareness | `XRPNotCreated`, `TransactionFeeCheck`, `LedgerEntryTypesMatch` |
| **Privilege-gated** | Use `hasPrivilege()` to differentiate by tx-type | `AccountRootsNotDeleted`, `ValidNewAccountRoot`, `ValidMPTIssuance` |
| **Domain** | Large switch on tx-type mixing domain + tx-specific checks | `ValidVault`, `ValidAMM`, `ValidLoan` |
The first two categories work well. The third is where the problem lies.
---
## Proposed Architecture
### Goal
Separate domain invariants from transaction invariants with minimal machinery:
- **Domain invariants** stay in domain invariant classes (e.g., `ValidVault`). They always
run when the domain is touched, regardless of transaction type.
- **Transaction invariants** move to static methods on the transactors themselves. They run
only for their specific transaction type.
- **Opt-in with compile-time enforcement**: transactors that declare they have invariants
must implement them, or compilation fails.
- **Two-phase process preserved**: state collection in Phase 1, validation in Phase 2.
- **ApplyContext unchanged**: the tuple, `visitEntry`, and `finalize` interface stay as-is.
### Overview
```
┌─────────────────────────────────────────────────────────┐
│ InvariantChecks tuple │
│ │
│ XRPNotCreated │ ... │ ValidVault │ ValidAMM │ ... │
└──────────────────────────┬──────────┬──────────────────-┘
│ │
┌────────────┘ └─────────────┐
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ ValidVault │ │ ValidAMM │
│ │ │ │
│ visitEntry() │ │ visitEntry() │
│ → collect state │ │ → collect state │
│ │ │ │
│ finalize() │ │ finalize() │
│ 1. domain checks │ │ 1. domain checks │
│ 2. dispatch tx │ │ 2. dispatch tx │
│ invariant │ │ invariant │
└─────────┬───────────┘ └─────────┬───────────┘
│ │
│ if ttVAULT_DEPOSIT │ if ttAMM_DEPOSIT
▼ ▼
VaultDeposit::checkInvariants() AMMDeposit::checkInvariants()
```
### Design
There are four components:
1. **Domain state** — extracted from the current invariant class into its own type
2. **Domain invariant class** — owns the state, runs domain-wide checks, delegates tx dispatch
3. **Transaction invariants** — static methods on transactors
4. **Opt-in trait + dispatch** — connects transactors to domain state with compile-time safety
Each is described below.
---
### 1. Domain State
Each domain extracts its accumulated state into a standalone class. This class is populated
during Phase 1 (`visitEntry`) and read during Phase 2 by both domain checks and transaction
checks.
```cpp
// include/xrpl/tx/invariants/VaultInvariantState.h
class VaultInvariantState
{
public:
struct Vault
{
uint256 key;
Asset asset;
AccountID pseudoId;
AccountID owner;
uint192 shareMPTID;
Number assetsTotal;
Number assetsAvailable;
Number assetsMaximum;
Number lossUnrealized;
static Vault make(SLE const&);
};
struct Shares
{
MPTIssue share;
std::uint64_t sharesTotal;
std::uint64_t sharesMaximum;
static Shares make(SLE const&);
};
void visitEntry(
bool isDelete,
std::shared_ptr<SLE const> const& before,
std::shared_ptr<SLE const> const& after);
// Read-only accessors
std::vector<Vault> const& beforeVault() const { return beforeVault_; }
std::vector<Vault> const& afterVault() const { return afterVault_; }
std::vector<Shares> const& beforeMPTs() const { return beforeMPTs_; }
std::vector<Shares> const& afterMPTs() const { return afterMPTs_; }
std::optional<Number> deltaAssets(Asset const&, AccountID const&) const;
private:
std::vector<Vault> beforeVault_;
std::vector<Vault> afterVault_;
std::vector<Shares> beforeMPTs_;
std::vector<Shares> afterMPTs_;
std::unordered_map<uint256, Number> deltas_;
};
```
The `visitEntry` implementation is extracted directly from the current `ValidVault::visitEntry`
— no logic changes, just moved to a new class.
### 2. Domain Invariant Class
Each domain invariant class owns its state, delegates `visitEntry`, and splits `finalize` into
two parts: domain checks (always run) and transaction dispatch.
```cpp
// include/xrpl/tx/invariants/VaultInvariant.h
class ValidVault
{
VaultInvariantState state_;
public:
void visitEntry(
bool isDelete,
std::shared_ptr<SLE const> const& before,
std::shared_ptr<SLE const> const& after)
{
state_.visitEntry(isDelete, before, after);
}
bool finalize(
STTx const& tx,
TER result,
XRPAmount fee,
ReadView const& view,
beast::Journal const& j);
};
```
```cpp
// src/libxrpl/tx/invariants/VaultInvariant.cpp
bool ValidVault::finalize(
STTx const& tx, TER result, XRPAmount fee,
ReadView const& view, beast::Journal const& j)
{
if (!isTesSuccess(result))
return true;
// --- Domain invariants (always run) ---
// These are the checks currently before/outside the switch statement:
// - vault operation must modify exactly one vault
// - vault immutable fields (asset, pseudoId, shareMPTID) unchanged
// - vault with zero shares has zero assets
// - assets available <= assets total
// - MPT issuance consistent with vault shareMPTID
if (!checkDomainInvariants(state_, tx, result, view, j))
return false;
// --- Transaction invariants (per-transaction) ---
return dispatchTransactionInvariant<VaultInvariantState>(
state_, tx, result, view, j);
}
```
`checkDomainInvariants` is a private method (or free function in the .cpp) containing the
checks that currently live outside the switch statement in `ValidVault::finalize`. These checks
run for every transaction that touches a vault.
### 3. Transaction Invariants
Each transactor that has transaction-specific invariant logic declares and implements a static
`checkInvariants` method:
```cpp
// include/xrpl/tx/transactors/vault/VaultDeposit.h
class VaultInvariantState; // forward declaration suffices
class VaultDeposit : public Transactor
{
public:
static constexpr ConsequencesFactoryType ConsequencesFactory{Normal};
explicit VaultDeposit(ApplyContext& ctx) : Transactor(ctx) {}
static NotTEC preflight(PreflightContext const& ctx);
static TER preclaim(PreclaimContext const& ctx);
TER doApply() override;
// Transaction invariant: validates post-conditions specific to VaultDeposit
static bool checkInvariants(
VaultInvariantState const& state,
STTx const& tx,
TER result,
ReadView const& view,
beast::Journal const& j);
};
```
```cpp
// src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
#include <xrpl/tx/invariants/VaultInvariantState.h> // full definition needed here
bool VaultDeposit::checkInvariants(
VaultInvariantState const& state,
STTx const& tx,
TER result,
ReadView const& view,
beast::Journal const& j)
{
// Logic currently in the ttVAULT_DEPOSIT case of ValidVault::finalize:
// - vault balance increased by deposit amount
// - depositor balance decreased by same amount
// - vault shares outstanding increased
// - assets maximum not exceeded
}
```
Transactors that have no transaction-specific invariants (e.g., `Payment` for the vault
domain) define nothing — the dispatch skips them.
### 4. Opt-In Trait and Dispatch
#### The Trait
A transactor declares participation in a domain by specializing `InvariantDomains<T>`:
```cpp
// include/xrpl/tx/invariants/InvariantDomains.h
#pragma once
#include <tuple>
#include <type_traits>
namespace xrpl {
// Primary template: no domain invariants by default.
template <typename Transactor>
struct InvariantDomains
{
using types = std::tuple<>;
};
// Compile-time tuple membership check.
template <typename State, typename Tuple>
struct tuple_contains : std::false_type {};
template <typename State, typename... Ts>
struct tuple_contains<State, std::tuple<State, Ts...>> : std::true_type {};
template <typename State, typename T, typename... Ts>
struct tuple_contains<State, std::tuple<T, Ts...>>
: tuple_contains<State, std::tuple<Ts...>> {};
template <typename State, typename Tuple>
inline constexpr bool tuple_contains_v = tuple_contains<State, Tuple>::value;
} // namespace xrpl
```
Transactors opt in by specializing the trait in their header:
```cpp
// At the bottom of VaultDeposit.h, after the class definition
template <>
struct InvariantDomains<VaultDeposit>
{
using types = std::tuple<VaultInvariantState>;
};
```
#### Why Traits Instead of SFINAE
An alternative is to use `if constexpr (requires { T::checkInvariants(...) })` to detect
whether a transactor provides invariant checks. This is dangerous because:
- If the `checkInvariants` signature changes (e.g., parameter reordering), the `requires`
expression quietly evaluates to `false` and the check is **silently skipped**.
- If someone deletes `checkInvariants` during refactoring, the same silent skip occurs.
- A method name typo (`checkInvariant` vs `checkInvariants`) disables the check silently.
The traits approach separates **declaration of intent** from **implementation**:
| Scenario | SFINAE | Traits |
| --------------------------- | --------------- | ------------------------- |
| Opted in, method correct | Runs | Runs |
| Opted in, method missing | **Silent skip** | **Compile error** |
| Opted in, wrong signature | **Silent skip** | **Compile error** |
| Not opted in, no method | Skips (correct) | Skips (correct) |
| Method deleted in refactor | **Silent skip** | **Compile error** |
| Opt-in removed deliberately | N/A | Skips (visible in review) |
The only way to disable a check is to remove the trait specialization — a deliberate,
reviewable change.
#### The Dispatch Function
The dispatch function is **declared** in a lightweight header and **defined** in a single
`.cpp` file. This keeps all 45+ transactor `#include`s out of invariant headers.
```cpp
// include/xrpl/tx/invariants/InvariantDispatch.h — declaration only
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
namespace xrpl {
class ReadView;
template <typename State>
bool dispatchTransactionInvariant(
State const& state,
STTx const& tx,
TER result,
ReadView const& view,
beast::Journal const& j);
} // namespace xrpl
```
The implementation uses `transactions.macro` to generate a switch over all transaction types.
For each type, `tuple_contains_v` checks whether that transactor opted in for the given
domain state. If it did, the transactor's `checkInvariants` is called — and if the method
is missing or has the wrong signature, **compilation fails**.
```cpp
// src/libxrpl/tx/invariants/InvariantDispatch.cpp
#define TRANSACTION_INCLUDE 1
#include <xrpl/protocol/detail/transactions.macro>
#include <xrpl/tx/invariants/InvariantDomains.h>
#include <xrpl/tx/invariants/VaultInvariantState.h>
#include <xrpl/tx/invariants/AMMInvariantState.h>
namespace xrpl {
template <typename State>
bool dispatchTransactionInvariant(
State const& state,
STTx const& tx,
TER result,
ReadView const& view,
beast::Journal const& j)
{
switch (tx.getTxnType())
{
#pragma push_macro("TRANSACTION")
#undef TRANSACTION
#define TRANSACTION(tag, value, name, ...) \
case tag: { \
if constexpr (tuple_contains_v<State, \
typename InvariantDomains<name>::types>) { \
return name::checkInvariants(state, tx, result, view, j); \
} \
return true; \
}
#include <xrpl/protocol/detail/transactions.macro>
#undef TRANSACTION
#pragma pop_macro("TRANSACTION")
default:
return true;
}
}
// Explicit instantiation — one line per domain
template bool dispatchTransactionInvariant<VaultInvariantState>(
VaultInvariantState const&, STTx const&, TER,
ReadView const&, beast::Journal const&);
template bool dispatchTransactionInvariant<AMMInvariantState>(
AMMInvariantState const&, STTx const&, TER,
ReadView const&, beast::Journal const&);
} // namespace xrpl
```
Each new domain adds one explicit instantiation line. Forgetting it produces a linker error.
**Note on RAII number guards:** `Transactor::operator()` establishes `NumberSO` and
`CurrentTransactionRulesGuard` before calling `checkInvariants`, so the dispatch function
does not need to duplicate them.
---
## Execution Flow
### Phase 1 — State Collection
```
ApplyContext::checkInvariantsHelper()
│ for each modified ledger entry:
├──▶ TransactionFeeCheck::visitEntry() (no-op)
├──▶ XRPNotCreated::visitEntry() → accumulates drops_
├──▶ ...
└──▶ ValidVault::visitEntry()
└──▶ VaultInvariantState::visitEntry()
├─ ltVAULT → push to beforeVault_ / afterVault_
├─ ltMPTOKEN_ISSUANCE → push to beforeMPTs_ / afterMPTs_
├─ ltACCOUNT_ROOT → record in deltas_
└─ (other types ignored)
```
### Phase 2 — Validation
```
ApplyContext::checkInvariantsHelper()
├──▶ TransactionFeeCheck::finalize() → checks fee
├──▶ XRPNotCreated::finalize() → checks drops_
├──▶ ...
└──▶ ValidVault::finalize()
├─ 1. checkDomainInvariants(state_)
│ "exactly one vault modified"
│ "immutable fields unchanged"
│ "vault with zero shares has zero assets"
└─ 2. dispatchTransactionInvariant<VaultInvariantState>(state_, ...)
│ switch(tx.getTxnType())
│ case ttVAULT_DEPOSIT:
│ InvariantDomains<VaultDeposit>::types
│ contains VaultInvariantState? YES
│ → VaultDeposit::checkInvariants(state_, ...)
│ "vault balance increased by deposit amount"
│ "depositor balance decreased by same amount"
│ "shares outstanding increased"
│ case ttPAYMENT:
│ InvariantDomains<Payment>::types
│ contains VaultInvariantState? NO
│ → return true (skip)
```
---
## Include Dependencies
The design avoids circular dependencies and minimizes compile-time impact:
```
InvariantDomains.h ← lightweight, only <tuple>
InvariantDispatch.h ← lightweight, only forward declarations + STTx/TER
VaultInvariantState.h ← protocol types only (Number, Asset, MPTIssue, etc.)
VaultInvariant.h → VaultInvariantState.h, InvariantDispatch.h
VaultDeposit.h → Transactor.h, InvariantDomains.h
(forward-declares VaultInvariantState)
InvariantDispatch.cpp → transactions.macro (TRANSACTION_INCLUDE=1)
ALL transactor headers, ALL state headers
(heavy includes confined to one .cpp)
VaultDeposit.cpp → VaultInvariantState.h (full definition)
```
No invariant header includes any transactor header. No transactor header includes any
invariant state header (forward declaration suffices). The heavy fan-out from
`transactions.macro` is confined to `InvariantDispatch.cpp`.
---
## What Changes and What Doesn't
| Component | Changes? | Details |
| -------------------------- | -------- | ---------------------------------------------------------------------------------------------- |
| `ApplyContext` | **No** | Tuple iteration, `visitEntry`/`finalize` interface unchanged |
| `InvariantChecks` tuple | **No** | `ValidVault`, `ValidAMM` stay in the tuple |
| Universal invariants | **No** | `XRPNotCreated`, `TransactionFeeCheck`, etc. untouched |
| Privilege-gated invariants | **No** | `AccountRootsNotDeleted`, `ValidMPTIssuance`, etc. untouched |
| `ValidVault` interface | **No** | Still has `visitEntry` + `finalize` with same signatures |
| `ValidVault` internals | **Yes** | State extracted; finalize split into domain + dispatch |
| `ValidAMM` internals | **Yes** | Same treatment as `ValidVault` |
| Transactor headers | **Yes** | Add `checkInvariants` declaration + trait specialization |
| Transactor `.cpp` files | **Yes** | Add `checkInvariants` implementation |
| New files | **Yes** | `InvariantDomains.h`, `InvariantDispatch.h`, `InvariantDispatch.cpp`, per-domain state headers |
---
## Migration Path
| Phase | Change | Files |
| ----- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| 1 | Add `InvariantDomains.h` trait and `InvariantDispatch.h` declaration | 2 new headers |
| 2 | Add `InvariantDispatch.cpp` with dispatch template (initially no instantiations) | 1 new `.cpp` |
| 3 | Extract `VaultInvariantState` from `ValidVault` | `VaultInvariantState.h`, `VaultInvariantState.cpp` |
| 4 | Refactor `ValidVault`: own state, split finalize into domain checks + dispatch | `VaultInvariant.h`, `VaultInvariant.cpp` |
| 5 | Migrate one transactor (e.g., `VaultDeposit`): add `checkInvariants` + trait, remove its switch case | `VaultDeposit.h`, `VaultDeposit.cpp`, `VaultInvariant.cpp` |
| 6 | Repeat for remaining vault transactors (`VaultCreate`, `VaultSet`, `VaultWithdraw`, `VaultClawback`) | Vault transactor files, `VaultInvariant.cpp` |
| 7 | Delete empty switch from `ValidVault::finalize` | `VaultInvariant.cpp` |
| 8 | Repeat 37 for `ValidAMM` and lending invariants | AMM/loan files, `InvariantDispatch.cpp` |
Each phase is independently deployable. The switch shrinks one case at a time.
---
## Alternatives Considered
### A. CRTP Base Class (`DomainInvariantBase<Derived, State>`)
A CRTP base could provide `visitEntry` delegation and the two-phase `finalize` pattern
generically, eliminating ~5 lines of boilerplate per domain.
**Rejected because:**
- The codebase has zero CRTP in the invariant system today. Adding it raises the template
literacy bar for all contributors.
- `static_cast<Derived*>(this)` is a known source of subtle bugs if the CRTP contract is
violated.
- Each domain's `finalize` has unique structure (feature gates, result filtering, helper
methods). A generic base cannot capture this without becoming complex itself.
### B. Virtual Base Class
A virtual `DomainInvariant` base class could provide a common interface.
**Rejected because:**
- The existing invariant system is entirely duck-typed via `std::tuple`. Introducing virtual
dispatch changes the paradigm for only some checkers, creating inconsistency.
- Does not solve the dispatch-to-transactor problem — you still need a mechanism to call
`VaultDeposit::checkInvariants` from the invariant system.
### C. SFINAE Detection Instead of Traits
Use `if constexpr (requires { T::checkInvariants(...) })` to detect transactor methods.
**Rejected because:**
- Silent failure on signature mismatch or method removal (see "Why Traits Instead of SFINAE"
above). This is the single most dangerous failure mode for a safety-critical system like
invariant checking.
### D. Runtime Registry
Transactors register `std::function` callbacks keyed by `TxType` at startup.
**Rejected because:**
- Static initialization order fiasco across translation units.
- Type safety loss (`void*` casts or type-erased state).
- A transactor that forgets to register silently passes — same problem as SFINAE.
- Runtime overhead on every transaction for no benefit.
### E. Extend `transactions.macro` with Invariant Domain Field
See **Proposal B** below — this is presented as a full alternative design rather than a
rejected option. It uses a domain bitfield in the macro (analogous to the existing `privileges`
field) instead of per-transactor trait specializations.
---
## Proposal B: Macro-Based Domain Opt-In
This is an alternative to the traits-based opt-in described above. Everything else in the
architecture (state extraction, domain invariant classes, `checkInvariants` on transactors,
the dispatch function, the two-phase process) stays the same. Only **how a transactor declares
its domain membership** changes.
### Motivation
The `transactions.macro` already serves as the single source of truth for transaction
metadata: tag, name, delegation, amendments, privileges, fields. The `privileges` bitfield
is already queried by invariant code via `hasPrivilege()`. A domain bitfield follows the same
established pattern — it belongs in the macro alongside everything else, rather than scattered
across transactor headers as trait specializations.
### Domain Enum
```cpp
// include/xrpl/tx/invariants/InvariantCheckDomain.h
#pragma once
#include <cstdint>
namespace xrpl {
enum Domain : std::uint16_t {
noDomain = 0x0000,
vaultDomain = 0x0001,
ammDomain = 0x0002,
loanDomain = 0x0004,
};
constexpr Domain operator|(Domain a, Domain b)
{
return static_cast<Domain>(
static_cast<std::uint16_t>(a) | static_cast<std::uint16_t>(b));
}
constexpr Domain operator&(Domain a, Domain b)
{
return static_cast<Domain>(
static_cast<std::uint16_t>(a) & static_cast<std::uint16_t>(b));
}
} // namespace xrpl
```
### Macro Change
Add `domain` as the 7th parameter, pushing `fields` to 8th:
```
// Before (7 parameters):
TRANSACTION(tag, value, name, delegable, amendments, privileges, fields)
// After (8 parameters):
TRANSACTION(tag, value, name, delegable, amendments, privileges, domain, fields)
```
Example entries:
```cpp
TRANSACTION(ttVAULT_DEPOSIT, 68, VaultDeposit,
Delegation::delegable,
featureSingleAssetVault,
mayAuthorizeMPT | mustModifyVault,
vaultDomain,
({
{sfVaultID, soeREQUIRED},
{sfAmount, soeREQUIRED, soeMPTSupported},
}))
TRANSACTION(ttPAYMENT, 0, Payment,
Delegation::notDelegable,
uint256{},
noPriv,
noDomain,
({
{sfDestination, soeREQUIRED},
...
}))
// A transaction participating in multiple domains:
TRANSACTION(ttLOAN_MANAGE, 81, LoanManage,
Delegation::delegable,
featureLendingProtocol,
mustModifyVault | ...,
loanDomain | vaultDomain,
({...}))
```
### Impact on Existing Macro Consumers
Most consumers use `TRANSACTION(tag, value, name, ...)` and are **unaffected**:
| Consumer | Current definition | Impact |
| --------------------- | ------------------------------------------------------------------------- | --------------- |
| `TxFormats.h` | `TRANSACTION(tag, value, ...)` | None |
| `jss.h` | `TRANSACTION(tag, value, name, ...)` | None |
| `applySteps.cpp` | `TRANSACTION(tag, value, name, ...)` | None |
| `Permissions.cpp` (1) | `TRANSACTION(tag, value, name, delegable, amendment, ...)` | None |
| `Permissions.cpp` (2) | `TRANSACTION(tag, value, name, delegable, ...)` | None |
| `InvariantCheck.cpp` | `TRANSACTION(tag, value, name, delegable, amendment, privileges, ...)` | None |
| `TxFormats.cpp` | `TRANSACTION(tag, value, name, delegable, amendment, privileges, fields)` | **Must update** |
Only `TxFormats.cpp` uses all 7 positional parameters and must be updated to accept 8:
```cpp
// TxFormats.cpp — only change needed
#define TRANSACTION(tag, value, name, delegable, amendment, privileges, domain, fields) \
add(jss::name, tag, UNWRAP fields, getCommonFields());
```
### Domain-to-State Mapping
A template variable maps each `State` type to its domain flag:
```cpp
// include/xrpl/tx/invariants/InvariantCheckDomain.h
template <typename State>
inline constexpr Domain domainFor = noDomain;
```
Each domain state header specializes it:
```cpp
// include/xrpl/tx/invariants/VaultInvariantState.h (at the bottom)
template <>
inline constexpr Domain domainFor<VaultInvariantState> = vaultDomain;
```
### Dispatch Function
The dispatch is the same structure as Proposal A, but uses the macro's domain field instead
of a traits lookup:
```cpp
// src/libxrpl/tx/invariants/InvariantDispatch.cpp
#define TRANSACTION_INCLUDE 1
#include <xrpl/protocol/detail/transactions.macro>
#include <xrpl/tx/invariants/InvariantCheckDomain.h>
#include <xrpl/tx/invariants/VaultInvariantState.h>
#include <xrpl/tx/invariants/AMMInvariantState.h>
namespace xrpl {
template <typename State>
bool dispatchTransactionInvariant(
State const& state,
STTx const& tx,
TER result,
ReadView const& view,
beast::Journal const& j)
{
constexpr auto target = domainFor<State>;
switch (tx.getTxnType())
{
#pragma push_macro("TRANSACTION")
#undef TRANSACTION
#define TRANSACTION(tag, value, name, delegable, amend, priv, domain, fields) \
case tag: { \
if constexpr ((domain & target) != noDomain) { \
return name::checkInvariants(state, tx, result, view, j); \
} \
return true; \
}
#include <xrpl/protocol/detail/transactions.macro>
#undef TRANSACTION
#pragma pop_macro("TRANSACTION")
default:
return true;
}
}
// Explicit instantiations — one per domain
template bool dispatchTransactionInvariant<VaultInvariantState>(
VaultInvariantState const&, STTx const&, TER,
ReadView const&, beast::Journal const&);
template bool dispatchTransactionInvariant<AMMInvariantState>(
AMMInvariantState const&, STTx const&, TER,
ReadView const&, beast::Journal const&);
} // namespace xrpl
```
**Compile-time enforcement**: when the macro expands for `ttVAULT_DEPOSIT`, `domain` is
literally `vaultDomain`, so `(vaultDomain & vaultDomain) != noDomain` is a true `constexpr`
expression. The `if constexpr` branch is compiled, and `VaultDeposit::checkInvariants` **must
exist** with the correct signature — otherwise compilation fails.
For `ttPAYMENT`, `domain` is `noDomain`, so `(noDomain & vaultDomain) != noDomain` is false.
The branch is discarded. `Payment::checkInvariants` is never referenced.
### `hasDomain` Query Function
Analogous to `hasPrivilege()`, for use in domain invariant `finalize` methods:
```cpp
// include/xrpl/tx/invariants/InvariantCheckDomain.h
bool hasDomain(STTx const& tx, Domain domain);
```
```cpp
// src/libxrpl/tx/invariants/InvariantCheckDomain.cpp
bool hasDomain(STTx const& tx, Domain domain)
{
switch (tx.getTxnType())
{
#pragma push_macro("TRANSACTION")
#undef TRANSACTION
#define TRANSACTION(tag, value, name, delegable, amend, priv, txDomain, fields) \
case tag: \
return (txDomain & domain) != noDomain;
#include <xrpl/protocol/detail/transactions.macro>
#undef TRANSACTION
#pragma pop_macro("TRANSACTION")
default:
return false;
}
}
```
This allows domain invariant classes to use `hasDomain(tx, vaultDomain)` in their domain-wide
checks, analogous to how existing invariants use `hasPrivilege(tx, mustModifyVault)`.
### Proposal A vs Proposal B
| Aspect | A: Traits | B: Macro domain field |
| ---------------------------- | ----------------------------------- | ---------------------------------------- |
| Opt-in location | Scattered across transactor headers | Centralized in `transactions.macro` |
| Single source of truth | No — trait + macro both describe tx | Yes — macro is the one place |
| Follows existing patterns | New pattern (traits) | Extends existing pattern (`privileges`) |
| New transactor | Add trait in header | Add domain flag in macro |
| New domain | Add `domainFor<>` + tuple helper | Add `domainFor<>` + enum value |
| Macro migration | None | One-time: update `TxFormats.cpp` |
| Compile-time enforcement | Same (`if constexpr` → hard error) | Same (`if constexpr` → hard error) |
| Auditability | Grep headers for specializations | Read one macro file |
| Runtime query | N/A | `hasDomain()` parallels `hasPrivilege()` |
| No transactor header changes | Trait specialization needed | Only `checkInvariants` declaration |
Proposal B is **recommended** because it follows the established `privileges` pattern, keeps
all transaction metadata in one file, and requires fewer new concepts (no traits template, no
`tuple_contains` helper).