Compare commits

..

7 Commits

Author SHA1 Message Date
Vito Tumas
e56d7c08d0 fix: change totalSendAmount to uint64
Co-authored-by: xrplf-ai-reviewer[bot] <266832837+xrplf-ai-reviewer[bot]@users.noreply.github.com>
2026-03-31 11:44:47 +02:00
Vito
1fec636c15 fix: use exact integer arithmetic for MaximumAmount checks
Replace Number/STAmount with std::int64_t for the totalSendAmount
accumulator. STAmount implicitly converts to Number, whose small-scale
mantissa (~16 digits) can lose precision for values near maxMPTokenAmount
(19 digits), potentially producing incorrect MaximumAmount comparisons.

Also fix test: re-enable fixSecurity3_1_3 after the pre-amendment block
to avoid leaking disabled state into subsequent tests.
2026-03-31 11:28:51 +02:00
Vito
867f21984e fix: build errors 2026-03-31 10:32:00 +02:00
Vito
6105bf7a52 Merge remote-tracking branch 'origin/develop' into tapanito/fix-multi-send-max-amount 2026-03-31 09:57:46 +02:00
Vito
5b3ae8c9d5 fix: Harden MaximumAmount overflow checks in rippleSendMultiMPT
Use subtraction-based guards instead of addition to prevent uint64_t
overflow in both the post-amendment aggregate check and the
pre-amendment per-iteration check. Each condition in the cascade
protects the subtraction in the next from underflow.

Move totalSendAmount accumulation after the check so the guard
operates on the pre-addition value.
2026-03-26 15:21:40 +01:00
Vito
e5b2d9a682 Merge remote-tracking branch 'origin/develop' into tapanito/fix-multi-send-max-amount
# Conflicts:
#	include/xrpl/protocol/detail/features.macro
#	src/libxrpl/ledger/View.cpp
2026-03-26 10:58:12 +01:00
Vito
bd544acb9e fix: Enforce aggregate MaximumAmount in multi-send MPT
rippleSendMultiMPT used a read-only SLE snapshot (view.read) to check
MaximumAmount per iteration. Since rippleCreditMPT updates a separate
mutable copy (view.peek), the snapshot's sfOutstandingAmount was stale
after the first iteration, allowing the aggregate to exceed
MaximumAmount.

Replace the per-iteration check with a running total that validates
the aggregate against MaximumAmount within the send loop. The old
per-iteration check is retained behind a !fixAssortedFixes gate for
ledger replay compatibility.
2026-03-25 12:22:33 +01:00
17 changed files with 324 additions and 229 deletions

View File

@@ -153,6 +153,19 @@ jobs:
${CMAKE_ARGS} \
..
- name: Build the binary
working-directory: ${{ env.BUILD_DIR }}
env:
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}"
- name: Check protocol autogen files are up-to-date
env:
MESSAGE: |
@@ -176,19 +189,6 @@ jobs:
exit 1
fi
- name: Build the binary
working-directory: ${{ env.BUILD_DIR }}
env:
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}"
- name: Show ccache statistics
if: ${{ inputs.ccache_enabled }}
run: |

View File

@@ -108,10 +108,11 @@ target_link_libraries(
)
# Level 05
## Set up code generation for protocol_autogen module.
## Generation runs at configure time (when the stamp is stale),
## so generated files are always present before add_module GLOBs them.
## Set up code generation for protocol_autogen module
include(XrplProtocolAutogen)
# Must call setup_protocol_autogen before add_module so that:
# 1. Stale generated files are cleared before GLOB runs
# 2. Output file list is known for custom commands
setup_protocol_autogen()
add_module(xrpl protocol_autogen)
@@ -120,6 +121,11 @@ target_link_libraries(
PUBLIC xrpl.libxrpl.protocol
)
# Ensure code generation runs before compiling protocol_autogen
if(TARGET protocol_autogen_generate)
add_dependencies(xrpl.libxrpl.protocol_autogen protocol_autogen_generate)
endif()
# Level 06
add_module(xrpl core)
target_link_libraries(

View File

@@ -15,6 +15,7 @@ set(CODEGEN_VENV_DIR
)
# Function to set up code generation for protocol_autogen module
# This runs at configure time to generate C++ wrapper classes from macro files
function(setup_protocol_autogen)
# Directory paths
set(MACRO_DIR "${CMAKE_CURRENT_SOURCE_DIR}/include/xrpl/protocol/detail")
@@ -24,7 +25,7 @@ function(setup_protocol_autogen)
set(AUTOGEN_TEST_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/src/tests/libxrpl/protocol_autogen"
)
set(SCRIPTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/scripts/codegen")
set(SCRIPTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/scripts")
# Input macro files
set(TRANSACTIONS_MACRO "${MACRO_DIR}/transactions.macro")
@@ -42,7 +43,6 @@ function(setup_protocol_autogen)
set(LEDGER_TEST_TEMPLATE
"${SCRIPTS_DIR}/templates/LedgerEntryTests.cpp.mako"
)
set(UPDATE_STAMP_SCRIPT "${SCRIPTS_DIR}/update_codegen_stamp.py")
# Check if code generation is disabled
if(XRPL_NO_CODEGEN)
@@ -60,33 +60,7 @@ function(setup_protocol_autogen)
file(MAKE_DIRECTORY "${AUTOGEN_TEST_DIR}/ledger_entries")
file(MAKE_DIRECTORY "${AUTOGEN_TEST_DIR}/transactions")
# === Stamp file check ===
# All input files whose content affects code generation output.
set(STAMP_FILE "${CMAKE_CURRENT_SOURCE_DIR}/scripts/codegen/.codegen_stamp")
set(ALL_INPUT_FILES
"${TRANSACTIONS_MACRO}"
"${LEDGER_ENTRIES_MACRO}"
"${SFIELDS_MACRO}"
"${GENERATE_TX_SCRIPT}"
"${GENERATE_LEDGER_SCRIPT}"
"${REQUIREMENTS_FILE}"
"${MACRO_PARSER_COMMON}"
"${TX_TEMPLATE}"
"${TX_TEST_TEMPLATE}"
"${LEDGER_TEMPLATE}"
"${LEDGER_TEST_TEMPLATE}"
)
# Tell CMake to reconfigure automatically when any input file changes.
# The reconfigure itself is cheap — it runs the stamp check below
# which only invokes stdlib Python (no venv needed).
set_property(
DIRECTORY
APPEND
PROPERTY CMAKE_CONFIGURE_DEPENDS ${ALL_INPUT_FILES}
)
# Find Python3 (needed for stamp check; no venv required).
# Find Python3 - check if already found by Conan or find it ourselves
if(NOT Python3_EXECUTABLE)
find_package(Python3 COMPONENTS Interpreter QUIET)
endif()
@@ -105,45 +79,19 @@ function(setup_protocol_autogen)
return()
endif()
# Check whether the stamp is up-to-date (stdlib-only, no venv).
execute_process(
COMMAND
${Python3_EXECUTABLE} "${UPDATE_STAMP_SCRIPT}" --check
"${STAMP_FILE}" ${ALL_INPUT_FILES}
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
RESULT_VARIABLE STAMP_CHECK_RESULT
)
message(STATUS "Using Python3 for code generation: ${Python3_EXECUTABLE}")
# ------------------------------------------------------------------
# Fast path: stamp matches — generated files are up to date.
# ------------------------------------------------------------------
if(STAMP_CHECK_RESULT EQUAL 0)
message(
STATUS
"Protocol autogen: inputs unchanged (stamp matches), skipping generation"
)
return()
endif()
# ------------------------------------------------------------------
# Slow path: stamp mismatch — run generation at configure time.
# ------------------------------------------------------------------
message(
STATUS
"Protocol autogen: inputs changed, running code generation..."
)
# Set up Python virtual environment for code generation.
# Set up Python virtual environment for code generation
if(CODEGEN_VENV_DIR)
# User-provided venv - skip automatic setup.
# User-provided venv - skip automatic setup
set(VENV_DIR "${CODEGEN_VENV_DIR}")
message(STATUS "Using user-provided Python venv: ${VENV_DIR}")
else()
# Use default venv in build directory.
# Use default venv in build directory
set(VENV_DIR "${CMAKE_CURRENT_BINARY_DIR}/codegen_venv")
endif()
# Determine the Python/pip executables inside the venv.
# Determine the Python executable path in the venv
if(WIN32)
set(VENV_PYTHON "${VENV_DIR}/Scripts/python.exe")
set(VENV_PIP "${VENV_DIR}/Scripts/pip.exe")
@@ -152,9 +100,9 @@ function(setup_protocol_autogen)
set(VENV_PIP "${VENV_DIR}/bin/pip")
endif()
# Create or update the virtual environment if needed.
# Only auto-setup venv if not user-provided
if(NOT CODEGEN_VENV_DIR)
# Check if venv needs to be created or updated.
# Check if venv needs to be created or updated
set(VENV_NEEDS_UPDATE FALSE)
if(NOT EXISTS "${VENV_PYTHON}")
set(VENV_NEEDS_UPDATE TRUE)
@@ -174,9 +122,8 @@ function(setup_protocol_autogen)
)
endif()
# Create/update virtual environment if needed.
# Create/update virtual environment if needed
if(VENV_NEEDS_UPDATE)
# Create the venv.
message(
STATUS
"Setting up Python virtual environment at ${VENV_DIR}"
@@ -193,7 +140,7 @@ function(setup_protocol_autogen)
)
endif()
# Warn if pip is configured with a non-default index (may need VPN).
# Check pip index URL configuration
execute_process(
COMMAND ${VENV_PIP} config get global.index-url
OUTPUT_VARIABLE PIP_INDEX_URL
@@ -215,7 +162,6 @@ function(setup_protocol_autogen)
endif()
endif()
# Install dependencies.
message(STATUS "Installing Python dependencies...")
execute_process(
COMMAND ${VENV_PIP} install --upgrade pip
@@ -239,56 +185,125 @@ function(setup_protocol_autogen)
)
endif()
# Mark requirements as installed.
# Mark requirements as installed
file(TOUCH "${VENV_DIR}/.requirements_installed")
message(STATUS "Python virtual environment ready")
endif()
endif()
# Generate transaction classes.
# At configure time - get list of output files for transactions
execute_process(
COMMAND
${VENV_PYTHON} "${GENERATE_TX_SCRIPT}" "${TRANSACTIONS_MACRO}"
--header-dir "${AUTOGEN_HEADER_DIR}/transactions" --test-dir
"${AUTOGEN_TEST_DIR}/transactions" --list-outputs
OUTPUT_VARIABLE TX_OUTPUT_FILES
OUTPUT_STRIP_TRAILING_WHITESPACE
RESULT_VARIABLE TX_LIST_RESULT
ERROR_VARIABLE TX_LIST_ERROR
)
if(NOT TX_LIST_RESULT EQUAL 0)
message(
FATAL_ERROR
"Failed to list transaction output files:\n${TX_LIST_ERROR}"
)
endif()
# Convert newline-separated list to CMake list
string(REPLACE "\\" "/" TX_OUTPUT_FILES "${TX_OUTPUT_FILES}")
string(REPLACE "\n" ";" TX_OUTPUT_FILES "${TX_OUTPUT_FILES}")
# At configure time - get list of output files for ledger entries
execute_process(
COMMAND
${VENV_PYTHON} "${GENERATE_LEDGER_SCRIPT}" "${LEDGER_ENTRIES_MACRO}"
--header-dir "${AUTOGEN_HEADER_DIR}/ledger_entries" --test-dir
"${AUTOGEN_TEST_DIR}/ledger_entries" --list-outputs
OUTPUT_VARIABLE LEDGER_OUTPUT_FILES
OUTPUT_STRIP_TRAILING_WHITESPACE
RESULT_VARIABLE LEDGER_LIST_RESULT
ERROR_VARIABLE LEDGER_LIST_ERROR
)
if(NOT LEDGER_LIST_RESULT EQUAL 0)
message(
FATAL_ERROR
"Failed to list ledger entry output files:\n${LEDGER_LIST_ERROR}"
)
endif()
# Convert newline-separated list to CMake list
string(REPLACE "\\" "/" LEDGER_OUTPUT_FILES "${LEDGER_OUTPUT_FILES}")
string(REPLACE "\n" ";" LEDGER_OUTPUT_FILES "${LEDGER_OUTPUT_FILES}")
# Custom command to generate transaction classes at build time
add_custom_command(
OUTPUT ${TX_OUTPUT_FILES}
COMMAND
${VENV_PYTHON} "${GENERATE_TX_SCRIPT}" "${TRANSACTIONS_MACRO}"
--header-dir "${AUTOGEN_HEADER_DIR}/transactions" --test-dir
"${AUTOGEN_TEST_DIR}/transactions" --sfields-macro
"${SFIELDS_MACRO}"
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
RESULT_VARIABLE TX_RESULT
ERROR_VARIABLE TX_ERROR
DEPENDS
"${TRANSACTIONS_MACRO}"
"${SFIELDS_MACRO}"
"${GENERATE_TX_SCRIPT}"
"${MACRO_PARSER_COMMON}"
"${TX_TEMPLATE}"
"${TX_TEST_TEMPLATE}"
"${REQUIREMENTS_FILE}"
COMMENT "Generating transaction classes from transactions.macro..."
VERBATIM
)
if(NOT TX_RESULT EQUAL 0)
message(FATAL_ERROR "Transaction code generation failed:\n${TX_ERROR}")
endif()
# Generate ledger entry classes.
execute_process(
# Custom command to generate ledger entry classes at build time
add_custom_command(
OUTPUT ${LEDGER_OUTPUT_FILES}
COMMAND
${VENV_PYTHON} "${GENERATE_LEDGER_SCRIPT}" "${LEDGER_ENTRIES_MACRO}"
--header-dir "${AUTOGEN_HEADER_DIR}/ledger_entries" --test-dir
"${AUTOGEN_TEST_DIR}/ledger_entries" --sfields-macro
"${SFIELDS_MACRO}"
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
RESULT_VARIABLE LEDGER_RESULT
ERROR_VARIABLE LEDGER_ERROR
DEPENDS
"${LEDGER_ENTRIES_MACRO}"
"${SFIELDS_MACRO}"
"${GENERATE_LEDGER_SCRIPT}"
"${MACRO_PARSER_COMMON}"
"${LEDGER_TEMPLATE}"
"${LEDGER_TEST_TEMPLATE}"
"${REQUIREMENTS_FILE}"
COMMENT "Generating ledger entry classes from ledger_entries.macro..."
VERBATIM
)
if(NOT LEDGER_RESULT EQUAL 0)
message(
FATAL_ERROR
"Ledger entry code generation failed:\n${LEDGER_ERROR}"
)
endif()
# Update the stamp file so subsequent configures skip generation.
execute_process(
COMMAND
${Python3_EXECUTABLE} "${UPDATE_STAMP_SCRIPT}" --update
"${STAMP_FILE}" ${ALL_INPUT_FILES}
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
RESULT_VARIABLE STAMP_RESULT
# Create a custom target that depends on all generated files
add_custom_target(
protocol_autogen_generate
DEPENDS ${TX_OUTPUT_FILES} ${LEDGER_OUTPUT_FILES}
COMMENT "Protocol autogen code generation"
)
if(NOT STAMP_RESULT EQUAL 0)
message(WARNING "Failed to update codegen stamp file")
endif()
message(STATUS "Protocol autogen: code generation complete")
# Extract test files from output lists (files ending in Tests.cpp)
set(PROTOCOL_AUTOGEN_TEST_SOURCES "")
foreach(FILE ${TX_OUTPUT_FILES} ${LEDGER_OUTPUT_FILES})
if(FILE MATCHES "Tests\\.cpp$")
list(APPEND PROTOCOL_AUTOGEN_TEST_SOURCES "${FILE}")
endif()
endforeach()
# Export test sources to parent scope for use in test CMakeLists.txt
set(PROTOCOL_AUTOGEN_TEST_SOURCES
"${PROTOCOL_AUTOGEN_TEST_SOURCES}"
CACHE INTERNAL
"Generated protocol_autogen test sources"
)
# Register dependencies so CMake reconfigures when macro files change
# (to update the list of output files)
set_property(
DIRECTORY
APPEND
PROPERTY
CMAKE_CONFIGURE_DEPENDS
"${TRANSACTIONS_MACRO}"
"${LEDGER_ENTRIES_MACRO}"
)
endfunction()

View File

@@ -6,15 +6,15 @@ This directory contains auto-generated C++ wrapper classes for XRP Ledger protoc
The files in this directory are automatically generated at **CMake configure time** from macro definition files:
- **Transaction classes** (in `transactions/`): Generated from `include/xrpl/protocol/detail/transactions.macro` by `scripts/codegen/generate_tx_classes.py`
- **Ledger entry classes** (in `ledger_entries/`): Generated from `include/xrpl/protocol/detail/ledger_entries.macro` by `scripts/codegen/generate_ledger_classes.py`
- **Transaction classes** (in `transactions/`): Generated from `include/xrpl/protocol/detail/transactions.macro` by `scripts/generate_tx_classes.py`
- **Ledger entry classes** (in `ledger_entries/`): Generated from `include/xrpl/protocol/detail/ledger_entries.macro` by `scripts/generate_ledger_classes.py`
## Generation Process
The generation happens automatically when you **configure** the project (not during build). When you run CMake, the system:
1. Creates a Python virtual environment in the build directory (`codegen_venv`)
2. Installs Python dependencies from `scripts/codegen/requirements.txt` into the venv (only if needed)
2. Installs Python dependencies from `scripts/requirements.txt` into the venv (only if needed)
3. Runs the Python generation scripts using the venv Python interpreter
4. Parses the macro files to extract type definitions
5. Generates type-safe C++ wrapper classes using Mako templates
@@ -26,7 +26,7 @@ The code is regenerated when:
- You run CMake configure for the first time
- The Python virtual environment doesn't exist
- `scripts/codegen/requirements.txt` has been modified
- `scripts/requirements.txt` has been modified
To force regeneration, delete the build directory and reconfigure.
@@ -55,9 +55,9 @@ The generated `.h` files **are checked into version control**. This means:
To modify the generated classes:
- Edit the macro files in `include/xrpl/protocol/detail/`
- Edit the Mako templates in `scripts/codegen/templates/`
- Edit the generation scripts in `scripts/codegen/`
- Update Python dependencies in `scripts/codegen/requirements.txt`
- Edit the Mako templates in `scripts/templates/`
- Edit the generation scripts in `scripts/`
- Update Python dependencies in `scripts/requirements.txt`
- Run CMake configure to regenerate
## Adding Common Fields
@@ -73,7 +73,7 @@ Base classes:
Templates (update to pass required common fields to base class constructors):
- `scripts/codegen/templates/Transaction.h.mako`
- `scripts/codegen/templates/LedgerEntry.h.mako`
- `scripts/templates/Transaction.h.mako`
- `scripts/templates/LedgerEntry.h.mako`
These files are **not auto-generated** and must be updated by hand.

View File

@@ -1,4 +0,0 @@
# Auto-generated by protocol autogen - do not edit manually.
# This file tracks input hashes to avoid unnecessary code regeneration.
# It should be checked into version control alongside the generated files.
COMBINED_HASH=24a9168ac6a450f09fa4e2ab288d06624a368041e91fbc7741101d3565d1e601

View File

@@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""
Check or update the codegen stamp file.
Uses only the Python standard library (hashlib, pathlib, sys) so it can
run without a virtual environment.
Modes:
--check Exit 0 if stamp is up-to-date, exit 1 if stale/missing.
--update Recompute the hash and write it to the stamp file.
Usage:
python update_codegen_stamp.py --check <stamp_file> <input_files...>
python update_codegen_stamp.py --update <stamp_file> <input_files...>
"""
import hashlib
import sys
from pathlib import Path
def compute_combined_hash(input_files: list[str]) -> str:
"""Compute a combined SHA-256 hash of all input files.
Algorithm: compute each file's SHA-256 hex digest, concatenate them
all, then SHA-256 the concatenation.
"""
parts = []
for filepath in input_files:
if not Path(filepath).exists():
print(f"Error: input file not found: {filepath}", file=sys.stderr)
raise FileNotFoundError(f"Input file not found: {filepath}")
file_hash = hashlib.sha256(Path(filepath).read_bytes()).hexdigest()
parts.append(file_hash)
combined = "".join(parts)
return hashlib.sha256(combined.encode()).hexdigest()
def read_stamp_hash(stamp_file: str) -> str:
"""Read the COMBINED_HASH from an existing stamp file, or '' if missing."""
path = Path(stamp_file)
if not path.exists():
return ""
for line in path.read_text(encoding="utf-8").splitlines():
if line.startswith("COMBINED_HASH="):
return line.split("=", 1)[1]
return ""
def main():
if len(sys.argv) < 4 or sys.argv[1] not in ("--check", "--update"):
print(
f"Usage: {sys.argv[0]} --check|--update <stamp_file> <input_files...>",
file=sys.stderr,
)
sys.exit(2)
mode = sys.argv[1]
stamp_file = sys.argv[2]
input_files = sys.argv[3:]
current_hash = compute_combined_hash(input_files)
if mode == "--check":
stamp_hash = read_stamp_hash(stamp_file)
if current_hash == stamp_hash:
sys.exit(0)
else:
sys.exit(1)
# --update
with open(stamp_file, "w", encoding="utf-8") as fp:
fp.write(
"# Auto-generated by protocol autogen - do not edit manually.\n"
"# This file tracks input hashes to avoid unnecessary code regeneration.\n"
"# It should be checked into version control alongside the generated files.\n"
)
fp.write(f"COMBINED_HASH={current_hash}\n")
if __name__ == "__main__":
main()

View File

@@ -138,11 +138,28 @@ def main():
"--sfields-macro",
help="Path to sfields.macro (default: auto-detect from macro_path)",
)
parser.add_argument(
"--list-outputs",
action="store_true",
help="List output files without generating (one per line)",
)
args = parser.parse_args()
# Parse the macro file to get ledger entry names
entries = parse_macro_file(args.macro_path)
# If --list-outputs, just print the output file paths and exit
if args.list_outputs:
header_dir = Path(args.header_dir)
for entry in entries:
print(header_dir / f"{entry['name']}.h")
if args.test_dir:
test_dir = Path(args.test_dir)
for entry in entries:
print(test_dir / f"{entry['name']}Tests.cpp")
return
# Auto-detect sfields.macro path if not provided
if args.sfields_macro:
sfields_path = Path(args.sfields_macro)

View File

@@ -147,11 +147,28 @@ def main():
"--sfields-macro",
help="Path to sfields.macro (default: auto-detect from macro_path)",
)
parser.add_argument(
"--list-outputs",
action="store_true",
help="List output files without generating (one per line)",
)
args = parser.parse_args()
# Parse the macro file to get transaction names
transactions = parse_macro_file(args.macro_path)
# If --list-outputs, just print the output file paths and exit
if args.list_outputs:
header_dir = Path(args.header_dir)
for tx in transactions:
print(header_dir / f"{tx['name']}.h")
if args.test_dir:
test_dir = Path(args.test_dir)
for tx in transactions:
print(test_dir / f"{tx['name']}Tests.cpp")
return
# Auto-detect sfields.macro path if not provided
if args.sfields_macro:
sfields_path = Path(args.sfields_macro)

View File

@@ -1155,57 +1155,86 @@ rippleSendMultiMPT(
beast::Journal j,
WaiveTransferFee waiveFee)
{
// Safe to get MPT since rippleSendMultiMPT is only called by
// accountSendMultiMPT
auto const& issuer = mptIssue.getIssuer();
auto const sle = view.read(keylet::mptIssuance(mptIssue.getMptID()));
if (!sle)
return tecOBJECT_NOT_FOUND;
// These may diverge
// For the issuer-as-sender case, track the running total to validate
// against MaximumAmount. The read-only SLE (view.read) is not updated
// by rippleCreditMPT, so a per-iteration SLE read would be stale.
// Use int64_t, not STAmount, to keep MaximumAmount comparisons in exact
// integer arithmetic. STAmount implicitly converts to Number, whose
// small-scale mantissa (~16 digits) can lose precision for values near
// maxMPTokenAmount (19 digits).
std::uint64_t totalSendAmount{0};
auto const maximumAmount = sle->at(~sfMaximumAmount).value_or(maxMPTokenAmount);
auto const outstandingAmount = sle->getFieldU64(sfOutstandingAmount);
// actual accumulates the total cost to the sender (includes transfer
// fees for third-party transit sends). takeFromSender accumulates only
// the transit portion that is debited to the issuer in bulk after the
// loop. They diverge when there are transfer fees.
STAmount takeFromSender{mptIssue};
actual = takeFromSender;
for (auto const& r : receivers)
for (auto const& [receiverID, amt] : receivers)
{
auto const& receiverID = r.first;
STAmount amount{mptIssue, r.second};
STAmount const amount{mptIssue, amt};
if (amount < beast::zero)
{
return tecINTERNAL; // LCOV_EXCL_LINE
}
/* If we aren't sending anything or if the sender is the same as the
* receiver then we don't need to do anything.
*/
if (!amount || (senderID == receiverID))
if (!amount || senderID == receiverID)
continue;
if (senderID == issuer || receiverID == issuer)
{
// if sender is issuer, check that the new OutstandingAmount will
// not exceed MaximumAmount
if (senderID == issuer)
{
XRPL_ASSERT_PARTS(
takeFromSender == beast::zero,
"xrpl::rippleSendMultiMPT",
"sender == issuer, takeFromSender == zero");
auto const sendAmount = amount.mpt().value();
auto const maximumAmount = sle->at(~sfMaximumAmount).value_or(maxMPTokenAmount);
if (sendAmount > maximumAmount ||
sle->getFieldU64(sfOutstandingAmount) > maximumAmount - sendAmount)
return tecPATH_DRY;
if (view.rules().enabled(fixSecurity3_1_3))
{
// Post-fixSecurity3_1_3: aggregate MaximumAmount
// check. Each condition guards the subtraction
// in the next to prevent underflow.
auto const exceedsMaximumAmount =
// This send alone exceeds the max cap
sendAmount > maximumAmount ||
// The aggregate of all sends exceeds the max cap
totalSendAmount > maximumAmount - sendAmount ||
// Outstanding + aggregate exceeds the max cap
outstandingAmount > maximumAmount - sendAmount - totalSendAmount;
if (exceedsMaximumAmount)
return tecPATH_DRY;
totalSendAmount += sendAmount;
}
else
{
// Pre-fixSecurity3_1_3: per-iteration MaximumAmount
// check. Reads sfOutstandingAmount from a stale
// view.read() snapshot — incorrect for multi-destination
// sends but retained for ledger replay compatibility.
if (sendAmount > maximumAmount ||
outstandingAmount > maximumAmount - sendAmount)
return tecPATH_DRY;
}
}
// Direct send: redeeming MPTs and/or sending own MPTs.
if (auto const ter = rippleCreditMPT(view, senderID, receiverID, amount, j))
return ter;
actual += amount;
// Do not add amount to takeFromSender, because rippleCreditMPT took
// it
// Do not add amount to takeFromSender, because rippleCreditMPT
// took it.
continue;
}

View File

@@ -6,6 +6,7 @@
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
@@ -3272,6 +3273,93 @@ class MPToken_test : public beast::unit_test::suite
mptAlice.claw(alice, bob, 1, tecNO_PERMISSION);
}
void
testMultiSendMaximumAmount(FeatureBitset features)
{
// Verify that rippleSendMultiMPT correctly enforces MaximumAmount
// when the issuer sends to multiple receivers. Pre-fixSecurity3_1_3,
// a stale view.read() snapshot caused per-iteration checks to miss
// aggregate overflows. Post-fix, a running total is used instead.
testcase("Multi-send MaximumAmount enforcement");
using namespace test::jtx;
Account const issuer("issuer");
Account const alice("alice");
Account const bob("bob");
std::uint64_t constexpr maxAmt = 150;
Env env{*this, features};
MPTTester mptt(env, issuer, {.holders = {alice, bob}});
mptt.create({.maxAmt = maxAmt, .ownerCount = 1, .flags = tfMPTCanTransfer});
mptt.authorize({.account = alice});
mptt.authorize({.account = bob});
Asset const asset{MPTIssue{mptt.issuanceID()}};
// Each test case creates a fresh ApplyView and calls
// accountSendMulti from the issuer to the given receivers.
auto const runTest = [&](MultiplePaymentDestinations const& receivers,
TER expectedTer,
std::optional<std::uint64_t> expectedOutstanding,
std::string const& label) {
ApplyViewImpl av(&*env.current(), tapNONE);
auto const ter =
accountSendMulti(av, issuer.id(), asset, receivers, env.app().getJournal("View"));
BEAST_EXPECTS(ter == expectedTer, label);
// Only verify OutstandingAmount on success — on error the
// view may contain partial state and must be discarded.
if (expectedOutstanding)
{
auto const sle = av.peek(keylet::mptIssuance(mptt.issuanceID()));
if (!BEAST_EXPECT(sle))
return;
BEAST_EXPECTS(sle->getFieldU64(sfOutstandingAmount) == *expectedOutstanding, label);
}
};
using R = MultiplePaymentDestinations;
// Post-amendment: aggregate check with running total
runTest(
R{{alice.id(), 100}, {bob.id(), 100}},
tecPATH_DRY,
std::nullopt,
"aggregate exceeds max");
runTest(R{{alice.id(), 75}, {bob.id(), 75}}, tesSUCCESS, maxAmt, "aggregate at boundary");
runTest(R{{alice.id(), 50}, {bob.id(), 50}}, tesSUCCESS, 100, "aggregate within limit");
runTest(
R{{alice.id(), 150}, {bob.id(), 0}},
tesSUCCESS,
maxAmt,
"one receiver at max, other zero");
runTest(
R{{alice.id(), 151}, {bob.id(), 0}},
tecPATH_DRY,
std::nullopt,
"one receiver exceeds max, other zero");
// Pre-amendment: the stale per-iteration check allows each
// individual send (100 <= 150) even though the aggregate (200)
// exceeds MaximumAmount. Preserved for ledger replay.
{
// KNOWN BUG (pre-fixSecurity3_1_3): preserved for ledger replay only
env.disableFeature(fixSecurity3_1_3);
runTest(
R{{alice.id(), 100}, {bob.id(), 100}},
tesSUCCESS,
200,
"pre-amendment allows over-send");
env.enableFeature(fixSecurity3_1_3);
}
}
public:
void
run() override
@@ -3279,6 +3367,7 @@ public:
using namespace test::jtx;
FeatureBitset const all{testable_amendments()};
testMultiSendMaximumAmount(all);
// MPTokenIssuanceCreate
testCreateValidation(all - featureSingleAssetVault);
testCreateValidation(all - featurePermissionedDomains);

View File

@@ -32,11 +32,20 @@ xrpl_add_test(json)
target_link_libraries(xrpl.test.json PRIVATE xrpl.imports.test)
add_dependencies(xrpl.tests xrpl.test.json)
# protocol_autogen tests — sources are checked into git so GLOB works.
# Code generation runs at configure time when inputs change.
xrpl_add_test(protocol_autogen)
# protocol_autogen tests use explicit source list (not GLOB) because sources are generated
# Mark generated sources so CMake knows they'll be created at build time
set_source_files_properties(
${PROTOCOL_AUTOGEN_TEST_SOURCES}
PROPERTIES GENERATED TRUE
)
add_executable(xrpl.test.protocol_autogen ${PROTOCOL_AUTOGEN_TEST_SOURCES})
target_link_libraries(xrpl.test.protocol_autogen PRIVATE xrpl.imports.test)
add_dependencies(xrpl.tests xrpl.test.protocol_autogen)
add_test(NAME xrpl.test.protocol_autogen COMMAND xrpl.test.protocol_autogen)
# Ensure code generation runs before compiling tests
if(TARGET protocol_autogen_generate)
add_dependencies(xrpl.test.protocol_autogen protocol_autogen_generate)
endif()
# Network unit tests are currently not supported on Windows
if(NOT WIN32)