diff --git a/.cspell.config.yaml b/.cspell.config.yaml index e3764fd2a2..21b0145f43 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -132,6 +132,7 @@ words: - godexsoft - gpgcheck - gpgkey + - Hinnant - hotwallet - hwaddress - hwrap @@ -165,6 +166,7 @@ words: - llection - LOCALGOOD - logwstream + - Lombrozo - lseq - lsmf - ltype @@ -201,6 +203,7 @@ words: - nftokens - nftpage - nikb + - Nikolaos - nixfmt - nixos - nixpkgs diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index 3797e5881d..47c7593892 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -143,7 +143,8 @@ class MatrixEntry: class PackagingEntry: """One entry in the generated packaging strategy matrix.""" - artifact_name: str + xrpld_artifact_name: str + validator_keys_artifact_name: str image: str distro: str # e.g. "debian" or "rhel"; drives package-format-specific steps @@ -218,14 +219,19 @@ def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]: the nix-based build images, because deb/rpm tooling (debhelper, rpm-build) is taken from the distro's archive rather than from nixpkgs. Each config entry carries its own 'image'. + + The artifact names must match what the build job uploads: one artifact per + binary, each named after the build config. """ entries = [] for distro, configs in linux.package_configs.items(): for cfg in configs: for compiler, build_type in itertools.product(cfg.compiler, cfg.build_type): + config_name = f"{distro}-{compiler}-{build_type.lower()}-amd64" entries.append( PackagingEntry( - artifact_name=f"xrpld-{distro}-{compiler}-{build_type.lower()}-amd64", + xrpld_artifact_name=f"xrpld-{config_name}", + validator_keys_artifact_name=f"validator-keys-{config_name}", image=cfg.image, distro=distro, ) diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 159c76b6c2..33146cff3b 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -70,7 +70,8 @@ "compiler": ["gcc"], "build_type": ["Release"], "arch": ["amd64"], - "minimal": false + "minimal": false, + "extra_cmake_args": "-Dvalidator_keys=ON" } ], @@ -79,7 +80,8 @@ "compiler": ["gcc"], "build_type": ["Release"], "arch": ["amd64"], - "minimal": false + "minimal": false, + "extra_cmake_args": "-Dvalidator_keys=ON" } ] }, diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index 548fde8b5e..d8550efc4c 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -106,9 +106,10 @@ jobs: # header files are copied into separate directories by CMake, which will # otherwise result in cache misses. CCACHE_SLOPPINESS: include_file_ctime,include_file_mtime - # Determine if coverage and voidstar should be enabled. + # Determine if coverage, voidstar and validator-keys should be enabled. COVERAGE_ENABLED: ${{ contains(inputs.cmake_args, '-Dcoverage=ON') }} VOIDSTAR_ENABLED: ${{ contains(inputs.cmake_args, '-Dvoidstar=ON') }} + VALIDATOR_KEYS_ENABLED: ${{ contains(inputs.cmake_args, '-Dvalidator_keys=ON') }} SANITIZERS_ENABLED: ${{ inputs.sanitizers != '' }} steps: - name: Cleanup workspace (macOS and Windows) @@ -229,6 +230,22 @@ jobs: retention-days: 3 if-no-files-found: error + - name: Run the validator-keys tests + if: ${{ env.VALIDATOR_KEYS_ENABLED == 'true' }} + working-directory: ${{ env.BUILD_DIR }} + run: ./validator-keys --unittest + + - name: Upload the validator-keys binary + if: ${{ github.event.repository.visibility == 'public' && env.VALIDATOR_KEYS_ENABLED == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: validator-keys-${{ inputs.config_name }} + path: | + ${{ env.BUILD_DIR }}/validator-keys + ${{ env.BUILD_DIR }}/validator-keys-LICENSE + retention-days: 3 + if-no-files-found: error + - name: Upload the test binary (Linux) if: ${{ github.event.repository.visibility == 'public' && runner.os == 'Linux' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -370,7 +387,7 @@ jobs: --target coverage - name: Upload coverage report - if: ${{ github.repository == 'XRPLF/rippled' && !inputs.build_only && env.COVERAGE_ENABLED == 'true' }} + if: ${{ github.repository_owner == 'XRPLF' && !inputs.build_only && env.COVERAGE_ENABLED == 'true' }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: disable_search: true diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index e1c11ac677..b45cae52d9 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -1,7 +1,7 @@ -# Build Linux packages (DEB and RPM) from pre-built binary artifacts. -# Discovers which configurations to package from linux.json (configs in -# "package_configs") and fans out one job per distro. Only linux/amd64 is -# supported; the runner is hardcoded in the job below. +# Build Linux packages (DEB and RPM) from pre-built binary artifacts (xrpld and +# validator-keys). Discovers which configurations to package from linux.json +# (configs in "package_configs") and fans out one job per distro. Only +# linux/amd64 is supported; the runner is hardcoded in the job below. name: Package on: @@ -45,7 +45,7 @@ jobs: strategy: fail-fast: false matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} - name: "${{ matrix.artifact_name }}" + name: "${{ matrix.xrpld_artifact_name }}" permissions: contents: read runs-on: ["self-hosted", "Linux", "X64", "heavy"] @@ -56,14 +56,20 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Download pre-built binary + - name: Download pre-built xrpld binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ${{ matrix.artifact_name }} + name: ${{ matrix.xrpld_artifact_name }} path: ${{ env.BUILD_DIR }} - - name: Make binary executable - run: chmod +x "${BUILD_DIR}/xrpld" + - name: Download pre-built validator-keys binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.validator_keys_artifact_name }} + path: ${{ env.BUILD_DIR }} + + - name: Make binaries executable + run: chmod +x "${BUILD_DIR}/xrpld" "${BUILD_DIR}/validator-keys" - name: Build package env: @@ -73,7 +79,7 @@ jobs: - name: Upload package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: ${{ matrix.artifact_name }}-pkg + name: ${{ matrix.xrpld_artifact_name }}-pkg path: | ${{ env.BUILD_DIR }}/debbuild/*.deb ${{ env.BUILD_DIR }}/debbuild/*.ddeb diff --git a/CMakeLists.txt b/CMakeLists.txt index b7e1c0cad0..efe7396661 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -161,8 +161,10 @@ endif() include(XrplCore) include(XrplProtocolAutogen) include(XrplInstall) -include(XrplPackaging) include(XrplValidatorKeys) +# Must come after XrplValidatorKeys: the 'package' target depends on the +# validator-keys target existing. +include(XrplPackaging) if(tests) include(CTest) diff --git a/cfg/xrpld-example.cfg b/cfg/xrpld-example.cfg index 9e334e6f4f..747bafe077 100644 --- a/cfg/xrpld-example.cfg +++ b/cfg/xrpld-example.cfg @@ -488,6 +488,17 @@ # Must be a number between 100 and 1000, defaults to 250 # # +# [max_subscriptions_per_connection] +# +# Maximum number of account, real-time account, and account-history +# subscriptions a single client connection may hold at once. Bounds the +# per-connection state torn down when the connection disconnects. Book +# subscriptions are tracked separately and are not counted here. +# +# Defaults to 100000 if not set; large enough for legitimate power users +# such as block explorers. +# +# # [overlay] # # Controls settings related to the peer to peer overlay. @@ -538,6 +549,45 @@ # only be used for local testing and debugging. Do not disable # on mainnet. # +# max_untrusted_count = +# +# The number of manifests the server keeps for validators it does not +# list, and the number it sends and processes in a single peer protocol +# message. Once the server holds this many, a manifest for a new +# unlisted validator is rejected, so peer gossip cannot grow the cache +# without end. +# +# This option can take any value between 50 and 1000, inclusive. If +# the option is not present the server uses its built-in value. +# +# The current default (which is subject to change) is 300. +# +# max_trusted_count = +# +# The number of manifests for listed validators to allow for when +# sizing peer protocol messages. Manifests for listed validators are +# never dropped, whether sending or receiving, because doing so would +# delay a validator key change reaching this server. Set this above the +# number of validators the server lists. +# +# Together the two counts above set the largest manifest message the +# server accepts: bigger messages are discarded without reading them, +# and without penalising the sender. Raising either means the server +# accepts and sends bigger messages than a peer using the defaults, and +# those peers will discard what this server sends. Lowering either below +# what peers send makes this server discard their manifest messages, +# which it does without recording anything. +# +# This option can take any value between 50 and 1000, inclusive. If +# the option is not present the server uses its built-in value. +# +# The current default (which is subject to change) is 300. +# +# NOTE: These two options (max_untrusted_count and max_trusted_count) +# are transitional. They exist to bound manifest-message size and cache +# growth during the network upgrade. They may be removed in a future +# release once the fleet has upgraded, and should not be relied upon as +# stable configuration. # # [transaction_queue] EXPERIMENTAL # diff --git a/cmake/PatchNixBinary.cmake b/cmake/PatchNixBinary.cmake index 2490416f1f..05d923b74e 100644 --- a/cmake/PatchNixBinary.cmake +++ b/cmake/PatchNixBinary.cmake @@ -2,9 +2,10 @@ Patch executables to run in non-Nix environments. The Nix toolchain links binaries against an ELF interpreter (loader) - that lives in the Nix store, so the resulting binaries don't run elsewhere. - `patch_nix_binary` adds a POST_BUILD step that resets the interpreter - to the system default loader and drops the rpath. + that lives in the Nix store, so the resulting binaries don't run elsewhere + (including once installed from the .deb package). `patch_nix_binary` resets + the interpreter to the system default loader and drops the rpath, once the + binary has been linked. This runs by default for Nix-toolchain builds (determined by whether the compiler resolves under /nix/store/). Those builds are where binaries get a Nix-store loader. @@ -52,13 +53,38 @@ function(patch_nix_binary target) if(NOT PATCH_NIX_BINARIES) return() endif() - add_custom_command( - TARGET ${target} - POST_BUILD - COMMAND - "${PATCHELF_COMMAND}" --set-interpreter "${DEFAULT_LOADER_PATH}" - --remove-rpath "$" - COMMENT "Patching ${target}: set default loader, remove rpath" - VERBATIM + + set(patch_command + "${PATCHELF_COMMAND}" + --set-interpreter + "${DEFAULT_LOADER_PATH}" + --remove-rpath + "$" ) + set(comment "Patching ${target}: set default loader, remove rpath") + + # POST_BUILD is the cheap way to do this: it runs only when the binary is + # relinked. It is also only available in the directory that defined the + # target, so for a target from elsewhere (e.g. a FetchContent subproject) + # fall back to a custom target that runs after the binary is linked. That + # one runs on every build, which is harmless because patchelf is idempotent. + get_target_property(target_source_dir ${target} SOURCE_DIR) + if("${target_source_dir}" STREQUAL "${CMAKE_CURRENT_SOURCE_DIR}") + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND ${patch_command} + COMMENT "${comment}" + VERBATIM + ) + else() + add_custom_target( + ${target}-patch-nix + ALL + COMMAND ${patch_command} + COMMENT "${comment}" + VERBATIM + ) + add_dependencies(${target}-patch-nix ${target}) + endif() endfunction() diff --git a/cmake/XrplPackaging.cmake b/cmake/XrplPackaging.cmake index 8e3861925d..bee7b15791 100644 --- a/cmake/XrplPackaging.cmake +++ b/cmake/XrplPackaging.cmake @@ -25,6 +25,19 @@ if(NOT (RPMBUILD_EXECUTABLE OR DPKG_BUILDPACKAGE_EXECUTABLE)) return() endif() +if(NOT TARGET xrpld) + message(STATUS "xrpld=ON is required; 'package' target not available") + return() +endif() + +if(NOT TARGET validator-keys) + message( + STATUS + "validator_keys=ON is required; 'package' target not available" + ) + return() +endif() + set(package_env SRC_DIR=${CMAKE_SOURCE_DIR} BUILD_DIR=${CMAKE_BINARY_DIR} @@ -37,7 +50,7 @@ add_custom_target( ${CMAKE_COMMAND} -E env ${package_env} ${CMAKE_SOURCE_DIR}/package/build_pkg.sh WORKING_DIRECTORY ${CMAKE_BINARY_DIR} - DEPENDS xrpld + DEPENDS xrpld validator-keys COMMENT "Building Linux package (deb/rpm inferred from host tooling)" VERBATIM ) diff --git a/cmake/XrplValidatorKeys.cmake b/cmake/XrplValidatorKeys.cmake index 0e511b6a88..0acaed1a56 100644 --- a/cmake/XrplValidatorKeys.cmake +++ b/cmake/XrplValidatorKeys.cmake @@ -5,22 +5,39 @@ option( ) if(validator_keys) - git_branch(current_branch) - # default to tracking VK master branch unless we are on release - if(NOT (current_branch STREQUAL "release")) - set(current_branch "master") - endif() - message(STATUS "Tracking ValidatorKeys branch: ${current_branch}") + # Own the install destination below rather than relying on another module + # having pulled this in first. + include(GNUInstallDirs) + + # Pinned to an exact commit, not a branch: the tool ships inside our + # packages, so the same xrpld version must always package the same + # validator-keys. Bump this deliberately. + set(validator_keys_commit "4c0fb75eec9601c711645998c904507e87e910ae") + message(STATUS "Using ValidatorKeys commit: ${validator_keys_commit}") FetchContent_Declare( validator_keys GIT_REPOSITORY https://github.com/ripple/validator-keys-tool.git - GIT_TAG "${current_branch}" + GIT_TAG "${validator_keys_commit}" ) FetchContent_MakeAvailable(validator_keys) + # The tool's own CMakeLists excludes the target from 'all' when it is built + # as a subproject. Undo that, so validator_keys=ON really does build it. set_target_properties( validator-keys - PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + EXCLUDE_FROM_ALL OFF + EXCLUDE_FROM_DEFAULT_BUILD OFF + ) + # We ship this binary, so like xrpld it must not keep the Nix store's ELF + # loader, or it cannot run on the target distro at all. + patch_nix_binary(validator-keys) + + configure_file( + "${validator_keys_SOURCE_DIR}/LICENSE" + "${CMAKE_BINARY_DIR}/validator-keys-LICENSE" + COPYONLY ) install(TARGETS validator-keys RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) endif() diff --git a/conan.lock b/conan.lock index 0e1461ba9c..5b01ffbf76 100644 --- a/conan.lock +++ b/conan.lock @@ -12,7 +12,7 @@ "protobuf/6.33.5#ff253ead763bd8d9904a52979cd21e81%1782392410.233933", "openssl/3.6.3#f806de8933e3bf6f01016c6a888cee2e%1783945160.863288", "nudb/2.0.9#11149c73f8f2baff9a0198fe25971fc7%1782392402.297166", - "mpt-crypto/0.4.0-rc4#ffdba12f2332357f0d8b0ae944cfff52%1784138702.932355", + "mpt-crypto/1.0.2#b313cef0c1a493eb970ad185b2e9bab7%1784285108.866483", "lz4/1.10.0#982d9b673900f665a1da109e09c17cab%1782392402.164188", "libiconv/1.17#9923bc6dc6f106646d6967e0039a5ada%1782392792.775744", "libbacktrace/cci.20210118#a7691bfccd8caaf66309df196790a5a1%1782392402.420732", diff --git a/conanfile.py b/conanfile.py index b9fa513d67..2742405b6c 100644 --- a/conanfile.py +++ b/conanfile.py @@ -139,7 +139,7 @@ class Xrpl(ConanFile): if self.options.jemalloc: self.requires("jemalloc/5.3.1") self.requires("lz4/1.10.0", force=True) - self.requires("mpt-crypto/0.4.0-rc4", transitive_headers=True) + self.requires("mpt-crypto/1.0.2", transitive_headers=True) self.requires("protobuf/6.33.5", force=True) if self.options.rocksdb: self.requires("rocksdb/10.5.1") diff --git a/include/xrpl/basics/base64.h b/include/xrpl/basics/base64.h index 24fd660e65..30fdc1f118 100644 --- a/include/xrpl/basics/base64.h +++ b/include/xrpl/basics/base64.h @@ -41,6 +41,35 @@ namespace xrpl { +namespace base64 { + +/** + * Returns the maximum number of characters needed to base64-encode @p nBytes bytes. + * + * @param nBytes Number of input bytes. + * @return Size of the encoded string, including padding. + */ +constexpr std::size_t +encodedSize(std::size_t const nBytes) +{ + return 4 * ((nBytes + 2) / 3); +} + +/** + * Returns the maximum number of bytes a base64 string of @p numChars characters + * decodes to. + * + * @param numChars Number of base64 characters. + * @return Upper bound on the number of decoded bytes. + */ +constexpr std::size_t +decodedSize(std::size_t const numChars) +{ + return ((numChars / 4) * 3) + 2; +} + +} // namespace base64 + std::string base64Encode(std::uint8_t const* data, std::size_t len); diff --git a/include/xrpl/config/Constants.h b/include/xrpl/config/Constants.h index 5514e0e77b..85d9e3f147 100644 --- a/include/xrpl/config/Constants.h +++ b/include/xrpl/config/Constants.h @@ -25,6 +25,7 @@ struct Sections static constexpr auto kLedgerHistory = "ledger_history"; static constexpr auto kLedgerReplay = "ledger_replay"; static constexpr auto kLedgerTxTables = "ledger_tx_tables"; + static constexpr auto kMaxSubscriptionsPerConnection = "max_subscriptions_per_connection"; static constexpr auto kMaxTransactions = "max_transactions"; static constexpr auto kNetworkId = "network_id"; static constexpr auto kNetworkQuorum = "network_quorum"; @@ -118,7 +119,9 @@ struct Keys static constexpr auto kLogInterval = "log_interval"; static constexpr auto kMaxDivergedTime = "max_diverged_time"; static constexpr auto kMaxLedgerCountsToStore = "max_ledger_counts_to_store"; + static constexpr auto kMaxTrustedCount = "max_trusted_count"; static constexpr auto kMaxUnknownTime = "max_unknown_time"; + static constexpr auto kMaxUntrustedCount = "max_untrusted_count"; static constexpr auto kMaximumTxnInLedger = "maximum_txn_in_ledger"; static constexpr auto kMaximumTxnPerAccount = "maximum_txn_per_account"; static constexpr auto kMemoryLevel = "memory_level"; diff --git a/include/xrpl/consensus/Consensus.h b/include/xrpl/consensus/Consensus.h index f9d5f7ef02..4c48e7f268 100644 --- a/include/xrpl/consensus/Consensus.h +++ b/include/xrpl/consensus/Consensus.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -1579,7 +1580,13 @@ Consensus::updateOurPositions(std::unique_ptr const& JLOG(j_.info()) << ss.str(); CLOG(clog) << ss.str(); - for (auto const& [t, v] : closeTimeVotes) + // Walk the votes highest-time first so that, among close times tied + // for the most votes, the earliest wins. The smaller value is the + // safer choice: without close-time consensus this round, the winner + // only updates our position for the next proposal, and a too-early + // time is bounded below by the prior ledger's close time. Only the + // tie-break changes; the bin with the most votes still wins. + for (auto const& [t, v] : std::views::reverse(closeTimeVotes)) { JLOG(j_.debug()) << "CCTime: seq " << static_cast(previousLedger_.seq()) + 1 << ": " diff --git a/include/xrpl/consensus/ConsensusTypes.h b/include/xrpl/consensus/ConsensusTypes.h index 4dac2d9912..56739527a1 100644 --- a/include/xrpl/consensus/ConsensusTypes.h +++ b/include/xrpl/consensus/ConsensusTypes.h @@ -8,7 +8,9 @@ #include #include +#include #include +#include #include namespace xrpl { @@ -189,6 +191,75 @@ struct ConsensusCloseTimes NetClock::time_point self; }; +/** + * Offset of the network's close time relative to ours, using a weighted median. + * + * Treats the sample set as `{self x 1}` merged with `{t x w}` for each + * `(t, w)` in `times.peers`, in time order, and returns `(median - self)` + * in whole seconds. Uses the lower weighted median: the median is the + * earliest time at which the running weight reaches half the total, so an + * even total whose halfway point falls between two bins resolves to the + * earlier bin. + * + * @param times Our own close time and the weighted close times of peers. + * @return Weighted median of all close times minus our own, in whole seconds. + */ +inline std::chrono::seconds +medianCloseOffset(ConsensusCloseTimes const& times) +{ + using namespace std::chrono; + using time_point = NetClock::time_point; + + std::int64_t totalWeight = 1; + for (auto const& [_, w] : times.peers) + totalWeight += w; + + std::int64_t const halfWeight = (totalWeight + 1) / 2; + + std::optional median{}; + std::int64_t tally = 0; + bool selfPlaced = false; + + // Accumulate weight in time order; the first bin to reach halfWeight is + // the (lower) weighted median. Returns true once that bin is found. + auto step = [&](time_point t, std::int64_t w) { + XRPL_ASSERT(tally < halfWeight, "xrpl::medianCloseOffset::step : median not yet found"); + tally += w; + if (tally >= halfWeight) + { + median = t; + return true; + } + return false; + }; + + for (auto const& [t, w] : times.peers) + { + if (!selfPlaced && times.self <= t) + { + selfPlaced = true; + if (step(times.self, 1)) + break; + } + if (step(t, w)) + break; + } + if (!selfPlaced && !median) + step(times.self, 1); + + if (!median) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::medianCloseOffset : median not found"); + median = times.self; + // LCOV_EXCL_STOP + } + + return duration_cast( + duration{median->time_since_epoch().count()} - + duration{times.self.time_since_epoch().count()}); +} + /** * Whether we have or don't have a consensus */ diff --git a/include/xrpl/proto/xrpl.proto b/include/xrpl/proto/xrpl.proto index bef5ec1d76..b9cb94e668 100644 --- a/include/xrpl/proto/xrpl.proto +++ b/include/xrpl/proto/xrpl.proto @@ -301,14 +301,15 @@ message TMLedgerData { } message TMPing { + // Previously used - don't reuse. + reserved 3, 4; + enum pingType { ptPING = 0; // we want a reply ptPONG = 1; // this is a reply } required pingType type = 1; - optional uint32 seq = 2; // detect stale replies, ensure other side is reading - optional uint64 pingTime = 3; // know when we think we sent the ping - optional uint64 netTime = 4; + optional uint32 seq = 2; // detect stale replies, ensure other side is reading } message TMSquelch { diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index 7c504f6bdd..68205e27e6 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -190,17 +190,6 @@ enum LedgerEntryType : std::uint16_t { LSF_FLAG(lsfMPTCanClawback, 0x00000040) \ LSF_FLAG(lsfMPTCanHoldConfidentialBalance, 0x00000080)) \ \ - LEDGER_OBJECT(MPTokenIssuanceMutable, \ - LSF_FLAG(lsmfMPTCanEnableCanLock, 0x00000002) \ - LSF_FLAG(lsmfMPTCanEnableRequireAuth, 0x00000004) \ - LSF_FLAG(lsmfMPTCanEnableCanEscrow, 0x00000008) \ - LSF_FLAG(lsmfMPTCanEnableCanTrade, 0x00000010) \ - LSF_FLAG(lsmfMPTCanEnableCanTransfer, 0x00000020) \ - LSF_FLAG(lsmfMPTCanEnableCanClawback, 0x00000040) \ - LSF_FLAG(lsmfMPTCannotEnableCanHoldConfidentialBalance, 0x00000080) \ - LSF_FLAG(lsmfMPTCanMutateMetadata, 0x00010000) \ - LSF_FLAG(lsmfMPTCanMutateTransferFee, 0x00020000)) \ - \ LEDGER_OBJECT(MPToken, \ LSF_FLAG2(lsfMPTLocked, 0x00000001) \ LSF_FLAG(lsfMPTAuthorized, 0x00000002) \ @@ -294,6 +283,17 @@ getAllLedgerFlags() #pragma pop_macro("TO_MAP") #pragma pop_macro("ALL_LEDGER_FLAGS") +// MPTokenIssuance ImmutableFlags (sfImmutableFlags) +inline constexpr std::uint32_t lsifMPTCanLock = 0x00000002; +inline constexpr std::uint32_t lsifMPTRequireAuth = 0x00000004; +inline constexpr std::uint32_t lsifMPTCanEscrow = 0x00000008; +inline constexpr std::uint32_t lsifMPTCanTrade = 0x00000010; +inline constexpr std::uint32_t lsifMPTCanTransfer = 0x00000020; +inline constexpr std::uint32_t lsifMPTCanClawback = 0x00000040; +inline constexpr std::uint32_t lsifMPTCanHoldConfidentialBalance = 0x00000080; +inline constexpr std::uint32_t lsifMPTMetadata = 0x00010000; +inline constexpr std::uint32_t lsifMPTTransferFee = 0x00020000; + //------------------------------------------------------------------------------ /** diff --git a/include/xrpl/protocol/STObject.h b/include/xrpl/protocol/STObject.h index ad87d106c4..c7fc4fa796 100644 --- a/include/xrpl/protocol/STObject.h +++ b/include/xrpl/protocol/STObject.h @@ -90,7 +90,11 @@ public: operator=(STObject&& other); STObject(SOTemplate const& type, SField const& name); - STObject(SOTemplate const& type, SerialIter& sit, SField const& name); + STObject( + SOTemplate const& type, + SerialIter& sit, + SField const& name, + bool requireCanonicalOrder = false); STObject(SerialIter& sit, SField const& name, int depth = 0); STObject(SerialIter&& sit, SField const& name); explicit STObject(SField const& name); @@ -123,7 +127,7 @@ public: set(SOTemplate const&); bool - set(SerialIter& u, int depth = 0); + set(SerialIter& u, int depth = 0, bool requireCanonicalOrder = false); [[nodiscard]] SerializedTypeID getSType() const override; diff --git a/include/xrpl/protocol/STPathSet.h b/include/xrpl/protocol/STPathSet.h index 23f4e653c4..d527e2479f 100644 --- a/include/xrpl/protocol/STPathSet.h +++ b/include/xrpl/protocol/STPathSet.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -108,6 +109,9 @@ public: [[nodiscard]] bool isType(Type const& pe) const; + [[nodiscard]] size_t + getHash() const; + bool operator==(STPathElement const& t) const; @@ -171,12 +175,23 @@ public: reserve(size_t s); }; +template +void +hash_append(Hasher& h, STPath const& p) noexcept +{ + for (auto const& e : p) + { + beast::hash_append(h, e.getHash()); + } +} + //------------------------------------------------------------------------------ // A set of zero or more payment paths class STPathSet final : public STBase, public CountedObject { std::vector value_; + xrpl::hardened_hash_set seenHashes_; public: STPathSet() = default; @@ -205,9 +220,6 @@ public: std::vector::const_reference operator[](std::vector::size_type n) const; - std::vector::reference - operator[](std::vector::size_type n); - [[nodiscard]] std::vector::const_iterator begin() const; @@ -227,6 +239,9 @@ public: void emplaceBack(Args&&... args); + [[nodiscard]] bool + contains(STPath const& path) const; + private: STBase* copy(std::size_t n, void* buf) const override; @@ -515,12 +530,6 @@ STPathSet::operator[](std::vector::size_type n) const return value_[n]; } -inline std::vector::reference -STPathSet::operator[](std::vector::size_type n) -{ - return value_[n]; -} - inline std::vector::const_iterator STPathSet::begin() const { @@ -549,6 +558,7 @@ inline void STPathSet::pushBack(STPath const& e) { value_.push_back(e); + seenHashes_.emplace(value_.back()); } template @@ -556,6 +566,13 @@ inline void STPathSet::emplaceBack(Args&&... args) { value_.emplace_back(std::forward(args)...); + seenHashes_.emplace(value_.back()); +} + +inline bool +STPathSet::contains(STPath const& path) const +{ + return seenHashes_.contains(path); } } // namespace xrpl diff --git a/include/xrpl/protocol/STValidation.h b/include/xrpl/protocol/STValidation.h index 444fdfa600..8101b27341 100644 --- a/include/xrpl/protocol/STValidation.h +++ b/include/xrpl/protocol/STValidation.h @@ -54,6 +54,22 @@ class STValidation final : public STObject, public CountedObject NetClock::time_point seenTime_; public: + /** + * @struct DeserializeOptions + * @brief Options controlling deserialization of a STValidation. + + * @var DeserializeOptions::checkSignature + * Whether to verify the data was signed properly + * + * @var DeserializeOptions::requireCanonicalOrder + * Whether to require the fields to be in canonical order + */ + struct DeserializeOptions + { + bool checkSignature; + bool requireCanonicalOrder; + }; + /** * Construct a STValidation from a peer from serialized data. * @@ -64,12 +80,12 @@ public: * that signed the validation. For manifest based * validators, this should be the NodeID of the master * public key. - * @param checkSignature Whether to verify the data was signed properly + * @param options Options controlling deserialization * * @note Throws if the object is not valid */ template - STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, bool checkSignature); + STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, DeserializeOptions options); /** * Construct, sign and trust a new STValidation issued by this node. @@ -163,8 +179,8 @@ private: }; template -STValidation::STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, bool checkSignature) - : STObject(validationFormat(), sit, sfValidation) +STValidation::STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, DeserializeOptions options) + : STObject(validationFormat(), sit, sfValidation, options.requireCanonicalOrder) , signingPubKey_([this]() { auto const spk = getFieldVL(sfSigningPubKey); @@ -175,7 +191,7 @@ STValidation::STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, bool ch }()) , nodeID_(lookupNodeID(signingPubKey_)) { - if (checkSignature && !isValid()) + if (options.checkSignature && !isValid()) { JLOG(debugLog().error()) << "Invalid signature in validation: " << getJson(JsonOptions::Values::None); diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index 0afdebb898..14bc0571e9 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -152,7 +152,14 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal; \ TRANSACTION(MPTokenIssuanceSet, \ TF_FLAG(tfMPTLock, 0x00000001) \ - TF_FLAG(tfMPTUnlock, 0x00000002), \ + TF_FLAG(tfMPTUnlock, 0x00000002) \ + TF_FLAG(tfMPTSetCanLock, 0x00000004) \ + TF_FLAG(tfMPTSetRequireAuth, 0x00000008) \ + TF_FLAG(tfMPTSetCanEscrow, 0x00000010) \ + TF_FLAG(tfMPTSetCanTrade, 0x00000020) \ + TF_FLAG(tfMPTSetCanTransfer, 0x00000040) \ + TF_FLAG(tfMPTSetCanClawback, 0x00000080) \ + TF_FLAG(tfMPTSetCanHoldConfidentialBalance, 0x00000100), \ MASK_ADJ(0)) \ \ TRANSACTION(NFTokenCreateOffer, \ @@ -356,38 +363,26 @@ inline constexpr FlagValue tfMPTPaymentMask = ~(tfUniversal | tfPartialPayment); inline constexpr FlagValue tfTrustSetPermissionMask = ~(tfUniversal | tfSetfAuth | tfSetFreeze | tfClearFreeze); -// MPTokenIssuanceCreate MutableFlags: -// Indicating specific fields or flags may be changed after issuance. -inline constexpr FlagValue tmfMPTCanEnableCanLock = lsmfMPTCanEnableCanLock; -inline constexpr FlagValue tmfMPTCanEnableRequireAuth = lsmfMPTCanEnableRequireAuth; -inline constexpr FlagValue tmfMPTCanEnableCanEscrow = lsmfMPTCanEnableCanEscrow; -inline constexpr FlagValue tmfMPTCanEnableCanTrade = lsmfMPTCanEnableCanTrade; -inline constexpr FlagValue tmfMPTCanEnableCanTransfer = lsmfMPTCanEnableCanTransfer; -inline constexpr FlagValue tmfMPTCanEnableCanClawback = lsmfMPTCanEnableCanClawback; -inline constexpr FlagValue tmfMPTCanMutateMetadata = lsmfMPTCanMutateMetadata; -inline constexpr FlagValue tmfMPTCanMutateTransferFee = lsmfMPTCanMutateTransferFee; -inline constexpr FlagValue tmfMPTCannotEnableCanHoldConfidentialBalance = - lsmfMPTCannotEnableCanHoldConfidentialBalance; -inline constexpr FlagValue tmfMPTokenIssuanceCreateMutableMask = - ~(tmfMPTCanEnableCanLock | tmfMPTCanEnableRequireAuth | tmfMPTCanEnableCanEscrow | - tmfMPTCanEnableCanTrade | tmfMPTCanEnableCanTransfer | tmfMPTCanEnableCanClawback | - tmfMPTCanMutateMetadata | tmfMPTCanMutateTransferFee | - tmfMPTCannotEnableCanHoldConfidentialBalance); +// MPTokenIssuanceCreate / MPTokenIssuanceSet ImmutableFlags: +// Defines the immutable fields and flags specific to MPTokenIssuance. +inline constexpr FlagValue tifMPTCanLock = lsifMPTCanLock; +inline constexpr FlagValue tifMPTRequireAuth = lsifMPTRequireAuth; +inline constexpr FlagValue tifMPTCanEscrow = lsifMPTCanEscrow; +inline constexpr FlagValue tifMPTCanTrade = lsifMPTCanTrade; +inline constexpr FlagValue tifMPTCanTransfer = lsifMPTCanTransfer; +inline constexpr FlagValue tifMPTCanClawback = lsifMPTCanClawback; +inline constexpr FlagValue tifMPTMetadata = lsifMPTMetadata; +inline constexpr FlagValue tifMPTTransferFee = lsifMPTTransferFee; +inline constexpr FlagValue tifMPTCanHoldConfidentialBalance = lsifMPTCanHoldConfidentialBalance; +inline constexpr FlagValue tifMPTokenIssuanceImmutableMask = + ~(tifMPTCanLock | tifMPTRequireAuth | tifMPTCanEscrow | tifMPTCanTrade | tifMPTCanTransfer | + tifMPTCanClawback | tifMPTMetadata | tifMPTTransferFee | tifMPTCanHoldConfidentialBalance); -// MPTokenIssuanceSet MutableFlags: -// Enable mutable capability flags. These flags are one-way: once enabled, -// the corresponding capability cannot be disabled by MPTokenIssuanceSet. - -inline constexpr FlagValue tmfMPTSetCanLock = 0x00000001; -inline constexpr FlagValue tmfMPTSetRequireAuth = 0x00000002; -inline constexpr FlagValue tmfMPTSetCanEscrow = 0x00000004; -inline constexpr FlagValue tmfMPTSetCanTrade = 0x00000008; -inline constexpr FlagValue tmfMPTSetCanTransfer = 0x00000010; -inline constexpr FlagValue tmfMPTSetCanClawback = 0x00000020; -inline constexpr FlagValue tmfMPTSetCanHoldConfidentialBalance = 0x00000040; -inline constexpr FlagValue tmfMPTokenIssuanceSetMutableMask = - ~(tmfMPTSetCanLock | tmfMPTSetRequireAuth | tmfMPTSetCanEscrow | tmfMPTSetCanTrade | - tmfMPTSetCanTransfer | tmfMPTSetCanClawback | tmfMPTSetCanHoldConfidentialBalance); +// MPTokenIssuanceSet set of flags that is used to enable capabilities on an MPTokenIssuance. +// Used as `txFlags & tfMPTokenIssuanceSetEnableFlagMask` to extract the capability-enabling bits. +inline constexpr FlagValue tfMPTokenIssuanceSetEnableFlagMask = tfMPTSetCanLock | + tfMPTSetRequireAuth | tfMPTSetCanEscrow | tfMPTSetCanTrade | tfMPTSetCanTransfer | + tfMPTSetCanClawback | tfMPTSetCanHoldConfidentialBalance; // Prior to fixRemoveNFTokenAutoTrustLine, transfer of an NFToken between accounts allowed a // TrustLine to be added to the issuer of that token without explicit permission from that issuer. diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index b6408581a9..ffcd025f01 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -404,7 +404,7 @@ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ {sfPreviousTxnID, SoeRequired}, {sfPreviousTxnLgrSeq, SoeRequired}, {sfDomainID, SoeOptional}, - {sfMutableFlags, SoeDefault}, + {sfImmutableFlags, SoeDefault}, {sfReferenceHolding, SoeOptional}, {sfIssuerEncryptionKey, SoeOptional}, {sfAuditorEncryptionKey, SoeOptional}, diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 16defe3ba3..cff075e738 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -98,7 +98,7 @@ TYPED_SFIELD(sfVoteWeight, UINT32, 48) TYPED_SFIELD(sfFirstNFTokenSequence, UINT32, 50) TYPED_SFIELD(sfOracleDocumentID, UINT32, 51) TYPED_SFIELD(sfPermissionValue, UINT32, 52) -TYPED_SFIELD(sfMutableFlags, UINT32, 53) +TYPED_SFIELD(sfImmutableFlags, UINT32, 53) TYPED_SFIELD(sfStartDate, UINT32, 54) TYPED_SFIELD(sfPaymentInterval, UINT32, 55) TYPED_SFIELD(sfGracePeriod, UINT32, 56) @@ -239,6 +239,7 @@ TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::kSmdNeedsAsset // int32 TYPED_SFIELD(sfLoanScale, INT32, 1) +TYPED_SFIELD(sfRemainingOwnerCountDelta, INT32, 2) // currency amount (common) TYPED_SFIELD(sfAmount, AMOUNT, 1) @@ -278,6 +279,7 @@ TYPED_SFIELD(sfMinAccountCreateAmount, AMOUNT, 30) TYPED_SFIELD(sfLPTokenBalance, AMOUNT, 31) TYPED_SFIELD(sfFeeAmount, AMOUNT, 32) TYPED_SFIELD(sfMaxFee, AMOUNT, 33) +TYPED_SFIELD(sfFeeAmountDelta, AMOUNT, 34) // variable length (common) TYPED_SFIELD(sfPublicKey, VL, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index e805596c00..1f9603dbae 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -705,7 +705,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, {sfMaximumAmount, SoeOptional}, {sfMPTokenMetadata, SoeOptional}, {sfDomainID, SoeOptional}, - {sfMutableFlags, SoeOptional}, + {sfImmutableFlags, SoeOptional}, })) /** This transaction type destroys a MPTokensIssuance instance */ @@ -734,7 +734,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, {sfDomainID, SoeOptional}, {sfMPTokenMetadata, SoeOptional}, {sfTransferFee, SoeOptional}, - {sfMutableFlags, SoeOptional}, + {sfImmutableFlags, SoeOptional}, {sfIssuerEncryptionKey, SoeOptional}, {sfAuditorEncryptionKey, SoeOptional}, })) @@ -1085,7 +1085,7 @@ TRANSACTION(ttLOAN_PAY, 84, LoanPay, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, - Delegation::Delegable, + Delegation::NotDelegable, featureConfidentialTransfer, NoPriv, ({ @@ -1189,9 +1189,9 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, ({ {sfCounterpartySponsor, SoeOptional}, {sfSponsee, SoeOptional}, - {sfFeeAmount, SoeOptional}, + {sfFeeAmountDelta, SoeOptional}, {sfMaxFee, SoeOptional}, - {sfRemainingOwnerCount, SoeOptional}, + {sfRemainingOwnerCountDelta, SoeOptional}, })) /** This system-generated transaction type is used to update the status of the various amendments. diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h index 8518a0fe14..6a2caf52ae 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h @@ -256,27 +256,27 @@ public: } /** - * @brief Get sfMutableFlags (SoeDefault) + * @brief Get sfImmutableFlags (SoeDefault) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] protocol_autogen::Optional - getMutableFlags() const + getImmutableFlags() const { - if (hasMutableFlags()) - return this->sle_->at(sfMutableFlags); + if (hasImmutableFlags()) + return this->sle_->at(sfImmutableFlags); return std::nullopt; } /** - * @brief Check if sfMutableFlags is present. + * @brief Check if sfImmutableFlags is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasMutableFlags() const + hasImmutableFlags() const { - return this->sle_->isFieldPresent(sfMutableFlags); + return this->sle_->isFieldPresent(sfImmutableFlags); } /** @@ -557,13 +557,13 @@ public: } /** - * @brief Set sfMutableFlags (SoeDefault) + * @brief Set sfImmutableFlags (SoeDefault) * @return Reference to this builder for method chaining. */ MPTokenIssuanceBuilder& - setMutableFlags(std::decay_t const& value) + setImmutableFlags(std::decay_t const& value) { - object_[sfMutableFlags] = value; + object_[sfImmutableFlags] = value; return *this; } diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h index dec7f733c9..284b7f9e70 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h @@ -19,7 +19,7 @@ class ConfidentialMPTConvertBuilder; * @brief Transaction: ConfidentialMPTConvert * * Type: ttCONFIDENTIAL_MPT_CONVERT (85) - * Delegable: Delegation::Delegable + * Delegable: Delegation::NotDelegable * Amendment: featureConfidentialTransfer * Privileges: NoPriv * diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h index e6fece8354..82ffba9996 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h @@ -178,29 +178,29 @@ public: } /** - * @brief Get sfMutableFlags (SoeOptional) + * @brief Get sfImmutableFlags (SoeOptional) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] protocol_autogen::Optional - getMutableFlags() const + getImmutableFlags() const { - if (hasMutableFlags()) + if (hasImmutableFlags()) { - return this->tx_->at(sfMutableFlags); + return this->tx_->at(sfImmutableFlags); } return std::nullopt; } /** - * @brief Check if sfMutableFlags is present. + * @brief Check if sfImmutableFlags is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasMutableFlags() const + hasImmutableFlags() const { - return this->tx_->isFieldPresent(sfMutableFlags); + return this->tx_->isFieldPresent(sfImmutableFlags); } }; @@ -302,13 +302,13 @@ public: } /** - * @brief Set sfMutableFlags (SoeOptional) + * @brief Set sfImmutableFlags (SoeOptional) * @return Reference to this builder for method chaining. */ MPTokenIssuanceCreateBuilder& - setMutableFlags(std::decay_t const& value) + setImmutableFlags(std::decay_t const& value) { - object_[sfMutableFlags] = value; + object_[sfImmutableFlags] = value; return *this; } diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h index 803868c640..ed7e1f0f6c 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h @@ -163,29 +163,29 @@ public: } /** - * @brief Get sfMutableFlags (SoeOptional) + * @brief Get sfImmutableFlags (SoeOptional) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] protocol_autogen::Optional - getMutableFlags() const + getImmutableFlags() const { - if (hasMutableFlags()) + if (hasImmutableFlags()) { - return this->tx_->at(sfMutableFlags); + return this->tx_->at(sfImmutableFlags); } return std::nullopt; } /** - * @brief Check if sfMutableFlags is present. + * @brief Check if sfImmutableFlags is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasMutableFlags() const + hasImmutableFlags() const { - return this->tx_->isFieldPresent(sfMutableFlags); + return this->tx_->isFieldPresent(sfImmutableFlags); } /** @@ -341,13 +341,13 @@ public: } /** - * @brief Set sfMutableFlags (SoeOptional) + * @brief Set sfImmutableFlags (SoeOptional) * @return Reference to this builder for method chaining. */ MPTokenIssuanceSetBuilder& - setMutableFlags(std::decay_t const& value) + setImmutableFlags(std::decay_t const& value) { - object_[sfMutableFlags] = value; + object_[sfImmutableFlags] = value; return *this; } diff --git a/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h b/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h index 0124da5e58..dfd12a329f 100644 --- a/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h +++ b/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h @@ -100,29 +100,29 @@ public: } /** - * @brief Get sfFeeAmount (SoeOptional) + * @brief Get sfFeeAmountDelta (SoeOptional) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] protocol_autogen::Optional - getFeeAmount() const + getFeeAmountDelta() const { - if (hasFeeAmount()) + if (hasFeeAmountDelta()) { - return this->tx_->at(sfFeeAmount); + return this->tx_->at(sfFeeAmountDelta); } return std::nullopt; } /** - * @brief Check if sfFeeAmount is present. + * @brief Check if sfFeeAmountDelta is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasFeeAmount() const + hasFeeAmountDelta() const { - return this->tx_->isFieldPresent(sfFeeAmount); + return this->tx_->isFieldPresent(sfFeeAmountDelta); } /** @@ -152,29 +152,29 @@ public: } /** - * @brief Get sfRemainingOwnerCount (SoeOptional) + * @brief Get sfRemainingOwnerCountDelta (SoeOptional) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] - protocol_autogen::Optional - getRemainingOwnerCount() const + protocol_autogen::Optional + getRemainingOwnerCountDelta() const { - if (hasRemainingOwnerCount()) + if (hasRemainingOwnerCountDelta()) { - return this->tx_->at(sfRemainingOwnerCount); + return this->tx_->at(sfRemainingOwnerCountDelta); } return std::nullopt; } /** - * @brief Check if sfRemainingOwnerCount is present. + * @brief Check if sfRemainingOwnerCountDelta is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasRemainingOwnerCount() const + hasRemainingOwnerCountDelta() const { - return this->tx_->isFieldPresent(sfRemainingOwnerCount); + return this->tx_->isFieldPresent(sfRemainingOwnerCountDelta); } }; @@ -243,13 +243,13 @@ public: } /** - * @brief Set sfFeeAmount (SoeOptional) + * @brief Set sfFeeAmountDelta (SoeOptional) * @return Reference to this builder for method chaining. */ SponsorshipSetBuilder& - setFeeAmount(std::decay_t const& value) + setFeeAmountDelta(std::decay_t const& value) { - object_[sfFeeAmount] = value; + object_[sfFeeAmountDelta] = value; return *this; } @@ -265,13 +265,13 @@ public: } /** - * @brief Set sfRemainingOwnerCount (SoeOptional) + * @brief Set sfRemainingOwnerCountDelta (SoeOptional) * @return Reference to this builder for method chaining. */ SponsorshipSetBuilder& - setRemainingOwnerCount(std::decay_t const& value) + setRemainingOwnerCountDelta(std::decay_t const& value) { - object_[sfRemainingOwnerCount] = value; + object_[sfRemainingOwnerCountDelta] = value; return *this; } diff --git a/include/xrpl/resource/Fees.h b/include/xrpl/resource/Fees.h index 411169253d..06e9f56c22 100644 --- a/include/xrpl/resource/Fees.h +++ b/include/xrpl/resource/Fees.h @@ -13,6 +13,7 @@ extern Charge const kFeeRequestNoReply; // A request that we cannot satisfy. extern Charge const kFeeInvalidSignature; // An object whose signature we had to check that failed. extern Charge const kFeeUselessData; // Data we have no use for. extern Charge const kFeeInvalidData; // Data we have to verify before rejecting. +extern Charge const kFeeMalformedData; // Data that no honest peer would send. // RPC loads extern Charge const kFeeMalformedRpc; // An RPC request that we can immediately tell is invalid. diff --git a/include/xrpl/server/InfoSub.h b/include/xrpl/server/InfoSub.h index 4bf88cd53b..db76396dc2 100644 --- a/include/xrpl/server/InfoSub.h +++ b/include/xrpl/server/InfoSub.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -22,6 +23,39 @@ namespace xrpl { // Operations that clients may wish to perform against the network // Master operational handler, server sequencer, network tracker +/** + * Maximum number of subscriptions a single client connection may hold at once. + * + * Applies to the account, real-time account, and account-history subscriptions + * tracked on one InfoSub (the sets counted by totalSubscriptionCount), bounding + * the disconnect-time cleanup of those sets. Book subscriptions are tracked + * separately (OrderBookDB) and are not counted here. Generous enough for + * legitimate power users such as block explorers. + */ +constexpr std::size_t kMaxSubscriptionsPerConnection = 100'000; + +/** + * Whether adding @p additional subscriptions to a connection already holding + * @p current would exceed the cap. + * + * Pure arithmetic split out so it can be unit-tested without a live + * connection. The first term avoids underflow in the subtraction. + * + * @param current Subscriptions already tracked on the connection. + * @param additional Subscriptions a request would add. + * @param cap The effective per-connection cap. Defaults to the + * built-in limit; callers may pass a configured override. + * @return true if the request must be rejected to stay within the cap. + */ +[[nodiscard]] constexpr bool +exceedsSubscriptionCap( + std::size_t current, + std::size_t additional, + std::size_t cap = kMaxSubscriptionsPerConnection) +{ + return additional > cap || current > cap - additional; +} + class InfoSubRequest : public CountedObject { public: @@ -44,12 +78,12 @@ public: * map. * * @note Lifetime contract: every `InfoSub` instance MUST be destroyed - * before the backing `Source`. NetworkOPsImp shutdown drops all - * subscriber strong refs before its own teardown to satisfy this. + * before the backing `Source`. NetworkOPsImp shutdown drops all + * subscriber strong refs before its own teardown to satisfy this. * @note Thread-safety: per-instance state is guarded by `lock_`. The - * destructor reads tracking sets without taking `lock_` because - * the strong-pointer ref-count is zero at destruction time, so - * no other thread can be calling the public mutators. + * destructor reads tracking sets without taking `lock_` because + * the strong-pointer ref-count is zero at destruction time, so + * no other thread can be calling the public mutators. */ class InfoSub : public CountedObject { @@ -117,6 +151,34 @@ public: AccountID const& account, bool historyOnly) = 0; + /** + * Schedule the server-side teardown of a disconnecting connection's + * account subscriptions off the destructor thread. + * + * The implementation posts a low-priority JobQueue task that erases the + * entries in bounded chunks, so `~InfoSub` returns immediately instead + * of running the erase loop inline. The sets are taken by value so the + * job owns its copies and never references the destroyed `InfoSub`. + * Cleanup is keyed on `seq` (unique per connection), so deferring it + * cannot disturb a reconnected client reusing the same accounts. + * + * @param seq The disconnecting connection's unique subscription id. + * @param rtAccounts Real-time account subscriptions to remove. + * @param normalAccounts Normal account subscriptions to remove. + * @param historyAccounts Account-history subscriptions to remove. + * + * @note The implementing `Source` must outlive any job it posts. If the + * JobQueue is already stopping (process shutdown), the job is not + * enqueued; the cleanup is skipped because the server-side maps + * are about to be destroyed and no publishing can run. + */ + virtual void + scheduleAccountCleanup( + std::uint64_t seq, + hash_set rtAccounts, + hash_set normalAccounts, + hash_set historyAccounts) = 0; + // VFALCO TODO Document the bool return value virtual bool subLedger(ref ispListener, json::Value& jvResult) = 0; @@ -153,12 +215,12 @@ public: * @param ispListener The subscriber requesting removal. * @param book The order book to unsubscribe from. * @return true if the entry was present and removed, false if the - * subscriber was not subscribed to @p book. + * subscriber was not subscribed to @p book. * - * @note Thread-safety: acquires subLock_ internally. + * @note Thread-safety: acquires bookLock_ internally. * @note Do NOT call from ~InfoSub(). Use unsubBookInternal instead - * to avoid a redundant write-back to bookSubscriptions_ on a - * partially-destroyed object. + * to avoid a redundant write-back to bookSubscriptions_ on a + * partially-destroyed object. */ virtual bool unsubBook(ref ispListener, Book const&) = 0; @@ -173,9 +235,9 @@ public: * @param uListener The sequence number of the subscriber being torn down. * @param book The order book entry to remove. * @return true if the entry was present and removed, false otherwise - * (e.g., already removed by a concurrent RPC unsubscribe). + * (e.g., already removed by a concurrent RPC unsubscribe). * - * @note Thread-safety: acquires subLock_ internally. + * @note Thread-safety: acquires bookLock_ internally. */ virtual bool unsubBookInternal(std::uint64_t uListener, Book const&) = 0; @@ -221,8 +283,8 @@ public: /** * Journal used by InfoSub for diagnostics that occur after the - * owning subsystem (e.g. application-level Logs) is the only - * surviving sink — primarily destructor-time cleanup failures. + * owning subsystem (e.g. application-level Logs) is the only + * surviving sink — primarily destructor-time cleanup failures. */ [[nodiscard]] virtual beast::Journal const& journal() const = 0; @@ -243,6 +305,56 @@ public: [[nodiscard]] std::uint64_t getSeq() const; + /** + * Return the number of subscriptions currently tracked on this + * connection. + * + * The combined size of the per-connection account, real-time account, and + * account-history subscription sets. `doSubscribe` reads this to enforce + * the per-connection subscription cap before admitting more. + * + * @return The total tracked subscription count for this connection. + * + * @note Thread-safe: takes `lock_` for the read; read-only. + */ + [[nodiscard]] std::size_t + totalSubscriptionCount() const; + + /** + * Enforce the cap and reserve a request's net-new accounts, atomically. + * + * Under one hold of `lock_`: count the net-new entries in the two sets, + * check the total against @p cap, and insert them only if it fits. + * All-or-nothing. Doing check and insert together stops two concurrent + * requests sharing an InfoSub (the admin subscribe-by-url path) from both + * passing the check before either records its accounts. The server-side + * maps are populated afterwards by subAccount, whose re-insert is a no-op. + * + * @param proposedAccounts Real-time (accounts_proposed) ids to reserve. + * @param normalAccounts Normal (accounts) ids to reserve. + * @param cap The effective per-connection cap. + * @return true if reserved; false if the request must be rejected. + * @note Thread-safe: takes `lock_`. + */ + [[nodiscard]] bool + tryReserveAccountSubscriptions( + hash_set const& proposedAccounts, + hash_set const& normalAccounts, + std::size_t cap); + + /** + * Whether this connection already tracks an account-history for @p account. + * + * `doSubscribe` reads this to charge the cap for an account_history_tx_stream + * only when it is net-new, matching the account branches. + * + * @param account The account an account_history_tx_stream would add. + * @return true if @p account is already in the account-history set. + * @note Thread-safe: takes `lock_`; read-only. + */ + [[nodiscard]] bool + hasAccountHistorySubscription(AccountID const& account) const; + void onSendEmpty(); @@ -302,7 +414,9 @@ public: getApiVersion() const noexcept; protected: - std::mutex lock_; + // Mutable so the read-only totalSubscriptionCount() accessor can lock it + // from a const method; locking semantics are otherwise unchanged. + mutable std::mutex lock_; private: Consumer consumer_; diff --git a/include/xrpl/server/Manifest.h b/include/xrpl/server/Manifest.h index 710545271a..786967b057 100644 --- a/include/xrpl/server/Manifest.h +++ b/include/xrpl/server/Manifest.h @@ -3,12 +3,14 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -43,12 +45,15 @@ namespace xrpl { dynamically generates the signatureless form when it needs to verify the signature. - An instance of ManifestCache stores, for each trusted validator, (a) its + An instance of ManifestCache stores, for each known validator, (a) its master public key, and (b) the most senior of all valid manifests it has seen for that validator, if any. On startup, the [validator_token] config entry (which contains the manifest for this validator) is decoded and added to the manifest cache. Other manifests are added as "gossip" - received from xrpld peers. + received from xrpld peers, including ones for validators this node does not + trust. Manifests for untrusted validators are capped (kMaxUntrustedCount) + so peer gossip cannot grow the cache without bound; trusted validators are + not capped. Entries are never evicted, so a stored revocation is permanent. When an ephemeral key is compromised, a new signing key pair is created, along with a new manifest vouching for it (with a higher sequence number), @@ -164,6 +169,100 @@ struct Manifest std::string to_string(Manifest const& m); +/** + * Largest a valid manifest can be, in decoded bytes. + * + * A manifest has a fixed set of fields. Each is serialized as a field header + * (1-2 bytes), an optional length prefix (1 byte for these sizes), and the + * field body. Taking every field at its largest gives the maximum below, so + * anything larger cannot be a valid manifest. + * + * Field header + length + body = bytes + * sfVersion (U16) 2 0 2 4 + * sfSequence (U32) 1 0 4 5 + * sfPublicKey (33) 1 1 33 35 + * sfSigningPubKey (33) 1 1 33 35 + * sfSignature (72) 1 1 72 74 + * sfMasterSignature (72) 2 1 72 75 + * sfDomain (128) 1 1 128 130 + * ----- + * 358 + */ +constexpr std::size_t kMaxManifestBytes = 358; + +/** + * Largest a valid manifest can be, in base64 characters. + * + * base64 encodes 3 bytes as 4 characters, so this is the encoded form of + * @ref kMaxManifestBytes. Callers that receive a base64 manifest should + * reject anything longer than this before decoding, to avoid allocating + * memory for an oversized input. + */ +constexpr std::size_t kMaxManifestBase64 = base64::encodedSize(kMaxManifestBytes); + +/** + * Default number of untrusted manifests to store in cache and allowed + * in one Manifest message. + * + * Bounds unlisted validators two ways. In the cache, a manifest for a + * brand-new unlisted key is rejected once this many are held, so peer gossip + * cannot grow the cache without end. In a TMManifests message, this many are + * sent and processed, so a peer sending its whole cache cannot force unbounded + * work. + * + * Operators can override this with `[overlay] max_untrusted_count`. Both users + * read the configured value and fall back to this default. + */ +constexpr std::size_t kMaxUntrustedCount = 300; + +/** + * Default number of trusted manifests allowed in a Manifest message. + * Not used atm while creating the message, but used to calculate the higher limit on + * received message size. Introduced to maintain consistency. Future implementation + * will use this limit. + * + * Trusted manifests are never dropped: every one this node holds is sent, and + * every one received is processed, since dropping one would delay a validator + * key rotation. This count only sizes the largest message accepted, so it must + * stay above any realistic validator list. Cap can be increased in the config + * file if messages get rejected with actual trusted manifest count crossing + * configured(or else default) value. + * Operators can override this with `[overlay] max_trusted_count`. + */ +constexpr std::size_t kMaxTrustedCount = 300; + +/** + * Number of untrusted manifests to store in cache and allowed + * in one Manifest message.. + * + * Returns the operator's override when one is configured, otherwise + * @ref kMaxUntrustedCount. Config stores an override rather than the default + * itself because the core module cannot depend on this module. + * + * @param configured The value from `[overlay] max_untrusted_count`, or + * `std::nullopt` when the operator did not set it. + */ +constexpr std::size_t +untrustedManifestCount(std::optional const& configured) +{ + return configured.value_or(kMaxUntrustedCount); +} + +/** + * Number of trusted manifests allowed in a Manifest message. + * + * Not a cap on how many are sent or processed; see @ref kMaxTrustedCount. + * but used to calculate the higher limit on received message size. + * + * @param configured The value from `[overlay] max_trusted_count`, or + * `std::nullopt` when the operator did not set it. + */ +constexpr std::size_t +trustedManifestCount(std::optional const& configured) +{ + return configured.value_or(kMaxTrustedCount); +} + /** * Constructs Manifest from serialized string * @@ -172,7 +271,7 @@ to_string(Manifest const& m); * @return `std::nullopt` if string is invalid * * @note This does not verify manifest signatures. - * `Manifest::verify` should be called after constructing manifest. + * `Manifest::verify` should be called after constructing manifest. */ /** @{ */ std::optional @@ -225,30 +324,17 @@ loadValidatorToken( beast::Journal journal = beast::Journal(beast::Journal::getNullSink())); enum class ManifestDisposition { - /** - * Manifest is valid - */ - Accepted = 0, + Accepted = 0, ///< Manifest is valid - /** - * Sequence is too old - */ - Stale, + Stale, ///< Sequence is too old - /** - * The master key is not acceptable to us - */ - BadMasterKey, + BadMasterKey, ///< The master key is not acceptable to us - /** - * The ephemeral key is not acceptable to us - */ - BadEphemeralKey, + BadEphemeralKey, ///< The ephemeral key is not acceptable to us - /** - * Timely, but invalid signature - */ - Invalid + Invalid, ///< Timely, but invalid signature + + UntrustedCapacity ///< Unlisted and limit reached }; inline std::string @@ -266,11 +352,25 @@ to_string(ManifestDisposition m) return "badEphemeralKey"; case ManifestDisposition::Invalid: return "invalid"; + case ManifestDisposition::UntrustedCapacity: + return "untrustedCapacity"; default: return "unknown"; } } +/** + * Whether a manifest counts against the 'untrusted' cache cap. + * + * Passed to `ManifestCache::applyManifest` with no default, so every caller + * must choose. `Capped` is the safe, flood-resistant value; only listed or + * configured keys should use `Uncapped`. + */ +enum class ManifestRateLimitCapPolicy : std::uint8_t { + Capped, ///< Subject to the untrusted cap (unlisted peer gossip) + Uncapped ///< Bypasses the cap (listed/trusted or config manifests) +}; + class DatabaseCon; /** @@ -294,8 +394,51 @@ private: std::atomic seq_{0}; + /** + * Master keys of cached manifests for validators this node does not list. + * + * One entry per capped key in `map_`; its size enforces the cap below. + * A key is added when first cached under `Capped` and removed when it + * becomes listed (see `promoteToTrusted`) or an `Uncapped` update arrives, + * never re-added on de-listing. Uncapped keys are not tracked here. + */ + hash_set untrustedKeys_; + + /** + * Maximum number of untrusted master keys kept in the cache. + * + * Once reached, a manifest for a brand-new unlisted key is rejected. Set + * from the config, defaulting to @ref kMaxUntrustedCount. + */ + std::size_t const maxUntrustedCount_; + + /** + * Running count of manifests rejected because the untrusted cap was full. + * + * Drives throttled logging (see `kUntrustedRejectCount`). Atomic because + * `applyManifest` may run concurrently. + */ + std::atomic untrustedRejectCount_{0}; + + /** + * Number of cap rejections between summary warnings. + * + * @see untrustedRejectCount_ + */ + static constexpr std::uint64_t kUntrustedRejectCount = 10000; + public: - explicit ManifestCache(beast::Journal j = beast::Journal(beast::Journal::getNullSink())) : j_(j) + /** + * @param j Journal for logging. + * + * @param maxUntrustedCount Untrusted master keys to keep. Pass the + * configured value; defaults to @ref kMaxUntrustedCount. Taken as a + * parameter because this module cannot depend on the config. + */ + explicit ManifestCache( + beast::Journal j = beast::Journal(beast::Journal::getNullSink()), + std::size_t maxUntrustedCount = kMaxUntrustedCount) + : j_(j), maxUntrustedCount_(maxUntrustedCount) { } @@ -378,17 +521,44 @@ public: /** * Add manifest to cache. * + * A brand-new unlisted key is rejected once the untrusted cap is full; + * updates to a cached key and `Uncapped` manifests bypass the cap. The + * caller decides `cap` before calling so the cache lock is not held while + * consulting the validator list, which would risk a lock-ordering deadlock. + * * @param m Manifest to add * - * @return `ManifestDisposition::accepted` if successful, or - * `stale` or `invalid` otherwise + * @param cap `Uncapped` skips the untrusted cap; use it for keys that are + * listed, configured, or loaded from the DB. Note `Uncapped` does not + * assert the key is currently trusted (a DB entry may predate a + * de-listing). Callers must state this explicitly so a manifest is + * never left uncapped by omission. + * + * @return `Accepted` if stored, `Stale` if superseded, `Invalid`/ + * `BadEphemeralKey` if malformed, or `UntrustedCapacity` if the + * untrusted cap is full. * * @par Thread Safety * * May be called concurrently */ ManifestDisposition - applyManifest(Manifest m); + applyManifest(Manifest m, ManifestRateLimitCapPolicy cap); + + /** + * Stop counting a master key against the untrusted cap. + * + * Called when a cached untrusted key becomes listed, freeing its slot. + * Idempotent and a no-op for keys that were never counted. + * + * @param pk Master public key that is now listed/trusted + * + * @par Thread Safety + * + * May be called concurrently + */ + void + promoteToTrusted(PublicKey const& pk); /** * Populate manifest cache with manifests in database and config. diff --git a/include/xrpl/tx/transactors/sponsor/SponsorshipSet.h b/include/xrpl/tx/transactors/sponsor/SponsorshipSet.h index 3310c995ae..1100c5352a 100644 --- a/include/xrpl/tx/transactors/sponsor/SponsorshipSet.h +++ b/include/xrpl/tx/transactors/sponsor/SponsorshipSet.h @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include #include @@ -16,7 +18,7 @@ namespace xrpl { class SponsorshipSet : public Transactor { public: - static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Custom; explicit SponsorshipSet(ApplyContext& ctx) : Transactor(ctx) { @@ -47,6 +49,15 @@ public: XRPAmount fee, ReadView const& view, beast::Journal const& j) override; + +private: + TER + createSponsorship( + Keylet const& sponsorshipKeylet, + AccountID const& sponsorID, + AccountID const& sponseeID, + SLE::ref sponsorAccSle, + SLE::ref reserveSponsorAccSle); }; } // namespace xrpl diff --git a/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h b/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h index 5d35f65f44..1aa853d6e2 100644 --- a/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h +++ b/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h @@ -32,7 +32,7 @@ struct MPTCreateArgs std::optional transferFee = std::nullopt; std::optional const& metadata{}; std::optional domainId = std::nullopt; - std::optional mutableFlags = std::nullopt; + std::optional immutableFlags = std::nullopt; // Set only by callers that issue an MPT representing a wrapped asset // (e.g. VaultCreate's share token). The keylet must point to an // existing MPToken or RippleState owned by `account`. Surfaces on diff --git a/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h b/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h index 52f155e8fe..a2a966009d 100644 --- a/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h +++ b/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h @@ -3,12 +3,15 @@ #include #include #include +#include #include #include +#include #include #include #include +#include #include namespace xrpl { @@ -22,6 +25,37 @@ public: { } + // Maps each MPTokenIssuanceSet set flag(e.g., tfMPTSetCanLock), to the issuance's + // corresponding immutable flag (e.g., lsifMPTCanLock) and the target ledger flag (e.g., + // lsfMPTCanLock). + struct FlagMapping + { + std::uint32_t setFlag; + std::uint32_t immutableFlag; + std::uint32_t ledgerFlag; + }; + + static constexpr std::array flagMapping = { + {{.setFlag = tfMPTSetCanLock, .immutableFlag = lsifMPTCanLock, .ledgerFlag = lsfMPTCanLock}, + {.setFlag = tfMPTSetRequireAuth, + .immutableFlag = lsifMPTRequireAuth, + .ledgerFlag = lsfMPTRequireAuth}, + {.setFlag = tfMPTSetCanEscrow, + .immutableFlag = lsifMPTCanEscrow, + .ledgerFlag = lsfMPTCanEscrow}, + {.setFlag = tfMPTSetCanTrade, + .immutableFlag = lsifMPTCanTrade, + .ledgerFlag = lsfMPTCanTrade}, + {.setFlag = tfMPTSetCanTransfer, + .immutableFlag = lsifMPTCanTransfer, + .ledgerFlag = lsfMPTCanTransfer}, + {.setFlag = tfMPTSetCanClawback, + .immutableFlag = lsifMPTCanClawback, + .ledgerFlag = lsfMPTCanClawback}, + {.setFlag = tfMPTSetCanHoldConfidentialBalance, + .immutableFlag = lsifMPTCanHoldConfidentialBalance, + .ledgerFlag = lsfMPTCanHoldConfidentialBalance}}}; + static bool checkExtraFeatures(PreflightContext const& ctx); diff --git a/package/README.md b/package/README.md index 4b78106c4c..887509b60b 100644 --- a/package/README.md +++ b/package/README.md @@ -1,6 +1,8 @@ # Linux Packaging -This directory contains all files needed to build RPM and Debian packages for `xrpld`. +This directory contains all files needed to build RPM and Debian packages for +`xrpld`. The packages also ship the `validator-keys` tool, so packaging requires +a build configured with `-Dvalidator_keys=ON`. ## Directory layout @@ -46,17 +48,28 @@ To print the full packaging matrix (artifact names and images) for the current Caller workflows (`on-pr.yml`, `on-tag.yml`, `on-trigger.yml`) call `reusable-package.yml`. That workflow generates its own packaging matrix from `package_configs` in `linux.json` (via `generate.py --packaging`) and fans out -one job per distro. Each job downloads the pre-built `xrpld` binary artifact and -runs in that distro's container, so the package format follows from the -container's package manager. The packaging script derives the package version -from the downloaded binary's `xrpld --version` output; no CMake configure or -build step is needed inside the packaging job. +one job per distro. Each job downloads the pre-built `xrpld` and `validator-keys` +binary artifacts and runs in that distro's container, so the package format +follows from the container's package manager. The packaging script derives the +package version from the downloaded binary's `xrpld --version` output; no CMake +configure or build step is needed inside the packaging job. + +The binaries come from the `debian` and `rhel` build configurations in +`linux.json`'s `configs` section, which pass `-Dvalidator_keys=ON` so that the +build job produces `validator-keys` next to `xrpld` and uploads it as the +`validator-keys-` artifact. The packaging entry for a distro names +both artifacts (`xrpld_artifact_name` and `validator_keys_artifact_name`), so a +packaged configuration must keep `-Dvalidator_keys=ON`. + +`validator-keys` is fetched from an exact commit pinned in +[`cmake/XrplValidatorKeys.cmake`](../cmake/XrplValidatorKeys.cmake), so a given +`xrpld` version always packages the same tool; bump that commit deliberately. ### Locally (mirrors CI) -With an `xrpld` binary already built at `build/xrpld`, run the packaging step -inside the same container CI uses. The image tag is derived from `linux.json` -so you don't need to hardcode a SHA. +With `xrpld` and `validator-keys` binaries already built at `build/xrpld` and +`build/validator-keys`, run the packaging step inside the same container CI uses. +The image tag is derived from `linux.json` so you don't need to hardcode a SHA. ```bash # From the repo root. Each distro's container image is the `image` field of its @@ -87,6 +100,7 @@ needed, but the host toolchain replaces the pinned CI image: ```bash cmake \ -Dxrpld=ON \ + -Dvalidator_keys=ON \ -Dpkg_release=1 \ -Dtests=OFF \ .. @@ -95,9 +109,11 @@ cmake --build . --target package # deb on Debian/Ubuntu, rpm on RHEL ``` The `cmake/XrplPackaging.cmake` module defines the `package` target only if at -least one of `rpmbuild` / `dpkg-buildpackage` is present; `build_pkg.sh` then -infers the package format from the host's package manager. The packaging script -installs to FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of +least one of `rpmbuild` / `dpkg-buildpackage` is present and both the `xrpld` and +`validator-keys` targets exist (`-Dxrpld=ON -Dvalidator_keys=ON`); the target +builds both binaries before packaging. `build_pkg.sh` then infers the package +format from the host's package manager. The packaging script installs to +FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of `CMAKE_INSTALL_PREFIX`. The package version is not a CMake input on this path: `build_pkg.sh` derives it @@ -156,13 +172,17 @@ CMake/CI integration. The CI workflow and the CMake `package` target both invoke 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 -into the staging area, and invokes the platform build tool. +`stage_common()` to copy the `xrpld` and `validator-keys` binaries, config files, +and shared support files into the staging area, and invokes the platform build +tool. Both binaries must be present in `BUILD_DIR` and must run in the packaging +environment; a missing or non-runnable one fails early. That runtime check is +what catches a binary still linked against the Nix store's ELF loader (see +`patch_nix_binary` in `cmake/PatchNixBinary.cmake`). ### RPM 1. Creates the standard `rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}` tree inside the build directory. -2. Copies `xrpld.spec` and all shared source files (binary, configs, service files) into `SOURCES/`. +2. Copies `xrpld.spec` and all shared source files (binaries, configs, service files) into `SOURCES/`. 3. Runs `rpmbuild -bb`, passing the normalized package metadata version as the `pkg_version` RPM macro and `PKG_RELEASE` as the `pkg_release` RPM macro. The spec uses manual `install` commands to place files, disables `dwz`, and @@ -182,7 +202,8 @@ service restart. ### DEB 1. Creates a staging source tree at `debbuild/source/` inside the build directory. -2. Stages the binary, configs, `README.md`, and `LICENSE.md`. +2. Stages the binaries, configs, `README.md`, `LICENSE.md`, and + `validator-keys-LICENSE`. 3. Copies `package/debian/` control files into `debbuild/source/debian/`. 4. Copies shared service/sysusers/tmpfiles into `debian/` where `dh_installsystemd`, `dh_installsysusers`, and `dh_installtmpfiles` pick them up automatically. 5. Generates a minimal `debian/changelog` using `${pkg_version}-${PKG_RELEASE}`, diff --git a/package/build_pkg.sh b/package/build_pkg.sh index 3684fc096a..d853bf95b7 100755 --- a/package/build_pkg.sh +++ b/package/build_pkg.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -# Build an RPM or Debian package from a pre-built xrpld binary. +# Build an RPM or Debian package from the pre-built xrpld and validator-keys +# binaries. # # Flags override env vars; env vars override defaults. @@ -11,7 +12,9 @@ Usage: build_pkg.sh [options] Options (each can also be set via the env var shown): --src-dir DIR repo root [SRC_DIR; default: ${PWD}] - --build-dir DIR directory holding xrpld [BUILD_DIR; default: ${PWD}/build] + --build-dir DIR directory holding the + xrpld and validator-keys + binaries [BUILD_DIR; default: ${PWD}/build] --pkg-release N package release iteration [PKG_RELEASE; default: 1] --source-date-epoch SECS reproducibility timestamp [SOURCE_DATE_EPOCH; latest git ctime; fallback: current time] -h, --help show this help and exit @@ -69,15 +72,44 @@ SRC_DIR="$(cd "${SRC_DIR:-${PWD}}" && pwd)" BUILD_DIR="${BUILD_DIR:-${PWD}/build}" if [[ ! -d "${BUILD_DIR}" ]]; then echo "build_pkg.sh: build directory not found: ${BUILD_DIR}" >&2 - echo "Build xrpld before packaging, or set BUILD_DIR to the directory containing xrpld." >&2 + echo "Build the binaries before packaging, or set BUILD_DIR to the directory containing them." >&2 exit 1 fi BUILD_DIR="$(cd "${BUILD_DIR}" && pwd)" xrpld_binary="${BUILD_DIR}/xrpld" -if [[ ! -x "${xrpld_binary}" ]]; then - echo "build_pkg.sh: expected executable xrpld binary at ${xrpld_binary}." >&2 - echo "Build xrpld before packaging, or set BUILD_DIR to the directory containing xrpld." >&2 +validator_keys_binary="${BUILD_DIR}/validator-keys" + +# Report both binaries at once: they share a single BUILD_DIR, so telling the +# reader to point it at one of them in isolation is advice they cannot follow. +missing=() +[[ -x "${xrpld_binary}" ]] || missing+=(xrpld) +[[ -x "${validator_keys_binary}" ]] || missing+=(validator-keys) + +if [[ ${#missing[@]} -gt 0 ]]; then + echo "build_pkg.sh: missing or not executable in ${BUILD_DIR}: ${missing[*]}" >&2 + echo "Both binaries come from a single CMake build directory configured with" >&2 + echo "-Dxrpld=ON -Dvalidator_keys=ON. Build them, then point BUILD_DIR at that" >&2 + echo "directory." >&2 + exit 1 +fi + +# Shipping validator-keys means shipping its notice, so treat it as required +# rather than letting a package go out without the attribution. +validator_keys_license="${BUILD_DIR}/validator-keys-LICENSE" +if [[ ! -f "${validator_keys_license}" ]]; then + echo "build_pkg.sh: missing ${validator_keys_license}." >&2 + echo "cmake/XrplValidatorKeys.cmake copies it out of the fetched" >&2 + echo "validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." >&2 + exit 1 +fi + +# The binary must also *run* here. Packaging happens in a vanilla distro +# container, so this is what catches a binary still pointing at the Nix store's +# ELF loader (see patch_nix_binary in cmake/PatchNixBinary.cmake); xrpld is +# covered implicitly by the version query below. +if ! "${validator_keys_binary}" --version >/dev/null; then + echo "build_pkg.sh: ${validator_keys_binary} exists but does not run here." >&2 exit 1 fi @@ -150,7 +182,9 @@ stage_common() { local dest="$1" mkdir -p "${dest}" - cp "${BUILD_DIR}/xrpld" "${dest}/xrpld" + cp "${xrpld_binary}" "${dest}/xrpld" + cp "${validator_keys_binary}" "${dest}/validator-keys" + cp "${validator_keys_license}" "${dest}/validator-keys-LICENSE" cp "${SRC_DIR}/cfg/xrpld-example.cfg" "${dest}/xrpld.cfg" cp "${SRC_DIR}/cfg/validators-example.txt" "${dest}/validators.txt" cp "${SRC_DIR}/LICENSE.md" "${dest}/LICENSE.md" diff --git a/package/debian/control b/package/debian/control index 45d2acbbea..62e5d79ef1 100644 --- a/package/debian/control +++ b/package/debian/control @@ -18,6 +18,8 @@ Depends: ${shlibs:Depends}, ${misc:Depends} Description: XRP Ledger daemon - Reference implementation of the XRP Ledger protocol. - Participates in the peer-to-peer network, processes transactions, - and maintains a local ledger copy. + xrpld is the reference implementation of the XRP Ledger protocol. It + participates in the peer-to-peer XRP Ledger network, processes + transactions, and maintains the ledger database. + This package also includes the validator-keys tool for validator key + management. diff --git a/package/debian/copyright b/package/debian/copyright index ddaa719e3a..2cf673854a 100644 --- a/package/debian/copyright +++ b/package/debian/copyright @@ -4,6 +4,25 @@ Source: https://github.com/XRPLF/rippled Files: * Copyright: 2011-present, the XRP Ledger developers +License: ISC + +Files: validator-keys +Copyright: 2016, Ripple Labs Inc. + 2011, Arthur Britto, David Schwartz, Jed McCaleb, Vinnie Falco, Bob Way, + Eric Lombrozo, Nikolaos D. Bougalis, Howard Hinnant + 2013, Raw Material Software Ltd. + 2003-2011, Christopher M. Kohlhoff + 2009-2010, Satoshi Nakamoto + 2011, The Bitcoin developers + 2003-2005, Tom Wu +License: ISC +Comment: Built from https://github.com/ripple/validator-keys-tool at the commit + pinned in cmake/XrplValidatorKeys.cmake. Besides ISC-licensed code it + incorporates work under the Boost Software License 1.0 (ASIO), the MIT/X11 + license (Bitcoin) and Tom Wu's license, whose terms require its notice to be + retained intact. The complete upstream notice is therefore shipped verbatim as + /usr/share/doc/xrpld/validator-keys-LICENSE. + License: ISC Permission to use, copy, modify, and distribute this software for any purpose with or without fee is hereby granted, provided that the above diff --git a/package/debian/rules b/package/debian/rules index 16574bca3f..8f880b8192 100644 --- a/package/debian/rules +++ b/package/debian/rules @@ -18,6 +18,7 @@ override_dh_installsysusers: override_dh_install: install -D -m 0755 xrpld debian/xrpld/usr/bin/xrpld + install -D -m 0755 validator-keys debian/xrpld/usr/bin/validator-keys install -D -m 0644 xrpld.cfg debian/xrpld/etc/xrpld/xrpld.cfg install -D -m 0644 validators.txt debian/xrpld/etc/xrpld/validators.txt diff --git a/package/debian/xrpld.docs b/package/debian/xrpld.docs index b43bf86b50..77681ddc6e 100644 --- a/package/debian/xrpld.docs +++ b/package/debian/xrpld.docs @@ -1 +1,2 @@ README.md +validator-keys-LICENSE diff --git a/package/rpm/xrpld.spec b/package/rpm/xrpld.spec index 61c2d61ec6..0e3ee2a968 100644 --- a/package/rpm/xrpld.spec +++ b/package/rpm/xrpld.spec @@ -32,6 +32,8 @@ BuildRequires: systemd-rpm-macros xrpld is the reference implementation of the XRP Ledger protocol. It participates in the peer-to-peer XRP Ledger network, processes transactions, and maintains the ledger database. +This package also includes the validator-keys tool for validator key +management. %prep : @@ -41,6 +43,7 @@ transactions, and maintains the ledger database. %install install -Dm0755 %{_sourcedir}/xrpld %{buildroot}%{_bindir}/%{name} +install -Dm0755 %{_sourcedir}/validator-keys %{buildroot}%{_bindir}/validator-keys install -Dm0644 %{_sourcedir}/xrpld.cfg %{buildroot}%{_sysconfdir}/%{name}/xrpld.cfg install -Dm0644 %{_sourcedir}/validators.txt %{buildroot}%{_sysconfdir}/%{name}/validators.txt @@ -59,6 +62,8 @@ install -Dm0644 %{_sourcedir}/xrpld.logrotate %{buildroot}%{_sysconfdir}/lo # Docs install -Dm0644 %{_sourcedir}/LICENSE.md %{buildroot}%{_docdir}/%{name}/LICENSE.md install -Dm0644 %{_sourcedir}/README.md %{buildroot}%{_docdir}/%{name}/README.md +# Upstream notice for the bundled validator-keys tool. +install -Dm0644 %{_sourcedir}/validator-keys-LICENSE %{buildroot}%{_docdir}/%{name}/validator-keys-LICENSE # Legacy compatibility for pre-FHS package layouts. # TODO: remove after rippled fully deprecated. @@ -80,11 +85,13 @@ systemd-tmpfiles --create %{_tmpfilesdir}/xrpld.conf || : %files %license %{_docdir}/%{name}/LICENSE.md +%license %{_docdir}/%{name}/validator-keys-LICENSE %doc %{_docdir}/%{name}/README.md %dir %{_sysconfdir}/%{name} %{_bindir}/%{name} +%{_bindir}/validator-keys %config(noreplace) %{_sysconfdir}/%{name}/xrpld.cfg %config(noreplace) %{_sysconfdir}/%{name}/validators.txt diff --git a/src/libxrpl/basics/base64.cpp b/src/libxrpl/basics/base64.cpp index c980a08669..f067dcbdca 100644 --- a/src/libxrpl/basics/base64.cpp +++ b/src/libxrpl/basics/base64.cpp @@ -76,24 +76,6 @@ getInverse() return &kTab[0]; } -/** - * Returns max chars needed to encode a base64 string - */ -constexpr std::size_t -encodedSize(std::size_t n) -{ - return 4 * ((n + 2) / 3); -} - -/** - * Returns max bytes needed to decode a base64 string - */ -constexpr std::size_t -decodedSize(std::size_t n) -{ - return ((n / 4) * 3) + 2; -} - /** * Encode a series of octets as a padded, base64 string. * diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index 8a18b3f228..6647437268 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.3.0-rc1" +char const* const versionString = "3.3.0" // clang-format on ; diff --git a/src/libxrpl/protocol/STObject.cpp b/src/libxrpl/protocol/STObject.cpp index c2543d2cca..4447a69457 100644 --- a/src/libxrpl/protocol/STObject.cpp +++ b/src/libxrpl/protocol/STObject.cpp @@ -56,10 +56,15 @@ STObject::STObject(SOTemplate const& type, SField const& name) : STBase(name) set(type); } -STObject::STObject(SOTemplate const& type, SerialIter& sit, SField const& name) : STBase(name) +STObject::STObject( + SOTemplate const& type, + SerialIter& sit, + SField const& name, + bool requireCanonicalOrder) + : STBase(name) { v_.reserve(type.size()); - set(sit); + set(sit, 0, requireCanonicalOrder); applyTemplate(type); // May throw } @@ -208,12 +213,13 @@ STObject::applyTemplateFromSField(SField const& sField) // return true = terminated with end-of-object bool -STObject::set(SerialIter& sit, int depth) +STObject::set(SerialIter& sit, int depth, bool requireCanonicalOrder) { bool reachedEndOfObject = false; v_.clear(); + std::optional prevFieldCode; // Consume data in the pipe until we run out or reach the end while (!sit.empty()) { @@ -238,7 +244,6 @@ STObject::set(SerialIter& sit, int depth) } auto const& fn = SField::getField(type, field); - if (fn.isInvalid()) { JLOG(debugLog().error()) @@ -246,6 +251,13 @@ STObject::set(SerialIter& sit, int depth) Throw("Unknown field"); } + if (requireCanonicalOrder && prevFieldCode.has_value() && fn.fieldCodeMem <= *prevFieldCode) + { + JLOG(debugLog().error()) << "Fields in object are not in canonical order"; + Throw("Fields in object are not in canonical order"); + } + prevFieldCode = fn.fieldCodeMem; + // Unflatten the field v_.emplace_back(sit, fn, depth + 1); diff --git a/src/libxrpl/protocol/STPathSet.cpp b/src/libxrpl/protocol/STPathSet.cpp index 8987d05f1e..658aaa65dd 100644 --- a/src/libxrpl/protocol/STPathSet.cpp +++ b/src/libxrpl/protocol/STPathSet.cpp @@ -51,6 +51,12 @@ STPathElement::getHash(STPathElement const& element) return (hashAccount ^ hashCurrency ^ hashIssuer); } +[[nodiscard]] size_t +STPathElement::getHash() const +{ + return STPathElement::getHash(*this); +} + STPathSet::STPathSet(SerialIter& sit, SField const& name) : STBase(name) { std::vector path; @@ -126,21 +132,15 @@ STPathSet::move(std::size_t n, void* buf) bool STPathSet::assembleAdd(STPath const& base, STPathElement const& tail) { // assemble base+tail and add it to the set if it's not a duplicate - value_.push_back(base); + STPath combined = base; + combined.pushBack(tail); - auto it = value_.rbegin(); - - STPath& newPath = *it; - newPath.pushBack(tail); - - while (++it != value_.rend()) + if (!seenHashes_.insert(combined).second) { - if (*it == newPath) - { - value_.pop_back(); - return false; - } + return false; } + + value_.push_back(std::move(combined)); return true; } diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp index 17d7617590..6aadefee27 100644 --- a/src/libxrpl/protocol/STTx.cpp +++ b/src/libxrpl/protocol/STTx.cpp @@ -812,16 +812,19 @@ invalidMPTAmountInTx(STObject const& tx) static bool isBatchRawTransactionOkay(STTx const& tx, std::string& reason) { - if (!tx.isFieldPresent(sfRawTransactions)) + XRPL_ASSERT( + tx.getTxnType() == ttBATCH || !tx.isFieldPresent(sfRawTransactions), + "xrpl::isBatchRawTransactionOkay : raw transactions only on batch"); + + if (tx.getTxnType() != ttBATCH) return true; - // sfRawTransactions only appears on a Batch. passesLocalChecks runs on - // unverified user and peer input, so reject (rather than assert) a non-batch - // transaction that carries it. - if (tx.getTxnType() != ttBATCH) + if (!tx.isFieldPresent(sfRawTransactions)) { - reason = "Only Batch transactions may contain raw transactions."; + // LCOV_EXCL_START + reason = "Batch transactions must contain raw transactions."; return false; + // LCOV_EXCL_STOP } if (tx.isFieldPresent(sfBatchSigners) && diff --git a/src/libxrpl/resource/Fees.cpp b/src/libxrpl/resource/Fees.cpp index 037f60051e..42c7f8e6ad 100644 --- a/src/libxrpl/resource/Fees.cpp +++ b/src/libxrpl/resource/Fees.cpp @@ -9,6 +9,7 @@ Charge const kFeeRequestNoReply(10, "unsatisfiable request"); Charge const kFeeInvalidSignature(2000, "invalid signature"); Charge const kFeeUselessData(150, "useless data"); Charge const kFeeInvalidData(400, "invalid data"); +Charge const kFeeMalformedData(2000, "malformed data"); Charge const kFeeMalformedRpc(100, "malformed RPC"); Charge const kFeeReferenceRpc(20, "reference RPC"); diff --git a/src/libxrpl/server/InfoSub.cpp b/src/libxrpl/server/InfoSub.cpp index 39883873fb..bd50b1311c 100644 --- a/src/libxrpl/server/InfoSub.cpp +++ b/src/libxrpl/server/InfoSub.cpp @@ -7,10 +7,12 @@ #include #include +#include #include #include #include #include +#include namespace xrpl { @@ -64,6 +66,9 @@ InfoSub::InfoSub(Source& source, Consumer consumer) InfoSub::~InfoSub() { + // Stream unsubscribes are O(1): each erases this connection's single seq_ + // from one stream map, so they are cheap enough to run inline on the + // disconnect thread. // Each Source teardown call below acquires a server-side lock and // can throw. Wrap each independent call so partial failure does not // skip the remaining teardown steps. @@ -79,29 +84,48 @@ InfoSub::~InfoSub() safeUnsub(seq_, [&] { source_.unsubPeerStatus(seq_); }, j); safeUnsub(seq_, [&] { source_.unsubConsensus(seq_); }, j); - // Use the internal unsubscribe so that it won't call - // back to us and modify its own parameter - if (!realTimeSubscriptions_.empty()) - { - safeUnsub( - seq_, [&] { source_.unsubAccountInternal(seq_, realTimeSubscriptions_, true); }, j); - } - - if (!normalSubscriptions_.empty()) - { - safeUnsub( - seq_, [&] { source_.unsubAccountInternal(seq_, normalSubscriptions_, false); }, j); - } - - for (auto const& account : accountHistorySubscriptions_) - { - safeUnsub(seq_, [&] { source_.unsubAccountHistoryInternal(seq_, account, false); }, j); - } - + // Book subscriptions are torn down inline here, keyed on seq_, rather than + // through the chunked account cleanup below. The book set is not capped, so + // it can be large; but each unsubBookInternal takes bookLock_ for a single + // O(1) erase and releases it, so even a large set never holds a lock across + // the whole loop - a competing book publish can interleave between erases. + // The disconnect thread still does O(N) brief acquisitions. Use the internal + // variant so it does not write back to bookSubscriptions_ on this + // partially-destroyed object. for (auto const& book : bookSubscriptions_) { safeUnsub(seq_, [&] { source_.unsubBookInternal(seq_, book); }, j); } + + // Hand the account sets off (by move) to the Source for a chunked, + // off-thread teardown keyed on seq_, instead of erasing them inline here. + // This keeps the destructor from holding the account lock across a large + // erase loop. The job never references this object, which is being + // destroyed. + // + // Moving the sets without holding lock_ is safe: the destructor runs only + // when the last shared_ptr to this InfoSub is released, so by the + // shared_ptr contract no other thread holds a reference. Subscription maps + // store weak_ptrs, so a concurrent publisher must weak_ptr::lock() first; + // that succeeds only while a strong reference exists, which cannot overlap + // with destruction. No other thread can observe the moved-from sets. + // + // Wrapped like the steps above: scheduleAccountCleanup enqueues a JobQueue + // task, which allocates and locks and so can throw. A throw out of this + // noexcept destructor would terminate the process. Skipping the cleanup on + // throw is harmless: the account/rt maps hold weak_ptrs that the next + // publish prunes once this InfoSub is gone, and any history paging job + // self-terminates when its weak sink can no longer be locked. + safeUnsub( + seq_, + [&] { + source_.scheduleAccountCleanup( + seq_, + std::move(realTimeSubscriptions_), + std::move(normalSubscriptions_), + std::move(accountHistorySubscriptions_)); + }, + j); } resource::Consumer& @@ -121,6 +145,53 @@ InfoSub::onSendEmpty() { } +std::size_t +InfoSub::totalSubscriptionCount() const +{ + // Hold lock_ for the whole read so the three sets cannot be mutated + // mid-count by a concurrent (un)subscribe on this connection. + std::scoped_lock const sl(lock_); + + // Combined tally the per-connection cap is enforced against. + return normalSubscriptions_.size() + realTimeSubscriptions_.size() + + accountHistorySubscriptions_.size(); +} + +bool +InfoSub::tryReserveAccountSubscriptions( + hash_set const& proposedAccounts, + hash_set const& normalAccounts, + std::size_t cap) +{ + // One lock hold covers the count, the check and the insert. + std::scoped_lock const sl(lock_); + + // Entries not already tracked; re-subscribing held accounts is not charged. + auto const countNew = [](hash_set const& requested, + hash_set const& existing) { + std::size_t fresh = 0; + for (auto const& account : requested) + { + if (!existing.contains(account)) + ++fresh; + } + return fresh; + }; + + std::size_t const additional = countNew(proposedAccounts, realTimeSubscriptions_) + + countNew(normalAccounts, normalSubscriptions_); + + std::size_t const current = normalSubscriptions_.size() + realTimeSubscriptions_.size() + + accountHistorySubscriptions_.size(); + + if (exceedsSubscriptionCap(current, additional, cap)) + return false; + + realTimeSubscriptions_.insert(proposedAccounts.begin(), proposedAccounts.end()); + normalSubscriptions_.insert(normalAccounts.begin(), normalAccounts.end()); + return true; +} + void InfoSub::insertSubAccountInfo(AccountID const& account, bool rt) { @@ -165,6 +236,13 @@ InfoSub::deleteSubAccountHistory(AccountID const& account) accountHistorySubscriptions_.erase(account); } +bool +InfoSub::hasAccountHistorySubscription(AccountID const& account) const +{ + std::scoped_lock const sl(lock_); + return accountHistorySubscriptions_.contains(account); +} + void InfoSub::insertBookSubscription(Book const& book) { diff --git a/src/libxrpl/server/Manifest.cpp b/src/libxrpl/server/Manifest.cpp index b26c67e531..0760196a3b 100644 --- a/src/libxrpl/server/Manifest.cpp +++ b/src/libxrpl/server/Manifest.cpp @@ -62,6 +62,11 @@ deserializeManifest(Slice s, beast::Journal journal) if (s.empty()) return std::nullopt; + // A valid manifest has a fixed maximum size, so reject anything larger + // before parsing it. + if (s.size() > kMaxManifestBytes) + return std::nullopt; + static SOTemplate const kManifestFormat{ // A manifest must include: // - the master public key @@ -377,16 +382,20 @@ ManifestCache::revoked(PublicKey const& pk) const } ManifestDisposition -ManifestCache::applyManifest(Manifest m) +ManifestCache::applyManifest(Manifest m, ManifestRateLimitCapPolicy const cap) { + bool const uncapped = cap == ManifestRateLimitCapPolicy::Uncapped; + + // The signature is checked only on the first `prewriteCheck` run (under the + // read lock). It is expensive, so `checkSignature` is cleared the first + // time it is read; the second run (under the write lock) skips it. + bool checkSignature = true; + // Check the manifest against the conditions that do not require a - // `unique_lock` (write lock) on the `mutex_`. Since the signature can be - // relatively expensive, the `checkSignature` parameter determines if the - // signature should be checked. Since `prewriteCheck` is run twice (see - // comment below), `checkSignature` only needs to be set to true on the - // first run. - auto prewriteCheck = [this, &m](auto const& iter, bool checkSignature, auto const& lock) - -> std::optional { + // `unique_lock` (write lock) on the `mutex_`. + auto prewriteCheck = [this, &m, &checkSignature]( + auto const& iter, + auto const& lock) -> std::optional { XRPL_ASSERT(lock.owns_lock(), "xrpl::ManifestCache::applyManifest::prewriteCheck : locked"); (void)lock; // not used. parameter is present to ensure the mutex is // locked when the lambda is called. @@ -401,11 +410,15 @@ ManifestCache::applyManifest(Manifest m) return ManifestDisposition::Stale; } - if (checkSignature && !m.verify()) + if (checkSignature) { - if (auto stream = j_.warn()) - logMftAct(stream, "Invalid", m.masterKey, m.sequence); - return ManifestDisposition::Invalid; + checkSignature = false; + if (!m.verify()) + { + if (auto stream = j_.warn()) + logMftAct(stream, "Invalid", m.masterKey, m.sequence); + return ManifestDisposition::Invalid; + } } // If the master key associated with a manifest is or might be @@ -465,14 +478,51 @@ ManifestCache::applyManifest(Manifest m) return std::nullopt; }; + // Reject a brand-new manifest for an unlisted key once the untrusted cap + // is full. Updates to a cached key and uncapped manifests always pass. + // Called under both the read and write lock, since the cap can be reached + // between the two. The lock param enforces that. + auto atUntrustedCap = [this, &m, uncapped](auto const& iter, auto const& lock) { + XRPL_ASSERT( + lock.owns_lock(), "xrpl::ManifestCache::applyManifest::atUntrustedCap : locked"); + (void)lock; // not used. parameter is present to ensure the mutex is + // locked when the lambda is called. + if (iter == map_.end() && !uncapped && untrustedKeys_.size() >= maxUntrustedCount_) + { + // Log each rejection at debug, but warn only once per interval so a + // flood does not fill the log. + if (auto stream = j_.debug()) + logMftAct(stream, "UntrustedCapacity", m.masterKey, m.sequence); + if (auto const n = untrustedRejectCount_.fetch_add(1) + 1; + n % kUntrustedRejectCount == 0) + { + JLOG(j_.warn()) << "Untrusted manifest cap reached; " << n + << " manifests rejected so far"; + } + return true; + } + return false; + }; + { std::shared_lock const sl{mutex_}; - if (auto d = prewriteCheck(map_.find(m.masterKey), /*checkSig*/ true, sl)) + auto const iter = map_.find(m.masterKey); + + if (atUntrustedCap(iter, sl)) + return ManifestDisposition::UntrustedCapacity; + + if (auto d = prewriteCheck(iter, sl); d.has_value()) return *d; } std::unique_lock const sl{mutex_}; auto const iter = map_.find(m.masterKey); + + // Re-check the cap under the write lock: the cache may have grown while the + // read lock above was released. + if (atUntrustedCap(iter, sl)) + return ManifestDisposition::UntrustedCapacity; + // Since we released the previously held read lock, it's possible that the // collections have been written to. This means we need to run // `prewriteCheck` again. This re-does work, but `prewriteCheck` is @@ -482,7 +532,7 @@ ManifestCache::applyManifest(Manifest m) // doesn't need to happen again (signature checks are somewhat expensive). // Note: It's a mistake to use an upgradable lock. This is a recipe for // deadlock. - if (auto d = prewriteCheck(iter, /*checkSig*/ false, sl)) + if (auto d = prewriteCheck(iter, sl); d.has_value()) return *d; bool const revoked = m.revoked(); @@ -501,6 +551,12 @@ ManifestCache::applyManifest(Manifest m) } auto masterKey = m.masterKey; + + // Count this key against the untrusted cap. Uncapped keys (listed, + // configured, or DB-loaded) are not tracked. + if (!uncapped) + untrustedKeys_.insert(masterKey); + map_.emplace(std::move(masterKey), std::move(m)); // Something has changed. Keep track of it. @@ -514,6 +570,11 @@ ManifestCache::applyManifest(Manifest m) if (auto stream = j_.info()) logMftAct(stream, "AcceptedUpdate", m.masterKey, m.sequence, iter->second.sequence); + // If this key was counted against the cap but now arrives uncapped, free + // its slot without waiting for promoteToTrusted. + if (uncapped) + untrustedKeys_.erase(m.masterKey); + signingToMasterKeys_.erase( *iter->second.signingKey); // NOLINT(bugprone-unchecked-optional-access) prewriteCheck // ensures old manifest is not revoked @@ -521,8 +582,8 @@ ManifestCache::applyManifest(Manifest m) if (!revoked) { signingToMasterKeys_.emplace( - *m.signingKey, m.masterKey); // NOLINT(bugprone-unchecked-optional-access) non-revoked - // manifest always has signingKey + *m.signingKey, m.masterKey); // NOLINT(bugprone-unchecked-optional-access) + // non-revoked manifest always has signingKey } iter->second = std::move(m); @@ -533,6 +594,16 @@ ManifestCache::applyManifest(Manifest m) return ManifestDisposition::Accepted; } +void +ManifestCache::promoteToTrusted(PublicKey const& pk) +{ + // Frees the key's untrusted slot; a no-op (and idempotent) if the key was + // never counted. Not re-added on de-listing, so list/de-list cannot grow + // the count. + std::unique_lock const sl{mutex_}; + untrustedKeys_.erase(pk); +} + void ManifestCache::load(DatabaseCon& dbCon, std::string const& dbTable) { @@ -563,7 +634,8 @@ ManifestCache::load( JLOG(j_.warn()) << "Configured manifest revokes public key"; } - if (applyManifest(std::move(*mo)) == ManifestDisposition::Invalid) + if (applyManifest(std::move(*mo), ManifestRateLimitCapPolicy::Uncapped) == + ManifestDisposition::Invalid) { JLOG(j_.error()) << "Manifest in config was rejected"; return false; @@ -585,7 +657,9 @@ ManifestCache::load( auto mo = deserializeManifest(base64Decode(revocationStr)); - if (!mo || !mo->revoked() || applyManifest(std::move(*mo)) == ManifestDisposition::Invalid) + if (!mo || !mo->revoked() || + applyManifest(std::move(*mo), ManifestRateLimitCapPolicy::Uncapped) == + ManifestDisposition::Invalid) { JLOG(j_.error()) << "Invalid validator key revocation in config"; return false; diff --git a/src/libxrpl/server/Wallet.cpp b/src/libxrpl/server/Wallet.cpp index f3a7ff76ba..42ac80ef3f 100644 --- a/src/libxrpl/server/Wallet.cpp +++ b/src/libxrpl/server/Wallet.cpp @@ -29,6 +29,7 @@ #include #include +#include #include #include #include @@ -77,7 +78,9 @@ getManifests( continue; } - cache.applyManifest(std::move(*mo)); + // Only trusted manifests are persisted (see saveManifests), so + // anything loaded from the DB bypasses the untrusted cap. + cache.applyManifest(std::move(*mo), ManifestRateLimitCapPolicy::Uncapped); } else { @@ -107,19 +110,27 @@ saveManifests( { soci::transaction tr(session); session << "DELETE FROM " << dbTable; + // Count skipped untrusted manifests and log one summary afterwards, since + // the cache can hold many and per-entry logging would flood at shutdown. + std::size_t skipped = 0; for (auto const& v : map) { - // Save all revocation manifests, - // but only save trusted non-revocation manifests. - if (!v.second.revoked() && !isTrusted(v.second.masterKey)) + // Persist only trusted keys. Untrusted gossip is left out so a flood + // cannot survive a restart on disk. + if (!isTrusted(v.second.masterKey)) { - JLOG(j.info()) << "Untrusted manifest in cache not saved to db"; + ++skipped; continue; } saveManifest(session, dbTable, v.second.serialized); } tr.commit(); + + if (skipped != 0) + { + JLOG(j.info()) << skipped << " untrusted manifest(s) in cache not saved to db"; + } } void diff --git a/src/libxrpl/tx/transactors/account/AccountDelete.cpp b/src/libxrpl/tx/transactors/account/AccountDelete.cpp index 0055fce403..ce027f4cad 100644 --- a/src/libxrpl/tx/transactors/account/AccountDelete.cpp +++ b/src/libxrpl/tx/transactors/account/AccountDelete.cpp @@ -241,6 +241,8 @@ AccountDelete::preclaim(PreclaimContext const& ctx) if (!ctx.tx.isFieldPresent(sfCredentialIDs)) { // Check whether the destination account requires deposit authorization. + // This also checks if destination is a pseudo-account, since pseudo-accounts have the + // lsfDepositAuth flag set by default if (sleDst->isFlag(lsfDepositAuth)) { if (!ctx.view.exists(keylet::depositPreauth(dst, account))) diff --git a/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp b/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp index e902ee73a6..5cce1a7de8 100644 --- a/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp +++ b/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp @@ -84,7 +84,9 @@ CredentialCreate::preclaim(PreclaimContext const& ctx) auto const credType(ctx.tx[sfCredentialType]); auto const subject = ctx.tx[sfSubject]; - if (!ctx.view.exists(keylet::account(subject))) + auto const subjectSle = ctx.view.read(keylet::account(subject)); + + if (!subjectSle) { JLOG(ctx.j.trace()) << "Subject doesn't exist."; return tecNO_TARGET; @@ -96,6 +98,12 @@ CredentialCreate::preclaim(PreclaimContext const& ctx) return tecDUPLICATE; } + if (ctx.view.rules().enabled(fixCleanup3_3_0) && isPseudoAccount(subjectSle)) + { + JLOG(ctx.j.trace()) << "Subject is a pseudo-account."; + return tecPSEUDO_ACCOUNT; + } + return tesSUCCESS; } diff --git a/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp b/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp index 96e6c9e443..12edb43bff 100644 --- a/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp +++ b/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp @@ -57,7 +57,7 @@ DelegateSet::preclaim(PreclaimContext const& ctx) return tecNO_TARGET; if (isPseudoAccount(sleAuthorize)) - return tecNO_PERMISSION; + return tecPSEUDO_ACCOUNT; // Deleting the delegate object is invalid if it doesn’t exist. if (ctx.tx.getFieldArray(sfPermissions).empty() && diff --git a/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp b/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp index d3e2af86ef..c11c0ed916 100644 --- a/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp +++ b/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp @@ -103,9 +103,16 @@ DepositPreauth::preclaim(PreclaimContext const& ctx) { // Verify that the Authorize account is present in the ledger. AccountID const auth{ctx.tx[sfAuthorize]}; - if (!ctx.view.exists(keylet::account(auth))) + auto const sleAuth = ctx.view.read(keylet::account(auth)); + if (!sleAuth) return tecNO_TARGET; + if (ctx.view.rules().enabled(fixCleanup3_3_0) && isPseudoAccount(sleAuth)) + { + JLOG(ctx.j.debug()) << "Authorized account is a pseudo-account."; + return tecPSEUDO_ACCOUNT; + } + // Verify that the Preauth entry they asked to add is not already // in the ledger. if (ctx.view.exists(keylet::depositPreauth(account, auth))) diff --git a/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp b/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp index 2b6ab8cf15..e717c626e4 100644 --- a/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp +++ b/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp @@ -3,13 +3,16 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include +#include #include #include #include @@ -17,36 +20,62 @@ #include #include +#include #include +#include #include #include namespace xrpl { +// Compute the resulting RemainingOwnerCount using signed 64-bit arithmetic to +// avoid unsigned wraparound. A missing SLE (object creation) or absent field +// counts as zero. Callers handle the out-of-range results: a negative value is +// clamped to zero (field absent) and overflow is rejected in preclaim. +static std::int64_t +totalRemainingOwnerCount( + SLE::const_ref sponsorshipSle, + std::optional const& remainingOwnerCountDelta) +{ + std::uint32_t const currentCount = + sponsorshipSle ? (*sponsorshipSle)[~sfRemainingOwnerCount].value_or(0u) : 0u; + return static_cast(currentCount) + remainingOwnerCountDelta.value_or(0); +} + static bool hasSponsorshipBudget( SLE::const_ref sponsorshipSle, - std::optional const& feeAmount, - std::optional const& remainingOwnerCount) + std::optional const& feeAmountDelta, + std::optional const& remainingOwnerCountDelta) { - // A field the transaction omits keeps whatever the existing object holds, + // sfFeeAmountDelta and sfRemainingOwnerCountDelta must be non-negative when creating a new + // Sponsorship object. + if (!sponsorshipSle) + { + if (feeAmountDelta.has_value() && *feeAmountDelta <= beast::kZero) + return false; + + if (remainingOwnerCountDelta.has_value() && *remainingOwnerCountDelta <= 0) + return false; + } + // If the transaction omits a field, it keeps whatever the existing object holds, // so fall back to the current SLE value when the tx does not set it. - bool const hasFeeAmount = feeAmount - ? *feeAmount > beast::kZero - : sponsorshipSle && (*sponsorshipSle)[~sfFeeAmount].value_or(STAmount{0}) > beast::kZero; + STAmount const currentFee = + sponsorshipSle ? (*sponsorshipSle)[~sfFeeAmount].value_or(STAmount{0}) : STAmount{0}; + STAmount const newFee = currentFee + feeAmountDelta.value_or(STAmount{0}); - bool const hasRemainingOwnerCount = remainingOwnerCount - ? *remainingOwnerCount > 0 - : sponsorshipSle && (*sponsorshipSle)[~sfRemainingOwnerCount].value_or(0) > 0; + std::int64_t const newCount = + totalRemainingOwnerCount(sponsorshipSle, remainingOwnerCountDelta); - return hasFeeAmount || hasRemainingOwnerCount; + return newFee > beast::kZero || newCount > 0; } TxConsequences SponsorshipSet::makeTxConsequences(PreflightContext const& ctx) { - auto const feeAmount = ctx.tx[~sfFeeAmount]; - return TxConsequences{ctx.tx, feeAmount.has_value() ? feeAmount->xrp() : beast::kZero}; + auto const feeAmount = ctx.tx[~sfFeeAmountDelta]; + auto const feeAmountDelta = std::max(STAmount{0}, feeAmount.value_or(STAmount{0})); + return TxConsequences{ctx.tx, feeAmountDelta.xrp()}; } std::uint32_t @@ -90,8 +119,8 @@ SponsorshipSet::preflight(PreflightContext const& ctx) return temINVALID_FLAG; // Transactions deleting `Sponsorship` cannot include modification fields. - if (ctx.tx.isFieldPresent(sfFeeAmount) || ctx.tx.isFieldPresent(sfRemainingOwnerCount) || - ctx.tx.isFieldPresent(sfMaxFee)) + if (ctx.tx.isFieldPresent(sfFeeAmountDelta) || + ctx.tx.isFieldPresent(sfRemainingOwnerCountDelta) || ctx.tx.isFieldPresent(sfMaxFee)) return temMALFORMED; } else @@ -101,27 +130,26 @@ SponsorshipSet::preflight(PreflightContext const& ctx) if (account != sponsorID) return temMALFORMED; - // FeeAmount and MaxFee must be non-negative XRP amounts when present. - auto const checkOptionalAmountField = [&](SField const& field) -> NotTEC { - if (!ctx.tx.isFieldPresent(field)) - return tesSUCCESS; + // FeeAmountDelta must be a non-zero XRP amount when present. + if (auto const feeAmt = ctx.tx[~sfFeeAmountDelta]; + feeAmt && (!isXRP(*feeAmt) || *feeAmt == beast::kZero)) + return temBAD_AMOUNT; - auto const amount = ctx.tx.getFieldAmount(field); + // MaxFee must be a non-negative XRP amount when present. + if (auto const maxFee = ctx.tx[~sfMaxFee]; + maxFee && (!isXRP(*maxFee) || *maxFee < beast::kZero)) + return temBAD_AMOUNT; - if (!isXRP(amount)) - return temBAD_AMOUNT; + // RemainingOwnerCountDelta must be a non-zero integer when present. + if (auto const remainingOwnerCountDelta = ctx.tx[~sfRemainingOwnerCountDelta]; + remainingOwnerCountDelta && *remainingOwnerCountDelta == 0) + return temINVALID; - if (amount.xrp() < beast::kZero) - return temBAD_AMOUNT; - - return tesSUCCESS; - }; - - if (auto const ret = checkOptionalAmountField(sfFeeAmount); !isTesSuccess(ret)) - return ret; - - if (auto const ret = checkOptionalAmountField(sfMaxFee); !isTesSuccess(ret)) - return ret; + // nothing specified in the tx + if (!ctx.tx.isFieldPresent(sfRemainingOwnerCountDelta) && + !ctx.tx.isFieldPresent(sfFeeAmountDelta) && !ctx.tx.isFieldPresent(sfMaxFee) && + ((ctx.tx.getFlags() & tfUniversalMask) == 0)) + return temREDUNDANT; } return tesSUCCESS; @@ -146,7 +174,7 @@ SponsorshipSet::preclaim(PreclaimContext const& ctx) // Pseudo-accounts cannot participate in sponsorship. if (isPseudoAccount(sponsorAccSle) || isPseudoAccount(sponseeSle)) - return tecNO_PERMISSION; + return tecPSEUDO_ACCOUNT; auto const sponsorshipSle = ctx.view.read(keylet::sponsorship(sponsorID, sponseeID)); @@ -154,12 +182,21 @@ SponsorshipSet::preclaim(PreclaimContext const& ctx) if (ctx.tx.isFlag(tfDeleteObject) && !sponsorshipSle) return tecNO_ENTRY; - // Reject creating or updating a Sponsorship that would be left with no - // budget (neither a positive FeeAmount nor a positive RemainingOwnerCount). - // Such an object is unusable yet still consumes the sponsor's reserve. - if (!ctx.tx.isFlag(tfDeleteObject) && - !hasSponsorshipBudget(sponsorshipSle, ctx.tx[~sfFeeAmount], ctx.tx[~sfRemainingOwnerCount])) - return tecNO_PERMISSION; + if (!ctx.tx.isFlag(tfDeleteObject)) + { + // Reject if applying the delta would overflow uint32_t. A negative delta + // that underflows is clamped to zero (field absent) rather than erroring. + if (totalRemainingOwnerCount(sponsorshipSle, ctx.tx[~sfRemainingOwnerCountDelta]) > + static_cast(std::numeric_limits::max())) + return tecLIMIT_EXCEEDED; + + // Reject creating or updating a Sponsorship that would be left with no + // budget (neither a positive FeeAmount nor a positive RemainingOwnerCount). + // Such an object is unusable yet still consumes the sponsor's reserve. + if (!hasSponsorshipBudget( + sponsorshipSle, ctx.tx[~sfFeeAmountDelta], ctx.tx[~sfRemainingOwnerCountDelta])) + return tecNO_PERMISSION; + } return tesSUCCESS; } @@ -208,6 +245,91 @@ deleteSponsorship(ApplyView& view, SLE::ref sle, beast::Journal j) return tesSUCCESS; } +TER +SponsorshipSet::createSponsorship( + Keylet const& sponsorshipKeylet, + AccountID const& sponsorID, + AccountID const& sponseeID, + SLE::ref sponsorAccSle, + SLE::ref reserveSponsorAccSle) +{ + auto const feeAmountDelta = ctx_.tx[~sfFeeAmountDelta]; + auto const maxFee = ctx_.tx[~sfMaxFee]; + auto const remainingOwnerCountDelta = ctx_.tx[~sfRemainingOwnerCountDelta]; + + bool const hasPositiveFeeAmount = feeAmountDelta.has_value() && *feeAmountDelta > beast::kZero; + + // Create a new Sponsorship object between the sponsor and sponsee. + auto newSle = std::make_shared(sponsorshipKeylet); + STAmount sponsorBalanceAfterFee = (*sponsorAccSle)[sfBalance]; + // sfFeeAmountDelta must be positive if the sponsorship object doesn't exist. This is + // checked in preclaim. + XRPL_ASSERT( + !feeAmountDelta.has_value() || *feeAmountDelta > beast::kZero, + "xrpl::SponsorshipSet::doApply : new sponsorship has positive fee amount"); + + (*newSle)[sfOwner] = sponsorID; + (*newSle)[sfSponsee] = sponseeID; + if (feeAmountDelta && feeAmountDelta->xrp() > sponsorBalanceAfterFee.xrp()) + return tecUNFUNDED; + + if (hasPositiveFeeAmount) + sponsorBalanceAfterFee -= *feeAmountDelta; + + if (auto const ret = checkReserve( + ctx_.getApplyViewContext(), + sponsorAccSle, + sponsorBalanceAfterFee.xrp(), + reserveSponsorAccSle, + {.ownerCountDelta = 1}, + ctx_.journal, + tecUNFUNDED); + !isTesSuccess(ret)) + { + return ret; + } + + if (hasPositiveFeeAmount) + { + // New object: FeeAmount starts absent, so deduct and record the full amount + (*newSle)[sfFeeAmount] = *feeAmountDelta; + (*sponsorAccSle)[sfBalance] -= *feeAmountDelta; + } + + if (maxFee && *maxFee > beast::kZero) + (*newSle)[sfMaxFee] = *maxFee; + if (remainingOwnerCountDelta && *remainingOwnerCountDelta > 0) + (*newSle)[sfRemainingOwnerCount] = *remainingOwnerCountDelta; + + std::uint32_t flags = 0; + if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForFee)) + flags |= lsfSponsorshipRequireSignForFee; + + if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForReserve)) + flags |= lsfSponsorshipRequireSignForReserve; + + (*newSle)[sfFlags] = flags; + + auto const sponsorPage = view().dirInsert( + keylet::ownerDir(sponsorID), sponsorshipKeylet, describeOwnerDir(sponsorID)); + if (!sponsorPage) + return tecDIR_FULL; // LCOV_EXCL_LINE + (*newSle)[sfOwnerNode] = *sponsorPage; + + auto const sponseePage = view().dirInsert( + keylet::ownerDir(sponseeID), sponsorshipKeylet, describeOwnerDir(sponseeID)); + if (!sponseePage) + return tecDIR_FULL; // LCOV_EXCL_LINE + (*newSle)[sfSponseeNode] = *sponseePage; + + // NOLINTNEXTLINE(readability-suspicious-call-argument) + increaseOwnerCount(view(), sponsorAccSle, reserveSponsorAccSle, 1, ctx_.journal); + addSponsorToLedgerEntry(newSle, reserveSponsorAccSle); + + ctx_.view().insert(newSle); + return tesSUCCESS; +} + TER SponsorshipSet::doApply() { @@ -224,8 +346,8 @@ SponsorshipSet::doApply() if (!ctx_.view().exists(keylet::account(sponseeID))) return tecINTERNAL; // LCOV_EXCL_LINE - auto const sponsorKeylet = keylet::sponsorship(sponsorID, sponseeID); - auto const sponsorshipSle = ctx_.view().peek(sponsorKeylet); + auto const sponsorshipKeylet = keylet::sponsorship(sponsorID, sponseeID); + auto const sponsorshipSle = ctx_.view().peek(sponsorshipKeylet); if (ctx_.tx.isFlag(tfDeleteObject)) { @@ -235,11 +357,9 @@ SponsorshipSet::doApply() return deleteSponsorship(ctx_.view(), sponsorshipSle, ctx_.journal); } - auto const feeAmount = ctx_.tx[~sfFeeAmount]; + auto const feeAmountDelta = ctx_.tx[~sfFeeAmountDelta]; auto const maxFee = ctx_.tx[~sfMaxFee]; - auto const remainingOwnerCount = ctx_.tx[~sfRemainingOwnerCount]; - - bool const hasPositiveFeeAmount = feeAmount.has_value() && *feeAmount > beast::kZero; + auto const remainingOwnerCountDelta = ctx_.tx[~sfRemainingOwnerCountDelta]; auto reserveSponsorAccSle = getTxReserveSponsor(ctx_.getApplyViewContext()); if (!reserveSponsorAccSle) @@ -247,24 +367,33 @@ SponsorshipSet::doApply() if (!sponsorshipSle) { - // Create a new Sponsorship object between the sponsor and sponsee. - auto newSle = std::make_shared(sponsorKeylet); + return createSponsorship( + sponsorshipKeylet, sponsorID, sponseeID, sponsorAccSle, *reserveSponsorAccSle); + } - (*newSle)[sfOwner] = sponsorID; - (*newSle)[sfSponsee] = sponseeID; - if (feeAmount && (*feeAmount).xrp() > (*sponsorAccSle)[sfBalance]) + // Update the existing Sponsorship object. + if (feeAmountDelta) + { + auto actualDelta = feeAmountDelta.value(); + auto const currentFee = (*sponsorshipSle)[~sfFeeAmount].valueOr(XRPAmount{0}); + + // Clamp negative delta to avoid underflow. + if (actualDelta < beast::kZero && -actualDelta > currentFee) + actualDelta = -currentFee; + // Reject if the sponsor cannot afford the (positive) delta. + if (actualDelta > beast::kZero && actualDelta > (*sponsorAccSle)[sfBalance]) return tecUNFUNDED; - STAmount sponsorBalanceAfterFee = (*sponsorAccSle)[sfBalance]; - if (hasPositiveFeeAmount) - sponsorBalanceAfterFee -= *feeAmount; + // Move the FeeAmount delta between the sponsor balance and Sponsorship + // object. + (*sponsorAccSle)[sfBalance] -= actualDelta; if (auto const ret = checkReserve( ctx_.getApplyViewContext(), sponsorAccSle, - sponsorBalanceAfterFee.xrp(), + (*sponsorAccSle)[sfBalance]->xrp(), *reserveSponsorAccSle, - {.ownerCountDelta = 1}, + {}, ctx_.journal, tecUNFUNDED); !isTesSuccess(ret)) @@ -272,87 +401,19 @@ SponsorshipSet::doApply() return ret; } - if (hasPositiveFeeAmount) + STAmount const newFee = currentFee + actualDelta; + // checked in preclaim + XRPL_ASSERT( + newFee >= beast::kZero, "xrpl::SponsorshipSet::doApply : new fee is non-negative"); + if (newFee == beast::kZero) { - // New object: FeeAmount starts absent, so deduct and record the full amount - (*newSle)[sfFeeAmount] = *feeAmount; - (*sponsorAccSle)[sfBalance] -= *feeAmount; + sponsorshipSle->makeFieldAbsent(sfFeeAmount); } - - if (maxFee && *maxFee > beast::kZero) - (*newSle)[sfMaxFee] = *maxFee; - if (remainingOwnerCount && *remainingOwnerCount > 0) - (*newSle)[sfRemainingOwnerCount] = *remainingOwnerCount; - - std::uint32_t flags = 0; - if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForFee)) - flags |= lsfSponsorshipRequireSignForFee; - - if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForReserve)) - flags |= lsfSponsorshipRequireSignForReserve; - - (*newSle)[sfFlags] = flags; - - auto const sponsorPage = view().dirInsert( - keylet::ownerDir(sponsorID), sponsorKeylet, describeOwnerDir(sponsorID)); - if (!sponsorPage) - return tecDIR_FULL; // LCOV_EXCL_LINE - (*newSle)[sfOwnerNode] = *sponsorPage; - - auto const sponseePage = view().dirInsert( - keylet::ownerDir(sponseeID), sponsorKeylet, describeOwnerDir(sponseeID)); - if (!sponseePage) - return tecDIR_FULL; // LCOV_EXCL_LINE - (*newSle)[sfSponseeNode] = *sponseePage; - - // NOLINTNEXTLINE(readability-suspicious-call-argument) - increaseOwnerCount(view(), sponsorAccSle, *reserveSponsorAccSle, 1, ctx_.journal); - addSponsorToLedgerEntry(newSle, *reserveSponsorAccSle); - - ctx_.view().insert(newSle); - return tesSUCCESS; - } - - // Update the existing Sponsorship object. - if (feeAmount) - { - auto const currentFeeAmount = (*sponsorshipSle)[~sfFeeAmount].valueOr(XRPAmount{0}); - auto const feeAmountDelta = XRPAmount(*feeAmount - currentFeeAmount); - - if (feeAmountDelta > beast::kZero && feeAmountDelta > (*sponsorAccSle)[sfBalance]) - return tecUNFUNDED; - - // Move the FeeAmount delta between the sponsor balance and Sponsorship - // object. - if (feeAmountDelta != beast::kZero) + else { - STAmount sponsorBalanceAfterFee = (*sponsorAccSle)[sfBalance]; - sponsorBalanceAfterFee -= feeAmountDelta; - - if (auto const ret = checkReserve( - ctx_.getApplyViewContext(), - sponsorAccSle, - sponsorBalanceAfterFee.xrp(), - *reserveSponsorAccSle, - {}, - ctx_.journal, - tecUNFUNDED); - !isTesSuccess(ret)) - { - return ret; - } - - (*sponsorAccSle)[sfBalance] -= feeAmountDelta; - if (*feeAmount == beast::kZero) - { - (*sponsorshipSle).makeFieldAbsent(sfFeeAmount); - } - else - { - (*sponsorshipSle).setFieldAmount(sfFeeAmount, *feeAmount); - } - ctx_.view().update(sponsorAccSle); + (*sponsorshipSle)[sfFeeAmount] = newFee; } + ctx_.view().update(sponsorAccSle); } if (maxFee) @@ -367,15 +428,21 @@ SponsorshipSet::doApply() } } - if (remainingOwnerCount) + if (remainingOwnerCountDelta) { - if (*remainingOwnerCount == 0) + std::int64_t const newCount = + totalRemainingOwnerCount(sponsorshipSle, remainingOwnerCountDelta); + // Overflow is rejected in preclaim; underflow clamps to zero (field absent). + XRPL_ASSERT( + newCount <= static_cast(std::numeric_limits::max()), + "xrpl::SponsorshipSet::doApply : RemainingOwnerCount does not overflow"); + if (newCount <= 0) { sponsorshipSle->makeFieldAbsent(sfRemainingOwnerCount); } else { - sponsorshipSle->at(sfRemainingOwnerCount) = *remainingOwnerCount; + sponsorshipSle->at(sfRemainingOwnerCount) = static_cast(newCount); } } diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp index aad1642f68..375110c330 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp @@ -35,18 +35,24 @@ MPTokenIssuanceCreate::checkExtraFeatures(PreflightContext const& ctx) ctx.rules.enabled(featureSingleAssetVault))) return false; - if (ctx.tx.isFieldPresent(sfMutableFlags) && !ctx.rules.enabled(featureDynamicMPT)) + if (ctx.tx.isFieldPresent(sfImmutableFlags) && !ctx.rules.enabled(featureDynamicMPT)) return false; if (ctx.tx.isFlag(tfMPTCanHoldConfidentialBalance) && !ctx.rules.enabled(featureConfidentialTransfer)) return false; - // can not set tmfMPTCannotEnableCanHoldConfidentialBalance without featureConfidentialTransfer - auto const mutableFlags = ctx.tx[~sfMutableFlags]; - return !mutableFlags || - ((*mutableFlags & tmfMPTCannotEnableCanHoldConfidentialBalance) == 0u) || - ctx.rules.enabled(featureConfidentialTransfer); + // can not set tifMPTCanHoldConfidentialBalance without featureConfidentialTransfer + auto const immutableFlags = ctx.tx[~sfImmutableFlags]; + // NOLINTBEGIN(readability-simplify-boolean-expr) + if (immutableFlags && ((*immutableFlags & tifMPTCanHoldConfidentialBalance) != 0u) && + !ctx.rules.enabled(featureConfidentialTransfer)) + { + return false; + } + // NOLINTEND(readability-simplify-boolean-expr) + + return true; } std::uint32_t @@ -64,10 +70,10 @@ MPTokenIssuanceCreate::preflight(PreflightContext const& ctx) if (ctx.rules.enabled(fixCleanup3_2_0) && ctx.tx.isFieldPresent(sfReferenceHolding)) return temMALFORMED; - // If the mutable flags field is included, at least one flag must be - // specified. - if (auto const mutableFlags = ctx.tx[~sfMutableFlags]; mutableFlags && - ((*mutableFlags == 0u) || ((*mutableFlags & tmfMPTokenIssuanceCreateMutableMask) != 0u))) + // If the immutable flags field is included, at least one flag must be + // specified, and undefined flags must not be specified. + if (auto const immutableFlags = ctx.tx[~sfImmutableFlags]; immutableFlags && + ((*immutableFlags == 0u) || ((*immutableFlags & tifMPTokenIssuanceImmutableMask) != 0u))) return temINVALID_FLAG; if (auto const fee = ctx.tx[~sfTransferFee]) @@ -170,8 +176,8 @@ MPTokenIssuanceCreate::create( if (args.domainId) (*mptIssuance)[sfDomainID] = *args.domainId; - if (args.mutableFlags) - (*mptIssuance)[sfMutableFlags] = *args.mutableFlags; + if (args.immutableFlags) + (*mptIssuance)[sfImmutableFlags] = *args.immutableFlags; if (args.referenceHolding) { @@ -217,7 +223,7 @@ MPTokenIssuanceCreate::doApply() .transferFee = tx[~sfTransferFee], .metadata = tx[~sfMPTokenMetadata], .domainId = tx[~sfDomainID], - .mutableFlags = tx[~sfMutableFlags], + .immutableFlags = tx[~sfImmutableFlags], }); return result ? tesSUCCESS : result.error(); } diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp index d526251069..e8fd2e22b6 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp @@ -20,7 +20,6 @@ #include #include -#include #include namespace xrpl { @@ -39,56 +38,29 @@ MPTokenIssuanceSet::getFlagsMask(PreflightContext const& ctx) return tfMPTokenIssuanceSetMask; } -// Maps each MPTokenIssuanceSet MutableFlags to the corresponding mutable -// flag and the target ledger flag to mutate. -struct MPTMutabilityFlags -{ - std::uint32_t setFlag; - std::uint32_t canEnableFlag; - std::uint32_t ledgerFlag; -}; - -static constexpr std::array kMptMutabilityFlags = { - {{.setFlag = tmfMPTSetCanLock, - .canEnableFlag = lsmfMPTCanEnableCanLock, - .ledgerFlag = lsfMPTCanLock}, - {.setFlag = tmfMPTSetRequireAuth, - .canEnableFlag = lsmfMPTCanEnableRequireAuth, - .ledgerFlag = lsfMPTRequireAuth}, - {.setFlag = tmfMPTSetCanEscrow, - .canEnableFlag = lsmfMPTCanEnableCanEscrow, - .ledgerFlag = lsfMPTCanEscrow}, - {.setFlag = tmfMPTSetCanTrade, - .canEnableFlag = lsmfMPTCanEnableCanTrade, - .ledgerFlag = lsfMPTCanTrade}, - {.setFlag = tmfMPTSetCanTransfer, - .canEnableFlag = lsmfMPTCanEnableCanTransfer, - .ledgerFlag = lsfMPTCanTransfer}, - {.setFlag = tmfMPTSetCanClawback, - .canEnableFlag = lsmfMPTCanEnableCanClawback, - .ledgerFlag = lsfMPTCanClawback}}}; - NotTEC MPTokenIssuanceSet::preflight(PreflightContext const& ctx) { - auto const mutableFlags = ctx.tx[~sfMutableFlags]; + auto const txFlags = ctx.tx.getFlags(); + auto const enableFlags = txFlags & tfMPTokenIssuanceSetEnableFlagMask; auto const metadata = ctx.tx[~sfMPTokenMetadata]; auto const transferFee = ctx.tx[~sfTransferFee]; - auto const isMutate = mutableFlags || metadata || transferFee; + auto const immutableFlags = ctx.tx[~sfImmutableFlags]; + auto const isMutate = (enableFlags != 0u) || metadata || transferFee || immutableFlags; auto const hasIssuerElGamalKey = ctx.tx.isFieldPresent(sfIssuerEncryptionKey); auto const hasAuditorElGamalKey = ctx.tx.isFieldPresent(sfAuditorEncryptionKey); - auto const txFlags = ctx.tx.getFlags(); - - bool const enablePrivacy = - mutableFlags && (*mutableFlags & tmfMPTSetCanHoldConfidentialBalance) != 0u; + bool const enablePrivacy = (enableFlags & tfMPTSetCanHoldConfidentialBalance) != 0u; auto const hasDomain = ctx.tx.isFieldPresent(sfDomainID); auto const hasHolder = ctx.tx.isFieldPresent(sfHolder); if (isMutate && !ctx.rules.enabled(featureDynamicMPT)) return temDISABLED; - if ((hasIssuerElGamalKey || hasAuditorElGamalKey || enablePrivacy) && + bool const setConfidentialBalanceImmutable = + immutableFlags && (*immutableFlags & tifMPTCanHoldConfidentialBalance) != 0u; + if ((hasIssuerElGamalKey || hasAuditorElGamalKey || enablePrivacy || + setConfidentialBalanceImmutable) && !ctx.rules.enabled(featureConfidentialTransfer)) return temDISABLED; @@ -122,8 +94,9 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) if (isMutate && holderID) return temMALFORMED; - // Can not set flags when mutating MPTokenIssuance - if (isMutate && ((ctx.tx.getFlags() & tfUniversalMask) != 0u)) + // A single transaction may either lock/unlock or mutate capability + // flags, but not both. + if (isMutate && (ctx.tx.isFlag(tfMPTLock) || ctx.tx.isFlag(tfMPTUnlock))) return temMALFORMED; if (transferFee && *transferFee > kMaxTransferFee) @@ -135,11 +108,12 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) if (metadata && metadata->length() > kMaxMpTokenMetadataLength) return temMALFORMED; - if (mutableFlags) - { - if ((*mutableFlags == 0u) || ((*mutableFlags & tmfMPTokenIssuanceSetMutableMask) != 0u)) - return temINVALID_FLAG; - } + // If the immutable flags field is included, at least one flag must be + // specified, and undefined flags must not be specified. + if (immutableFlags && + ((*immutableFlags == 0u) || + ((*immutableFlags & tifMPTokenIssuanceImmutableMask) != 0u))) + return temINVALID_FLAG; } if (hasHolder && (hasIssuerElGamalKey || hasAuditorElGamalKey)) @@ -207,40 +181,32 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) } } - // sfMutableFlags is soeDEFAULT, defaulting to 0 if not specified on + // sfImmutableFlags is soeDEFAULT, defaulting to 0 if not specified on // the ledger. - auto const currentMutableFlags = sleMptIssuance->getFieldU32(sfMutableFlags); + auto const currentImmutableFlags = sleMptIssuance->getFieldU32(sfImmutableFlags); - auto isMutableFlag = [&](std::uint32_t mutableFlag) -> bool { - return currentMutableFlags & mutableFlag; - }; + auto isImmutable = [&](std::uint32_t flag) -> bool { return currentImmutableFlags & flag; }; - auto const mutableFlags = ctx.tx[~sfMutableFlags]; - // Whether the transaction is enabling confidential amounts. - bool const enablesConfidentialAmount = - mutableFlags && (*mutableFlags & tmfMPTSetCanHoldConfidentialBalance) != 0u; - if (mutableFlags) + auto const enableFlags = ctx.tx.getFlags() & tfMPTokenIssuanceSetEnableFlagMask; + if (enableFlags != 0u) { - if (std::ranges::any_of(kMptMutabilityFlags, [mutableFlags, &isMutableFlag](auto const& f) { - return !isMutableFlag(f.canEnableFlag) && ((*mutableFlags & f.setFlag) != 0u); + // If any of the flags to be set is immutable, return tecNO_PERMISSION. + if (std::ranges::any_of(flagMapping, [&](auto const& f) { + return isImmutable(f.immutableFlag) && ctx.tx.isFlag(f.setFlag); })) return tecNO_PERMISSION; - - if (enablesConfidentialAmount && - isMutableFlag(lsmfMPTCannotEnableCanHoldConfidentialBalance)) - return tecNO_PERMISSION; } - if (!isMutableFlag(lsmfMPTCanMutateMetadata) && ctx.tx.isFieldPresent(sfMPTokenMetadata)) + if (isImmutable(lsifMPTMetadata) && ctx.tx.isFieldPresent(sfMPTokenMetadata)) return tecNO_PERMISSION; if (auto const fee = ctx.tx[~sfTransferFee]) { // A non-zero TransferFee is only valid if the lsfMPTCanTransfer flag - // was previously enabled (at issuance or via a prior mutation). Setting - // it by tmfMPTSetCanTransfer in the current transaction does not meet - // this requirement. - if (fee > 0u && !sleMptIssuance->isFlag(lsfMPTCanTransfer)) + // is already set on the ledger object, or is being enabled by this + // same transaction. The Immutability of lsfMPTCanTransfer is checked above. + if (fee > 0u && !sleMptIssuance->isFlag(lsfMPTCanTransfer) && + (enableFlags & tfMPTSetCanTransfer) == 0u) return tecNO_PERMISSION; // Cannot set a non-zero TransferFee on an issuance that has confidential @@ -248,7 +214,8 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) if (fee > 0u && sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance)) return tecNO_PERMISSION; - if (!isMutableFlag(lsmfMPTCanMutateTransferFee)) + // Cannot set TransferFee if it is immutable + if (isImmutable(lsifMPTTransferFee)) return tecNO_PERMISSION; } @@ -266,27 +233,29 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) return tecNO_PERMISSION; // LCOV_EXCL_LINE } - if (enablesConfidentialAmount && sleMptIssuance->isFieldPresent(sfTransferFee) && + auto const enablesConfidentialBalance = + (enableFlags & tfMPTSetCanHoldConfidentialBalance) != 0u; + if (enablesConfidentialBalance && sleMptIssuance->isFieldPresent(sfTransferFee) && (*sleMptIssuance)[sfTransferFee] > 0u) return tecNO_PERMISSION; // Encryption keys can only be set if confidential amounts are already // enabled on the issuance OR if the transaction is enabling it if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) && - !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialAmount) + !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialBalance) { return tecNO_PERMISSION; } if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) && - !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialAmount) + !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialBalance) { return tecNO_PERMISSION; } // cannot upload key if there's circulating supply of COA if ((ctx.tx.isFieldPresent(sfIssuerEncryptionKey) || - ctx.tx.isFieldPresent(sfAuditorEncryptionKey) || enablesConfidentialAmount) && + ctx.tx.isFieldPresent(sfAuditorEncryptionKey) || enablesConfidentialBalance) && (*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0) { return tecNO_PERMISSION; // LCOV_EXCL_LINE @@ -327,23 +296,41 @@ MPTokenIssuanceSet::doApply() flagsOut &= ~lsfMPTLocked; } - if (auto const mutableFlags = ctx_.tx[~sfMutableFlags].value_or(0)) + if (auto const enableFlags = (ctx_.tx.getFlags() & tfMPTokenIssuanceSetEnableFlagMask); + enableFlags != 0u) { - for (auto const& f : kMptMutabilityFlags) + for (auto const& f : flagMapping) { - if ((mutableFlags & f.setFlag) != 0u) + if (ctx_.tx.isFlag(f.setFlag)) { flagsOut |= f.ledgerFlag; } } - - if ((mutableFlags & tmfMPTSetCanHoldConfidentialBalance) != 0u) - flagsOut |= lsfMPTCanHoldConfidentialBalance; } if (flagsIn != flagsOut) sle->setFieldU32(sfFlags, flagsOut); + if (auto const immutableFlags = ctx_.tx[~sfImmutableFlags]) + { + // sle is guaranteed to be an ltMPTOKEN_ISSUANCE rather than an ltMPTOKEN. + // Preflight verification ensures that sfHolder and sfImmutableFlags can + // never both be present in the same transaction. Therefore, if + // sfImmutableFlags is present, sfHolder must be absent. + // + // In doApply, the absence of sfHolder causes the MPTokenIssuance keylet + // to be peeked. The runtime check below is a defensive fallback in case + // this invariant is ever broken by a future change. + XRPL_ASSERT( + sle->getType() == ltMPTOKEN_ISSUANCE, + "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance"); + + if (sle->getType() != ltMPTOKEN_ISSUANCE) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sle)[sfImmutableFlags] = (*sle)[sfImmutableFlags] | *immutableFlags; + } + if (auto const transferFee = ctx_.tx[~sfTransferFee]) { // TransferFee uses soeDEFAULT style: diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index a522f62788..efb0d57c42 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -210,7 +210,6 @@ VaultCreate::doApply() .transferFee = std::nullopt, .metadata = tx[~sfMPTokenMetadata], .domainId = tx[~sfDomainID], - .mutableFlags = std::nullopt, .referenceHolding = referenceHolding, }); if (!maybeShare) diff --git a/src/test/app/AccountDelete_test.cpp b/src/test/app/AccountDelete_test.cpp index 399696ec0d..8fbb786caf 100644 --- a/src/test/app/AccountDelete_test.cpp +++ b/src/test/app/AccountDelete_test.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -31,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -687,7 +689,7 @@ public: } void - testDest() + testDest(FeatureBitset features) { testcase("Destination Constraints"); @@ -698,7 +700,7 @@ public: Account const carol{"carol"}; Account const daria{"daria"}; - Env env{*this}; + Env env{*this, features}; env.fund(XRP(100000), alice, becky, carol); env.close(); @@ -711,6 +713,16 @@ public: env(fset(carol, asfRequireDest)); env.close(); + // Need to create a pseudo-account + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = alice, .asset = xrpIssue()}); + env(tx); + env.close(); + auto const sleVault = env.le(keylet); + if (!BEAST_EXPECT(sleVault)) + return; + Account const vaultPseudo{"vaultPseudo", sleVault->at(sfAccount)}; + // Close enough ledgers to be able to delete becky's account. incLgrSeqForAccDel(env, becky); @@ -730,6 +742,10 @@ public: env(acctdelete(becky, alice), Fee(acctDelFee), Ter(tecNO_PERMISSION)); env.close(); + // becky attempts to delete her account using a pseudo-account as the + // destination, which fails since pseudo-accounts have deposit auth enabled. + env(acctdelete(becky, vaultPseudo), Fee(acctDelFee), Ter(tecNO_PERMISSION)); + // alice preauthorizes deposits from becky. Now becky can delete her // account and forward the leftovers to alice. env(deposit::auth(alice, becky)); @@ -1076,6 +1092,7 @@ public: void run() override { + auto const all{jtx::testableAmendments()}; testBasics(); testDirectories(); testOwnedTypes(); @@ -1083,7 +1100,8 @@ public: testImplicitlyCreatedTrustline(); testBalanceTooSmallForFee(); testWithTickets(); - testDest(); + testDest(all); + testDest(all - fixCleanup3_3_0); testDestinationDepositAuthCredentials(); testDeleteCredentialsOwner(); } diff --git a/src/test/app/ConfidentialTransferExtended_test.cpp b/src/test/app/ConfidentialTransferExtended_test.cpp index 953325a6e9..fe5e0b3064 100644 --- a/src/test/app/ConfidentialTransferExtended_test.cpp +++ b/src/test/app/ConfidentialTransferExtended_test.cpp @@ -1653,30 +1653,35 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase mptAlice.generateKeyPair(carol); mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)}); - // Bob delegates Convert, MergeInbox to dave. - env(delegate::set(bob, dave, {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox"})); + // ConfidentialMPTConvert is not delegable: attempting to grant it as a + // delegated permission is rejected at preflight of DelegateSet. + env(delegate::set(bob, dave, {"ConfidentialMPTConvert"}), Ter(temMALFORMED)); env.close(); - // Carol has no permission from bob to convert on his behalf. + // Bob delegates MergeInbox to dave. + env(delegate::set(bob, dave, {"ConfidentialMPTMergeInbox"})); + env.close(); + + // A Convert carrying a Delegate is rejected at preflight because the + // transaction type is not delegable at all. mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), - .delegate = carol, - .err = terNO_DELEGATE_PERMISSION, + .delegate = dave, + .err = temINVALID, }); - // Dave executes Convert on behalf of bob, registering bob's key. + // Bob converts, registering bob's key. mptAlice.convert({ .account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob), - .delegate = dave, }); env.require(MptBalance(mptAlice, bob, 100)); - // Dave executes Convert again on behalf of bob (no key registration). - mptAlice.convert({.account = bob, .amt = 50, .delegate = dave}); + // Bob converts again (no key registration). + mptAlice.convert({.account = bob, .amt = 50}); // Dave executes MergeInbox on behalf of bob. mptAlice.mergeInbox({.account = bob, .delegate = dave}); @@ -1698,10 +1703,7 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase .err = terNO_DELEGATE_PERMISSION}); // Bob delegates ConfidentialMPTSend to dave. - env(delegate::set( - bob, - dave, - {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox", "ConfidentialMPTSend"})); + env(delegate::set(bob, dave, {"ConfidentialMPTMergeInbox", "ConfidentialMPTSend"})); env.close(); // Dave executes Send on behalf of bob. @@ -1716,10 +1718,7 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase env(delegate::set( bob, dave, - {"ConfidentialMPTConvert", - "ConfidentialMPTMergeInbox", - "ConfidentialMPTSend", - "ConfidentialMPTConvertBack"})); + {"ConfidentialMPTMergeInbox", "ConfidentialMPTSend", "ConfidentialMPTConvertBack"})); env.close(); // Dave executes ConvertBack on behalf of bob. @@ -1766,16 +1765,15 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase // Creating the Delegate SLE consumes one owner reserve slot for bob. auto const bobOwnersBefore = ownerCount(env, bob); - env(delegate::set(bob, carol, {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox"})); + env(delegate::set(bob, carol, {"ConfidentialMPTMergeInbox"})); env.close(); env.require(Owners(bob, bobOwnersBefore + 1)); - // Carol converts and merge inbox on behalf of bob. + // Bob converts; carol merges inbox on behalf of bob. mptAlice.convert({ .account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob), - .delegate = carol, }); mptAlice.mergeInbox({.account = bob, .delegate = carol}); @@ -1784,16 +1782,18 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase env.close(); env.require(Owners(bob, bobOwnersBefore)); - // Carol can no longer convert on behalf of bob. - mptAlice.convert({ + // Bob converts again to populate a fresh inbox. + mptAlice.convert({.account = bob, .amt = 30}); + + // Carol can no longer merge inbox on behalf of bob. + mptAlice.mergeInbox({ .account = bob, - .amt = 30, .delegate = carol, .err = terNO_DELEGATE_PERMISSION, }); - // Bob can still convert by himself. - mptAlice.convert({.account = bob, .amt = 30}); + // Bob can still merge his inbox. + mptAlice.mergeInbox({.account = bob}); } // Verifies that a delegated confidential transfer works correctly when an @@ -1833,16 +1833,15 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase .auditorPubKey = mptAlice.getPubKey(auditor), }); - // Bob delegates Convert and Send permissions to dave. - env(delegate::set(bob, dave, {"ConfidentialMPTSend", "ConfidentialMPTConvert"})); + // Bob delegates Send permission to dave (Convert is not delegable). + env(delegate::set(bob, dave, {"ConfidentialMPTSend"})); env.close(); - // Dave converts on behalf of bob. + // Bob converts. mptAlice.convert({ .account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob), - .delegate = dave, }); mptAlice.mergeInbox({.account = bob}); @@ -2229,7 +2228,7 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase mpt.pay(alice, frank, 40); mpt.generateKeyPair(frank); - env(delegate::set(bob, dave, {"ConfidentialMPTConvert", "ConfidentialMPTConvertBack"})); + env(delegate::set(bob, dave, {"ConfidentialMPTConvertBack"})); env(delegate::set(carol, erin, {"ConfidentialMPTSend"})); env(delegate::set(bob, erin, {"ConfidentialMPTMergeInbox"})); env.close(); @@ -2238,15 +2237,15 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase auto const bobSeq = env.seq(bob); auto const carolSeq = env.seq(carol); auto const frankSeq = env.seq(frank); - auto const batchFee = batch::calcConfidentialBatchFee(env, 3, 6); + auto const batchFee = batch::calcConfidentialBatchFee(env, 4, 6); - // Dave submits the batch. Bob's convert and convertback use Dave as Delegate; + // Dave submits the batch. Bob's convertback uses Dave as Delegate; + // Convert is not delegable, so Bob signs his own convert inner tx. // Carol's send and Bob's mergeInbox use Erin as Delegate. Frank's // convert and mergeInbox are non-delegated. auto jv1 = mpt.convertBackJV({.account = bob, .amt = 30}, bobSeq); jv1[jss::Delegate] = dave.human(); - auto jv2 = mpt.convertJV({.account = bob, .amt = 20}, bobSeq + 1); - jv2[jss::Delegate] = dave.human(); + auto const jv2 = mpt.convertJV({.account = bob, .amt = 20}, bobSeq + 1); auto jv3 = mpt.sendJV({.account = carol, .dest = bob, .amt = 15}, carolSeq); jv3[jss::Delegate] = erin.human(); auto const jv4 = mpt.convertJV( @@ -2262,7 +2261,7 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase batch::Inner(jv4, frankSeq), batch::Inner(jv5, frankSeq + 1), batch::Inner(jv6, bobSeq + 2), - batch::Sig(erin, frank), + batch::Sig(erin, frank, bob), Ter(tesSUCCESS)); env.close(); @@ -2283,7 +2282,10 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase BEAST_EXPECT(mpt.getIssuanceConfidentialBalance() == 175); } - // Test invalid scenarios for delegation with tickets. + // Test invalid scenarios for delegation with tickets. ConfidentialMPTConvert + // is not delegable, so ConfidentialMPTConvertBack (which is delegable and + // whose ZK proof also binds to the transaction/ticket sequence) is used as + // the delegated operation. Carol acts as bob's delegate throughout. void testInvalidDelegationWithTickets(FeatureBitset features) { @@ -2309,33 +2311,52 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase mptAlice.generateKeyPair(bob); mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)}); - // Bob grants carol permissions. - env(delegate::set(bob, carol, {"ConfidentialMPTConvert"})); + // Give bob a confidential spending balance to convert back from. + mptAlice.convert({.account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({.account = bob}); + + // Bob delegates ConfidentialMPTConvertBack to carol. + env(delegate::set(bob, carol, {"ConfidentialMPTConvertBack"})); env.close(); uint64_t const amt = 10; - auto const bf = generateBlindingFactor(); - auto const holderCt = mptAlice.encryptAmount(bob, amt, bf); - auto const issuerCt = mptAlice.encryptAmount(alice, amt, bf); + + // Every case below fails, so bob's spending balance and version never + // change; capture the crypto material needed to build proofs once. + auto const spendingBalance = requireOptional( + mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending), + "Missing spending balance."); + auto const encSpending = requireOptional( + mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending), + "Missing encrypted spending balance."); + auto const version = mptAlice.getMPTokenVersion(bob); + auto const pcBf = generateBlindingFactor(); + auto const pc = mptAlice.getPedersenCommitment(spendingBalance, pcBf); + + // Build a ConvertBack proof bound to a given sequence. + auto proofForSeq = [&](std::uint32_t seq) { + return mptAlice.getConvertBackProof( + bob, + amt, + getConvertBackContextHash(bob, mptAlice.issuanceID(), seq, version), + { + .pedersenCommitment = pc, + .amt = spendingBalance, + .encryptedAmt = encSpending, + .blindingFactor = pcBf, + }); + }; // Invalid: proof built with wrong ticket sequence (ticketSeq + 1). { auto const ticketSeq = env.seq(bob) + 1; env(ticket::create(bob, 1)); - auto const badCtxHash = - getConvertContextHash(bob, mptAlice.issuanceID(), ticketSeq + 1); - auto const badProof = requireOptional( - mptAlice.getSchnorrProof(bob, badCtxHash), "Missing Schnorr Proof."); - - mptAlice.convert({ + mptAlice.convertBack({ .account = bob, .amt = amt, - .proof = strHex(badProof), - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, + .proof = proofForSeq(ticketSeq + 1), + .pedersenCommitment = pc, .delegate = carol, .ticketSeq = ticketSeq, .err = tecBAD_PROOF, @@ -2346,18 +2367,12 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase { auto const ticketSeq = env.seq(bob) + 1; env(ticket::create(bob, 1)); - auto const badCtxHash = getConvertContextHash(bob, mptAlice.issuanceID(), env.seq(bob)); - auto const badProof = requireOptional( - mptAlice.getSchnorrProof(bob, badCtxHash), "Missing Schnorr Proof."); - mptAlice.convert({ + mptAlice.convertBack({ .account = bob, .amt = amt, - .proof = strHex(badProof), - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, + .proof = proofForSeq(env.seq(bob)), + .pedersenCommitment = pc, .delegate = carol, .ticketSeq = ticketSeq, .err = tecBAD_PROOF, @@ -2366,13 +2381,9 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase // Invalid: ticket sequence is far in the future and hasn't been created yet. { - mptAlice.convert({ + mptAlice.convertBack({ .account = bob, .amt = amt, - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, .delegate = carol, .ticketSeq = env.seq(bob) + 100, .err = terPRE_TICKET, @@ -2381,13 +2392,9 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase // Invalid: ticket sequence is in the past but was never created. { - mptAlice.convert({ + mptAlice.convertBack({ .account = bob, .amt = amt, - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, .delegate = carol, .ticketSeq = 1, .err = tefNO_TICKET, @@ -2395,17 +2402,14 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase } // Invalid: the delegated account, carol, creates a ticket and uses it. + // The ticket must belong to the delegator (bob), not the delegate. { auto const carolTicketSeq = env.seq(carol) + 1; env(ticket::create(carol, 1)); - mptAlice.convert({ + mptAlice.convertBack({ .account = bob, .amt = amt, - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, .delegate = carol, .ticketSeq = carolTicketSeq, .err = tefNO_TICKET, @@ -2418,25 +2422,31 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase auto const ticketSeq = env.seq(bob) + 1; env(ticket::create(bob, 1)); - // Build proof using ticketSeq. - auto const ctxHashForTicket = - getConvertContextHash(bob, mptAlice.issuanceID(), ticketSeq); - auto const proof = requireOptional( - mptAlice.getSchnorrProof(bob, ctxHashForTicket), "Missing Schnorr Proof."); - - // Submit without ticket. - mptAlice.convert({ + // Submit without a ticket; proof is bound to ticketSeq. + mptAlice.convertBack({ .account = bob, .amt = amt, - .proof = strHex(proof), - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, + .proof = proofForSeq(ticketSeq), + .pedersenCommitment = pc, .delegate = carol, .err = tecBAD_PROOF, }); } + + // Valid: carol converts back on bob's behalf using a ticket owned by bob, + // with a proof correctly bound to that ticket sequence. bob's spending + // balance drops from 100 to 90. + { + auto const ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .delegate = carol, + .ticketSeq = ticketSeq, + }); + } } // Verifies that delegation works correctly when the delegating account uses @@ -2471,19 +2481,16 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase mptAlice.generateKeyPair(carol); mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)}); - // Bob grants dave permissions. + // Bob grants dave permissions (Convert is not delegable). env(delegate::set( bob, dave, - {"ConfidentialMPTConvert", - "ConfidentialMPTMergeInbox", - "ConfidentialMPTSend", - "ConfidentialMPTConvertBack"})); + {"ConfidentialMPTMergeInbox", "ConfidentialMPTSend", "ConfidentialMPTConvertBack"})); // Alice grants dave permission to clawback on her behalf. env(delegate::set(alice, dave, {"ConfidentialMPTClawback"})); env.close(); - // Dave executes Convert on behalf of bob using ticket. + // Bob converts using a ticket. auto ticketSeq = env.seq(bob) + 1; env(ticket::create(bob, 1)); BEAST_EXPECT(env.seq(bob) != ticketSeq); @@ -2491,7 +2498,6 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase .account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob), - .delegate = dave, .ticketSeq = ticketSeq, }); env.require(MptBalance(mptAlice, bob, 100)); diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index 0fc5d6f845..6450ceeb61 100644 --- a/src/test/app/ConfidentialTransfer_test.cpp +++ b/src/test/app/ConfidentialTransfer_test.cpp @@ -616,7 +616,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, }); } @@ -637,7 +637,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor), }); @@ -880,11 +880,11 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); - // Create with tmfMPTCannotEnableCanHoldConfidentialBalance + // Create with tifMPTCanHoldConfidentialBalance mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, - .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance, + .immutableFlags = tifMPTCanHoldConfidentialBalance, }); mptAlice.generateKeyPair(alice); @@ -893,7 +893,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase // because the issuance cannot mutate canConfidentialAmount mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, .issuerPubKey = mptAlice.getPubKey(alice), .err = tecNO_PERMISSION, }); @@ -965,15 +965,11 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); - mptAlice.create({ - .ownerCount = 1, - .flags = tfMPTCanTransfer | tfMPTCanLock, - .mutableFlags = tmfMPTCanMutateTransferFee, - }); + mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock}); mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, .transferFee = 100, .err = temBAD_TRANSFER_FEE, }); @@ -986,16 +982,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); - mptAlice.create({ - .transferFee = 100, - .ownerCount = 1, - .flags = tfMPTCanTransfer | tfMPTCanLock, - .mutableFlags = tmfMPTCanMutateTransferFee, - }); + mptAlice.create( + {.transferFee = 100, .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock}); mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, .err = tecNO_PERMISSION, }); } @@ -1007,11 +999,9 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); - mptAlice.create({ - .ownerCount = 1, - .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, - .mutableFlags = tmfMPTCanMutateTransferFee, - }); + mptAlice.create( + {.ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance}); mptAlice.set({ .account = alice, @@ -5087,7 +5077,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase testcase("mutate lsfMPTCanHoldConfidentialBalance"); using namespace test::jtx; - // can not create mpt issuance with tmfMPTCannotEnableCanHoldConfidentialBalance + // can not create mpt issuance with tifMPTCanHoldConfidentialBalance // when featureDynamicMPT is disabled { Env env{*this, features - featureDynamicMPT}; @@ -5097,12 +5087,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.create({ .ownerCount = 0, - .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance, + .immutableFlags = tifMPTCanHoldConfidentialBalance, .err = temDISABLED, }); } - // can not create mpt issuance with tmfMPTCannotEnableCanHoldConfidentialBalance when + // can not create mpt issuance with tifMPTCanHoldConfidentialBalance when // featureConfidentialTransfer is disabled { Env env{*this, features - featureConfidentialTransfer}; @@ -5112,12 +5102,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.create({ .ownerCount = 0, - .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance, + .immutableFlags = tifMPTCanHoldConfidentialBalance, .err = temDISABLED, }); } - // if lsmfMPTCannotEnableCanHoldConfidentialBalance is set, can not set/clear + // if lsifMPTCanHoldConfidentialBalance is set, can not set/clear // lsfMPTCanHoldConfidentialBalance { Env env{*this, features}; @@ -5128,12 +5118,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance, + .immutableFlags = tifMPTCanHoldConfidentialBalance, }); mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, .err = tecNO_PERMISSION, }); } @@ -5148,7 +5138,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, - .mutableFlags = tmfMPTCanEnableCanLock, + .immutableFlags = tifMPTCanLock, }); mptAlice.authorize({ @@ -5200,14 +5190,14 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase // lsfMPTCanHoldConfidentialBalance was already set mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, }); verifyToggle(tesSUCCESS, 10); - // set tmfMPTSetCanHoldConfidentialBalance again + // set tfMPTSetCanHoldConfidentialBalance again mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, }); verifyToggle(tesSUCCESS, 30); } @@ -5220,7 +5210,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); - // lsmfMPTCannotEnableCanHoldConfidentialBalance is false by default, + // lsifMPTCanHoldConfidentialBalance is false by default, // so that lsfMPTCanHoldConfidentialBalance can be mutated mptAlice.create({ .ownerCount = 1, @@ -5243,7 +5233,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase // confidential outstanding balance mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, .err = tecNO_PERMISSION, }); } diff --git a/src/test/app/Credentials_test.cpp b/src/test/app/Credentials_test.cpp index 1f6ec012c8..ff3489884e 100644 --- a/src/test/app/Credentials_test.cpp +++ b/src/test/app/Credentials_test.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -24,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -425,7 +427,6 @@ struct Credentials_test : public beast::unit_test::Suite Account const subject{"subject"}; { - using namespace jtx; Env env{*this, features}; env.fund(XRP(5000), subject, issuer); @@ -566,10 +567,27 @@ struct Credentials_test : public beast::unit_test::Suite // End test env.close(); } + + { + testcase("Credentials fail, subject is a pseudo-account."); + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = subject, .asset = xrpIssue()}); + env(tx); + env.close(); + + auto const sleVault = env.le(keylet); + if (!BEAST_EXPECT(sleVault)) + return; + Account const vaultPseudo{"vault", sleVault->at(sfAccount)}; + auto const expectedResult = + features[fixCleanup3_3_0] ? Ter(tecPSEUDO_ACCOUNT) : Ter(tesSUCCESS); + + env(credentials::create(vaultPseudo, issuer, credType), expectedResult); + env.close(); + } } { - using namespace jtx; Env env{*this, features}; env.fund(XRP(5000), issuer); @@ -583,7 +601,6 @@ struct Credentials_test : public beast::unit_test::Suite } { - using namespace jtx; Env env{*this, features}; auto const reserve = drops(env.current()->fees().reserve); @@ -1157,6 +1174,7 @@ struct Credentials_test : public beast::unit_test::Suite testCredentialsDelete(all); testCreateFailed(all); testCreateFailed(all - fixDirectoryLimit); + testCreateFailed(all - fixCleanup3_3_0); testAcceptFailed(all); testDeleteFailed(all); testFeatureFailed(all - featureCredentials); diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp index 788514e284..1166816115 100644 --- a/src/test/app/Delegate_test.cpp +++ b/src/test/app/Delegate_test.cpp @@ -236,7 +236,7 @@ class Delegate_test : public beast::unit_test::Suite env(delegate::set(gw, Account("unknown"), {"Payment"}), Ter(tecNO_TARGET)); } - // Delegating to a pseudo-account is not allowed, should return tecNO_PERMISSION + // Delegating to a pseudo-account is not allowed, should return tecPSEUDO_ACCOUNT { Vault const vault{env}; auto [tx, keylet] = vault.create({.owner = gw, .asset = xrpIssue()}); @@ -246,7 +246,7 @@ class Delegate_test : public beast::unit_test::Suite auto const sleVault = env.le(keylet); BEAST_EXPECT(sleVault); Account const vaultPseudo{"vault", sleVault->at(sfAccount)}; - env(delegate::set(gw, vaultPseudo, {"Payment"}), Ter(tecNO_PERMISSION)); + env(delegate::set(gw, vaultPseudo, {"Payment"}), Ter(tecPSEUDO_ACCOUNT)); } // non-delegable transaction @@ -2167,11 +2167,12 @@ class Delegate_test : public beast::unit_test::Suite env(delegate::set(alice, bob, {"MPTokenIssuanceLock"})); env.close(); - // Field is not permitted, permitted fields for delegation is defined in - // permissions.macro. + // tfMPTSetCanLock is a valid MPTokenIssuanceSet flag but is not + // covered by the MPTokenIssuanceLock granular permission, so a + // delegate holding only that permission cannot set it. mpt.set( {.account = alice, - .mutableFlags = 2, + .flags = tfMPTSetCanLock, .delegate = bob, .err = terNO_DELEGATE_PERMISSION}); @@ -2749,7 +2750,7 @@ class Delegate_test : public beast::unit_test::Suite // DO NOT modify expectedDelegableCount unless all scenarios, including // edge cases, have been fully tested and verified. // ==================================================================== - std::size_t const expectedDelegableCount = 57; + std::size_t const expectedDelegableCount = 56; BEAST_EXPECTS( delegableCount == expectedDelegableCount, diff --git a/src/test/app/DepositAuth_test.cpp b/src/test/app/DepositAuth_test.cpp index c75bdeaf3a..881441e0f9 100644 --- a/src/test/app/DepositAuth_test.cpp +++ b/src/test/app/DepositAuth_test.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -28,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -444,7 +446,7 @@ struct DepositPreauth_test : public beast::unit_test::Suite } void - testInvalid() + testInvalid(FeatureBitset features) { testcase("Invalid"); @@ -453,7 +455,7 @@ struct DepositPreauth_test : public beast::unit_test::Suite Account const becky{"becky"}; Account const carol{"carol"}; - Env env(*this); + Env env(*this, features); // Tell env about alice, becky and carol since they are not yet funded. env.memoize(alice); @@ -559,6 +561,25 @@ struct DepositPreauth_test : public beast::unit_test::Suite env.close(); env.require(Owners(alice, 0)); env.require(Owners(becky, 0)); + + { + // alice attempts to authorize a pseudo-account. + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = becky, .asset = xrpIssue()}); + env(tx); + env.close(); + + auto const sleVault = env.le(keylet); + if (!BEAST_EXPECT(sleVault)) + return; + Account const vaultPseudo{"vault", sleVault->at(sfAccount)}; + + auto const expectedResult = + features[fixCleanup3_3_0] ? Ter(tecPSEUDO_ACCOUNT) : Ter(tesSUCCESS); + env(deposit::auth(alice, vaultPseudo), expectedResult); + env.close(); + env.require(Owners(alice, features[fixCleanup3_3_0] ? 0 : 1)); + } } void @@ -1419,8 +1440,9 @@ struct DepositPreauth_test : public beast::unit_test::Suite run() override { testEnable(); - testInvalid(); auto const supported{jtx::testableAmendments()}; + testInvalid(supported); + testInvalid(supported - fixCleanup3_3_0); testPayment(supported - featureCredentials); testPayment(supported); testCredentialsPayment(); diff --git a/src/test/app/LedgerReplay_test.cpp b/src/test/app/LedgerReplay_test.cpp index 7b521402a4..2e2c80d6f8 100644 --- a/src/test/app/LedgerReplay_test.cpp +++ b/src/test/app/LedgerReplay_test.cpp @@ -58,6 +58,8 @@ #include #include #include +#include +#include #include #include #include @@ -333,7 +335,7 @@ public: setPublisherListSequence(PublicKey const&, std::size_t const) override { } - [[nodiscard]] uint256 const& + [[nodiscard]] uint256 getClosedLedgerHash() const override { static uint256 const kHash{}; @@ -958,7 +960,8 @@ struct LedgerReplayer_test : public beast::unit_test::Suite auto reply = std::make_shared( server.msgHandler.processProofPathRequest(request)); BEAST_EXPECT(reply->has_error()); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(reply) == ReplayMsgStatus::BadData); } { // request, wrong hash @@ -982,7 +985,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite auto reply = std::make_shared( server.msgHandler.processProofPathRequest(request)); BEAST_EXPECT(!reply->has_error()); - BEAST_EXPECT(server.msgHandler.processProofPathResponse(reply)); + BEAST_EXPECT(server.msgHandler.processProofPathResponse(reply) == ReplayMsgStatus::Ok); { // bad reply: invalid hash/key sizes @@ -990,37 +993,49 @@ struct LedgerReplayer_test : public beast::unit_test::Suite // reply with undersized ledgerhash (31 bytes) auto bad = std::make_shared(*reply); bad->set_ledgerhash(std::string(31, '\x01')); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(bad) == + ReplayMsgStatus::Malformed); } { // reply with oversized ledgerhash (33 bytes) auto bad = std::make_shared(*reply); bad->set_ledgerhash(std::string(33, '\x01')); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(bad) == + ReplayMsgStatus::Malformed); } { // reply with empty ledgerhash auto bad = std::make_shared(*reply); bad->set_ledgerhash(std::string()); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(bad) == + ReplayMsgStatus::Malformed); } { // reply with undersized key (31 bytes) auto bad = std::make_shared(*reply); bad->set_key(std::string(31, '\x01')); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(bad) == + ReplayMsgStatus::Malformed); } { // reply with oversized key (33 bytes) auto bad = std::make_shared(*reply); bad->set_key(std::string(33, '\x01')); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(bad) == + ReplayMsgStatus::Malformed); } { // reply with empty key auto bad = std::make_shared(*reply); bad->set_key(std::string()); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(bad) == + ReplayMsgStatus::Malformed); } } @@ -1030,13 +1045,18 @@ struct LedgerReplayer_test : public beast::unit_test::Suite std::string r(reply->ledgerheader()); r.back()--; reply->set_ledgerheader(r); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(reply) == + ReplayMsgStatus::Malformed); r.back()++; reply->set_ledgerheader(r); - BEAST_EXPECT(server.msgHandler.processProofPathResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(reply) == ReplayMsgStatus::Ok); // bad proof path reply->mutable_path()->RemoveLast(); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(reply) == + ReplayMsgStatus::Malformed); } } } @@ -1054,14 +1074,16 @@ struct LedgerReplayer_test : public beast::unit_test::Suite auto reply = std::make_shared( server.msgHandler.processReplayDeltaRequest(request)); BEAST_EXPECT(reply->has_error()); - BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(reply) == ReplayMsgStatus::BadData); // request, wrong hash uint256 hash(1234567); request->set_ledgerhash(hash.data(), hash.size()); reply = std::make_shared( server.msgHandler.processReplayDeltaRequest(request)); BEAST_EXPECT(reply->has_error()); - BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(reply) == ReplayMsgStatus::BadData); } { @@ -1071,7 +1093,8 @@ struct LedgerReplayer_test : public beast::unit_test::Suite auto reply = std::make_shared( server.msgHandler.processReplayDeltaRequest(request)); BEAST_EXPECT(!reply->has_error()); - BEAST_EXPECT(server.msgHandler.processReplayDeltaResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(reply) == ReplayMsgStatus::Ok); { // bad reply: invalid hash sizes @@ -1079,19 +1102,25 @@ struct LedgerReplayer_test : public beast::unit_test::Suite // reply with undersized ledgerhash (31 bytes) auto bad = std::make_shared(*reply); bad->set_ledgerhash(std::string(31, '\x01')); - BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(bad) == + ReplayMsgStatus::Malformed); } { // reply with oversized ledgerhash (33 bytes) auto bad = std::make_shared(*reply); bad->set_ledgerhash(std::string(33, '\x01')); - BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(bad) == + ReplayMsgStatus::Malformed); } { // reply with empty ledgerhash auto bad = std::make_shared(*reply); bad->set_ledgerhash(std::string()); - BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(bad) == + ReplayMsgStatus::Malformed); } } @@ -1101,17 +1130,77 @@ struct LedgerReplayer_test : public beast::unit_test::Suite std::string r(reply->ledgerheader()); r.back()--; reply->set_ledgerheader(r); - BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(reply) == + ReplayMsgStatus::Malformed); r.back()++; reply->set_ledgerheader(r); - BEAST_EXPECT(server.msgHandler.processReplayDeltaResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(reply) == ReplayMsgStatus::Ok); // bad txns reply->mutable_transaction()->RemoveLast(); - BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(reply) == + ReplayMsgStatus::Malformed); } } } + void + testTruncatedHeader() + { + testcase("TruncatedLedgerHeader"); + LedgerServer server(*this, {.initLedgers = 1}); + auto const l = server.ledgerMaster.getClosedLedger(); + + auto runNoThrow = [this](auto fn, char const* what) { + try + { + BEAST_EXPECT(fn() == ReplayMsgStatus::Malformed); + } + catch (std::exception const& e) + { + fail( + std::format("processor threw on truncated header ({}): {}", what, e.what()), + __FILE__, + __LINE__); + } + catch (...) + { + fail( + std::format("processor threw unknown exception ({}) on truncated header", what), + __FILE__, + __LINE__); + } + }; + + { + auto request = std::make_shared(); + request->set_ledgerhash(l->header().hash.data(), l->header().hash.size()); + auto reply = std::make_shared( + server.msgHandler.processReplayDeltaRequest(request)); + BEAST_EXPECT(!reply->has_error()); + + reply->set_ledgerheader(std::string(1, '\x00')); + runNoThrow( + [&] { return server.msgHandler.processReplayDeltaResponse(reply); }, "ReplayDelta"); + } + + { + auto request = std::make_shared(); + request->set_ledgerhash(l->header().hash.data(), l->header().hash.size()); + request->set_type(protocol::TMLedgerMapType::lmACCOUNT_STATE); + request->set_key(keylet::skip().key.data(), keylet::skip().key.size()); + auto reply = std::make_shared( + server.msgHandler.processProofPathRequest(request)); + BEAST_EXPECT(!reply->has_error()); + + reply->set_ledgerheader(std::string(1, '\x00')); + runNoThrow( + [&] { return server.msgHandler.processProofPathResponse(reply); }, "ProofPath"); + } + } + void testTaskParameter() { @@ -1514,6 +1603,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite { testProofPath(); testReplayDelta(); + testTruncatedHeader(); testTaskParameter(); testConfig(); testHandshake(); diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp index befc46e2ae..c9adce0305 100644 --- a/src/test/app/MPToken_test.cpp +++ b/src/test/app/MPToken_test.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -59,6 +60,7 @@ #include #include #include +#include #include #include @@ -602,9 +604,9 @@ class MPToken_test : public beast::unit_test::Suite mptAlice.authorize({.account = bob, .holderCount = 1}); - // test invalid flag - only valid flags are tfMPTLock (1) and Unlock - // (2) - mptAlice.set({.account = alice, .flags = 0x00000008, .err = temINVALID_FLAG}); + // test invalid flag - an unrecognized flag bit is always + // rejected, regardless of which amendments are enabled + mptAlice.set({.account = alice, .flags = 0x00001000, .err = temINVALID_FLAG}); if (!features[featureSingleAssetVault] && !features[featureDynamicMPT] && !features[featureConfidentialTransfer]) @@ -2114,8 +2116,8 @@ class MPToken_test : public beast::unit_test::Suite jv[jss::TransactionType] = jss::SponsorshipSet; jv[jss::Account] = alice.human(); jv[sfSponsee.fieldName] = carol.human(); - jv[sfFeeAmount.fieldName] = mpt.getJson(JsonOptions::Values::None); - test(jv, sfFeeAmount.fieldName); + jv[sfFeeAmountDelta.fieldName] = mpt.getJson(JsonOptions::Values::None); + test(jv, sfFeeAmountDelta.fieldName); } } BEAST_EXPECT(txWithAmounts.empty()); @@ -3393,26 +3395,26 @@ class MPToken_test : public beast::unit_test::Suite using namespace test::jtx; Account const alice("alice"); - // Can not provide MutableFlags when DynamicMPT amendment is not enabled + // Can not provide ImmutableFlags when DynamicMPT amendment is not enabled { Env env{*this, features - featureDynamicMPT}; MPTTester mptAlice(env, alice); - mptAlice.create({.ownerCount = 0, .mutableFlags = 2, .err = temDISABLED}); - mptAlice.create({.ownerCount = 0, .mutableFlags = 0, .err = temDISABLED}); + mptAlice.create({.ownerCount = 0, .immutableFlags = 2, .err = temDISABLED}); + mptAlice.create({.ownerCount = 0, .immutableFlags = 0, .err = temDISABLED}); } - // MutableFlags contains invalid values + // ImmutableFlags contains invalid values { Env env{*this, features}; MPTTester mptAlice(env, alice); // Value 1 is reserved for MPT lock. - mptAlice.create({.ownerCount = 0, .mutableFlags = 1, .err = temINVALID_FLAG}); - mptAlice.create({.ownerCount = 0, .mutableFlags = 17, .err = temINVALID_FLAG}); - mptAlice.create({.ownerCount = 0, .mutableFlags = 65535, .err = temINVALID_FLAG}); + mptAlice.create({.ownerCount = 0, .immutableFlags = 1, .err = temINVALID_FLAG}); + mptAlice.create({.ownerCount = 0, .immutableFlags = 17, .err = temINVALID_FLAG}); + mptAlice.create({.ownerCount = 0, .immutableFlags = 65535, .err = temINVALID_FLAG}); - // MutableFlags can not be 0 - mptAlice.create({.ownerCount = 0, .mutableFlags = 0, .err = temINVALID_FLAG}); + // ImmutableFlags can not be 0 + mptAlice.create({.ownerCount = 0, .immutableFlags = 0, .err = temINVALID_FLAG}); } } @@ -3425,16 +3427,16 @@ class MPToken_test : public beast::unit_test::Suite Account const alice("alice"); Account const bob("bob"); - // Can not provide MutableFlags, MPTokenMetadata or TransferFee when + // Can not provide mutate related flags, MPTokenMetadata or TransferFee when // DynamicMPT amendment is not enabled { Env env{*this, features - featureDynamicMPT}; MPTTester mptAlice(env, alice, {.holders = {bob}}); auto const mptID = makeMptID(env.seq(alice), alice); - // MutableFlags is not allowed when DynamicMPT is not enabled - mptAlice.set({.account = alice, .id = mptID, .mutableFlags = 2, .err = temDISABLED}); - mptAlice.set({.account = alice, .id = mptID, .mutableFlags = 0, .err = temDISABLED}); + // Mutate related flags is not allowed when DynamicMPT is not enabled + mptAlice.set( + {.account = alice, .id = mptID, .flags = tfMPTSetCanLock, .err = temDISABLED}); // MPTokenMetadata is not allowed when DynamicMPT is not enabled mptAlice.set({.account = alice, .id = mptID, .metadata = "test", .err = temDISABLED}); @@ -3445,19 +3447,19 @@ class MPToken_test : public beast::unit_test::Suite mptAlice.set({.account = alice, .id = mptID, .transferFee = 0, .err = temDISABLED}); } - // Can not provide holder when MutableFlags, MPTokenMetadata or + // Can not provide holder when mutate related flags, MPTokenMetadata or // TransferFee is present { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); auto const mptID = makeMptID(env.seq(alice), alice); - // Holder is not allowed when MutableFlags is present + // Holder is not allowed when mutate related flags is present mptAlice.set( {.account = alice, .holder = bob, .id = mptID, - .mutableFlags = 2, + .flags = tfMPTSetCanLock, .err = temMALFORMED}); // Holder is not allowed when MPTokenMetadata is present @@ -3477,27 +3479,24 @@ class MPToken_test : public beast::unit_test::Suite .err = temMALFORMED}); } - // Can not set Flags when MutableFlags, MPTokenMetadata or + // Can not lock when mutate related flags, MPTokenMetadata or // TransferFee is present { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create( - {.ownerCount = 1, - .mutableFlags = tmfMPTCanMutateMetadata | tmfMPTCanEnableCanLock | - tmfMPTCanMutateTransferFee}); + mptAlice.create({.ownerCount = 1}); - // Setting flags is not allowed when MutableFlags is present + // Lock is not allowed when mutate related flags is present mptAlice.set( - {.account = alice, .flags = tfMPTCanLock, .mutableFlags = 2, .err = temMALFORMED}); + {.account = alice, .flags = tfMPTLock | tfMPTSetCanLock, .err = temMALFORMED}); - // Setting flags is not allowed when MPTokenMetadata is present + // Lock is not allowed when MPTokenMetadata is present mptAlice.set( - {.account = alice, .flags = tfMPTCanLock, .metadata = "test", .err = temMALFORMED}); + {.account = alice, .flags = tfMPTLock, .metadata = "test", .err = temMALFORMED}); - // setting flags is not allowed when TransferFee is present + // Lock is not allowed when TransferFee is present mptAlice.set( - {.account = alice, .flags = tfMPTCanLock, .transferFee = 100, .err = temMALFORMED}); + {.account = alice, .flags = tfMPTLock, .transferFee = 100, .err = temMALFORMED}); } // Flags being 0 or tfFullyCanonicalSig is fine @@ -3509,48 +3508,39 @@ class MPToken_test : public beast::unit_test::Suite {.transferFee = 10, .ownerCount = 1, .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCanMutateTransferFee | tmfMPTCanMutateMetadata}); + .immutableFlags = tifMPTTransferFee}); - mptAlice.set({.account = alice, .flags = 0, .transferFee = 100, .metadata = "test"}); - mptAlice.set( - {.account = alice, - .flags = tfFullyCanonicalSig, - .transferFee = 200, - .metadata = "test2"}); + mptAlice.set({.account = alice, .flags = 0, .metadata = "test"}); + mptAlice.set({.account = alice, .flags = tfFullyCanonicalSig, .metadata = "test2"}); } - // Invalid MutableFlags + // Invalid flags { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); auto const mptID = makeMptID(env.seq(alice), alice); - for (auto const flags : {10000, 0, 5000}) + for (auto const flags : {0x0200u, 0x0800u, 0x2000u, 0x0201u}) { mptAlice.set( - {.account = alice, .id = mptID, .mutableFlags = flags, .err = temINVALID_FLAG}); + {.account = alice, .id = mptID, .flags = flags, .err = temINVALID_FLAG}); } } - // Can not mutate flag which is not mutable + // Can not set flag which is immutable { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create({.ownerCount = 1}); + mptAlice.create( + {.ownerCount = 1, + .immutableFlags = tifMPTCanLock | tifMPTCanTrade | tifMPTCanTransfer | + tifMPTCanClawback | tifMPTCanEscrow | tifMPTRequireAuth | + tifMPTCanHoldConfidentialBalance}); - auto const mutableFlags = { - tmfMPTSetCanLock, - tmfMPTSetRequireAuth, - tmfMPTSetCanEscrow, - tmfMPTSetCanTrade, - tmfMPTSetCanTransfer, - tmfMPTSetCanClawback}; - - for (auto const& mutableFlag : mutableFlags) + for (auto const& f : MPTokenIssuanceSet::flagMapping) { - mptAlice.set( - {.account = alice, .mutableFlags = mutableFlag, .err = tecNO_PERMISSION}); + mptAlice.set({.account = alice, .flags = f.setFlag, .err = tecNO_PERMISSION}); } } @@ -3559,18 +3549,18 @@ class MPToken_test : public beast::unit_test::Suite Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create({.ownerCount = 1, .mutableFlags = tmfMPTCanMutateMetadata}); + mptAlice.create({.ownerCount = 1}); std::string const metadata(kMaxMpTokenMetadataLength + 1, 'a'); mptAlice.set({.account = alice, .metadata = metadata, .err = temMALFORMED}); } - // Can not mutate metadata when it is not mutable + // Can not set metadata when it is immutable { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create({.ownerCount = 1}); + mptAlice.create({.ownerCount = 1, .immutableFlags = tifMPTMetadata}); mptAlice.set({.account = alice, .metadata = "test", .err = tecNO_PERMISSION}); } @@ -3580,7 +3570,7 @@ class MPToken_test : public beast::unit_test::Suite MPTTester mptAlice(env, alice, {.holders = {bob}}); auto const mptID = makeMptID(env.seq(alice), alice); - mptAlice.create({.ownerCount = 1, .mutableFlags = tmfMPTCanMutateTransferFee}); + mptAlice.create({.ownerCount = 1}); mptAlice.set( {.account = alice, @@ -3594,83 +3584,70 @@ class MPToken_test : public beast::unit_test::Suite Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create( - {.ownerCount = 1, - .mutableFlags = tmfMPTCanMutateTransferFee | tmfMPTCanEnableCanTransfer}); + mptAlice.create({.ownerCount = 1}); + // MPTCanTransfer is not set, return tecNO_PERMISSION mptAlice.set({.account = alice, .transferFee = 100, .err = tecNO_PERMISSION}); - // Can not set transfer fee even when trying to set MPTCanTransfer - // at the same time. MPTCanTransfer must be set first, then transfer - // fee can be set in a separate transaction. - mptAlice.set( - {.account = alice, - .mutableFlags = tmfMPTSetCanTransfer, - .transferFee = 100, - .err = tecNO_PERMISSION}); + // Setting a non-zero transfer fee is fine if MPTCanTransfer is + // being enabled in the same transaction + mptAlice.set({.account = alice, .flags = tfMPTSetCanTransfer, .transferFee = 100}); + BEAST_EXPECT(mptAlice.checkFlags(lsfMPTCanTransfer)); + BEAST_EXPECT(mptAlice.checkTransferFee(100)); } - // Can not mutate transfer fee when it is not mutable + // Can not set transfer fee when it is immutable { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create({.transferFee = 10, .ownerCount = 1, .flags = tfMPTCanTransfer}); + mptAlice.create( + {.transferFee = 10, + .ownerCount = 1, + .flags = tfMPTCanTransfer, + .immutableFlags = tifMPTTransferFee}); mptAlice.set({.account = alice, .transferFee = 100, .err = tecNO_PERMISSION}); - mptAlice.set({.account = alice, .transferFee = 0, .err = tecNO_PERMISSION}); } - // Set some flags mutable. Can not mutate the others + // Set some flags immutable. Others can still be set. { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create( {.ownerCount = 1, - .mutableFlags = tmfMPTCanEnableCanTrade | tmfMPTCanEnableCanTransfer | - tmfMPTCanMutateMetadata}); + .immutableFlags = tifMPTCanTrade | tifMPTCanTransfer | tifMPTMetadata}); - // Can not mutate transfer fee - mptAlice.set({.account = alice, .transferFee = 100, .err = tecNO_PERMISSION}); + auto const canEnableFlags = { + tfMPTSetCanLock, tfMPTSetRequireAuth, tfMPTSetCanEscrow, tfMPTSetCanClawback}; - auto const invalidFlags = { - tmfMPTSetCanLock, tmfMPTSetRequireAuth, tmfMPTSetCanEscrow, tmfMPTSetCanClawback}; + // Can not enable immutable flags + mptAlice.set({.account = alice, .flags = tfMPTSetCanTrade, .err = tecNO_PERMISSION}); + mptAlice.set({.account = alice, .flags = tfMPTSetCanTransfer, .err = tecNO_PERMISSION}); - // Can not mutate flags which are not mutable - for (auto const& mutableFlag : invalidFlags) + // Can enable flags which are not immutable + for (auto const& mutableFlag : canEnableFlags) { - mptAlice.set( - {.account = alice, .mutableFlags = mutableFlag, .err = tecNO_PERMISSION}); + mptAlice.set({.account = alice, .flags = mutableFlag}); } - - // Can mutate MPTCanTrade - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTrade}); - - // Can mutate MPTCanTransfer - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTransfer}); - - // Can mutate metadata - mptAlice.set({.account = alice, .metadata = "test"}); - mptAlice.set({.account = alice, .metadata = ""}); } } void - testMutateMPT(FeatureBitset features) + testSetMPT(FeatureBitset features) { - testcase("Mutate MPT"); + testcase("Set MPT"); using namespace test::jtx; Account const alice("alice"); - // Mutate metadata + // Set metadata { Env env{*this, features}; MPTTester mptAlice(env, alice); - mptAlice.create( - {.metadata = "test", .ownerCount = 1, .mutableFlags = tmfMPTCanMutateMetadata}); + mptAlice.create({.metadata = "test", .ownerCount = 1}); std::vector const metadatas = { "mutate metadata", @@ -3691,7 +3668,7 @@ class MPToken_test : public beast::unit_test::Suite BEAST_EXPECT(!mptAlice.isMetadataPresent()); } - // Mutate transfer fee + // Set transfer fee { Env env{*this, features}; MPTTester mptAlice(env, alice); @@ -3699,8 +3676,7 @@ class MPToken_test : public beast::unit_test::Suite {.transferFee = 100, .metadata = "test", .ownerCount = 1, - .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCanMutateTransferFee}); + .flags = tfMPTCanTransfer}); for (std::uint16_t const fee : std::initializer_list{1, 10, 100, 200, 500, 1000, kMaxTransferFee}) @@ -3718,33 +3694,29 @@ class MPToken_test : public beast::unit_test::Suite BEAST_EXPECT(mptAlice.checkTransferFee(10)); } - // Test mutable flag enablement + // Test setting flags { - auto testFlagSet = [&](std::uint32_t createFlags, std::uint32_t setFlags) { + auto testFlagSet = [&](std::uint32_t setFlags) { Env env{*this, features}; MPTTester mptAlice(env, alice); - // Create the MPT object with the specified initial flags - mptAlice.create({.metadata = "test", .ownerCount = 1, .mutableFlags = createFlags}); + // Create issuance and the flags can be enabled once by default. + mptAlice.create({.metadata = "test", .ownerCount = 1}); - // Setting the same mutable capability more than once is harmless. - mptAlice.set({.account = alice, .mutableFlags = setFlags}); - mptAlice.set({.account = alice, .mutableFlags = setFlags}); + // Setting the same immutable flag more than once is harmless. + mptAlice.set({.account = alice, .flags = setFlags}); + mptAlice.set({.account = alice, .flags = setFlags}); }; - testFlagSet(tmfMPTCanEnableCanLock, tmfMPTSetCanLock); - testFlagSet(tmfMPTCanEnableRequireAuth, tmfMPTSetRequireAuth); - testFlagSet(tmfMPTCanEnableCanEscrow, tmfMPTSetCanEscrow); - testFlagSet(tmfMPTCanEnableCanTrade, tmfMPTSetCanTrade); - testFlagSet(tmfMPTCanEnableCanTransfer, tmfMPTSetCanTransfer); - testFlagSet(tmfMPTCanEnableCanClawback, tmfMPTSetCanClawback); + for (auto const& f : MPTokenIssuanceSet::flagMapping) + testFlagSet(f.setFlag); } } void - testMutateCanLock(FeatureBitset features) + testSetCanLock(FeatureBitset features) { - testcase("Mutate MPTCanLock"); + testcase("Set MPTCanLock"); using namespace test::jtx; Account const alice("alice"); @@ -3754,78 +3726,41 @@ class MPToken_test : public beast::unit_test::Suite { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create( - {.ownerCount = 1, - .holderCount = 0, - .flags = tfMPTCanLock | tfMPTCanTransfer, - .mutableFlags = tmfMPTCanEnableCanLock | tmfMPTCanEnableCanTrade | - tmfMPTCanMutateTransferFee}); + mptAlice.create({.ownerCount = 1, .holderCount = 0}); mptAlice.authorize({.account = bob, .holderCount = 1}); - // Lock bob's mptoken - mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock}); + // Lock bob's mptoken fails because alice has not enabled MPTCanLock + mptAlice.set( + {.account = alice, .holder = bob, .flags = tfMPTLock, .err = tecNO_PERMISSION}); - // Can mutate the mutable flags and fields - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanLock}); - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTrade}); - mptAlice.set({.account = alice, .transferFee = 200}); + // set CanLock + mptAlice.set({.account = alice, .flags = tfMPTSetCanLock}); + + // Now can lock + mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock}); } // Global lock { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create( - {.ownerCount = 1, - .holderCount = 0, - .flags = tfMPTCanLock, - .mutableFlags = tmfMPTCanEnableCanLock | tmfMPTCanEnableCanClawback | - tmfMPTCanMutateMetadata}); + mptAlice.create({.ownerCount = 1, .holderCount = 0}); mptAlice.authorize({.account = bob, .holderCount = 1}); - // Lock issuance - mptAlice.set({.account = alice, .flags = tfMPTLock}); - - // Can mutate the mutable flags and fields - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanLock}); - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanClawback}); - mptAlice.set({.account = alice, .metadata = "mutate"}); - } - - // Test lock and unlock after enabling MPTCanLock - { - Env env{*this, features}; - MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create( - {.ownerCount = 1, - .holderCount = 0, - .mutableFlags = tmfMPTCanEnableCanLock | tmfMPTCanEnableCanClawback | - tmfMPTCanMutateMetadata}); - mptAlice.authorize({.account = bob, .holderCount = 1}); - - // Can not lock or unlock before MPTCanLock is enabled + // Lock issuance fails because alice has not enabled MPTCanLock mptAlice.set({.account = alice, .flags = tfMPTLock, .err = tecNO_PERMISSION}); - mptAlice.set({.account = alice, .flags = tfMPTUnlock, .err = tecNO_PERMISSION}); - mptAlice.set( - {.account = alice, .holder = bob, .flags = tfMPTLock, .err = tecNO_PERMISSION}); - mptAlice.set( - {.account = alice, .holder = bob, .flags = tfMPTUnlock, .err = tecNO_PERMISSION}); - // Set MPTCanLock - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanLock}); - - // Can lock and unlock + // Set CanLock + mptAlice.set({.account = alice, .flags = tfMPTSetCanLock}); + // Now can lock mptAlice.set({.account = alice, .flags = tfMPTLock}); - mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock}); - mptAlice.set({.account = alice, .flags = tfMPTUnlock}); - mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTUnlock}); } } void - testMutateRequireAuth(FeatureBitset features) + testSetRequireAuth(FeatureBitset features) { - testcase("Mutate MPTRequireAuth"); + testcase("Set MPTRequireAuth"); using namespace test::jtx; // test enabling RequireAuth flag on the issuance and its effect on payment @@ -3835,16 +3770,13 @@ class MPToken_test : public beast::unit_test::Suite Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create( - {.ownerCount = 1, - .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCanEnableRequireAuth}); + mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer}); mptAlice.authorize({.account = bob}); mptAlice.pay(alice, bob, 1000); - // Set RequireAuth because it is mutable. - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetRequireAuth}); + // Set RequireAuth + mptAlice.set({.account = alice, .flags = tfMPTSetRequireAuth}); // This should fail because bob is not authorized yet. mptAlice.pay(alice, bob, 1000, tecNO_AUTH); @@ -3855,9 +3787,9 @@ class MPToken_test : public beast::unit_test::Suite } void - testMutateCanEscrow(FeatureBitset features) + testSetCanEscrow(FeatureBitset features) { - testcase("Mutate MPTCanEscrow"); + testcase("Set MPTCanEscrow"); using namespace test::jtx; using namespace std::literals; @@ -3868,11 +3800,7 @@ class MPToken_test : public beast::unit_test::Suite auto const carol = Account("carol"); MPTTester mptAlice(env, alice, {.holders = {carol, bob}}); - mptAlice.create( - {.ownerCount = 1, - .holderCount = 0, - .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCanEnableCanEscrow}); + mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer}); mptAlice.authorize({.account = carol}); mptAlice.authorize({.account = bob}); @@ -3888,8 +3816,8 @@ class MPToken_test : public beast::unit_test::Suite Fee(baseFee * 150), Ter(tecNO_PERMISSION)); - // MPTCanEscrow is enabled now - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanEscrow}); + // Set MPTCanEscrow + mptAlice.set({.account = alice, .flags = tfMPTSetCanEscrow}); env(escrow::create(carol, bob, mpt(3)), escrow::kCondition(escrow::kCb1), escrow::kFinishTime(env.now() + 1s), @@ -3897,9 +3825,9 @@ class MPToken_test : public beast::unit_test::Suite } void - testMutateCanTransfer(FeatureBitset features) + testSetCanTransfer(FeatureBitset features) { - testcase("Mutate MPTCanTransfer"); + testcase("Set MPTCanTransfer"); using namespace test::jtx; Account const alice("alice"); @@ -3910,9 +3838,7 @@ class MPToken_test : public beast::unit_test::Suite Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); - mptAlice.create( - {.ownerCount = 1, - .mutableFlags = tmfMPTCanEnableCanTransfer | tmfMPTCanMutateTransferFee}); + mptAlice.create({.ownerCount = 1}); mptAlice.authorize({.account = bob}); mptAlice.authorize({.account = carol}); @@ -3926,20 +3852,10 @@ class MPToken_test : public beast::unit_test::Suite // Can not set non-zero transfer fee when MPTCanTransfer is not set mptAlice.set({.account = alice, .transferFee = 100, .err = tecNO_PERMISSION}); - // Can not set non-zero transfer fee even when trying to set - // MPTCanTransfer at the same time - mptAlice.set( - {.account = alice, - .mutableFlags = tmfMPTSetCanTransfer, - .transferFee = 100, - .err = tecNO_PERMISSION}); - - // Alice sets MPTCanTransfer - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTransfer}); - - // Can set transfer fee now + // Set MPTCanTransfer BEAST_EXPECT(!mptAlice.isTransferFeePresent()); - mptAlice.set({.account = alice, .transferFee = 100}); + mptAlice.set({.account = alice, .flags = tfMPTSetCanTransfer, .transferFee = 100}); + BEAST_EXPECT(mptAlice.checkFlags(lsfMPTCanTransfer)); BEAST_EXPECT(mptAlice.isTransferFeePresent()); // Bob can pay carol @@ -3958,16 +3874,13 @@ class MPToken_test : public beast::unit_test::Suite } } - // Can set transfer fee to zero when tmfMPTCanMutateTransferFee is set. + // Can set transfer fee to zero when transfer fee is mutable (i.e. + // tifMPTTransferFee is not set). { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); - mptAlice.create( - {.transferFee = 100, - .ownerCount = 1, - .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCanMutateTransferFee}); + mptAlice.create({.transferFee = 100, .ownerCount = 1, .flags = tfMPTCanTransfer}); BEAST_EXPECT(mptAlice.checkTransferFee(100)); @@ -3978,9 +3891,9 @@ class MPToken_test : public beast::unit_test::Suite } void - testMutateCanClawback(FeatureBitset features) + testSetCanClawback(FeatureBitset features) { - testcase("Mutate MPTCanClawback"); + testcase("Set MPTCanClawback"); using namespace test::jtx; Env env(*this, features); @@ -3989,8 +3902,7 @@ class MPToken_test : public beast::unit_test::Suite MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create( - {.ownerCount = 1, .holderCount = 0, .mutableFlags = tmfMPTCanEnableCanClawback}); + mptAlice.create({.ownerCount = 1, .holderCount = 0}); // Bob creates an MPToken mptAlice.authorize({.account = bob}); @@ -4001,13 +3913,117 @@ class MPToken_test : public beast::unit_test::Suite // MPTCanClawback is not enabled mptAlice.claw(alice, bob, 1, tecNO_PERMISSION); - // Enable MPTCanClawback - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanClawback}); + // Set MPTCanClawback + mptAlice.set({.account = alice, .flags = tfMPTSetCanClawback}); // Can clawback now mptAlice.claw(alice, bob, 1); } + void + testSetImmutableFlags(FeatureBitset features) + { + testcase("Set MPT ImmutableFlags via MPTokenIssuanceSet"); + + using namespace test::jtx; + Account const alice{"alice"}; + Account const bob{"bob"}; + + // ImmutableFlags requires featureDynamicMPT. + { + Env env(*this, features - featureDynamicMPT); + MPTTester mptAlice(env, alice); + mptAlice.create({.ownerCount = 1}); + + mptAlice.set( + {.account = alice, .immutableFlags = tifMPTCanClawback, .err = temDISABLED}); + } + + // ImmutableFlags containing tifMPTCanHoldConfidentialBalance requires + // featureConfidentialTransfer. + { + Env env(*this, features - featureConfidentialTransfer); + MPTTester mptAlice(env, alice); + mptAlice.create({.ownerCount = 1}); + + mptAlice.set( + {.account = alice, + .immutableFlags = tifMPTCanHoldConfidentialBalance, + .err = temDISABLED}); + } + + // ImmutableFlags of 0, or containing unknown bits, is rejected. + { + Env env(*this, features); + MPTTester mptAlice(env, alice); + mptAlice.create({.ownerCount = 1}); + + mptAlice.set({.account = alice, .immutableFlags = 0, .err = temINVALID_FLAG}); + mptAlice.set({.account = alice, .immutableFlags = 1, .err = temINVALID_FLAG}); + } + + // Holder is not allowed alongside ImmutableFlags, and ImmutableFlags + // can not be combined with Lock/Unlock in the same transaction. + { + Env env(*this, features); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create({.ownerCount = 1}); + + mptAlice.set( + {.account = alice, + .holder = bob, + .immutableFlags = tifMPTCanClawback, + .err = temMALFORMED}); + + mptAlice.set( + {.account = alice, + .flags = tfMPTLock, + .immutableFlags = tifMPTCanClawback, + .err = temMALFORMED}); + } + + // Can sets ImmutableFlags and the capability flags in the same transaction + { + Env env(*this, features); + MPTTester mptAlice(env, alice); + mptAlice.create({.ownerCount = 1}); + + mptAlice.set( + {.account = alice, + .flags = tfMPTSetCanClawback, + .immutableFlags = tifMPTCanClawback}); + + mptAlice.set( + {.account = alice, + .flags = tfMPTSetCanTransfer | tfMPTSetRequireAuth, + .immutableFlags = tifMPTCanTrade}); + } + + // Setting ImmutableFlags persists to the ledger, permanently blocks + // enabling the corresponding capability, and merges (rather than + // overwrites) across multiple transactions. + { + Env env(*this, features); + MPTTester mptAlice(env, alice); + mptAlice.create({.ownerCount = 1}); + + mptAlice.set({.account = alice, .immutableFlags = tifMPTCanClawback}); + BEAST_EXPECT(mptAlice.checkImmutableFlags(tifMPTCanClawback)); + + // The CanClawback can no longer be enabled. + mptAlice.set({.account = alice, .flags = tfMPTSetCanClawback, .err = tecNO_PERMISSION}); + + // A distinct bit merges with the first rather than overwriting it. + // Both CanClawback and CanTrade are now immutable. + mptAlice.set({.account = alice, .immutableFlags = tifMPTCanTrade}); + BEAST_EXPECT(mptAlice.checkImmutableFlags(tifMPTCanClawback | tifMPTCanTrade)); + + // Setting the same bit again is a harmless no-op. + mptAlice.set({.account = alice, .immutableFlags = tifMPTCanClawback}); + BEAST_EXPECT(mptAlice.checkImmutableFlags(tifMPTCanClawback | tifMPTCanTrade)); + } + } + void testMultiSendMaximumAmount(FeatureBitset features) { @@ -4398,14 +4414,14 @@ class MPToken_test : public beast::unit_test::Suite .holders = {alice, carol}, .pay = 100, .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCanEnableCanTrade}); + .immutableFlags = tifMPTCanTrade}); MPTTester const eth( {.env = env, .issuer = gw, .holders = {alice, carol}, .pay = 100, .flags = tfMPTCanTrade, - .mutableFlags = tmfMPTCanEnableCanTrade}); + .immutableFlags = tifMPTCanTrade}); // Can't create env(offer(gw, eth(10), btc(10)), Ter(tecNO_PERMISSION)); @@ -4641,30 +4657,25 @@ class MPToken_test : public beast::unit_test::Suite .issuer = gw, .holders = {alice, carol, bob}, .pay = 1'000, - .flags = tfMPTCanLock | kMptDexFlags, - .mutableFlags = tmfMPTCanEnableRequireAuth | tmfMPTCanEnableCanTrade | - tmfMPTCanEnableCanTransfer}); + .flags = tfMPTCanLock | kMptDexFlags}); MPTTester eth( {.env = env, .issuer = gw, .holders = {alice, carol, bob}, .pay = 1'000, - .flags = tfMPTCanLock | kMptDexFlags, - .mutableFlags = tmfMPTCanEnableCanTransfer}); + .flags = tfMPTCanLock | kMptDexFlags}); MPTTester const usd( {.env = env, .issuer = gw, .holders = {alice, carol, bob}, .pay = 1'000, - .flags = kMptDexFlags | tfMPTCanLock, - .mutableFlags = tmfMPTCanEnableCanTransfer}); + .flags = kMptDexFlags | tfMPTCanLock}); MPTTester const cad( {.env = env, .issuer = gw, .holders = {alice, carol, bob}, .pay = 1'000, - .flags = kMptDexFlags | tfMPTCanLock, - .mutableFlags = tmfMPTCanEnableCanTransfer}); + .flags = kMptDexFlags | tfMPTCanLock}); env(offer(bob, eth(1'000), btc(1'000)), Txflags(tfPassive)); env.close(); @@ -4694,7 +4705,7 @@ class MPToken_test : public beast::unit_test::Suite env(pay(gw, ed, eth(100))); env(pay(gw, ed, btc(100))); env.close(); - btc.set({.mutableFlags = tmfMPTSetRequireAuth}); + btc.set({.flags = tfMPTSetRequireAuth}); // authorize bob to enable the offers trading btc.authorize({.account = gw, .holder = bob}); env.close(); @@ -4932,8 +4943,7 @@ class MPToken_test : public beast::unit_test::Suite .issuer = gw, .holders = {alice, carol, bob}, .pay = 1'000, - .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCanEnableCanTrade}); + .flags = tfMPTCanTransfer}); MPTTester const eth( {.env = env, .issuer = gw, @@ -4952,7 +4962,7 @@ class MPToken_test : public beast::unit_test::Suite env.close(); // Enable MPTCanTrade so BTC can be crossed through offers. - btc.set({.mutableFlags = tmfMPTSetCanTrade}); + btc.set({.flags = tfMPTSetCanTrade}); env(offer(bob, XRP(1), btc(1))); env(offer(bob, btc(1), eth(1))); env(offer(bob, eth(1), usd(1))); @@ -6753,11 +6763,7 @@ class MPToken_test : public beast::unit_test::Suite env.close(); MPTTester mpt( - {.env = env, - .issuer = gw, - .holders = {alice, carol}, - .flags = tfMPTCanTrade, - .mutableFlags = tmfMPTCanEnableCanTransfer}); + {.env = env, .issuer = gw, .holders = {alice, carol}, .flags = tfMPTCanTrade}); // src is issuer uint256 checkId{keylet::check(gw, env.seq(gw)).key}; @@ -6793,7 +6799,7 @@ class MPToken_test : public beast::unit_test::Suite env.close(); // can create now - mpt.set({.account = gw, .mutableFlags = tmfMPTSetCanTransfer}); + mpt.set({.account = gw, .flags = tfMPTSetCanTransfer}); checkId = keylet::check(alice, env.seq(alice)).key; env(check::create(alice, carol, mpt(100))); env.close(); @@ -7223,37 +7229,26 @@ class MPToken_test : public beast::unit_test::Suite auto const txfee = Fee(drops(increment)); auto const badMPT = MPT(gw, 1'000); - auto const makeMPT = [&](std::uint32_t const flags, - Holders holders = {}, - std::uint64_t const pay = 0, - std::optional const mutableFlags = - std::nullopt) { - return MPTTester( - {.env = env, - .issuer = gw, - .holders = holders, - .pay = pay ? std::optional{pay} : std::nullopt, - .flags = flags, - .mutableFlags = mutableFlags}); - }; + auto const makeMPT = + [&](std::uint32_t const flags, Holders holders = {}, std::uint64_t const pay = 0) { + return MPTTester( + {.env = env, + .issuer = gw, + .holders = holders, + .pay = pay ? std::optional{pay} : std::nullopt, + .flags = flags}); + }; auto const makeDexMPT = [&](Holders holders = {}, std::uint64_t const pay = 0) { - return makeMPT( - tfMPTCanLock | kMptDexFlags, - holders, - pay, - tmfMPTCanEnableRequireAuth | tmfMPTCanEnableCanTransfer | - tmfMPTCanEnableCanTrade); + return makeMPT(tfMPTCanLock | kMptDexFlags, holders, pay); }; auto const makeNoTransferMPT = [&](Holders holders = {}, std::uint64_t const pay = 0) { - return makeMPT( - tfMPTCanLock | tfMPTCanTrade, holders, pay, tmfMPTCanEnableCanTransfer); + return makeMPT(tfMPTCanLock | tfMPTCanTrade, holders, pay); }; auto const makeNoTradeMPT = [&](Holders holders = {}, std::uint64_t const pay = 0) { - return makeMPT( - tfMPTCanLock | tfMPTCanTransfer, holders, pay, tmfMPTCanEnableCanTrade); + return makeMPT(tfMPTCanLock | tfMPTCanTransfer, holders, pay); }; // AMMCreate @@ -7299,7 +7294,7 @@ class MPToken_test : public beast::unit_test::Suite // MPTRequireAuth is set // alice is not authorized usd.set({.flags = tfMPTUnlock}); - usd.set({.mutableFlags = tmfMPTSetRequireAuth}); + usd.set({.flags = tfMPTSetRequireAuth}); createFail(usd, alice, tecNO_AUTH); // issuer can create createDeleteAMM(usd, gw); @@ -7316,7 +7311,7 @@ class MPToken_test : public beast::unit_test::Suite createFail(usd2, alice, tecNO_AUTH); // issuer can create createDeleteAMM(usd2, gw); - usd2.set({.mutableFlags = tmfMPTSetCanTransfer}); + usd2.set({.flags = tfMPTSetCanTransfer}); // alice can create createDeleteAMM(usd2, alice); } @@ -7328,7 +7323,7 @@ class MPToken_test : public beast::unit_test::Suite // alice and issuer can't create createFail(usd3, alice, tecNO_PERMISSION); createFail(usd3, gw, tecNO_PERMISSION); - usd3.set({.mutableFlags = tmfMPTSetCanTrade}); + usd3.set({.flags = tfMPTSetCanTrade}); // alice can create createDeleteAMM(usd3, alice); } @@ -7383,7 +7378,7 @@ class MPToken_test : public beast::unit_test::Suite // MPTRequireAuth is set // carol is not authorized by the issuer - usd.set({.mutableFlags = tmfMPTSetRequireAuth}); + usd.set({.flags = tfMPTSetRequireAuth}); env.close(); amm.deposit( {.account = carol, @@ -7429,7 +7424,7 @@ class MPToken_test : public beast::unit_test::Suite .err = Ter(tecNO_AUTH)}); // issuer can deposit amm2.deposit({.account = gw, .tokens = 1'000}); - usd2.set({.mutableFlags = tmfMPTSetCanTransfer}); + usd2.set({.flags = tfMPTSetCanTransfer}); // carol can deposit amm2.deposit({.account = carol, .tokens = 1'000}); } @@ -7499,7 +7494,7 @@ class MPToken_test : public beast::unit_test::Suite usd.set({.flags = tfMPTUnlock}); // MPTRequireAuth is set - usd.set({.mutableFlags = tmfMPTSetRequireAuth}); + usd.set({.flags = tfMPTSetRequireAuth}); usd.authorize({.account = gw, .holder = carol, .flags = tfMPTUnauthorize}); // carol can't withdraw amm.withdraw( @@ -7543,7 +7538,7 @@ class MPToken_test : public beast::unit_test::Suite usd2.authorize({.account = bob, .flags = tfMPTUnauthorize}); // Can redeem env(pay(carol, gw, usd2(1))); - usd2.set({.mutableFlags = tmfMPTSetCanTransfer}); + usd2.set({.flags = tfMPTSetCanTransfer}); // carol can withdraw amm2.withdraw({.account = carol, .asset1Out = usd2(1), .asset2Out = eur(1)}); } @@ -7739,13 +7734,14 @@ public: // Dynamic MPT testInvalidCreateDynamic(all); testInvalidSetDynamic(all); - testMutateMPT(all); - testMutateCanLock(all); - testMutateRequireAuth(all); - testMutateCanEscrow(all); - testMutateCanTransfer(all); - testMutateCanTransfer(all - featureMPTokensV2); - testMutateCanClawback(all); + testSetMPT(all); + testSetCanLock(all); + testSetRequireAuth(all); + testSetCanEscrow(all); + testSetCanTransfer(all); + testSetCanTransfer(all - featureMPTokensV2); + testSetCanClawback(all); + testSetImmutableFlags(all); // Test offer crossing testOfferCrossing(all); diff --git a/src/test/app/Manifest_test.cpp b/src/test/app/Manifest_test.cpp index 50e8ab4a8d..ef2043a22c 100644 --- a/src/test/app/Manifest_test.cpp +++ b/src/test/app/Manifest_test.cpp @@ -399,7 +399,8 @@ public: BEAST_EXPECT( ManifestDisposition::Accepted == cache.applyManifest( - makeManifest(sk, KeyType::Ed25519, kp0.second, KeyType::Secp256k1, 0))); + makeManifest(sk, KeyType::Ed25519, kp0.second, KeyType::Secp256k1, 0), + ManifestRateLimitCapPolicy::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp0.first); BEAST_EXPECT(cache.getMasterKey(kp0.first) == pk); @@ -411,7 +412,8 @@ public: BEAST_EXPECT( ManifestDisposition::Accepted == cache.applyManifest( - makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 1))); + makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 1), + ManifestRateLimitCapPolicy::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first); BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -421,7 +423,8 @@ public: BEAST_EXPECT( ManifestDisposition::BadEphemeralKey == cache.applyManifest( - makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 2))); + makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 2), + ManifestRateLimitCapPolicy::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first); BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -431,7 +434,8 @@ public: // key from a revoked master public key BEAST_EXPECT( ManifestDisposition::Accepted == - cache.applyManifest(makeRevocation(sk, KeyType::Ed25519))); + cache.applyManifest( + makeRevocation(sk, KeyType::Ed25519), ManifestRateLimitCapPolicy::Capped)); BEAST_EXPECT(cache.revoked(pk)); BEAST_EXPECT(cache.getSigningKey(pk) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -902,39 +906,69 @@ public: // applyManifest should accept new manifests with // higher sequence numbers auto const seq0 = cache.sequence(); - BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Accepted); BEAST_EXPECT(cache.sequence() > seq0); auto const seq1 = cache.sequence(); - BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Stale); BEAST_EXPECT(cache.sequence() == seq1); - BEAST_EXPECT(cache.applyManifest(clone(sA1)) == ManifestDisposition::Accepted); - BEAST_EXPECT(cache.applyManifest(clone(sA1)) == ManifestDisposition::Stale); - BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA1), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sA1), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Stale); - BEAST_EXPECT(cache.applyManifest(clone(sA2)) == ManifestDisposition::BadEphemeralKey); + BEAST_EXPECT( + cache.applyManifest(clone(sA2), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::BadEphemeralKey); // applyManifest should accept manifests with max sequence numbers // that revoke the master public key BEAST_EXPECT(!cache.revoked(pkA)); BEAST_EXPECT(sAMax.revoked()); - BEAST_EXPECT(cache.applyManifest(clone(sAMax)) == ManifestDisposition::Accepted); - BEAST_EXPECT(cache.applyManifest(clone(sAMax)) == ManifestDisposition::Stale); - BEAST_EXPECT(cache.applyManifest(clone(sA1)) == ManifestDisposition::Stale); - BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sAMax), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sAMax), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA1), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Stale); BEAST_EXPECT(cache.revoked(pkA)); // applyManifest should reject manifests with invalid signatures - BEAST_EXPECT(cache.applyManifest(clone(sB0)) == ManifestDisposition::Accepted); - BEAST_EXPECT(cache.applyManifest(clone(sB0)) == ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sB0), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sB0), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Stale); BEAST_EXPECT(!deserializeManifest(fake)); - BEAST_EXPECT(cache.applyManifest(clone(sB1)) == ManifestDisposition::Invalid); - BEAST_EXPECT(cache.applyManifest(clone(sB2)) == ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sB1), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Invalid); + BEAST_EXPECT( + cache.applyManifest(clone(sB2), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Accepted); auto const sC0 = makeManifest( kpB2.second, KeyType::Ed25519, randomSecretKey(), KeyType::Ed25519, 47); - BEAST_EXPECT(cache.applyManifest(clone(sC0)) == ManifestDisposition::BadMasterKey); + BEAST_EXPECT( + cache.applyManifest(clone(sC0), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::BadMasterKey); } testLoadStore(cache); diff --git a/src/test/app/Path_test.cpp b/src/test/app/Path_test.cpp index 409a9e86f8..29b4a5b048 100644 --- a/src/test/app/Path_test.cpp +++ b/src/test/app/Path_test.cpp @@ -53,6 +53,7 @@ #include #include #include +#include #include #include @@ -1866,6 +1867,103 @@ public: BEAST_EXPECT(same(st, stpath(gw_, ipe(xrpIssue())))); } + void + testAssembleAddDeduplication() + { + testcase("STPathSet::assembleAdd deduplication — O(N^2) regression"); + + static constexpr std::string_view kAccount1 = "A3F19C7B2E5D08146FB93A7C0E2D5184BC6F3A09"; + static constexpr std::string_view kAccount2 = "1D7E4B90C2A6F3851E0B9D47A2C5F8136E0A4B7D"; + static constexpr std::string_view kAccount3 = "F08C36A1D95E27B40CA1F63E8D204B7950E1C3A6"; + static constexpr std::string_view kAccount4 = "4B6209E7F1A3C85D0E94B27Af3D6018C5A7E92B4"; + static constexpr std::string_view kAccount5 = "9E2D7041BCA3F6589D013E7B2A4C6F80159D3E7A"; + static constexpr std::string_view kAccount6 = "7C5A91E384F2D06BA19C4E73D820F516B3A9C0E4"; + static constexpr std::string_view kAccount7 = "2F8B043C6A1E9D75B0C38E14F6A2D509731BC4E8"; + static constexpr std::string_view kAccount8 = "E61D9A30F47C285BA0D31E96C7B4F802513A8D6F"; + + static constexpr AccountID kAccountID1{kAccount1}; + static constexpr AccountID kAccountID2{kAccount2}; + static constexpr AccountID kAccountID3{kAccount3}; + static constexpr AccountID kAccountID4{kAccount4}; + static constexpr AccountID kAccountID5{kAccount5}; + static constexpr AccountID kAccountID6{kAccount6}; + static constexpr AccountID kAccountID7{kAccount7}; + static constexpr AccountID kAccountID8{kAccount8}; + + auto ps = STPathSet{}; + + auto createPathElements = [](auto const& account1, auto const& account2) { + auto base = STPath{}; + base.pushBack( + STPathElement{STPathElement::TypeAccount, account1, xrpCurrency(), account1}); + auto tail = + STPathElement{STPathElement::TypeAccount, account2, xrpCurrency(), account2}; + return std::make_pair(base, tail); + }; + + { + auto [base, tail] = createPathElements(kAccountID1, kAccountID2); + + for (auto i = 0uz; i < 10000; ++i) + { + ps.assembleAdd(base, tail); + } + + BEAST_EXPECT(ps.size() == 1); + } + + { + auto [base, tail] = createPathElements(kAccountID3, kAccountID4); + ps.assembleAdd(base, tail); + } + + { + auto [base, tail] = createPathElements(kAccountID5, kAccountID6); + ps.assembleAdd(base, tail); + } + + { + auto [base, tail] = createPathElements(kAccountID7, kAccountID8); + + auto before = ps.size(); + + for (auto i = 0uz; i < 10000; ++i) + { + ps.assembleAdd(base, tail); + } + + BEAST_EXPECT(ps.size() - before == 1); + } + + { + auto [base, tail] = createPathElements(kAccountID1, kAccountID3); + auto copy = base; + copy.pushBack(tail); + + auto before = ps.size(); + + ps.pushBack(copy); + ps.assembleAdd(base, tail); + + BEAST_EXPECT(ps.size() - before == 1); + } + + { + auto [base, tail] = createPathElements(kAccountID2, kAccountID4); + auto copy = base; + copy.pushBack(tail); + + auto before = ps.size(); + + ps.emplaceBack(copy); + ps.assembleAdd(base, tail); + + BEAST_EXPECT(ps.size() - before == 1); + } + + BEAST_EXPECT(ps.size() == 6); + } + void run() override { @@ -1878,6 +1976,7 @@ public: issuesPathNegativeRippleClientIssue23Smaller(); issuesPathNegativeRippleClientIssue23Larger(); qualityPathsQualitySetAndTest(); + testAssembleAddDeduplication(); trustAutoClearTrustNormalClear(); trustAutoClearTrustAutoClear(); norippleCombinations(); diff --git a/src/test/app/PayChan_test.cpp b/src/test/app/PayChan_test.cpp index 5068472135..592b0ef326 100644 --- a/src/test/app/PayChan_test.cpp +++ b/src/test/app/PayChan_test.cpp @@ -13,13 +13,18 @@ #include #include +#include +#include +#include + #include #include #include #include #include #include -#include // IWYU pragma: keep +#include +#include #include #include #include @@ -39,6 +44,9 @@ #include #include #include +#include +#include +#include #include #include @@ -48,6 +56,7 @@ #include #include #include +#include #include #include @@ -495,7 +504,7 @@ struct PayChan_test : public beast::unit_test::Suite // Owner closes, will close after settleDelay env(claim(alice, chan), Txflags(tfClose)); BEAST_EXPECT(channelExists(*env.current(), chan)); - env.close(settleTimepoint - settleDelay / 2); + env.close(settleTimepoint - (settleDelay / 2)); { // receiver can still claim auto const chanBal = channelBalance(*env.current(), chan); @@ -1587,6 +1596,146 @@ struct PayChan_test : public beast::unit_test::Suite } } + void + testChannelVerifyLoadType(FeatureBitset features) + { + testcase("channel_verify sets kFEE_HEAVY_BURDEN_RPC load type"); + + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + env.fund(XRP(10000), alice, bob); + + auto const pk = alice.pk(); + auto const settleDelay = 3600s; + auto const channelFunds = XRP(1000); + auto const chanStr = to_string(channel(alice, bob, env.seq(alice))); + + env(create(alice, bob, channelFunds, settleDelay, pk)); + env.close(); + + // Step 1: get a valid signature from channel_authorize + auto const authResult = env.rpc("channel_authorize", "alice", chanStr, "1000"); + auto const sig = authResult[jss::result][jss::signature].asString(); + BEAST_EXPECT(!sig.empty()); + auto const pkHex = strHex(pk.slice()); + + // Step 2: build rpc::JsonContext directly so we can inspect loadType + auto& app = env.app(); + resource::Charge loadType = resource::kFeeReferenceRpc; + resource::Consumer c; + rpc::JsonContext context{ + {.j = env.journal, + .app = app, + .loadType = loadType, + .netOps = app.getOPs(), + .ledgerMaster = app.getLedgerMaster(), + .consumer = c, + .role = Role::USER, + .coro = {}, + .infoSub = {}, + .apiVersion = rpc::kApiVersionIfUnspecified}, + {}, + {}}; + json::Value params; + params[jss::public_key] = pkHex; + params[jss::channel_id] = chanStr; + params[jss::amount] = "1000"; + params[jss::signature] = sig; + context.params = std::move(params); + + // Confirm default before calling handler + BEAST_EXPECT(context.loadType == resource::kFeeReferenceRpc); + json::Value result; + Gate g; + app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) { + context.coro = coro; + result = doChannelVerify(context); + g.signal(); + }); + + using namespace std::chrono_literals; + BEAST_EXPECT(g.waitFor(5s)); + // Signature must verify correctly + BEAST_EXPECT(result[jss::signature_verified].asBool()); + // KEY ASSERTION: loadType must be kFEE_HEAVY_BURDEN_RPC after the fix + // Before fix: this will FAIL because loadType stays kFEE_REFERENCE_RPC (20) + // After fix: this will PASS because loadType is kFEE_HEAVY_BURDEN_RPC (3000) + BEAST_EXPECT(context.loadType == resource::kFeeHeavyBurdenRpc); + // Confirm the charge is 150x heavier than the current (broken) default + BEAST_EXPECT(context.loadType.cost() == resource::kFeeHeavyBurdenRpc.cost()); // 3000 + BEAST_EXPECT(context.loadType.cost() != resource::kFeeReferenceRpc.cost()); // not 20 + } + + void + testChannelAuthorizeLoadType(FeatureBitset features) + { + testcase("channel_authorize sets kFEE_HEAVY_BURDEN_RPC load type"); + + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + env.fund(XRP(10000), alice, bob); + + auto const pk = alice.pk(); + auto const settleDelay = 3600s; + auto const chanStr = to_string(channel(alice, bob, env.seq(alice))); + + env(create(alice, bob, XRP(1000), settleDelay, pk)); + env.close(); + + auto& app = env.app(); + resource::Charge loadType = resource::kFeeReferenceRpc; + resource::Consumer c; + rpc::JsonContext context{ + {.j = env.journal, + .app = app, + .loadType = loadType, + .netOps = app.getOPs(), + .ledgerMaster = app.getLedgerMaster(), + .consumer = c, + .role = Role::ADMIN, // channel_authorize requires ADMIN or canSign() + .coro = {}, + .infoSub = {}, + .apiVersion = rpc::kApiVersionIfUnspecified}, + {}, + {}}; + json::Value params; + params[jss::channel_id] = chanStr; + params[jss::amount] = "1000"; + params[jss::secret] = alice.name(); // use account name as seed + context.params = std::move(params); + + // Confirm default before calling handler + BEAST_EXPECT(context.loadType == resource::kFeeReferenceRpc); + json::Value result; + Gate g; + app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) { + context.coro = coro; + result = doChannelAuthorize(context); + g.signal(); + }); + + using namespace std::chrono_literals; + + BEAST_EXPECT(g.waitFor(5s)); + // Must return a valid signature + BEAST_EXPECT(result.isMember(jss::signature)); + BEAST_EXPECT(!result[jss::signature].asString().empty()); + // KEY ASSERTION: loadType must be kFEE_HEAVY_BURDEN_RPC after the fix + // Before fix: FAILS — stays at kFEE_REFERENCE_RPC (charge=20) + // After fix: PASSES — set to kFEE_HEAVY_BURDEN_RPC (charge=3000) + BEAST_EXPECT(context.loadType == resource::kFeeHeavyBurdenRpc); + } + void testMalformedPK(FeatureBitset features) { @@ -1983,6 +2132,8 @@ struct PayChan_test : public beast::unit_test::Suite testMetaAndOwnership(features); testAccountDelete(features); testUsingTickets(features); + testChannelVerifyLoadType(features); + testChannelAuthorizeLoadType(features); } public: diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index 393a6e58f7..b1c762d733 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -58,6 +58,7 @@ #include #include +#include #include #include #include @@ -197,10 +198,12 @@ public: sponsor::SponseeAcc(alice), Ter(temMALFORMED)); - // Invalid feeAmount - for (auto const& amt : {XRP(-1), usd(1)}) + // Invalid FeeAmountDelta + for (auto const& amt : {XRP(0), usd(1)}) { - env(sponsor::set_fee(sponsor, 0, amt), sponsor::SponseeAcc(alice), Ter(temBAD_AMOUNT)); + env(sponsor::set_fee(sponsor, 0, amt, XRP(1)), + sponsor::SponseeAcc(alice), + Ter(temBAD_AMOUNT)); } // Invalid MaxFee for (auto const& amt : {XRP(-1), usd(1)}) @@ -209,6 +212,10 @@ public: sponsor::SponseeAcc(alice), Ter(temBAD_AMOUNT)); } + // Invalid RemainingOwnerCountDelta + env(sponsor::set(sponsor, 0, 0, XRP(2), XRP(1)), + sponsor::SponseeAcc(alice), + Ter(temINVALID)); // Invalid Delete operation env(sponsor::set_reserve(sponsor, tfDeleteObject, 1), @@ -229,12 +236,15 @@ public: sponsor::CounterpartySponsor(alice), Ter(temMALFORMED)); + // Redundant tx + env(sponsor::set(sponsor, 0), sponsor::SponseeAcc(alice), Ter(temREDUNDANT)); + // // preclaim // // Invalid Sponsee - env(sponsor::set(sponsor, 0), sponsor::SponseeAcc(noFunded), Ter(tecNO_DST)); + env(sponsor::set(sponsor, 0, 1), sponsor::SponseeAcc(noFunded), Ter(tecNO_DST)); env.close(); // Invalid Sponsor @@ -290,7 +300,7 @@ public: // Decreasing feeAmount should succeed (refund, negative delta) adjustAccountXRPBalance(env, sponsor, XRP(500)); - env(sponsor::set_fee(sponsor, 0, XRP(800)), + env(sponsor::set_fee(sponsor, 0, XRP(-200)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tesSUCCESS)); @@ -299,7 +309,7 @@ public: // Increasing feeAmount within delta budget should succeed adjustAccountXRPBalance(env, sponsor, XRP(500)); - env(sponsor::set_fee(sponsor, 0, XRP(850)), + env(sponsor::set_fee(sponsor, 0, XRP(50)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tesSUCCESS)); @@ -308,18 +318,15 @@ public: // Increasing feeAmount where delta exceeds balance should fail adjustAccountXRPBalance(env, sponsor, XRP(310)); - env(sponsor::set_fee(sponsor, 0, XRP(1200)), + env(sponsor::set_fee(sponsor, 0, XRP(350)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tecUNFUNDED)); env.close(); // Increasing feeAmount to reach insufficient reserve - auto const currentFeeAmount = env.le(keylet::sponsorship(sponsor.id(), alice.id())) - ->getFieldAmount(sfFeeAmount) - .xrp(); adjustAccountXRPBalance(env, sponsor, XRP(310)); - env(sponsor::set_fee(sponsor, 0, currentFeeAmount + XRP(309)), + env(sponsor::set_fee(sponsor, 0, XRP(309)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tecUNFUNDED)); @@ -353,17 +360,17 @@ public: Account const pseudoAcc("vault", vaultSle->getAccountID(sfAccount)); env.memoize(pseudoAcc); - // Sponsee is a pseudo account -> tecNO_PERMISSION + // Sponsee is a pseudo account -> tecPSEUDO_ACCOUNT env(sponsor::set(sp, 0, 100, XRP(100)), sponsor::SponseeAcc(pseudoAcc), - Ter(tecNO_PERMISSION)); + Ter(tecPSEUDO_ACCOUNT)); env.close(); - // Sponsor is a pseudo account -> tecNO_PERMISSION + // Sponsor is a pseudo account -> tecPSEUDO_ACCOUNT // (submitted by bob with counterpartySponsor pointing to pseudo account) env(sponsor::set(bob, tfDeleteObject), sponsor::CounterpartySponsor(pseudoAcc), - Ter(tecNO_PERMISSION)); + Ter(tecPSEUDO_ACCOUNT)); env.close(); } @@ -543,7 +550,7 @@ public: BEAST_EXPECT(env.balance(sponsor) == XRP(10000) - sle->at(sfFeeAmount) - XRP(1)); // update sponsorship (decrement) - env(sponsor::set(sponsor, 0, 50, XRP(50), XRP(0.5)), + env(sponsor::set(sponsor, 0, -50, XRP(-50), XRP(0.5)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tesSUCCESS)); @@ -557,7 +564,7 @@ public: BEAST_EXPECT(env.balance(sponsor) == XRP(10000) - sle->at(sfFeeAmount) - XRP(2)); // update sponsorship (increment) - env(sponsor::set(sponsor, 0, 200, XRP(200), XRP(2)), + env(sponsor::set(sponsor, 0, 150, XRP(150), XRP(2)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tesSUCCESS)); @@ -591,26 +598,32 @@ public: env.close(); BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice))); - // Cannot create sponsorship with no fee or reserve budget. MaxFee - // and flags do not make a sponsorship object useful by themselves. - env(sponsor::set(sponsor, 0), sponsor::SponseeAcc(alice), Ter(tecNO_PERMISSION)); - env.close(); - BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice))); - env(sponsor::set_max_fee(sponsor, 0, XRP(1)), sponsor::SponseeAcc(alice), Ter(tecNO_PERMISSION)); env.close(); BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice))); - env(sponsor::set(sponsor, 0, 0, XRP(0), XRP(0)), + env(sponsor::set(sponsor, 0, std::nullopt, std::nullopt, XRP(0)), sponsor::SponseeAcc(alice), Ter(tecNO_PERMISSION)); env.close(); BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice))); - // update sponsorship with non-zero value - env(sponsor::set(sponsor, 0, 100, XRP(100), XRP(1)), + // create sponsorship with negative values + env(sponsor::set_reserve(sponsor, 0, -100), + sponsor::SponseeAcc(alice), + Ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice))); + env(sponsor::set_fee(sponsor, 0, XRP(-100)), + sponsor::SponseeAcc(alice), + Ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice))); + + // create sponsorship with non-zero value + env(sponsor::set(sponsor, 0, 100, XRP(101), XRP(1)), sponsor::SponseeAcc(alice), Fee(XRP(1))); env.close(); @@ -618,7 +631,7 @@ public: sle = env.le(keylet::sponsorship(sponsor, alice)); BEAST_EXPECT(sle); BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 100); - BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100)); + BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(101)); BEAST_EXPECT(sle->at(sfMaxFee) == XRP(1)); // update sponsorship flags @@ -648,7 +661,7 @@ public: lsfSponsorshipRequireSignForReserve); // Cannot update sponsorship so both fee and reserve budgets are absent. - env(sponsor::set(sponsor, 0, 0, XRP(0), XRP(0)), + env(sponsor::set(sponsor, 0, -100, XRP(-101), std::nullopt), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tecNO_PERMISSION)); @@ -657,17 +670,17 @@ public: sle = env.le(keylet::sponsorship(sponsor, alice)); BEAST_EXPECT(sle); BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 100); - BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100)); + BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(101)); BEAST_EXPECT(sle->at(sfMaxFee) == XRP(1)); } { // Removing one budget field while the other remains keeps the // Sponsorship valid. Starting state (from above): - // RemainingOwnerCount = 100, FeeAmount = XRP(100). + // RemainingOwnerCount = 100, FeeAmount = XRP(101). // Remove only FeeAmount (set to 0); RemainingOwnerCount remains. - env(sponsor::set_fee(sponsor, 0, XRP(0)), + env(sponsor::set_fee(sponsor, 0, XRP(-101)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tesSUCCESS)); @@ -686,12 +699,51 @@ public: Ter(tesSUCCESS)); env.close(); - env(sponsor::set_reserve(sponsor, 0, 0), + // A negative FeeAmountDelta larger than the current FeeAmount is + // clamped, so only the current FeeAmount is refunded and the field + // is removed. RemainingOwnerCount keeps the Sponsorship valid. + auto const balanceBefore = env.balance(sponsor); + env(sponsor::set_fee(sponsor, 0, XRP(-500)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tesSUCCESS)); env.close(); + sle = env.le(keylet::sponsorship(sponsor, alice)); + BEAST_EXPECT(sle); + BEAST_EXPECT(!sle->isFieldPresent(sfFeeAmount)); + BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 100); + BEAST_EXPECT(env.balance(sponsor) == balanceBefore + XRP(100) - XRP(1)); + + // Restore FeeAmount for the checks below. + env(sponsor::set_fee(sponsor, 0, XRP(100)), + sponsor::SponseeAcc(alice), + Fee(XRP(1)), + Ter(tesSUCCESS)); + env.close(); + + env(sponsor::set_reserve(sponsor, 0, -100), + sponsor::SponseeAcc(alice), + Fee(XRP(1)), + Ter(tesSUCCESS)); + env.close(); + + sle = env.le(keylet::sponsorship(sponsor, alice)); + BEAST_EXPECT(sle); + BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount)); + BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100)); + + // Decreasing FeeAmount below zero must fail with tecNO_PERMISSION + // when there is no RemainingOwnerCount (the budget would become + // entirely empty). Current state: FeeAmount = XRP(100), no + // RemainingOwnerCount. + env(sponsor::set_fee(sponsor, 0, XRP(-101)), + sponsor::SponseeAcc(alice), + Fee(XRP(1)), + Ter(tecNO_PERMISSION)); + env.close(); + + // Confirm that the sponsorship is unchanged. sle = env.le(keylet::sponsorship(sponsor, alice)); BEAST_EXPECT(sle); BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount)); @@ -748,6 +800,160 @@ public: } } + void + testRemainingOwnerCountOverflow() + { + testcase("RemainingOwnerCount overflow and underflow clamping"); + using namespace test::jtx; + Env env{*this, testableAmendments()}; + Account const alice("alice"); + Account const sponsor("sponsor"); + env.fund(XRP(10000), alice, sponsor); + env.close(); + + constexpr std::int32_t kInt32Max = std::numeric_limits::max(); + + // --- Positive overflow: delta causes count to exceed UINT32_MAX --- + { + // Create with count = INT32_MAX. + env(sponsor::set_reserve(sponsor, 0, kInt32Max), + sponsor::SponseeAcc(alice), + Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == + static_cast(kInt32Max)); + + // Add INT32_MAX again: count = 2 * INT32_MAX = 4294967294 (<= UINT32_MAX, still ok). + env(sponsor::set_reserve(sponsor, 0, kInt32Max), + sponsor::SponseeAcc(alice), + Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == + 2u * static_cast(kInt32Max)); + + // Adding 2 more pushes count to 4294967296, exceeding UINT32_MAX: reject. + env(sponsor::set_reserve(sponsor, 0, 2), + sponsor::SponseeAcc(alice), + Ter(tecLIMIT_EXCEEDED)); + env.close(); + + // SLE is unchanged. + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == + 2u * static_cast(kInt32Max)); + + env(sponsor::del(sponsor), sponsor::SponseeAcc(alice), Ter(tesSUCCESS)); + env.close(); + } + + // --- Negative underflow: clamps to 0; fee budget survives --- + { + // Create with count=10 and a fee budget. + env(sponsor::set(sponsor, 0, 10, XRP(100)), + sponsor::SponseeAcc(alice), + Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == 10u); + + // Delta of -20 produces count = -10; clamps to 0 (field absent). + env(sponsor::set_reserve(sponsor, 0, -20), sponsor::SponseeAcc(alice), Ter(tesSUCCESS)); + env.close(); + + auto sle = env.le(keylet::sponsorship(sponsor, alice)); + BEAST_EXPECT(sle); + BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount)); + BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100)); + + env(sponsor::del(sponsor), sponsor::SponseeAcc(alice), Ter(tesSUCCESS)); + env.close(); + } + + // --- Negative underflow: clamped count=0 with no fee budget → no budget --- + { + // Create with count=10, no fee. + env(sponsor::set_reserve(sponsor, 0, 10), sponsor::SponseeAcc(alice), Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == 10u); + + // Delta of -20 would clamp count to 0 with no fee → empty budget → tecNO_PERMISSION. + env(sponsor::set_reserve(sponsor, 0, -20), + sponsor::SponseeAcc(alice), + Ter(tecNO_PERMISSION)); + env.close(); + + // SLE is unchanged. + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == 10u); + + env(sponsor::del(sponsor), sponsor::SponseeAcc(alice), Ter(tesSUCCESS)); + env.close(); + } + } + + void + testConsequences() + { + testcase("Consequences"); + using namespace test::jtx; + Env env{*this, testableAmendments()}; + auto const baseFee = env.current()->fees().base; + + Account const alice("alice"); + Account const sponsor("sponsor"); + env.memoize(alice); + env.memoize(sponsor); + + { + // A positive FeeAmountDelta is the maximum XRP the tx can spend. + auto const jt = env.jt( + sponsor::set_fee(sponsor, 0, XRP(100)), + sponsor::SponseeAcc(alice), + Seq(1), + Fee(baseFee)); + auto const pf = + preflight(env.app(), env.current()->rules(), *jt.stx, TapNone, env.journal); + BEAST_EXPECT(isTesSuccess(pf.ter)); + BEAST_EXPECT(!pf.consequences.isBlocker()); + BEAST_EXPECT(pf.consequences.fee() == drops(baseFee)); + BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(100)); + } + + { + // A negative FeeAmountDelta withdraws from the sponsorship, so the + // transaction cannot spend anything. + auto const jt = env.jt( + sponsor::set_fee(sponsor, 0, XRP(-100)), + sponsor::SponseeAcc(alice), + Seq(1), + Fee(baseFee)); + auto const pf = + preflight(env.app(), env.current()->rules(), *jt.stx, TapNone, env.journal); + BEAST_EXPECT(isTesSuccess(pf.ter)); + BEAST_EXPECT(!pf.consequences.isBlocker()); + BEAST_EXPECT(pf.consequences.fee() == drops(baseFee)); + BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(0)); + } + + { + // No FeeAmountDelta at all. + auto const jt = env.jt( + sponsor::set_reserve(sponsor, 0, 10), + sponsor::SponseeAcc(alice), + Seq(1), + Fee(baseFee)); + auto const pf = + preflight(env.app(), env.current()->rules(), *jt.stx, TapNone, env.journal); + BEAST_EXPECT(isTesSuccess(pf.ter)); + BEAST_EXPECT(!pf.consequences.isBlocker()); + BEAST_EXPECT(pf.consequences.fee() == drops(baseFee)); + BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(0)); + } + } + void testPreFundAndCosign() { @@ -810,7 +1016,7 @@ public: Ter(terINSUF_FEE_B)); env.close(); - env(sponsor::set_reserve(sponsor, 0, 0), sponsor::SponseeAcc(alice), Ter(tesSUCCESS)); + env(sponsor::set_reserve(sponsor, 0, -1), sponsor::SponseeAcc(alice), Ter(tesSUCCESS)); env.close(); // reserve insufficient @@ -2090,7 +2296,7 @@ public: XRP(10)); // clear flag - env(sponsor::set_fee(sponsor, tfSponsorshipClearRequireSignForFee, XRP(10)), + env(sponsor::set(sponsor, tfSponsorshipClearRequireSignForFee), sponsor::SponseeAcc(alice)); env.close(); @@ -2322,7 +2528,7 @@ public: XRP(10)); // clear flag - env(sponsor::set_fee(sponsor, tfSponsorshipClearRequireSignForFee, XRP(10)), + env(sponsor::set(sponsor, tfSponsorshipClearRequireSignForFee), sponsor::SponseeAcc(alice)); env.close(); @@ -4939,7 +5145,7 @@ public: env.close(); // Create pre-funded sponsorship - env(sponsor::set(sponsor, 0, 0, XRP(1)), sponsor::SponseeAcc(alice), Fee(XRP(1))); + env(sponsor::set_fee(sponsor, 0, XRP(1)), sponsor::SponseeAcc(alice), Fee(XRP(1))); env.close(); auto const seq = env.seq(alice); @@ -5443,6 +5649,8 @@ protected: testInvalidSponsorField(); testSimpleSponsorshipSet(); + testRemainingOwnerCountOverflow(); + testConsequences(); testPreFundAndCosign(); testSponsoredFreeTierReserve(); diff --git a/src/test/app/ValidatorList_test.cpp b/src/test/app/ValidatorList_test.cpp index 60228f6723..323c77c780 100644 --- a/src/test/app/ValidatorList_test.cpp +++ b/src/test/app/ValidatorList_test.cpp @@ -278,8 +278,10 @@ private: trustedKeys->load(localSigningPublicOuter, emptyCfgKeys, emptyCfgPublishers)); BEAST_EXPECT(trustedKeys->listed(localSigningPublicOuter)); - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifests.applyManifest(*deserializeManifest(cfgManifest)); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + manifests.applyManifest( + *deserializeManifest(cfgManifest), ManifestRateLimitCapPolicy::Capped); + // NOLINTEND(bugprone-unchecked-optional-access) BEAST_EXPECT( trustedKeys->load(localSigningPublicOuter, emptyCfgKeys, emptyCfgPublishers)); @@ -369,8 +371,10 @@ private: app.config().legacy(Sections::kDatabasePath), env.journal); - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifests.applyManifest(*deserializeManifest(cfgManifest)); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + manifests.applyManifest( + *deserializeManifest(cfgManifest), ManifestRateLimitCapPolicy::Capped); + // NOLINTEND(bugprone-unchecked-optional-access) BEAST_EXPECT(trustedKeys->load(localSigningPublicOuter, cfgKeys, emptyCfgPublishers)); @@ -455,13 +459,16 @@ private: auto const pubRevokedSigning = randomKeyPair(KeyType::Secp256k1); // make this manifest revoked (seq num = max) // -- thus should not be loaded - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - pubManifests.applyManifest(*deserializeManifest(makeManifestString( - pubRevokedPublic, - pubRevokedSecret, - pubRevokedSigning.first, - pubRevokedSigning.second, - std::numeric_limits::max()))); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + pubManifests.applyManifest( + *deserializeManifest(makeManifestString( + pubRevokedPublic, + pubRevokedSecret, + pubRevokedSigning.first, + pubRevokedSigning.second, + std::numeric_limits::max())), + ManifestRateLimitCapPolicy::Capped); + // NOLINTEND(bugprone-unchecked-optional-access) // these two are not revoked (and not in the manifest cache at all.) auto legitKey1 = randomMasterKey(); @@ -494,13 +501,16 @@ private: auto const pubRevokedSigning = randomKeyPair(KeyType::Secp256k1); // make this manifest revoked (seq num = max) // -- thus should not be loaded - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - pubManifests.applyManifest(*deserializeManifest(makeManifestString( - pubRevokedPublic, - pubRevokedSecret, - pubRevokedSigning.first, - pubRevokedSigning.second, - std::numeric_limits::max()))); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + pubManifests.applyManifest( + *deserializeManifest(makeManifestString( + pubRevokedPublic, + pubRevokedSecret, + pubRevokedSigning.first, + pubRevokedSigning.second, + std::numeric_limits::max())), + ManifestRateLimitCapPolicy::Capped); + // NOLINTEND(bugprone-unchecked-optional-access) // this one is not revoked (and not in the manifest cache at all.) auto legitKey = randomMasterKey(); @@ -1218,7 +1228,8 @@ private: BEAST_EXPECT( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifestsOuter.applyManifest(std::move(*m1)) == ManifestDisposition::Accepted); + manifestsOuter.applyManifest(std::move(*m1), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Accepted); BEAST_EXPECT(trustedKeysOuter->listed(masterPublic)); BEAST_EXPECT(trustedKeysOuter->trusted(masterPublic)); BEAST_EXPECT(trustedKeysOuter->listed(signingPublic1)); @@ -1232,7 +1243,8 @@ private: masterPublic, masterPrivate, signingPublic2, signingKeys2.second, 2)); BEAST_EXPECT( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifestsOuter.applyManifest(std::move(*m2)) == ManifestDisposition::Accepted); + manifestsOuter.applyManifest(std::move(*m2), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Accepted); BEAST_EXPECT(trustedKeysOuter->listed(masterPublic)); BEAST_EXPECT(trustedKeysOuter->trusted(masterPublic)); BEAST_EXPECT(trustedKeysOuter->listed(signingPublic2)); @@ -1249,7 +1261,8 @@ private: // NOLINTBEGIN(bugprone-unchecked-optional-access) BEAST_EXPECT(max->revoked()); BEAST_EXPECT( - manifestsOuter.applyManifest(std::move(*max)) == ManifestDisposition::Accepted); + manifestsOuter.applyManifest(std::move(*max), ManifestRateLimitCapPolicy::Capped) == + ManifestDisposition::Accepted); // NOLINTEND(bugprone-unchecked-optional-access) BEAST_EXPECT(manifestsOuter.getSigningKey(masterPublic) == masterPublic); @@ -2668,7 +2681,9 @@ private: auto threshold = listThreshold > 0 ? std::optional(listThreshold) : std::nullopt; if (self) { - valManifests.applyManifest(*deserializeManifest(base64Decode(self->manifest))); + valManifests.applyManifest( + *deserializeManifest(base64Decode(self->manifest)), + ManifestRateLimitCapPolicy::Capped); BEAST_EXPECT( result->load(self->signingPublic, emptyCfgKeys, cfgPublishers, threshold)); } diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 791cf216c3..773ce28963 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -1602,8 +1602,7 @@ class Vault_test : public beast::unit_test::Suite mptt.create( {.flags = tfMPTCanTransfer | tfMPTCanLock | (args.enableClawback ? tfMPTCanClawback : kNone) | - (args.requireAuth ? tfMPTRequireAuth : kNone), - .mutableFlags = tmfMPTCanEnableCanTransfer}); + (args.requireAuth ? tfMPTRequireAuth : kNone)}); PrettyAsset const asset = mptt.issuanceID(); mptt.authorize({.account = owner}); mptt.authorize({.account = depositor}); @@ -2206,9 +2205,7 @@ class Vault_test : public beast::unit_test::Suite Vault const vault{env}; MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create( - {.flags = tfMPTCanTransfer | tfMPTCanLock, - .mutableFlags = tmfMPTCanEnableCanTrade}); + mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); PrettyAsset const asset = mptt.issuanceID(); mptt.authorize({.account = owner}); mptt.authorize({.account = alice}); @@ -2252,7 +2249,7 @@ class Vault_test : public beast::unit_test::Suite env.close(); // Enable CanTrade on the underlying. - mptt.set({.mutableFlags = tmfMPTSetCanTrade}); + mptt.set({.flags = tfMPTSetCanTrade}); env.close(); env(offer(alice, XRP(1), asset(10))); diff --git a/src/test/app/lending/LoanMisc_test.cpp b/src/test/app/lending/LoanMisc_test.cpp index 798cddda17..44c093b3c2 100644 --- a/src/test/app/lending/LoanMisc_test.cpp +++ b/src/test/app/lending/LoanMisc_test.cpp @@ -363,8 +363,7 @@ private: {.env = env, .issuer = issuer, .holders = {lender, borrower}, - .flags = tfMPTCanTransfer | tfMPTCanLock, - .mutableFlags = tmfMPTCanEnableCanTrade}); + .flags = tfMPTCanTransfer | tfMPTCanLock}); PrettyAsset const asset = mpt.issuanceID(); env(pay(issuer, lender, asset(10'000'000))); env(pay(issuer, borrower, asset(100'000))); @@ -399,7 +398,7 @@ private: env.close(); // Enable CanTrade and verify the DEX path is restored. - mpt.set({.mutableFlags = tmfMPTSetCanTrade}); + mpt.set({.flags = tfMPTSetCanTrade}); env.close(); env(offer(lender, XRP(1), asset(10))); diff --git a/src/test/core/Config_test.cpp b/src/test/core/Config_test.cpp index e98a0e1e88..ac5471fd3c 100644 --- a/src/test/core/Config_test.cpp +++ b/src/test/core/Config_test.cpp @@ -1575,6 +1575,87 @@ r.ripple.com:51235 // Above upper bound BEAST_EXPECT(!testDiverged("901")); + + testcase("overlay: manifest counts"); + + // Both keys share one range and one parse path, so exercise each + // through the same helper. + auto testCount = [](std::string const& key, + std::string const& value) -> std::optional { + try + { + Config c; + c.loadFromString("[overlay]\n" + key + "=" + value); + return key == "max_trusted_count" ? c.maxTrustedCount : c.maxUntrustedCount; + } + catch (std::runtime_error const&) + { + return {}; + } + }; + + for (auto const* key : {"max_untrusted_count", "max_trusted_count"}) + { + // Failures. A bad value must surface as std::runtime_error, not + // the std::bad_cast that the underlying parse throws. + BEAST_EXPECT(!testCount(key, "none")); + BEAST_EXPECT(!testCount(key, "0.5")); + BEAST_EXPECT(!testCount(key, "400 manifests")); + BEAST_EXPECT(!testCount(key, "-1")); + + // Below lower bound + BEAST_EXPECT(!testCount(key, "0")); + BEAST_EXPECT(!testCount(key, "49")); + + // In bounds + BEAST_EXPECT(testCount(key, "50") == 50); + BEAST_EXPECT(testCount(key, "51") == 51); + BEAST_EXPECT(testCount(key, "300") == 300); + BEAST_EXPECT(testCount(key, "400") == 400); + BEAST_EXPECT(testCount(key, "999") == 999); + BEAST_EXPECT(testCount(key, "1000") == 1000); + + // Above upper bound + BEAST_EXPECT(!testCount(key, "1001")); + } + + // Each key is independent: setting one leaves the other unset. + { + Config c; + c.loadFromString("[overlay]\nmax_untrusted_count=500"); + BEAST_EXPECT(c.maxUntrustedCount == 500); + BEAST_EXPECT(!c.maxTrustedCount); + } + { + Config c; + c.loadFromString("[overlay]\nmax_trusted_count=500"); + BEAST_EXPECT(c.maxTrustedCount == 500); + BEAST_EXPECT(!c.maxUntrustedCount); + } + + // Both can be set together. + { + Config c; + c.loadFromString("[overlay]\nmax_untrusted_count=250\nmax_trusted_count=750"); + BEAST_EXPECT(c.maxUntrustedCount == 250); + BEAST_EXPECT(c.maxTrustedCount == 750); + } + + // Unset leaves no override, so the use sites fall back to the defaults. + { + Config c; + c.loadFromString("[overlay]\nip_limit=64"); + BEAST_EXPECT(!c.maxUntrustedCount); + BEAST_EXPECT(!c.maxTrustedCount); + } + + // No [overlay] section at all leaves both unset too. + { + Config c; + c.loadFromString(""); + BEAST_EXPECT(!c.maxUntrustedCount); + BEAST_EXPECT(!c.maxTrustedCount); + } } void diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index dddfc88c7f..c6cd49fa26 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include @@ -38,7 +39,6 @@ #include #include -#include #include #include #include @@ -94,21 +94,6 @@ makePedersenParams(PedersenProofParams const& params) } // namespace -struct MPTSetFlagMapping -{ - std::uint32_t setFlag; - std::uint32_t ledgerFlag; -}; - -static constexpr std::array mptSetFlagMappings = {{ - {.setFlag = tmfMPTSetCanLock, .ledgerFlag = lsfMPTCanLock}, - {.setFlag = tmfMPTSetRequireAuth, .ledgerFlag = lsfMPTRequireAuth}, - {.setFlag = tmfMPTSetCanEscrow, .ledgerFlag = lsfMPTCanEscrow}, - {.setFlag = tmfMPTSetCanClawback, .ledgerFlag = lsfMPTCanClawback}, - {.setFlag = tmfMPTSetCanTrade, .ledgerFlag = lsfMPTCanTrade}, - {.setFlag = tmfMPTSetCanTransfer, .ledgerFlag = lsfMPTCanTransfer}, -}}; - void MptFlags::operator()(Env& env) const { @@ -195,7 +180,7 @@ makeMPTCreate(MPTInitDef const& arg) .transferFee = arg.transferFee, .pay = {{arg.holders, *arg.pay}}, .flags = arg.flags, - .mutableFlags = arg.mutableFlags, + .immutableFlags = arg.immutableFlags, .authHolder = arg.authHolder}; } return { @@ -203,7 +188,7 @@ makeMPTCreate(MPTInitDef const& arg) .transferFee = arg.transferFee, .authorize = arg.holders, .flags = arg.flags, - .mutableFlags = arg.mutableFlags, + .immutableFlags = arg.immutableFlags, .authHolder = arg.authHolder}; } @@ -245,8 +230,8 @@ MPTTester::createJV(MPTCreate const& arg) jv[sfMaximumAmount] = std::to_string(*arg.maxAmt); if (arg.domainID) jv[sfDomainID] = to_string(*arg.domainID); - if (arg.mutableFlags) - jv[sfMutableFlags] = *arg.mutableFlags; + if (arg.immutableFlags) + jv[sfImmutableFlags] = *arg.immutableFlags; jv[sfTransactionType] = jss::MPTokenIssuanceCreate; return jv; @@ -264,7 +249,7 @@ MPTTester::create(MPTCreate const& arg) .assetScale = arg.assetScale, .transferFee = arg.transferFee, .metadata = arg.metadata, - .mutableFlags = arg.mutableFlags, + .immutableFlags = arg.immutableFlags, .domainID = arg.domainID}); if (!isTesSuccess(submit(arg, jv))) { @@ -463,8 +448,8 @@ MPTTester::setJV(MPTSet const& arg) jv[sfDelegate] = arg.delegate->human(); if (arg.domainID) jv[sfDomainID] = to_string(*arg.domainID); - if (arg.mutableFlags) - jv[sfMutableFlags] = *arg.mutableFlags; + if (arg.immutableFlags) + jv[sfImmutableFlags] = *arg.immutableFlags; if (arg.transferFee) jv[sfTransferFee] = *arg.transferFee; if (arg.metadata) @@ -487,95 +472,85 @@ MPTTester::set(MPTSet const& arg) {.account = arg.account ? arg.account : issuer_, .holder = arg.holder, .id = arg.id ? arg.id : id_, - .mutableFlags = arg.mutableFlags, + .immutableFlags = arg.immutableFlags, .transferFee = arg.transferFee, .metadata = arg.metadata, .delegate = arg.delegate, .domainID = arg.domainID, .issuerPubKey = arg.issuerPubKey, .auditorPubKey = arg.auditorPubKey}); - if (submit(arg, jv) == tesSUCCESS && ((arg.flags.value_or(0) != 0u) || arg.mutableFlags)) + if (submit(arg, jv) == tesSUCCESS && arg.flags.value_or(0) != 0u) { - if (((arg.flags.value_or(0) != 0u) || arg.mutableFlags)) - { - auto require = [&](std::optional const& holder, bool unchanged) { - auto flags = getFlags(holder); - if (!unchanged) + auto require = [&](std::optional const& holder, bool unchanged) { + auto flags = getFlags(holder); + if (!unchanged) + { + if (arg.flags) { - if (arg.flags) + if (*arg.flags & tfMPTLock) { - if (*arg.flags & tfMPTLock) - { - flags |= lsfMPTLocked; - } - else if (*arg.flags & tfMPTUnlock) - { - flags &= ~lsfMPTLocked; - } + flags |= lsfMPTLocked; + } + else if (*arg.flags & tfMPTUnlock) + { + flags &= ~lsfMPTLocked; } - if (arg.mutableFlags) + for (auto const& f : MPTokenIssuanceSet::flagMapping) { - for (auto const& [setFlag, ledgerFlag] : mptSetFlagMappings) + if ((*arg.flags & f.setFlag) != 0u) { - if ((*arg.mutableFlags & setFlag) != 0u) - { - flags |= ledgerFlag; - } + flags |= f.ledgerFlag; } - - if (*arg.mutableFlags & tmfMPTSetCanHoldConfidentialBalance) - flags |= tfMPTCanHoldConfidentialBalance; } } - env_.require(MptFlags(*this, flags, holder)); - }; - if (arg.account) - require(std::nullopt, arg.holder.has_value()); - if (auto const account = (arg.holder ? std::get_if(&(*arg.holder)) : nullptr)) - require(*account, false); - - if (arg.issuerPubKey) - { - env_.require(RequireAny([&]() -> bool { - return forObject([&](SLEP const& sle) -> bool { - if (sle) - { - auto const issuerPubKey = getPubKey(issuer_); - if (!issuerPubKey) - { - Throw( - "MPTTester::set: issuer's pubkey is not set"); - } - - return strHex((*sle)[sfIssuerEncryptionKey]) == strHex(*issuerPubKey); - } - return false; - }); - })); } - if (arg.auditorPubKey) - { - env_.require(RequireAny([&]() -> bool { - return forObject([&](SLEP const& sle) -> bool { - if (sle) + env_.require(MptFlags(*this, flags, holder)); + }; + if (arg.account) + require(std::nullopt, arg.holder.has_value()); + if (auto const account = (arg.holder ? std::get_if(&(*arg.holder)) : nullptr)) + require(*account, false); + + if (arg.issuerPubKey) + { + env_.require(RequireAny([&]() -> bool { + return forObject([&](SLEP const& sle) -> bool { + if (sle) + { + auto const issuerPubKey = getPubKey(issuer_); + if (!issuerPubKey) { - if (!auditor_.has_value()) - Throw("MPTTester::set: auditor is not set"); - - auto const auditorPubKey = getPubKey(*auditor_); - if (!auditorPubKey) - { - Throw( - "MPTTester::set: auditor's pubkey is not set"); - } - - return strHex((*sle)[sfAuditorEncryptionKey]) == strHex(*auditorPubKey); + Throw("MPTTester::set: issuer's pubkey is not set"); } - return false; - }); - })); - } + + return strHex((*sle)[sfIssuerEncryptionKey]) == strHex(*issuerPubKey); + } + return false; + }); + })); + } + if (arg.auditorPubKey) + { + env_.require(RequireAny([&]() -> bool { + return forObject([&](SLEP const& sle) -> bool { + if (sle) + { + if (!auditor_.has_value()) + Throw("MPTTester::set: auditor is not set"); + + auto const auditorPubKey = getPubKey(*auditor_); + if (!auditorPubKey) + { + Throw( + "MPTTester::set: auditor's pubkey is not set"); + } + + return strHex((*sle)[sfAuditorEncryptionKey]) == strHex(*auditorPubKey); + } + return false; + }); + })); } } } @@ -664,6 +639,15 @@ MPTTester::isTransferFeePresent() const return forObject([&](SLEP const& sle) -> bool { return sle->isFieldPresent(sfTransferFee); }); } +[[nodiscard]] bool +MPTTester::checkImmutableFlags(std::uint32_t expectedFlags) const +{ + // sfImmutableFlags is soeDEFAULT, defaulting to 0 if not present. + return forObject([&](SLEP const& sle) -> bool { + return sle->getFieldU32(sfImmutableFlags) == expectedFlags; + }); +} + void MPTTester::pay( Account const& src, diff --git a/src/test/jtx/impl/sponsor.cpp b/src/test/jtx/impl/sponsor.cpp index cdf68800f5..453ccebcb9 100644 --- a/src/test/jtx/impl/sponsor.cpp +++ b/src/test/jtx/impl/sponsor.cpp @@ -21,18 +21,18 @@ namespace xrpl::test::jtx::sponsor { json::Value set(jtx::Account const& account, uint32_t flags, - std::optional const reserveCount, - std::optional const feeAmount, + std::optional const reserveCountDelta, + std::optional const feeAmountDelta, std::optional const maxFee) { json::Value jv; jv[jss::TransactionType] = jss::SponsorshipSet; jv[jss::Account] = account.human(); jv[sfFlags.jsonName] = flags; - if (reserveCount) - jv[sfRemainingOwnerCount.jsonName] = *reserveCount; - if (feeAmount) - jv[sfFeeAmount.jsonName] = feeAmount->getJson(JsonOptions::Values::None); + if (reserveCountDelta) + jv[sfRemainingOwnerCountDelta.jsonName] = *reserveCountDelta; + if (feeAmountDelta) + jv[sfFeeAmountDelta.jsonName] = feeAmountDelta->getJson(JsonOptions::Values::None); if (maxFee) jv[sfMaxFee.jsonName] = maxFee->getJson(JsonOptions::Values::None); return jv; diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index c6532ab14a..35ab7264bd 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -147,7 +147,7 @@ struct MPTCreate // if empty vector then pay to either authorize or all holders. std::optional, std::uint64_t>> pay = std::nullopt; std::optional flags = {0}; - std::optional mutableFlags = std::nullopt; + std::optional immutableFlags = std::nullopt; bool authHolder = false; std::optional domainID = std::nullopt; std::optional err = std::nullopt; @@ -183,7 +183,7 @@ struct MPTInitDef std::uint16_t transferFee = 0; std::optional pay = std::nullopt; std::uint32_t flags = kMptDexFlags; - std::optional mutableFlags = std::nullopt; + std::optional immutableFlags = std::nullopt; bool authHolder = false; bool fund = false; bool close = true; @@ -229,7 +229,7 @@ struct MPTSet std::optional ownerCount = std::nullopt; std::optional holderCount = std::nullopt; std::optional flags = std::nullopt; - std::optional mutableFlags = std::nullopt; + std::optional immutableFlags = std::nullopt; std::optional transferFee = std::nullopt; std::optional metadata = std::nullopt; std::optional delegate = std::nullopt; @@ -609,6 +609,9 @@ public: [[nodiscard]] bool isTransferFeePresent() const; + [[nodiscard]] bool + checkImmutableFlags(std::uint32_t expectedFlags) const; + [[nodiscard]] Account const& issuer() const { diff --git a/src/test/jtx/sponsor.h b/src/test/jtx/sponsor.h index 43d55d7246..f87a13c462 100644 --- a/src/test/jtx/sponsor.h +++ b/src/test/jtx/sponsor.h @@ -18,24 +18,24 @@ namespace xrpl::test::jtx::sponsor { json::Value set(jtx::Account const& account, std::uint32_t flags, - std::optional const reserveCount = std::nullopt, - std::optional const feeAmount = std::nullopt, + std::optional const reserveCountDelta = std::nullopt, + std::optional const feeAmountDelta = std::nullopt, std::optional const maxFee = std::nullopt); inline json::Value set_fee( jtx::Account const& account, std::uint32_t flags, - STAmount feeAmount, + STAmount feeAmountDelta, std::optional maxFee = std::nullopt) { - return set(account, flags, std::nullopt, std::move(feeAmount), std::move(maxFee)); + return set(account, flags, std::nullopt, std::move(feeAmountDelta), std::move(maxFee)); } inline json::Value -set_reserve(jtx::Account const& account, std::uint32_t flags, std::uint32_t reserveCount) +set_reserve(jtx::Account const& account, std::uint32_t flags, std::int32_t reserveCountDelta) { - return set(account, flags, reserveCount); + return set(account, flags, reserveCountDelta); } inline json::Value diff --git a/src/test/overlay/reduce_relay_test.cpp b/src/test/overlay/reduce_relay_test.cpp index 77920007de..4091efa0ca 100644 --- a/src/test/overlay/reduce_relay_test.cpp +++ b/src/test/overlay/reduce_relay_test.cpp @@ -140,7 +140,7 @@ public: setPublisherListSequence(PublicKey const&, std::size_t const) override { } - [[nodiscard]] uint256 const& + [[nodiscard]] uint256 getClosedLedgerHash() const override { static uint256 const kHash{}; diff --git a/src/test/protocol/STValidation_test.cpp b/src/test/protocol/STValidation_test.cpp index e42411bd3f..eb9aefd0ed 100644 --- a/src/test/protocol/STValidation_test.cpp +++ b/src/test/protocol/STValidation_test.cpp @@ -153,7 +153,10 @@ public: SerialIter sit{kPayload8}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, true); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = true, .requireCanonicalOrder = false}); BEAST_EXPECT(val); BEAST_EXPECT(val->isFieldPresent(sfLedgerSequence)); @@ -174,7 +177,10 @@ public: { SerialIter sit{kPayload1}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = false}); fail("An exception should have been thrown"); } catch (std::exception const& ex) @@ -186,7 +192,10 @@ public: { SerialIter sit{kPayload2}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = false}); fail("An exception should have been thrown"); } catch (std::exception const& ex) @@ -198,7 +207,10 @@ public: { SerialIter sit{kPayload3}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = false}); fail("An exception should have been thrown"); } catch (std::exception const& ex) @@ -210,7 +222,10 @@ public: { SerialIter sit{kPayload4}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = false}); fail("An exception should have been thrown"); } catch (std::exception const& ex) @@ -224,7 +239,10 @@ public: { SerialIter sit{kPayload5}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = false}); fail("Expected exception not thrown from validation"); } catch (std::exception const& ex) @@ -236,7 +254,10 @@ public: { SerialIter sit{kPayload6}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = false}); fail("Expected exception not thrown from validation"); } catch (std::exception const& ex) @@ -249,7 +270,10 @@ public: SerialIter sit{kPayload7}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = false}); fail("Expected exception not thrown from validation"); } @@ -279,7 +303,10 @@ public: SerialIter sit{makeSlice(v2)}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, true); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = true, .requireCanonicalOrder = false}); fail("Mutated validation signature checked out: offset=" + std::to_string(i)); } diff --git a/src/test/rpc/Subscribe_test.cpp b/src/test/rpc/Subscribe_test.cpp index 97c5290947..567f31437a 100644 --- a/src/test/rpc/Subscribe_test.cpp +++ b/src/test/rpc/Subscribe_test.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -1548,6 +1549,413 @@ public: } } + // ----- Subscription limit / teardown verification ---------------------- + // + // The helpers and tests below exercise: + // * the per-connection subscription cap + proportional charge enforced + // in doSubscribe (Subscribe.cpp), and + // * the asynchronous, chunked teardown of a disconnecting connection's + // account subscriptions (~InfoSub -> scheduleAccountCleanup -> JobQueue). + // + // The cap-exceeded error is rpcINVALID_PARAMS with the message "Too many + // subscriptions for this connection."; the tests assert that exactly. + // + // There is no public accessor for the server-side per-connection count, so + // the async cleanup is verified behaviorally: publishing still flows to a + // live subscriber, rather than by reading a count to zero. + + // Build `count` distinct, valid, base58-encoded account strings cheaply by + // incrementing an AccountID. parseAccountIds dedups into a hash_set, so the + // strings MUST be distinct for the cap arithmetic to be exact; incrementing + // guarantees distinctness without deriving `count` keypairs. + static std::vector + makeAccountStrings(std::size_t count, std::uint32_t seed = 1) + { + std::vector out; + out.reserve(count); + // Start at `seed` so separate calls produce non-overlapping ranges, + // letting a test subscribe disjoint batches across requests. + AccountID id{static_cast(seed)}; + for (std::size_t i = 0; i < count; ++i) + { + out.push_back(toBase58(id)); + ++id; + } + return out; + } + + // Append the given account strings as a jss::accounts array onto a fresh + // subscribe request object. + static json::Value + accountsRequest(std::vector const& accts) + { + json::Value jv{json::ValueType::Object}; + jv[jss::accounts] = json::ValueType::Array; + for (auto const& a : accts) + jv[jss::accounts].append(a); + return jv; + } + + // Append the given account strings as a jss::accounts_proposed array onto a + // fresh subscribe request object. + static json::Value + accountsProposedRequest(std::vector const& accts) + { + json::Value jv{json::ValueType::Object}; + jv[jss::accounts_proposed] = json::ValueType::Array; + for (auto const& a : accts) + jv[jss::accounts_proposed].append(a); + return jv; + } + + // A single, valid XRP/USD order book request, as one entry of a + // jss::books array. + static json::Value + oneBookRequest() + { + using namespace jtx; + json::Value jv{json::ValueType::Object}; + jv[jss::books] = json::ValueType::Array; + json::Value& book = jv[jss::books][0u]; + book[jss::taker_gets] = json::ValueType::Object; + book[jss::taker_gets][jss::currency] = "XRP"; + book[jss::taker_pays] = json::ValueType::Object; + book[jss::taker_pays][jss::currency] = "USD"; + book[jss::taker_pays][jss::issuer] = Account("alice").human(); + return jv; + } + + // A single account_history_tx_stream subscribe request for `acct`. + static json::Value + accountHistoryRequest(std::string const& acct) + { + json::Value jv{json::ValueType::Object}; + jv[jss::account_history_tx_stream] = json::ValueType::Object; + jv[jss::account_history_tx_stream][jss::account] = acct; + return jv; + } + + // An envconfig modifier that lowers the per-connection subscription cap to + // `cap`, so the cap logic in doSubscribe can be driven without subscribing + // the production default (100'000) entries. (Env is non-movable, so this + // returns the config modifier rather than a ready-made Env.) + static auto + cappedConfig(std::size_t cap) + { + return [cap](std::unique_ptr cfg) { + cfg->maxSubscriptionsPerConnection = cap; + return jtx::singleThreadIo(std::move(cfg)); + }; + } + + void + testSubscriptionCapRejects() + { + // A request that alone exceeds the cap is rejected with the exact + // cap error, before any state is recorded. Baseline negative path. + testcase("subscription cap rejects an over-cap request"); + + using namespace jtx; + Env env{*this, envconfig(cappedConfig(5))}; + auto wsc = makeWSClient(env.app().config()); + + // Six accounts against a cap of five: rejected. + auto const jr = + wsc->invoke("subscribe", accountsRequest(makeAccountStrings(6)))[jss::result]; + BEAST_EXPECT(jr[jss::error] == "invalidParams"); + BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection."); + } + + void + testReSubscribeNotOvercounted() + { + // Re-subscribing accounts already held by this connection adds no new + // tracked state, so it must be admitted even at the cap. The cap check + // must count only NET-NEW accounts, not the raw request size. + testcase("re-subscribe at the cap is not over-counted"); + + using namespace jtx; + Env env{*this, envconfig(cappedConfig(5))}; + auto wsc = makeWSClient(env.app().config()); + + // Fill the cap exactly with five distinct accounts. + auto const five = makeAccountStrings(5); + { + auto const r = wsc->invoke("subscribe", accountsRequest(five)); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + + // Re-subscribe the same five: net-new is zero, so it stays within the + // cap and must succeed. (Pre-fix this was wrongly rejected.) + { + auto const r = wsc->invoke("subscribe", accountsRequest(five)); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + } + + void + testBooksCapIndependentOfAccounts() + { + // Book subscriptions are tracked separately (OrderBookDB) and are not + // part of totalSubscriptionCount(). An account set at the cap must not + // block an unrelated book subscription. + testcase("books cap is independent of account count"); + + using namespace jtx; + Env env{*this, envconfig(cappedConfig(5))}; + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + BEAST_EXPECT(env.syncClose()); + + auto wsc = makeWSClient(env.app().config()); + + // Fill the account cap exactly. + { + auto const r = wsc->invoke("subscribe", accountsRequest(makeAccountStrings(5))); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + + // A single book subscription must still be admitted: it does not count + // against the account cap. (Pre-fix this was wrongly rejected.) + { + auto const r = wsc->invoke("subscribe", oneBookRequest()); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + } + + void + testMultiFieldNoPartialSubscribe() + { + // A single request mixing fields must be all-or-nothing: if a later + // field trips the cap, an earlier field must NOT have subscribed. The + // leak is detected through the cap arithmetic itself - a follow-up + // request succeeds only if no state leaked from the rejected one. + testcase("multi-field subscribe does not partially subscribe"); + + using namespace jtx; + Env env{*this, envconfig(cappedConfig(5))}; + auto wsc = makeWSClient(env.app().config()); + + // accounts_proposed (3, evaluated first, would subscribe) + + // accounts (3): combined 6 exceeds the cap of 5, so the request is + // rejected. The proposed branch must not have leaked its 3 entries. + json::Value req = accountsProposedRequest(makeAccountStrings(3, 1)); + for (auto const& a : makeAccountStrings(3, 100)) + req[jss::accounts].append(a); + { + auto const jr = wsc->invoke("subscribe", req)[jss::result]; + BEAST_EXPECT(jr[jss::error] == "invalidParams"); + BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection."); + } + + // If the rejected request leaked its 3 proposed subscriptions, the + // connection's count is already 3 and this 3-account request would be + // rejected (3 + 3 > 5). With no leak the count is 0 and it succeeds. + { + auto const r = wsc->invoke("subscribe", accountsRequest(makeAccountStrings(3, 200))); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + } + + void + testHistoryReSubscribeNotOvercounted() + { + // An account_history_tx_stream subscribe is charged against the cap only + // when it is net-new, matching the account branches. Re-subscribing an + // account-history already held on this connection adds no tracked entry, + // so it must NOT be rejected at the cap. The two rejection causes are + // told apart by their exact error_message: the cap check yields "Too + // many subscriptions for this connection."; a duplicate that gets past + // the cap and is rejected downstream by subAccountHistory yields the + // generic "Invalid parameters.". + testcase("account_history re-subscribe at the cap is not over-counted"); + + using namespace jtx; + Env env{*this, envconfig(cappedConfig(1))}; + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + BEAST_EXPECT(env.syncClose()); + + auto wsc = makeWSClient(env.app().config()); + + // First account-history subscribe is net-new: charge 1 fills the cap of + // 1 exactly, so it is admitted. Positive path. + { + auto const r = wsc->invoke("subscribe", accountHistoryRequest(alice.human())); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + + // Re-subscribe the same account-history while sitting exactly at the + // cap. Net-new is zero, so the cap check must pass; the request is then + // rejected by subAccountHistory as a duplicate, NOT by the cap. Proven + // by the exact message: it is the duplicate error, not the cap error. + // (Pre-fix, the flat charge of 1 made the cap check reject this with the + // cap message instead.) + { + auto const jr = + wsc->invoke("subscribe", accountHistoryRequest(alice.human()))[jss::result]; + BEAST_EXPECT(jr[jss::error] == "invalidParams"); + BEAST_EXPECT(jr[jss::error_message] == "Invalid parameters."); + BEAST_EXPECT(jr[jss::error_message] != "Too many subscriptions for this connection."); + } + } + + void + testHistoryCapRejectsNetNew() + { + // A genuinely net-new account-history subscribe on a connection already + // at the cap IS rejected, with the cap error. Negative path, and the + // counterpart to testHistoryReSubscribeNotOvercounted: it confirms the + // net-new charge still rejects when the entry really is new. + testcase("account_history net-new subscribe is rejected at the cap"); + + using namespace jtx; + Env env{*this, envconfig(cappedConfig(1))}; + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, bob); + BEAST_EXPECT(env.syncClose()); + + auto wsc = makeWSClient(env.app().config()); + + // Fill the cap of 1 with alice's account-history. + { + auto const r = wsc->invoke("subscribe", accountHistoryRequest(alice.human())); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + + // A different account-history (bob) is net-new: charge 1 over a cap of 1 + // already full, so it is rejected with the cap error. + { + auto const jr = + wsc->invoke("subscribe", accountHistoryRequest(bob.human()))[jss::result]; + BEAST_EXPECT(jr[jss::error] == "invalidParams"); + BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection."); + } + } + + void + testAsyncTeardownDoesNotStall() + { + // Test C (core regression): disconnecting a connection with many + // account subscriptions must NOT block subsequent operations or + // publishing. The teardown is now posted to a JobQueue job + // (scheduleAccountCleanup), so it runs off the disconnect thread. + testcase("async teardown does not stall publishing"); + + using namespace std::chrono_literals; + using namespace jtx; + Env env{*this, singleThreadIo(envconfig())}; + + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + BEAST_EXPECT(env.syncClose()); + + // A second, long-lived subscriber to alice that must keep receiving + // publishes after the first connection disconnects. + auto wscLive = makeWSClient(env.app().config()); + { + json::Value jv{json::ValueType::Object}; + jv[jss::accounts] = json::ValueType::Array; + jv[jss::accounts].append(alice.human()); + auto const r = wscLive->invoke("subscribe", jv); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + + // A connection that subscribes to many accounts, then disconnects. A + // few thousand entries is enough to be a real teardown while still + // running fast in CI. + constexpr std::size_t kBulk = 3000; + { + auto wscBulk = makeWSClient(env.app().config()); + auto const r = + wscBulk->invoke("subscribe", accountsRequest(makeAccountStrings(kBulk, 10))); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + // Destroying the client closes the WS connection, which destroys + // the server-side InfoSub and posts the chunked async cleanup job. + // WSClient exposes no explicit close(); resetting the owning + // unique_ptr is the disconnect path. + wscBulk.reset(); + } + + // Immediately after the disconnect, an unrelated operation completes + // promptly (it would block for seconds with inline teardown). This is a + // cheap liveness check; the publish assertion below is the real proof. + { + auto const info = env.app().getOPs().getServerInfo(false, true, false); + BEAST_EXPECT(info.isMember(jss::server_state)); + } + + // The live subscriber still receives a published transaction for alice + // within a short timeout, proving account-publishing was not stalled by + // the concurrent teardown. + { + env(pay(env.master, alice, XRP(100))); + BEAST_EXPECT(env.syncClose()); + BEAST_EXPECT(wscLive->findMsg(5s, [&](auto const& jv) { + return jv.isMember(jss::transaction) && + jv[jss::transaction][jss::TransactionType] == jss::Payment && + jv[jss::transaction][jss::Destination] == alice.human(); + })); + } + + wscLive->invoke("unsubscribe", accountsRequest({alice.human()})); + } + + void + testResubscribeAfterDisconnect() + { + // Test D (Phase 3 correctness): connection A subscribes to account X + // and disconnects (async cleanup pending, keyed on A's seq). A new + // connection B subscribes to X and MUST still receive publishes for X - + // A's deferred, seq-keyed cleanup must not remove B's subscription. + testcase("re-subscribe after disconnect still delivers"); + + using namespace std::chrono_literals; + using namespace jtx; + Env env{*this, singleThreadIo(envconfig())}; + + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + BEAST_EXPECT(env.syncClose()); + + // Connection A subscribes to alice, then disconnects. A also subscribes + // to a bulk set so its deferred cleanup is non-trivial and races with B. + { + auto wscA = makeWSClient(env.app().config()); + auto bulk = makeAccountStrings(2000, 10); + bulk.push_back(alice.human()); + auto const r = wscA->invoke("subscribe", accountsRequest(bulk)); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + // Disconnect A by destroying its client (no explicit close()). + wscA.reset(); + } + + // Connection B (a new InfoSub with a distinct seq) subscribes to alice. + auto wscB = makeWSClient(env.app().config()); + { + json::Value jv{json::ValueType::Object}; + jv[jss::accounts] = json::ValueType::Array; + jv[jss::accounts].append(alice.human()); + auto const r = wscB->invoke("subscribe", jv); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + + // A publish for alice must reach B. If A's seq-keyed cleanup had wrongly + // removed the shared alice entry, B would receive nothing. + { + env(pay(env.master, alice, XRP(100))); + BEAST_EXPECT(env.syncClose()); + BEAST_EXPECT(wscB->findMsg(5s, [&](auto const& jv) { + return jv.isMember(jss::transaction) && + jv[jss::transaction][jss::TransactionType] == jss::Payment && + jv[jss::transaction][jss::Destination] == alice.human(); + })); + } + + wscB->invoke("unsubscribe", accountsRequest({alice.human()})); + } + void run() override { @@ -1569,6 +1977,14 @@ public: testSubBookChanges(); testNFToken(all); testNFToken(all - featureNFTokenMintOffer); + testAsyncTeardownDoesNotStall(); + testResubscribeAfterDisconnect(); + testSubscriptionCapRejects(); + testReSubscribeNotOvercounted(); + testBooksCapIndependentOfAccounts(); + testMultiFieldNoPartialSubscribe(); + testHistoryReSubscribeNotOvercounted(); + testHistoryCapRejectsNetNew(); } }; diff --git a/src/tests/libxrpl/CMakeLists.txt b/src/tests/libxrpl/CMakeLists.txt index c45f55b5f2..5e4cda243a 100644 --- a/src/tests/libxrpl/CMakeLists.txt +++ b/src/tests/libxrpl/CMakeLists.txt @@ -38,6 +38,7 @@ set(test_modules shamap tx protocol_autogen + server ) if(NOT WIN32) list(APPEND test_modules net) diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp index 8dc5960ee0..974d0e81d7 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp @@ -32,7 +32,7 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip) auto const previousTxnIDValue = canonical_UINT256(); auto const previousTxnLgrSeqValue = canonical_UINT32(); auto const domainIDValue = canonical_UINT256(); - auto const mutableFlagsValue = canonical_UINT32(); + auto const immutableFlagsValue = canonical_UINT32(); auto const referenceHoldingValue = canonical_UINT256(); auto const issuerEncryptionKeyValue = canonical_VL(); auto const auditorEncryptionKeyValue = canonical_VL(); @@ -53,7 +53,7 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip) builder.setLockedAmount(lockedAmountValue); builder.setMPTokenMetadata(mPTokenMetadataValue); builder.setDomainID(domainIDValue); - builder.setMutableFlags(mutableFlagsValue); + builder.setImmutableFlags(immutableFlagsValue); builder.setReferenceHolding(referenceHoldingValue); builder.setIssuerEncryptionKey(issuerEncryptionKeyValue); builder.setAuditorEncryptionKey(auditorEncryptionKeyValue); @@ -153,11 +153,11 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip) } { - auto const& expected = mutableFlagsValue; - auto const actualOpt = entry.getMutableFlags(); + auto const& expected = immutableFlagsValue; + auto const actualOpt = entry.getImmutableFlags(); ASSERT_TRUE(actualOpt.has_value()); - expectEqualField(expected, *actualOpt, "sfMutableFlags"); - EXPECT_TRUE(entry.hasMutableFlags()); + expectEqualField(expected, *actualOpt, "sfImmutableFlags"); + EXPECT_TRUE(entry.hasImmutableFlags()); } { @@ -217,7 +217,7 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip) auto const previousTxnIDValue = canonical_UINT256(); auto const previousTxnLgrSeqValue = canonical_UINT32(); auto const domainIDValue = canonical_UINT256(); - auto const mutableFlagsValue = canonical_UINT32(); + auto const immutableFlagsValue = canonical_UINT32(); auto const referenceHoldingValue = canonical_UINT256(); auto const issuerEncryptionKeyValue = canonical_VL(); auto const auditorEncryptionKeyValue = canonical_VL(); @@ -237,7 +237,7 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip) sle->at(sfPreviousTxnID) = previousTxnIDValue; sle->at(sfPreviousTxnLgrSeq) = previousTxnLgrSeqValue; sle->at(sfDomainID) = domainIDValue; - sle->at(sfMutableFlags) = mutableFlagsValue; + sle->at(sfImmutableFlags) = immutableFlagsValue; sle->at(sfReferenceHolding) = referenceHoldingValue; sle->at(sfIssuerEncryptionKey) = issuerEncryptionKeyValue; sle->at(sfAuditorEncryptionKey) = auditorEncryptionKeyValue; @@ -391,16 +391,16 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip) } { - auto const& expected = mutableFlagsValue; + auto const& expected = immutableFlagsValue; - auto const fromSleOpt = entryFromSle.getMutableFlags(); - auto const fromBuilderOpt = entryFromBuilder.getMutableFlags(); + auto const fromSleOpt = entryFromSle.getImmutableFlags(); + auto const fromBuilderOpt = entryFromBuilder.getImmutableFlags(); ASSERT_TRUE(fromSleOpt.has_value()); ASSERT_TRUE(fromBuilderOpt.has_value()); - expectEqualField(expected, *fromSleOpt, "sfMutableFlags"); - expectEqualField(expected, *fromBuilderOpt, "sfMutableFlags"); + expectEqualField(expected, *fromSleOpt, "sfImmutableFlags"); + expectEqualField(expected, *fromBuilderOpt, "sfImmutableFlags"); } { @@ -531,8 +531,8 @@ TEST(MPTokenIssuanceTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getMPTokenMetadata().has_value()); EXPECT_FALSE(entry.hasDomainID()); EXPECT_FALSE(entry.getDomainID().has_value()); - EXPECT_FALSE(entry.hasMutableFlags()); - EXPECT_FALSE(entry.getMutableFlags().has_value()); + EXPECT_FALSE(entry.hasImmutableFlags()); + EXPECT_FALSE(entry.getImmutableFlags().has_value()); EXPECT_FALSE(entry.hasReferenceHolding()); EXPECT_FALSE(entry.getReferenceHolding().has_value()); EXPECT_FALSE(entry.hasIssuerEncryptionKey()); diff --git a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp index f7151fc749..8228188950 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp @@ -34,7 +34,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderSettersRoundTrip) auto const maximumAmountValue = canonical_UINT64(); auto const mPTokenMetadataValue = canonical_VL(); auto const domainIDValue = canonical_UINT256(); - auto const mutableFlagsValue = canonical_UINT32(); + auto const immutableFlagsValue = canonical_UINT32(); MPTokenIssuanceCreateBuilder builder{ accountValue, @@ -48,7 +48,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderSettersRoundTrip) builder.setMaximumAmount(maximumAmountValue); builder.setMPTokenMetadata(mPTokenMetadataValue); builder.setDomainID(domainIDValue); - builder.setMutableFlags(mutableFlagsValue); + builder.setImmutableFlags(immutableFlagsValue); auto tx = builder.build(publicKey, secretKey); @@ -107,11 +107,11 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderSettersRoundTrip) } { - auto const& expected = mutableFlagsValue; - auto const actualOpt = tx.getMutableFlags(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMutableFlags should be present"; - expectEqualField(expected, *actualOpt, "sfMutableFlags"); - EXPECT_TRUE(tx.hasMutableFlags()); + auto const& expected = immutableFlagsValue; + auto const actualOpt = tx.getImmutableFlags(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfImmutableFlags should be present"; + expectEqualField(expected, *actualOpt, "sfImmutableFlags"); + EXPECT_TRUE(tx.hasImmutableFlags()); } } @@ -135,7 +135,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderFromStTxRoundTrip) auto const maximumAmountValue = canonical_UINT64(); auto const mPTokenMetadataValue = canonical_VL(); auto const domainIDValue = canonical_UINT256(); - auto const mutableFlagsValue = canonical_UINT32(); + auto const immutableFlagsValue = canonical_UINT32(); // Build an initial transaction MPTokenIssuanceCreateBuilder initialBuilder{ @@ -149,7 +149,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderFromStTxRoundTrip) initialBuilder.setMaximumAmount(maximumAmountValue); initialBuilder.setMPTokenMetadata(mPTokenMetadataValue); initialBuilder.setDomainID(domainIDValue); - initialBuilder.setMutableFlags(mutableFlagsValue); + initialBuilder.setImmutableFlags(immutableFlagsValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -204,10 +204,10 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderFromStTxRoundTrip) } { - auto const& expected = mutableFlagsValue; - auto const actualOpt = rebuiltTx.getMutableFlags(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMutableFlags should be present"; - expectEqualField(expected, *actualOpt, "sfMutableFlags"); + auto const& expected = immutableFlagsValue; + auto const actualOpt = rebuiltTx.getImmutableFlags(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfImmutableFlags should be present"; + expectEqualField(expected, *actualOpt, "sfImmutableFlags"); } } @@ -275,8 +275,8 @@ TEST(TransactionsMPTokenIssuanceCreateTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getMPTokenMetadata().has_value()); EXPECT_FALSE(tx.hasDomainID()); EXPECT_FALSE(tx.getDomainID().has_value()); - EXPECT_FALSE(tx.hasMutableFlags()); - EXPECT_FALSE(tx.getMutableFlags().has_value()); + EXPECT_FALSE(tx.hasImmutableFlags()); + EXPECT_FALSE(tx.getImmutableFlags().has_value()); } } diff --git a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp index e7b34590b2..af696ce47b 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp @@ -34,7 +34,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip) auto const domainIDValue = canonical_UINT256(); auto const mPTokenMetadataValue = canonical_VL(); auto const transferFeeValue = canonical_UINT16(); - auto const mutableFlagsValue = canonical_UINT32(); + auto const immutableFlagsValue = canonical_UINT32(); auto const issuerEncryptionKeyValue = canonical_VL(); auto const auditorEncryptionKeyValue = canonical_VL(); @@ -50,7 +50,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip) builder.setDomainID(domainIDValue); builder.setMPTokenMetadata(mPTokenMetadataValue); builder.setTransferFee(transferFeeValue); - builder.setMutableFlags(mutableFlagsValue); + builder.setImmutableFlags(immutableFlagsValue); builder.setIssuerEncryptionKey(issuerEncryptionKeyValue); builder.setAuditorEncryptionKey(auditorEncryptionKeyValue); @@ -109,11 +109,11 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip) } { - auto const& expected = mutableFlagsValue; - auto const actualOpt = tx.getMutableFlags(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMutableFlags should be present"; - expectEqualField(expected, *actualOpt, "sfMutableFlags"); - EXPECT_TRUE(tx.hasMutableFlags()); + auto const& expected = immutableFlagsValue; + auto const actualOpt = tx.getImmutableFlags(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfImmutableFlags should be present"; + expectEqualField(expected, *actualOpt, "sfImmutableFlags"); + EXPECT_TRUE(tx.hasImmutableFlags()); } { @@ -153,7 +153,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip) auto const domainIDValue = canonical_UINT256(); auto const mPTokenMetadataValue = canonical_VL(); auto const transferFeeValue = canonical_UINT16(); - auto const mutableFlagsValue = canonical_UINT32(); + auto const immutableFlagsValue = canonical_UINT32(); auto const issuerEncryptionKeyValue = canonical_VL(); auto const auditorEncryptionKeyValue = canonical_VL(); @@ -169,7 +169,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip) initialBuilder.setDomainID(domainIDValue); initialBuilder.setMPTokenMetadata(mPTokenMetadataValue); initialBuilder.setTransferFee(transferFeeValue); - initialBuilder.setMutableFlags(mutableFlagsValue); + initialBuilder.setImmutableFlags(immutableFlagsValue); initialBuilder.setIssuerEncryptionKey(issuerEncryptionKeyValue); initialBuilder.setAuditorEncryptionKey(auditorEncryptionKeyValue); @@ -225,10 +225,10 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip) } { - auto const& expected = mutableFlagsValue; - auto const actualOpt = rebuiltTx.getMutableFlags(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMutableFlags should be present"; - expectEqualField(expected, *actualOpt, "sfMutableFlags"); + auto const& expected = immutableFlagsValue; + auto const actualOpt = rebuiltTx.getImmutableFlags(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfImmutableFlags should be present"; + expectEqualField(expected, *actualOpt, "sfImmutableFlags"); } { @@ -310,8 +310,8 @@ TEST(TransactionsMPTokenIssuanceSetTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getMPTokenMetadata().has_value()); EXPECT_FALSE(tx.hasTransferFee()); EXPECT_FALSE(tx.getTransferFee().has_value()); - EXPECT_FALSE(tx.hasMutableFlags()); - EXPECT_FALSE(tx.getMutableFlags().has_value()); + EXPECT_FALSE(tx.hasImmutableFlags()); + EXPECT_FALSE(tx.getImmutableFlags().has_value()); EXPECT_FALSE(tx.hasIssuerEncryptionKey()); EXPECT_FALSE(tx.getIssuerEncryptionKey().has_value()); EXPECT_FALSE(tx.hasAuditorEncryptionKey()); diff --git a/src/tests/libxrpl/protocol_autogen/transactions/SponsorshipSetTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/SponsorshipSetTests.cpp index dce8cfca3f..c5bc41c6e6 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/SponsorshipSetTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/SponsorshipSetTests.cpp @@ -31,9 +31,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip) // Transaction-specific field values auto const counterpartySponsorValue = canonical_ACCOUNT(); auto const sponseeValue = canonical_ACCOUNT(); - auto const feeAmountValue = canonical_AMOUNT(); + auto const feeAmountDeltaValue = canonical_AMOUNT(); auto const maxFeeValue = canonical_AMOUNT(); - auto const remainingOwnerCountValue = canonical_UINT32(); + auto const remainingOwnerCountDeltaValue = canonical_INT32(); SponsorshipSetBuilder builder{ accountValue, @@ -44,9 +44,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip) // Set optional fields builder.setCounterpartySponsor(counterpartySponsorValue); builder.setSponsee(sponseeValue); - builder.setFeeAmount(feeAmountValue); + builder.setFeeAmountDelta(feeAmountDeltaValue); builder.setMaxFee(maxFeeValue); - builder.setRemainingOwnerCount(remainingOwnerCountValue); + builder.setRemainingOwnerCountDelta(remainingOwnerCountDeltaValue); auto tx = builder.build(publicKey, secretKey); @@ -81,11 +81,11 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip) } { - auto const& expected = feeAmountValue; - auto const actualOpt = tx.getFeeAmount(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmount should be present"; - expectEqualField(expected, *actualOpt, "sfFeeAmount"); - EXPECT_TRUE(tx.hasFeeAmount()); + auto const& expected = feeAmountDeltaValue; + auto const actualOpt = tx.getFeeAmountDelta(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmountDelta should be present"; + expectEqualField(expected, *actualOpt, "sfFeeAmountDelta"); + EXPECT_TRUE(tx.hasFeeAmountDelta()); } { @@ -97,11 +97,11 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip) } { - auto const& expected = remainingOwnerCountValue; - auto const actualOpt = tx.getRemainingOwnerCount(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCount should be present"; - expectEqualField(expected, *actualOpt, "sfRemainingOwnerCount"); - EXPECT_TRUE(tx.hasRemainingOwnerCount()); + auto const& expected = remainingOwnerCountDeltaValue; + auto const actualOpt = tx.getRemainingOwnerCountDelta(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCountDelta should be present"; + expectEqualField(expected, *actualOpt, "sfRemainingOwnerCountDelta"); + EXPECT_TRUE(tx.hasRemainingOwnerCountDelta()); } } @@ -122,9 +122,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip) // Transaction-specific field values auto const counterpartySponsorValue = canonical_ACCOUNT(); auto const sponseeValue = canonical_ACCOUNT(); - auto const feeAmountValue = canonical_AMOUNT(); + auto const feeAmountDeltaValue = canonical_AMOUNT(); auto const maxFeeValue = canonical_AMOUNT(); - auto const remainingOwnerCountValue = canonical_UINT32(); + auto const remainingOwnerCountDeltaValue = canonical_INT32(); // Build an initial transaction SponsorshipSetBuilder initialBuilder{ @@ -135,9 +135,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip) initialBuilder.setCounterpartySponsor(counterpartySponsorValue); initialBuilder.setSponsee(sponseeValue); - initialBuilder.setFeeAmount(feeAmountValue); + initialBuilder.setFeeAmountDelta(feeAmountDeltaValue); initialBuilder.setMaxFee(maxFeeValue); - initialBuilder.setRemainingOwnerCount(remainingOwnerCountValue); + initialBuilder.setRemainingOwnerCountDelta(remainingOwnerCountDeltaValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -171,10 +171,10 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip) } { - auto const& expected = feeAmountValue; - auto const actualOpt = rebuiltTx.getFeeAmount(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmount should be present"; - expectEqualField(expected, *actualOpt, "sfFeeAmount"); + auto const& expected = feeAmountDeltaValue; + auto const actualOpt = rebuiltTx.getFeeAmountDelta(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmountDelta should be present"; + expectEqualField(expected, *actualOpt, "sfFeeAmountDelta"); } { @@ -185,10 +185,10 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip) } { - auto const& expected = remainingOwnerCountValue; - auto const actualOpt = rebuiltTx.getRemainingOwnerCount(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCount should be present"; - expectEqualField(expected, *actualOpt, "sfRemainingOwnerCount"); + auto const& expected = remainingOwnerCountDeltaValue; + auto const actualOpt = rebuiltTx.getRemainingOwnerCountDelta(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCountDelta should be present"; + expectEqualField(expected, *actualOpt, "sfRemainingOwnerCountDelta"); } } @@ -250,12 +250,12 @@ TEST(TransactionsSponsorshipSetTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getCounterpartySponsor().has_value()); EXPECT_FALSE(tx.hasSponsee()); EXPECT_FALSE(tx.getSponsee().has_value()); - EXPECT_FALSE(tx.hasFeeAmount()); - EXPECT_FALSE(tx.getFeeAmount().has_value()); + EXPECT_FALSE(tx.hasFeeAmountDelta()); + EXPECT_FALSE(tx.getFeeAmountDelta().has_value()); EXPECT_FALSE(tx.hasMaxFee()); EXPECT_FALSE(tx.getMaxFee().has_value()); - EXPECT_FALSE(tx.hasRemainingOwnerCount()); - EXPECT_FALSE(tx.getRemainingOwnerCount().has_value()); + EXPECT_FALSE(tx.hasRemainingOwnerCountDelta()); + EXPECT_FALSE(tx.getRemainingOwnerCountDelta().has_value()); } } diff --git a/src/tests/libxrpl/server/InfoSub.cpp b/src/tests/libxrpl/server/InfoSub.cpp new file mode 100644 index 0000000000..6913812a92 --- /dev/null +++ b/src/tests/libxrpl/server/InfoSub.cpp @@ -0,0 +1,60 @@ +#include + +#include + +#include +#include + +using namespace xrpl; + +// The per-connection subscription cap is enforced by the pure predicate +// exceedsSubscriptionCap(current, additional). Testing it directly (rather than +// by subscribing the real cap through a WebSocket, which would exceed the frame +// limit and drop the connection before the check runs) lets the boundary be +// asserted exactly. +TEST(InfoSubSubscriptionCap, Boundary) +{ + constexpr std::size_t cap = kMaxSubscriptionsPerConnection; + + // Empty connection: anything up to the cap is admitted, cap+1 is not. + EXPECT_FALSE(exceedsSubscriptionCap(0, 0)); + EXPECT_FALSE(exceedsSubscriptionCap(0, cap)); + EXPECT_TRUE(exceedsSubscriptionCap(0, cap + 1)); + + // Exactly at the cap: zero more is fine, one more is rejected. + EXPECT_FALSE(exceedsSubscriptionCap(cap, 0)); + EXPECT_TRUE(exceedsSubscriptionCap(cap, 1)); + + // One below the cap: exactly one more reaches the cap; two exceed it. + EXPECT_FALSE(exceedsSubscriptionCap(cap - 1, 1)); + EXPECT_TRUE(exceedsSubscriptionCap(cap - 1, 2)); +} + +TEST(InfoSubSubscriptionCap, NoOverflow) +{ + constexpr std::size_t cap = kMaxSubscriptionsPerConnection; + constexpr std::size_t max = std::numeric_limits::max(); + + // current + additional must not wrap: a huge additional is rejected even + // when current is 0 (the additional > cap term guards the subtraction). + EXPECT_TRUE(exceedsSubscriptionCap(0, max)); + EXPECT_TRUE(exceedsSubscriptionCap(cap, max)); +} + +TEST(InfoSubSubscriptionCap, ExplicitCap) +{ + // A configured override is honored: the boundary tracks the passed cap, not + // the built-in default. This is the seam doSubscribe uses to enforce a + // per-connection cap set via [max_subscriptions_per_connection]. + constexpr std::size_t cap = 5; + + EXPECT_FALSE(exceedsSubscriptionCap(0, cap, cap)); + EXPECT_TRUE(exceedsSubscriptionCap(0, cap + 1, cap)); + EXPECT_FALSE(exceedsSubscriptionCap(cap, 0, cap)); + EXPECT_TRUE(exceedsSubscriptionCap(cap, 1, cap)); + EXPECT_FALSE(exceedsSubscriptionCap(cap - 1, 1, cap)); + EXPECT_TRUE(exceedsSubscriptionCap(cap - 1, 2, cap)); + + // The overflow guard still holds with a small explicit cap. + EXPECT_TRUE(exceedsSubscriptionCap(0, std::numeric_limits::max(), cap)); +} diff --git a/src/xrpld/app/consensus/RCLConsensus.cpp b/src/xrpld/app/consensus/RCLConsensus.cpp index 28b910c8e5..42270a91f1 100644 --- a/src/xrpld/app/consensus/RCLConsensus.cpp +++ b/src/xrpld/app/consensus/RCLConsensus.cpp @@ -686,28 +686,17 @@ RCLConsensus::Adaptor::doAccept( // close time reports, and update our clock. if ((mode == ConsensusMode::Proposing || mode == ConsensusMode::Observing) && !consensusFail) { - auto closeTime = rawCloseTimes.self; - - JLOG(j_.info()) << "We closed at " << closeTime.time_since_epoch().count(); - using usec64_t = std::chrono::duration; - auto closeTotal = std::chrono::duration_cast(closeTime.time_since_epoch()); + JLOG(j_.info()) << "We closed at " << rawCloseTimes.self.time_since_epoch().count(); int closeCount = 1; - for (auto const& [t, v] : rawCloseTimes.peers) { JLOG(j_.info()) << std::to_string(v) << " time votes for " << std::to_string(t.time_since_epoch().count()); closeCount += v; - closeTotal += std::chrono::duration_cast(t.time_since_epoch()) * v; } - closeTotal += usec64_t(closeCount / 2); // for round to nearest - closeTotal /= closeCount; - - // Use signed times since we are subtracting - using duration = std::chrono::duration; - using time_point = std::chrono::time_point; - auto offset = time_point{closeTotal} - std::chrono::time_point_cast(closeTime); + // Median handles outliers better than mean. + auto const offset = medianCloseOffset(rawCloseTimes); JLOG(j_.info()) << "Our close offset is estimated at " << offset.count() << " (" << closeCount << ")"; diff --git a/src/xrpld/app/ledger/AcceptedLedger.h b/src/xrpld/app/ledger/AcceptedLedger.h index 6e42d611d4..a8b78d08b0 100644 --- a/src/xrpld/app/ledger/AcceptedLedger.h +++ b/src/xrpld/app/ledger/AcceptedLedger.h @@ -57,6 +57,15 @@ public: return transactions_.end(); } + /** + * The last accepted transaction. Precondition: size() > 0. + */ + [[nodiscard]] AcceptedLedgerTx const& + back() const + { + return *transactions_.back(); + } + private: std::shared_ptr ledger_; std::vector> transactions_; diff --git a/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.cpp b/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.cpp index 07738d99f4..6ed4a296ac 100644 --- a/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.cpp +++ b/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.cpp @@ -101,42 +101,54 @@ LedgerReplayMsgHandler::processProofPathRequest( return reply; } -bool +ReplayMsgStatus LedgerReplayMsgHandler::processProofPathResponse( std::shared_ptr const& msg) { protocol::TMProofPathResponse const& reply = *msg; - if (reply.has_error() || !reply.has_key() || !reply.has_ledgerhash() || !reply.has_type() || + if (reply.has_error()) + { + JLOG(journal_.debug()) << "ProofPathResponse: peer reported error"; + return ReplayMsgStatus::BadData; + } + if (!reply.has_key() || !reply.has_ledgerhash() || !reply.has_type() || !reply.has_ledgerheader() || reply.path_size() == 0 || reply.ledgerhash().size() != uint256::size() || reply.key().size() != uint256::size()) { - JLOG(journal_.debug()) << "Bad message: Error reply"; - return false; + JLOG(journal_.debug()) << "ProofPathResponse: malformed (missing or wrong-size fields)"; + return ReplayMsgStatus::Malformed; } if (reply.type() != protocol::lmACCOUNT_STATE) { - JLOG(journal_.debug()) << "Bad message: we only support the state ShaMap for now"; - return false; + JLOG(journal_.debug()) << "ProofPathResponse: malformed (unsupported map type)"; + return ReplayMsgStatus::Malformed; } // deserialize the header - auto info = deserializeHeader({reply.ledgerheader().data(), reply.ledgerheader().size()}); + LedgerHeader info; + try + { + info = deserializeHeader(makeSlice(reply.ledgerheader())); + } + catch (std::exception const& e) + { + JLOG(journal_.debug()) << "ProofPathResponse: malformed header (" << e.what() << ")"; + return ReplayMsgStatus::Malformed; + } uint256 const replyHash = uint256::fromRaw(reply.ledgerhash()); if (calculateLedgerHash(info) != replyHash) { - JLOG(journal_.debug()) << "Bad message: Hash mismatch"; - return false; + JLOG(journal_.debug()) << "ProofPathResponse: malformed (hash mismatch)"; + return ReplayMsgStatus::Malformed; } info.hash = replyHash; uint256 const key = uint256::fromRaw(reply.key()); if (key != keylet::skip().key) { - JLOG(journal_.debug()) << "Bad message: we only support the short skip list for now. " - "Key in reply " - << key; - return false; + JLOG(journal_.debug()) << "ProofPathResponse: malformed (unexpected key " << key << ")"; + return ReplayMsgStatus::Malformed; } // verify the skip list @@ -149,26 +161,35 @@ LedgerReplayMsgHandler::processProofPathResponse( if (!SHAMap::verifyProofPath(info.accountHash, key, path)) { - JLOG(journal_.debug()) << "Bad message: Proof path verify failed"; - return false; + JLOG(journal_.debug()) << "ProofPathResponse: malformed (proof path verify failed)"; + return ReplayMsgStatus::Malformed; } // deserialize the SHAMapItem - auto node = SHAMapTreeNode::makeFromWire(makeSlice(path.front())); + SHAMapTreeNodePtr node; + try + { + node = SHAMapTreeNode::makeFromWire(makeSlice(path.front())); + } + catch (std::exception const& e) + { + JLOG(journal_.debug()) << "ProofPathResponse: malformed SHAMap node (" << e.what() << ")"; + return ReplayMsgStatus::Malformed; + } if (!node || !node->isLeaf()) { - JLOG(journal_.debug()) << "Bad message: Cannot deserialize"; - return false; + JLOG(journal_.debug()) << "ProofPathResponse: malformed (not a leaf node)"; + return ReplayMsgStatus::Malformed; } if (auto item = safeDowncast(node.get())->peekItem()) { replayer_.gotSkipList(info, item); - return true; + return ReplayMsgStatus::Ok; } - JLOG(journal_.debug()) << "Bad message: Cannot get ShaMapItem"; - return false; + JLOG(journal_.debug()) << "ProofPathResponse: malformed (no SHAMapItem)"; + return ReplayMsgStatus::Malformed; } protocol::TMReplayDeltaResponse @@ -210,24 +231,38 @@ LedgerReplayMsgHandler::processReplayDeltaRequest( return reply; } -bool +ReplayMsgStatus LedgerReplayMsgHandler::processReplayDeltaResponse( std::shared_ptr const& msg) { protocol::TMReplayDeltaResponse const& reply = *msg; - if (reply.has_error() || !reply.has_ledgerheader() || !reply.has_ledgerhash() || + if (reply.has_error()) + { + JLOG(journal_.debug()) << "ReplayDeltaResponse: peer reported error"; + return ReplayMsgStatus::BadData; + } + if (!reply.has_ledgerheader() || !reply.has_ledgerhash() || reply.ledgerhash().size() != uint256::size()) { - JLOG(journal_.debug()) << "Bad message: Error reply"; - return false; + JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (missing or wrong-size fields)"; + return ReplayMsgStatus::Malformed; } - auto info = deserializeHeader({reply.ledgerheader().data(), reply.ledgerheader().size()}); + LedgerHeader info; + try + { + info = deserializeHeader(makeSlice(reply.ledgerheader())); + } + catch (std::exception const& e) + { + JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed header (" << e.what() << ")"; + return ReplayMsgStatus::Malformed; + } uint256 const replyHash = uint256::fromRaw(reply.ledgerhash()); if (calculateLedgerHash(info) != replyHash) { - JLOG(journal_.debug()) << "Bad message: Hash mismatch"; - return false; + JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (hash mismatch)"; + return ReplayMsgStatus::Malformed; } info.hash = replyHash; @@ -252,8 +287,8 @@ LedgerReplayMsgHandler::processReplayDeltaResponse( auto tx = std::make_shared(txSit); if (!tx) { - JLOG(journal_.debug()) << "Bad message: Cannot deserialize"; - return false; + JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (tx deserialize)"; + return ReplayMsgStatus::Malformed; } auto tid = tx->getTransactionID(); STObject meta(metaSit, sfMetadata); @@ -262,25 +297,26 @@ LedgerReplayMsgHandler::processReplayDeltaResponse( if (!txMap.addGiveItem( SHAMapNodeType::TnTransactionMd, makeShamapitem(tid, shaMapItemData.slice()))) { - JLOG(journal_.debug()) << "Bad message: Cannot deserialize"; - return false; + JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (tx map add)"; + return ReplayMsgStatus::Malformed; } } } - catch (std::exception const&) + catch (std::exception const& e) { - JLOG(journal_.debug()) << "Bad message: Cannot deserialize"; - return false; + JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed transactions (" << e.what() + << ")"; + return ReplayMsgStatus::Malformed; } if (txMap.getHash().asUInt256() != info.txHash) { - JLOG(journal_.debug()) << "Bad message: Transactions verify failed"; - return false; + JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (transactions verify failed)"; + return ReplayMsgStatus::Malformed; } replayer_.gotReplayDelta(info, std::move(orderedTxns)); - return true; + return ReplayMsgStatus::Ok; } } // namespace xrpl diff --git a/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.h b/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.h index 5a8951fb25..ba989e2586 100644 --- a/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.h +++ b/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.h @@ -10,6 +10,15 @@ namespace xrpl { class Application; class LedgerReplayer; +/** + * Outcome of processing an incoming ledger-replay response. + */ +enum class ReplayMsgStatus { + Ok, ///< Accepted. + BadData, ///< Peer reported has_error() (legitimate "cannot fulfill" signal). + Malformed, ///< Protocol-level violation; no honest peer would produce this. +}; + class LedgerReplayMsgHandler final { public: @@ -19,31 +28,31 @@ public: /** * Process TMProofPathRequest and return TMProofPathResponse * @note check has_error() and error() of the response for error + * @return TMProofPathResponse with the proof path, or with error() set if + * the request cannot be fulfilled */ protocol::TMProofPathResponse processProofPathRequest(std::shared_ptr const& msg); /** * Process TMProofPathResponse - * @return false if the response message has bad format or bad data; - * true otherwise */ - bool + ReplayMsgStatus processProofPathResponse(std::shared_ptr const& msg); /** * Process TMReplayDeltaRequest and return TMReplayDeltaResponse * @note check has_error() and error() of the response for error + * @return TMReplayDeltaResponse with the ledger delta, or with error() set + * if the request cannot be fulfilled */ protocol::TMReplayDeltaResponse processReplayDeltaRequest(std::shared_ptr const& msg); /** * Process TMReplayDeltaResponse - * @return false if the response message has bad format or bad data; - * true otherwise */ - bool + ReplayMsgStatus processReplayDeltaResponse(std::shared_ptr const& msg); private: diff --git a/src/xrpld/app/main/Application.cpp b/src/xrpld/app/main/Application.cpp index 52ec9ce544..d50637d98e 100644 --- a/src/xrpld/app/main/Application.cpp +++ b/src/xrpld/app/main/Application.cpp @@ -91,6 +91,7 @@ #include #include #include +#include #include #include #include @@ -428,8 +429,14 @@ public: , cluster_(std::make_unique(logs_->journal("Overlay"))) , peerReservations_( std::make_unique(logs_->journal("PeerReservationTable"))) - , validatorManifests_(std::make_unique(logs_->journal("ManifestCache"))) - , publisherManifests_(std::make_unique(logs_->journal("ManifestCache"))) + , validatorManifests_( + std::make_unique( + logs_->journal("ManifestCache"), + untrustedManifestCount(config_->maxUntrustedCount))) + , publisherManifests_( + std::make_unique( + logs_->journal("ManifestCache"), + untrustedManifestCount(config_->maxUntrustedCount))) , validators_( std::make_unique( *validatorManifests_, @@ -1190,6 +1197,15 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline) JLOG(journal_.info()) << "Process starting: " << build_info::getFullVersionString() << ", Instance Cookie: " << instanceCookie_; + // Log the resolved manifest counts, whether configured or defaulted, so a + // shared log shows what the server is running without needing its config. + JLOG(journal_.warn()) << "Manifest counts: max_untrusted_count " + << untrustedManifestCount(config_->maxUntrustedCount) + << (config_->maxUntrustedCount ? " (configured)" : " (default)") + << ", max_trusted_count " + << trustedManifestCount(config_->maxTrustedCount) + << (config_->maxTrustedCount ? " (configured)" : " (default)"); + if (numberOfThreads(*config_) < 2) { JLOG(journal_.warn()) << "Limited to a single I/O service thread by " diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 8f31ce1eb3..1af3b64161 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -154,6 +154,12 @@ namespace xrpl { +/** + * Concrete NetworkOPs: server sequencer, network tracker, and owner of all + * client subscription state (accounts, books, streams). Subscriptions use three + * independent non-recursive locks (accountLock_, bookLock_, streamLock_); see + * their declarations for the locking and deferred-destruction rules. + */ class NetworkOPsImp final : public NetworkOPs { /** @@ -194,7 +200,7 @@ class NetworkOPsImp final : public NetworkOPs /** * State accounting records two attributes for each possible server state: * 1) Amount of time spent in each state (in microseconds). This value is - * updated upon each state transition. + * updated upon each state transition. * 2) Number of transitions to each state. * * This data can be polled through server_info and represented by @@ -573,6 +579,13 @@ public: unsubAccountHistoryInternal(std::uint64_t seq, AccountID const& account, bool historyOnly) override; + void + scheduleAccountCleanup( + std::uint64_t seq, + hash_set rtAccounts, + hash_set normalAccounts, + hash_set historyAccounts) override; + bool subLedger(InfoSub::ref ispListener, json::Value& jvResult) override; bool @@ -636,6 +649,20 @@ public: bool tryRemoveRpcSub(std::string const& strUrl) override; + /** + * Look up an RPC subscription without taking streamLock_. + * + * Callers MUST already hold streamLock_. This exists so tryRemoveRpcSub + * can reuse the lookup while holding the lock; the plain std::mutex is not + * recursive, so calling the public findRpcSub (which locks) from under the + * lock would self-deadlock. + * + * @param strUrl The subscription URL key into rpcSubMap_. + * @return The matching InfoSub, or an empty pointer if not found. + */ + InfoSub::pointer + findRpcSubLocked(std::string const& strUrl); + beast::Journal const& journal() const override { @@ -724,22 +751,22 @@ private: * Extracts the set of order books affected by @p transaction, then * delivers @p jvObj to every live subscriber of those books. * - * Uses a two-pass design to keep subLock_ hold time short: - * 1. Under subLock_, collect strong InfoSub pointers for all live - * subscribers and prune any expired weak_ptrs encountered. - * 2. Release subLock_, then call send() on each collected pointer. + * Uses a two-pass design to keep bookLock_ hold time short: + * 1. Under bookLock_, collect strong InfoSub pointers for all live + * subscribers and prune any expired weak_ptrs encountered. + * 2. Release bookLock_, then call send() on each collected pointer. * * @param transaction The accepted ledger transaction to inspect. * @param jvObj JSON representation of the transaction to deliver. * - * @note Thread-safety: acquires subLock_ for the collection pass only. - * send() is intentionally called outside the lock to avoid blocking - * all other sub/unsub/publish paths while I/O is in progress. - * @note Contention: subLock_ is shared with all other subscription types. - * On high-throughput nodes processing multi-hop payments that touch - * many offer nodes, this pass holds subLock_ longer than the old - * per-book BookListeners locks did. This is an accepted trade-off - * for lock-domain simplicity. + * @note Thread-safety: acquires bookLock_ for the collection pass only. + * send() is intentionally called outside the lock to avoid blocking + * other book sub/unsub/publish paths while I/O is in progress. + * @note Contention: bookLock_ guards only book subscriptions, so this pass + * no longer competes with account or stream traffic. On high-throughput + * nodes processing multi-hop payments that touch many offer nodes, it + * still holds bookLock_ longer than the old per-book BookListeners + * locks did. This is an accepted trade-off for lock-domain simplicity. */ void pubBookTransaction(AcceptedLedgerTx const& transaction, MultiApiJson const& jvObj); @@ -750,6 +777,23 @@ private: std::shared_ptr const& transaction, TER result); + /** + * Send the ledgerClosed and book-changes stream updates for a ledger. + * Takes streamLock_ only. + */ + void + publishLedgerStreams( + std::shared_ptr const& lpAccepted, + std::shared_ptr const& alpAccepted); + + /** + * On the first published ledger only, start the delayed account-history + * streaming for any subscriptions that were registered before a validated + * ledger existed. Takes accountLock_ only. + */ + void + kickoffAccountHistory(std::shared_ptr const& alpAccepted); + void pubServer(); void @@ -802,7 +846,9 @@ private: hash_map>; /** - * @note called while holding subLock_ + * @note called while holding accountLock_ (it only touches + * subAccountHistory_ and posts a JobQueue task; it never reacquires + * a subscription lock nor touches the stream maps). */ void subAccountHistoryStart( @@ -813,12 +859,85 @@ private: void setAccountHistoryJobTimer(SubAccountHistoryInfoWeak subInfo); + /** + * Maximum number of account entries erased per accountLock_ acquisition + * during disconnect-time cleanup. + * + * The cleanup erase loops drop and reacquire accountLock_ after every + * chunk of this many accounts, bounding how long a large teardown holds + * the lock. A concurrent publish may interleave between chunks; that is + * safe because publishing tolerates a partially-cleaned map (a dead + * subscriber is simply not notified). + */ + static constexpr std::size_t kAccountCleanupChunk = 4096; + + /** + * Erase one connection's entries from a subscription map in + * accountLock_-bounded chunks. + * + * Shared engine behind cleanupAccountSubscriptions and + * cleanupAccountHistorySubscriptions: both walk @p accounts, and for each + * remove this connection's @p seq from the inner per-account map, dropping + * the outer entry once its last subscriber leaves. The lock is released + * between chunks so a competing publish can interleave; no iterator is held + * across the unlock, so a concurrent mutation cannot dangle. + * + * @tparam OuterMap hash_map>. + * @tparam BeforeErase Invoked with the inner value about to be erased, for + * per-entry teardown the plain account maps do not need + * (the history map uses it to stop its paging job). + * @param seq The disconnecting connection's subscription id. + * @param accounts The accounts this connection was subscribed to. + * @param outerMap The subscription map to erase from. + * @param beforeErase Called on each inner value just before it is erased. + * See kAccountCleanupChunk. + */ + template + void + cleanupSubscriptionMap( + std::uint64_t seq, + hash_set const& accounts, + OuterMap& outerMap, + BeforeErase&& beforeErase); + + /** + * Erase one connection's entries from the given account map (subAccount_ + * or subRTAccount_) in accountLock_-bounded chunks. The caller selects the + * map, so this need not know about the real-time/normal distinction. Keyed + * on seq, so it only removes the disconnecting connection's entries. + * See kAccountCleanupChunk. + */ + void + cleanupAccountSubscriptions( + std::uint64_t seq, + hash_set const& accounts, + SubInfoMapType& subMap); + + /** + * Erase one connection's entries from subAccountHistory_ in + * accountLock_-bounded chunks. Keyed on seq. See kAccountCleanupChunk. + */ + void + cleanupAccountHistorySubscriptions(std::uint64_t seq, hash_set const& accounts); + std::reference_wrapper registry_; beast::Journal journal_; std::unique_ptr localTX_; - std::recursive_mutex subLock_; + // Independent lock domains so a long cleanup/publish on one does not stall + // the others. Hold at most one at a time; if ever more, order: accountLock_, + // bookLock_, streamLock_. + // + // Deferred-destruction rule (non-recursive mutexes): under bookLock_ or + // streamLock_, never let the last InfoSub pointer die inside the lock - + // ~InfoSub re-acquires it via unsub* -> self-deadlock. Publishers collect the + // locked pointers in a vector declared before the lock and destruct after + // release (see pubServer / pubBookTransaction). accountLock_ is exempt: + // ~InfoSub offloads account teardown to scheduleAccountCleanup. + std::mutex accountLock_; ///< Guards subAccount_, subRTAccount_, subAccountHistory_. + std::mutex bookLock_; ///< Guards subBook_. + std::mutex streamLock_; ///< Guards streamMaps_[] and rpcSubMap_. std::atomic mode_; @@ -843,18 +962,18 @@ private: /** * Maps each order book to its current set of subscribers. - * Outer key: the Book (currency pair + optional domain). - * Inner key: InfoSub::seq (unique per connection). - * Inner value: weak_ptr so that a dropped connection does not prevent - * the InfoSub from being destroyed; expired entries are pruned lazily - * by pubBookTransaction and eagerly by unsubBookInternal (~InfoSub path). - * Guarded by subLock_. + * Outer key: the Book (currency pair + optional domain). + * Inner key: InfoSub::seq (unique per connection). + * Inner value: weak_ptr so that a dropped connection does not prevent + * the InfoSub from being destroyed; expired entries are pruned lazily + * by pubBookTransaction and eagerly by unsubBookInternal (~InfoSub path). + * Guarded by bookLock_. */ using SubBookMapType = hash_map; SubInfoMapType subAccount_; SubInfoMapType subRTAccount_; - SubBookMapType subBook_; ///< Guarded by subLock_. + SubBookMapType subBook_; ///< Guarded by bookLock_. subRpcMapType rpcSubMap_; @@ -875,6 +994,10 @@ private: SLastEntry // Any new entry must be ADDED ABOVE this one }; + /** + * One weak_ptr subscriber map per stream type. Guarded by streamLock_; + * subject to its deferred-destruction rule (see pubServer). + */ std::array streamMaps_; ServerFeeSummary lastFeeSummary_; @@ -2245,8 +2368,14 @@ NetworkOPsImp::consensusViewChange() void NetworkOPsImp::pubManifest(Manifest const& mo) { + // Hold each locked subscriber alive until after streamLock_ is released: + // if this is the last reference, ~InfoSub re-acquires streamLock_ (via its + // unsub* calls), which would self-deadlock on this non-recursive mutex. + // Declared before the lock so it is destroyed after the lock is dropped. + std::vector toRelease; + // VFALCO consider std::shared_mutex - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); if (!streamMaps_[SManifests].empty()) { @@ -2269,6 +2398,7 @@ NetworkOPsImp::pubManifest(Manifest const& mo) if (auto p = i->second.lock()) { p->send(jvObj, true); + toRelease.push_back(std::move(p)); ++i; } else @@ -2320,11 +2450,16 @@ trunc32(std::uint64_t v) void NetworkOPsImp::pubServer() { + // Hold each locked subscriber alive until after streamLock_ is released; a + // last-reference ~InfoSub would otherwise re-acquire this non-recursive + // mutex and self-deadlock. Declared before the lock, destroyed after it. + std::vector toRelease; + // VFALCO TODO Don't hold the lock across calls to send...make a copy of the // list into a local array while holding the lock then release // the lock and call send on everyone. // - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); if (!streamMaps_[SServer].empty()) { @@ -2362,7 +2497,7 @@ NetworkOPsImp::pubServer() for (auto i = streamMaps_[SServer].begin(); i != streamMaps_[SServer].end();) { - InfoSub::pointer const p = i->second.lock(); + InfoSub::pointer p = i->second.lock(); // VFALCO TODO research the possibility of using thread queues and // linearizing the deletion of subscribers with the @@ -2370,6 +2505,7 @@ NetworkOPsImp::pubServer() if (p) { p->send(jvObj, true); + toRelease.push_back(std::move(p)); ++i; } else @@ -2383,7 +2519,12 @@ NetworkOPsImp::pubServer() void NetworkOPsImp::pubConsensus(ConsensusPhase phase) { - std::scoped_lock const sl(subLock_); + // Hold each locked subscriber alive until after streamLock_ is released; a + // last-reference ~InfoSub would otherwise re-acquire this non-recursive + // mutex and self-deadlock. Declared before the lock, destroyed after it. + std::vector toRelease; + + std::scoped_lock const sl(streamLock_); auto& streamMap = streamMaps_[SConsensusPhase]; if (!streamMap.empty()) @@ -2397,6 +2538,7 @@ NetworkOPsImp::pubConsensus(ConsensusPhase phase) if (auto p = i->second.lock()) { p->send(jvObj, true); + toRelease.push_back(std::move(p)); ++i; } else @@ -2410,8 +2552,13 @@ NetworkOPsImp::pubConsensus(ConsensusPhase phase) void NetworkOPsImp::pubValidation(std::shared_ptr const& val) { + // Hold each locked subscriber alive until after streamLock_ is released; a + // last-reference ~InfoSub would otherwise re-acquire this non-recursive + // mutex and self-deadlock. Declared before the lock, destroyed after it. + std::vector toRelease; + // VFALCO consider std::shared_mutex - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); if (!streamMaps_[SValidations].empty()) { @@ -2503,6 +2650,7 @@ NetworkOPsImp::pubValidation(std::shared_ptr const& val) multiObj.visit( p->getApiVersion(), // [&](json::Value const& jv) { p->send(jv, true); }); + toRelease.push_back(std::move(p)); ++i; } else @@ -2516,7 +2664,12 @@ NetworkOPsImp::pubValidation(std::shared_ptr const& val) void NetworkOPsImp::pubPeerStatus(std::function const& func) { - std::scoped_lock const sl(subLock_); + // Hold each locked subscriber alive until after streamLock_ is released; a + // last-reference ~InfoSub would otherwise re-acquire this non-recursive + // mutex and self-deadlock. Declared before the lock, destroyed after it. + std::vector toRelease; + + std::scoped_lock const sl(streamLock_); if (!streamMaps_[SPeerStatus].empty()) { @@ -2526,11 +2679,12 @@ NetworkOPsImp::pubPeerStatus(std::function const& func) for (auto i = streamMaps_[SPeerStatus].begin(); i != streamMaps_[SPeerStatus].end();) { - InfoSub::pointer const p = i->second.lock(); + InfoSub::pointer p = i->second.lock(); if (p) { p->send(jvObj, true); + toRelease.push_back(std::move(p)); ++i; } else @@ -3074,7 +3228,13 @@ NetworkOPsImp::pubProposedTransaction( MultiApiJson const jvObj = transJson(transaction, result, false, ledger, std::nullopt); { - std::scoped_lock const sl(subLock_); + // Hold each locked subscriber alive until after streamLock_ is + // released; a last-reference ~InfoSub would otherwise re-acquire this + // non-recursive mutex and self-deadlock. Declared before the lock, + // destroyed after the block ends. + std::vector toRelease; + + std::scoped_lock const sl(streamLock_); auto it = streamMaps_[SRtTransactions].begin(); while (it != streamMaps_[SRtTransactions].end()) @@ -3086,6 +3246,7 @@ NetworkOPsImp::pubProposedTransaction( jvObj.visit( p->getApiVersion(), // [&](json::Value const& jv) { p->send(jv, true); }); + toRelease.push_back(std::move(p)); ++it; } else @@ -3117,100 +3278,121 @@ NetworkOPsImp::pubLedger(std::shared_ptr const& lpAccepted) alpAccepted->getLedger().get() == lpAccepted.get(), "xrpl::NetworkOPsImp::pubLedger : accepted input"); - { - JLOG(journal_.debug()) << "Publishing ledger " << lpAccepted->header().seq << " " - << lpAccepted->header().hash; + JLOG(journal_.debug()) << "Publishing ledger " << lpAccepted->header().seq << " " + << lpAccepted->header().hash; - std::scoped_lock const sl(subLock_); - - if (!streamMaps_[SLedger].empty()) - { - json::Value jvObj(json::ValueType::Object); - - jvObj[jss::type] = "ledgerClosed"; - jvObj[jss::ledger_index] = lpAccepted->header().seq; - jvObj[jss::ledger_hash] = to_string(lpAccepted->header().hash); - jvObj[jss::ledger_time] = - json::Value::UInt(lpAccepted->header().closeTime.time_since_epoch().count()); - - jvObj[jss::network_id] = registry_.get().getNetworkIDService().getNetworkID(); - - if (!lpAccepted->rules().enabled(featureXRPFees)) - jvObj[jss::fee_ref] = kFeeUnitsDeprecated; - jvObj[jss::fee_base] = lpAccepted->fees().base.jsonClipped(); - jvObj[jss::reserve_base] = lpAccepted->fees().reserve.jsonClipped(); - jvObj[jss::reserve_inc] = lpAccepted->fees().increment.jsonClipped(); - - jvObj[jss::txn_count] = json::UInt(alpAccepted->size()); - - if (mode_ >= OperatingMode::SYNCING) - { - jvObj[jss::validated_ledgers] = - registry_.get().getLedgerMaster().getCompleteLedgers(); - } - - auto it = streamMaps_[SLedger].begin(); - while (it != streamMaps_[SLedger].end()) - { - InfoSub::pointer const p = it->second.lock(); - if (p) - { - p->send(jvObj, true); - ++it; - } - else - { - it = streamMaps_[SLedger].erase(it); - } - } - } - - if (!streamMaps_[SBookChanges].empty()) - { - json::Value const jvObj = xrpl::rpc::computeBookChanges(lpAccepted); - - auto it = streamMaps_[SBookChanges].begin(); - while (it != streamMaps_[SBookChanges].end()) - { - InfoSub::pointer const p = it->second.lock(); - if (p) - { - p->send(jvObj, true); - ++it; - } - else - { - it = streamMaps_[SBookChanges].erase(it); - } - } - } - - { - static bool kFirstTime = true; - if (kFirstTime) - { - // First validated ledger, start delayed SubAccountHistory - kFirstTime = false; - for (auto& outer : subAccountHistory_) - { - for (auto& inner : outer.second) - { - auto& subInfo = inner.second; - if (subInfo.index->separationLedgerSeq == 0) - { - subAccountHistoryStart(alpAccepted->getLedger(), subInfo); - } - } - } - } - } - } + // Stream updates and the account-history kick-off touch different lock + // domains; each helper takes only its own lock, so the two are never held + // together. + publishLedgerStreams(lpAccepted, alpAccepted); + kickoffAccountHistory(alpAccepted); // Don't lock since pubAcceptedTransaction is locking. for (auto const& accTx : *alpAccepted) { JLOG(journal_.trace()) << "pubAccepted: " << accTx->getJson(); - pubValidatedTransaction(lpAccepted, *accTx, accTx == *(--alpAccepted->end())); + bool const last = &*accTx == &alpAccepted->back(); + pubValidatedTransaction(lpAccepted, *accTx, last); + } +} + +void +NetworkOPsImp::publishLedgerStreams( + std::shared_ptr const& lpAccepted, + std::shared_ptr const& alpAccepted) +{ + // Hold each locked subscriber alive until after streamLock_ is released; a + // last-reference ~InfoSub would otherwise re-acquire this non-recursive + // mutex and self-deadlock. Declared before the lock, destroyed after it; + // covers both the ledger and book-changes loops below. + std::vector toRelease; + + std::scoped_lock const sl(streamLock_); + + if (!streamMaps_[SLedger].empty()) + { + json::Value jvObj(json::ValueType::Object); + + jvObj[jss::type] = "ledgerClosed"; + jvObj[jss::ledger_index] = lpAccepted->header().seq; + jvObj[jss::ledger_hash] = to_string(lpAccepted->header().hash); + jvObj[jss::ledger_time] = + json::Value::UInt(lpAccepted->header().closeTime.time_since_epoch().count()); + + jvObj[jss::network_id] = registry_.get().getNetworkIDService().getNetworkID(); + + if (!lpAccepted->rules().enabled(featureXRPFees)) + jvObj[jss::fee_ref] = kFeeUnitsDeprecated; + jvObj[jss::fee_base] = lpAccepted->fees().base.jsonClipped(); + jvObj[jss::reserve_base] = lpAccepted->fees().reserve.jsonClipped(); + jvObj[jss::reserve_inc] = lpAccepted->fees().increment.jsonClipped(); + + jvObj[jss::txn_count] = json::UInt(alpAccepted->size()); + + if (mode_ >= OperatingMode::SYNCING) + { + jvObj[jss::validated_ledgers] = registry_.get().getLedgerMaster().getCompleteLedgers(); + } + auto it = streamMaps_[SLedger].begin(); + while (it != streamMaps_[SLedger].end()) + { + InfoSub::pointer p = it->second.lock(); + if (p) + { + p->send(jvObj, true); + toRelease.push_back(std::move(p)); + ++it; + } + else + { + it = streamMaps_[SLedger].erase(it); + } + } + } + + if (!streamMaps_[SBookChanges].empty()) + { + json::Value const jvObj = xrpl::rpc::computeBookChanges(lpAccepted); + + auto it = streamMaps_[SBookChanges].begin(); + while (it != streamMaps_[SBookChanges].end()) + { + InfoSub::pointer p = it->second.lock(); + if (p) + { + p->send(jvObj, true); + toRelease.push_back(std::move(p)); + ++it; + } + else + { + it = streamMaps_[SBookChanges].erase(it); + } + } + } +} + +void +NetworkOPsImp::kickoffAccountHistory(std::shared_ptr const& alpAccepted) +{ + // Runs exactly once, the first time a ledger is published. The atomic + // exchange lets the common post-first-ledger path return without taking + // accountLock_, while still admitting exactly one caller even if ledger + // publishing is ever made concurrent. + static std::atomic done{false}; + if (done.exchange(true)) + return; + + // It only reads/writes subAccountHistory_, so it takes accountLock_ alone. + std::scoped_lock const sl(accountLock_); + for (auto& outer : subAccountHistory_) + { + for (auto& inner : outer.second) + { + auto& subInfo = inner.second; + if (subInfo.index->separationLedgerSeq == 0) + subAccountHistoryStart(alpAccepted->getLedger(), subInfo); + } } } @@ -3249,7 +3431,7 @@ NetworkOPsImp::getLocalTxCount() std::size_t NetworkOPsImp::getBookSubscribersCount() { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(bookLock_); std::size_t total = 0; for (auto const& [_, subs] : subBook_) total += subs.size(); @@ -3376,7 +3558,13 @@ NetworkOPsImp::pubValidatedTransaction( MultiApiJson const jvObj = transJson(stTxn, trResult, true, ledger, metaRef); { - std::scoped_lock const sl(subLock_); + // Hold each locked subscriber alive until after streamLock_ is + // released; a last-reference ~InfoSub would otherwise re-acquire this + // non-recursive mutex and self-deadlock. Declared before the lock, + // destroyed after the block ends; covers both loops below. + std::vector toRelease; + + std::scoped_lock const sl(streamLock_); auto it = streamMaps_[STransactions].begin(); while (it != streamMaps_[STransactions].end()) @@ -3388,6 +3576,7 @@ NetworkOPsImp::pubValidatedTransaction( jvObj.visit( p->getApiVersion(), // [&](json::Value const& jv) { p->send(jv, true); }); + toRelease.push_back(std::move(p)); ++it; } else @@ -3407,6 +3596,7 @@ NetworkOPsImp::pubValidatedTransaction( jvObj.visit( p->getApiVersion(), // [&](json::Value const& jv) { p->send(jv, true); }); + toRelease.push_back(std::move(p)); ++it; } else @@ -3431,20 +3621,20 @@ NetworkOPsImp::pubBookTransaction(AcceptedLedgerTx const& alTx, MultiApiJson con // Two-pass design: // - // 1. Under subLock_, walk subBook_, collect a strong pointer for each + // 1. Under bookLock_, walk subBook_, collect a strong pointer for each // unique listener (and prune any expired weak_ptrs we encounter). - // 2. Release subLock_, then send to each collected listener. + // 2. Release bookLock_, then send to each collected listener. // // Reasoning: - // * send() can be slow / blocking, so holding subLock_ across it would - // stall every other sub/unsub/pub path on this server (see the matching - // TODO above pubServer at line ~2275). - // * A strong pointer destructed while subLock_ is held risks running + // * send() can be slow / blocking, so holding bookLock_ across it would + // stall every other book sub/unsub/pub path on this server (see the + // matching TODO above pubServer at line ~2275). + // * A strong pointer destructed while bookLock_ is held risks running // ~InfoSub() in-line, which re-enters unsubBook() and mutates the very // subBook_/SubMapType being iterated -> dangling iterator UB. // - // Releasing subLock_ before any InfoSub::pointer can decay solves both. - // ~InfoSub() reacquires subLock_ via unsubBook() on its own and serializes + // Releasing bookLock_ before any InfoSub::pointer can decay solves both. + // ~InfoSub() reacquires bookLock_ via unsubBook() on its own and serializes // safely with concurrent traffic. std::vector listeners; @@ -3458,7 +3648,7 @@ NetworkOPsImp::pubBookTransaction(AcceptedLedgerTx const& alTx, MultiApiJson con seen.reserve(books.size()); { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(bookLock_); for (auto const& book : books) { @@ -3496,8 +3686,8 @@ NetworkOPsImp::pubBookTransaction(AcceptedLedgerTx const& alTx, MultiApiJson con { jvObj.visit(p->getApiVersion(), [&](json::Value const& jv) { p->send(jv, true); }); } - // listeners destructs here, outside subLock_; ~InfoSub (if any fires) - // will reacquire subLock_ via unsubBook with no iterator hazard. + // listeners destructs here, outside bookLock_; ~InfoSub (if any fires) + // will reacquire bookLock_ via unsubBook with no iterator hazard. } void @@ -3513,7 +3703,7 @@ NetworkOPsImp::pubAccountTransaction( std::vector accountHistoryNotify; auto const currLedgerSeq = ledger->seq(); { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(accountLock_); if (!subAccount_.empty() || !subRTAccount_.empty() || !subAccountHistory_.empty()) { @@ -3646,7 +3836,7 @@ NetworkOPsImp::pubProposedAccountTransaction( std::vector accountHistoryNotify; { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(accountLock_); if (subRTAccount_.empty()) return; @@ -3730,7 +3920,7 @@ NetworkOPsImp::subAccount( isrListener->insertSubAccountInfo(naAccountID, rt); } - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(accountLock_); for (auto const& naAccountID : vnaAccountIDs) { @@ -3773,7 +3963,7 @@ NetworkOPsImp::unsubAccountInternal( hash_set const& vnaAccountIDs, bool rt) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(accountLock_); SubInfoMapType& subMap = rt ? subRTAccount_ : subAccount_; @@ -3795,6 +3985,122 @@ NetworkOPsImp::unsubAccountInternal( } } +template +void +NetworkOPsImp::cleanupSubscriptionMap( + std::uint64_t seq, + hash_set const& accounts, + OuterMap& outerMap, + BeforeErase&& beforeErase) +{ + // Walk the disconnecting connection's accounts in chunks. Each chunk takes + // accountLock_, erases up to kAccountCleanupChunk entries, then releases + // the lock so a competing account-publish can run before the next chunk. + // No iterator into outerMap is held across the unlock: every chunk re-finds + // each account, so a concurrent mutation between chunks cannot dangle. + auto it = accounts.begin(); + auto const end = accounts.end(); + while (it != end) + { + std::scoped_lock const sl(accountLock_); + + for (std::size_t n = 0; n < kAccountCleanupChunk && it != end; ++n, ++it) + { + auto outerIter = outerMap.find(*it); + if (outerIter != outerMap.end()) + { + // Give the caller a chance to tear down this connection's inner + // entry before it is erased (the history map stops its paging + // job here); the plain account maps pass a no-op. + auto innerIter = outerIter->second.find(seq); + if (innerIter != outerIter->second.end()) + beforeErase(innerIter->second); + + // Erase only this connection's seq; other connections sharing + // the account keep their entry, so a reconnect is unaffected. + outerIter->second.erase(seq); + if (outerIter->second.empty()) + outerMap.erase(outerIter); + } + } + } +} + +void +NetworkOPsImp::cleanupAccountSubscriptions( + std::uint64_t seq, + hash_set const& accounts, + SubInfoMapType& subMap) +{ + // Plain account maps need no per-entry teardown before erase. + cleanupSubscriptionMap(seq, accounts, subMap, [](InfoSub::wptr const&) {}); +} + +void +NetworkOPsImp::cleanupAccountHistorySubscriptions( + std::uint64_t seq, + hash_set const& accounts) +{ + // Cancel any in-flight historical paging job for this connection before + // dropping its record. The job holds its own shared_ptr to the index, so + // erasing the map entry alone would not stop it; it reads this atomic + // between pages and exits promptly once set. + cleanupSubscriptionMap( + seq, accounts, subAccountHistory_, [](SubAccountHistoryInfoWeak const& info) { + info.index->stopHistorical = true; + }); +} + +void +NetworkOPsImp::scheduleAccountCleanup( + std::uint64_t seq, + hash_set rtAccounts, + hash_set normalAccounts, + hash_set historyAccounts) +{ + // Nothing to do for a connection that never subscribed to any account. + if (rtAccounts.empty() && normalAccounts.empty() && historyAccounts.empty()) + return; + + // Post the erase work to a low-priority job so the disconnect thread (and + // ~InfoSub) returns immediately. The job captures the sets BY MOVE and + // operates purely on seq + the captured accounts; it never touches the + // destroyed InfoSub. `this` outlives the job per the Source lifetime + // contract. Running on a JobQueue thread, it cannot re-enter accountLock_ + // held by the disconnecting thread, so the plain std::mutex is safe. + // + // The body is exception-guarded: the JobQueue invokes it bare, so an + // escaping exception on the worker thread would terminate the process. + // + // addJob returns false only once the JobQueue has been stopped, i.e. during + // process shutdown. At that point NetworkOPsImp's maps are about to be + // destroyed wholesale and no publish path can run, so dropping the cleanup + // is harmless; no inline fallback is needed. + jobQueue_.addJob( + JtClientAcctHist, + "SubCleanup", + [this, + seq, + rt = std::move(rtAccounts), + normal = std::move(normalAccounts), + history = std::move(historyAccounts)]() noexcept { + try + { + cleanupAccountSubscriptions(seq, rt, subRTAccount_); + cleanupAccountSubscriptions(seq, normal, subAccount_); + cleanupAccountHistorySubscriptions(seq, history); + } + catch (std::exception const& e) + { + JLOG(journal_.error()) << "SubCleanup[seq=" << seq << "]: " << e.what(); + } + catch (...) + { + JLOG(journal_.error()) << "SubCleanup[seq=" << seq << "]: unknown exception"; + } + }); +} + void NetworkOPsImp::addAccountHistoryJob(SubAccountHistoryInfoWeak subInfo) { @@ -4077,7 +4383,7 @@ NetworkOPsImp::subAccountHistory(InfoSub::ref isrListener, AccountID const& acco return RpcInvalidParams; } - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(accountLock_); SubAccountHistoryInfoWeak ahi{ .sinkWptr = isrListener, .index = std::make_shared(accountId)}; auto simIterator = subAccountHistory_.find(accountId); @@ -4125,7 +4431,7 @@ NetworkOPsImp::unsubAccountHistoryInternal( AccountID const& account, bool historyOnly) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(accountLock_); auto simIterator = subAccountHistory_.find(account); if (simIterator != subAccountHistory_.end()) { @@ -4157,7 +4463,7 @@ NetworkOPsImp::subBook(InfoSub::ref isrListener, Book const& book) // prune in pubBookTransaction. With the reverse ordering, ~InfoSub would // call unsubBookInternal for a key that was never inserted server-side. { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(bookLock_); subBook_[book].try_emplace(isrListener->getSeq(), isrListener); } isrListener->insertBookSubscription(book); @@ -4177,7 +4483,7 @@ NetworkOPsImp::unsubBook(InfoSub::ref isrListener, Book const& book) bool NetworkOPsImp::unsubBookInternal(std::uint64_t uSeq, Book const& book) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(bookLock_); auto it = subBook_.find(book); if (it == subBook_.end()) return false; @@ -4227,7 +4533,7 @@ NetworkOPsImp::subLedger(InfoSub::ref isrListener, json::Value& jvResult) jvResult[jss::validated_ledgers] = registry_.get().getLedgerMaster().getCompleteLedgers(); } - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SLedger].emplace(isrListener->getSeq(), isrListener).second; } @@ -4235,7 +4541,7 @@ NetworkOPsImp::subLedger(InfoSub::ref isrListener, json::Value& jvResult) bool NetworkOPsImp::subBookChanges(InfoSub::ref isrListener) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SBookChanges].emplace(isrListener->getSeq(), isrListener).second; } @@ -4243,7 +4549,7 @@ NetworkOPsImp::subBookChanges(InfoSub::ref isrListener) bool NetworkOPsImp::unsubLedger(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SLedger].erase(uSeq) != 0u; } @@ -4251,7 +4557,7 @@ NetworkOPsImp::unsubLedger(std::uint64_t uSeq) bool NetworkOPsImp::unsubBookChanges(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SBookChanges].erase(uSeq) != 0u; } @@ -4259,7 +4565,7 @@ NetworkOPsImp::unsubBookChanges(std::uint64_t uSeq) bool NetworkOPsImp::subManifests(InfoSub::ref isrListener) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SManifests].emplace(isrListener->getSeq(), isrListener).second; } @@ -4267,7 +4573,7 @@ NetworkOPsImp::subManifests(InfoSub::ref isrListener) bool NetworkOPsImp::unsubManifests(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SManifests].erase(uSeq) != 0u; } @@ -4292,7 +4598,7 @@ NetworkOPsImp::subServer(InfoSub::ref isrListener, json::Value& jvResult, bool a jvResult[jss::pubkey_node] = toBase58(TokenType::NodePublic, registry_.get().getApp().nodeIdentity().first); - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SServer].emplace(isrListener->getSeq(), isrListener).second; } @@ -4300,7 +4606,7 @@ NetworkOPsImp::subServer(InfoSub::ref isrListener, json::Value& jvResult, bool a bool NetworkOPsImp::unsubServer(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SServer].erase(uSeq) != 0u; } @@ -4308,7 +4614,7 @@ NetworkOPsImp::unsubServer(std::uint64_t uSeq) bool NetworkOPsImp::subTransactions(InfoSub::ref isrListener) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[STransactions].emplace(isrListener->getSeq(), isrListener).second; } @@ -4316,7 +4622,7 @@ NetworkOPsImp::subTransactions(InfoSub::ref isrListener) bool NetworkOPsImp::unsubTransactions(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[STransactions].erase(uSeq) != 0u; } @@ -4324,7 +4630,7 @@ NetworkOPsImp::unsubTransactions(std::uint64_t uSeq) bool NetworkOPsImp::subRTTransactions(InfoSub::ref isrListener) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SRtTransactions].emplace(isrListener->getSeq(), isrListener).second; } @@ -4332,7 +4638,7 @@ NetworkOPsImp::subRTTransactions(InfoSub::ref isrListener) bool NetworkOPsImp::unsubRTTransactions(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SRtTransactions].erase(uSeq) != 0u; } @@ -4340,7 +4646,7 @@ NetworkOPsImp::unsubRTTransactions(std::uint64_t uSeq) bool NetworkOPsImp::subValidations(InfoSub::ref isrListener) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SValidations].emplace(isrListener->getSeq(), isrListener).second; } @@ -4354,7 +4660,7 @@ NetworkOPsImp::stateAccounting(json::Value& obj) bool NetworkOPsImp::unsubValidations(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SValidations].erase(uSeq) != 0u; } @@ -4362,7 +4668,7 @@ NetworkOPsImp::unsubValidations(std::uint64_t uSeq) bool NetworkOPsImp::subPeerStatus(InfoSub::ref isrListener) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SPeerStatus].emplace(isrListener->getSeq(), isrListener).second; } @@ -4370,7 +4676,7 @@ NetworkOPsImp::subPeerStatus(InfoSub::ref isrListener) bool NetworkOPsImp::unsubPeerStatus(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SPeerStatus].erase(uSeq) != 0u; } @@ -4378,7 +4684,7 @@ NetworkOPsImp::unsubPeerStatus(std::uint64_t uSeq) bool NetworkOPsImp::subConsensus(InfoSub::ref isrListener) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SConsensusPhase].emplace(isrListener->getSeq(), isrListener).second; } @@ -4386,15 +4692,14 @@ NetworkOPsImp::subConsensus(InfoSub::ref isrListener) bool NetworkOPsImp::unsubConsensus(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SConsensusPhase].erase(uSeq) != 0u; } InfoSub::pointer -NetworkOPsImp::findRpcSub(std::string const& strUrl) +NetworkOPsImp::findRpcSubLocked(std::string const& strUrl) { - std::scoped_lock const sl(subLock_); - + // Caller already holds streamLock_; this performs the lookup only. auto const it = rpcSubMap_.find(strUrl); if (it != rpcSubMap_.end()) @@ -4403,10 +4708,17 @@ NetworkOPsImp::findRpcSub(std::string const& strUrl) return InfoSub::pointer(); } +InfoSub::pointer +NetworkOPsImp::findRpcSub(std::string const& strUrl) +{ + std::scoped_lock const sl(streamLock_); + return findRpcSubLocked(strUrl); +} + InfoSub::pointer NetworkOPsImp::addRpcSub(std::string const& strUrl, InfoSub::ref rspEntry) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); rpcSubMap_.emplace(strUrl, rspEntry); @@ -4416,20 +4728,31 @@ NetworkOPsImp::addRpcSub(std::string const& strUrl, InfoSub::ref rspEntry) bool NetworkOPsImp::tryRemoveRpcSub(std::string const& strUrl) { - std::scoped_lock const sl(subLock_); - auto pInfo = findRpcSub(strUrl); - - if (!pInfo) - return false; - - // check to see if any of the stream maps still hold a weak reference to - // this entry before removing - for (SubMapType const& map : streamMaps_) + // Declared before the lock so it outlives the scoped_lock and is destroyed + // only after streamLock_ is released. The erase below may drop the last + // strong reference; if so, ~InfoSub runs and its unsub* calls re-acquire + // the non-recursive streamLock_. Destroying pInfo inside the lock would + // self-deadlock. + InfoSub::pointer pInfo; { - if (map.contains(pInfo->getSeq())) + std::scoped_lock const sl(streamLock_); + // Use the no-lock helper: we already hold streamLock_ and the mutex is + // not recursive, so calling the public findRpcSub here would deadlock. + pInfo = findRpcSubLocked(strUrl); + + if (!pInfo) return false; + + // check to see if any of the stream maps still hold a weak reference to + // this entry before removing + for (SubMapType const& map : streamMaps_) + { + if (map.contains(pInfo->getSeq())) + return false; + } + rpcSubMap_.erase(strUrl); } - rpcSubMap_.erase(strUrl); + // pInfo destroyed here, after streamLock_ is released. return true; } diff --git a/src/xrpld/app/misc/detail/ValidatorList.cpp b/src/xrpld/app/misc/detail/ValidatorList.cpp index a9e7156158..e355cfacab 100644 --- a/src/xrpld/app/misc/detail/ValidatorList.cpp +++ b/src/xrpld/app/misc/detail/ValidatorList.cpp @@ -1065,6 +1065,8 @@ ValidatorList::updatePublisherList( { // Increment list count for added keys ++keyListings_[*iNew]; + // Key is now listed: free its untrusted slot if it had one. + validatorManifests_.promoteToTrusted(*iNew); ++iNew; } else if (iNew == publisherList.end() || (iOld != oldList.end() && *iOld < *iNew)) @@ -1103,7 +1105,8 @@ ValidatorList::updatePublisherList( continue; } - if (auto const r = validatorManifests_.applyManifest(std::move(*m)); + if (auto const r = validatorManifests_.applyManifest( + std::move(*m), ManifestRateLimitCapPolicy::Uncapped); r == ManifestDisposition::Invalid) { JLOG(j_.warn()) << "List for " << strHex(pubKey) @@ -1127,6 +1130,15 @@ ValidatorList::applyList( json::Value list; auto const& manifest = localManifest ? *localManifest : globalManifest; + // Reject an oversized manifest before decoding it, so we do not allocate + // memory for an input that cannot be a valid manifest. deserializeManifest + // also enforces the decoded-byte limit, but checking here avoids the + // base64 decode entirely. + if (manifest.size() > kMaxManifestBase64) + { + JLOG(j_.warn()) << "UNL manifest exceeds maximum size"; + return PublisherListStats{ListDisposition::Invalid}; + } auto m = deserializeManifest(base64Decode(manifest)); if (!m) { @@ -1348,7 +1360,10 @@ ValidatorList::verify( PublicKey masterPubKey = manifest.masterKey; auto const revoked = manifest.revoked(); - auto const result = publisherManifests_.applyManifest(std::move(manifest)); + // Publisher keys are configured/trusted (checked above), so bypass the + // untrusted cap. + auto const result = publisherManifests_.applyManifest( + std::move(manifest), ManifestRateLimitCapPolicy::Uncapped); if (revoked && result == ManifestDisposition::Accepted) { diff --git a/src/xrpld/core/Config.h b/src/xrpld/core/Config.h index d43a7a566d..ac28b6e224 100644 --- a/src/xrpld/core/Config.h +++ b/src/xrpld/core/Config.h @@ -229,6 +229,12 @@ public: static constexpr int kMaxJobQueueTx = 1000; static constexpr int kMinJobQueueTx = 100; + // Optional override for the per-connection subscription cap. Unset means + // use the built-in default (kMaxSubscriptionsPerConnection in InfoSub.h). + // Kept as an override here, rather than the default itself, so the core + // module need not depend on the server module that owns the constant. + std::optional maxSubscriptionsPerConnection; + // Amendment majority time std::chrono::seconds amendmentMajorityTime = kDefaultAmendmentMajorityTime; @@ -289,6 +295,23 @@ public: // How long can a peer remain in the "diverged" state std::chrono::seconds maxDivergedTime{300}; + // Optional overrides for how many manifests are kept in the cache and + // carried in one TMManifests message, split by whether this node lists the + // validator. Unset means use the built-in defaults (kMaxUntrustedCount and + // kMaxTrustedCount in Manifest.h). Kept as overrides here, rather than the + // defaults themselves, so the core module need not depend on the server + // module that owns the constants. + std::optional maxUntrustedCount; + std::optional maxTrustedCount; + + // Bounds for both counts above. The lower bound leaves room for a small + // network or a deliberately tight limit; note that setting a count below + // what peers actually send means their manifest messages are dropped for + // being oversized. The upper bound keeps the implied message size well + // under the overall protocol message limit. + static constexpr std::size_t kMinManifestCount = 50; + static constexpr std::size_t kMaxManifestCount = 1000; + // Enable the beta API version bool betaRpcApi = false; diff --git a/src/xrpld/core/detail/Config.cpp b/src/xrpld/core/detail/Config.cpp index 3b7b57328b..e93ccec56e 100644 --- a/src/xrpld/core/detail/Config.cpp +++ b/src/xrpld/core/detail/Config.cpp @@ -677,6 +677,9 @@ Config::loadFromString(std::string const& fileContents) if (getSingleSection(secConfig, Sections::kNetworkQuorum, strTemp, j_)) networkQuorum = beast::lexicalCastThrow(strTemp); + if (getSingleSection(secConfig, Sections::kMaxSubscriptionsPerConnection, strTemp, j_)) + maxSubscriptionsPerConnection = beast::lexicalCastThrow(strTemp); + fees = setupFeeVote(section(Sections::kVoting)); /* [fee_default] is documented in the example config files as useful for * things like offline transaction signing. Until that's completely @@ -920,6 +923,38 @@ Config::loadFromString(std::string const& fileContents) std::string("Invalid value 'max_diverged_time' in ") + Sections::kOverlay + ": the time must be between 60 and 900 seconds, inclusive."); } + + // Both manifest counts parse and validate identically, so read them + // the same way. Returns nullopt when the key is absent, leaving the + // built-in default in effect at the use site. + auto manifestCount = [&sec](char const* key) -> std::optional { + std::optional count; + + try + { + if (auto val = sec.get(key)) + count = beast::lexicalCastThrow(*val); + } + catch (...) + { + Throw( + std::string("Invalid value '") + key + "' in " + Sections::kOverlay + + ": must be of the form '' representing a count of manifests."); + } + + if (count && (*count < kMinManifestCount || *count > kMaxManifestCount)) + { + Throw( + std::string("Invalid value '") + key + "' in " + Sections::kOverlay + + ": the count must be between " + std::to_string(kMinManifestCount) + " and " + + std::to_string(kMaxManifestCount) + ", inclusive."); + } + + return count; + }; + + maxUntrustedCount = manifestCount(Keys::kMaxUntrustedCount); + maxTrustedCount = manifestCount(Keys::kMaxTrustedCount); } if (getSingleSection(secConfig, Sections::kAmendmentMajorityTime, strTemp, j_)) diff --git a/src/xrpld/overlay/Message.h b/src/xrpld/overlay/Message.h index 2e187a2a4d..065da696d8 100644 --- a/src/xrpld/overlay/Message.h +++ b/src/xrpld/overlay/Message.h @@ -4,6 +4,7 @@ #include #include +#include #include @@ -19,6 +20,41 @@ namespace xrpl { constexpr std::size_t kMaximumMessageSize = megabytes(64); +// Ping messages should be much smaller than the maximum message size, +// so we define a separate limit for them. +constexpr std::size_t kMaximumPingMessageSize = kilobytes(1); + +// Allowance for protobuf framing around each manifest in a TMManifests message. +constexpr std::size_t kManifestFramingBytes = 8; + +/** + * Upper bound on the wire size of a TMManifests message. + * + * Allows both counts' worth of entries at @ref kMaxManifestBytes each, plus + * framing per entry. Messages larger than this are dropped before parsing, + * which bounds the work an oversized message can cause. + * + * @param trustedCount Trusted manifests per message. + * + * @param untrustedCount Untrusted manifests per message. + * + * @note A node that raises either count accepts larger messages than a peer + * running the defaults, and the messages it sends may be dropped by such a + * peer. Lowering either count below what peers send drops their manifest + * messages, including any trusted key rotations they carry, and the drop + * is not recorded on either side. + */ +constexpr std::size_t +maximumManifestsMessageSize(std::size_t const trustedCount, std::size_t const untrustedCount) +{ + return (trustedCount + untrustedCount) * (kMaxManifestBytes + kManifestFramingBytes); +} + +// The message size the defaults imply must stay within the overall protocol +// message limit. The same check for the largest configurable counts lives in +// OverlayImpl.h, where the configured bound is visible. +static_assert( + maximumManifestsMessageSize(kMaxTrustedCount, kMaxUntrustedCount) < kMaximumMessageSize); // VFALCO NOTE If we forward declare Message and write out shared_ptr // instead of using the in-class type alias, we can remove the diff --git a/src/xrpld/overlay/Peer.h b/src/xrpld/overlay/Peer.h index c2631cc7ce..87750ed40e 100644 --- a/src/xrpld/overlay/Peer.h +++ b/src/xrpld/overlay/Peer.h @@ -118,7 +118,7 @@ public: // Ledger // - [[nodiscard]] virtual uint256 const& + [[nodiscard]] virtual uint256 getClosedLedgerHash() const = 0; [[nodiscard]] virtual bool hasLedger(uint256 const& hash, std::uint32_t seq) const = 0; diff --git a/src/xrpld/overlay/detail/OverlayImpl.cpp b/src/xrpld/overlay/detail/OverlayImpl.cpp index 81ead14111..587f4f810c 100644 --- a/src/xrpld/overlay/detail/OverlayImpl.cpp +++ b/src/xrpld/overlay/detail/OverlayImpl.cpp @@ -45,6 +45,7 @@ #include #include #include +#include #include #include #include @@ -666,25 +667,51 @@ OverlayImpl::onManifests( std::shared_ptr const& m, std::shared_ptr const& from) { - auto const n = m->list_size(); auto const& journal = from->pJournal(); + // Process every trusted manifest, but stop processing untrusted ones once + // the configured untrusted count has been handled, so the work stays + // bounded. Trusted manifests are always processed: dropping one would delay + // a validator key rotation reaching this node. + auto const maxUntrusted = untrustedManifestCount(app_.config().maxUntrustedCount); + auto const total = static_cast(m->list_size()); + std::size_t untrusted = 0; + bool skippedUntrusted = false; + protocol::TMManifests relay; - for (std::size_t i = 0; i < n; ++i) + for (std::size_t i = 0; i < total; ++i) { auto& s = m->list().Get(i).stobject(); if (auto mo = deserializeManifest(s)) { auto const serialized = mo->serialized; + // Resolve trust before applyManifest takes the manifest-cache + // lock: listed() takes the validator-list lock, so ordering it + // first avoids holding the two locks in opposite orders. + bool const isTrusted = app_.getValidators().listed(mo->masterKey); - auto const result = app_.getValidatorManifests().applyManifest(std::move(*mo)); + // Bound untrusted work: process at most maxUntrusted untrusted + // manifests, but never skip a trusted one. Trusted manifests are + // not counted against the cap. + if (!isTrusted) + { + if (untrusted >= maxUntrusted) + { + skippedUntrusted = true; + continue; + } + ++untrusted; + } + + auto const result = app_.getValidatorManifests().applyManifest( + std::move(*mo), + isTrusted ? ManifestRateLimitCapPolicy::Uncapped + : ManifestRateLimitCapPolicy::Capped); if (result == ManifestDisposition::Accepted) { - relay.add_list()->set_stobject(s); - // N.B.: this is important; the applyManifest call above moves // the loaded Manifest out of the optional so we need to // reload it here. @@ -695,13 +722,17 @@ OverlayImpl::onManifests( "deserialization succeeded"); // NOLINTBEGIN(bugprone-unchecked-optional-access) assert above app_.getOPs().pubManifest(*mo); + // NOLINTEND(bugprone-unchecked-optional-access) - if (app_.getValidators().listed(mo->masterKey)) + relay.add_list()->set_stobject(s); + + // Persist to the wallet DB only for trusted keys, so untrusted + // gossip never survives a restart. + if (isTrusted) { auto db = app_.getWalletDB().checkoutDb(); addValidatorManifest(*db, serialized); } - // NOLINTEND(bugprone-unchecked-optional-access) } } else @@ -711,6 +742,17 @@ OverlayImpl::onManifests( } } + if (skippedUntrusted) + { + // The sender exceeded the untrusted per-message cap. Charge it (once, + // here) so a flood of untrusted manifests is penalized. + from->charge(resource::kFeeMalformedRequest, "too many untrusted manifests"); + + JLOG(journal.warn()) << "Manifests: message had " << total + << " entries; processed all trusted plus the first " << maxUntrusted + << " untrusted"; + } + if (!relay.list().empty()) { forEach([m2 = std::make_shared(relay, protocol::mtMANIFESTS)]( @@ -1213,15 +1255,63 @@ OverlayImpl::getManifestsMessage() if (auto seq = app_.getValidatorManifests().sequence(); seq != manifestListSeq_) { - protocol::TMManifests tm; - + // Phase 1: snapshot the cache under its own lock. Do not call + // Validators::listed() here — that takes the validator-list lock, and + // forEachManifest holds the manifest-cache lock, so consulting trust + // inside the callback would invert the lock order used elsewhere + // (see onManifests) and risk deadlock. Capture the manifest hash now, + // while we have the Manifest object, for the suppression key. + struct CachedManifest + { + PublicKey masterKey; + std::string serialized; + uint256 hash; + }; + std::vector cached; app_.getValidatorManifests().forEachManifest( - [&tm](std::size_t s) { tm.mutable_list()->Reserve(s); }, - [&tm, &hr = app_.getHashRouter()](Manifest const& manifest) { - tm.add_list()->set_stobject(manifest.serialized.data(), manifest.serialized.size()); - hr.addSuppression(manifest.hash()); + [&cached](std::size_t s) { cached.reserve(s); }, + [&cached](Manifest const& manifest) { + cached.push_back( + {.masterKey = manifest.masterKey, + .serialized = manifest.serialized, + .hash = manifest.hash()}); }); + // Phase 2: no cache lock held, so trust checks are safe. Include every + // trusted manifest, then fill any remaining headroom up to the + // configured untrusted count with gossip. Trusted manifests are never + // dropped; the trusted count only sizes the accepted message. + std::vector selected; + std::vector untrusted; + for (auto const& e : cached) + { + if (app_.getValidators().listed(e.masterKey)) + { + selected.push_back(&e); + } + else + { + untrusted.push_back(&e); + } + } + + // Cap untrusted only; trusted manifests are all included above. + auto const take = + std::min(untrustedManifestCount(app_.config().maxUntrustedCount), untrusted.size()); + selected.insert(selected.end(), untrusted.begin(), untrusted.begin() + take); + + // Shuffle the order. Cryptographic randomness is not needed here. + std::shuffle(selected.begin(), selected.end(), defaultPrng()); + + protocol::TMManifests tm; + auto& hr = app_.getHashRouter(); + tm.mutable_list()->Reserve(static_cast(selected.size())); + for (auto const* e : selected) + { + tm.add_list()->set_stobject(e->serialized.data(), e->serialized.size()); + hr.addSuppression(e->hash); + } + manifestMessage_.reset(); if (tm.list_size() != 0) diff --git a/src/xrpld/overlay/detail/OverlayImpl.h b/src/xrpld/overlay/detail/OverlayImpl.h index f274bbba5a..bc90d64e89 100644 --- a/src/xrpld/overlay/detail/OverlayImpl.h +++ b/src/xrpld/overlay/detail/OverlayImpl.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -55,6 +56,13 @@ namespace xrpl { +// The largest counts an operator can configure must still imply a message size +// within the overall protocol message limit. The same check for the defaults +// lives in Message.h. +static_assert( + maximumManifestsMessageSize(Config::kMaxManifestCount, Config::kMaxManifestCount) < + kMaximumMessageSize); + class PeerImp; class BasicConfig; diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index ca6fb180d4..726002fce4 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -61,6 +61,7 @@ #include #include #include +#include #include #include #include @@ -1107,6 +1108,9 @@ PeerImp::onMessage(std::shared_ptr const& m) if (s > 100) fee_.update(resource::kFeeModerateBurdenPeer, "oversize"); + // OverlayImpl::onManifests bounds the untrusted work and charges the fee + // if the untrusted count exceeds the per-message cap; trusted manifests + // are always processed and not counted against it. app_.getJobQueue().addJob(JtManifest, "RcvManifests", [this, that = shared_from_this(), m]() { overlay_.onManifests(m, that); }); @@ -1117,10 +1121,13 @@ PeerImp::onMessage(std::shared_ptr const& m) { if (m->type() == protocol::TMPing::ptPING) { - // We have received a ping request, reply with a pong + // We have received a ping request, reply with a pong. fee_.update(resource::kFeeModerateBurdenPeer, "ping request"); - m->set_type(protocol::TMPing::ptPONG); - send(std::make_shared(*m, protocol::mtPING)); + protocol::TMPing pong; + pong.set_type(protocol::TMPing::ptPONG); + if (m->has_seq()) + pong.set_seq(m->seq()); + send(std::make_shared(pong, protocol::mtPING)); return; } @@ -1604,9 +1611,16 @@ PeerImp::onMessage(std::shared_ptr const& m) return; } - if (!ledgerReplayMsgHandler_.processProofPathResponse(m)) + switch (ledgerReplayMsgHandler_.processProofPathResponse(m)) { - fee_.update(resource::kFeeInvalidData, "proof_path_response"); + case ReplayMsgStatus::Ok: + break; + case ReplayMsgStatus::BadData: + fee_.update(resource::kFeeInvalidData, "proof_path_response"); + break; + case ReplayMsgStatus::Malformed: + fee_.update(resource::kFeeMalformedData, "proof_path_response malformed"); + break; } } @@ -1654,9 +1668,16 @@ PeerImp::onMessage(std::shared_ptr const& m) return; } - if (!ledgerReplayMsgHandler_.processReplayDeltaResponse(m)) + switch (ledgerReplayMsgHandler_.processReplayDeltaResponse(m)) { - fee_.update(resource::kFeeInvalidData, "replay_delta_response"); + case ReplayMsgStatus::Ok: + break; + case ReplayMsgStatus::BadData: + fee_.update(resource::kFeeInvalidData, "replay_delta_response"); + break; + case ReplayMsgStatus::Malformed: + fee_.update(resource::kFeeMalformedData, "replay_delta_response malformed"); + break; } } @@ -2475,12 +2496,22 @@ PeerImp::onMessage(std::shared_ptr const& m) std::shared_ptr val; { SerialIter sit(makeSlice(m->validation())); - val = std::make_shared( - std::ref(sit), - [this](PublicKey const& pk) { - return calcNodeID(app_.getValidatorManifests().getMasterKey(pk)); - }, - false); + try + { + val = std::make_shared( + std::ref(sit), + [this](PublicKey const& pk) { + return calcNodeID(app_.getValidatorManifests().getMasterKey(pk)); + }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = true}); + } + catch (std::exception const& e) + { + JLOG(pJournal_.warn()) << "Validation: Exception, " << e.what(); + fee_.update(resource::kFeeInvalidData, e.what()); + return; + } val->setSeen(closeTime); } diff --git a/src/xrpld/overlay/detail/PeerImp.h b/src/xrpld/overlay/detail/PeerImp.h index 3fcfe6359a..7078d6fb56 100644 --- a/src/xrpld/overlay/detail/PeerImp.h +++ b/src/xrpld/overlay/detail/PeerImp.h @@ -32,6 +32,7 @@ #include #include #include +#include #include #include @@ -429,9 +430,10 @@ public: // Ledger // - uint256 const& + uint256 getClosedLedgerHash() const override { + std::scoped_lock const sl{recentLock_}; return closedLedgerHash_; } @@ -466,6 +468,21 @@ public: return compressionEnabled_ == Compressed::On; } + /** + * Largest TMManifests message this node accepts, in bytes. + * + * Read by invokeProtocolMessage to drop oversized messages before + * parsing. Not part of the Peer interface: the message handler is a + * template parameter, so only PeerImp needs to provide this. + */ + [[nodiscard]] std::size_t + maxManifestsMessageSize() const + { + return maximumManifestsMessageSize( + trustedManifestCount(app_.config().maxTrustedCount), + untrustedManifestCount(app_.config().maxUntrustedCount)); + } + bool txReduceRelayEnabled() const override { diff --git a/src/xrpld/overlay/detail/ProtocolMessage.h b/src/xrpld/overlay/detail/ProtocolMessage.h index ef1bc8cb2b..f7d5e26272 100644 --- a/src/xrpld/overlay/detail/ProtocolMessage.h +++ b/src/xrpld/overlay/detail/ProtocolMessage.h @@ -359,6 +359,13 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin return result; } + if (header->messageType == protocol::mtPING && + header->uncompressedSize + header->headerSize > kMaximumPingMessageSize) + { + result.second = make_error_code(boost::system::errc::message_size); + return result; + } + // We don't have the whole message yet. This isn't an error but we have // nothing to do. if (header->totalWireSize > size) @@ -367,6 +374,19 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin return result; } + // Drop an oversized TMManifests without penalty: consume the bytes and + // return no error, so the connection is preserved. The limit follows this + // node's configured manifests-per-message count. + if (header->messageType == protocol::mtMANIFESTS) + { + auto const maxSize = handler.maxManifestsMessageSize(); + if (header->payloadWireSize > maxSize || header->uncompressedSize > maxSize) + { + result.first = header->totalWireSize; + return result; + } + } + bool success = false; switch (header->messageType) diff --git a/src/xrpld/rpc/detail/Pathfinder.cpp b/src/xrpld/rpc/detail/Pathfinder.cpp index 5b7f1415a2..642b5c4253 100644 --- a/src/xrpld/rpc/detail/Pathfinder.cpp +++ b/src/xrpld/rpc/detail/Pathfinder.cpp @@ -962,14 +962,10 @@ Pathfinder::isNoRippleOut(STPath const& currentPath) void addUniquePath(STPathSet& pathSet, STPath const& path) { - // TODO(tom): building an STPathSet this way is quadratic in the size - // of the STPathSet! - for (auto const& p : pathSet) + if (!pathSet.contains(path)) { - if (p == path) - return; + pathSet.pushBack(path); } - pathSet.pushBack(path); } void diff --git a/src/xrpld/rpc/handlers/ChannelVerify.cpp b/src/xrpld/rpc/handlers/ChannelVerify.cpp index ab54745a5d..50230a34a1 100644 --- a/src/xrpld/rpc/handlers/ChannelVerify.cpp +++ b/src/xrpld/rpc/handlers/ChannelVerify.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -36,6 +37,8 @@ doChannelVerify(rpc::JsonContext& context) return rpc::missingFieldError(p); } + context.loadType = resource::kFeeHeavyBurdenRpc; + std::optional pk; { std::string const strPk = params[jss::public_key].asString(); diff --git a/src/xrpld/rpc/handlers/admin/signing/ChannelAuthorize.cpp b/src/xrpld/rpc/handlers/admin/signing/ChannelAuthorize.cpp index d97ce9dac4..eb3ac24378 100644 --- a/src/xrpld/rpc/handlers/admin/signing/ChannelAuthorize.cpp +++ b/src/xrpld/rpc/handlers/admin/signing/ChannelAuthorize.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -37,6 +38,8 @@ doChannelAuthorize(rpc::JsonContext& context) return rpc::makeError(RpcNotSupported, "Signing is not supported by this server."); } + context.loadType = resource::kFeeHeavyBurdenRpc; + auto const& params(context.params); for (auto const& p : {jss::channel_id, jss::amount}) { diff --git a/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp b/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp index 7ce432c49e..3f6d716f29 100644 --- a/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp +++ b/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include +#include #include #include #include @@ -26,6 +28,24 @@ namespace xrpl { +namespace { + +/** + * Test whether admitting `additional` subscriptions would exceed the cap. + * + * @param ispSub The connection's InfoSub, queried for its current count. + * @param additional Number of new items this branch would add. + * @param cap The effective per-connection cap for this request. + * @return true if the request must be rejected to stay within the cap. + */ +[[nodiscard]] bool +wouldExceedSubscriptionCap(InfoSub::ref ispSub, std::size_t additional, std::size_t cap) +{ + return exceedsSubscriptionCap(ispSub->totalSubscriptionCount(), additional, cap); +} + +} // namespace + json::Value doSubscribe(rpc::JsonContext& context) { @@ -105,6 +125,11 @@ doSubscribe(rpc::JsonContext& context) } ispSub->setApiVersion(context.apiVersion); + // Effective per-connection subscription cap: a configured override if set, + // otherwise the built-in default. Resolved once and reused by every branch. + std::size_t const subscriptionCap = + context.app.config().maxSubscriptionsPerConnection.value_or(kMaxSubscriptionsPerConnection); + if (context.params.isMember(jss::streams)) { if (!context.params[jss::streams].isArray()) @@ -166,30 +191,59 @@ doSubscribe(rpc::JsonContext& context) } } + // Parse the proposed (real-time) and normal account sets first, then check + // the cap against their COMBINED net-new total before subscribing either. + // This keeps the account pair all-or-nothing: it never subscribes one set + // and then rejects on the other. Other fields (streams and account_history) + // are still checked and subscribed independently, as they always have been, + // so a later field can be rejected after an earlier one subscribed. The cap + // counts only NET-NEW accounts (those not already tracked on this + // connection), so re-subscribing accounts already held is never wrongly + // rejected. auto accountsProposed = context.params.isMember(jss::accounts_proposed) ? jss::accounts_proposed : jss::rt_accounts; // DEPRECATED - if (context.params.isMember(accountsProposed)) + bool const hasProposed = context.params.isMember(accountsProposed); + bool const hasAccounts = context.params.isMember(jss::accounts); + + hash_set proposedIds; + hash_set accountIds; + + if (hasProposed) { if (!context.params[accountsProposed].isArray()) return rpcError(RpcInvalidParams); - auto ids = rpc::parseAccountIds(context.params[accountsProposed]); - if (ids.empty()) + proposedIds = rpc::parseAccountIds(context.params[accountsProposed]); + if (proposedIds.empty()) return rpcError(RpcActMalformed); - context.netOps.subAccount(ispSub, ids, true); } - if (context.params.isMember(jss::accounts)) + if (hasAccounts) { if (!context.params[jss::accounts].isArray()) return rpcError(RpcInvalidParams); - auto ids = rpc::parseAccountIds(context.params[jss::accounts]); - if (ids.empty()) + accountIds = rpc::parseAccountIds(context.params[jss::accounts]); + if (accountIds.empty()) return rpcError(RpcActMalformed); - context.netOps.subAccount(ispSub, ids, false); - JLOG(context.j.debug()) << "doSubscribe: accounts: " << ids.size(); + } + + if (hasProposed || hasAccounts) + { + // Atomic check-and-reserve, so two concurrent requests sharing this + // InfoSub (admin subscribe-by-url) cannot both pass the cap check. + if (!ispSub->tryReserveAccountSubscriptions(proposedIds, accountIds, subscriptionCap)) + return rpc::makeParamError("Too many subscriptions for this connection."); + } + + if (hasProposed) + context.netOps.subAccount(ispSub, proposedIds, true); + + if (hasAccounts) + { + context.netOps.subAccount(ispSub, accountIds, false); + JLOG(context.j.debug()) << "doSubscribe: accounts: " << accountIds.size(); } if (context.params.isMember(jss::account_history_tx_stream)) @@ -206,6 +260,13 @@ doSubscribe(rpc::JsonContext& context) if (!id) return rpcError(RpcInvalidParams); + // Charge the cap only when net-new, like the account branches. Not + // atomic here (subAccountHistory does its own dup-detecting insert), but + // a concurrent race adds at most one entry, so the overshoot is trivial. + std::size_t const historyCharge = ispSub->hasAccountHistorySubscription(*id) ? 0 : 1; + if (wouldExceedSubscriptionCap(ispSub, historyCharge, subscriptionCap)) + return rpc::makeParamError("Too many subscriptions for this connection."); + if (auto result = context.netOps.subAccountHistory(ispSub, *id); result != RpcSuccess) { return rpcError(result); @@ -222,6 +283,10 @@ doSubscribe(rpc::JsonContext& context) if (!context.params[jss::books].isArray()) return rpcError(RpcInvalidParams); + // Book subscriptions are tracked separately (OrderBookDB) and are not + // part of totalSubscriptionCount(), so they are not gated by the + // per-connection account cap. Each book entry is validated and + // subscribed below. for (auto& j : context.params[jss::books]) { if (!j.isObject() || !j.isMember(jss::taker_pays) || !j.isMember(jss::taker_gets) ||