diff --git a/.cspell.config.yaml b/.cspell.config.yaml index 89e9efa50c..5bdca112db 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -154,6 +154,7 @@ words: - ifndef - inequation - initialiser + - Injectivity - insuf - insuff - invasively diff --git a/.github/scripts/levelization/results/ordering.txt b/.github/scripts/levelization/results/ordering.txt index c5f4658b2a..b54308853c 100644 --- a/.github/scripts/levelization/results/ordering.txt +++ b/.github/scripts/levelization/results/ordering.txt @@ -87,7 +87,6 @@ test.app > xrpl.tx test.basics > test.jtx test.basics > xrpl.basics test.basics > xrpl.core -test.basics > xrpld.rpc test.basics > xrpl.json test.basics > xrpl.protocol test.beast > xrpl.basics @@ -308,10 +307,10 @@ xrpld.perflog > xrpl.basics xrpld.perflog > xrpl.config xrpld.perflog > xrpl.core xrpld.perflog > xrpld.app -xrpld.perflog > xrpld.rpc xrpld.perflog > xrpl.json xrpld.perflog > xrpl.nodestore xrpld.perflog > xrpl.protocol +xrpld.perflog > xrpl.server xrpld.perflog > xrpl.telemetry xrpld.rpc > xrpl.basics xrpld.rpc > xrpl.config diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index 65671dbd11..5528d6442e 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -15,6 +15,14 @@ _BASE_CMAKE_ARGS = [ "-Drust=ON", ] +# The package formats a config can be packaged as, each with its own +# install-test job in reusable-package.yml. +PACKAGE_TYPES = ("deb", "rpm") + +# The package name a variant suffixes, as build_pkg.py's BASE_NAME spells it: +# the two have to agree, or the artifact globs miss what was built. +BASE_NAME = "xrpld" + # Maps sanitizer names (as used in cmake) to short config-name suffixes. _SANITIZER_SUFFIX: dict[str, str] = { "address": "asan", @@ -62,10 +70,20 @@ def get_cmake_args(build_type: str, extra_args: str) -> str: class PackageConfig: """The 'package' map of a config whose binaries are also packaged.""" - type: str # "deb" or "rpm"; has to match what the image provides + type: str # has to match what the image provides # The packaging container image: a vanilla distro image, not the nix image # the config itself builds in. image: str + # A flavour of the package, named xrpld-, for a config whose + # binaries are not the plain release build. A variant needs no counterpart + # in the other format. + variant: str = "" + + def __post_init__(self) -> None: + assert self.type in PACKAGE_TYPES, ( + f"unsupported package type {self.type!r}: " + f"use one of {', '.join(PACKAGE_TYPES)}." + ) @dataclasses.dataclass @@ -178,6 +196,8 @@ class PackagingEntry: validator_keys_artifact_name: str image: str package_type: str # "deb" or "rpm"; drives the format-specific steps + package_variant: str # passed to build_pkg.py --variant; empty for xrpld + package_name: str # the name it builds under, which the artifact globs use # --------------------------------------------------------------------------- @@ -267,12 +287,32 @@ def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]: validator_keys_artifact_name=f"validator-keys-{name}", image=cfg.package.image, package_type=cfg.package.type, + package_variant=cfg.package.variant, + package_name=( + f"{BASE_NAME}-{cfg.package.variant}" + if cfg.package.variant + else BASE_NAME + ), ) ) return entries +def package_names_by_type(entries: list[PackagingEntry]) -> dict[str, list[str]]: + """The names of the packages in 'entries', keyed by format. + + Derived from the packaging matrix rather than listed again, so the packages + the install-test jobs look for are the packages that were built. + """ + return { + package_type: sorted( + {e.package_name for e in entries if e.package_type == package_type} + ) + for package_type in PACKAGE_TYPES + } + + def expand_platform_matrix(pf: PlatformFile, minimal: bool) -> list[MatrixEntry]: """Expand a PlatformFile (macOS or Windows) into matrix entries. @@ -341,6 +381,10 @@ if __name__ == "__main__": if args.packaging: matrix = expand_linux_packaging(LinuxFile.load(THIS_DIR / "linux.json")) + # One list per format, so each install-test job installs the packages its + # own format produced. + for package_type, names in package_names_by_type(matrix).items(): + print(f"{package_type}_package_names={json.dumps(names)}") else: if args.config in ("linux", None): matrix += expand_linux_matrix( diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 2229332e41..9b069f0ce3 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -76,6 +76,19 @@ "type": "deb", "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-49cdc10" } + }, + { + "compiler": ["gcc"], + "build_type": ["Release"], + "arch": ["amd64"], + "minimal": false, + "suffix": "assert", + "extra_cmake_args": "-Dvalidator_keys=ON -Dassert=ON", + "package": { + "type": "deb", + "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-49cdc10", + "variant": "assert" + } } ], diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index 27dfd50f7c..a882253b36 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -98,6 +98,7 @@ jobs: .github/workflows/reusable-build-test.yml .github/workflows/reusable-check-autogen.yml .github/workflows/reusable-clang-tidy.yml + .github/workflows/reusable-package-test-install.yml .github/workflows/reusable-package.yml .github/workflows/reusable-rust.yml .github/workflows/reusable-strategy-matrix.yml @@ -262,6 +263,12 @@ jobs: # matrix (i.e. not yet labeled "Ready to merge" or "Full CI build"). if: ${{ needs.should-run.outputs.go == 'true' && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'Ready to merge') || contains(github.event.pull_request.labels.*.name, 'Full CI build')) }} uses: ./.github/workflows/reusable-package.yml + with: + # A pull request builds packages to prove they still build, and publishes + # nothing. Stated rather than left to the input's default, so that changing + # that default cannot start publishing from pull requests. No secrets are + # passed either, which is the second reason a publish here cannot succeed. + publish: false upload-recipe: needs: diff --git a/.github/workflows/on-trigger.yml b/.github/workflows/on-trigger.yml index 2099f5f739..2bf73332d2 100644 --- a/.github/workflows/on-trigger.yml +++ b/.github/workflows/on-trigger.yml @@ -23,6 +23,7 @@ on: - ".github/workflows/reusable-build-test.yml" - ".github/workflows/reusable-check-autogen.yml" - ".github/workflows/reusable-clang-tidy.yml" + - ".github/workflows/reusable-package-test-install.yml" - ".github/workflows/reusable-package.yml" - ".github/workflows/reusable-rust.yml" - ".github/workflows/reusable-strategy-matrix.yml" diff --git a/.github/workflows/reusable-package-test-install.yml b/.github/workflows/reusable-package-test-install.yml new file mode 100644 index 0000000000..4f1b6d7cb5 --- /dev/null +++ b/.github/workflows/reusable-package-test-install.yml @@ -0,0 +1,120 @@ +# Install one package format on every distro family it targets, one job per +# package name and image, and run the binaries there. Called once per format by +# reusable-package.yml, which owns the names and the image lists. +name: Install packages + +on: + workflow_call: + inputs: + package_type: + description: 'The package format to install ("deb" or "rpm").' + required: true + type: string + package_names: + description: "JSON array of package names built for this format." + required: true + type: string + images: + description: "JSON array of container images to install in." + required: true + type: string + +defaults: + run: + shell: bash + +env: + PACKAGE_DIR: packages + +jobs: + install: + strategy: + fail-fast: false + matrix: + package_name: ${{ fromJson(inputs.package_names) }} + image: ${{ fromJson(inputs.images) }} + name: "${{ matrix.package_name }} on ${{ matrix.image }}" + permissions: + contents: read + runs-on: ubuntu-latest + container: ${{ matrix.image }} + timeout-minutes: 5 + + steps: + # Every package lands in one directory; the step below picks its own, + # which keeps this independent of the artifact names. + - name: Download package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: "*-pkg" + merge-multiple: true + path: ${{ env.PACKAGE_DIR }} + + - name: Find the package + id: find + env: + PACKAGE_NAME: ${{ matrix.package_name }} + PACKAGE_TYPE: ${{ inputs.package_type }} + run: | + # The version follows the name, separated by '_' in a DEB and '-' in an + # RPM. Requiring a digit after it is what keeps 'xrpld' from picking up + # another package, such as 'xrpld-assert'. + pattern="${PACKAGE_NAME}[_-][0-9]*.${PACKAGE_TYPE}" + package="$(find "${PACKAGE_DIR}" -type f -name "${pattern}" -print -quit)" + test -n "${package}" || { + echo "no ${pattern} found in ${PACKAGE_DIR}" >&2 + exit 1 + } + echo "package=${package}" >>"${GITHUB_OUTPUT}" + + # Debian 11 went end-of-life on 2026-08-31 + # (https://www.debian.org/News/2026/20260831) and its packages are + # already partly gone from deb.debian.org, so switch to the + # snapshot.debian.org entries the image ships commented out in its + # sources.list: they are pinned to the snapshot the image was built + # from, so they serve every version it needs and never go away. + # Snapshots keep their original, long-passed Valid-Until, hence the + # disabled check; the retries absorb snapshot.debian.org's throttling. + - name: Switch Debian 11 to snapshot.debian.org + if: ${{ matrix.image == 'debian:11' }} + run: | + sed -i 's|^deb |# deb |; s|^# deb http://snapshot|deb http://snapshot|' /etc/apt/sources.list + printf '%s\n' \ + 'Acquire::Check-Valid-Until "false";' \ + 'Acquire::Retries "3";' \ + >/etc/apt/apt.conf.d/99snapshot + + - name: Install the DEB + if: ${{ inputs.package_type == 'deb' }} + env: + DEBIAN_FRONTEND: noninteractive + PACKAGE: ${{ steps.find.outputs.package }} + run: | + # Stock Debian and Ubuntu images carry no package lists, so apt has + # nothing to resolve the systemd dependency from until it fetches them. + apt-get update -qq + apt-get install -y "./${PACKAGE}" + + - name: Install the RPM + if: ${{ inputs.package_type == 'rpm' }} + env: + PACKAGE: ${{ steps.find.outputs.package }} + run: dnf install -y "./${PACKAGE}" + + - name: Run xrpld + run: xrpld --version + + - name: Run validator-keys + run: validator-keys --version + + - name: Run rippled, the legacy compatibility symlink + run: rippled --version + + - name: Check the service account + run: id xrpld + + - name: Check the state directory + run: test -d /var/lib/xrpld + + - name: Check the log directory + run: test -d /var/log/xrpld diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index 9dbc027176..700ec9180b 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -3,8 +3,10 @@ # # - 'package' builds and signs one format per config that carries a "package" # map in linux.json; that map names the container image and the format -# - 'test-install' installs what was built on a range of distros and runs the -# binaries there, so a package that cannot be installed never reaches Nexus +# - 'test-install-deb' and 'test-install-rpm' call +# reusable-package-test-install.yml to install what was built on a range of +# distros and run the binaries there, so a package that cannot be installed +# never reaches Nexus # - 'publish' uploads with the image's publish_pkg.py, doing a --dry-run # unless 'publish: true' # @@ -23,7 +25,7 @@ on: description: "The base URL of the Nexus instance hosting the deb and rpm repositories." required: false type: string - default: https://packages.xrplf.org + default: https://packages-upload.xrplf.org secrets: remote_username: @@ -49,6 +51,8 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.generate.outputs.matrix }} + deb_package_names: ${{ steps.generate.outputs.deb_package_names }} + rpm_package_names: ${{ steps.generate.outputs.rpm_package_names }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -107,6 +111,7 @@ jobs: - name: Build package env: PACKAGE_TYPE: ${{ matrix.package_type }} + PACKAGE_VARIANT: ${{ matrix.package_variant }} PKG_RELEASE: ${{ steps.release_info.outputs.pkg_release }} CHANNEL: ${{ steps.release_info.outputs.channel }} run: | @@ -114,6 +119,7 @@ jobs: --package-type "${PACKAGE_TYPE}" \ --build-dir "${BUILD_DIR}" \ --pkg-release "${PKG_RELEASE}" \ + --variant "${PACKAGE_VARIANT}" \ --channel "${CHANNEL}" # Before the upload, so the artifact, the tested package and the published @@ -125,14 +131,17 @@ jobs: run: ./package/sign_rpm.py --package-dir "${BUILD_DIR}" # Split from the debug symbols, which are an order of magnitude larger, so - # that test-install downloads only what it installs. + # that test-install downloads only what it installs. In the globs below the + # version follows the name, separated by '_' in a DEB and '-' in an RPM. A + # version starts with a digit and a longer name does not, so that one digit + # is what tells 'xrpld-3.4.1-...' from 'xrpld-assert-3.4.1-...'. - name: Upload package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ matrix.xrpld_artifact_name }}-pkg path: | - ${{ env.BUILD_DIR }}/debbuild/xrpld_*.deb - ${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/xrpld-[0-9]*.rpm + ${{ env.BUILD_DIR }}/debbuild/${{ matrix.package_name }}_[0-9]*.deb + ${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/${{ matrix.package_name }}-[0-9]*.rpm if-no-files-found: error - name: Upload debug symbol artifact @@ -140,133 +149,59 @@ jobs: with: name: ${{ matrix.xrpld_artifact_name }}-pkg-debug path: | - ${{ env.BUILD_DIR }}/debbuild/xrpld-dbgsym_*.deb - ${{ env.BUILD_DIR }}/debbuild/xrpld-dbgsym_*.ddeb - ${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/xrpld-debuginfo-*.rpm + ${{ env.BUILD_DIR }}/debbuild/${{ matrix.package_name }}-dbgsym_[0-9]*.deb + ${{ env.BUILD_DIR }}/debbuild/${{ matrix.package_name }}-dbgsym_[0-9]*.ddeb + ${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/${{ matrix.package_name }}-debuginfo-[0-9]*.rpm if-no-files-found: error - # Every distro family the packages target, oldest release first, so both ends - # of the dependency range they declare are exercised. - test-install: - needs: [package] - strategy: - fail-fast: false - matrix: - include: - - package_type: deb - image: debian:11 - - package_type: deb - image: debian:12 - - package_type: deb - image: debian:13 - - package_type: deb - image: ubuntu:20.04 - - package_type: deb - image: ubuntu:22.04 - - package_type: deb - image: ubuntu:24.04 - - package_type: deb - image: ubuntu:26.04 + # One call per format, so a variant packaged for one format is installed for + # that format alone. The images are every distro family that format targets, + # oldest release first, so both ends of the dependency range the packages + # declare are exercised. + test-install-deb: + needs: [generate-matrix, package] + name: install deb + uses: ./.github/workflows/reusable-package-test-install.yml + with: + package_type: deb + package_names: ${{ needs.generate-matrix.outputs.deb_package_names }} + images: | + [ + "debian:11", + "debian:12", + "debian:13", + "ubuntu:20.04", + "ubuntu:22.04", + "ubuntu:24.04", + "ubuntu:26.04" + ] - - package_type: rpm - image: almalinux:9 - - package_type: rpm - image: almalinux:10 - - package_type: rpm - image: rockylinux/rockylinux:9 - - package_type: rpm - image: rockylinux/rockylinux:10 - - package_type: rpm - image: registry.access.redhat.com/ubi9/ubi - - package_type: rpm - image: registry.access.redhat.com/ubi10/ubi - name: "install ${{ matrix.package_type }} on ${{ matrix.image }}" - permissions: - contents: read - runs-on: ubuntu-latest - container: ${{ matrix.image }} - timeout-minutes: 5 - - steps: - # Both formats land in one directory; the step below picks its own by - # extension, so this stays independent of the artifact names. - - name: Download package artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: "*-pkg" - merge-multiple: true - path: ${{ env.PACKAGE_DIR }} - - - name: Find the package - id: find - env: - PACKAGE_TYPE: ${{ matrix.package_type }} - run: | - package="$(find "${PACKAGE_DIR}" -type f -name "*.${PACKAGE_TYPE}" -print -quit)" - test -n "${package}" || { - echo "no .${PACKAGE_TYPE} found in ${PACKAGE_DIR}" >&2 - exit 1 - } - echo "package=${package}" >>"${GITHUB_OUTPUT}" - - # Debian 11 went end-of-life on 2026-08-31 - # (https://www.debian.org/News/2026/20260831) and its packages are - # already partly gone from deb.debian.org, so switch to the - # snapshot.debian.org entries the image ships commented out in its - # sources.list: they are pinned to the snapshot the image was built - # from, so they serve every version it needs and never go away. - # Snapshots keep their original, long-passed Valid-Until, hence the - # disabled check; the retries absorb snapshot.debian.org's throttling. - - name: Switch Debian 11 to snapshot.debian.org - if: ${{ matrix.image == 'debian:11' }} - run: | - sed -i 's|^deb |# deb |; s|^# deb http://snapshot|deb http://snapshot|' /etc/apt/sources.list - printf '%s\n' \ - 'Acquire::Check-Valid-Until "false";' \ - 'Acquire::Retries "3";' \ - >/etc/apt/apt.conf.d/99snapshot - - - name: Install the DEB - if: ${{ matrix.package_type == 'deb' }} - env: - DEBIAN_FRONTEND: noninteractive - PACKAGE: ${{ steps.find.outputs.package }} - run: | - # Stock Debian and Ubuntu images carry no package lists, so apt has - # nothing to resolve the systemd dependency from until it fetches them. - apt-get update -qq - apt-get install -y "./${PACKAGE}" - - - name: Install the RPM - if: ${{ matrix.package_type == 'rpm' }} - env: - PACKAGE: ${{ steps.find.outputs.package }} - run: dnf install -y "./${PACKAGE}" - - - name: Run xrpld - run: xrpld --version - - - name: Run validator-keys - run: validator-keys --version - - - name: Run rippled, the legacy compatibility symlink - run: rippled --version - - - name: Check the service account - run: id xrpld - - - name: Check the state directory - run: test -d /var/lib/xrpld - - - name: Check the log directory - run: test -d /var/log/xrpld + test-install-rpm: + needs: [generate-matrix, package] + name: install rpm + uses: ./.github/workflows/reusable-package-test-install.yml + with: + package_type: rpm + package_names: ${{ needs.generate-matrix.outputs.rpm_package_names }} + images: | + [ + "almalinux:9", + "almalinux:10", + "rockylinux/rockylinux:9", + "rockylinux/rockylinux:10", + "registry.access.redhat.com/ubi9/ubi", + "registry.access.redhat.com/ubi10/ubi" + ] publish: - needs: [generate-matrix, package, test-install] + needs: [generate-matrix, package, test-install-deb, test-install-rpm] strategy: fail-fast: false matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} - name: "publish ${{ matrix.xrpld_artifact_name }}" + # The name says which of the two this is, because the job runs either way: + # with publish false it passes --dry-run and uploads nothing, and a job + # called "publish ..." succeeding on a pull request reads like a release. + name: "publish ${{ matrix.xrpld_artifact_name }}${{ !inputs.publish && ' (dry run)' || '' }}" permissions: contents: read runs-on: ["self-hosted", "Linux", "X64", "heavy"] diff --git a/cmake/XrplPackaging.cmake b/cmake/XrplPackaging.cmake index e2f7029ad2..0fdeae0d0d 100644 --- a/cmake/XrplPackaging.cmake +++ b/cmake/XrplPackaging.cmake @@ -44,12 +44,18 @@ else() set(pkg_type rpm) endif() +# Unquoted below, so an empty value adds no argument at all. +set(pkg_variant_option "") +if(assert) + set(pkg_variant_option --variant=assert) +endif() + add_custom_target( package COMMAND ${CMAKE_SOURCE_DIR}/package/build_pkg.py --package-type=${pkg_type} --build-dir=${CMAKE_BINARY_DIR} --pkg-release=${pkg_release} - --channel=UNRELEASED + ${pkg_variant_option} --channel=UNRELEASED WORKING_DIRECTORY ${CMAKE_BINARY_DIR} DEPENDS xrpld validator-keys COMMENT "Building Linux ${pkg_type} package" diff --git a/conanfile.py b/conanfile.py index cc0c6da74c..1fd1ff842e 100644 --- a/conanfile.py +++ b/conanfile.py @@ -155,6 +155,7 @@ class Xrpl(ConanFile): self.requires("xxhash/0.8.3", transitive_headers=True) exports_sources = ( + "bin/default-loader-path.sh", "CMakeLists.txt", "cfg/*", "cmake/*", diff --git a/docs/install.md b/docs/install.md index 4c52b587b6..34606fbe11 100644 --- a/docs/install.md +++ b/docs/install.md @@ -6,7 +6,8 @@ `xrpld` is published as DEB and RPM packages for 64-bit x86 Linux. Use APT on Debian-based distributions such as Debian and Ubuntu, -and YUM on Red Hat-based distributions such as RHEL, AlmaLinux, and Rocky Linux. +and DNF on Red Hat-based distributions such as RHEL, AlmaLinux, and Rocky Linux, +where `yum` is a symlink to `dnf`. To build from source instead, see [BUILD.md](../BUILD.md). ## Release channels @@ -81,7 +82,7 @@ wherever it appears in the repository configuration. sudo apt -y install xrpld ``` -### With the YUM package manager +### With the DNF package manager 1. Add the XRPL Foundation package-signing key: @@ -109,9 +110,23 @@ wherever it appears in the repository configuration. 3. Install the `xrpld` package: ```bash - sudo yum install -y xrpld + sudo dnf install -y xrpld ``` +### Optional: the assert-enabled build + +Every channel also carries `xrpld-assert` as a DEB, the same build with assertions +enabled, for diagnosing a problem on a non-production server. +It installs the same files as `xrpld` and replaces it, so install one or the other: + +```bash +sudo apt -y install xrpld-assert # APT removes xrpld itself +``` + +Switching stops the service, since it is a removal and an installation rather than an upgrade, +and APT starts it again. +Install `xrpld` the same way to switch back. + ## The xrpld service Both package managers install a systemd unit and enable it, so `xrpld` starts on boot. @@ -121,7 +136,7 @@ Check whether it is already running: systemctl status xrpld.service ``` -The APT packages start it immediately as well; the YUM packages do not, so start it yourself: +The DEB packages start it immediately as well; the RPM packages do not, so start it yourself: ```bash sudo systemctl start xrpld.service diff --git a/include/xrpl/basics/StringUtilities.h b/include/xrpl/basics/StringUtilities.h index e3b91c2f25..7cb67cb15b 100644 --- a/include/xrpl/basics/StringUtilities.h +++ b/include/xrpl/basics/StringUtilities.h @@ -162,4 +162,89 @@ toUInt64(std::string const& s); bool isProperlyFormedTomlDomain(std::string_view domain); +/** + * Whether a view can be passed on as a C string. + * + * A reader given only data() stops at the first null, so the view must reach the + * terminating null. The test rebuilds the view from data() and compares: a view + * that stops earlier rebuilds longer, and so compares unequal. + * + * consteval because reading the byte after the view is only defined when @p str + * points into storage holding a null at or after its end, such as a string + * literal. An unterminated view is then a compile error, not an out-of-bounds + * read. + * + * @param str The view to test. + * @return Whether @p str is null-terminated. A view with no data is not. + */ +consteval bool +isNullTerminated(std::string_view str) +{ + if (str.data() == nullptr) + return false; + + // Reading past the view is the point, so the usual data() warning does not + // apply. + // NOLINTNEXTLINE(bugprone-suspicious-stringview-data-usage) + return std::string_view{str.data()} == str; +} + +/** + * A string that is known to reach its terminating null. + * + * Converts to std::string_view, so it compares and hashes as one. Unlike a + * view, asCString() may be handed to a reader that expects a C string, such + * as json::StaticString. + * + * The only constructor is consteval and rejects a view that stops before the + * null, so the property holds by construction and no caller asserts it. + */ +class NullTerminatedView +{ +public: + /** + * Build a view from one that reaches its terminating null. + * + * Explicit, so that a plain view cannot become a proof of termination by + * accident. The conversion the other way stays implicit. + * + * @param view The string to hold. Rejected at compile time if it stops + * before its terminating null, or has no data. + */ + explicit consteval NullTerminatedView(std::string_view view) + : data_(view.data()), size_(view.size()) + { + if (!isNullTerminated(view)) + throw "xrpl::NullTerminatedView : view does not reach a null"; + } + + constexpr + operator std::string_view() const noexcept + { + return view(); + } + + /** + * @return The string as a view. + */ + [[nodiscard]] constexpr std::string_view + view() const noexcept + { + return {data_, size_}; + } + + /** + * @return The string as a C string. Never null. + */ + [[nodiscard]] constexpr char const* + asCString() const noexcept + { + return data_; + } + +private: + char const* data_; + std::size_t size_; +}; + } // namespace xrpl diff --git a/include/xrpl/core/PerfLog.h b/include/xrpl/core/PerfLog.h index 9d7d8475d8..44cc123ff7 100644 --- a/include/xrpl/core/PerfLog.h +++ b/include/xrpl/core/PerfLog.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -9,7 +10,8 @@ #include #include #include -#include +#include +#include namespace beast { class Journal; @@ -67,7 +69,7 @@ public: * @param requestId Unique identifier to track command */ virtual void - rpcStart(std::string const& method, std::uint64_t requestId) = 0; + rpcStart(std::string_view method, std::uint64_t requestId) = 0; /** * Log successful finish of RPC call @@ -76,7 +78,7 @@ public: * @param requestId Unique identifier to track command */ virtual void - rpcFinish(std::string const& method, std::uint64_t requestId) = 0; + rpcFinish(std::string_view method, std::uint64_t requestId) = 0; /** * Log errored RPC call @@ -85,7 +87,7 @@ public: * @param requestId Unique identifier to track command */ virtual void - rpcError(std::string const& method, std::uint64_t requestId) = 0; + rpcError(std::string_view method, std::uint64_t requestId) = 0; /** * Log queued job @@ -161,10 +163,20 @@ public: PerfLog::Setup setupPerfLog(Section const& section, std::filesystem::path const& configDir); +/** + * @param methodNames The RPC methods to count, one counter per name. Reported + * as JSON keys that borrow each name and read it as a C string, which is + * why the parameter type requires one that reaches its terminating null. + * The names must outlive the returned object, which holds views of them. + * The range itself need not: it is copied. + * Passed in rather than looked up here, so that this layer needs no + * knowledge of the dispatch table. + */ std::unique_ptr makePerfLog( PerfLog::Setup const& setup, Application& app, + std::span methodNames, beast::Journal journal, std::function&& signalStop); @@ -172,7 +184,7 @@ template auto measureDurationAndLog( Func&& func, - std::string const& actionDescription, + std::string_view actionDescription, std::chrono::duration maxDelay, beast::Journal const& journal) { diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index f3fc82eacb..3887b10120 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -338,9 +338,9 @@ struct AccountingDeltas Number debtTotalDelta; }; -// Whole-life (pre-LendingProtocolV1_1) recognition model: interest is -// recognized into AssetsTotal/DebtTotal up front, at origination. -namespace accrual { +// Instant interest recognition (pre-LendingProtocolV1_1): interest is +// recognized into AssetsTotal/DebtTotal immediately, at origination. +namespace instant_recognition { // LoanSet origination: what's added to Vault.AssetsTotal and LoanBroker.DebtTotal AccountingDeltas @@ -362,7 +362,7 @@ loanVaultExposure(SLE::const_ref loanSle); AccountingDeltas loanPaymentDeltas(LoanPaymentParts const& parts); -} // namespace accrual +} // namespace instant_recognition // Cash-basis (LendingProtocolV1_1) recognition model: AssetsTotal/DebtTotal // are principal-only, interest is recognized only as it's actually paid. @@ -381,7 +381,7 @@ loanPaymentDeltas(LoanPaymentParts const& parts); // Public dispatchers: pick cash_basis:: if featureLendingProtocolV1_1 is // enabled AND the Vault's LEVersion (VaultHelpers::getVaultVersion) is -// VaultVersion::CashBasis, else accrual::. These are the only entry points +// VaultVersion::CashBasis, else instant_recognition::. These are the only entry points // transactors call. AccountingDeltas loanOriginationDeltas( diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index b42f349b95..acbcb8ec04 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -172,8 +172,8 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref /** * Resolves a Vault's LEVersion, the single point every accounting touch - * point should call to determine which recognition model (accrual vs. - * cash-basis) a Vault uses. Vaults created before featureLendingProtocolV1_1 + * point should call to determine which recognition model (instant interest + * recognition vs. cash-basis) a Vault uses. Vaults created before featureLendingProtocolV1_1 * activated never have sfLEVersion set, which resolves here to * VaultVersion::Legacy. * diff --git a/include/xrpl/nodestore/detail/DatabaseNodeImp.h b/include/xrpl/nodestore/detail/DatabaseNodeImp.h index d2aff66467..0cc80b0969 100644 --- a/include/xrpl/nodestore/detail/DatabaseNodeImp.h +++ b/include/xrpl/nodestore/detail/DatabaseNodeImp.h @@ -99,7 +99,7 @@ public: void importDatabase(Database& source) override { - importInternal(*backend_.get(), source); + importInternal(*backend_, source); } void diff --git a/include/xrpl/protocol/PathAsset.h b/include/xrpl/protocol/PathAsset.h index ebf6fb68a4..02de9aa7df 100644 --- a/include/xrpl/protocol/PathAsset.h +++ b/include/xrpl/protocol/PathAsset.h @@ -5,9 +5,11 @@ #include #include +#include #include #include #include +#include #include namespace xrpl { @@ -121,9 +123,32 @@ operator==(PathAsset const& lhs, PathAsset const& rhs) template void -hash_append(Hasher& h, PathAsset const& pathAsset) +hash_append(Hasher& h, PathAsset const& pathAsset) noexcept { - std::visit([&](T const& e) { hash_append(h, e); }, pathAsset.value()); + using beast::hash_append; + using Variant = std::remove_cvref_t; + + static_assert( + std::variant_size_v < 0xFFu, + "PathAsset's discriminant must fit in a byte, leaving 0xFF reserved."); + + // std::visit is not noexcept: it throws bad_variant_access when the variant + // is valueless_by_exception. + if (pathAsset.value().valueless_by_exception()) [[unlikely]] + { + hash_append(h, static_cast(0xFFu)); + return; + } + + hash_append(h, static_cast(pathAsset.value().index())); + std::visit( + [&](T const& e) noexcept { + static_assert( + noexcept(hash_append(h, e)), + "Every PathAsset alternative must be nothrow-hashable."); + hash_append(h, e); + }, + pathAsset.value()); } inline bool diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 1b88eea456..0170cbb88a 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -322,7 +322,7 @@ constexpr std::uint8_t kVaultMaximumIouScale = 18; * Vault ledger-entry schema versions. Assigned to newly created * Vaults once featureLendingProtocolV1_1 is enabled. Vaults created before * activation are left without LEVersion (implicit legacy version 0, - * accrual-basis accounting). + * instant interest recognition). */ enum class VaultVersion : uint8_t { Legacy = 0, diff --git a/include/xrpl/protocol/STPathSet.h b/include/xrpl/protocol/STPathSet.h index 5768721111..b91d899071 100644 --- a/include/xrpl/protocol/STPathSet.h +++ b/include/xrpl/protocol/STPathSet.h @@ -12,6 +12,8 @@ #include #include +#include +#include #include #include #include @@ -65,7 +67,7 @@ public: PathAsset const& asset, AccountID const& issuer); - [[nodiscard]] auto + [[nodiscard]] std::uint32_t getNodeType() const; [[nodiscard]] bool @@ -109,9 +111,6 @@ public: [[nodiscard]] bool isType(Type const& pe) const; - [[nodiscard]] size_t - getHash() const; - bool operator==(STPathElement const& t) const; @@ -120,6 +119,17 @@ private: getHash(STPathElement const& element); }; +template +void +hash_append(Hasher& h, STPathElement const& e) noexcept +{ + using beast::hash_append; + hash_append(h, (e.getNodeType() & STPathElement::TypeAccount) != 0u); + hash_append(h, e.getAccountID()); + hash_append(h, e.getPathAsset()); + hash_append(h, e.getIssuerID()); +} + class STPath final : public CountedObject { std::vector path_; @@ -176,9 +186,10 @@ template void hash_append(Hasher& h, STPath const& p) noexcept { + using beast::hash_append; for (auto const& e : p) { - beast::hash_append(h, e.getHash()); + hash_append(h, e); } } @@ -188,13 +199,39 @@ hash_append(Hasher& h, STPath const& p) noexcept class STPathSet final : public STBase, public CountedObject { std::vector value_; - xrpl::hardened_hash_set seenHashes_; + + /** + * Deduplication index over `value_`, for pathfinding. + * The use of a std::unique_ptr is intentional as it + * only requires 8 additional bytes of storage for the pointer + * as opposed to 64 bytes with an optional. This keeps the size + * of the STPathSet to within the `STVar::kMaxSize` limit of 72 bytes. + */ + std::unique_ptr> seen_; public: + struct DeduplicationTag + { + }; + STPathSet() = default; + /** + * Deduplication tagged constructor. + * Use when you want to ensure that the STPathSet does not contain duplicate paths. + */ + explicit STPathSet(DeduplicationTag); STPathSet(SField const& n); STPathSet(SerialIter& sit, SField const& name); + STPathSet(STPathSet const& other); + STPathSet(STPathSet&&) = default; + + STPathSet& + operator=(STPathSet const& other); + STPathSet& + operator=(STPathSet&&) = default; + + ~STPathSet() override = default; void add(Serializer& s) const override; @@ -204,6 +241,16 @@ public: [[nodiscard]] SerializedTypeID getSType() const override; + /** + * @brief assembleAdd adds a path to the set by combining a base path and a tail element. + * + * @param base The base path. + * @param tail The tail element. + * @return true if the path was added, false if it was a duplicate and not added. + * @remarks Requires the STPathSet to be constructed with the DeduplicationTag. The return value + * indicates whether the combined path was inserted (true) or rejected as a duplicate (false). + * It is fine for callers to ignore the return value. + */ bool assembleAdd(STPath const& base, STPathElement const& tail); @@ -229,22 +276,61 @@ public: [[nodiscard]] bool empty() const; - void + /** + * @brief pushBack adds a path to the set. + * + * @param e The path to add. + * @return true if the path was added, false if it was a duplicate and not added. + * @remarks If the STPathSet was constructed with the DeduplicationTag, then this method will + * check for duplicates and only add the path if it is not already present in the + * set. If the STPathSet was constructed without the DeduplicationTag, + * then this method will always add the path to the set, regardless of duplicates. + * It is fine for callers to ignore the return value. + */ + bool pushBack(STPath const& e); + /** + * @brief emplaceBack adds a path to the set. + * + * @param args The arguments to construct the path with. + * @return true if the path was added, false if it was a duplicate and not added. + * @remarks If the STPathSet was constructed with the DeduplicationTag, then this method will + * check for duplicates and only add the path if it is not already present in the + * set. If the STPathSet was constructed without the DeduplicationTag, + * then this method will always add the path to the set, regardless of duplicates. + * It is fine for callers to ignore the return value. + * @note The path is constructed before the duplicate check, so on a false + * return the constructed path is discarded and any argument + * forwarded as an rvalue is left in a moved-from state. Use + * pushBack when the caller needs to keep its path on rejection. + */ template - void + bool emplaceBack(Args&&... args); - [[nodiscard]] bool - contains(STPath const& path) const; - private: STBase* copy(std::size_t n, void* buf) const override; STBase* move(std::size_t n, void* buf) override; + /** + * @brief Append a path via `append`, then register it in the deduplication index. + * + * @param append Invoked with `value_`; must append exactly one path to it. + * @return true if the path was kept, false if it was a duplicate and was rolled back. + * @remarks Appends to the vector before touching the index, so that a failed allocation + * there leaves both containers untouched rather than leaving the index holding + * a path the vector does not. If the index insert reports a duplicate, or + * throws, the append is rolled back so the two containers stay consistent; in + * the throwing case the exception propagates. With no index (constructed + * without the DeduplicationTag) the append is unconditional. + */ + template + bool + appendUnique(Append&& append); + friend class detail::STVar; }; @@ -336,7 +422,7 @@ inline STPathElement::STPathElement( hashValue_ = getHash(*this); } -inline auto +inline std::uint32_t STPathElement::getNodeType() const { return type_; @@ -545,25 +631,50 @@ STPathSet::empty() const return value_.empty(); } -inline void -STPathSet::pushBack(STPath const& e) +template +inline bool +STPathSet::appendUnique(Append&& append) { - value_.push_back(e); - seenHashes_.emplace(value_.back()); -} + // Append to the vector first, so that a failed allocation there leaves both + // containers untouched rather than leaving the index holding a path the + // vector does not. + append(value_); -template -inline void -STPathSet::emplaceBack(Args&&... args) -{ - value_.emplace_back(std::forward(args)...); - seenHashes_.emplace(value_.back()); + if (seen_ == nullptr) + { + return true; + } + + try + { + if (!seen_->insert(value_.back()).second) + { + // Already present: roll back the append. + value_.pop_back(); + return false; + } + } + catch (...) + { + // The index insert failed, so roll back the append to keep the vector + // and the index consistent. + value_.pop_back(); + throw; + } + return true; } inline bool -STPathSet::contains(STPath const& path) const +STPathSet::pushBack(STPath const& e) { - return seenHashes_.contains(path); + return appendUnique([&](auto& value) { value.push_back(e); }); +} + +template +inline bool +STPathSet::emplaceBack(Args&&... args) +{ + return appendUnique([&](auto& value) { value.emplace_back(std::forward(args)...); }); } } // namespace xrpl diff --git a/include/xrpl/protocol/STValidation.h b/include/xrpl/protocol/STValidation.h index 8101b27341..f70e971f87 100644 --- a/include/xrpl/protocol/STValidation.h +++ b/include/xrpl/protocol/STValidation.h @@ -124,6 +124,13 @@ public: [[nodiscard]] NodeID const& getNodeID() const noexcept; + /** + * Whether this validation carries a good signature. + * + * Reports false if the signature cannot be checked at all, so a caller + * cannot tell that apart from a bad signature. Either way the validation is + * unusable, and the reason is logged. Only a computed answer is remembered. + */ [[nodiscard]] bool isValid() const noexcept; diff --git a/include/xrpl/protocol/Serializer.h b/include/xrpl/protocol/Serializer.h index c1ea5c16ba..997199629a 100644 --- a/include/xrpl/protocol/Serializer.h +++ b/include/xrpl/protocol/Serializer.h @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -25,6 +26,101 @@ private: Blob data_; public: + /** + * A header is never longer than this. The encoder fills a buffer of this + * size and writes only the bytes it used. + */ + static constexpr int kMaxNumberOfBytesInHeader = 3; + + // A field whose size varies is stored as a header holding its length, then + // the field data. The header is 1, 2 or 3 bytes long. Nothing outside it says + // which, so the decoder reads the first byte and its value says how long the + // header is: + // + // 0 ... 192 kMin/kMaxValueOfFirstByteFor1ByteHeader + // 193 ... 240 kMin/kMaxValueOfFirstByteFor2ByteHeader + // 241 ... 254 kMin/kMaxValueOfFirstByteFor3ByteHeader + // 255 belongs to no header + // + // Each range starts one past the end of the range before it. + + static constexpr int kMinValueOfFirstByteFor1ByteHeader = 0; + static constexpr int kMaxValueOfFirstByteFor1ByteHeader = 192; + + static constexpr int kMinValueOfFirstByteFor2ByteHeader = + kMaxValueOfFirstByteFor1ByteHeader + 1; + static constexpr int kMaxValueOfFirstByteFor2ByteHeader = 240; + + static constexpr int kMinValueOfFirstByteFor3ByteHeader = + kMaxValueOfFirstByteFor2ByteHeader + 1; + + static constexpr int kMaxValueOfFirstByteFor3ByteHeader = 254; + + // A length x too big for one byte is split across the header. For 2 bytes: + // + // first byte = 193 + (x - 193) / 256 + // second byte = (x - 193) % 256 + // + // so 300 is stored as 193, 107. For 3 bytes it is the same, from 241, with + // the remainder split across two bytes: 20,000 is stored as 241, 29, 95. + + static constexpr int kNumberOfValuesInOneByte = 256; + static constexpr int kNumberOfValuesInTwoBytes = + kNumberOfValuesInOneByte * kNumberOfValuesInOneByte; + + // Each header length therefore covers a range of field lengths: + // + // 0 ... 192 kMin/kMaxValueOfLengthFor1ByteHeader + // 193 ... 12,480 kMin/kMaxValueOfLengthFor2ByteHeader + // 12,481 ... 918,744 kMin/kMaxValueOfLengthFor3ByteHeader + // + // The encoder always uses the shortest header that fits. + + /** + * A 1 byte header holds the length in the byte itself, so both ends of + * this range are the same numbers as the first byte's own range. + */ + static constexpr int kMinValueOfLengthFor1ByteHeader = kMinValueOfFirstByteFor1ByteHeader; + static constexpr int kMaxValueOfLengthFor1ByteHeader = kMaxValueOfFirstByteFor1ByteHeader; + + static constexpr int kMinValueOfLengthFor2ByteHeader = kMaxValueOfLengthFor1ByteHeader + 1; + + /** + * 48 values of the first byte mean a 2 byte header, and each of them covers + * 256 lengths. The 48 is worked out from the two range ends above, so it + * stays right if either of them changes. + */ + static constexpr int kMaxValueOfLengthFor2ByteHeader = kMinValueOfLengthFor2ByteHeader + + ((kMaxValueOfFirstByteFor2ByteHeader - kMaxValueOfFirstByteFor1ByteHeader) * + kNumberOfValuesInOneByte) - + 1; + + static constexpr int kMinValueOfLengthFor3ByteHeader = kMaxValueOfLengthFor2ByteHeader + 1; + + /** + * 14 values of the first byte mean a 3 byte header, and each of them covers + * 65,536 lengths. Counted the same way, that gives the largest length any + * header can state. + * + * Nothing is accepted or rejected against this. The assertion below uses it + * to check that every length the encoder writes is one a header can state. + */ + static constexpr int kMaxRepresentableLength = kMinValueOfLengthFor3ByteHeader + + ((kMaxValueOfFirstByteFor3ByteHeader - kMaxValueOfFirstByteFor2ByteHeader) * + kNumberOfValuesInTwoBytes) - + 1; + + /** + * The largest length the encoder will write. This is the one number here + * that is picked rather than worked out. The decoder accepts nothing above + * it, so both sides agree on the same set of lengths. + */ + static constexpr int kMaxValueOfLengthFor3ByteHeader = 918744; + + static_assert( + kMaxValueOfLengthFor3ByteHeader <= kMaxRepresentableLength, + "a length the encoder writes must be one a header can state"); + explicit Serializer(int n = 256) { data_.reserve(n); @@ -61,7 +157,7 @@ public: // assemble functions int - add8(unsigned char i); + add8(unsigned char byteValue); int add16(std::uint16_t i); @@ -270,18 +366,90 @@ public: return v.data_ == data_; } + /** + * Works out how long a header is, from its first byte. + * + * Each overload of decodeVLLength below reads one header length, so call + * this first to learn which of them to call. + * + * @param firstByte First byte of the header, as read from the stream. + * @return How many bytes the whole header takes, counting firstByte: 1, 2 + * or 3. + * @throws std::overflow_error if firstByte is the one value that starts no + * header. + */ static int - decodeLengthLength(int b1); + decodeLengthLength(std::byte firstByte); + + /** + * Reads the field length out of a 1 byte header. + * + * @param firstByte The single header byte, which is the length itself. + * @return Field length in bytes, from kMinValueOfLengthFor1ByteHeader to + * kMaxValueOfLengthFor1ByteHeader. + * @throws std::overflow_error if firstByte is big enough to mean a longer + * header, in which case it is not a length by itself. + */ static int - decodeVLLength(int b1); + decodeVLLength(std::byte firstByte); + + /** + * Reads the field length out of a 2 byte header. + * + * @param firstByte First header byte. Its value means a 2 byte header, and + * how far it sits into that range gives the top part of the length. + * @param secondByte Second header byte, holding the rest of the length. + * @return Field length in bytes, from kMinValueOfLengthFor2ByteHeader to + * kMaxValueOfLengthFor2ByteHeader. + * @throws std::overflow_error if firstByte is outside the range that means + * a 2 byte header. + */ static int - decodeVLLength(int b1, int b2); + decodeVLLength(std::byte firstByte, std::byte secondByte); + + /** + * Reads the field length out of a 3 byte header. + * + * @param firstByte First header byte. Its value means a 3 byte header, and + * how far it sits into that range gives the top part of the length. + * @param secondByte Second header byte, holding the middle part of the + * length. + * @param thirdByte Third header byte, holding the low part. + * @return Field length in bytes, from kMinValueOfLengthFor3ByteHeader to + * kMaxValueOfLengthFor3ByteHeader. + * @throws std::overflow_error if firstByte is outside the range that means + * a 3 byte header, or if the three bytes together state a length above + * kMaxValueOfLengthFor3ByteHeader, which the encoder would not write back. + */ static int - decodeVLLength(int b1, int b2, int b3); + decodeVLLength(std::byte firstByte, std::byte secondByte, std::byte thirdByte); private: + /** + * Works out how many bytes the header needs for the given length. + * + * This deliberately repeats the width choice addEncoded makes, so that + * addVL's assertion can compare the two. It has no other caller; do not + * reach for it as a utility. + * + * @param length Field length in bytes. + * @return How many header bytes it needs: 1, 2 or 3. + * @throws std::overflow_error if length is negative, or above + * kMaxValueOfLengthFor3ByteHeader. + */ static int - encodeLengthLength(int length); // length to encode length + encodeLengthLength(int length); + + /** + * Appends the length header for a field of the given length. + * + * The field's own data is not written; the caller appends it next. + * + * @param length Field length in bytes. + * @return Offset within this Serializer at which the header was written. + * @throws std::overflow_error if length is negative, or above + * kMaxValueOfLengthFor3ByteHeader. + */ int addEncoded(int length); }; @@ -390,9 +558,15 @@ public: void getFieldID(int& type, int& name); - // Returns the size of the VL if the - // next object is a VL. Advances the iterator - // to the beginning of the VL. + /** + * Reads the length header at the read position and steps past it. + * + * @return Field length in bytes. The iterator is left on the first byte of + * the field data. + * @throws std::overflow_error if the header states a length the encoder could + * not have written. + * @throws std::runtime_error if the data runs out before the header does. + */ int getVLDataLength(); diff --git a/include/xrpl/protocol/detail/STVar.h b/include/xrpl/protocol/detail/STVar.h index 56f868b665..72a310546e 100644 --- a/include/xrpl/protocol/detail/STVar.h +++ b/include/xrpl/protocol/detail/STVar.h @@ -34,10 +34,11 @@ concept ValidConstructSTArgs = // and includes a small-object allocation optimization. class STVar { -private: +public: // The largest "small object" we can accommodate static constexpr std::size_t kMaxSize = 72; +private: alignas(std::max_align_t) std::byte d_[kMaxSize] = {}; STBase* p_ = nullptr; diff --git a/include/xrpl/tx/ApplyContext.h b/include/xrpl/tx/ApplyContext.h index e827e69f01..7be7f34b0b 100644 --- a/include/xrpl/tx/ApplyContext.h +++ b/include/xrpl/tx/ApplyContext.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -129,6 +130,14 @@ public: view_->rawDestroyXRP(fee); } + /** + * Registers a newly-created order book directory with the shared, + * process-wide OrderBookDB, unless this transaction is being applied + * under TapDryRun. + */ + void + addOrderBook(Book const& book); + ApplyViewContext getApplyViewContext() { diff --git a/include/xrpl/tx/applySteps.h b/include/xrpl/tx/applySteps.h index bd495481f2..0a1ae9fa3a 100644 --- a/include/xrpl/tx/applySteps.h +++ b/include/xrpl/tx/applySteps.h @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -393,16 +394,21 @@ preclaim(PreflightResult const& preflightResult, ServiceRegistry& registry, Open * * No validation is done or implied by this function. * - * Caller is responsible for handling any exceptions. - * Since none should be thrown, that will usually - * mean terminating. - * + * Callers do not expect this function to throw; exceptions from a transactor's + * `calculateBaseFee` are caught and reported as an error instead. * @param view The current open ledger. * @param tx The transaction to be checked. * - * @return The base fee. + * @return The base fee on success. Returns `std::unexpected(temUNKNOWN)` if the transaction + * type is not recognized, and `std::unexpected(tefEXCEPTION)` if the transactor's + * `calculateBaseFee` threw. + * + * @note Failure is reported as an error rather than a fee of zero because a + * zero (or default) fee would pass checkFee and let the transaction be + * applied for less than it owes. Callers that only need a fee hint may fall + * back to a default; callers deciding whether to apply should reject. */ -XRPAmount +[[nodiscard]] std::expected calculateBaseFee(ReadView const& view, STTx const& tx); /** diff --git a/include/xrpl/tx/invariants/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index 34ce1a4dc2..8cbdefc911 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -38,9 +38,11 @@ namespace xrpl { * f. A Loan must reference a live `ltLOAN_BROKER`, and that broker must * reference a live `ltVAULT`. * g. Post-conditions for the Loan paid down by a successful `ttLOAN_PAY`: - * `PaymentRemaining > 0` after: `PrincipalOutstanding` and - * `PaymentRemaining` strictly decrease; `NextPaymentDueDate` - * advances by N * `PaymentInterval`, N > 0. + * `PaymentRemaining > 0` after: neither `PrincipalOutstanding` nor + * `TotalValueOutstanding` increases, and at least one of them + * strictly decreases; + * `PaymentRemaining` strictly decreases; + * `NextPaymentDueDate` advances by N * `PaymentInterval`, N > 0. * `PaymentRemaining == 0` after: pinned by checks 1 and 5b. * */ diff --git a/package/README.md b/package/README.md index 6e88309ecd..50c12734e0 100644 --- a/package/README.md +++ b/package/README.md @@ -15,7 +15,8 @@ package/ publish_pkg.py Uploads built packages to the XRPLF Nexus repositories (called by CI, and shipped in that image) rpm/ xrpld.spec RPM spec - debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, xrpld.lintian-overrides, source/format) + debian/ Debian control files (control.in, lintian-overrides.in, rules, copyright, docs, links, source/format). + The `.in` files are templates rendered by `build_pkg.py`; `docs` and `links` are staged under the package name shared/ xrpld.service systemd unit file (used by both RPM and DEB) xrpld.sysusers sysusers.d config (used by both RPM and DEB) @@ -32,20 +33,74 @@ packaging job cannot drift apart. Today only `linux/amd64` is emitted. The map pins the full container image in `image` — edit that field to move to a new image and both CI and local builds pick it up — and names the format that image builds in `type`, which CI passes to `build_pkg.py` as `--package-type`; the two -have to stay in step. +have to stay in step. An optional `variant` names a flavour of the package (see +[Package variants](#package-variants)), and CI passes it as `--variant`. | Package type | Image (`configs.[].package.image` in `linux.json`) | Tools required | | ------------ | ---------------------------------------------------------- | -------------------------------------------------------------- | | RPM | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-` | `rpmbuild`, `rpmsign` | | DEB | `ghcr.io/xrplf/xrpld/packaging-debian:sha-` | `dpkg-buildpackage`, debhelper with compat level 13, `lintian` | -To print the full packaging matrix (artifact names and images) for the current -`linux.json`: +To print the full packaging matrix (artifact names, images and package names) +for the current `linux.json`: ```bash ./.github/scripts/strategy-matrix/generate.py --packaging ``` +## Package variants + +A config whose binaries are not the plain release build cannot be packaged as +`xrpld`: both would carry the same name and version, so whichever published last +would win. It is packaged as a **variant** instead — `variant: "assert"` in its +`package` map, which CI passes to `build_pkg.py` as `--variant assert`, +producing `xrpld-assert`. What the build option itself does is a build concern, +not a packaging one; see the options table in [`BUILD.md`](../BUILD.md). + +A variant ships the same paths as `xrpld` — `/usr/bin/xrpld`, `/etc/xrpld`, +`xrpld.service`, `/etc/logrotate.d/xrpld` — differing only in the per-package +documentation directory, so it declares itself a stand-in for the plain package +rather than something installable next to it: `Conflicts`, `Replaces` and a +versioned `Provides: xrpld` on Debian, `Conflicts` and `Provides` on RPM. +Neither format declares `Obsoletes`, so `apt upgrade` and `dnf upgrade` keep an +installed flavour on its own flavour, and switching is always explicit: + +```bash +apt-get install xrpld-assert # apt removes the plain package itself +dnf swap xrpld xrpld-VARIANT # 'dnf install' alone stops at the conflict +``` + +Only the DEB packages carry a variant today — `xrpld-assert` comes from the +`debian` config alone, there being no call for an assert build on RHEL-based +distributions — but the RPM side works the same way if one is added. + +A switch is a removal plus an installation rather than an upgrade, so unlike a +version upgrade it stops the service: Debian's scriptlets start it again, while +on RPM the operator runs `systemctl start xrpld`. Configuration survives either +way, being conffiles on Debian and `%config(noreplace)` on RPM. + +`dnf` installs the replacement before erasing the old flavour, whose `%preun` +would leave `xrpld.service` disabled, so `%postun` re-applies the preset when +the unit file outlives the erase — which, since rpm keeps a file another +installed package owns, happens only during a swap. The cost is that a +deliberate `systemctl disable` is not carried across an RPM switch. + +The alternative is an `xrpld-common` package owning the unit, the sysusers and +tmpfiles snippets and the configuration, required by both flavours at an exact +version: nothing is erased mid-swap, so no scriptlet has to detect one. It is +not worth it for a single variant — it moves files out of the production +package, and a sanitizer flavour would likely need its own unit anyway, putting +the lifecycle back where it is now. + +Adding a variant is the flavour in `VARIANTS` in `build_pkg.py`, which is the +list `--variant` accepts, plus a config in `linux.json` with the CMake arguments +and a `package` map naming it, for one format or for both: `generate.py +--packaging` emits the package names per format, and the `test-install-deb` and +`test-install-rpm` jobs install what their own format produced. + +Operators switch between the flavours as described in +[`docs/install.md`](../docs/install.md#optional-the-assert-enabled-build). + ## Building packages ### Via CI @@ -56,9 +111,11 @@ Caller workflows (`on-pr.yml`, `on-tag.yml`, `on-trigger.yml`) call 1. `package` fans out one job per config carrying a `package` map, building and signing in that config's container, and uploading `-pkg` alongside `-pkg-debug` for the much larger debug symbols. -2. `test-install` installs `-pkg` in the container of every distro the - packages target and runs the binaries there, so one that cannot be installed - never reaches Nexus. +2. `test-install-deb` and `test-install-rpm` call + [`reusable-package-test-install.yml`](../.github/workflows/reusable-package-test-install.yml) + with their format's package names and distro images, installing each package + in the container of every distro that format targets and running the binaries + there, so one that cannot be installed never reaches Nexus. 3. `publish` uploads both artifacts, or lists what it would upload. The packaging script derives the package version from the downloaded binary's @@ -104,6 +161,9 @@ docker run --rm \ # build/rpmbuild/RPMS/x86_64/*.rpm ``` +Add `--variant assert` to package binaries built with `-Dassert=ON`; the package +is then named `xrpld-assert`. + ### Via CMake (host-side target) If you run CMake configure on a host that has `rpmbuild` or `dpkg-buildpackage` @@ -133,11 +193,17 @@ The package version is not a CMake input on this path: `build_pkg.py` derives it from the just-built `xrpld` binary's `xrpld --version` output. The package release defaults to 1 and is overridable with `-Dpkg_release=N`. +`-Dassert=ON` passes `--variant assert`, so such a build packages as +`xrpld-assert` without anything else being asked for. + ## Publishing packages -Packages are published to the XRPLF repositories on Sonatype Nexus at -`https://packages.xrplf.org`. The `release-info` action decides the channel from -the event, and `publish_pkg.py` maps that channel to its repositories: +Packages are published to the XRPLF repositories on Sonatype Nexus through +`https://packages-upload.xrplf.org`. Reads go through +`https://packages.xrplf.org`, which Cloudflare proxies to cache them and which +rejects request bodies over 100 MB, so uploads use the DNS-only host instead. +The `release-info` action decides the channel from the event, and +`publish_pkg.py` maps that channel to its repositories: | Event | Version | Channel | DEB repository | RPM upload repository | | ------------------------ | ----------------- | --------- | -------------- | --------------------- | @@ -147,6 +213,9 @@ the event, and `publish_pkg.py` maps that channel to its repositories: | push to `develop` | `xrpld --version` | `develop` | `deb-develop` | `rpm-develop-hosted` | | tag, non-public codebase | _any_ | `private` | `deb-private` | `rpm-private-hosted` | +A variant is published to the same channel under its own name, so +`xrpld-assert` never overwrites `xrpld`. + Only a tag names a channel — do not extend that to `develop`, where `BuildInfo.cpp`'s `versionString` moves through `-bN`, `-rcN` and even the final version during a release cycle, which would send develop builds into `stable`. @@ -160,7 +229,7 @@ the last, and the date and hash say which commit a package on `packages.xrplf.org` came from. Both reach the packaging scripts as arguments, so neither script derives anything itself. -Publishing is its own job, gated behind `test-install`, uploading from the same +Publishing is its own job, gated behind the install tests, uploading from the same image that built the packages with the `publish_pkg.py` shipped in it — the same copy other repositories run. Without `publish: true` the job is a `--dry-run`, listing the uploads it would make without needing credentials, so @@ -175,7 +244,7 @@ Nexus owns the repository metadata; nothing here indexes anything. Worth knowing - Each apt-hosted repository needs a distribution (ours use `any`) and a PGP signing keypair configured in Nexus, which rejects one created without a keypair. Nexus signs the apt metadata with it, never the packages. -- Hosted yum repositories cannot be signed by Nexus, so each `rpm--hosted` +- yum-hosted repositories cannot be signed by Nexus, so each `rpm--hosted` repository sits behind a `rpm-` yum group repository whose metadata Nexus signs. Uploads go to the hosted repository; clients point at the group and verify the metadata with `repo_gpgcheck=1`. Nexus never signs the RPMs @@ -244,6 +313,19 @@ pre-release ordering convention, so RPM filenames/NVRs begin with forms like `xrpld-3.2.0~b1-...` and `xrpld-3.2.0~rc1-...` instead of encoding pre-releases with an older `0..` RPM `Release` value. +`--variant` is the flavour of the package, empty by default and accepting only +the flavours in `VARIANTS`; see [Package variants](#package-variants). The RPM +path passes it to the spec as the `pkg_variant` macro, which suffixes `Name` and +adds the `Conflicts`/`Provides` pair. Debian control files have no conditionals, so the DEB path renders +`debian/control.in` and `debian/lintian-overrides.in` instead, substituting +`@PKG@` with the package name and `@VARIANT_FIELDS@` with the +`Conflicts`/`Replaces`/`Provides` block, empty for the plain package; a token +with no value fails the build rather than reaching dpkg. The files debhelper +keys by package name (`docs`, `links`, and the units) are staged under that same +name. The paths inside the package are unchanged either way, so `debian/rules` +reads its package name from `dh_listpackages` and names the unit, sysusers, +tmpfiles and logrotate files with `--name xrpld`. + The package format is `--package-type`, either `deb` or `rpm`. It is required, so a job never silently builds the wrong format for the image it runs in; the matching build tool still has to be on PATH. @@ -286,8 +368,13 @@ service restart. 1. Creates a staging source tree at `debbuild/source/` inside the build directory. 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/logrotate into `debian/` where `dh_installsystemd`, `dh_installsysusers`, `dh_installtmpfiles` and `dh_installlogrotate` pick them up automatically. +3. Stages `package/debian/` into `debbuild/source/debian/`: the `.in` templates + are rendered, and the files debhelper keys by package name (`docs`, `links`, + `lintian-overrides`) are staged under the name being built. +4. Copies shared service/sysusers/tmpfiles/logrotate into `debian/` as + `.xrpld.*`, which `dh_installsystemd`, `dh_installsysusers`, + `dh_installtmpfiles` and `dh_installlogrotate` read because `debian/rules` + passes them `--name xrpld`. 5. Generates a minimal `debian/changelog` using `${pkg_version}-${PKG_RELEASE}`, where `pkg_version` is derived from the binary-reported `xrpld` version. 6. Runs `dpkg-buildpackage -b --no-sign -d` (`-d` skips the build-dependency check, since the binary is already built). `debian/rules` uses manual `install` commands. diff --git a/package/build_pkg.py b/package/build_pkg.py index 1aaf53d5ff..77ef8f3120 100755 --- a/package/build_pkg.py +++ b/package/build_pkg.py @@ -21,6 +21,14 @@ SRC_DIR = Path(__file__).resolve().parents[1] PRE_RELEASE = re.compile(r"^(b|rc)(0|[1-9][0-9]*)(\+.*)?$") +# The package name a variant suffixes, and the name every variant keeps for its +# on-disk paths (/usr/bin/xrpld, /etc/xrpld, xrpld.service). +BASE_NAME = "xrpld" + +# The flavours that can be built, '' being the plain xrpld package. A variant +# needs a config in linux.json to be built by CI; see package/README.md. +VARIANTS = ("", "assert") + # Files both packaging systems consume, staged under the same names. STAGED_FROM_BUILD = ("xrpld", "validator-keys", "validator-keys-LICENSE") STAGED_FROM_SRC = { @@ -31,6 +39,18 @@ STAGED_FROM_SRC = { } STAGED_UNITS = ("xrpld.service", "xrpld.sysusers", "xrpld.tmpfiles", "xrpld.logrotate") +# debian/ files debhelper keys by package name, staged as '.'. +DEBIAN_PKG_FILES = ("docs", "links") + +# Debian control files have no conditionals, so what makes a variant replace the +# plain package is rendered into control.in rather than written there. +DEB_VARIANT_FIELDS = """\ +Conflicts: xrpld +Replaces: xrpld +Provides: xrpld (= ${binary:Version})""" + +TOKEN = re.compile(r"@[A-Z_]+@") + def run(*command: object, cwd: Path | None = None) -> None: """Echo a command and run it.""" @@ -75,6 +95,28 @@ def package_version(reported: str) -> str: return version +def render(template: Path, dest: Path, values: dict[str, str]) -> None: + """Write template to dest with its @TOKEN@ placeholders substituted. + + A token left without a value fails the build rather than reaching dpkg. + """ + text = template.read_text() + for token, value in values.items(): + text = text.replace(f"@{token}@", value) + + missing = sorted(set(TOKEN.findall(text))) + assert not missing, f"{template}: no value for {', '.join(missing)}" + + # An empty value at the end of a stanza would otherwise leave a blank line, + # which is what ends a stanza. + dest.write_text(text.rstrip("\n") + "\n") + + +def package_name(variant: str) -> str: + """The binary package name for a variant: '' -> xrpld, 'assert' -> xrpld-assert.""" + return f"{BASE_NAME}-{variant}" if variant else BASE_NAME + + def read_version(xrpld: Path) -> str: """Read the version from the binary that is about to be packaged.""" fields = capture(xrpld, "--version").partition("\n")[0].split() @@ -135,17 +177,18 @@ def stage_common(build_dir: Path, dest: Path) -> None: shutil.copy2(SRC_DIR / source, dest / name) -def stage_units(dest: Path) -> None: +def stage_units(dest: Path, *, prefix: str = "") -> None: """Copy the systemd, sysusers, tmpfiles and logrotate files into dest. - Each format wants them somewhere else: rpmbuild reads them from SOURCES, - debhelper from debian/. + Each format wants them somewhere else: rpmbuild reads them from SOURCES by + path, debhelper from debian/ by package name -- hence 'prefix', which makes + the copies 'xrpld-assert.xrpld.service' and so on. """ for name in STAGED_UNITS: - shutil.copy2(SRC_DIR / "package" / "shared" / name, dest / name) + shutil.copy2(SRC_DIR / "package" / "shared" / name, dest / f"{prefix}{name}") -def build_rpm(build_dir: Path, *, version: str, pkg_release: str) -> None: +def build_rpm(build_dir: Path, *, version: str, pkg_release: str, variant: str) -> None: """Stage the spec and its sources, then build the binary RPMs.""" topdir = build_dir / "rpmbuild" for name in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"): @@ -156,6 +199,9 @@ def build_rpm(build_dir: Path, *, version: str, pkg_release: str) -> None: stage_common(build_dir, topdir / "SOURCES") stage_units(topdir / "SOURCES") + # The spec defaults it to nothing, so a plain build is unchanged. + variant_defines = ["--define", f"pkg_variant {variant}"] if variant else [] + run( "rpmbuild", "-bb", @@ -168,10 +214,29 @@ def build_rpm(build_dir: Path, *, version: str, pkg_release: str) -> None: # The image tracks the newest distro, but the packages target el9. "--define", "dist .el9", + *variant_defines, spec, ) +def stage_debian(dest: Path, name: str) -> None: + """Stage the debian directory for the package name being built.""" + source = SRC_DIR / "package" / "debian" + shutil.copytree( + source, dest, ignore=shutil.ignore_patterns("*.in", *DEBIAN_PKG_FILES) + ) + + values = { + "PKG": name, + "VARIANT_FIELDS": "" if name == BASE_NAME else DEB_VARIANT_FIELDS, + } + render(source / "control.in", dest / "control", values) + render(source / "lintian-overrides.in", dest / f"{name}.lintian-overrides", values) + + for suffix in DEBIAN_PKG_FILES: + shutil.copy2(source / suffix, dest / f"{name}.{suffix}") + + def build_deb( build_dir: Path, *, @@ -180,21 +245,23 @@ def build_deb( pkg_release: str, channel: str, epoch: int, + name: str, ) -> None: """Stage the debian directory and its sources, then build the binary DEBs.""" staging = build_dir / "debbuild" / "source" stage_common(build_dir, staging) - shutil.copytree(SRC_DIR / "package" / "debian", staging / "debian") + stage_debian(staging / "debian", name) - # debhelper picks these up from debian/ automatically. - stage_units(staging / "debian") + # Prefixed whether it is a variant's name or not: debian/rules names them + # explicitly either way. + stage_units(staging / "debian", prefix=f"{name}.") date = datetime.fromtimestamp(epoch, timezone.utc).strftime( "%a, %d %b %Y %H:%M:%S %z" ) # The leading spaces are significant to dpkg. changelog = textwrap.dedent(f"""\ - xrpld ({version}-{pkg_release}) {channel}; urgency=medium + {name} ({version}-{pkg_release}) {channel}; urgency=medium * Release {reported}. -- XRPL Foundation {date} @@ -223,6 +290,14 @@ def main() -> None: default="1", help="package release iteration (default: %(default)s)", ) + parser.add_argument( + "--variant", + default="", + choices=VARIANTS, + help="the flavour of the package to build: 'assert' produces " + "xrpld-assert, which ships the same paths as xrpld and replaces it " + "(default: the plain xrpld package)", + ) parser.add_argument( "--channel", required=True, @@ -234,6 +309,8 @@ def main() -> None: build_dir: Path = args.build_dir.resolve() pkg_release: str = args.pkg_release channel: str = args.channel + variant: str = args.variant + name = package_name(variant) assert build_dir.is_dir(), ( f"build directory not found: {build_dir}. Build the binaries before " @@ -253,6 +330,8 @@ def main() -> None: for tree in ("debbuild", "rpmbuild"): shutil.rmtree(build_dir / tree, ignore_errors=True) + print(f"Building {package_type} {name} {version}-{pkg_release}", flush=True) + if package_type == "deb": build_deb( build_dir, @@ -261,9 +340,10 @@ def main() -> None: pkg_release=pkg_release, channel=channel, epoch=epoch, + name=name, ) else: - build_rpm(build_dir, version=version, pkg_release=pkg_release) + build_rpm(build_dir, version=version, pkg_release=pkg_release, variant=variant) if __name__ == "__main__": diff --git a/package/debian/control b/package/debian/control.in similarity index 93% rename from package/debian/control rename to package/debian/control.in index 359f39f770..20486efc9a 100644 --- a/package/debian/control +++ b/package/debian/control.in @@ -1,4 +1,4 @@ -Source: xrpld +Source: @PKG@ Section: net Priority: optional Maintainer: XRPL Foundation @@ -11,7 +11,7 @@ Homepage: https://github.com/XRPLF/rippled Vcs-Git: https://github.com/XRPLF/rippled.git Vcs-Browser: https://github.com/XRPLF/rippled -Package: xrpld +Package: @PKG@ Architecture: any Depends: ${shlibs:Depends}, @@ -22,3 +22,4 @@ Description: XRP Ledger daemon transactions, and maintains the ledger database. This package also includes the validator-keys tool for validator key management. +@VARIANT_FIELDS@ diff --git a/package/debian/xrpld.docs b/package/debian/docs similarity index 100% rename from package/debian/xrpld.docs rename to package/debian/docs diff --git a/package/debian/xrpld.links b/package/debian/links similarity index 100% rename from package/debian/xrpld.links rename to package/debian/links diff --git a/package/debian/lintian-overrides.in b/package/debian/lintian-overrides.in new file mode 100644 index 0000000000..5e72a5ef6b --- /dev/null +++ b/package/debian/lintian-overrides.in @@ -0,0 +1,6 @@ +# The /usr/local/bin/rippled symlink is deliberate compatibility for pre-FHS +# layouts, so the Policy 9.1.2 tags it raises are expected. +# TODO: remove alongside debian/links after rippled fully deprecated. +@PKG@: dir-in-usr-local [usr/local/bin/] +@PKG@: file-in-usr-local [usr/local/bin/rippled] +@PKG@: file-in-unusual-dir [usr/local/bin/rippled] diff --git a/package/debian/rules b/package/debian/rules index dd6d1e66b9..bc12f54218 100755 --- a/package/debian/rules +++ b/package/debian/rules @@ -8,33 +8,58 @@ export DH_VERBOSE = 1 # the binaries actually run on. LIBC_MIN = 2.31 +# The binary package's name, which a variant build changes to e.g. xrpld-assert, +# and the directory debhelper expects its files staged in. +PKG := $(firstword $(shell dh_listpackages)) +PKG_DIR = debian/$(PKG) + +# The base name, which every package ships under whatever it is called itself. +BASE_NAME = xrpld + +# What build_pkg.py stages beside this directory, each installed under its own +# name. The binaries are also the ones checked against LIBC_MIN below. +BINARIES = $(BASE_NAME) validator-keys +CONFIGS = $(BASE_NAME).cfg validators.txt + %: dh $@ override_dh_auto_configure override_dh_auto_build override_dh_auto_test: @: +# The unit, sysusers, tmpfiles and logrotate files are named after the daemon +# rather than after the package, so a variant still ships xrpld.service and +# /etc/logrotate.d/xrpld. debhelper only reads debian/$(PKG).$(BASE_NAME).* when told +# the name. override_dh_installsystemd: - dh_installsystemd --no-stop-on-upgrade xrpld.service + dh_installsystemd --no-stop-on-upgrade --name $(BASE_NAME) # The tmpfiles snippet sets ownership to the xrpld user, so the sysusers snippet # has to be emitted first: run it early and make its own sequence slot a no-op. execute_before_dh_installtmpfiles: - dh_installsysusers + dh_installsysusers --name $(BASE_NAME) override_dh_installsysusers: +override_dh_installtmpfiles: + dh_installtmpfiles --name $(BASE_NAME) + +override_dh_installlogrotate: + dh_installlogrotate --name $(BASE_NAME) + 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 + for binary in $(BINARIES); do \ + install -D -m 0755 "$$binary" "$(PKG_DIR)/usr/bin/$$binary"; \ + done + for config in $(CONFIGS); do \ + install -D -m 0644 "$$config" "$(PKG_DIR)/etc/$(BASE_NAME)/$$config"; \ + done override_dh_shlibdeps: dh_shlibdeps # Guards against the toolchain moving past LIBC_MIN and the packages then # claiming a floor they do not meet. - for binary in xrpld validator-keys; do \ + for binary in $(BINARIES); do \ needed=$$(readelf --dyn-syms --wide $$binary \ | grep -o 'GLIBC_[0-9.]*' | sed 's/GLIBC_//' | sort -uV | tail -1); \ if [ -z "$$needed" ]; then \ @@ -46,7 +71,7 @@ override_dh_shlibdeps: exit 1; \ fi; \ done - sed -i 's/libc6 (>= [0-9.]*)/libc6 (>= $(LIBC_MIN))/' debian/xrpld.substvars + sed -i 's/libc6 (>= [0-9.]*)/libc6 (>= $(LIBC_MIN))/' debian/$(PKG).substvars override_dh_dwz: @: diff --git a/package/debian/xrpld.lintian-overrides b/package/debian/xrpld.lintian-overrides deleted file mode 100644 index a0b3f583ed..0000000000 --- a/package/debian/xrpld.lintian-overrides +++ /dev/null @@ -1,6 +0,0 @@ -# The /usr/local/bin/rippled symlink is deliberate compatibility for pre-FHS -# layouts, so the Policy 9.1.2 tags it raises are expected. -# TODO: remove alongside debian/xrpld.links after rippled fully deprecated. -xrpld: dir-in-usr-local [usr/local/bin/] -xrpld: file-in-usr-local [usr/local/bin/rippled] -xrpld: file-in-unusual-dir [usr/local/bin/rippled] diff --git a/package/docker/publish_pkg.py b/package/docker/publish_pkg.py index 84a0448e7b..a112e284aa 100755 --- a/package/docker/publish_pkg.py +++ b/package/docker/publish_pkg.py @@ -98,7 +98,7 @@ def main() -> None: ) parser.add_argument( "--nexus-url", - default="https://packages.xrplf.org", + default="https://packages-upload.xrplf.org", help="the Nexus instance to publish to (default: %(default)s)", ) parser.add_argument( diff --git a/package/rpm/xrpld.spec b/package/rpm/xrpld.spec index 5139cd54e5..45e7a78e42 100644 --- a/package/rpm/xrpld.spec +++ b/package/rpm/xrpld.spec @@ -6,10 +6,14 @@ %{error:pkg_release must be defined} %endif -Name: xrpld +# The base name, which every package ships under. A variant build +# (build_pkg.py --variant) only suffixes the package name, e.g. xrpld-assert. +%global base_name xrpld + +Name: %{base_name}%{?pkg_variant:-%{pkg_variant}} Version: %{pkg_version} Release: %{pkg_release}%{?dist} -Summary: XRP Ledger daemon +Summary: XRP Ledger daemon%{?pkg_variant: (%{pkg_variant} build)} License: ISC URL: https://github.com/XRPLF/rippled @@ -17,6 +21,12 @@ URL: https://github.com/XRPLF/rippled ExclusiveArch: x86_64 aarch64 BuildRequires: systemd-rpm-macros +# A variant owns the same paths, so it stands in for the plain package. +%if "%{?pkg_variant}" != "" +Conflicts: %{base_name} +Provides: %{base_name} = %{version}-%{release} +%endif + # These have to precede %%debug_package: it opens the debuginfo subpackage, and # any tag after it is silently dropped from the main package. %{?systemd_requires} @@ -52,22 +62,22 @@ management. : %install -install -Dm0755 %{_sourcedir}/xrpld %{buildroot}%{_bindir}/%{name} +install -Dm0755 %{_sourcedir}/xrpld %{buildroot}%{_bindir}/%{base_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 +install -Dm0644 %{_sourcedir}/xrpld.cfg %{buildroot}%{_sysconfdir}/%{base_name}/xrpld.cfg +install -Dm0644 %{_sourcedir}/validators.txt %{buildroot}%{_sysconfdir}/%{base_name}/validators.txt # systemd units, sysusers, tmpfiles, preset install -Dm0644 %{_sourcedir}/xrpld.service %{buildroot}%{_unitdir}/xrpld.service install -Dm0644 %{_sourcedir}/xrpld.sysusers %{buildroot}%{_sysusersdir}/xrpld.conf install -Dm0644 %{_sourcedir}/xrpld.tmpfiles %{buildroot}%{_tmpfilesdir}/xrpld.conf install -d %{buildroot}%{_presetdir} -cat >%{buildroot}%{_presetdir}/50-xrpld.preset <<'EOF' +cat >%{buildroot}%{_presetdir}/50-%{base_name}.preset <<'EOF' enable xrpld.service EOF # Logrotate config -install -Dm0644 %{_sourcedir}/xrpld.logrotate %{buildroot}%{_sysconfdir}/logrotate.d/%{name} +install -Dm0644 %{_sourcedir}/xrpld.logrotate %{buildroot}%{_sysconfdir}/logrotate.d/%{base_name} # Docs install -Dm0644 %{_sourcedir}/LICENSE.md %{buildroot}%{_docdir}/%{name}/LICENSE.md @@ -78,13 +88,13 @@ install -Dm0644 %{_sourcedir}/validator-keys-LICENSE %{buildroot}%{_docdir}/%{na # Legacy compatibility for pre-FHS package layouts. # TODO: remove after rippled fully deprecated. install -d %{buildroot}/usr/local/bin -ln -s %{_bindir}/%{name} %{buildroot}/usr/local/bin/rippled +ln -s %{_bindir}/%{base_name} %{buildroot}/usr/local/bin/rippled %pre -%sysusers_create_package %{name} %{_sourcedir}/xrpld.sysusers +%sysusers_create_package %{base_name} %{_sourcedir}/xrpld.sysusers %post -%tmpfiles_create_package %{name} %{_sourcedir}/xrpld.tmpfiles +%tmpfiles_create_package %{base_name} %{_sourcedir}/xrpld.tmpfiles %systemd_post xrpld.service %preun @@ -92,6 +102,13 @@ ln -s %{_bindir}/%{name} %{buildroot}/usr/local/bin/rippled %postun %systemd_postun xrpld.service +# A flavour swap installs the replacement before erasing this package, so the +# %%preun above has just disabled a unit the replacement still owns. rpm keeps a +# file that another installed package owns, so the unit outliving our own erase +# means exactly that; a plain erase takes it with us and re-presets nothing. +if [ $1 -eq 0 ] && [ -f %{_unitdir}/xrpld.service ]; then + systemctl preset xrpld.service >/dev/null 2>&1 || : +fi %files %attr(0755,root,root) %dir %{_docdir}/%{name} @@ -99,18 +116,18 @@ ln -s %{_bindir}/%{name} %{buildroot}/usr/local/bin/rippled %license %{_docdir}/%{name}/validator-keys-LICENSE %doc %{_docdir}/%{name}/README.md -%attr(0755,root,root) %dir %{_sysconfdir}/%{name} +%attr(0755,root,root) %dir %{_sysconfdir}/%{base_name} -%{_bindir}/%{name} +%{_bindir}/%{base_name} %{_bindir}/validator-keys -%config(noreplace) %{_sysconfdir}/%{name}/xrpld.cfg -%config(noreplace) %{_sysconfdir}/%{name}/validators.txt -%config(noreplace) %{_sysconfdir}/logrotate.d/%{name} +%config(noreplace) %{_sysconfdir}/%{base_name}/xrpld.cfg +%config(noreplace) %{_sysconfdir}/%{base_name}/validators.txt +%config(noreplace) %{_sysconfdir}/logrotate.d/%{base_name} %{_unitdir}/xrpld.service -%attr(0644,root,root) %{_presetdir}/50-xrpld.preset +%attr(0644,root,root) %{_presetdir}/50-%{base_name}.preset %{_sysusersdir}/xrpld.conf %{_tmpfilesdir}/xrpld.conf %ghost %dir /var/lib/xrpld diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 10c7e62c6c..2728a3b86f 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -179,7 +179,7 @@ isPaymentLate(ReadView const& view, SLE::const_ref loanSle) : ExpiryComparison::Inclusive); } -namespace accrual { +namespace instant_recognition { AccountingDeltas loanOriginationDeltas(Number const& principalRequested, Number const& interestDue) @@ -217,7 +217,7 @@ loanPaymentDeltas(LoanPaymentParts const& parts) .debtTotalDelta = (parts.principalPaid + parts.interestPaid) - parts.valueChange}; } -} // namespace accrual +} // namespace instant_recognition namespace cash_basis { @@ -250,8 +250,8 @@ namespace { // Cash-basis accounting applies only when featureLendingProtocolV1_1 is // enabled AND the specific Vault was created under it (LEVersion == -// VaultVersion::CashBasis). Vaults created before activation keep accrual-basis -// accounting forever, even after the amendment later turns on. +// VaultVersion::CashBasis). Vaults created before activation keep instant +// interest recognition forever, even after the amendment later turns on. bool cashBasisEnabled(SLE::const_ref vaultSle) { @@ -268,7 +268,7 @@ loanOriginationDeltas( { return cashBasisEnabled(vaultSle) ? cash_basis::loanOriginationDeltas(principalRequested) - : accrual::loanOriginationDeltas(principalRequested, interestDue); + : instant_recognition::loanOriginationDeltas(principalRequested, interestDue); } bool @@ -283,21 +283,22 @@ loanOriginationExceedsVaultMaximum( return false; auto const vaultMaximum = vaultSle->at(sfAssetsMaximum); - return accrual::loanOriginationExceedsVaultMaximum(vaultMaximum, vaultTotal, interestDue); + return instant_recognition::loanOriginationExceedsVaultMaximum( + vaultMaximum, vaultTotal, interestDue); } Number loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle) { return cashBasisEnabled(vaultSle) ? cash_basis::loanVaultExposure(loanSle) - : accrual::loanVaultExposure(loanSle); + : instant_recognition::loanVaultExposure(loanSle); } AccountingDeltas loanPaymentDeltas(SLE::const_ref vaultSle, LoanPaymentParts const& parts) { return cashBasisEnabled(vaultSle) ? cash_basis::loanPaymentDeltas(parts) - : accrual::loanPaymentDeltas(parts); + : instant_recognition::loanPaymentDeltas(parts); } namespace detail { diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index bf67defa3b..9788b4c025 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.4.0-rc1" +char const* const versionString = "3.5.0-b0" // clang-format on ; diff --git a/src/libxrpl/protocol/STPathSet.cpp b/src/libxrpl/protocol/STPathSet.cpp index 658aaa65dd..2c074c3f2f 100644 --- a/src/libxrpl/protocol/STPathSet.cpp +++ b/src/libxrpl/protocol/STPathSet.cpp @@ -1,6 +1,8 @@ #include +#include #include +#include #include #include #include @@ -11,10 +13,12 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -31,6 +35,11 @@ STPathElement::getHash(STPathElement const& element) // NIKB NOTE: This doesn't have to be a secure hash as speed is more // important. We don't even really need to fully hash the whole // base_uint here, as a few bytes would do for our use. + // + // The note above is only true because the result of this function reaches + // nothing but STPathElement::operator==, where it is a fast-reject + // prefilter ahead of the field comparisons that decide the answer. Do not + // use it to key a container. for (auto const x : element.getAccountID()) hashAccount += (hashAccount * 257) ^ x; @@ -51,10 +60,49 @@ STPathElement::getHash(STPathElement const& element) return (hashAccount ^ hashCurrency ^ hashIssuer); } -[[nodiscard]] size_t -STPathElement::getHash() const +// For guidance on deciding which option to pursue: +// 1. Try to decrease the size of the STPathSet first. For instance, if a std::optional was +// injected into the type, could you get the same functionality using a std::unique_ptr instead? +// 2. If the size of the STPathSet is already as small as it can be, then consider what the cost +// of increasing STVar::kMaxSize would be on all the other STVar types. Each of those types +// will carry the additional cost of accommodating the larger STPathSet in their SBO. +// 3. If the cost of increasing STVar::kMaxSize is too high, then heap allocate the STPathSet and +// remove this static_assert. +static_assert( + sizeof(STPathSet) <= detail::STVar::kMaxSize, + "STPathSet is too large to fit in STVar's small object optimization. Please verify if it " + "should, if the kMaxSize should be increased, or if STPathSet should be stored on the heap " + "instead of in STVar."); + +STPathSet::STPathSet(DeduplicationTag) : seen_{std::make_unique>()} { - return STPathElement::getHash(*this); +} + +STPathSet::STPathSet(STPathSet const& other) + : STBase{other} + , CountedObject{other} + , value_{other.value_} + , seen_{ + other.seen_ != nullptr ? std::make_unique>(*other.seen_) + : nullptr} +{ +} + +STPathSet& +STPathSet::operator=(STPathSet const& other) +{ + if (this == &other) + { + return *this; + } + auto newSeen = other.seen_ != nullptr + ? std::make_unique>(*other.seen_) + : nullptr; + STBase::operator=(other); + CountedObject::operator=(other); + value_ = other.value_; + seen_ = std::move(newSeen); + return *this; } STPathSet::STPathSet(SerialIter& sit, SField const& name) : STBase(name) @@ -72,7 +120,8 @@ STPathSet::STPathSet(SerialIter& sit, SField const& name) : STBase(name) Throw("empty path"); } - pushBack(path); + // Move rather than converting the vector to an STPath by copy. + value_.emplace_back(std::move(path)); path.clear(); if (iType == STPathElement::TypeNone) @@ -132,16 +181,10 @@ 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 + XRPL_ASSERT(seen_ != nullptr, "xrpl::STPathSet::assembleAdd : DeduplicationTag"); STPath combined = base; combined.pushBack(tail); - - if (!seenHashes_.insert(combined).second) - { - return false; - } - - value_.push_back(std::move(combined)); - return true; + return appendUnique([&](auto& value) { value.push_back(std::move(combined)); }); } bool diff --git a/src/libxrpl/protocol/STValidation.cpp b/src/libxrpl/protocol/STValidation.cpp index 9fdb6e4cee..1fad610c83 100644 --- a/src/libxrpl/protocol/STValidation.cpp +++ b/src/libxrpl/protocol/STValidation.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include +#include #include namespace xrpl { @@ -108,11 +110,42 @@ STValidation::isValid() const noexcept publicKeyType(getSignerPublic()) == KeyType::Secp256k1, "xrpl::STValidation::isValid : valid key type"); - valid_ = verifyDigest( - getSignerPublic(), - getSigningHash(), - makeSlice(getFieldVL(sfSignature)), - (getFlags() & kVfFullyCanonicalSig) != 0u); + // Log that the signature was never checked, so an operator does not + // read this as a bad key. The log is guarded because it can throw too. + auto reportUncheckable = [this](char const* reason) noexcept { + try + { + JLOG(debugLog().error()) + << "Cannot check the signature of the validation for ledger " << getLedgerHash() + << ": " << reason; + } + catch (...) // NOLINT(bugprone-empty-catch) + { + // Nothing can be reported when reporting is what failed. + } + }; + + // The signing hash re-serializes the fields, which can fail. This + // function is noexcept, so report the validation as invalid instead of + // throwing. valid_ stays unset, so a later call checks again. + try + { + valid_ = verifyDigest( + getSignerPublic(), + getSigningHash(), + makeSlice(getFieldVL(sfSignature)), + (getFlags() & kVfFullyCanonicalSig) != 0u); + } + catch (std::exception const& e) + { + reportUncheckable(e.what()); + return false; + } + catch (...) + { + reportUncheckable("unknown exception"); + return false; + } } return valid_.value(); diff --git a/src/libxrpl/protocol/Serializer.cpp b/src/libxrpl/protocol/Serializer.cpp index 80ecdee6c8..7f6fe625c2 100644 --- a/src/libxrpl/protocol/Serializer.cpp +++ b/src/libxrpl/protocol/Serializer.cpp @@ -143,10 +143,10 @@ Serializer::addFieldID(int type, int name) } int -Serializer::add8(unsigned char byte) +Serializer::add8(unsigned char byteValue) { int const ret = data_.size(); - data_.push_back(byte); + data_.push_back(byteValue); return ret; } @@ -210,109 +210,138 @@ Serializer::addVL(void const* ptr, int len) int Serializer::addEncoded(int length) { - std::array bytes{}; + // Without this, a negative length would fall into the 1 byte case below and + // be cast to a first byte no header uses. A size too big for int arrives + // here negative as well, since callers pass sizes through this parameter. + if (length < kMinValueOfLengthFor1ByteHeader) + Throw("addEncoded: length is negative or did not fit in an int"); + + std::array bytes{}; int numBytes = 0; - if (length <= 192) + if (length <= kMaxValueOfLengthFor1ByteHeader) { - bytes[0] = static_cast(length); + bytes[0] = static_cast(length); numBytes = 1; } - else if (length <= 12480) + else if (length <= kMaxValueOfLengthFor2ByteHeader) { - length -= 193; - bytes[0] = 193 + static_cast(length >> 8); - bytes[1] = static_cast(length & 0xff); + // Count from the smallest length a 2 byte header covers. + int const offset = length - kMinValueOfLengthFor2ByteHeader; + bytes[0] = static_cast( + kMinValueOfFirstByteFor2ByteHeader + (offset / kNumberOfValuesInOneByte)); + bytes[1] = static_cast(offset % kNumberOfValuesInOneByte); numBytes = 2; } - else if (length <= 918744) + else if (length <= kMaxValueOfLengthFor3ByteHeader) { - length -= 12481; - bytes[0] = 241 + static_cast(length >> 16); - bytes[1] = static_cast((length >> 8) & 0xff); - bytes[2] = static_cast(length & 0xff); + int const offset = length - kMinValueOfLengthFor3ByteHeader; + bytes[0] = static_cast( + kMinValueOfFirstByteFor3ByteHeader + (offset / kNumberOfValuesInTwoBytes)); + bytes[1] = + static_cast((offset / kNumberOfValuesInOneByte) % kNumberOfValuesInOneByte); + bytes[2] = static_cast(offset % kNumberOfValuesInOneByte); numBytes = 3; } else { - Throw("lenlen"); + Throw("addEncoded: length is too large to encode"); } - return addRaw(&bytes[0], numBytes); + return addRaw(bytes.data(), numBytes); } int Serializer::encodeLengthLength(int length) { - if (length < 0) - Throw("len<0"); + if (length < kMinValueOfLengthFor1ByteHeader) + { + Throw( + "encodeLengthLength: length is negative or did not fit in an int"); + } - if (length <= 192) + if (length <= kMaxValueOfLengthFor1ByteHeader) return 1; - if (length <= 12480) + if (length <= kMaxValueOfLengthFor2ByteHeader) return 2; - if (length <= 918744) + if (length <= kMaxValueOfLengthFor3ByteHeader) return 3; - Throw("len>918744"); - return 0; // Silence compiler warning. + Throw("encodeLengthLength: length is too large to encode"); } int -Serializer::decodeLengthLength(int b1) +Serializer::decodeLengthLength(std::byte firstByte) { - if (b1 < 0) - Throw("b1<0"); + int const firstByteValue = std::to_integer(firstByte); - if (b1 <= 192) + if (firstByteValue <= kMaxValueOfFirstByteFor1ByteHeader) return 1; - if (b1 <= 240) + if (firstByteValue <= kMaxValueOfFirstByteFor2ByteHeader) return 2; - if (b1 <= 254) + if (firstByteValue <= kMaxValueOfFirstByteFor3ByteHeader) return 3; - Throw("b1>254"); - return 0; // Silence compiler warning. + Throw("decodeLengthLength: first byte does not start any header"); } int -Serializer::decodeVLLength(int b1) +Serializer::decodeVLLength(std::byte firstByte) { - if (b1 < 0) - Throw("b1<0"); + int const length = std::to_integer(firstByte); - if (b1 > 254) - Throw("b1>254"); + // A bigger value means a longer header, so it is not a length by itself. + if (length > kMaxValueOfLengthFor1ByteHeader) + Throw("decodeVLLength 1 byte: first byte is not a length"); - return b1; + return length; } int -Serializer::decodeVLLength(int b1, int b2) +Serializer::decodeVLLength(std::byte firstByte, std::byte secondByte) { - if (b1 < 193) - Throw("b1<193"); + int const firstByteValue = std::to_integer(firstByte); - if (b1 > 240) - Throw("b1>240"); + if (firstByteValue < kMinValueOfFirstByteFor2ByteHeader) + Throw("decodeVLLength 2 byte: first byte is below the range"); - return 193 + ((b1 - 193) * 256) + b2; + if (firstByteValue > kMaxValueOfFirstByteFor2ByteHeader) + Throw("decodeVLLength 2 byte: first byte is above the range"); + + // Both bytes are bounded by their own type, and the first one is bounded to + // the 2 byte range above, so this cannot leave the range the header covers. + return kMinValueOfLengthFor2ByteHeader + + ((firstByteValue - kMinValueOfFirstByteFor2ByteHeader) * kNumberOfValuesInOneByte) + + std::to_integer(secondByte); } int -Serializer::decodeVLLength(int b1, int b2, int b3) +Serializer::decodeVLLength(std::byte firstByte, std::byte secondByte, std::byte thirdByte) { - if (b1 < 241) - Throw("b1<241"); + int const firstByteValue = std::to_integer(firstByte); - if (b1 > 254) - Throw("b1>254"); + if (firstByteValue < kMinValueOfFirstByteFor3ByteHeader) + Throw("decodeVLLength 3 byte: first byte is below the range"); - return 12481 + ((b1 - 241) * 65536) + (b2 * 256) + b3; + if (firstByteValue > kMaxValueOfFirstByteFor3ByteHeader) + Throw("decodeVLLength 3 byte: first byte is above the range"); + + int const length = kMinValueOfLengthFor3ByteHeader + + ((firstByteValue - kMinValueOfFirstByteFor3ByteHeader) * kNumberOfValuesInTwoBytes) + + (std::to_integer(secondByte) * kNumberOfValuesInOneByte) + + std::to_integer(thirdByte); + + // A 3 byte header reaches further than kMaxValueOfLengthFor3ByteHeader, which + // is as far as the encoder goes. Refuse the rest, so every length accepted + // here is one that can be written back. + if (length > kMaxValueOfLengthFor3ByteHeader) + Throw("decodeVLLength 3 byte: length is too large to re-encode"); + + return length; } //------------------------------------------------------------------------------ @@ -471,24 +500,24 @@ SerialIter::getRaw(int size) int SerialIter::getVLDataLength() { - int const b1 = get8(); + std::byte const firstByte{get8()}; int datLen = 0; - int const lenLen = Serializer::decodeLengthLength(b1); + int const lenLen = Serializer::decodeLengthLength(firstByte); if (lenLen == 1) { - datLen = Serializer::decodeVLLength(b1); + datLen = Serializer::decodeVLLength(firstByte); } else if (lenLen == 2) { - int const b2 = get8(); - datLen = Serializer::decodeVLLength(b1, b2); + std::byte const secondByte{get8()}; + datLen = Serializer::decodeVLLength(firstByte, secondByte); } else { XRPL_ASSERT(lenLen == 3, "xrpl::SerialIter::getVLDataLength : lenLen is 3"); - int const b2 = get8(); - int const b3 = get8(); - datLen = Serializer::decodeVLLength(b1, b2, b3); + std::byte const secondByte{get8()}; + std::byte const thirdByte{get8()}; + datLen = Serializer::decodeVLLength(firstByte, secondByte, thirdByte); } return datLen; } diff --git a/src/libxrpl/tx/ApplyContext.cpp b/src/libxrpl/tx/ApplyContext.cpp index 50f46fceef..96dcd5f587 100644 --- a/src/libxrpl/tx/ApplyContext.cpp +++ b/src/libxrpl/tx/ApplyContext.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -54,6 +56,13 @@ ApplyContext::apply(TER ter) return view_->apply(base_, tx, ter, parentBatchId_, (flags_ & TapDryRun) != 0u, journal); } +void +ApplyContext::addOrderBook(Book const& book) +{ + if ((flags_ & TapDryRun) == TapNone) + registry.get().getOrderBookDB().addOrderBook(book); +} + std::size_t ApplyContext::size() { diff --git a/src/libxrpl/tx/applySteps.cpp b/src/libxrpl/tx/applySteps.cpp index 3ec6c25aa8..08b5bcb9f6 100644 --- a/src/libxrpl/tx/applySteps.cpp +++ b/src/libxrpl/tx/applySteps.cpp @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -304,7 +305,12 @@ invokePreclaim(PreclaimContext const& ctx) }()) return preSigResult; - if (TER const result = T::checkFee(ctx, calculateBaseFee(ctx.view, ctx.tx))) + // We can't check the fee if we can't compute it, so reject. + auto const baseFee = calculateBaseFee(ctx.view, ctx.tx); + if (!baseFee) + return baseFee.error(); + + if (TER const result = T::checkFee(ctx, *baseFee)) return result; } @@ -352,13 +358,12 @@ invokePreclaim(PreclaimContext const& ctx) * * @param view The ledger view to use for fee calculation. * @param tx The transaction for which the base fee is to be calculated. - * @return The calculated base fee as an XRPAmount. + * @return The calculated base fee. Returns `std::unexpected(temUNKNOWN)` if the transaction + * type is not recognized, and `std::unexpected(tefEXCEPTION)` if the transactor's + * `calculateBaseFee` threw. * - * @throws std::exception If an error occurs during fee calculation, including - * but not limited to unknown transaction types or internal errors, the function - * logs an error and returns an XRPAmount of zero. */ -static XRPAmount +static std::expected invokeCalculateBaseFee(ReadView const& view, STTx const& tx) { try @@ -367,13 +372,25 @@ invokeCalculateBaseFee(ReadView const& view, STTx const& tx) return T::calculateBaseFee(view, tx); }); } - catch (UnknownTxnType const& e) + catch (UnknownTxnType const&) { // LCOV_EXCL_START UNREACHABLE("xrpl::invoke_calculateBaseFee : unknown transaction type"); - return XRPAmount{0}; + return std::unexpected(temUNKNOWN); // LCOV_EXCL_STOP } + catch (std::exception const& e) + { + JLOG(debugLog().error()) << "calculateBaseFee: " << tx.getTransactionID() + << " threw an exception: " << e.what(); + return std::unexpected(tefEXCEPTION); + } + catch (...) + { + JLOG(debugLog().error()) << "calculateBaseFee: " << tx.getTransactionID() + << " threw an unknown exception"; + return std::unexpected(tefEXCEPTION); + } } TxConsequences::TxConsequences(NotTEC pfResult) @@ -545,7 +562,7 @@ preclaim(PreflightResult const& preflightResult, ServiceRegistry& registry, Open } } -XRPAmount +std::expected calculateBaseFee(ReadView const& view, STTx const& tx) { return invokeCalculateBaseFee(view, tx); @@ -570,13 +587,26 @@ doApply(PreclaimResult const& preclaimResult, ServiceRegistry& registry, OpenVie { if (!preclaimResult.likelyToClaimFee) return {preclaimResult.ter, false}; + + // For any tx with a real account, preclaim already computed this fee + // successfully against this same view. + auto const baseFee = calculateBaseFee(view, preclaimResult.tx); + if (!baseFee) + { + // LCOV_EXCL_START + JLOG(preclaimResult.j.error()) + << "apply: could not compute base fee: " << transToken(baseFee.error()); + return {tefINTERNAL, false}; + // LCOV_EXCL_STOP + } + ApplyContext ctx( registry, view, preclaimResult.parentBatchId, preclaimResult.tx, preclaimResult.ter, - calculateBaseFee(view, preclaimResult.tx), + *baseFee, preclaimResult.flags, preclaimResult.j); return invokeApply(ctx); diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index b34d7088be..f87a7620af 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -231,17 +231,38 @@ ValidLoan::finalize( // must show that payment in its balance and schedule. A payment that clears // the loan outright instead drives PaymentRemaining to zero, which the // fully-paid-off and zero due-date checks above pin. + // + // PrincipalOutstanding may stay put on a non-final pay: at integer + // scale, fixCleanup3_2_0 rounds principal up so a fractional + // amortization step does not reduce it. Interest (TVO) still falls. + // Neither balance may grow: a payment never adds to what is owed, + // since late-payment penalties are charged in the same transaction + // rather than tracked in TotalValueOutstanding. if (isTesSuccess(result) && txType == ttLOAN_PAY) { if (before && after->at(sfPaymentRemaining) != 0) { - if (!(after->at(sfPrincipalOutstanding) < before->at(sfPrincipalOutstanding))) + if (after->at(sfPrincipalOutstanding) > before->at(sfPrincipalOutstanding)) { - JLOG(j.fatal()) << "Invariant failed: loan pay must strictly decrease " + JLOG(j.fatal()) << "Invariant failed: loan pay must not increase " "PrincipalOutstanding on a non-full-repayment"; return false; } - if (!(after->at(sfPaymentRemaining) < before->at(sfPaymentRemaining))) + if (after->at(sfTotalValueOutstanding) > before->at(sfTotalValueOutstanding)) + { + JLOG(j.fatal()) << "Invariant failed: loan pay must not increase " + "TotalValueOutstanding on a non-full-repayment"; + return false; + } + if (after->at(sfPrincipalOutstanding) == before->at(sfPrincipalOutstanding) && + after->at(sfTotalValueOutstanding) == before->at(sfTotalValueOutstanding)) + { + JLOG(j.fatal()) << "Invariant failed: loan pay must decrease " + "PrincipalOutstanding or TotalValueOutstanding " + "on a non-full-repayment"; + return false; + } + if (after->at(sfPaymentRemaining) >= before->at(sfPaymentRemaining)) { JLOG(j.fatal()) << "Invariant failed: loan pay must decrease " "PaymentRemaining on a non-full-repayment"; diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index e38e8f2b93..46d1037acf 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -299,27 +299,46 @@ ValidMPTIssuance::finalize( return false; } } - else if (lendingProtocolEnabled && (mptokensCreated_ + mptokensDeleted_) > 1) + else { - JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded " - "but created/deleted bad number mptokens"; - return false; - } - else if (submittedByIssuer && (mptokensCreated_ > 0 || mptokensDeleted_ > 0)) - { - JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by issuer " - "succeeded but created/deleted mptokens"; - return false; - } - else if ( - !submittedByIssuer && hasPrivilege(tx, Privilege::MustAuthorizeMpt) && - (mptokensCreated_ + mptokensDeleted_ != 1)) - { - // if the holder submitted this tx, then a mptoken must be - // either created or deleted. - JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by holder " - "succeeded but created/deleted bad number of mptokens"; - return false; + // Cap on MPToken creates and deletes while featureLendingProtocol is enabled. + // - LoanSet: at most two creates and no deletes. + // - VaultWithdraw: at most one create and one delete. + // - Other MayAuthorizeMpt types: created + deleted <= 1. + // - MustAuthorizeMpt still requires exactly one create or delete below. + auto const mptokensExceedAuthorizeCap = [&] { + if (!lendingProtocolEnabled) + return false; + if (rules.enabled(fixCleanup3_4_0)) + { + if (txnType == ttLOAN_SET) + return mptokensDeleted_ != 0 || mptokensCreated_ > 2; + if (txnType == ttVAULT_WITHDRAW) + return mptokensCreated_ > 1 || mptokensDeleted_ > 1; + } + return (mptokensCreated_ + mptokensDeleted_) > 1; + }; + if (mptokensExceedAuthorizeCap()) + { + JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded " + "but created/deleted bad number mptokens"; + return false; + } + if (submittedByIssuer && (mptokensCreated_ > 0 || mptokensDeleted_ > 0)) + { + JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by issuer " + "succeeded but created/deleted mptokens"; + return false; + } + if (!submittedByIssuer && hasPrivilege(tx, Privilege::MustAuthorizeMpt) && + (mptokensCreated_ + mptokensDeleted_ != 1)) + { + // if the holder submitted this tx, then a mptoken must be + // either created or deleted. + JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by holder " + "succeeded but created/deleted bad number of mptokens"; + return false; + } } return true; diff --git a/src/libxrpl/tx/transactors/check/CheckCash.cpp b/src/libxrpl/tx/transactors/check/CheckCash.cpp index 857f759752..f753f604f0 100644 --- a/src/libxrpl/tx/transactors/check/CheckCash.cpp +++ b/src/libxrpl/tx/transactors/check/CheckCash.cpp @@ -439,6 +439,12 @@ CheckCash::doApply() AccountID const& deliverIssuer = flowDeliver.getIssuer(); auto const err = flowDeliver.asset().visit( [&](Issue const& issue) -> std::optional { + // An issuer needs no holder-limit waiver to receive its own currency. + if (deliverIssuer == accountID_ && ctx_.view().rules().enabled(fixCleanup3_4_0)) + { + return std::nullopt; + } + // If a trust line does not exist yet create one. Issue const& trustLineIssue = issue; AccountID const truster = deliverIssuer == accountID_ ? srcId : accountID_; diff --git a/src/libxrpl/tx/transactors/dex/AMMCreate.cpp b/src/libxrpl/tx/transactors/dex/AMMCreate.cpp index 7c7d35497a..2bc9aa5ba1 100644 --- a/src/libxrpl/tx/transactors/dex/AMMCreate.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMCreate.cpp @@ -2,8 +2,6 @@ #include #include -#include -#include #include #include #include @@ -397,7 +395,7 @@ applyCreate(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Jou Book const book{assetIn, assetOut, std::nullopt}; auto const dir = keylet::quality(keylet::book(book), uRate); if (auto const bookExisted = static_cast(sb.read(dir)); !bookExisted) - ctx.registry.get().getOrderBookDB().addOrderBook(book); + ctx.addOrderBook(book); }; addOrderBook(amount.asset(), amount2.asset(), getRate(amount2, amount)); addOrderBook(amount2.asset(), amount.asset(), getRate(amount, amount2)); diff --git a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp index 57ba6eff0d..6c3c04f1a0 100644 --- a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp +++ b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp @@ -6,7 +6,6 @@ #include #include #include -#include #include #include #include @@ -634,7 +633,7 @@ OfferCreate::applyHybrid( bookArr.pushBack(std::move(bookInfo)); if (!bookExists) - ctx_.registry.get().getOrderBookDB().addOrderBook(book); + ctx_.addOrderBook(book); sleOffer->setFieldArray(sfAdditionalBooks, bookArr); return tesSUCCESS; @@ -1014,7 +1013,7 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel) sb.insert(sleOffer); if (!bookExisted) - ctx_.registry.get().getOrderBookDB().addOrderBook(book); + ctx_.addOrderBook(book); JLOG(j_.debug()) << "final result: success"; diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 18886b2682..624c4d0a84 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -36,6 +36,15 @@ namespace xrpl { namespace { +// Returns true if the transaction's payment amount is malformed. A loan +// payment must be strictly positive: zero would move nothing, and a negative +// amount is not a payment at all. +bool +isPaymentAmountInvalid(STAmount const& amount) +{ + return amount <= beast::kZero; +} + // Returns the account's true, unclamped balance in `asset`, for use only in // fund-conservation checks. accountHolds(..., SpendableHandling::FullBalance) // cannot be used for this: for XRP it always defers to xrpLiquid, which @@ -81,7 +90,7 @@ LoanPay::preflight(PreflightContext const& ctx) if (ctx.tx[sfLoanID] == beast::kZero) return temINVALID; - if (ctx.tx[sfAmount] <= beast::kZero) + if (isPaymentAmountInvalid(ctx.tx[sfAmount])) return temBAD_AMOUNT; // The loan payment flags are all mutually exclusive. If more than one is @@ -103,10 +112,19 @@ LoanPay::preflight(PreflightContext const& ctx) XRPAmount LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx) { + auto fixEnabled313 = view.rules().enabled(fixCleanup3_1_3); + auto fixEnabled340 = view.rules().enabled(fixCleanup3_4_0); + using namespace lending; auto const normalCost = Transactor::calculateBaseFee(view, tx); + if (fixEnabled340 && isPaymentAmountInvalid(tx[sfAmount])) + { + // Let preflight worry about the error for this + return normalCost; + } + if (tx.isFlag(tfLoanFullPayment) || tx.isFlag(tfLoanLatePayment)) { // The loan will be making one set of calculations for one full or late @@ -179,8 +197,7 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx) static constexpr std::int64_t kMaxFeeIncrements = kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement; - if (view.rules().enabled(fixCleanup3_1_3) && - amount >= regularPayment * kLoanMaximumPaymentsPerTransaction) + if (fixEnabled313 && amount >= regularPayment * kLoanMaximumPaymentsPerTransaction) { // The payment handler will never process more than // loanMaximumPaymentsPerTransaction payments (including overpayments), diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index f7b97dfedf..da2eb609d8 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -343,10 +343,10 @@ LoanSet::preclaim(PreclaimContext const& ctx) } } - // Accrual origination credits interestDue into AssetsTotal, so a vault + // Instant interest recognition credits interestDue into AssetsTotal, so a vault // already at AssetsMaximum cannot take another loan. Cash-basis origination // does not change AssetsTotal (see cash_basis::loanOriginationDeltas), so - // this leftover accrual gate must not apply there. + // this leftover instant-recognition gate must not apply there. if (getVaultVersion(vault) != VaultVersion::CashBasis && vault->at(sfAssetsMaximum) != 0 && vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum)) { @@ -499,7 +499,7 @@ LoanSet::doApply() getVaultVersion(vaultSle) == VaultVersion::CashBasis || *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy, "xrpl::LoanSet::doApply", - "accrual vault is below maximum limit"); + "instant-recognition vault is below maximum limit"); if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotalProxy, state.interestDue)) { diff --git a/src/libxrpl/tx/transactors/payment/Payment.cpp b/src/libxrpl/tx/transactors/payment/Payment.cpp index c4c2f9227b..d2da173345 100644 --- a/src/libxrpl/tx/transactors/payment/Payment.cpp +++ b/src/libxrpl/tx/transactors/payment/Payment.cpp @@ -339,17 +339,36 @@ Payment::checkGranularSemantics( bool const accountIsHolder = accountIsLow ? rawBalance > beast::kZero : rawBalance < beast::kZero; + bool const mayIssue = + heldGranularPermissions.contains(PaymentMint) && destLimit > beast::kZero; + // PaymentMint requires the destination to be the holder and the account to be the // issuer. destLimit > 0: destination is willing to hold account's IOUs (account is the // issuer). !accountIsHolder: DirectStepI will issue, not redeem. - if (heldGranularPermissions.contains(PaymentMint) && destLimit > beast::kZero && - !accountIsHolder) + if (mayIssue && !accountIsHolder) return tesSUCCESS; // PaymentBurn requires the source account to be the holder and the destination to be // the issuer. accountIsHolder: DirectStepI will redeem, not issue. if (heldGranularPermissions.contains(PaymentBurn) && accountIsHolder) - return tesSUCCESS; + { + if (view.rules().enabled(fixCleanup3_4_0)) + { + // Redeeming stops at the balance held; beyond that the payment engine + // crosses zero and issues the account's own IOUs, which is a mint. So with + // only PaymentBurn we must check the amount against the balance held. The + // granular template forbids sfPaths, tfPartialPayment and a cross-asset + // sfSendMax, so this is a single direct step, sfAmount is what the + // trustline is debited. + STAmount const held = accountIsLow ? rawBalance : -rawBalance; + if (dstAmount <= held || mayIssue) + return tesSUCCESS; + } + else + { + return tesSUCCESS; + } + } return terNO_DELEGATE_PERMISSION; }); diff --git a/src/libxrpl/tx/transactors/system/Batch.cpp b/src/libxrpl/tx/transactors/system/Batch.cpp index ccb113e07b..dcd06453aa 100644 --- a/src/libxrpl/tx/transactors/system/Batch.cpp +++ b/src/libxrpl/tx/transactors/system/Batch.cpp @@ -73,14 +73,23 @@ Batch::calculateBaseFeeImpl(ReadView const& view, STTx const& tx) for (auto const& stx : tx.getBatchTransactions()) { auto const fee = xrpl::calculateBaseFee(view, *stx); - // LCOV_EXCL_START - if (txnFees > maxAmount - fee) + if (!fee) { + JLOG(debugLog().error()) + << "BatchTrace: base fee of inner transaction " << stx->getTransactionID() + << " could not be computed: " << transToken(fee.error()); + return std::nullopt; + } + + // LCOV_EXCL_START + if (txnFees > maxAmount - *fee) + { + UNREACHABLE("XRPAmount overflow in txnFees calculation"); JLOG(debugLog().error()) << "BatchTrace: XRPAmount overflow in txnFees calculation."; return std::nullopt; } // LCOV_EXCL_STOP - txnFees += fee; + txnFees += *fee; } // Calculate the Signers/BatchSigners Fees diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index a964193c1a..9a0bf08660 100644 --- a/src/test/app/ConfidentialTransfer_test.cpp +++ b/src/test/app/ConfidentialTransfer_test.cpp @@ -2191,6 +2191,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .account = bob, .dest = bob, .amt = 10, + .proof = getTrivialSendProofHex(), .err = temMALFORMED, }); @@ -2897,22 +2898,6 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase auto& mptAlice = confEnv.mpt; { - // Bob has 60, tries to send 70. Invalid remaining balance. - mptAlice.send({ - .account = bob, - .dest = carol, - .amt = 70, - .err = tecBAD_PROOF, - }); - - // Bob has 60, tries to send 61. Invalid remaining balance. - mptAlice.send({ - .account = bob, - .dest = carol, - .amt = 61, - .err = tecBAD_PROOF, - }); - // Bob has 60, sends 60. Remainder is exactly 0. Valid remaining balance. mptAlice.send({ .account = bob, @@ -2933,12 +2918,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase }); // Bob has 100, tries to send 2^64-1. Invalid remaining balance. - mptAlice.send({ - .account = bob, - .dest = carol, - .amt = std::numeric_limits::max(), - .err = tecBAD_PROOF, - }); + { + ConfidentialSendSetup const setup( + mptAlice, bob, carol, alice, std::numeric_limits::max()); + auto const forged = getForgedSendProof(mptAlice, env, bob, carol, setup); + mptAlice.send(setup.sendArgs(bob, carol, forged, tecBAD_PROOF)); + } // Bob sends 1, remaining 99. mptAlice.send({ @@ -2947,14 +2932,6 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .amt = 1, .err = tesSUCCESS, }); - - // Bob sends 100, but only has 99. Invalid remaining balance. - mptAlice.send({ - .account = bob, - .dest = carol, - .amt = 100, - .err = tecBAD_PROOF, - }); } // send when spending balance is 0 (key registered, inbox merged, but nothing converted) @@ -2971,18 +2948,13 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase // Trying to send any amount with 0 spending balance must fail: // the range proof for < 0 is invalid. - mptAlice2.send({ - .account = bob2, - .dest = carol2, - .amt = 1, - .err = tecBAD_PROOF, - }); + ConfidentialSendSetup const setup(mptAlice2, bob2, carol2, alice2, 1); + auto const forged = getForgedSendProof(mptAlice2, env2, bob2, carol2, setup); + mptAlice2.send(setup.sendArgs(bob2, carol2, forged, tecBAD_PROOF)); BEAST_EXPECT( mptAlice2.getDecryptedBalance(bob2, MPTTester::holderEncryptedSpending) == 0); } - - // todo: test m exceeding range, require using scala and refactor } /* The equality proof library and range proof library do not @@ -3462,7 +3434,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const convertBackContextHash = getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, convertBackAmt, convertBackContextHash, @@ -3472,6 +3444,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; { json::Value jv; @@ -3483,7 +3457,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase jv[sfIssuerEncryptedAmount.jsonName] = strHex(convertBackIssuerCiphertext); jv[sfBlindingFactor.jsonName] = strHex(convertBackBlindingFactor); jv[sfBalanceCommitment.jsonName] = strHex(pedersenCommitment); - jv[sfZKProof.jsonName] = strHex(proof); + jv[sfZKProof.jsonName] = strHex(requireOptionalRef(proof, "Missing proof")); env(jv, Ter(tesSUCCESS)); } @@ -5283,7 +5257,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); Buffer const badPedersenCommitment = mptAlice.getPedersenCommitment(1, pcBlindingFactor); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, @@ -5293,6 +5267,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -5313,7 +5289,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, @@ -5323,6 +5299,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = generateBlindingFactor(), // wrong blinding factor }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -5337,22 +5315,26 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase } // Test 3: Proof generated with wrong balance value. - // The proof claims balance=1 but the encrypted spending balance contains - // the actual balance. Verification fails because the values don't match. + // The sigma proof claims balance=20 but the pedersen commitment and + // encrypted spending balance were built for the actual balance (40). + // we cannot call mpt_get_convert_back_proof because it has client-side + // verification. { uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); - Buffer const proof = mptAlice.getConvertBackProof( + uint64_t constexpr claimedBalance = 20; // wrong: real balance is 40 + + auto const proof = getForgedConvertBackProof( + mptAlice, bob, + claimedBalance, + spendingBalance, amt, - contextHash, - { - .pedersenCommitment = pedersenCommitment, - .amt = 1, // wrong balance - .encryptedAmt = encryptedSpendingBalance, - .blindingFactor = pcBlindingFactor, - }); + pedersenCommitment, + encryptedSpendingBalance, + pcBlindingFactor, + contextHash); mptAlice.convertBack({ .account = bob, @@ -5375,7 +5357,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); Buffer const badPedersenCommitment = mptAlice.getPedersenCommitment(1, pcBlindingFactor); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, @@ -5385,6 +5367,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -5405,7 +5389,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase { uint256 const badContextHash{1}; - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, badContextHash, // wrong context hash @@ -5415,6 +5399,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -5434,7 +5420,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, @@ -5444,6 +5430,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -5919,22 +5907,26 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase // linkage, and that the remaining balance is non-negative. // Test 1: Proof generated with wrong balance value. - // The sigma proof claims balance=1 but the spending balance contains the - // actual balance. The compact proof's balance-linkage check fails. + // The sigma proof claims balance=20 but the pedersen commitment and + // encrypted spending balance were built for the actual balance (40). + // we cannot call mpt_get_convert_back_proof because it has client-side + // verification. { uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); - Buffer const proof = mptAlice.getConvertBackProof( + uint64_t constexpr claimedBalance = 20; // wrong: real balance is 40 + + auto const proof = getForgedConvertBackProof( + mptAlice, bob, + claimedBalance, + spendingBalance, amt, - contextHash, - { - .pedersenCommitment = pedersenCommitment, - .amt = 1, // wrong balance (actual balance is ~40) - .encryptedAmt = encryptedSpendingBalance, - .blindingFactor = pcBlindingFactor, - }); + pedersenCommitment, + encryptedSpendingBalance, + pcBlindingFactor, + contextHash); mptAlice.convertBack({ .account = bob, @@ -5956,7 +5948,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, @@ -5966,6 +5958,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = generateBlindingFactor(), // wrong blinding factor }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -5985,7 +5979,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase // makes the proof invalid for this transaction, preventing replay attacks. { uint256 const badContextHash{1}; - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, badContextHash, // wrong context hash @@ -5995,6 +5989,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -6014,7 +6010,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, @@ -6024,6 +6020,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -6073,7 +6071,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor); auto const version = mptAlice.getMPTokenVersion(bob); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, makeContextHash(env, mptAlice, alice, bob, carol, version), @@ -6084,6 +6082,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase encryptedSpendingBalance, "Missing encrypted spending balance"), .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -6173,7 +6173,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const contextHashA = getConvertBackContextHash(bob, mptAlice.issuanceID(), currentSeq, version); - Buffer const proofA = mptAlice.getConvertBackProof( + auto const proofA = mptAlice.getConvertBackProof( bob, amtA, contextHashA, @@ -6183,6 +6183,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proofA.has_value())) + return; // Construct Transaction B with Amount m2 = 20 and attach Proof pi uint64_t const amtB = 20; @@ -6254,7 +6256,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const oldContextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), currentSeq, versionV); - Buffer const oldProof = mptAlice.getConvertBackProof( + auto const oldProof = mptAlice.getConvertBackProof( bob, amt, oldContextHash, @@ -6264,6 +6266,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalanceV, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(oldProof.has_value())) + return; // Submit and verify failure mptAlice.convertBack({ @@ -6326,7 +6330,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), currentVersion); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, @@ -6336,6 +6340,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = spendingBalEnc, .blindingFactor = pcBf, }); + if (!BEAST_EXPECT(proof.has_value())) + return; // Submit transaction with Divergent Ciphertexts // Holder Ciphertext encrypts 11. Issuer Ciphertext encrypts 10. @@ -6469,7 +6475,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), currentVersion); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, 1, contextHash, @@ -6479,6 +6485,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = underflowedCt, .blindingFactor = pcBf, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -7741,7 +7749,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const convertBackCtxHash = getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version); - Buffer const convertBackProof = mptAlice.getConvertBackProof( + auto const convertBackProof = mptAlice.getConvertBackProof( bob, sendAmount, convertBackCtxHash, @@ -7751,14 +7759,18 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpending, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(convertBackProof.has_value())) + return; // Resize the convertBack proof to match the expected send proof // size so it passes preflight's size check and reaches the actual // ZK verification in doApply. auto const expectedSendSize = kEcSendProofLength; Buffer resizedProof(expectedSendSize); - auto const copyLen = std::min(convertBackProof.size(), expectedSendSize); - std::memcpy(resizedProof.data(), convertBackProof.data(), copyLen); + Buffer const& convertBackProofRef = + requireOptionalRef(convertBackProof, "Missing proof"); + auto const copyLen = std::min(convertBackProofRef.size(), expectedSendSize); + std::memcpy(resizedProof.data(), convertBackProofRef.data(), copyLen); // Zero-pad the rest (if convertBack proof is shorter) if (copyLen < expectedSendSize) std::memset(resizedProof.data() + copyLen, 0, expectedSendSize - copyLen); diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp index 3ff90c2a8f..5414269333 100644 --- a/src/test/app/Delegate_test.cpp +++ b/src/test/app/Delegate_test.cpp @@ -1144,6 +1144,88 @@ class Delegate_test : public beast::unit_test::Suite env.require(Balance(gw, aliceUSD(-20))); } + // PaymentBurn must not exceed the balance the account holds. Redeeming past + // zero makes the payment engine issue the account's own IOUs, which is a mint. + { + Env env(*this, features); + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const gw{"gateway"}; + auto const gwUSD = gw["USD"]; + auto const aliceUSD = alice["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.trust(gwUSD(200), alice); + env.close(); + + env(pay(gw, alice, gwUSD(50))); + env.close(); + env.require(Balance(alice, gwUSD(50))); + + // gw accepts alice-issued USD, so the engine has issuing liquidity + // available once the trustline reaches zero. + env(trust(gw, aliceUSD(200))); + env.close(); + + env(delegate::set(alice, bob, {"PaymentBurn"})); + env.close(); + + if (!features[fixCleanup3_4_0]) + { + // Pre-fixCleanup3_4_0: the balance direction alone authorizes the payment, so it + // redeems alice's 50 and then mints 50 alice-issued USD. + env(pay(alice, gw, gwUSD(100)), delegate::As(bob)); + env.require(Balance(alice, gwUSD(-50))); + env.require(Balance(gw, aliceUSD(50))); + } + else + { + // Post-fixCleanup3_4_0: Rejected because it exceeds what alice holds. + env(pay(alice, gw, gwUSD(100)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION)); + env.require(Balance(alice, gwUSD(50))); + env.require(Balance(gw, aliceUSD(-50))); + + // Allowed because it is less than what alice holds. + env(pay(alice, gw, gwUSD(20)), delegate::As(bob)); + env.require(Balance(alice, gwUSD(30))); + env.close(); + + // Exactly what alice holds: allowed, and settles at zero. + env(pay(alice, gw, gwUSD(30)), delegate::As(bob)); + env.require(Balance(alice, gwUSD(0))); + env.close(); + + // Nothing left to burn: rejected. + env(pay(alice, gw, gwUSD(1)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION)); + env.require(Balance(gw, aliceUSD(0))); + } + } + + // A delegate holding both PaymentMint and PaymentBurn may cross zero. + { + Env env(*this, features); + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const gw{"gateway"}; + auto const gwUSD = gw["USD"]; + auto const aliceUSD = alice["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.trust(gwUSD(200), alice); + env.close(); + + env(pay(gw, alice, gwUSD(50))); + env(trust(gw, aliceUSD(200))); + env.close(); + + env(delegate::set(alice, bob, {"PaymentBurn", "PaymentMint"})); + env.close(); + + env(pay(alice, gw, gwUSD(100)), delegate::As(bob)); + env.require(Balance(alice, gwUSD(-50))); + env.require(Balance(gw, aliceUSD(50))); + } + // Test invalid fields or flags not allowed in granular permission template { Env env(*this, features); @@ -2916,6 +2998,7 @@ class Delegate_test : public beast::unit_test::Suite testAccountDelete(); testDelegateTransaction(); testPaymentGranular(all); + testPaymentGranular(all - fixCleanup3_4_0); testTrustSetGranular(); testAccountSetGranular(); testMPTokenIssuanceSetGranular(); diff --git a/src/test/app/Path_test.cpp b/src/test/app/Path_test.cpp index cd61668b03..5ecad1a420 100644 --- a/src/test/app/Path_test.cpp +++ b/src/test/app/Path_test.cpp @@ -25,6 +25,7 @@ #include #include +#include #include #include #include @@ -46,16 +47,20 @@ #include #include +#include #include #include +#include #include #include #include #include +#include #include #include #include #include +#include namespace xrpl::test { @@ -1943,7 +1948,7 @@ public: static constexpr AccountID kAccountID7{kAccount7}; static constexpr AccountID kAccountID8{kAccount8}; - auto ps = STPathSet{}; + auto ps = STPathSet{STPathSet::DeduplicationTag{}}; auto createPathElements = [](auto const& account1, auto const& account2) { auto base = STPath{}; @@ -2017,6 +2022,215 @@ public: BEAST_EXPECT(ps.size() == 6); } + void + testPushBackDeduplication() + { + testcase("STPathSet::pushBack/emplaceBack deduplication"); + + // pushBack and emplaceBack reject duplicates on a set built with the + // DeduplicationTag, and append unconditionally without it. Both + // report which happened. The unconditional case is the one the wire + // and JSON paths rely on: collapsing duplicates there would change the + // signed content of a transaction. + + static constexpr AccountID kAccountID1{"A3F19C7B2E5D08146FB93A7C0E2D5184BC6F3A09"}; + static constexpr AccountID kAccountID2{"1D7E4B90C2A6F3851E0B9D47A2C5F8136E0A4B7D"}; + static constexpr AccountID kAccountID3{"F08C36A1D95E27B40CA1F63E8D204B7950E1C3A6"}; + + auto makePath = [](AccountID const& account) { + auto p = STPath{}; + p.pushBack(STPathElement{STPathElement::TypeAccount, account, xrpCurrency(), account}); + return p; + }; + + auto const first = makePath(kAccountID1); + auto const second = makePath(kAccountID2); + auto const third = makePath(kAccountID3); + + // Deduplicating set: the second insert of a path is rejected, and the + // rejection is reported rather than silently swallowed. + { + auto ps = STPathSet{STPathSet::DeduplicationTag{}}; + + BEAST_EXPECT(ps.pushBack(first)); + BEAST_EXPECT(ps.size() == 1); + + BEAST_EXPECT(!ps.pushBack(first)); + BEAST_EXPECT(ps.size() == 1); + + // emplaceBack sees paths registered by pushBack... + BEAST_EXPECT(!ps.emplaceBack(first)); + BEAST_EXPECT(ps.size() == 1); + + BEAST_EXPECT(ps.emplaceBack(second)); + BEAST_EXPECT(ps.size() == 2); + + // ...and pushBack sees paths registered by emplaceBack. + BEAST_EXPECT(!ps.pushBack(second)); + BEAST_EXPECT(ps.size() == 2); + + // emplaceBack's forwarding form registers the same way. + BEAST_EXPECT(ps.emplaceBack(std::vector{third.front()})); + BEAST_EXPECT(ps.size() == 3); + BEAST_EXPECT(!ps.pushBack(third)); + BEAST_EXPECT(ps.size() == 3); + + // A rejected duplicate must not disturb what is already stored. + BEAST_EXPECT(ps[0] == first); + BEAST_EXPECT(ps[1] == second); + BEAST_EXPECT(ps[2] == third); + } + + // Without the tag there is no index, so duplicates are appended and + // both methods report success every time. + { + auto plain = STPathSet{}; + BEAST_EXPECT(plain.pushBack(first)); + BEAST_EXPECT(plain.pushBack(first)); + BEAST_EXPECT(plain.emplaceBack(first)); + BEAST_EXPECT(plain.size() == 3); + + auto named = STPathSet{sfPaths}; + BEAST_EXPECT(named.pushBack(first)); + BEAST_EXPECT(named.pushBack(first)); + BEAST_EXPECT(named.size() == 2); + } + } + + void + testPathHashInjectivity() + { + testcase("STPathElement hash injectivity"); + + auto const zeroCurrency = + STPathElement{AccountID{}, PathAsset{Currency{}}, AccountID{}, true}; + auto const zeroMPT = STPathElement{AccountID{}, PathAsset{MPTID{}}, AccountID{}, true}; + + BEAST_EXPECT(!(zeroCurrency == zeroMPT)); + + auto path = [](std::vector const& elements) { + auto p = STPath{}; + for (auto const& element : elements) + p.pushBack(element); + return p; + }; + + auto const currencyFirst = path({zeroCurrency, zeroMPT}); + auto const mptFirst = path({zeroMPT, zeroCurrency}); + + BEAST_EXPECT(!(currencyFirst == mptFirst)); + + auto const hasher = HardenedHash<>{}; + BEAST_EXPECT(hasher(currencyFirst) != hasher(mptFirst)); + + auto mask = std::vector{0, 0, 1, 1}; + auto hashes = std::set{}; + auto orderings = 0uz; + do + { + auto elements = std::vector{}; + for (auto const isMPT : mask) + { + elements.push_back(isMPT != 0 ? zeroMPT : zeroCurrency); + } + hashes.insert(hasher(path(elements))); + ++orderings; + } while (std::ranges::next_permutation(mask).found); + + BEAST_EXPECT(orderings == 6); + BEAST_EXPECT(hashes.size() == orderings); + + auto seen = hardened_hash_set{}; + for (auto const& p : {currencyFirst, mptFirst}) + { + seen.emplace(p); + } + BEAST_EXPECT(seen.size() == 2); + + // The other half of the invariant: equal elements must hash equally. + // STPathElement::operator== masks type_ down to the TypeAccount bit, so + // elements whose remaining type bits differ still compare equal -- + // hashing the full type_ would give them distinct hashes and silently + // defeat deduplication. + static constexpr AccountID kAccount{"A3F19C7B2E5D08146FB93A7C0E2D5184BC6F3A09"}; + static constexpr AccountID kIssuer{"1D7E4B90C2A6F3851E0B9D47A2C5F8136E0A4B7D"}; + + auto const equivalent = std::vector>{ + // forceAsset toggles TypeCurrency on an XRP asset. + {STPathElement{kAccount, PathAsset{xrpCurrency()}, kIssuer, true}, + STPathElement{kAccount, PathAsset{xrpCurrency()}, kIssuer, false}}, + // An explicit type mask vs. one derived from the populated fields. + {STPathElement{STPathElement::TypeAccount, kAccount, xrpCurrency(), kIssuer}, + STPathElement{kAccount, PathAsset{xrpCurrency()}, kIssuer, false}}, + }; + + for (auto const& [lhs, rhs] : equivalent) + { + BEAST_EXPECT(lhs.getNodeType() != rhs.getNodeType()); + BEAST_EXPECT(lhs == rhs); + + auto const lhsPath = path({lhs}); + auto const rhsPath = path({rhs}); + BEAST_EXPECT(hasher(lhsPath) == hasher(rhsPath)); + + auto equal = hardened_hash_set{}; + equal.emplace(lhsPath); + equal.emplace(rhsPath); + BEAST_EXPECT(equal.size() == 1); + } + } + + void + testDeserializationPreservesDuplicates() + { + testcase("STPathSet deserialization preserves duplicate paths"); + + // The `Paths` field of a signed transaction must round-trip byte for + // byte. The deduplication index exists solely for pathfinding, so the + // deserializing constructor must never engage it: collapsing duplicates + // on parse would silently change the signed content of a transaction. + + static constexpr AccountID kAccountID1{"A3F19C7B2E5D08146FB93A7C0E2D5184BC6F3A09"}; + static constexpr AccountID kAccountID2{"1D7E4B90C2A6F3851E0B9D47A2C5F8136E0A4B7D"}; + + auto const element = + STPathElement{kAccountID1, PathAsset{xrpCurrency()}, kAccountID2, true}; + + auto path = STPath{}; + path.pushBack(element); + + static constexpr auto kDuplicates = 64uz; + + auto original = STPathSet{sfPaths}; + for (auto i = 0uz; i < kDuplicates; ++i) + { + original.pushBack(path); + } + + // No index was requested, so nothing is deduplicated on the way in. + BEAST_EXPECT(original.size() == kDuplicates); + + auto s = Serializer{}; + original.add(s); + + auto sit = SerialIter{s.slice()}; + auto const parsed = STPathSet{sit, sfPaths}; + + // The duplicates survive the round trip... + BEAST_EXPECT(parsed.size() == kDuplicates); + BEAST_EXPECT(parsed.isEquivalent(original)); + + // ...and re-serializing reproduces the original bytes exactly. + auto serialized = Serializer{}; + parsed.add(serialized); + BEAST_EXPECT(serialized.getData() == s.getData()); + + // A parsed set holds no index, so appending to it stays append-only. + auto appended = parsed; + appended.pushBack(path); + BEAST_EXPECT(appended.size() == kDuplicates + 1); + } + void run() override { @@ -2031,6 +2245,9 @@ public: issuesPathNegativeRippleClientIssue23Larger(); qualityPathsQualitySetAndTest(); testAssembleAddDeduplication(); + testPushBackDeduplication(); + testPathHashInjectivity(); + testDeserializationPreservesDuplicates(); trustAutoClearTrustNormalClear(); trustAutoClearTrustAutoClear(); norippleCombinations(); diff --git a/src/test/app/invariants/InvariantsMPT_test.cpp b/src/test/app/invariants/InvariantsMPT_test.cpp index 4692463baa..91984f2021 100644 --- a/src/test/app/invariants/InvariantsMPT_test.cpp +++ b/src/test/app/invariants/InvariantsMPT_test.cpp @@ -967,6 +967,75 @@ class InvariantsMPT_test : public InvariantsBase }); } + // LoanSet / VaultWithdraw MayAuthorizeMpt caps (fixCleanup3_4_0): + // LoanSet allows at most two creates and no deletes; VaultWithdraw + // allows at most one of each. Fabricate one extra mutation so a + // too-loose cap would miss these. + { + auto const insertHolderTokens = + [](Account const& issuer, Account const& holder, ApplyContext& ac, int n) { + auto const sle = ac.view().peek(keylet::account(issuer.id())); + if (!sle) + return false; + auto seq = sle->getFieldU32(sfSequence); + for (int i = 0; i < n; ++i) + { + MPTIssue const mpt{makeMptID(seq + i, issuer)}; + auto sleNew = + std::make_shared(keylet::mptoken(mpt.getMptID(), holder)); + (*sleNew)[sfAccount] = holder.id(); + (*sleNew)[sfMPTokenIssuanceID] = mpt.getMptID(); + ac.view().insert(sleNew); + } + return true; + }; + + std::array, 2> const createOverCap{ + {{ttLOAN_SET, 3}, {ttVAULT_WITHDRAW, 2}}}; + for (auto const& [txnType, nTokens] : createOverCap) + { + doInvariantCheck( + {{"MPT authorize succeeded but created/deleted bad number mptokens"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + return insertHolderTokens(a1, a2, ac, nTokens); + }, + XRPAmount{}, + STTx{txnType, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + MPTID id; + auto const precloseTwoHolders = [&id](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw); + MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2}}); + id = mpt.issuanceID(); + return true; + }; + std::array, 2> const deleteOverCap{ + {{ttLOAN_SET, 1}, {ttVAULT_WITHDRAW, 2}}}; + for (auto const& [txnType, nTokens] : deleteOverCap) + { + doInvariantCheck( + {{"MPT authorize succeeded but created/deleted bad number mptokens"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + std::array const holders{a1, a2}; + for (int i = 0; i < nTokens; ++i) + { + auto sle = ac.view().peek(keylet::mptoken(id, holders[i])); + if (!sle) + return false; + ac.view().erase(sle); + } + return true; + }, + XRPAmount{}, + STTx{txnType, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseTwoHolders); + } + } + // sfReferenceHolding can only be set on creation by VaultCreate. A // non-VaultCreate transaction that creates an MPTokenIssuance with // sfReferenceHolding present must trip the invariant. diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp index dcf783a1b5..e264b91cb1 100644 --- a/src/test/app/invariants/InvariantsVault_test.cpp +++ b/src/test/app/invariants/InvariantsVault_test.cpp @@ -1224,33 +1224,51 @@ class InvariantsVault_test : public InvariantsBase // ttLOAN_PAY success post-conditions. A loan left with payments still // remaining after a successful payment must show that payment in its - // balance and schedule: PrincipalOutstanding and PaymentRemaining both - // strictly decrease, and NextPaymentDueDate advances by a positive - // multiple of PaymentInterval. Each case seeds the same loan, then applies + // balance and schedule: neither PrincipalOutstanding nor + // TotalValueOutstanding may increase, at least one of them must + // strictly decrease, PaymentRemaining must strictly decrease, and + // NextPaymentDueDate must advance by a positive multiple of + // PaymentInterval. Each failing case seeds the same loan, then applies // an after-image that breaks exactly one of those conditions. { struct Case { Number principal; + Number totalValue; std::uint32_t remaining; std::uint32_t dueDate; std::string expected; }; auto const cases = std::to_array({ {.principal = Number(100), + .totalValue = Number(150), .remaining = 1, .dueDate = 110, - .expected = "loan pay must strictly decrease PrincipalOutstanding"}, + .expected = "loan pay must decrease PrincipalOutstanding or " + "TotalValueOutstanding"}, + {.principal = Number(110), + .totalValue = Number(150), + .remaining = 1, + .dueDate = 110, + .expected = "loan pay must not increase PrincipalOutstanding"}, {.principal = Number(50), + .totalValue = Number(160), + .remaining = 1, + .dueDate = 110, + .expected = "loan pay must not increase TotalValueOutstanding"}, + {.principal = Number(50), + .totalValue = Number(150), .remaining = 2, .dueDate = 110, .expected = "loan pay must decrease PaymentRemaining"}, {.principal = Number(50), + .totalValue = Number(150), .remaining = 1, .dueDate = 100, .expected = "loan pay must advance NextPaymentDueDate"}, // Advanced, but not by a whole number of payment intervals. {.principal = Number(50), + .totalValue = Number(150), .remaining = 1, .dueDate = 105, .expected = "loan pay must advance NextPaymentDueDate"}, @@ -1291,6 +1309,7 @@ class InvariantsVault_test : public InvariantsBase if (!BEAST_EXPECT(sleLoan)) continue; sleLoan->at(sfPrincipalOutstanding) = c.principal; + sleLoan->at(sfTotalValueOutstanding) = c.totalValue; sleLoan->setFieldU32(sfPaymentRemaining, c.remaining); sleLoan->setFieldU32(sfNextPaymentDueDate, c.dueDate); ac.view().update(sleLoan); @@ -1303,6 +1322,65 @@ class InvariantsVault_test : public InvariantsBase BEAST_EXPECT(result == tecINVARIANT_FAILED); BEAST_EXPECT(sink.messages().str().contains(c.expected)); } + + // Principal may stick while TotalValueOutstanding falls. This + // after-image is only a Loan mutation, so other (vault) invariants + // still fail under Full scope; ValidLoan itself must not. + { + Env env{*this, all_}; + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + auto const keys = createClosedXrpBroker(a1, env); + if (!keys) + { + fail(); + } + else + { + auto const& brokerKeylet = keys->second; + OpenView ov{*env.current()}; + auto const loanKeylet = + keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + { + auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id()); + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(150); + sleLoan->at(sfPaymentInterval) = 10u; + sleLoan->setFieldU32(sfPaymentRemaining, 2); + sleLoan->setFieldU32(sfNextPaymentDueDate, 100); + ov.rawInsert(sleLoan); + } + + STTx const tx{ + ttLOAN_PAY, [](STObject& t) { t.setFieldAmount(sfAmount, XRPAmount(50)); }}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + auto sleLoan = ac.view().peek(loanKeylet); + if (BEAST_EXPECT(sleLoan)) + { + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(140); + sleLoan->setFieldU32(sfPaymentRemaining, 1); + sleLoan->setFieldU32(sfNextPaymentDueDate, 110); + ac.view().update(sleLoan); + + auto transactor = makeTransactor(ac); + if (BEAST_EXPECT(transactor)) + { + std::ignore = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + auto const logs = sink.messages().str(); + BEAST_EXPECT(!logs.contains("Invariant failed: Loan")); + BEAST_EXPECT(!logs.contains("loan pay")); + } + } + } + } } // ttLOAN_MANAGE (default): the write-off is rounded downward at the diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp index 96adfd5254..c509469de9 100644 --- a/src/test/app/lending/LendingHelpers_test.cpp +++ b/src/test/app/lending/LendingHelpers_test.cpp @@ -1482,9 +1482,9 @@ class LendingHelpers_test : public beast::unit_test::Suite } void - testAccrualLoanOriginationDeltas() + testInstantRecognitionLoanOriginationDeltas() { - using namespace xrpl::accrual; + using namespace xrpl::instant_recognition; struct TestCase { @@ -1504,7 +1504,7 @@ class LendingHelpers_test : public beast::unit_test::Suite for (auto const& tc : testCases) { - testcase("accrual::loanOriginationDeltas: " + tc.name); + testcase("instant_recognition::loanOriginationDeltas: " + tc.name); auto const deltas = loanOriginationDeltas(tc.principalRequested, tc.interestDue); BEAST_EXPECTS( @@ -1540,9 +1540,9 @@ class LendingHelpers_test : public beast::unit_test::Suite } void - testAccrualLoanOriginationExceedsVaultMaximum() + testInstantRecognitionLoanOriginationExceedsVaultMaximum() { - using namespace xrpl::accrual; + using namespace xrpl::instant_recognition; struct TestCase { @@ -1578,7 +1578,7 @@ class LendingHelpers_test : public beast::unit_test::Suite for (auto const& tc : testCases) { - testcase("accrual::loanOriginationExceedsVaultMaximum: " + tc.name); + testcase("instant_recognition::loanOriginationExceedsVaultMaximum: " + tc.name); BEAST_EXPECT( loanOriginationExceedsVaultMaximum( tc.vaultMaximum, tc.vaultTotal, tc.interestDue) == tc.expected); @@ -1620,12 +1620,12 @@ class LendingHelpers_test : public beast::unit_test::Suite } void - testAccrualLoanVaultExposure() + testInstantRecognitionLoanVaultExposure() { - testcase("accrual::loanVaultExposure"); + testcase("instant_recognition::loanVaultExposure"); auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); - BEAST_EXPECT(xrpl::accrual::loanVaultExposure(sle) == Number{950}); + BEAST_EXPECT(xrpl::instant_recognition::loanVaultExposure(sle) == Number{950}); } void @@ -1642,7 +1642,7 @@ class LendingHelpers_test : public beast::unit_test::Suite { // principalPaid, interestPaid, feePaid, valueChange are all distinct // and nonzero, with a nonzero valueChange simulating a late-payment - // penalty, so Accrual's formula is meaningfully exercised. + // penalty, so InstantRecognition's formula is meaningfully exercised. LoanPaymentParts const parts{ .principalPaid = Number{100}, .interestPaid = Number{20}, @@ -1650,8 +1650,8 @@ class LendingHelpers_test : public beast::unit_test::Suite .feePaid = Number{3}}; { - testcase("accrual::loanPaymentDeltas: nonzero valueChange"); - auto const deltas = xrpl::accrual::loanPaymentDeltas(parts); + testcase("instant_recognition::loanPaymentDeltas: nonzero valueChange"); + auto const deltas = xrpl::instant_recognition::loanPaymentDeltas(parts); BEAST_EXPECT(deltas.assetsTotalDelta == parts.valueChange); BEAST_EXPECT( deltas.debtTotalDelta == @@ -1680,11 +1680,11 @@ class LendingHelpers_test : public beast::unit_test::Suite { testcase( "loanOriginationDeltas dispatcher: amendment enabled, legacy vault picks " - "Accrual"); + "InstantRecognition"); Env const env{*this}; auto const deltas = loanOriginationDeltas(legacyVault, principalRequested, interestDue); auto const expected = - xrpl::accrual::loanOriginationDeltas(principalRequested, interestDue); + xrpl::instant_recognition::loanOriginationDeltas(principalRequested, interestDue); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); } @@ -1709,7 +1709,7 @@ class LendingHelpers_test : public beast::unit_test::Suite Number const vaultMaximum{1'000}; Number const vaultTotal{900}; - // Exceeds Accrual's headroom (100), but must never trip CashBasis. + // Exceeds InstantRecognition's headroom (100), but must never trip CashBasis. Number const interestDue{101}; auto const legacyVault = makeVaultSle(std::nullopt, vaultMaximum, vaultTotal); @@ -1718,11 +1718,11 @@ class LendingHelpers_test : public beast::unit_test::Suite { testcase( "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, legacy vault " - "picks Accrual"); + "picks InstantRecognition"); Env const env{*this}; BEAST_EXPECT( loanOriginationExceedsVaultMaximum(legacyVault, vaultTotal, interestDue) == - xrpl::accrual::loanOriginationExceedsVaultMaximum( + xrpl::instant_recognition::loanOriginationExceedsVaultMaximum( vaultMaximum, vaultTotal, interestDue)); } @@ -1746,11 +1746,14 @@ class LendingHelpers_test : public beast::unit_test::Suite auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); { - testcase("loanVaultExposure dispatcher: amendment enabled, legacy vault picks Accrual"); + testcase( + "loanVaultExposure dispatcher: amendment enabled, legacy vault picks " + "InstantRecognition"); Env const env{*this}; auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); BEAST_EXPECT( - loanVaultExposure(legacyVault, sle) == xrpl::accrual::loanVaultExposure(sle)); + loanVaultExposure(legacyVault, sle) == + xrpl::instant_recognition::loanVaultExposure(sle)); } { @@ -1780,10 +1783,12 @@ class LendingHelpers_test : public beast::unit_test::Suite auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); { - testcase("loanPaymentDeltas dispatcher: amendment enabled, legacy vault picks Accrual"); + testcase( + "loanPaymentDeltas dispatcher: amendment enabled, legacy vault picks " + "InstantRecognition"); Env const env{*this}; auto const deltas = loanPaymentDeltas(legacyVault, parts); - auto const expected = xrpl::accrual::loanPaymentDeltas(parts); + auto const expected = xrpl::instant_recognition::loanPaymentDeltas(parts); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); } @@ -1999,10 +2004,10 @@ public: testComputeInterestAndFeeParts(); testCanApplyToBrokerCover(); - testAccrualLoanOriginationDeltas(); + testInstantRecognitionLoanOriginationDeltas(); testCashBasisLoanOriginationDeltas(); - testAccrualLoanOriginationExceedsVaultMaximum(); - testAccrualLoanVaultExposure(); + testInstantRecognitionLoanOriginationExceedsVaultMaximum(); + testInstantRecognitionLoanVaultExposure(); testCashBasisLoanVaultExposure(); testLoanPaymentDeltas(); testLoanOriginationDeltasDispatcher(); diff --git a/src/test/app/lending/LoanCashBasis_test.cpp b/src/test/app/lending/LoanCashBasis_test.cpp index e238838306..f9f5948db9 100644 --- a/src/test/app/lending/LoanCashBasis_test.cpp +++ b/src/test/app/lending/LoanCashBasis_test.cpp @@ -39,13 +39,13 @@ namespace xrpl::test { // amendment. They are called once, directly, from // runAmendmentIndependent() -- not looped through // runAmendmentSensitive()/amendmentCombinations(), since doing so would -// require re-deriving whole-life-specific expected values for ~15 +// require re-deriving instant-recognition-specific expected values for ~15 // unrelated regression tests. class LoanCashBasis_test : public LoanTestBase { private: // 1. LoanSet origination: Vault.AssetsTotal/LoanBroker.DebtTotal deltas, - // and the AssetsMaximum/DebtMaximum guards. Accrual AssetsMaximum still + // and the AssetsMaximum/DebtMaximum guards. Instant-recognition AssetsMaximum still // requires headroom for interestDue; cash-basis AssetsMaximum does not, // because origination does not credit interest into AssetsTotal. void @@ -147,16 +147,16 @@ private: BEAST_EXPECTS( assetsTotalDelta == interestDue, - "whole-life origination must add interestDue to AssetsTotal; delta=" + + "instant-recognition origination must add interestDue to AssetsTotal; delta=" + to_string(assetsTotalDelta) + " interestDue=" + to_string(interestDue)); BEAST_EXPECTS( debtTotalDelta == principalOutstanding + interestDue, - "whole-life origination must add principal+interest to DebtTotal; delta=" + + "instant-recognition origination must add principal+interest to DebtTotal; delta=" + to_string(debtTotalDelta)); } // AssetsMaximum guard checks interestDue headroom only under - // whole-life accounting; DebtMaximum guard also varies by model. + // instant interest recognition; DebtMaximum guard also varies by model. auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) { Env env(*this, features); @@ -217,7 +217,8 @@ private: Number const oneDrop = xrpAsset(1).value(); { - testcase("whole-life: LoanSet AssetsMaximum guard checks interestDue headroom"); + testcase( + "instant-recognition: LoanSet AssetsMaximum guard checks interestDue headroom"); // Guard rejects when there's not quite enough headroom for the // interest. runVaultGuard(all_, interestDueCash - oneDrop, tecLIMIT_EXCEEDED); @@ -230,19 +231,19 @@ private: // Even far less headroom than interestDue still succeeds, since // cash-basis origination never adds interest to AssetsTotal. runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS); - // Fully subscribed: AssetsTotal == AssetsMaximum. Accrual preclaim + // Fully subscribed: AssetsTotal == AssetsMaximum. Instant-recognition preclaim // used to refuse this; origination must still succeed because it // does not change AssetsTotal. runVaultGuard(all_ | featureLendingProtocolV1_1, Number{0}, tesSUCCESS); } // DebtMaximum guard: cash-basis projects principal-only DebtTotal; - // whole-life projects principal + interestDue. + // instant recognition projects principal + interestDue. for (auto const cashBasis : {true, false}) { testcase( std::string("LoanSet DebtMaximum guard (") + - (cashBasis ? "cash-basis)" : "whole-life)")); + (cashBasis ? "cash-basis)" : "instant-recognition)")); auto const features = cashBasis ? all_ | featureLendingProtocolV1_1 : all_; Number const newDebtTotal = principalOutstandingCash + (cashBasis ? Number{} : interestDueCash); @@ -254,9 +255,9 @@ private: // 2. LoanPay: regular, late, overpayment, and full-payment types. // Assert Vault.AssetsTotal/LoanBroker.DebtTotal deltas match // interestPaid/principalPaid under cash-basis, and cross-check the - // amendment-disabled run's deltas against the documented whole-life + // amendment-disabled run's deltas against the documented instant-recognition // formula (AssetsTotal += valueChange; DebtTotal mirrors the loan's own - // TotalValueOutstanding delta exactly, since whole-life debt recognition + // TotalValueOutstanding delta exactly, since instant-recognition debt recognition // tracks total loan value). void testCashBasisLoanPay() @@ -360,7 +361,7 @@ private: .totalValueDelta = totalValueAfter - totalValueBefore}; }; - // Compares the disabled (whole-life) and enabled (cash-basis) runs + // Compares the disabled (instant-recognition) and enabled (cash-basis) runs // of the same payment scenario, and asserts the documented // relationships between them. auto checkScenario = [&](std::string const& label, @@ -381,12 +382,12 @@ private: // does. BEAST_EXPECTS( off.debtTotalDelta == off.totalValueDelta, - "whole-life DebtTotal delta must mirror TotalValueOutstanding delta; " + "instant-recognition DebtTotal delta must mirror TotalValueOutstanding delta; " "debtTotalDelta=" + to_string(off.debtTotalDelta) + " totalValueDelta=" + to_string(off.totalValueDelta)); - // Derive interestPaid from the whole-life run's independent + // Derive interestPaid from the instant-recognition run's independent // ledger deltas: // assetsTotalDelta_off == valueChange // debtTotalDelta_off == valueChange - (principalPaid + interestPaid) @@ -423,10 +424,11 @@ private: // Regular, on-time payments never change the loan's value beyond // normal amortization (production asserts valueChange == 0), so - // AssetsTotal must be unaffected in the whole-life run. + // AssetsTotal must be unaffected in the instant-recognition run. BEAST_EXPECTS( off.assetsTotalDelta == beast::kZero, - "regular on-time payment must not change AssetsTotal under whole-life; delta=" + + "regular on-time payment must not change AssetsTotal under instant recognition; " + "delta=" + to_string(off.assetsTotalDelta)); checkScenario("regular payment", off, on); @@ -932,14 +934,15 @@ private: } // 3b. LEVersion regression: a Vault created before featureLendingProtocolV1_1 - // activates (LEVersion absent) must keep whole-life (accrual) accounting + // activates (LEVersion absent) must keep instant interest recognition // forever, even after the amendment is later enabled -- the switch is // per-Vault (LEVersion == VaultVersion::CashBasis), not a single global amendment // flag. void - testLegacyVaultKeepsAccrualAfterAmendmentEnabled() + testLegacyVaultKeepsInstantRecognitionAfterAmendmentEnabled() { - testcase("LEVersion: legacy vault keeps accrual after amendment enabled"); + testcase( + "LEVersion: legacy vault keeps instant interest recognition after amendment enabled"); using namespace jtx; using namespace loan; @@ -977,7 +980,7 @@ private: } // Now enable the amendment -- production dispatch must still treat - // this specific Vault as accrual-basis, since its LEVersion is + // this specific Vault as instant interest recognition, since its LEVersion is // (and remains) absent. env.enableFeature(featureLendingProtocolV1_1); env.close(); @@ -997,7 +1000,7 @@ private: auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); - // ---- LoanSet origination: whole-life formulas expected ---- + // ---- LoanSet origination: instant-recognition formulas expected ---- auto const vaultBeforeSet = env.le(broker.vaultKeylet()); auto const brokerBeforeSet = env.le(broker.brokerKeylet()); BEAST_EXPECT(vaultBeforeSet && brokerBeforeSet); @@ -1034,7 +1037,7 @@ private: LoanState const state = getCurrentState(env, broker, loanKeylet); env.close(); - // ---- LoanPay: whole-life formulas expected ---- + // ---- LoanPay: instant-recognition formulas expected ---- auto const vaultBeforePay = env.le(broker.vaultKeylet()); auto const brokerBeforePay = env.le(broker.brokerKeylet()); auto const loanBeforePay = env.le(loanKeylet); @@ -1059,7 +1062,7 @@ private: Number(brokerAfterPay->at(sfDebtTotal)) - debtTotalBeforePay; Number const totalValueDeltaPay = totalValueAfterPay - totalValueBeforePay; - // A regular, on-time payment has valueChange == 0, so whole-life + // A regular, on-time payment has valueChange == 0, so instant-recognition // AssetsTotal is untouched and DebtTotal mirrors TotalValueOutstanding. BEAST_EXPECTS( assetsTotalDeltaPay == beast::kZero, @@ -1071,7 +1074,7 @@ private: "debtTotalDelta=" + to_string(debtTotalDeltaPay) + " totalValueDelta=" + to_string(totalValueDeltaPay)); - // ---- LoanManage: impair, then default -- whole-life exposure expected ---- + // ---- LoanManage: impair, then default -- instant-recognition exposure expected ---- auto const loanBeforeImpair = env.le(loanKeylet); BEAST_EXPECT(loanBeforeImpair); Number const totalValueBeforeImpair = loanBeforeImpair->at(sfTotalValueOutstanding); @@ -1110,7 +1113,7 @@ private: BEAST_EXPECTS( debtTotalDeltaDefault == -expectedExposure, - "legacy vault default must reduce DebtTotal by whole-life exposure; delta=" + + "legacy vault default must reduce DebtTotal by instant-recognition exposure; delta=" + to_string(debtTotalDeltaDefault) + " expected=" + to_string(expectedExposure)); BEAST_EXPECTS( lossDeltaDefault == -expectedExposure, @@ -1131,7 +1134,7 @@ private: // entirely under the amendment, with independently hand-computed // expected AssetsTotal/DebtTotal/LossUnrealized/CoverAvailable values at // each step. 0% interest keeps the arithmetic exact and tractable; the - // divergence from whole-life accounting is already covered directly by + // divergence from instant interest recognition is already covered directly by // testCashBasisLoanSetOrigination/LoanPay/LoanManage above, so this test // focuses purely on an independent, from-scratch trajectory check. void @@ -1270,7 +1273,7 @@ public: testVaultSetWhileAssetsTotalExceedsMaximum(); testCashBasisLoanSetAfterInterestExceedsCap(); testCashBasisLoanManage(); - testLegacyVaultKeepsAccrualAfterAmendmentEnabled(); + testLegacyVaultKeepsInstantRecognitionAfterAmendmentEnabled(); testCashBasisEndToEndTrajectory(); } }; diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp index ded1c816a2..4a2063ed77 100644 --- a/src/test/app/lending/LoanRounding_test.cpp +++ b/src/test/app/lending/LoanRounding_test.cpp @@ -415,6 +415,9 @@ private: // The test pays one period at a time across three LoanPay // transactions and verifies the loan completes (paymentRemaining=0) // with totals matching the loan's economics (1 principal + 2 interest). + // Also run under featureLendingProtocolV1_1: ValidLoan must allow the + // two sticking pays (TVO falls, PO does not) and the final clear + // (PaymentRemaining 0, NextPaymentDueDate 0). void testIntegerScalePrincipalSticks(FeatureBitset features) { @@ -446,28 +449,19 @@ private: env(pay(issuer, borrower, asset(10'000))); env.close(); - Vault const vault{env}; - auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); - env(vaultTx); - env.close(); + // createVaultAndBroker promotes the vault to ClosedEnded under + // featureLendingProtocolV1_1 (LoanBrokerSet rejects open-ended). + BrokerParameters const params{ + .vaultDeposit = Number{5'000}, + .debtMax = Number{100}, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + BrokerInfo const broker = createVaultAndBroker(env, asset, lender, params); - env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)})); - env.close(); - - auto const brokerKeylet = - keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender))); - env(loan_broker::set(lender, vaultKeylet.key), - loan_broker::kDebtMaximum(Number{100}), - Fee(env.current()->fees().base * 2)); - env.close(); - - auto const brokerStateBefore = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerStateBefore)) - return; - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(loanSequence)); - - env(loan::set(borrower, brokerKeylet.key, Number{1}), + auto const loanKeylet = nextLoanKeylet(env, broker); + env(loan::set(borrower, broker.brokerID, Number{1}), Sig(sfCounterpartySignature, lender), loan::kInterestRate(TenthBips32{50'000}), loan::kPaymentTotal(3), @@ -499,6 +493,8 @@ private: BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]); BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]); BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]); + if (expectedRemaining[i] == 0) + BEAST_EXPECT(sle->at(~sfNextPaymentDueDate).value_or(0) == 0); } // Borrower paid 3 total regardless of fee split (1 principal + 2 @@ -1226,6 +1222,9 @@ private: testBugVaultWithdrawDustVsAssetsTotal(all_ - fixCleanup3_4_0); testBugVaultWithdrawDustVsAssetsTotal(all_); testBugInterestDueDeltaCrash(); + // all_ excludes V1.1; amendmentCombinations never pairs it with the + // sticking schedule. Run that combination explicitly. + testIntegerScalePrincipalSticks(all_ | featureLendingProtocolV1_1); } // Tests run under each entry in amendmentCombinations(). diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index 5eea6f83fe..469c1662ad 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -597,6 +598,105 @@ private: nullptr); } + void + testLoanSetOriginationFeeTwoMptCreates(FeatureBitset features) + { + using namespace jtx; + using namespace loan; + + bool const fix340Enabled = features[fixCleanup3_4_0]; + testcase << "LoanSet: borrower and broker owner missing MPToken" + << (fix340Enabled ? "" : " pre-fixCleanup3_4_0"); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + Env env(*this, features); + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); + env.close(); + PrettyAsset const asset = mptt.issuanceID(); + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + env.close(); + + env(pay(issuer, lender, asset(10'000'000))); + env.close(); + + auto const broker = createVaultAndBroker(env, asset, lender); + + // Delete borrower's asset MPToken. + mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize}); + env.close(); + + // Pay out and delete the broker owner's asset MPToken. + auto const lenderMPToken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleLenderMPT = env.le(lenderMPToken); + if (!BEAST_EXPECT(sleLenderMPT)) + return; + env(pay(lender, issuer, asset(sleLenderMPT->at(sfMPTAmount)))); + env.close(); + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); + + auto const borrowerMPToken = keylet::mptoken(mptt.issuanceID(), borrower); + auto const brokerKeylet = keylet::loanBroker(broker.brokerID); + auto const sleBrokerBefore = env.le(brokerKeylet); + if (!BEAST_EXPECT(sleBrokerBefore)) + return; + auto const loanSequence = sleBrokerBefore->at(sfLoanSequence); + auto const debtTotalBefore = sleBrokerBefore->at(sfDebtTotal); + auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); + + auto const sleVaultBefore = env.le(keylet::vault(broker.vaultID)); + if (!BEAST_EXPECT(sleVaultBefore)) + return; + auto const assetsAvailableBefore = sleVaultBefore->at(sfAssetsAvailable); + + env(set(borrower, broker.brokerID, asset(1'000).value()), + kLoanOriginationFee(asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{fix340Enabled ? TER{tesSUCCESS} : TER{tecINVARIANT_FAILED}}); + env.close(); + + auto const sleBorrowerAfter = env.le(borrowerMPToken); + auto const sleLenderAfter = env.le(lenderMPToken); + auto const sleLoanAfter = env.le(loanKeylet); + auto const sleBrokerAfter = env.le(brokerKeylet); + auto const sleVaultAfter = env.le(keylet::vault(broker.vaultID)); + if (!BEAST_EXPECT(sleVaultAfter)) + return; + if (fix340Enabled) + { + if (!BEAST_EXPECT(sleBorrowerAfter && sleLenderAfter && sleLoanAfter && sleBrokerAfter)) + return; + BEAST_EXPECT(sleBorrowerAfter->at(sfMPTAmount) == 999); + BEAST_EXPECT(sleLenderAfter->at(sfMPTAmount) == 1); + BEAST_EXPECT(sleLoanAfter->at(sfPrincipalOutstanding) == Number{1'000}); + BEAST_EXPECT(sleBrokerAfter->at(sfLoanSequence) == loanSequence + 1); + BEAST_EXPECT( + sleVaultAfter->at(sfAssetsAvailable) == assetsAvailableBefore - Number{1'000}); + } + else + { + // The whole transaction must roll back. + BEAST_EXPECT(!sleBorrowerAfter); + BEAST_EXPECT(!sleLenderAfter); + BEAST_EXPECT(!sleLoanAfter); + if (!BEAST_EXPECT(sleBrokerAfter)) + return; + BEAST_EXPECT(sleBrokerAfter->at(sfLoanSequence) == loanSequence); + BEAST_EXPECT(sleBrokerAfter->at(sfDebtTotal) == debtTotalBefore); + BEAST_EXPECT(sleVaultAfter->at(sfAssetsAvailable) == assetsAvailableBefore); + } + } + // LoanSet in a closed-ended vault — phase gating and maturity bound. void testLoanSetClosedEnded() @@ -838,6 +938,8 @@ public: testLoanSetClosedEnded(); testLoanSetExistingLineAfterIssuerClearsDefaultRipple(); + testLoanSetOriginationFeeTwoMptCreates(all_); + testLoanSetOriginationFeeTwoMptCreates(all_ - fixCleanup3_4_0); } }; diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index 13dffb6b9e..ab74ab6811 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -85,7 +85,7 @@ protected: // // featureLendingProtocolV1_1 is excluded from the default set: it changes // Vault/LoanBroker accounting (AssetsTotal/DebtTotal/LossUnrealized), and - // most of this file's tests assert whole-life-specific expected values + // most of this file's tests assert instant-interest-recognition-specific expected values // for those fields. Tests that specifically exercise the amendment opt // it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`). FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1}; diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index cc30bd6091..43f2b0354c 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -1586,14 +1586,10 @@ private: // which for an integral MPT asset the destination check would reject if // it were reached. // - // ValidMPTIssuance is a separate checker and still runs. It only trips on - // the one arm that both creates and deletes an MPToken: Alice's last - // share with the asset MPToken missing, where addEmptyHolding creates the - // asset token while her share token is deleted (created + deleted > 1). - // Leftover shares with the token missing is create-only, and a last share - // with the token present is delete-only; neither exceeds one. Bob still - // owns shares throughout, so this is never the vault's final outstanding - // share. + // ValidMPTIssuance: pre-fixCleanup3_4_0, a VaultWithdraw that both + // creates and deletes an MPToken fails. Post-fixCleanup3_4_0 that is + // allowed. + // // Post-fixCleanup3_4_0, doWithdraw skips addEmptyHolding on a zero // payout and zeroDeltaIsLegitimate lets the vault-delta and diff --git a/src/test/app/vault/VaultClosedEnded_test.cpp b/src/test/app/vault/VaultClosedEnded_test.cpp index 5ed242f8a4..6909a1bffa 100644 --- a/src/test/app/vault/VaultClosedEnded_test.cpp +++ b/src/test/app/vault/VaultClosedEnded_test.cpp @@ -699,9 +699,9 @@ private: env.close(); // A real loan is originated during Investment (permitted only in this phase). Zero-interest - // one-payment schedule keeps AssetsTotal unchanged (both accrual and cash-basis - // accounting recognise no interest at origination); AssetsAvailable drops by the loan - // principal. + // one-payment schedule keeps AssetsTotal unchanged (both instant interest recognition and + // cash-basis accounting recognise no interest at origination); AssetsAvailable drops by + // the loan principal. env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), loan::kInterestRate(TenthBips32(0)), kGracePeriod(60), diff --git a/src/test/app/vault/VaultLifecycle_test.cpp b/src/test/app/vault/VaultLifecycle_test.cpp index ce91ca857a..8d7afe67f2 100644 --- a/src/test/app/vault/VaultLifecycle_test.cpp +++ b/src/test/app/vault/VaultLifecycle_test.cpp @@ -795,6 +795,86 @@ private: }, {.requireAuth = false}); + auto const redeemAllNoAssetMpt = [this](TER expected) { + return [this, expected]( + Env& env, + Account const&, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase << "MPT non-owner redeems all shares with no asset MPToken" + << (isTesSuccess(expected) ? "" : " pre-fixCleanup3_4_0"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + tx = vault.deposit( + {.depositor = depositor, + .id = keylet.key, + .amount = asset(1000)}); // all assets held by depositor + env(tx); + env.close(); + + auto const vaultSle = env.le(keylet); + if (!BEAST_EXPECT(vaultSle)) + return; + auto const shareMPTID = vaultSle->at(sfShareMPTID); + + // Depositor's asset MPToken balance is now zero; delete it. + mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize}); + env.close(); + + auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor); + + auto const shareKeylet = keylet::mptoken(shareMPTID, depositor.id()); + auto const sleShareBefore = env.le(shareKeylet); + if (!BEAST_EXPECT(sleShareBefore)) + return; + auto const shareAmountBefore = sleShareBefore->at(sfMPTAmount); + auto const assetsTotalBefore = vaultSle->at(sfAssetsTotal); + auto const assetsAvailableBefore = vaultSle->at(sfAssetsAvailable); + + // Redeeming ALL shares in one transaction both erases the + // now-empty share MPToken and re-creates the asset MPToken. + tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); + env(tx, Ter{expected}); + env.close(); + + auto const sleAsset = env.le(mptoken); + auto const sleShare = env.le(shareKeylet); + auto const vaultAfter = env.le(keylet); + if (!BEAST_EXPECT(vaultAfter)) + return; + if (isTesSuccess(expected)) + { + if (!BEAST_EXPECT(sleAsset)) + return; + BEAST_EXPECT(sleAsset->at(sfMPTAmount) == 1000); + BEAST_EXPECT(!sleShare); + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == beast::kZero); + BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == beast::kZero); + } + else + { + BEAST_EXPECT(!sleAsset); + if (!BEAST_EXPECT(sleShare)) + return; + BEAST_EXPECT(sleShare->at(sfMPTAmount) == shareAmountBefore); + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore); + BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == assetsAvailableBefore); + } + }; + }; + + testCase(redeemAllNoAssetMpt(tesSUCCESS), {.requireAuth = false}); + testCase( + redeemAllNoAssetMpt(tecINVARIANT_FAILED), + {.requireAuth = false, .features = testableAmendments() - fixCleanup3_4_0}); + auto const [acctReserve, incReserve] = [this]() -> std::pair { Env const env{*this, testableAmendments()}; return { diff --git a/src/test/basics/PerfLog_test.cpp b/src/test/basics/PerfLog_test.cpp index 29cde84a7e..146d973ef8 100644 --- a/src/test/basics/PerfLog_test.cpp +++ b/src/test/basics/PerfLog_test.cpp @@ -1,9 +1,7 @@ #include -#include #include -#include - +#include #include #include #include @@ -16,6 +14,7 @@ #include #include +#include #include #include #include @@ -26,7 +25,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -42,6 +43,21 @@ class PerfLog_test : public beast::unit_test::Suite using path = std::filesystem::path; + // The method names to count. PerfLog treats them as opaque keys, so these are + // made up rather than taken from the dispatch table: this test then needs no + // knowledge of the RPC layer, and does not change shape when a method is + // added or removed. + // + // String literals because PerfLog reads them back as C strings, which is what + // NullTerminatedView requires, and they must outlive the PerfLog. Sorted, + // because the counters are reported in sorted order. + static constexpr std::array kMethodNames{ + NullTerminatedView{"method_a"}, + NullTerminatedView{"method_b"}, + NullTerminatedView{"method_c"}, + NullTerminatedView{"method_d"}, + NullTerminatedView{"method_e"}}; + // We're only using Env for its Journal. That Journal gives better // coverage in unit tests. test::jtx::Env env_{*this, test::jtx::envconfig(), nullptr, beast::Severity::Disabled}; @@ -114,7 +130,7 @@ class PerfLog_test : public beast::unit_test::Suite { perf::PerfLog::Setup const setup{ .perfLog = withFile == WithFile::No ? "" : logFile(), .logInterval = logInterval()}; - return perf::makePerfLog(setup, app, j, [this]() { + return perf::makePerfLog(setup, app, kMethodNames, j, [this]() { signalStop(); return; }); @@ -310,9 +326,11 @@ public: auto perfLog{fixture.perfLog(withFile)}; perfLog->start(); - // Get the all the labels we can use for RPC interfaces without - // causing an assert. - std::vector labels = test::jtx::makeVector(xrpl::rpc::getHandlerNames()); + // The only labels the RPC interface accepts: those the PerfLog was + // constructed with, since rpcStart() reaches UNREACHABLE for any other. + // Copied into a vector because they are shuffled below, then paired + // positionally with the request ids. + auto labels = std::ranges::to(kMethodNames); std::shuffle(labels.begin(), labels.end(), defaultPrng()); // Get two IDs to associate with each label. Errors tend to happen at @@ -347,7 +365,7 @@ public: for (auto& label : labels) { // Expect every label in labels to have the same contents. - json::Value const& counter{countersJson[label]}; + json::Value const& counter{countersJson[std::string{label}]}; BEAST_EXPECT(counter[jss::duration_us] == "0"); BEAST_EXPECT(counter[jss::errored] == "0"); BEAST_EXPECT(counter[jss::finished] == "0"); @@ -370,7 +388,7 @@ public: std::uint64_t prevDur = std::numeric_limits::max(); for (int i = 0; i < currents.size(); ++i) { - BEAST_EXPECT(currents[i].name == labels[i / 2]); + BEAST_EXPECT(currents[i].name == labels[i / 2].view()); BEAST_EXPECT(prevDur > currents[i].dur); prevDur = currents[i].dur; } @@ -404,7 +422,7 @@ public: // their durations with the appropriate labels. { // The first label is special. It should have "errored" : "0". - json::Value const& first = rpc[labels[0]]; + json::Value const& first = rpc[std::string{labels[0]}]; BEAST_EXPECT(first[jss::duration_us] != "0"); BEAST_EXPECT(first[jss::errored] == "0"); BEAST_EXPECT(first[jss::finished] == "1"); @@ -415,7 +433,7 @@ public: std::uint64_t prevDur = std::numeric_limits::max(); for (int i = 1; i < labels.size(); ++i) { - json::Value const& counter{rpc[labels[i]]}; + json::Value const& counter{rpc[std::string{labels[i]}]}; std::uint64_t const dur{jsonToUInt64(counter[jss::duration_us])}; BEAST_EXPECT(dur != 0 && dur < prevDur); prevDur = dur; @@ -447,7 +465,7 @@ public: BEAST_EXPECT(only.size() == 2); BEAST_EXPECT(only.isObject()); BEAST_EXPECT(only[jss::duration_us] != "0"); - BEAST_EXPECT(only[jss::method] == labels[0]); + BEAST_EXPECT(only[jss::method] == std::string{labels[0]}); }; // Validate the final state of the PerfLog. @@ -1014,6 +1032,34 @@ public: } } + // makePerfLog() copies the range of names it is given, so only the names have + // to outlive the PerfLog. Here the range does not: it is destroyed before the + // counters are read. Retaining it instead is a use-after-free that a + // sanitizer build reports and this test would otherwise pass through. + void + testCallerRangeNeedNotOutlive() + { + testcase("Caller's range need not outlive the PerfLog"); + + Fixture const fixture{env_.app(), j_}; + + std::unique_ptr perfLog; + { + std::vector const names{kMethodNames.begin(), kMethodNames.end()}; + perf::PerfLog::Setup const setup{.perfLog = "", .logInterval = fixture.logInterval()}; + perfLog = perf::makePerfLog(setup, env_.app(), names, j_, []() {}); + } + + perfLog->start(); + perfLog->rpcStart(kMethodNames[0], 1); + perfLog->rpcFinish(kMethodNames[0], 1); + + // Reads the retained names, which is where a dangling range would surface. + json::Value const counters{perfLog->countersJson()[jss::rpc]}; + BEAST_EXPECT(counters.isMember(std::string{kMethodNames[0].view()})); + perfLog->stop(); + } + void run() override { @@ -1026,6 +1072,7 @@ public: testInvalidID(WithFile::Yes); testRotate(WithFile::No); testRotate(WithFile::Yes); + testCallerRangeNeedNotOutlive(); } }; diff --git a/src/test/core/JobQueue_test.cpp b/src/test/core/JobQueue_test.cpp index c5ebfb3f82..08af95a091 100644 --- a/src/test/core/JobQueue_test.cpp +++ b/src/test/core/JobQueue_test.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include @@ -243,15 +244,15 @@ public: class SilentPerfLog : public perf::PerfLog { void - rpcStart(std::string const&, std::uint64_t) override + rpcStart(std::string_view, std::uint64_t) override { } void - rpcFinish(std::string const&, std::uint64_t) override + rpcFinish(std::string_view, std::uint64_t) override { } void - rpcError(std::string const&, std::uint64_t) override + rpcError(std::string_view, std::uint64_t) override { } void diff --git a/src/test/core/Workers_test.cpp b/src/test/core/Workers_test.cpp index 102f5df14d..95fa6e64be 100644 --- a/src/test/core/Workers_test.cpp +++ b/src/test/core/Workers_test.cpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace xrpl { @@ -21,17 +22,17 @@ namespace perf { class PerfLogTest : public PerfLog { void - rpcStart(std::string const& method, std::uint64_t requestId) override + rpcStart(std::string_view method, std::uint64_t requestId) override { } void - rpcFinish(std::string const& method, std::uint64_t requestId) override + rpcFinish(std::string_view method, std::uint64_t requestId) override { } void - rpcError(std::string const& method, std::uint64_t dur) override + rpcError(std::string_view method, std::uint64_t requestId) override { } diff --git a/src/test/jtx/ConfidentialTransfer.h b/src/test/jtx/ConfidentialTransfer.h index 02b2e8dccb..5c1b90328b 100644 --- a/src/test/jtx/ConfidentialTransfer.h +++ b/src/test/jtx/ConfidentialTransfer.h @@ -124,6 +124,87 @@ protected: return proof; } + // Forges a ConvertBack proof (compact sigma + single bulletproof) whose + // sigma component claims claimedBalance (which may be wrong) while binding + // to the real pedersen commitment and encrypted spending balance + // ciphertext already on the ledger. The bulletproof component is built + // from realBalance so it stays honest. + // mpt_get_convert_back_proof does not allow to build a proof whose amount + // exceeds the holder's claimed balance. + static Buffer + getForgedConvertBackProof( + test::jtx::MPTTester& mpt, + test::jtx::Account const& holder, + uint64_t claimedBalance, + uint64_t realBalance, + uint64_t amt, + Buffer const& pedersenCommitment, + Buffer const& encryptedSpendingBalance, + Buffer const& pcBlindingFactor, + uint256 const& contextHash) + { + if (pedersenCommitment.size() != kCompressedEcPointLength) + Throw("getForgedConvertBackProof: bad pedersenCommitment length"); + if (encryptedSpendingBalance.size() != kEcGamalEncryptedTotalLength) + { + Throw( + "getForgedConvertBackProof: bad encryptedSpendingBalance length"); + } + if (amt > realBalance) + Throw("getForgedConvertBackProof: amt exceeds realBalance"); + + auto* const ctx = mpt_secp256k1_context(); + auto const holderPubKey = requireOptional(mpt.getPubKey(holder), "Missing holder pubkey"); + auto const holderPrivKey = + requireOptional(mpt.getPrivKey(holder), "Missing holder privkey"); + + secp256k1_pubkey pkHolder; + if (secp256k1_ec_pubkey_parse( + ctx, &pkHolder, holderPubKey.data(), kCompressedEcPointLength) != 1) + Throw("Failed to parse holder's public key"); + + secp256k1_pubkey pcB; + if (secp256k1_ec_pubkey_parse( + ctx, &pcB, pedersenCommitment.data(), kCompressedEcPointLength) != 1) + Throw("Failed to parse pedersen commitment"); + + secp256k1_pubkey b1, b2; + if (secp256k1_ec_pubkey_parse( + ctx, &b1, encryptedSpendingBalance.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, + &b2, + encryptedSpendingBalance.data() + kCompressedEcPointLength, + kCompressedEcPointLength) != 1) + Throw("Failed to parse balance ciphertext"); + + Buffer sigmaProof(SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE); + if (secp256k1_compact_convertback_prove( + ctx, + sigmaProof.data(), + claimedBalance, + holderPrivKey.data(), + pcBlindingFactor.data(), + &pkHolder, + &b1, + &b2, + &pcB, + contextHash.data()) != 1) + Throw("Failed to generate convertback sigma proof"); + + auto const forgedBulletproof = + getForgedSingleBulletproof(realBalance - amt, pcBlindingFactor, contextHash); + + Buffer proof(kEcConvertBackProofLength); + std::memcpy(proof.data(), sigmaProof.data(), SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE); + std::memcpy( + proof.data() + SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE, + forgedBulletproof.data(), + kEcSingleBulletproofLength); + + return proof; + } + // Get a bad ciphertext with valid structure but cryptographic invalid for // testing purposes. For preflight test purposes. static Buffer const& @@ -347,6 +428,111 @@ protected: } }; + // Forges a ConfidentialMPTSend proof (compact sigma + double bulletproof) + // for setup.sendAmount against setup's real balance commitment/ciphertext. + // mpt_get_confidential_send_proof does not allow to build a proof whose amount + // exceeds the sender's claimed balance. + static Buffer + getForgedSendProof( + test::jtx::MPTTester& mpt, + test::jtx::Env& env, + test::jtx::Account const& sender, + test::jtx::Account const& dest, + ConfidentialSendSetup const& setup) + { + auto* const ctx = mpt_secp256k1_context(); + + secp256k1_pubkey c1; + std::vector c2Vec(setup.recipients.size()); + std::vector pkVec(setup.recipients.size()); + for (std::size_t i = 0; i < setup.recipients.size(); ++i) + { + auto const& r = setup.recipients[i]; + if (i == 0 && + secp256k1_ec_pubkey_parse( + ctx, &c1, r.encryptedAmount.data(), kCompressedEcPointLength) != 1) + Throw("Failed to parse C1"); + if (secp256k1_ec_pubkey_parse( + ctx, + &c2Vec[i], + r.encryptedAmount.data() + kCompressedEcPointLength, + kCompressedEcPointLength) != 1) + Throw("Failed to parse C2"); + if (secp256k1_ec_pubkey_parse( + ctx, &pkVec[i], r.publicKey.data(), kCompressedEcPointLength) != 1) + Throw("Failed to parse recipient pubkey"); + } + + secp256k1_pubkey pkSender, pcAmount, pcBalance, b1, b2; + if (secp256k1_ec_pubkey_parse( + ctx, &pkSender, setup.senderPubKey.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, &pcAmount, setup.amountCommitment.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, &pcBalance, setup.balanceCommitment.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, &b1, setup.prevEncryptedSpending.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, + &b2, + setup.prevEncryptedSpending.data() + kCompressedEcPointLength, + kCompressedEcPointLength) != 1) + Throw("Failed to parse commitments/ciphertext"); + + Buffer const senderPrivKey = + requireOptional(mpt.getPrivKey(sender), "Missing sender privkey"); + auto const ctxHash = getSendContextHash( + sender.id(), mpt.issuanceID(), env.seq(sender), dest.id(), setup.version); + + Buffer sigmaProof(SECP256K1_COMPACT_STANDARD_PROOF_SIZE); + if (secp256k1_compact_standard_prove( + ctx, + sigmaProof.data(), + setup.sendAmount, + setup.prevSpending, + setup.blindingFactor.data(), + senderPrivKey.data(), + setup.balanceBlindingFactor.data(), + setup.recipients.size(), + &c1, + c2Vec.data(), + pkVec.data(), + &pcAmount, + &pkSender, + &pcBalance, + &b1, + &b2, + ctxHash.data()) != 1) + Throw("Failed to generate sigma proof"); + + // Wraps (mod 2^64) for overdrafts, unlike the ledger's own homomorphic + // commitment subtraction (mod the curve order) — that mismatch is + // exactly what makes the forged proof fail verification. + // Computed without a wrapping `uint64` subtract: Clang UBSan treats + // unsigned overflow as fatal (see incrementConfidentialVersion). + std::uint64_t const remaining = setup.sendAmount <= setup.prevSpending + ? setup.prevSpending - setup.sendAmount + : ~setup.sendAmount + setup.prevSpending + 1; + + Buffer negAmountBf(kEcBlindingFactorLength); + Buffer remainingBf(kEcBlindingFactorLength); + secp256k1_mpt_scalar_negate(negAmountBf.data(), setup.amountBlindingFactor.data()); + secp256k1_mpt_scalar_add( + remainingBf.data(), setup.balanceBlindingFactor.data(), negAmountBf.data()); + + auto const forgedBulletproof = getForgedBulletproof( + {setup.sendAmount, remaining}, {setup.amountBlindingFactor, remainingBf}, ctxHash); + + Buffer combinedProof(kEcSendProofLength); + std::memcpy(combinedProof.data(), sigmaProof.data(), SECP256K1_COMPACT_STANDARD_PROOF_SIZE); + std::memcpy( + combinedProof.data() + SECP256K1_COMPACT_STANDARD_PROOF_SIZE, + forgedBulletproof.data(), + kEcDoubleBulletproofLength); + + return combinedProof; + } + // Helper that wraps the boilerplate setup: Env + MPT creation, funding, key // generation, and seeding each holder with a confidential balance. // The caller supplies the issuer and any number of holders. diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h index 801c3627b8..382a6fe333 100644 --- a/src/test/jtx/TestHelpers.h +++ b/src/test/jtx/TestHelpers.h @@ -43,7 +43,6 @@ #include #include #include -#include #include #include #include @@ -316,13 +315,6 @@ auto const kData = JTxFieldWrapper(sfData); auto const kAmount = JTxFieldWrapper(sfAmount); -template -auto -makeVector(Input const& input) -{ - return std::vector(std::ranges::begin(input), std::ranges::end(input)); -} - // Functions used in debugging json::Value getAccountOffers(Env& env, AccountID const& acct, bool current = false); diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index 48183c4a2f..512257cdc5 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -44,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -63,14 +64,23 @@ constexpr std::uint64_t kElGamalDecryptRangeHigh = 3000; * * @param opt The optional to unwrap. * @param what Description used in the thrown exception if opt is empty. + * @param loc The call site to report in the thrown exception, defaulting to + * the immediate caller. * @return A const reference to the contained value. */ template [[nodiscard]] T const& -requireValue(std::optional const& opt, char const* what) +requireValue( + std::optional const& opt, + char const* what, + std::source_location const& loc = std::source_location::current()) { if (!opt) - Throw(what); + { + Throw( + std::string(what) + " must be present (called from " + + std::string(loc.function_name()) + ")"); + } return *opt; } @@ -92,6 +102,50 @@ makePedersenParams(PedersenProofParams const& params) return res; } +/** + * @brief Sets sfAccount on jv to the given account. + * + * @param jv The JSON object to set the field on. + * @param account The account to set. Throws if not present. + * @return The resolved account. + */ +Account const& +setAccountField(json::Value& jv, std::optional const& account) +{ + Account const& act = requireValue(account, "account"); + jv[sfAccount] = act.human(); + return act; +} + +/** + * @brief Sets sfDestination on jv to the given account. + * + * @param jv The JSON object to set the field on. + * @param dest The destination account to set. Throws if not present. + * @return The resolved account. + */ +Account const& +setDestinationField(json::Value& jv, std::optional const& dest) +{ + Account const& act = requireValue(dest, "dest"); + jv[sfDestination] = act.human(); + return act; +} + +/** + * @brief Sets sfZKProof to the given proof if present, otherwise to a + * zero-filled placeholder of the given length. + * + * @param jv The JSON object to set the field on. + * @param proof The real proof to use, if generated. + * @param dummyLen The length of the placeholder buffer to use when proof is not set. + */ +void +setProofOrDummy(json::Value& jv, std::optional const& proof, std::size_t dummyLen) +{ + jv[sfZKProof.jsonName] = strHex(proof ? *proof : gMakeZeroBuffer(dummyLen)); +} + /** * @brief Looks up an account's key at a given key epoch. * @@ -1003,7 +1057,7 @@ MPTTester::getPedersenCommitment(std::uint64_t const amount, Buffer const& peder return buf; } -Buffer +std::optional MPTTester::getConvertBackProof( Account const& holder, std::uint64_t const amount, @@ -1015,13 +1069,13 @@ MPTTester::getConvertBackProof( auto const sleMptoken = env_.le(keylet::mptoken(issuanceID(), holder.id())); if (!sleMptoken || !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending)) - return gMakeZeroBuffer(kExpectedProofLength); + return std::nullopt; auto const holderPubKey = getPubKey(holder); auto const holderPrivKey = getPrivKey(holder); if (!holderPubKey || !holderPrivKey) - return gMakeZeroBuffer(kExpectedProofLength); + return std::nullopt; auto const pedersenParams = makePedersenParams(pcParams); Buffer proof(kExpectedProofLength); @@ -1033,7 +1087,7 @@ MPTTester::getConvertBackProof( amount, &pedersenParams, proof.data()) != 0) - return gMakeZeroBuffer(kExpectedProofLength); + return std::nullopt; return proof; } @@ -1044,33 +1098,32 @@ MPTTester::getEncryptedBalance(Account const& account, EncryptedBalanceType opti if (!id_) Throw("MPT has not been created"); - if (auto const sle = env_.le(keylet::mptoken(*id_, account.id()))) + auto const sle = env_.le(keylet::mptoken(*id_, account.id())); + if (!sle) + return {}; + + SField const* field = nullptr; + switch (option) { - if (option == holderEncryptedInbox && sle->isFieldPresent(sfConfidentialBalanceInbox)) - { - return Buffer( - (*sle)[sfConfidentialBalanceInbox].data(), - (*sle)[sfConfidentialBalanceInbox].size()); - } - if (option == holderEncryptedSpending && sle->isFieldPresent(sfConfidentialBalanceSpending)) - { - return Buffer( - (*sle)[sfConfidentialBalanceSpending].data(), - (*sle)[sfConfidentialBalanceSpending].size()); - } - if (option == issuerEncryptedBalance && sle->isFieldPresent(sfIssuerEncryptedBalance)) - { - return Buffer( - (*sle)[sfIssuerEncryptedBalance].data(), (*sle)[sfIssuerEncryptedBalance].size()); - } - if (option == auditorEncryptedBalance && sle->isFieldPresent(sfAuditorEncryptedBalance)) - { - return Buffer( - (*sle)[sfAuditorEncryptedBalance].data(), (*sle)[sfAuditorEncryptedBalance].size()); - } + case holderEncryptedInbox: + field = &sfConfidentialBalanceInbox; + break; + case holderEncryptedSpending: + field = &sfConfidentialBalanceSpending; + break; + case issuerEncryptedBalance: + field = &sfIssuerEncryptedBalance; + break; + case auditorEncryptedBalance: + field = &sfAuditorEncryptedBalance; + break; } - return {}; + if (field == nullptr || !sle->isFieldPresent(*field)) + return {}; + + auto const blob = sle->getFieldVL(*field); + return Buffer(blob.data(), blob.size()); } std::uint32_t @@ -1087,6 +1140,33 @@ MPTTester::getFlags(std::optional const& holder) const return flags; } +void +MPTTester::setIssuanceIdField(json::Value& jv, std::optional const& id) const +{ + if (id) + { + jv[sfMPTokenIssuanceID] = to_string(*id); + } + else if (id_) + { + jv[sfMPTokenIssuanceID] = to_string(*id_); + } + else + { + Throw("MPT has not been created"); + } +} + +std::uint32_t +MPTTester::ticketOrSeq( + std::optional const& ticketSeq, + std::optional const& account) const +{ + if (ticketSeq) + return *ticketSeq; + return env_.seq(requireValue(account, "account")); +} + MPT MPTTester::operator[](std::string const& name) const { @@ -1104,47 +1184,37 @@ void MPTTester::fillConversionCiphertexts( T const& arg, json::Value& jv, - Buffer& holderCiphertext, - Buffer& issuerCiphertext, - std::optional& auditorCiphertext, - Buffer& blindingFactor) const + Account const& account, + std::uint64_t const amount) const { - blindingFactor = arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor(); + Buffer const blindingFactor = + arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor(); + jv[sfBlindingFactor.jsonName] = strHex(blindingFactor); // Handle Holder - if (arg.holderEncryptedAmt) - { - holderCiphertext = *arg.holderEncryptedAmt; - } - else - { - holderCiphertext = encryptAmount( - requireValue(arg.account, "account"), requireValue(arg.amt, "amt"), blindingFactor); - } + Buffer const holderCiphertext = arg.holderEncryptedAmt + ? *arg.holderEncryptedAmt + : encryptAmount(account, amount, blindingFactor); jv[sfHolderEncryptedAmount.jsonName] = strHex(holderCiphertext); // Handle Issuer - if (arg.issuerEncryptedAmt) - { - issuerCiphertext = *arg.issuerEncryptedAmt; - } - else - { - issuerCiphertext = encryptAmount(issuer_, requireValue(arg.amt, "amt"), blindingFactor); - } + Buffer const issuerCiphertext = arg.issuerEncryptedAmt + ? *arg.issuerEncryptedAmt + : encryptAmount(issuer_, amount, blindingFactor); jv[sfIssuerEncryptedAmount.jsonName] = strHex(issuerCiphertext); // Handle Auditor + std::optional auditorCiphertext; if (arg.auditorEncryptedAmt) { auditorCiphertext = *arg.auditorEncryptedAmt; } else if (auditor_.has_value() && arg.fillAuditorEncryptedAmt.value_or(false)) { - auditorCiphertext = encryptAmount( - requireValue(auditor_, "auditor"), requireValue(arg.amt, "amt"), blindingFactor); + auditorCiphertext = + encryptAmount(requireValue(auditor_, "auditor"), amount, blindingFactor); } // Update auditor JSON only if ciphertext exists @@ -1155,73 +1225,17 @@ MPTTester::fillConversionCiphertexts( void MPTTester::convert(MPTConvert const& arg) { - json::Value jv; - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } + json::Value const jv = convertJV(arg, ticketOrSeq(arg.ticketSeq, arg.account)); - jv[jss::TransactionType] = jss::ConfidentialMPTConvert; - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + Account const& account = requireValue(arg.account, "account"); + auto const amt = requireValue(arg.amt, "amt"); - if (arg.amt) - jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt); - if (arg.holderPubKey) - jv[sfHolderEncryptionKey.jsonName] = strHex(*arg.holderPubKey); - - Buffer holderCiphertext; - Buffer issuerCiphertext; - std::optional auditorCiphertext; - Buffer blindingFactor; - - fillConversionCiphertexts( - arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor); - - jv[sfBlindingFactor.jsonName] = strHex(blindingFactor); - if (arg.proof) - { - jv[sfZKProof.jsonName] = *arg.proof; - } - else if (arg.fillSchnorrProof.value_or(arg.holderPubKey.has_value())) - { - // whether to automatically generate and attach a Schnorr proof: - // if fillSchnorrProof is explicitly set, follow its value; - // otherwise, default to generating the proof only if holder pub key is - // present. - auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account)); - auto const contextHash = - getConvertContextHash(requireValue(arg.account, "account").id(), issuanceID(), seq); - - auto const proof = getSchnorrProof(*arg.account, contextHash); - if (proof) - { - jv[sfZKProof.jsonName] = strHex(*proof); - } - else - { - jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSchnorrProofLength)); - } - } - - auto const holderAmt = getBalance(*arg.account); + auto const holderAmt = getBalance(account); auto const prevConfidentialOutstanding = getIssuanceConfidentialBalance(); - auto const prevInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending); - auto const prevIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance); + auto const prevInboxBalance = getDecryptedBalance(account, holderEncryptedInbox); + auto const prevSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending); + auto const prevIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance); if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance) Throw("Failed to get Pre-convert balance"); @@ -1234,7 +1248,7 @@ MPTTester::convert(MPTConvert const& arg) std::optional prevAuditorBalance; if (hasAuditorAmt) { - prevAuditorBalance = getDecryptedBalance(*arg.account, auditorEncryptedBalance); + prevAuditorBalance = getDecryptedBalance(account, auditorEncryptedBalance); if (!prevAuditorBalance) Throw("Failed to get Pre-convert balance"); } @@ -1245,59 +1259,57 @@ MPTTester::convert(MPTConvert const& arg) { auto const postConfidentialOutstanding = getIssuanceConfidentialBalance(); auto const postOutstanding = getIssuanceOutstandingBalance(); - env_.require(MptBalance( - *this, requireValue(arg.account, "account"), holderAmt - requireValue(arg.amt, "amt"))); + env_.require(MptBalance(*this, account, holderAmt - amt)); env_.require(RequireAny([&]() -> bool { return prevOutstanding && postOutstanding && *prevOutstanding == *postOutstanding; })); env_.require(RequireAny([&]() -> bool { - return prevConfidentialOutstanding + *arg.amt == postConfidentialOutstanding; + return prevConfidentialOutstanding + amt == postConfidentialOutstanding; })); env_.require(RequireAny([&]() -> bool { - return getEncryptedBalance(*arg.account, holderEncryptedInbox).has_value(); + return getEncryptedBalance(account, holderEncryptedInbox).has_value(); })); env_.require(RequireAny([&]() -> bool { - return getEncryptedBalance(*arg.account, holderEncryptedSpending).has_value(); + return getEncryptedBalance(account, holderEncryptedSpending).has_value(); })); env_.require(RequireAny([&]() -> bool { - return getEncryptedBalance(*arg.account, issuerEncryptedBalance).has_value(); + return getEncryptedBalance(account, issuerEncryptedBalance).has_value(); })); - auto const postInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const postIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance); - auto const postSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending); + auto const postInboxBalance = getDecryptedBalance(account, holderEncryptedInbox); + auto const postIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance); + auto const postSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending); if (!postInboxBalance || !postIssuerBalance || !postSpendingBalance) Throw("Failed to get post-convert balance"); if (hasAuditorAmt) { - auto const postAuditorBalance = - getDecryptedBalance(*arg.account, auditorEncryptedBalance); + auto const postAuditorBalance = getDecryptedBalance(account, auditorEncryptedBalance); if (!postAuditorBalance) Throw("Failed to get post-convert auditor balance"); env_.require(RequireAny([&]() -> bool { - return getEncryptedBalance(*arg.account, auditorEncryptedBalance).has_value(); + return getEncryptedBalance(account, auditorEncryptedBalance).has_value(); })); // auditor's encrypted balance is updated correctly env_.require(RequireAny( - [&]() -> bool { return *prevAuditorBalance + *arg.amt == *postAuditorBalance; })); + [&]() -> bool { return *prevAuditorBalance + amt == *postAuditorBalance; })); } // spending balance should not change env_.require( RequireAny([&]() -> bool { return *postSpendingBalance == *prevSpendingBalance; })); // issuer's encrypted balance is updated correctly - env_.require(RequireAny( - [&]() -> bool { return *prevIssuerBalance + *arg.amt == *postIssuerBalance; })); + env_.require( + RequireAny([&]() -> bool { return *prevIssuerBalance + amt == *postIssuerBalance; })); // holder's inbox balance is updated correctly - env_.require(RequireAny( - [&]() -> bool { return *prevInboxBalance + *arg.amt == *postInboxBalance; })); + env_.require( + RequireAny([&]() -> bool { return *prevInboxBalance + amt == *postInboxBalance; })); // sum of holder's inbox and spending balance should equal to issuer's // encrypted balance @@ -1312,7 +1324,7 @@ MPTTester::convert(MPTConvert const& arg) [&](SLEP const& sle) -> bool { if (sle) { - auto const holderPubKey = getPubKey(*arg.account); + auto const holderPubKey = getPubKey(account); if (!holderPubKey) { Throw( @@ -1324,7 +1336,7 @@ MPTTester::convert(MPTConvert const& arg) } return false; }, - arg.account); + account); })); } } @@ -1334,41 +1346,17 @@ json::Value MPTTester::convertJV(MPTConvert const& arg, std::uint32_t seq) { json::Value jv; - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } + Account const& account = setAccountField(jv, arg.account); jv[jss::TransactionType] = jss::ConfidentialMPTConvert; - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + setIssuanceIdField(jv, arg.id); - if (arg.amt) - jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt); + auto const amt = requireValue(arg.amt, "amt"); + jv[sfMPTAmount.jsonName] = std::to_string(amt); if (arg.holderPubKey) jv[sfHolderEncryptionKey.jsonName] = strHex(*arg.holderPubKey); - Buffer holderCiphertext; - Buffer issuerCiphertext; - std::optional auditorCiphertext; - Buffer blindingFactor; - - fillConversionCiphertexts( - arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor); - - jv[sfBlindingFactor.jsonName] = strHex(blindingFactor); + fillConversionCiphertexts(arg, jv, account, amt); if (arg.proof) { @@ -1376,17 +1364,8 @@ MPTTester::convertJV(MPTConvert const& arg, std::uint32_t seq) } else if (arg.fillSchnorrProof.value_or(arg.holderPubKey.has_value())) { - auto const contextHash = - getConvertContextHash(requireValue(arg.account, "account").id(), issuanceID(), seq); - auto const proof = getSchnorrProof(*arg.account, contextHash); - if (proof) - { - jv[sfZKProof.jsonName] = strHex(*proof); - } - else - { - jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSchnorrProofLength)); - } + auto const contextHash = getConvertContextHash(account.id(), issuanceID(), seq); + setProofOrDummy(jv, getSchnorrProof(account, contextHash), kEcSchnorrProofLength); } return jv; @@ -1395,253 +1374,48 @@ MPTTester::convertJV(MPTConvert const& arg, std::uint32_t seq) void MPTTester::send(MPTConfidentialSend const& arg) { - json::Value jv; - jv[jss::TransactionType] = jss::ConfidentialMPTSend; + json::Value const jv = sendJV(arg, ticketOrSeq(arg.ticketSeq, arg.account)); - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } - - if (arg.dest) - { - jv[sfDestination] = arg.dest->human(); - } - else - { - Throw("Destination not specified"); - } - - if (!arg.amt) - Throw("Amount not specified for testing purposes"); - - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } - - Buffer const blindingFactor = - arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor(); - - // fill in the encrypted amounts if not provided - auto const senderAmt = arg.senderEncryptedAmt - ? *arg.senderEncryptedAmt - : encryptAmount(*arg.account, *arg.amt, blindingFactor); - auto const destAmt = arg.destEncryptedAmt ? *arg.destEncryptedAmt - : encryptAmount(*arg.dest, *arg.amt, blindingFactor); - auto const issuerAmt = arg.issuerEncryptedAmt - ? *arg.issuerEncryptedAmt - : encryptAmount(issuer_, *arg.amt, blindingFactor); - - std::optional auditorAmt; - if (arg.auditorEncryptedAmt) - { - auditorAmt = arg.auditorEncryptedAmt; - } - else if (auditor_.has_value() && arg.fillAuditorEncryptedAmt.value_or(false)) - { - auditorAmt = encryptAmount( - requireValue(auditor_, "auditor"), requireValue(arg.amt, "amt"), blindingFactor); - } - - jv[sfSenderEncryptedAmount] = strHex(senderAmt); - jv[sfDestinationEncryptedAmount] = strHex(destAmt); - jv[sfIssuerEncryptedAmount] = strHex(issuerAmt); - if (auditorAmt) - jv[sfAuditorEncryptedAmount] = strHex(*auditorAmt); - - if (arg.credentials) - { - auto& arr(jv[sfCredentialIDs.jsonName] = json::ValueType::Array); - for (auto const& hash : *arg.credentials) - arr.append(hash); - } + Account const& account = requireValue(arg.account, "account"); + Account const& dest = requireValue(arg.dest, "dest"); + auto const amt = requireValue(arg.amt, "amt"); // Version counters before send - auto const prevSenderVersion = getMPTokenVersion(*arg.account); - auto const prevDestVersion = getMPTokenVersion(*arg.dest); + auto const prevSenderVersion = getMPTokenVersion(account); + auto const prevDestVersion = getMPTokenVersion(dest); // Sender's previous confidential state - auto const prevSenderInbox = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const prevSenderSpending = getDecryptedBalance(*arg.account, holderEncryptedSpending); - auto const prevSenderIssuer = getDecryptedBalance(*arg.account, issuerEncryptedBalance); - auto const prevSenderInboxEncrypted = getEncryptedBalance(*arg.account, holderEncryptedInbox); - auto const prevSenderSpendingEncrypted = - getEncryptedBalance(*arg.account, holderEncryptedSpending); - auto const prevSenderIssuerEncrypted = - getEncryptedBalance(*arg.account, issuerEncryptedBalance); + auto const prevSenderInbox = getDecryptedBalance(account, holderEncryptedInbox); + auto const prevSenderSpending = getDecryptedBalance(account, holderEncryptedSpending); + auto const prevSenderIssuer = getDecryptedBalance(account, issuerEncryptedBalance); if (!prevSenderInbox || !prevSenderSpending || !prevSenderIssuer) Throw("Failed to get Pre-send balance"); std::optional prevSenderAuditor; - auto const prevSenderAuditorEncrypted = - getEncryptedBalance(*arg.account, auditorEncryptedBalance); if (arg.auditorEncryptedAmt || auditor_) { - prevSenderAuditor = getDecryptedBalance(*arg.account, auditorEncryptedBalance); + prevSenderAuditor = getDecryptedBalance(account, auditorEncryptedBalance); if (!prevSenderAuditor) Throw("Failed to get Pre-send balance"); } // Destination's previous confidential state - auto const prevDestInbox = getDecryptedBalance(*arg.dest, holderEncryptedInbox); - auto const prevDestSpending = getDecryptedBalance(*arg.dest, holderEncryptedSpending); - auto const prevDestIssuer = getDecryptedBalance(*arg.dest, issuerEncryptedBalance); - auto const prevDestInboxEncrypted = getEncryptedBalance(*arg.dest, holderEncryptedInbox); - auto const prevDestSpendingEncrypted = getEncryptedBalance(*arg.dest, holderEncryptedSpending); - auto const prevDestIssuerEncrypted = getEncryptedBalance(*arg.dest, issuerEncryptedBalance); + auto const prevDestInbox = getDecryptedBalance(dest, holderEncryptedInbox); + auto const prevDestSpending = getDecryptedBalance(dest, holderEncryptedSpending); + auto const prevDestIssuer = getDecryptedBalance(dest, issuerEncryptedBalance); if (!prevDestInbox || !prevDestSpending || !prevDestIssuer) Throw("Failed to get Pre-send balance"); std::optional prevDestAuditor; - auto const prevDestAuditorEncrypted = getEncryptedBalance(*arg.dest, auditorEncryptedBalance); if (arg.auditorEncryptedAmt || auditor_) { - prevDestAuditor = getDecryptedBalance(*arg.dest, auditorEncryptedBalance); + prevDestAuditor = getDecryptedBalance(dest, auditorEncryptedBalance); if (!prevDestAuditor) Throw("Failed to get Pre-send balance"); } - // Fill in the commitment if not provided - // The amount commitment must use the same blinding factor as the ElGamal - // encryption. The sigma proof links the two, so using different randomness - // for each would cause proof verification to fail. - Buffer amountCommitment, balanceCommitment; - if (arg.amountCommitment) - { - amountCommitment = *arg.amountCommitment; - } - else - { - amountCommitment = getPedersenCommitment(*arg.amt, blindingFactor); - } - - jv[sfAmountCommitment] = strHex(amountCommitment); - - auto const balanceBlindingFactor = generateBlindingFactor(); - if (arg.balanceCommitment) - { - balanceCommitment = *arg.balanceCommitment; - } - else - { - balanceCommitment = getPedersenCommitment(*prevSenderSpending, balanceBlindingFactor); - } - - jv[sfBalanceCommitment] = strHex(balanceCommitment); - - // Fill in the proof if not provided - if (arg.proof) - { - jv[sfZKProof] = *arg.proof; - } - else - { - auto const version = getMPTokenVersion(*arg.account); - auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account)); - auto const ctxHash = getSendContextHash( - requireValue(arg.account, "account").id(), - issuanceID(), - seq, - requireValue(arg.dest, "dest").id(), - version); - - std::vector recipients; - - auto const senderPubKey = getPubKey(*arg.account); - auto const destPubKey = getPubKey(*arg.dest); - auto const issuerPubKey = getPubKey(issuer_); - - // If a key is missing, we skip adding the recipient. This intentionally - // causes proof generation to fail, triggering the dummy proof fallback. - if (senderPubKey) - { - recipients.push_back({ - .publicKey = Slice(*senderPubKey), - .encryptedAmount = senderAmt, - }); - } - if (destPubKey) - { - recipients.push_back({ - .publicKey = Slice(*destPubKey), - .encryptedAmount = destAmt, - }); - } - if (issuerPubKey) - { - recipients.push_back({ - .publicKey = Slice(*issuerPubKey), - .encryptedAmount = issuerAmt, - }); - } - - std::optional auditorPubKey; - if (auditorAmt) - { - if (!auditor_) - Throw("Auditor not registered"); - - auditorPubKey = getPubKey(*auditor_); - if (auditorPubKey) - { - recipients.push_back({ - .publicKey = Slice(*auditorPubKey), - .encryptedAmount = *auditorAmt, - }); - } - } - - std::optional proof; - - // Skip proof generation if encrypted balance is missing (e.g., - // feature disabled), when the sender and destination are the same - // (malformed case causing pcm to be zero), or when spending balance - // is 0 - if (arg.account != arg.dest && prevSenderSpendingEncrypted && *prevSenderSpending > 0) - { - proof = getConfidentialSendProof( - *arg.account, - *arg.amt, - recipients, - blindingFactor, - ctxHash, - { - .pedersenCommitment = amountCommitment, - .amt = *arg.amt, - .encryptedAmt = senderAmt, - .blindingFactor = blindingFactor, - }, - { - .pedersenCommitment = balanceCommitment, - .amt = *prevSenderSpending, - .encryptedAmt = *prevSenderSpendingEncrypted, - .blindingFactor = balanceBlindingFactor, - }); - } - - if (proof) - { - jv[sfZKProof.jsonName] = strHex(*proof); - } - else - { - jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSendProofLength)); - } - } - - auto const senderPubAmt = getBalance(*arg.account); - auto const destPubAmt = getBalance(*arg.dest); + auto const senderPubAmt = getBalance(account); + auto const destPubAmt = getBalance(dest); auto const prevCOA = getIssuanceConfidentialBalance(); auto const prevOA = getIssuanceOutstandingBalance(); @@ -1651,24 +1425,24 @@ MPTTester::send(MPTConfidentialSend const& arg) auto const postOA = getIssuanceOutstandingBalance(); // Sender's post confidential state - auto const postSenderInbox = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const postSenderSpending = getDecryptedBalance(*arg.account, holderEncryptedSpending); - auto const postSenderIssuer = getDecryptedBalance(*arg.account, issuerEncryptedBalance); + auto const postSenderInbox = getDecryptedBalance(account, holderEncryptedInbox); + auto const postSenderSpending = getDecryptedBalance(account, holderEncryptedSpending); + auto const postSenderIssuer = getDecryptedBalance(account, issuerEncryptedBalance); if (!postSenderInbox || !postSenderSpending || !postSenderIssuer) Throw("Failed to get Post-send balance"); // Destination's post confidential state - auto const postDestInbox = getDecryptedBalance(*arg.dest, holderEncryptedInbox); - auto const postDestSpending = getDecryptedBalance(*arg.dest, holderEncryptedSpending); - auto const postDestIssuer = getDecryptedBalance(*arg.dest, issuerEncryptedBalance); + auto const postDestInbox = getDecryptedBalance(dest, holderEncryptedInbox); + auto const postDestSpending = getDecryptedBalance(dest, holderEncryptedSpending); + auto const postDestIssuer = getDecryptedBalance(dest, issuerEncryptedBalance); if (!postDestInbox || !postDestSpending || !postDestIssuer) Throw("Failed to get Post-send balance"); // Public balances unchanged - env_.require(MptBalance(*this, *arg.account, senderPubAmt)); - env_.require(MptBalance(*this, *arg.dest, destPubAmt)); + env_.require(MptBalance(*this, account, senderPubAmt)); + env_.require(MptBalance(*this, dest, destPubAmt)); // OA and COA unchanged env_.require(RequireAny([&]() -> bool { return prevOA && postOA && *prevOA == *postOA; })); @@ -1676,21 +1450,18 @@ MPTTester::send(MPTConfidentialSend const& arg) // Verify sender changes env_.require(RequireAny([&]() -> bool { - return *prevSenderSpending >= *arg.amt && - *postSenderSpending == *prevSenderSpending - *arg.amt; + return *prevSenderSpending >= amt && *postSenderSpending == *prevSenderSpending - amt; })); env_.require(RequireAny([&]() -> bool { return postSenderInbox == prevSenderInbox; })); env_.require(RequireAny([&]() -> bool { - return *prevSenderIssuer >= *arg.amt && - *postSenderIssuer == *prevSenderIssuer - *arg.amt; + return *prevSenderIssuer >= amt && *postSenderIssuer == *prevSenderIssuer - amt; })); // Verify destination changes - env_.require( - RequireAny([&]() -> bool { return *postDestInbox == *prevDestInbox + *arg.amt; })); + env_.require(RequireAny([&]() -> bool { return *postDestInbox == *prevDestInbox + amt; })); env_.require(RequireAny([&]() -> bool { return *postDestSpending == *prevDestSpending; })); env_.require( - RequireAny([&]() -> bool { return *postDestIssuer == *prevDestIssuer + *arg.amt; })); + RequireAny([&]() -> bool { return *postDestIssuer == *prevDestIssuer + amt; })); // Cross checks env_.require(RequireAny( @@ -1700,15 +1471,14 @@ MPTTester::send(MPTConfidentialSend const& arg) // Version: sender increments by 1; receiver version is unchanged by incoming sends env_.require(RequireAny( - [&]() -> bool { return getMPTokenVersion(*arg.account) == prevSenderVersion + 1; })); + [&]() -> bool { return getMPTokenVersion(account) == prevSenderVersion + 1; })); env_.require( - RequireAny([&]() -> bool { return getMPTokenVersion(*arg.dest) == prevDestVersion; })); + RequireAny([&]() -> bool { return getMPTokenVersion(dest) == prevDestVersion; })); if (arg.auditorEncryptedAmt || auditor_) { - auto const postSenderAuditor = - getDecryptedBalance(*arg.account, auditorEncryptedBalance); - auto const postDestAuditor = getDecryptedBalance(*arg.dest, auditorEncryptedBalance); + auto const postSenderAuditor = getDecryptedBalance(account, auditorEncryptedBalance); + auto const postDestAuditor = getDecryptedBalance(dest, auditorEncryptedBalance); if (!postSenderAuditor || !postDestAuditor) Throw("Failed to get Post-send balance"); @@ -1719,13 +1489,12 @@ MPTTester::send(MPTConfidentialSend const& arg) // verify sender env_.require(RequireAny([&]() -> bool { - return prevSenderAuditor >= *arg.amt && - *postSenderAuditor == *prevSenderAuditor - *arg.amt; + return *prevSenderAuditor >= amt && *postSenderAuditor == *prevSenderAuditor - amt; })); // verify dest - env_.require(RequireAny( - [&]() -> bool { return *postDestAuditor == *prevDestAuditor + *arg.amt; })); + env_.require( + RequireAny([&]() -> bool { return *postDestAuditor == *prevDestAuditor + amt; })); } } } @@ -1739,49 +1508,21 @@ MPTTester::sendJV( json::Value jv; jv[jss::TransactionType] = jss::ConfidentialMPTSend; - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } + Account const& account = setAccountField(jv, arg.account); + Account const& dest = setDestinationField(jv, arg.dest); + auto const amt = requireValue(arg.amt, "amt"); - if (arg.dest) - { - jv[sfDestination] = arg.dest->human(); - } - else - { - Throw("Destination not specified"); - } - - if (!arg.amt) - Throw("Amount not specified for testing purposes"); - - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + setIssuanceIdField(jv, arg.id); Buffer const blindingFactor = arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor(); - auto const senderAmt = arg.senderEncryptedAmt - ? *arg.senderEncryptedAmt - : encryptAmount(*arg.account, *arg.amt, blindingFactor); - auto const destAmt = arg.destEncryptedAmt ? *arg.destEncryptedAmt - : encryptAmount(*arg.dest, *arg.amt, blindingFactor); - auto const issuerAmt = arg.issuerEncryptedAmt - ? *arg.issuerEncryptedAmt - : encryptAmount(issuer_, *arg.amt, blindingFactor); + auto const senderAmt = arg.senderEncryptedAmt ? *arg.senderEncryptedAmt + : encryptAmount(account, amt, blindingFactor); + auto const destAmt = + arg.destEncryptedAmt ? *arg.destEncryptedAmt : encryptAmount(dest, amt, blindingFactor); + auto const issuerAmt = arg.issuerEncryptedAmt ? *arg.issuerEncryptedAmt + : encryptAmount(issuer_, amt, blindingFactor); std::optional auditorAmt; if (arg.auditorEncryptedAmt) @@ -1790,8 +1531,7 @@ MPTTester::sendJV( } else if (auditor_.has_value() && arg.fillAuditorEncryptedAmt.value_or(false)) { - auditorAmt = encryptAmount( - requireValue(auditor_, "auditor"), requireValue(arg.amt, "amt"), blindingFactor); + auditorAmt = encryptAmount(requireValue(auditor_, "auditor"), amt, blindingFactor); } jv[sfSenderEncryptedAmount] = strHex(senderAmt); @@ -1818,12 +1558,12 @@ MPTTester::sendJV( } else { - auto const ledgerSpending = getDecryptedBalance(*arg.account, holderEncryptedSpending); + auto const ledgerSpending = getDecryptedBalance(account, holderEncryptedSpending); if (!ledgerSpending) Throw("Failed to get sender spending balance"); prevSenderSpending = *ledgerSpending; - prevEncryptedSenderSpending = getEncryptedBalance(*arg.account, holderEncryptedSpending); - version = getMPTokenVersion(*arg.account); + prevEncryptedSenderSpending = getEncryptedBalance(account, holderEncryptedSpending); + version = getMPTokenVersion(account); } // The amount commitment must use the same blinding factor as the tx ElGamal @@ -1835,7 +1575,7 @@ MPTTester::sendJV( } else { - amountCommitment = getPedersenCommitment(*arg.amt, blindingFactor); + amountCommitment = getPedersenCommitment(amt, blindingFactor); } jv[sfAmountCommitment] = strHex(amountCommitment); @@ -1858,17 +1598,13 @@ MPTTester::sendJV( } else { - auto const ctxHash = getSendContextHash( - requireValue(arg.account, "account").id(), - issuanceID(), - seq, - requireValue(arg.dest, "dest").id(), - version); + auto const ctxHash = + getSendContextHash(account.id(), issuanceID(), seq, dest.id(), version); std::vector recipients; - auto const senderPubKey = getPubKey(*arg.account); - auto const destPubKey = getPubKey(*arg.dest); + auto const senderPubKey = getPubKey(account); + auto const destPubKey = getPubKey(dest); auto const issuerPubKey = getPubKey(issuer_); if (senderPubKey) @@ -1911,17 +1647,17 @@ MPTTester::sendJV( std::optional proof; // Skip proof generation when spending balance is 0 - if (arg.account != arg.dest && prevEncryptedSenderSpending && prevSenderSpending > 0) + if (prevEncryptedSenderSpending && prevSenderSpending > 0) { proof = getConfidentialSendProof( - *arg.account, - *arg.amt, + account, + amt, recipients, blindingFactor, ctxHash, { .pedersenCommitment = amountCommitment, - .amt = *arg.amt, + .amt = amt, .encryptedAmt = senderAmt, .blindingFactor = blindingFactor, }, @@ -1933,14 +1669,7 @@ MPTTester::sendJV( }); } - if (proof) - { - jv[sfZKProof.jsonName] = strHex(*proof); - } - else - { - jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSendProofLength)); - } + setProofOrDummy(jv, proof, kEcSendProofLength); } return jv; @@ -2003,31 +1732,14 @@ MPTTester::confidentialClaw(MPTConfidentialClawback const& arg) auto const account = arg.account ? *arg.account : issuer_; jv[sfAccount] = account.human(); - if (arg.holder) - { - jv[sfHolder] = arg.holder->human(); - } - else - { - Throw("Holder not specified"); - } + Account const& holder = requireValue(arg.holder, "holder"); + jv[sfHolder] = holder.human(); jv[jss::TransactionType] = jss::ConfidentialMPTClawback; - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else if (id_) - { - jv[sfMPTokenIssuanceID] = to_string(*id_); - } - else - { - Throw("MPT has not been created"); - } + setIssuanceIdField(jv, arg.id); - if (arg.amt) - jv[sfMPTAmount] = std::to_string(*arg.amt); + auto const amt = requireValue(arg.amt, "amt"); + jv[sfMPTAmount] = std::to_string(amt); if (arg.proof) { @@ -2036,62 +1748,49 @@ MPTTester::confidentialClaw(MPTConfidentialClawback const& arg) else { auto const seq = arg.ticketSeq ? *arg.ticketSeq : env_.seq(account); - auto const contextHash = getClawbackContextHash( - account.id(), issuanceID(), seq, requireValue(arg.holder, "holder").id()); + auto const contextHash = + getClawbackContextHash(account.id(), issuanceID(), seq, holder.id()); auto const privKey = getPrivKey(account); if (!privKey || privKey->size() != kEcPrivKeyLength) Throw("Failed to get clawback private key"); - auto const proof = getClawbackProof( - requireValue(arg.holder, "holder"), - requireValue(arg.amt, "amt"), - requireValue(privKey, "privKey"), - contextHash); + auto const proof = + getClawbackProof(holder, amt, requireValue(privKey, "privKey"), contextHash); - if (proof) - { - jv[sfZKProof] = strHex(*proof); - } - else - { - jv[sfZKProof] = strHex(gMakeZeroBuffer(kEcClawbackProofLength)); - } + setProofOrDummy(jv, proof, kEcClawbackProofLength); } - auto const holderPubAmt = getBalance(*arg.holder); + auto const holderPubAmt = getBalance(holder); auto const prevCOA = getIssuanceConfidentialBalance(); auto const prevOA = getIssuanceOutstandingBalance(); - auto const prevVersion = getMPTokenVersion(*arg.holder); + auto const prevVersion = getMPTokenVersion(holder); if (submit(arg, jv) == tesSUCCESS) { auto const postCOA = getIssuanceConfidentialBalance(); auto const postOA = getIssuanceOutstandingBalance(); - auto const postVersion = getMPTokenVersion(*arg.holder); + auto const postVersion = getMPTokenVersion(holder); // Verify holder's public balance is unchanged - env_.require(MptBalance(*this, *arg.holder, holderPubAmt)); + env_.require(MptBalance(*this, holder, holderPubAmt)); // Verify COA and OA are reduced correctly - env_.require(RequireAny( - [&]() -> bool { return prevCOA >= *arg.amt && postCOA == prevCOA - *arg.amt; })); + env_.require( + RequireAny([&]() -> bool { return prevCOA >= amt && postCOA == prevCOA - amt; })); env_.require(RequireAny([&]() -> bool { - return prevOA && postOA && *prevOA >= *arg.amt && *postOA == *prevOA - *arg.amt; + return prevOA && postOA && *prevOA >= amt && *postOA == *prevOA - amt; })); // Verify holder's confidential balances are zeroed out env_.require(RequireAny( - [&]() -> bool { return getDecryptedBalance(*arg.holder, holderEncryptedInbox) == 0; })); - env_.require(RequireAny([&]() -> bool { - return getDecryptedBalance(*arg.holder, holderEncryptedSpending) == 0; - })); - env_.require(RequireAny([&]() -> bool { - return getDecryptedBalance(*arg.holder, issuerEncryptedBalance) == 0; - })); - env_.require(RequireAny([&]() -> bool { - return getDecryptedBalance(*arg.holder, auditorEncryptedBalance) == 0; - })); + [&]() -> bool { return getDecryptedBalance(holder, holderEncryptedInbox) == 0; })); + env_.require(RequireAny( + [&]() -> bool { return getDecryptedBalance(holder, holderEncryptedSpending) == 0; })); + env_.require(RequireAny( + [&]() -> bool { return getDecryptedBalance(holder, issuerEncryptedBalance) == 0; })); + env_.require(RequireAny( + [&]() -> bool { return getDecryptedBalance(holder, auditorEncryptedBalance) == 0; })); // Verify version is incremented env_.require(RequireAny([&]() -> bool { return postVersion == prevVersion + 1; })); @@ -2213,30 +1912,14 @@ MPTTester::getDecryptedBalance(Account const& account, EncryptedBalanceType bala } return decryptAmount(decryptor, *encryptedAmt, epoch); -}; +} json::Value MPTTester::mergeInboxJV(MPTMergeInbox const& arg) const { json::Value jv; - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + setAccountField(jv, arg.account); + setIssuanceIdField(jv, arg.id); jv[sfTransactionType] = jss::ConfidentialMPTMergeInbox; return jv; } @@ -2244,36 +1927,18 @@ MPTTester::mergeInboxJV(MPTMergeInbox const& arg) const void MPTTester::mergeInbox(MPTMergeInbox const& arg) { - json::Value jv; - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + json::Value const jv = mergeInboxJV(arg); + Account const& account = requireValue(arg.account, "account"); - jv[sfTransactionType] = jss::ConfidentialMPTMergeInbox; - auto const holderPubAmt = getBalance(*arg.account); + auto const holderPubAmt = getBalance(account); auto const prevCOA = getIssuanceConfidentialBalance(); auto const prevOA = getIssuanceOutstandingBalance(); - auto const prevInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending); - auto const prevIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance); - auto const prevIssuerEncrypted = getEncryptedBalance(*arg.account, issuerEncryptedBalance); - auto const prevAuditorEncrypted = getEncryptedBalance(*arg.account, auditorEncryptedBalance); - auto const prevVersion = getMPTokenVersion(*arg.account); + auto const prevInboxBalance = getDecryptedBalance(account, holderEncryptedInbox); + auto const prevSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending); + auto const prevIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance); + auto const prevIssuerEncrypted = getEncryptedBalance(account, issuerEncryptedBalance); + auto const prevAuditorEncrypted = getEncryptedBalance(account, auditorEncryptedBalance); + auto const prevVersion = getMPTokenVersion(account); if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance) Throw("Failed to get pre-mergeInbox balances"); @@ -2282,20 +1947,19 @@ MPTTester::mergeInbox(MPTMergeInbox const& arg) { auto const postCOA = getIssuanceConfidentialBalance(); auto const postOA = getIssuanceOutstandingBalance(); - auto const postInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const postSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending); - auto const postIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance); - auto const postInboxEncrypted = getEncryptedBalance(*arg.account, holderEncryptedInbox); - auto const postIssuerEncrypted = getEncryptedBalance(*arg.account, issuerEncryptedBalance); - auto const postAuditorEncrypted = - getEncryptedBalance(*arg.account, auditorEncryptedBalance); - auto const postVersion = getMPTokenVersion(*arg.account); + auto const postInboxBalance = getDecryptedBalance(account, holderEncryptedInbox); + auto const postSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending); + auto const postIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance); + auto const postInboxEncrypted = getEncryptedBalance(account, holderEncryptedInbox); + auto const postIssuerEncrypted = getEncryptedBalance(account, issuerEncryptedBalance); + auto const postAuditorEncrypted = getEncryptedBalance(account, auditorEncryptedBalance); + auto const postVersion = getMPTokenVersion(account); if (!postInboxBalance || !postSpendingBalance || !postIssuerBalance || !prevIssuerEncrypted || !postInboxEncrypted || !postIssuerEncrypted) Throw("Failed to get post-mergeInbox balances"); - env_.require(MptBalance(*this, *arg.account, holderPubAmt)); + env_.require(MptBalance(*this, account, holderPubAmt)); env_.require(RequireAny([&]() -> bool { return prevOA && postOA && *prevOA == *postOA; })); env_.require(RequireAny([&]() -> bool { return prevCOA == postCOA; })); @@ -2307,14 +1971,12 @@ MPTTester::mergeInbox(MPTMergeInbox const& arg) env_.require( RequireAny([&]() -> bool { return *prevIssuerBalance == *postIssuerBalance; })); - auto const holderPubKey = getPubKey(*arg.account); + auto const holderPubKey = getPubKey(account); if (!holderPubKey) Throw("Failed to get holder public key"); auto const expectedInbox = encryptCanonicalZeroAmount( - requireValue(holderPubKey, "holderPubKey"), - requireValue(arg.account, "account").id(), - issuanceID()); + requireValue(holderPubKey, "holderPubKey"), account.id(), issuanceID()); if (!expectedInbox) Throw("Failed to get canonical zero encryption"); @@ -2366,158 +2028,74 @@ MPTTester::getMPTokenVersion(Account const account) const void MPTTester::convertBack(MPTConvertBack const& arg) { - json::Value jv; - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } + json::Value const jv = convertBackJV(arg, ticketOrSeq(arg.ticketSeq, arg.account)); - jv[jss::TransactionType] = jss::ConfidentialMPTConvertBack; - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + Account const& account = requireValue(arg.account, "account"); + auto const amt = requireValue(arg.amt, "amt"); - if (arg.amt) - jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt); - - Buffer holderCiphertext; - Buffer issuerCiphertext; - std::optional auditorCiphertext; - Buffer blindingFactor; - - fillConversionCiphertexts( - arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor); - - jv[sfBlindingFactor] = strHex(blindingFactor); - - auto const prevInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending); - auto const prevIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance); + auto const prevInboxBalance = getDecryptedBalance(account, holderEncryptedInbox); + auto const prevSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending); + auto const prevIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance); if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance) Throw("Failed to get Pre-convertBack balance"); - Buffer pedersenCommitment; - Buffer const pcBlindingFactor = generateBlindingFactor(); - if (arg.pedersenCommitment) - { - pedersenCommitment = *arg.pedersenCommitment; - } - else - { - pedersenCommitment = getPedersenCommitment(*prevSpendingBalance, pcBlindingFactor); - } - - jv[sfBalanceCommitment] = strHex(pedersenCommitment); - - if (arg.proof) - { - jv[sfZKProof.jsonName] = strHex(*arg.proof); - } - else - { - auto const version = getMPTokenVersion(*arg.account); - - // if the caller generated ciphertexts themselves, they should also - // generate the proof themselves from the blinding factor - auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account)); - auto const contextHash = getConvertBackContextHash( - requireValue(arg.account, "account").id(), issuanceID(), seq, version); - auto const prevEncryptedSpendingBalance = - getEncryptedBalance(*arg.account, holderEncryptedSpending); - - Buffer proof; - // generate a dummy proof if no encrypted amount field, so that other - // preflight/preclaim are checked - if (!prevEncryptedSpendingBalance) - { - proof = gMakeZeroBuffer(kEcConvertBackProofLength); - } - else - { - proof = getConvertBackProof( - *arg.account, - requireValue(arg.amt, "amt"), - contextHash, - { - .pedersenCommitment = pedersenCommitment, - .amt = *prevSpendingBalance, - .encryptedAmt = *prevEncryptedSpendingBalance, - .blindingFactor = pcBlindingFactor, - }); - } - jv[sfZKProof] = strHex(proof); - } - - auto const holderAmt = getBalance(*arg.account); + auto const holderAmt = getBalance(account); auto const prevConfidentialOutstanding = getIssuanceConfidentialBalance(); std::optional prevAuditorBalance; if (arg.auditorEncryptedAmt || auditor_) { - prevAuditorBalance = getDecryptedBalance(*arg.account, auditorEncryptedBalance); + prevAuditorBalance = getDecryptedBalance(account, auditorEncryptedBalance); if (!prevAuditorBalance) Throw("Failed to get Pre-convertBack balance"); } auto const prevOutstanding = getIssuanceOutstandingBalance(); - auto const prevVersion = getMPTokenVersion(*arg.account); + auto const prevVersion = getMPTokenVersion(account); if (submit(arg, jv) == tesSUCCESS) { auto const postConfidentialOutstanding = getIssuanceConfidentialBalance(); auto const postOutstanding = getIssuanceOutstandingBalance(); - auto const postVersion = getMPTokenVersion(*arg.account); - env_.require(MptBalance( - *this, requireValue(arg.account, "account"), holderAmt + requireValue(arg.amt, "amt"))); + auto const postVersion = getMPTokenVersion(account); + env_.require(MptBalance(*this, account, holderAmt + amt)); env_.require(RequireAny([&]() -> bool { return prevOutstanding && postOutstanding && *prevOutstanding == *postOutstanding; })); env_.require(RequireAny([&]() -> bool { - return prevConfidentialOutstanding - *arg.amt == postConfidentialOutstanding; + return prevConfidentialOutstanding - amt == postConfidentialOutstanding; })); - auto const postInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const postIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance); - auto const postSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending); + auto const postInboxBalance = getDecryptedBalance(account, holderEncryptedInbox); + auto const postIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance); + auto const postSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending); if (!postInboxBalance || !postIssuerBalance || !postSpendingBalance) Throw("Failed to get post-convertBack balance"); if (arg.auditorEncryptedAmt || auditor_) { - auto const postAuditorBalance = - getDecryptedBalance(*arg.account, auditorEncryptedBalance); + auto const postAuditorBalance = getDecryptedBalance(account, auditorEncryptedBalance); if (!postAuditorBalance) Throw("Failed to get post-convertBack balance"); // auditor's encrypted balance is updated correctly env_.require(RequireAny( - [&]() -> bool { return *prevAuditorBalance - *arg.amt == *postAuditorBalance; })); + [&]() -> bool { return *prevAuditorBalance - amt == *postAuditorBalance; })); } // inbox balance should not change env_.require(RequireAny([&]() -> bool { return *postInboxBalance == *prevInboxBalance; })); // issuer's encrypted balance is updated correctly - env_.require(RequireAny( - [&]() -> bool { return *prevIssuerBalance - *arg.amt == *postIssuerBalance; })); + env_.require( + RequireAny([&]() -> bool { return *prevIssuerBalance - amt == *postIssuerBalance; })); // holder's spending balance is updated correctly env_.require(RequireAny( - [&]() -> bool { return *prevSpendingBalance - *arg.amt == *postSpendingBalance; })); + [&]() -> bool { return *prevSpendingBalance - amt == *postSpendingBalance; })); // holder's confidential balance version is updated correctly env_.require(RequireAny([&]() -> bool { return postVersion == prevVersion + 1; })); @@ -2534,41 +2112,17 @@ json::Value MPTTester::convertBackJV(MPTConvertBack const& arg, std::uint32_t seq) { json::Value jv; - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } + Account const& account = setAccountField(jv, arg.account); jv[jss::TransactionType] = jss::ConfidentialMPTConvertBack; - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + setIssuanceIdField(jv, arg.id); - if (arg.amt) - jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt); + auto const amt = requireValue(arg.amt, "amt"); + jv[sfMPTAmount.jsonName] = std::to_string(amt); - Buffer holderCiphertext; - Buffer issuerCiphertext; - std::optional auditorCiphertext; - Buffer blindingFactor; + fillConversionCiphertexts(arg, jv, account, amt); - fillConversionCiphertexts( - arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor); - - jv[sfBlindingFactor] = strHex(blindingFactor); - - auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending); + auto const prevSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending); if (!prevSpendingBalance) Throw("convertBackJV: failed to read spending balance from ledger"); @@ -2591,21 +2145,17 @@ MPTTester::convertBackJV(MPTConvertBack const& arg, std::uint32_t seq) } else { - auto const version = getMPTokenVersion(*arg.account); - auto const prevEncSpending = getEncryptedBalance(*arg.account, holderEncryptedSpending); - auto const contextHash = getConvertBackContextHash( - requireValue(arg.account, "account").id(), issuanceID(), seq, version); + auto const version = getMPTokenVersion(account); + auto const prevEncSpending = getEncryptedBalance(account, holderEncryptedSpending); + auto const contextHash = + getConvertBackContextHash(account.id(), issuanceID(), seq, version); - Buffer proof; - if (!prevEncSpending) - { - proof = gMakeZeroBuffer(kEcConvertBackProofLength); - } - else + std::optional proof; + if (prevEncSpending) { proof = getConvertBackProof( - *arg.account, - requireValue(arg.amt, "amt"), + account, + amt, contextHash, { .pedersenCommitment = pedersenCommitment, @@ -2615,7 +2165,7 @@ MPTTester::convertBackJV(MPTConvertBack const& arg, std::uint32_t seq) }); } - jv[sfZKProof] = strHex(proof); + setProofOrDummy(jv, proof, kEcConvertBackProofLength); } return jv; diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index 13bf4ead3f..cefdd2cdca 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -742,7 +742,7 @@ public: PedersenProofParams const& amountParams, PedersenProofParams const& balanceParams) const; - [[nodiscard]] Buffer + [[nodiscard]] std::optional getConvertBackProof( Account const& holder, std::uint64_t const amount, @@ -841,15 +841,28 @@ private: [[nodiscard]] std::uint32_t getFlags(std::optional const& holder) const; + /** + * @brief Sets sfMPTokenIssuanceID on jv, falling back to id_ if arg's id is + * not set. + * + * @param jv The JSON object to set the field on. + * @param id The explicit issuance ID override from the caller, if any. + */ + void + setIssuanceIdField(json::Value& jv, std::optional const& id) const; + + [[nodiscard]] std::uint32_t + ticketOrSeq( + std::optional const& ticketSeq, + std::optional const& account) const; + template void fillConversionCiphertexts( T const& arg, json::Value& jv, - Buffer& holderCiphertext, - Buffer& issuerCiphertext, - std::optional& auditorCiphertext, - Buffer& blindingFactor) const; + Account const& account, + std::uint64_t const amount) const; }; } // namespace xrpl::test::jtx diff --git a/src/test/overlay/PeerTest.cpp b/src/test/overlay/PeerTest.cpp new file mode 100644 index 0000000000..341febb25b --- /dev/null +++ b/src/test/overlay/PeerTest.cpp @@ -0,0 +1,166 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +PeerTest::PeerTest( + Application& app, + std::shared_ptr const& slot, + http_request_type&& request, + PublicKey const& publicKey, + ProtocolVersion protocol, + resource::Consumer consumer, + std::unique_ptr&& streamPtr, + OverlayImpl& overlay) + : PeerImp{ + app, + id++, + slot, + std::move(request), + publicKey, + protocol, + consumer, + std::move(streamPtr), + overlay} +{ +} + +void +PeerTest::run() +{ +} + +void +PeerTest::send(std::shared_ptr const& message) +{ + lastSentMessage_ = message; +} + +std::shared_ptr +PeerTest::getLastSentMessage() const +{ + return lastSentMessage_; +} + +void +PeerTest::runProcessGetObjectByHash(std::shared_ptr const& message) +{ + PeerImp::processGetObjectByHash(message); +} + +void +PeerTest::runProcessLedgerRequest( + std::shared_ptr const& message, + std::vector nodeIDs) +{ + PeerImp::processLedgerRequest(message, std::move(nodeIDs)); +} + +resource::Charge +PeerTest::getCurrentFeeCharge() const +{ + return PeerImp::currentFeeCharge(); +} + +void +PeerTest::resetId() +{ + id = 0; +} + +bool +PeerTest::compressionEnabled() const +{ + if (compressionEnabled_.has_value()) + { + return *compressionEnabled_; + } + return PeerImp::compressionEnabled(); +} + +void +PeerTest::compressionEnabled(std::optional enabled) +{ + compressionEnabled_ = enabled; +} + +bool +PeerTest::txReduceRelayEnabled() const +{ + if (reduceRelayEnabled_.has_value()) + { + return *reduceRelayEnabled_; + } + return PeerImp::txReduceRelayEnabled(); +} + +void +PeerTest::txReduceRelayEnabled(std::optional enabled) +{ + reduceRelayEnabled_ = enabled; +} + +std::shared_ptr +makePeerTest(jtx::Env& env, PeerTest::SharedContext const& context, ProtocolVersion protocolVersion) +{ + using SocketType = boost::asio::ip::tcp::socket; + + auto& overlay = dynamic_cast(env.app().getOverlay()); + boost::beast::http::request request; + auto streamPtr = + std::make_unique(SocketType(env.app().getIOContext()), *context); + + beast::ip::Endpoint const local(boost::asio::ip::make_address("172.1.1.1"), 51235); + beast::ip::Endpoint const remote(boost::asio::ip::make_address("172.1.1.2"), 51235); + + PublicKey const key{std::get<0>(randomKeyPair(KeyType::Ed25519))}; + auto consumer = overlay.resourceManager().newInboundEndpoint(remote); + auto [slot, _] = overlay.peerFinder().newInboundSlot(local, remote); + + auto peer = std::make_shared( + env.app(), + slot, + std::move(request), + key, + protocolVersion, + consumer, + std::move(streamPtr), + overlay); + + overlay.addActive(peer); + return peer; +} + +} // namespace xrpl::test diff --git a/src/test/overlay/PeerTest.h b/src/test/overlay/PeerTest.h new file mode 100644 index 0000000000..f7b2815da3 --- /dev/null +++ b/src/test/overlay/PeerTest.h @@ -0,0 +1,108 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace xrpl::test { + +/** + * Test peer that captures sent messages for verification. + */ +class PeerTest : public PeerImp +{ + inline static Peer::id_t id{}; + std::shared_ptr lastSentMessage_; + std::optional compressionEnabled_; + std::optional reduceRelayEnabled_; + +public: + using MiddleType = boost::beast::tcp_stream; + using SharedContext = std::shared_ptr; + using StreamType = boost::beast::ssl_stream; + + PeerTest( + Application& app, + std::shared_ptr const& slot, + http_request_type&& request, + PublicKey const& publicKey, + ProtocolVersion protocol, + resource::Consumer consumer, + std::unique_ptr&& streamPtr, + OverlayImpl& overlay); + + ~PeerTest() override = default; + + void + run() override; + + void + send(std::shared_ptr const& m) override; + + std::shared_ptr + getLastSentMessage() const; + + // Synchronous test access to the JobQueue-dispatched processor. + // The production path runs this on JtLedgerReq; tests need a + // synchronous entry point to inspect the reply via send(). + // PeerImp::processGetObjectByHash is `protected` so the derived + // test subclass can call it directly. + void + runProcessGetObjectByHash(std::shared_ptr const& m); + + void + runProcessLedgerRequest( + std::shared_ptr const& m, + std::vector nodeIDs); + + resource::Charge + getCurrentFeeCharge() const; + + static void + resetId(); + + bool + compressionEnabled() const override; + + void + compressionEnabled(std::optional enabled); + + bool + txReduceRelayEnabled() const override; + + void + txReduceRelayEnabled(std::optional enabled); +}; + +std::shared_ptr +makePeerTest( + jtx::Env& env, + PeerTest::SharedContext const& context, + ProtocolVersion protocolVersion); + +} // namespace xrpl::test diff --git a/src/test/overlay/ProtocolMessage_test.cpp b/src/test/overlay/ProtocolMessage_test.cpp new file mode 100644 index 0000000000..08e039f606 --- /dev/null +++ b/src/test/overlay/ProtocolMessage_test.cpp @@ -0,0 +1,296 @@ +#include +#include +#include + +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class ProtocolMessage_test : public beast::unit_test::Suite +{ + struct TestHandler + { + bool compression = false; + int beginCount = 0; + int messageCount = 0; + int endCount = 0; + int unknownCount = 0; + std::uint16_t lastType = 0; + + [[nodiscard]] bool + compressionEnabled() const + { + return compression; + } + + void + onMessageUnknown(std::uint16_t type) + { + ++unknownCount; + lastType = type; + } + + void + onMessageBegin( + std::uint16_t type, + std::shared_ptr<::google::protobuf::Message> const&, + std::size_t, + std::size_t, + bool) + { + ++beginCount; + lastType = type; + } + + template + void + onMessage(std::shared_ptr const&) + { + ++messageCount; + } + + void + onMessageEnd(std::uint16_t, std::shared_ptr<::google::protobuf::Message> const&) + { + ++endCount; + } + + [[nodiscard]] static std::size_t + maxManifestsMessageSize() + { + return std::numeric_limits::max(); + } + }; + + // Wire bytes: `type` (2 bytes) + unknown field tag (2 bytes) + length varint (2 bytes, as these + // tests all use unknownFieldSize >= 128). + static constexpr std::size_t kPingProtoOverheadWithUnknownLen = 6; + static constexpr std::size_t kMinimumPingSizeWithEmptyUnknownField = + compression::kHeaderBytes + kPingProtoOverheadWithUnknownLen; + static constexpr std::size_t kMinimumPingSizeCompressedWithEmptyUnknownField = + compression::kHeaderBytesCompressed + kPingProtoOverheadWithUnknownLen; + + static std::vector + makePingBuffer(std::size_t unknownFieldSize, bool compressed = false) + { + auto ping = protocol::TMPing{}; + ping.set_type(protocol::TMPing::ptPING); + if (unknownFieldSize > 0) + { + ping.mutable_unknown_fields()->AddLengthDelimited( + 42, std::string(unknownFieldSize, 'A')); + } + + if (!compressed) + { + auto m = Message{ping, protocol::mtPING}; + return m.getBuffer(compression::Compressed::Off); + } + + // Message::compress() refuses to compress pings (mtPING is not in its + // allow-list), so getBuffer(Compressed::On) would just return the + // uncompressed bytes. Roll it by hand here to get a compressed + // ping message on the wire. + auto payload = std::string{}; + ping.SerializeToString(&payload); + + auto deflated = std::vector{}; + auto const deflatedSize = compression::compress( + payload.data(), + payload.size(), + [&](std::size_t sz) { + deflated.resize(sz); + return deflated.data(); + }, + compression::Algorithm::LZ4); + deflated.resize(deflatedSize); + + auto const type = static_cast(protocol::mtPING); + auto buffer = std::vector{}; + auto pack = [&buffer](std::uint32_t value) { + buffer.push_back(static_cast((value >> 24) & 0x0F)); + buffer.push_back(static_cast((value >> 16) & 0xFF)); + buffer.push_back(static_cast((value >> 8) & 0xFF)); + buffer.push_back(static_cast(value & 0xFF)); + }; + + pack(static_cast(deflated.size())); // compressed payload size + buffer.push_back(static_cast((type >> 8) & 0xFF)); + buffer.push_back(static_cast(type & 0xFF)); + pack(static_cast(payload.size())); // uncompressed size + buffer[0] |= static_cast(compression::Algorithm::LZ4); + + buffer.insert(buffer.end(), deflated.begin(), deflated.end()); + return buffer; + } + + static std::optional + declaredPingSize(std::vector const& buffer) + { + auto ec = boost::system::error_code{}; + auto const seq = std::array{boost::asio::buffer(buffer)}; + if (auto const header = xrpl::detail::parseMessageHeader(ec, seq, buffer.size())) + { + return header->uncompressedSize + header->headerSize; + } + return std::nullopt; + } + + static std::pair + invoke(std::vector const& buffer, TestHandler& handler) + { + auto const seq = std::array{boost::asio::buffer(buffer)}; + auto hint = 0uz; + return invokeProtocolMessage(seq, handler, hint); + } + + void + testOversizedPingRejected() + { + testcase("oversized ping rejected before dispatch"); + + auto runLocalTest = [&](std::size_t size, bool compressed = false) { + auto const buffer = makePingBuffer(size, compressed); + auto const declared = declaredPingSize(buffer); + if (BEAST_EXPECT(declared.has_value())) + BEAST_EXPECT(*declared > kMaximumPingMessageSize); + BEAST_EXPECT(buffer.size() < kMaximumMessageSize); + + auto handler = TestHandler{}; + handler.compression = compressed; + auto const [bytes, ec] = invoke(buffer, handler); + + BEAST_EXPECT(ec == make_error_code(boost::system::errc::message_size)); + BEAST_EXPECT(bytes == 0); + BEAST_EXPECT(handler.beginCount == 0); + BEAST_EXPECT(handler.messageCount == 0); + BEAST_EXPECT(handler.endCount == 0); + }; + // Just over the cap, and comfortably over it. + runLocalTest(kMaximumPingMessageSize + 1 - kMinimumPingSizeWithEmptyUnknownField); + runLocalTest((2 * kMaximumPingMessageSize) - kMinimumPingSizeWithEmptyUnknownField); + runLocalTest( + kMaximumPingMessageSize + 1 - kMinimumPingSizeCompressedWithEmptyUnknownField, true); + runLocalTest( + (2 * kMaximumPingMessageSize) - kMinimumPingSizeCompressedWithEmptyUnknownField, true); + } + + void + testOversizedPingRejectedFromHeaderAlone() + { + testcase("oversized ping rejected from header alone"); + + auto runLocalTest = [&](std::size_t size, bool compressed = false) { + auto const full = makePingBuffer(size, compressed); + auto const headerSize = + compressed ? compression::kHeaderBytesCompressed : compression::kHeaderBytes; + + // Only the header has arrived; the declared payload is still in flight. + auto const headerOnly = + std::vector{full.begin(), full.begin() + headerSize}; + BEAST_EXPECT(headerOnly.size() < full.size()); + + auto handler = TestHandler{}; + handler.compression = compressed; + auto const [bytes, ec] = invoke(headerOnly, handler); + + BEAST_EXPECT(ec == make_error_code(boost::system::errc::message_size)); + BEAST_EXPECT(bytes == 0); + BEAST_EXPECT(handler.beginCount == 0); + BEAST_EXPECT(handler.messageCount == 0); + BEAST_EXPECT(handler.endCount == 0); + }; + runLocalTest(kMaximumPingMessageSize + 1 - kMinimumPingSizeWithEmptyUnknownField); + runLocalTest((2 * kMaximumPingMessageSize) - kMinimumPingSizeWithEmptyUnknownField); + runLocalTest( + kMaximumPingMessageSize + 1 - kMinimumPingSizeCompressedWithEmptyUnknownField, true); + runLocalTest( + (2 * kMaximumPingMessageSize) - kMinimumPingSizeCompressedWithEmptyUnknownField, true); + } + + void + testNormalPingDispatched() + { + testcase("normal ping dispatched"); + + auto runLocalTest = [&](std::size_t size, bool compressed = false) { + auto const buffer = makePingBuffer(size, compressed); + auto const declared = declaredPingSize(buffer); + if (BEAST_EXPECT(declared.has_value())) + BEAST_EXPECT(*declared <= kMaximumPingMessageSize); + + auto handler = TestHandler{}; + handler.compression = compressed; + auto const [bytes, ec] = invoke(buffer, handler); + + BEAST_EXPECT(!ec); + BEAST_EXPECT(bytes == buffer.size()); + BEAST_EXPECT(handler.beginCount == 1); + BEAST_EXPECT(handler.messageCount == 1); + BEAST_EXPECT(handler.endCount == 1); + }; + runLocalTest(0); + runLocalTest(0, true); + } + + void + testPingWithSmallUnknownFieldDispatched() + { + testcase("ping with small unknown field still dispatched"); + + auto runLocalTest = [&](std::size_t size, bool compressed = false) { + auto const buffer = makePingBuffer(size, compressed); + auto const declared = declaredPingSize(buffer); + if (BEAST_EXPECT(declared.has_value())) + BEAST_EXPECT(*declared <= kMaximumPingMessageSize); + + auto handler = TestHandler{}; + handler.compression = compressed; + auto const [bytes, ec] = invoke(buffer, handler); + + BEAST_EXPECT(!ec); + BEAST_EXPECT(bytes == buffer.size()); + BEAST_EXPECT(handler.beginCount == 1); + BEAST_EXPECT(handler.messageCount == 1); + BEAST_EXPECT(handler.endCount == 1); + }; + // Well under the cap, one byte under it, and exactly at it. + runLocalTest((kMaximumPingMessageSize / 2) - kMinimumPingSizeWithEmptyUnknownField); + runLocalTest(kMaximumPingMessageSize - 1 - kMinimumPingSizeWithEmptyUnknownField); + runLocalTest(kMaximumPingMessageSize - kMinimumPingSizeWithEmptyUnknownField); + runLocalTest( + (kMaximumPingMessageSize / 2) - kMinimumPingSizeCompressedWithEmptyUnknownField, true); + runLocalTest( + kMaximumPingMessageSize - 1 - kMinimumPingSizeCompressedWithEmptyUnknownField, true); + runLocalTest( + kMaximumPingMessageSize - kMinimumPingSizeCompressedWithEmptyUnknownField, true); + } + + void + run() override + { + testOversizedPingRejected(); + testOversizedPingRejectedFromHeaderAlone(); + testNormalPingDispatched(); + testPingWithSmallUnknownFieldDispatched(); + } +}; + +BEAST_DEFINE_TESTSUITE(ProtocolMessage, overlay, xrpl); + +} // namespace xrpl::test diff --git a/src/test/overlay/TMGetLedger_test.cpp b/src/test/overlay/TMGetLedger_test.cpp new file mode 100644 index 0000000000..9088e6fa65 --- /dev/null +++ b/src/test/overlay/TMGetLedger_test.cpp @@ -0,0 +1,133 @@ +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace xrpl::test { + +using namespace jtx; + +class TMGetLedger_test : public beast::unit_test::Suite +{ + PeerTest::SharedContext context_{makeSslContext("")}; + ProtocolVersion protocolVersion_{1, 7}; + + // Build a well-formed TMGetLedger node request carrying `numNodeIds` node + // IDs. + static std::shared_ptr + createRequest(std::size_t const numNodeIds) + { + auto request = std::make_shared(); + request->set_itype(protocol::liTX_NODE); + + // A uint256-sized ledger hash, as a well-formed request carries. + uint256 const ledgerHash{1}; + request->set_ledgerhash(ledgerHash.data(), ledgerHash.size()); + + // Valid, deserializable SHAMap node IDs. + auto const rootNodeId = SHAMapNodeID{}.getRawString(); + for (std::size_t i = 0; i < numNodeIds; ++i) + { + request->add_nodeids(rootNodeId); + } + + return request; + } + + void + testNodeIdCountAccepted(std::size_t const numNodeIds, bool const expectRejected) + { + testcase("Node ID Count Accepted"); + + Env env{*this}; + PeerTest::resetId(); + + auto peer = makePeerTest(env, context_, protocolVersion_); + peer->onMessage(createRequest(numNodeIds)); + + // A request outside the accepted node-ID count is charged kFeeInvalidData; one inside + // it is not. The JobQueue handler may run concurrently and update the fee in the + // accepted case. + BEAST_EXPECT( + expectRejected ? (peer->getCurrentFeeCharge() == resource::kFeeInvalidData) + : !(peer->getCurrentFeeCharge() == resource::kFeeInvalidData)); + } + + void + testProcessLedgerRequestNodeCount(std::size_t const numNodeIds) + { + testcase("Process Ledger Request Node Count"); + + Env env{*this}; + env.close(); + PeerTest::resetId(); + + auto peer = makePeerTest(env, context_, protocolVersion_); + + // Ask for the account-state root node of the closed ledger. + auto request = createRequest(numNodeIds); + request->clear_ledgerhash(); + request->set_itype(protocol::liAS_NODE); + request->set_ltype(protocol::ltCLOSED); + + peer->runProcessLedgerRequest(request, std::vector(numNodeIds)); + + auto sentMessage = peer->getLastSentMessage(); + BEAST_EXPECT(sentMessage != nullptr); + if (!sentMessage) + { + return; + } + + auto const& buffer = sentMessage->getBuffer(compression::Compressed::Off); + BEAST_EXPECT(buffer.size() > 6); + + // Skip the message header (6 bytes: 4 for size, 2 for type). + protocol::TMLedgerData reply; + BEAST_EXPECT(reply.ParseFromArray(buffer.data() + 6, buffer.size() - 6) == true); + + BEAST_EXPECT(reply.type() == protocol::liAS_NODE); + BEAST_EXPECT(reply.nodes_size() > 0); + BEAST_EXPECT(reply.nodes_size() <= static_cast(tuning::kHardMaxReplyNodes)); + } + + void + run() override + { + auto const limit = static_cast(tuning::kHardMaxReplyNodes); + testNodeIdCountAccepted(limit + 1, true); + testNodeIdCountAccepted(limit, false); + testNodeIdCountAccepted(limit - 1, false); + testProcessLedgerRequestNodeCount(limit + 1); + testProcessLedgerRequestNodeCount(limit); + testProcessLedgerRequestNodeCount(limit - 1); + } +}; + +BEAST_DEFINE_TESTSUITE(TMGetLedger, overlay, xrpl); + +} // namespace xrpl::test diff --git a/src/test/overlay/TMTransaction_test.cpp b/src/test/overlay/TMTransaction_test.cpp new file mode 100644 index 0000000000..b208b3d81b --- /dev/null +++ b/src/test/overlay/TMTransaction_test.cpp @@ -0,0 +1,60 @@ +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include + +namespace xrpl::test { + +using namespace jtx; + +class TMTransaction_test : public beast::unit_test::Suite +{ + PeerTest::SharedContext context_{makeSslContext("")}; + ProtocolVersion protocolVersion_{1, 7}; + + void + testFailureDeserializingTransactionIsCharged() + { + testcase("Undeserializable Transaction Is Charged"); + + Env env{*this, envconfig()}; + PeerTest::resetId(); + + auto peer = makePeerTest(env, context_, protocolVersion_); + auto tx = std::make_shared(); + tx->set_status(protocol::tsNEW); + + // Bytes that are not a serialized transaction, so deserialization fails. + tx->set_rawtransaction("\x01\x02\x03", 3); + + peer->onMessage(tx); + BEAST_EXPECT(peer->getCurrentFeeCharge() == resource::kFeeInvalidData); + } + + void + run() override + { + testFailureDeserializingTransactionIsCharged(); + } +}; + +BEAST_DEFINE_TESTSUITE(TMTransaction, overlay, xrpl); + +} // namespace xrpl::test diff --git a/src/test/overlay/TMTransactions_test.cpp b/src/test/overlay/TMTransactions_test.cpp new file mode 100644 index 0000000000..67d36cc04b --- /dev/null +++ b/src/test/overlay/TMTransactions_test.cpp @@ -0,0 +1,88 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace xrpl::test { + +using namespace jtx; + +class TMTransactions_test : public beast::unit_test::Suite +{ + PeerTest::SharedContext context_{makeSslContext("")}; + ProtocolVersion protocolVersion_{1, 7}; + + static std::shared_ptr + createRequest(std::size_t const numTransactions) + { + auto request = std::make_shared(); + for (std::size_t i = 0; i < numTransactions; ++i) + { + request->mutable_transactions()->Add(protocol::TMTransaction{}); + } + return request; + } + + void + testTransactionCountAccepted(std::size_t const numTransactions, bool const expectRejected) + { + testcase("Transaction Count Accepted"); + + static constexpr auto kLimitExceededMessage = "TMTransactions: transaction list too large"; + auto foundExpectedLog = false; + Env env{ + *this, + envconfig(), + std::make_unique(kLimitExceededMessage, &foundExpectedLog)}; + PeerTest::resetId(); + + auto peer = makePeerTest(env, context_, protocolVersion_); + peer->txReduceRelayEnabled(true); + peer->onMessage(createRequest(numTransactions)); + + auto fee = peer->getCurrentFeeCharge(); + if (expectRejected) + { + BEAST_EXPECT(fee == resource::kFeeMalformedRequest); + BEAST_EXPECT(foundExpectedLog); + } + else + { + BEAST_EXPECT(!foundExpectedLog); + } + } + + void + run() override + { + auto const limit = reduce_relay::kMaxTxQueueSize; + testTransactionCountAccepted(limit + 1, true); + testTransactionCountAccepted(limit, false); + testTransactionCountAccepted(limit - 1, false); + } +}; + +BEAST_DEFINE_TESTSUITE(TMTransactions, overlay, xrpl); + +} // namespace xrpl::test diff --git a/src/test/overlay/cluster_test.cpp b/src/test/overlay/cluster_test.cpp index 0a51f98594..06df4fb73a 100644 --- a/src/test/overlay/cluster_test.cpp +++ b/src/test/overlay/cluster_test.cpp @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -96,6 +97,29 @@ public: } } + { + testcase("Membership: isMember agrees with member"); + + // Number of network nodes that also belong to the cluster. + std::size_t const overlapCount = 16; + + // Total size of the cluster once padded with non-network nodes. + std::size_t const clusterSize = 32; + + std::vector cluster(network.begin(), network.begin() + overlapCount); + + while (cluster.size() != clusterSize) + cluster.push_back(randomNode()); + + auto c = create(cluster); + + for (auto const& n : cluster) + BEAST_EXPECT(c->isMember(n)); + + for (auto const& n : network) + BEAST_EXPECT(c->isMember(n) == static_cast(c->member(n))); + } + { testcase("Membership: Non-empty cluster and all present"); diff --git a/src/test/rpc/Handler_test.cpp b/src/test/rpc/Handler_test.cpp index be78864cac..16838d3f5a 100644 --- a/src/test/rpc/Handler_test.cpp +++ b/src/test/rpc/Handler_test.cpp @@ -1,9 +1,8 @@ -#include - #include #include +#include #include #include @@ -12,7 +11,11 @@ #include #include #include +#include +#include #include +#include +#include #include #include // cspell: words stdev @@ -88,21 +91,50 @@ class Handler_test : public beast::unit_test::Suite std::random_device dev; std::ranlux48 prng(dev()); - std::vector names = test::jtx::makeVector(xrpl::rpc::getHandlerNames()); + // The lowest version still served. Outside the supported range getHandler() + // returns at its bounds check without searching, so the benchmark would + // time that check instead of a lookup. + constexpr unsigned kVersion = rpc::kApiMinimumSupportedVersion; + + // Only the names that answer at kVersion, so that every timed call does a + // whole lookup: a method served from a later version only would not. + // Contiguous, so that picking one by index costs nothing. + std::vector names; + std::ranges::copy_if( + rpc::getHandlerNames(), std::back_inserter(names), [](std::string_view name) { + return rpc::getHandler(kVersion, false, name) != nullptr; + }); + + if (!BEAST_EXPECTS( + !names.empty(), + "no handler answers at API version " + std::to_string(kVersion) + + ", so there is nothing to measure")) + return; std::uniform_int_distribution distr{0, names.size() - 1}; std::size_t dummy = 0; + std::size_t misses = 0; auto const [mean, stdev, n] = time( 1'000'000, [&](std::size_t i) { - auto const d = rpc::getHandler(1, false, names[i]); + auto const d = rpc::getHandler(kVersion, false, names[i]); + if (d == nullptr) + { + ++misses; + return; + } dummy = dummy + i + (int)d->role; }, [&]() -> std::size_t { return distr(prng); }); std::cout << "mean=" << mean << " stdev=" << stdev << " N=" << n << '\n'; + // Every name answered once already, so a miss here cannot happen. + BEAST_EXPECTS( + misses == 0, + std::to_string(misses) + " of " + std::to_string(n) + " lookups at API version " + + std::to_string(kVersion) + " found no handler, so nothing was measured"); BEAST_EXPECT(dummy != 0); } @@ -114,6 +146,125 @@ public: } }; +// Manual: the suite only reports a timing, which says nothing on a CI runner. +// The table invariants are static_asserts in Handler.cpp. BEAST_DEFINE_TESTSUITE_MANUAL(Handler, rpc, xrpl); +// What getHandler() answers, as opposed to how fast it answers. A lookup needs no +// Application, so these cases run as an automatic suite. +// +// The bounds check they cover is unreachable from a request: getAPIVersionNumber() +// applies the same predicate first, and every caller rejects an invalid version +// before it asks for a handler. That is why it is checked here directly, and why +// it is worth checking at all rather than deleting as unreachable. +class HandlerLookup_test : public beast::unit_test::Suite +{ + /** + * Find a method that is served at a given API version. + * + * The name comes from the table, so a case below does not name a method that a + * later API version may retire. + * + * @param version The API version to answer at. + * @param betaEnabled Whether the beta API version is enabled. + * @return A name that answers, or nullopt if none does. + */ + static std::optional + nameServedAt(unsigned version, bool betaEnabled) + { + for (std::string_view name : rpc::getHandlerNames()) + { + if (rpc::getHandler(version, betaEnabled, name) != nullptr) + return name; + } + + return std::nullopt; + } + + void + testUnservedVersion() + { + testcase("An unserved API version has no handler"); + + // A name the table certainly holds, so that a null answer below can only + // come from the version and not from the name. + auto const name = nameServedAt(rpc::kApiMinimumSupportedVersion, false); + if (!BEAST_EXPECTS( + name.has_value(), + "no handler answers at API version " + + std::to_string(rpc::kApiMinimumSupportedVersion) + + ", so there is no name to ask about")) + return; + + // Below the minimum, which no setting serves. + BEAST_EXPECT( + rpc::getHandler(rpc::kApiMinimumSupportedVersion - 1, false, *name) == nullptr); + BEAST_EXPECT(rpc::getHandler(rpc::kApiMinimumSupportedVersion - 1, true, *name) == nullptr); + + // Above the maximum each setting serves. Both values stay outside the + // served range however the version constants move, so neither case can + // become vacuous. + BEAST_EXPECT( + rpc::getHandler(rpc::kApiMaximumSupportedVersion + 1, false, *name) == nullptr); + BEAST_EXPECT(rpc::getHandler(rpc::kApiBetaVersion + 1, true, *name) == nullptr); + } + + void + testBetaVersionGate() + { + testcase("The beta API version is served only where it is enabled"); + + // Between betas the beta version is the maximum supported one, leaving the + // two settings nothing to tell apart. Compiled out rather than asserted, so + // that the case arms itself again when a later beta version arrives. + if constexpr (rpc::kApiBetaVersion > rpc::kApiMaximumSupportedVersion) + { + auto const name = nameServedAt(rpc::kApiBetaVersion, true); + if (!BEAST_EXPECTS( + name.has_value(), + "no handler answers at API version " + std::to_string(rpc::kApiBetaVersion) + + ", so there is nothing for the gate to reject")) + return; + + // The handler serves this version, so only the server's own range can + // turn the answer into a null one. + BEAST_EXPECT(rpc::getHandler(rpc::kApiBetaVersion, true, *name) != nullptr); + BEAST_EXPECT(rpc::getHandler(rpc::kApiBetaVersion, false, *name) == nullptr); + } + else + { + log << "the beta API version is the maximum supported version, so no gate " + "separates them\n"; + pass(); + } + } + + void + testUnknownMethod() + { + testcase("An unknown method has no handler"); + + constexpr unsigned kVersion = rpc::kApiMinimumSupportedVersion; + + BEAST_EXPECT(rpc::getHandler(kVersion, false, "no such method") == nullptr); + BEAST_EXPECT(rpc::getHandler(kVersion, false, "") == nullptr); + + // A method name holds lowercase letters and underscores, so a tilde sorts + // after every entry. This runs the search off the end of the table, which + // no other case here does. + BEAST_EXPECT(rpc::getHandler(kVersion, false, "~") == nullptr); + } + +public: + void + run() override + { + testUnservedVersion(); + testBetaVersionGate(); + testUnknownMethod(); + } +}; + +BEAST_DEFINE_TESTSUITE(HandlerLookup, rpc, xrpl); + } // namespace xrpl::test diff --git a/src/test/rpc/RPCCall_test.cpp b/src/test/rpc/RPCCall_test.cpp index ef3213008c..e09d95f99a 100644 --- a/src/test/rpc/RPCCall_test.cpp +++ b/src/test/rpc/RPCCall_test.cpp @@ -3,6 +3,9 @@ #include #include +#include +#include +#include #include #include @@ -12,11 +15,14 @@ #include +#include +#include #include #include #include #include #include +#include #include #include @@ -5923,10 +5929,67 @@ public: } } + // The command-line table and the dispatch table must agree. + // + // Forwards: every name the command line accepts must reach a handler at the + // version the command-line client requests. Presence in the dispatch table is + // not enough: a handler whose API range excludes kApiCommandLineVersion parses + // the command and then answers RpcUnknownCommand. + // + // Backwards: a handler that claims a command-line form must have one, and + // one that denies it must not, so that Handler::hasCommandLineForm cannot go + // stale. + // + // Three command-line names are exempt from the forward check because they + // are wrappers that forward a caller-supplied method rather than naming one + // themselves, so they have no handler of their own. + void + testCommandLineTableMatchesHandlers() + { + testcase("Command-line and dispatch tables agree"); + + static constexpr std::array kWrappers{ + rpc::method::kInternal, rpc::method::kJson, rpc::method::kJson2}; + + auto const commandLine = commandLineMethodNames(); + auto const handlers = rpc::getHandlerNames(); + BEAST_EXPECT(!commandLine.empty()); + BEAST_EXPECT(!handlers.empty()); + + // The command-line client always requests this version, so this is the + // only version at which its commands have to be dispatchable. Beta + // methods are off: a command must work against a stock server. + auto const handlerFor = [](std::string_view name) { + return rpc::getHandler(rpc::kApiCommandLineVersion, false, name); + }; + + for (auto const& name : commandLine) + { + if (std::ranges::find(kWrappers, name) != kWrappers.end()) + continue; + + auto const* handler = handlerFor(name); + if (BEAST_EXPECTS(handler != nullptr, std::string{name})) + BEAST_EXPECTS(handler->hasCommandLineForm, std::string{name}); + } + + for (auto const& name : handlers) + { + auto const* handler = handlerFor(name); + bool const claimsCommandLine = handler != nullptr && handler->hasCommandLineForm; + + // Both name lists are sorted, so a binary search suffices. + BEAST_EXPECTS( + claimsCommandLine == std::ranges::binary_search(commandLine, name.view()), + std::string{name}); + } + } + void run() override { forAllApiVersions([this](unsigned apiVersion) { testRPCCall(apiVersion); }); + testCommandLineTableMatchesHandlers(); } }; diff --git a/src/xrpld/app/main/Application.cpp b/src/xrpld/app/main/Application.cpp index a46146fb5b..d984840e64 100644 --- a/src/xrpld/app/main/Application.cpp +++ b/src/xrpld/app/main/Application.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -432,6 +433,7 @@ public: perf::makePerfLog( perf::setupPerfLog(config_->section(Sections::kPerf), config_->configDir), *this, + rpc::getHandlerNames(), logs_->journal("PerfLog"), [this] { signalStop("PerfLog"); })) , nodeIdentity_(resolvedIdentity) diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 4debc51099..a92d515ed7 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -2073,11 +2073,21 @@ NetworkOPsImp::apply(std::unique_lock& batchLock) if (validatedLedgerIndex) { - auto [fee, accountSeq, availableSeq] = - registry_.get().getTxQ().getTxRequiredFeeAndSeq( - *newOL, e.transaction->getSTransaction()); - e.transaction->setCurrentLedgerState( - *validatedLedgerIndex, fee, accountSeq, availableSeq); + auto maybeFeeAndSeq = registry_.get().getTxQ().getTxRequiredFeeAndSeq( + *newOL, e.transaction->getSTransaction()); + if (maybeFeeAndSeq.has_value()) + { + auto [fee, accountSeq, availableSeq] = *maybeFeeAndSeq; + e.transaction->setCurrentLedgerState( + *validatedLedgerIndex, fee, accountSeq, availableSeq); + } + else + { + JLOG(journal_.debug()) + << "Unable to compute current ledger state for tx " + << e.transaction->getID() << " in validated ledger " + << *validatedLedgerIndex << ": " << transToken(maybeFeeAndSeq.error()); + } } } } diff --git a/src/xrpld/app/misc/TxQ.h b/src/xrpld/app/misc/TxQ.h index bbcbffecf3..358b426332 100644 --- a/src/xrpld/app/misc/TxQ.h +++ b/src/xrpld/app/misc/TxQ.h @@ -24,6 +24,7 @@ #include #include +#include #include #include #include @@ -392,10 +393,10 @@ public: * and first available sequence for transaction * @param view current open ledger * @param tx the transaction - * @return minimum required fee, first sequence in the ledger + * @return minimum required fee or an error, first sequence in the ledger * and first available sequence */ - FeeAndSeq + std::expected getTxRequiredFeeAndSeq(OpenView const& view, std::shared_ptr const& tx) const; /** diff --git a/src/xrpld/app/misc/detail/TxQ.cpp b/src/xrpld/app/misc/detail/TxQ.cpp index 33d9e22ea0..7f43936c2f 100644 --- a/src/xrpld/app/misc/detail/TxQ.cpp +++ b/src/xrpld/app/misc/detail/TxQ.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include #include #include @@ -58,22 +59,29 @@ namespace xrpl { ////////////////////////////////////////////////////////////////////////// -static FeeLevel64 +/** + * Compute the fee level that a transaction pays. + * @return The fee level paid, or the error reported by `calculateBaseFee`. + */ +static std::expected getFeeLevelPaid(ReadView const& view, STTx const& tx) { - auto const [baseFee, effectiveFeePaid] = [&view, &tx]() { - XRPAmount const baseFee = calculateBaseFee(view, tx); + auto const computedBaseFee = calculateBaseFee(view, tx); + if (!computedBaseFee) + return std::unexpected(computedBaseFee.error()); + + auto const [baseFee, effectiveFeePaid] = [&view, &tx, fee = *computedBaseFee]() { XRPAmount const feePaid = tx[sfFee].xrp(); // If baseFee is 0 then the cost of a basic transaction is free, but we // need the effective fee level to be non-zero. - XRPAmount const mod = [&view, &tx, baseFee]() { - if (baseFee.signum() > 0) + XRPAmount const mod = [&view, &tx, fee]() { + if (fee.signum() > 0) return XRPAmount{0}; auto def = calculateDefaultBaseFee(view, tx); return def.signum() == 0 ? XRPAmount{1} : def; }(); - return std::pair{baseFee + mod, feePaid + mod}; + return std::pair{fee + mod, feePaid + mod}; }(); XRPL_ASSERT(baseFee.signum() > 0, "xrpl::getFeeLevelPaid : positive fee"); @@ -116,10 +124,20 @@ TxQ::FeeMetrics::update( auto const size = std::distance(txBegin, txEnd); feeLevels.reserve(size); std::for_each(txBegin, txEnd, [&](auto const& tx) { - feeLevels.push_back(getFeeLevelPaid(view, *tx.first)); + auto const maybeFeeLevel = getFeeLevelPaid(view, *tx.first); + if (maybeFeeLevel.has_value()) + { + feeLevels.push_back(*maybeFeeLevel); + } + else + { + // Excluded from the median sample below. + JLOG(j_.warn()) << "Unable to compute the fee level for a validated transaction " + << tx.first->getTransactionID() << " in ledger " << view.header().seq + << ": " << transToken(maybeFeeLevel.error()); + } }); std::ranges::sort(feeLevels); - XRPL_ASSERT(size == feeLevels.size(), "xrpl::TxQ::FeeMetrics::update : fee levels size"); JLOG((timeLeap ? j_.warn() : j_.debug())) << "Ledger " << view.header().seq << " has " << size << " transactions. " @@ -163,7 +181,10 @@ TxQ::FeeMetrics::update( txnsExpected_ = std::min(next, maximumTxnCount_.value_or(next)); } - if (size == 0) + // The median is taken over the transactions whose fee level could be + // computed, while txnsExpected_ above deliberately uses the full + // transaction count. + if (feeLevels.empty()) { escalationMultiplier_ = setup.minimumEscalationMultiplier; } @@ -173,8 +194,9 @@ TxQ::FeeMetrics::update( // evaluates to the middle element; for an even // number of elements, it will add the two elements // on either side of the "middle" and average them. + auto const count = feeLevels.size(); escalationMultiplier_ = - (feeLevels[size / 2] + feeLevels[(size - 1) / 2] + FeeLevel64{1}) / 2; + (feeLevels[count / 2] + feeLevels[(count - 1) / 2] + FeeLevel64{1}) / 2; escalationMultiplier_ = std::max(escalationMultiplier_, setup.minimumEscalationMultiplier); } JLOG(j_.debug()) << "Expected transactions updated to " << txnsExpected_ @@ -942,7 +964,14 @@ TxQ::apply( // We may need the base fee for multiple transactions or transaction // replacement, so just pull it up now. auto const metricsSnapshot = feeMetrics_.getSnapshot(); - auto const feeLevelPaid = getFeeLevelPaid(view, *tx); + auto const computedFeeLevelPaid = getFeeLevelPaid(view, *tx); + // Without a fee level there is no way to tell whether the transaction + // pays enough, so it can be neither applied nor queued. + if (!computedFeeLevelPaid.has_value()) + { + return {computedFeeLevelPaid.error(), false}; + } + FeeLevel64 const feeLevelPaid = *computedFeeLevelPaid; auto const requiredFeeLevel = getRequiredFeeLevel(view, flags, metricsSnapshot, lock); span.setAttribute( txq_span::attr::feeLevelPaid, static_cast(feeLevelPaid.value())); @@ -1807,7 +1836,14 @@ TxQ::tryDirectApply( // If the transaction's fee is high enough we may be able to put the // transaction straight into the ledger. - FeeLevel64 const feeLevelPaid = getFeeLevelPaid(view, *tx); + auto const computedFeeLevelPaid = getFeeLevelPaid(view, *tx); + // The fee level is unknown, so the transaction cannot be applied here, + // and queueing it would only run into the same failure. Reject it. + if (!computedFeeLevelPaid.has_value()) + { + return ApplyResult{computedFeeLevelPaid.error(), false}; + } + FeeLevel64 const feeLevelPaid = *computedFeeLevelPaid; if (feeLevelPaid >= requiredFeeLevel) { @@ -1891,7 +1927,7 @@ TxQ::getMetrics(OpenView const& view) const return result; } -TxQ::FeeAndSeq +std::expected TxQ::getTxRequiredFeeAndSeq(OpenView const& view, std::shared_ptr const& tx) const { auto const account = (*tx)[sfAccount]; @@ -1899,14 +1935,19 @@ TxQ::getTxRequiredFeeAndSeq(OpenView const& view, std::shared_ptr co std::scoped_lock const lock(mutex_); auto const snapshot = feeMetrics_.getSnapshot(); - auto const baseFee = calculateBaseFee(view, *tx); + auto const maybeBaseFee = calculateBaseFee(view, *tx); + if (!maybeBaseFee.has_value()) + { + return std::unexpected(maybeBaseFee.error()); + } + auto const baseFee = *maybeBaseFee; auto const fee = FeeMetrics::scaleFeeLevel(snapshot, view); auto const sle = view.read(keylet::account(account)); std::uint32_t const accountSeq = sle ? (*sle)[sfSequence] : 0; std::uint32_t const availableSeq = nextQueuableSeqImpl(sle, lock).value(); - return { + return FeeAndSeq{ .fee = mulDiv(fee, baseFee, kBaseLevel) .value_or(XRPAmount(std::numeric_limits::max())), .accountSeq = accountSeq, diff --git a/src/xrpld/overlay/Cluster.h b/src/xrpld/overlay/Cluster.h index 703e1601aa..bffebca550 100644 --- a/src/xrpld/overlay/Cluster.h +++ b/src/xrpld/overlay/Cluster.h @@ -62,6 +62,19 @@ public: std::optional member(PublicKey const& node) const; + /** + * Determines whether a node belongs in the cluster. + * + * Prefer this to `member` when the comment is not wanted: `member` + * copies the node's name out from under the lock, and most callers + * only test the result for engagement. + * + * @param node The node's public identity. + * @return Whether the node is a cluster member. + */ + bool + isMember(PublicKey const& node) const; + /** * The number of nodes in the cluster list. */ diff --git a/src/xrpld/overlay/detail/Cluster.cpp b/src/xrpld/overlay/detail/Cluster.cpp index 15c8fa9c66..5e2e2053e9 100644 --- a/src/xrpld/overlay/detail/Cluster.cpp +++ b/src/xrpld/overlay/detail/Cluster.cpp @@ -38,6 +38,14 @@ Cluster::member(PublicKey const& identity) const return iter->name(); } +bool +Cluster::isMember(PublicKey const& identity) const +{ + std::scoped_lock const lock(mutex_); + + return nodes_.contains(identity); +} + std::size_t Cluster::size() const { diff --git a/src/xrpld/overlay/detail/OverlayImpl.cpp b/src/xrpld/overlay/detail/OverlayImpl.cpp index 32b20127f4..ab8ddf90aa 100644 --- a/src/xrpld/overlay/detail/OverlayImpl.cpp +++ b/src/xrpld/overlay/detail/OverlayImpl.cpp @@ -322,7 +322,7 @@ OverlayImpl::onHandoff( { // The node gets a reserved slot if it is in our cluster // or if it has a reservation. - bool const reserved = static_cast(app_.getCluster().member(publicKey)) || + bool const reserved = app_.getCluster().isMember(publicKey) || app_.getPeerReservations().contains(publicKey); auto const result = peerFinder_->activate(slot, publicKey, reserved); if (result != peer_finder::Result::Success) diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index 72d60ec257..d76c1025e0 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -454,7 +454,7 @@ PeerImp::crawl() const bool PeerImp::cluster() const { - return static_cast(app_.getCluster().member(publicKey_)); + return app_.getCluster().isMember(publicKey_); } std::string @@ -1580,6 +1580,10 @@ PeerImp::handleTransaction( } catch (std::exception const& ex) { + if (fee_.fee < resource::kFeeInvalidData) + { + fee_.update(resource::kFeeInvalidData, "tx invalid"); + } JLOG(pJournal_.warn()) << "Transaction invalid: " << strHex(m->rawtransaction()) << ". Exception: " << ex.what(); } @@ -1653,10 +1657,21 @@ PeerImp::onMessage(std::shared_ptr const& m) // Verify ledger node counts. Full parsing of the node IDs is deferred to the job, so the I/O // thread is not burdened with SHAMapNodeID deserialization for every TMGetLedger message. - if (itype != protocol::liBASE && m->nodeids_size() <= 0) + if (itype != protocol::liBASE) { - badData("Invalid ledger node IDs"); - return; + if (m->nodeids_size() <= 0) + { + badData("Invalid ledger node IDs"); + return; + } + + if (m->nodeids_size() > tuning::kHardMaxReplyNodes) + { + badData( + "Requested number of ledger node IDs must be less than or equal to " + + std::to_string(tuning::kHardMaxReplyNodes)); + return; + } } // Verify query type @@ -3221,6 +3236,13 @@ PeerImp::onMessage(std::shared_ptr const& m) return; } + if (m->transactions_size() > reduce_relay::kMaxTxQueueSize) + { + JLOG(pJournal_.error()) << "TMTransactions: transaction list too large"; + fee_.update(resource::kFeeMalformedRequest, "Transaction list too large"); + return; + } + JLOG(pJournal_.trace()) << "received TMTransactions " << m->transactions_size(); overlay_.addTxMetrics(m->transactions_size()); diff --git a/src/xrpld/overlay/detail/PeerImp.h b/src/xrpld/overlay/detail/PeerImp.h index 11b3407f3f..c6945eb46f 100644 --- a/src/xrpld/overlay/detail/PeerImp.h +++ b/src/xrpld/overlay/detail/PeerImp.h @@ -763,6 +763,7 @@ private: std::shared_ptr getTxSet(std::shared_ptr const& m) const; +protected: void processLedgerRequest( std::shared_ptr const& m, @@ -840,14 +841,6 @@ private: std::chrono::microseconds const lookupElapsed, resource::Charge const& fee); -protected: - // Kept `protected` so test subclasses (see - // TMGetObjectByHash_test) can drive the - // synchronous processor and the differential-pricing helper without - // routing through the JobQueue or going through `friend` plumbing. - // Production callers reach these members only via - // `onMessage(TMGetObjectByHash)` → JobQueue → `processGetObjectByHash`. - /** * Process a generic-query TMGetObjectByHash message. * diff --git a/src/xrpld/overlay/detail/ProtocolMessage.h b/src/xrpld/overlay/detail/ProtocolMessage.h index 88f50e1e2e..9c9b3e0138 100644 --- a/src/xrpld/overlay/detail/ProtocolMessage.h +++ b/src/xrpld/overlay/detail/ProtocolMessage.h @@ -278,6 +278,8 @@ parseMessageContent(MessageHeader const& header, Buffers const& buffers) return {}; } + m->DiscardUnknownFields(); + return m; } diff --git a/src/xrpld/perflog/detail/PerfLogImp.cpp b/src/xrpld/perflog/detail/PerfLogImp.cpp index 42e0bf2061..189fbdac79 100644 --- a/src/xrpld/perflog/detail/PerfLogImp.cpp +++ b/src/xrpld/perflog/detail/PerfLogImp.cpp @@ -9,6 +9,7 @@ #endif #include +#include #include #include #include @@ -23,6 +24,7 @@ #include #include #include +#include #include #include @@ -33,8 +35,9 @@ #include #include #include -#include +#include #include +#include #include #include #include @@ -42,40 +45,39 @@ namespace xrpl::perf { -PerfLogImp::Counters::Counters(std::set const& labels, JobTypes const& jobTypes) +PerfLogImp::Counters::Counters( + std::span methodNames, + JobTypes const& jobTypes) { + // Only a name that got a counter is kept, so labels and rpc hold the same set + // and countersJson() reports each counter once. Keeping a repeated name would + // add its counter to the totals twice, because the assertion below is compiled + // out of a release build. + labels.reserve(methodNames.size()); + rpc.reserve(methodNames.size()); + for (auto const& name : methodNames) { - // populateRpc - rpc.reserve(labels.size()); - for (std::string const label : labels) + auto const inserted = rpc.try_emplace(name).second; + if (!inserted) { - auto const inserted = rpc.emplace(label, Rpc()).second; - if (!inserted) - { - // Ensure that no other function populates this entry. - // LCOV_EXCL_START - UNREACHABLE( - "xrpl::perf::PerfLogImp::Counters::Counters : failed to " - "insert label"); - // LCOV_EXCL_STOP - } + // LCOV_EXCL_START + UNREACHABLE("xrpl::perf::PerfLogImp::Counters::Counters : method name is unique"); + continue; + // LCOV_EXCL_STOP } + labels.push_back(name); } + + jq.reserve(jobTypes.size()); + for (auto const& [jobType, _] : jobTypes) { - // populateJq - jq.reserve(jobTypes.size()); - for (auto const& [jobType, _] : jobTypes) + auto const inserted = jq.emplace(jobType, Jq()).second; + if (!inserted) { - auto const inserted = jq.emplace(jobType, Jq()).second; - if (!inserted) - { - // Ensure that no other function populates this entry. - // LCOV_EXCL_START - UNREACHABLE( - "xrpl::perf::PerfLogImp::Counters::Counters : failed to " - "insert job type"); - // LCOV_EXCL_STOP - } + // Nothing else inserts into jq, so a job type cannot repeat. + // LCOV_EXCL_START + UNREACHABLE("xrpl::perf::PerfLogImp::Counters::Counters : failed to insert job type"); + // LCOV_EXCL_STOP } } } @@ -86,17 +88,31 @@ PerfLogImp::Counters::countersJson() const json::Value rpcobj(json::ValueType::Object); // totalRpc represents all rpc methods. All that started, finished, etc. Rpc totalRpc; - for (auto const& proc : rpc) + // Walked by label rather than by map entry, so that each key can be reported + // as a C string. The constructor gives rpc an entry per label, so the lookup + // succeeds; it is a find rather than an at() because this runs on the logging + // thread, where a throw would end the process. + for (auto const& label : labels) { + auto const entry = rpc.find(label); + if (entry == rpc.end()) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::perf::PerfLogImp::Counters::countersJson : label has a counter"); + continue; + // LCOV_EXCL_STOP + } + auto const& counter = entry->second; + Rpc value; { - std::scoped_lock const lock(proc.second.mutex); - if ((proc.second.value.started == 0u) && (proc.second.value.finished == 0u) && - (proc.second.value.errored == 0u)) + std::scoped_lock const lock(counter.mutex); + if ((counter.value.started == 0u) && (counter.value.finished == 0u) && + (counter.value.errored == 0u)) { continue; } - value = proc.second.value; + value = counter.value; } json::Value p(json::ValueType::Object); @@ -108,7 +124,7 @@ PerfLogImp::Counters::countersJson() const totalRpc.errored += value.errored; p[jss::duration_us] = std::to_string(value.duration.count()); totalRpc.duration += value.duration; - rpcobj[proc.first] = p; + rpcobj[json::StaticString{label.asCString()}] = p; } if (totalRpc.started != 0u) @@ -203,7 +219,9 @@ PerfLogImp::Counters::currentJson() const for (auto m : methods) { json::Value methodobj(json::ValueType::Object); - methodobj[jss::method] = m.first; + // A key of rpc, per methods' declaration, so borrowed as above. + // NOLINTNEXTLINE(bugprone-suspicious-stringview-data-usage) + methodobj[jss::method] = json::StaticString{m.first.data()}; methodobj[jss::duration_us] = std::to_string(std::chrono::duration_cast(present - m.second).count()); methodsArray.append(methodobj); @@ -307,9 +325,14 @@ PerfLogImp::report() PerfLogImp::PerfLogImp( Setup setup, Application& app, + std::span methodNames, beast::Journal journal, std::function&& signalStop) - : setup_(std::move(setup)), app_(app), j_(journal), signalStop_(std::move(signalStop)) + : setup_(std::move(setup)) + , app_(app) + , j_(journal) + , signalStop_(std::move(signalStop)) + , counters_(methodNames, JobTypes::instance()) { openLog(); } @@ -320,7 +343,7 @@ PerfLogImp::~PerfLogImp() } void -PerfLogImp::rpcStart(std::string const& method, std::uint64_t const requestId) +PerfLogImp::rpcStart(std::string_view method, std::uint64_t const requestId) { auto counter = counters_.rpc.find(method); if (counter == counters_.rpc.end()) @@ -337,7 +360,8 @@ PerfLogImp::rpcStart(std::string const& method, std::uint64_t const requestId) } { std::scoped_lock const lock(counters_.methodsMutex); - counters_.methods[requestId] = {counter->first.c_str(), steady_clock::now()}; + // The key, not the method argument: what is stored has to outlive the call. + counters_.methods[requestId] = {counter->first, steady_clock::now()}; } // Record RPC start in OTel metrics pipeline. Recorded after the locks @@ -357,7 +381,7 @@ PerfLogImp::rpcStart(std::string const& method, std::uint64_t const requestId) } void -PerfLogImp::rpcEnd(std::string const& method, std::uint64_t const requestId, bool finish) +PerfLogImp::rpcEnd(std::string_view method, std::uint64_t const requestId, bool finish) { auto counter = counters_.rpc.find(method); if (counter == counters_.rpc.end()) @@ -590,10 +614,11 @@ std::unique_ptr makePerfLog( PerfLog::Setup const& setup, Application& app, + std::span methodNames, beast::Journal journal, std::function&& signalStop) { - return std::make_unique(setup, app, journal, std::move(signalStop)); + return std::make_unique(setup, app, methodNames, journal, std::move(signalStop)); } } // namespace xrpl::perf diff --git a/src/xrpld/perflog/detail/PerfLogImp.h b/src/xrpld/perflog/detail/PerfLogImp.h index 937aac6f23..4c92e3ad56 100644 --- a/src/xrpld/perflog/detail/PerfLogImp.h +++ b/src/xrpld/perflog/detail/PerfLogImp.h @@ -1,7 +1,6 @@ #pragma once -#include - +#include #include #include #include @@ -15,8 +14,9 @@ #include #include #include -#include +#include #include +#include #include #include #include @@ -59,7 +59,8 @@ class PerfLogImp : public PerfLog struct Counters { public: - using MethodStart = std::pair; + using MethodStart = std::pair; + /** * RPC performance counters. */ @@ -91,14 +92,27 @@ class PerfLogImp : public PerfLog // rpc and jq do not need mutex protection because all // keys and values are created before more threads are started. - std::unordered_map> rpc; + // + // Every key views the characters of a name in labels below, which the caller + // guarantees outlive this object, so the map copies no name to store one and + // needs no string to look one up. + std::unordered_map> rpc; + + // The same names, in the order the caller gave them, and still carrying the + // proof that each reaches its terminating null. countersJson() walks these + // rather than rpc, so that it can report a key as a C string. Held by value, + // so that a caller may build the range it passes on the fly: only the names + // have to outlive this object, not the container that carried them. + std::vector labels; std::unordered_map> jq; std::vector> jobs; mutable std::mutex jobsMutex; + // Each view is a key of rpc above, not the argument rpcStart() received, so + // currentJson() may read it as a C string. std::unordered_map methods; mutable std::mutex methodsMutex; - Counters(std::set const& labels, JobTypes const& jobTypes); + Counters(std::span labels, JobTypes const& jobTypes); json::Value countersJson() const; json::Value @@ -109,7 +123,7 @@ class PerfLogImp : public PerfLog Application& app_; beast::Journal const j_; std::function const signalStop_; - Counters counters_{xrpl::rpc::getHandlerNames(), JobTypes::instance()}; + Counters counters_; std::ofstream logFile_; std::thread thread_; std::mutex mutex_; @@ -126,28 +140,35 @@ class PerfLogImp : public PerfLog void report(); void - rpcEnd(std::string const& method, std::uint64_t const requestId, bool finish); + rpcEnd(std::string_view method, std::uint64_t const requestId, bool finish); public: + /** + * @param methodNames The RPC methods to count, one counter per name. The + * names must outlive this object, which holds views of them. Passed + * in rather than looked up here so that this layer needs to know + * nothing about the RPC dispatch table. + */ PerfLogImp( Setup setup, Application& app, + std::span methodNames, beast::Journal journal, std::function&& signalStop); ~PerfLogImp() override; void - rpcStart(std::string const& method, std::uint64_t const requestId) override; + rpcStart(std::string_view method, std::uint64_t const requestId) override; void - rpcFinish(std::string const& method, std::uint64_t const requestId) override + rpcFinish(std::string_view method, std::uint64_t const requestId) override { rpcEnd(method, requestId, true); } void - rpcError(std::string const& method, std::uint64_t const requestId) override + rpcError(std::string_view method, std::uint64_t const requestId) override { rpcEnd(method, requestId, false); } diff --git a/src/xrpld/rpc/MethodNames.h b/src/xrpld/rpc/MethodNames.h new file mode 100644 index 0000000000..5dc2d9ff88 --- /dev/null +++ b/src/xrpld/rpc/MethodNames.h @@ -0,0 +1,95 @@ +#pragma once + +#include + +namespace xrpl::rpc::method { + +/** + * Names of the RPC methods the server accepts. + * + * Defined here so the dispatch table in Handler.cpp and the command-line parser + * table in RPCCall.cpp name each method through the same constant, and cannot + * drift apart. + * + * Not every method appears in both tables. Whether a method has a command-line + * form is recorded by Handler::hasCommandLineForm in the dispatch table, and + * checked against the command-line table by RPCCall_test. + */ + +inline constexpr std::string_view kAccountChannels{"account_channels"}; +inline constexpr std::string_view kAccountCurrencies{"account_currencies"}; +inline constexpr std::string_view kAccountInfo{"account_info"}; +inline constexpr std::string_view kAccountLines{"account_lines"}; +inline constexpr std::string_view kAccountNfts{"account_nfts"}; +inline constexpr std::string_view kAccountObjects{"account_objects"}; +inline constexpr std::string_view kAccountOffers{"account_offers"}; +inline constexpr std::string_view kAccountTx{"account_tx"}; +inline constexpr std::string_view kAmmInfo{"amm_info"}; +inline constexpr std::string_view kBlacklist{"blacklist"}; +inline constexpr std::string_view kBookChanges{"book_changes"}; +inline constexpr std::string_view kBookOffers{"book_offers"}; +inline constexpr std::string_view kCanDelete{"can_delete"}; +inline constexpr std::string_view kChannelAuthorize{"channel_authorize"}; +inline constexpr std::string_view kChannelVerify{"channel_verify"}; +inline constexpr std::string_view kConnect{"connect"}; +inline constexpr std::string_view kConsensusInfo{"consensus_info"}; +inline constexpr std::string_view kDepositAuthorized{"deposit_authorized"}; +inline constexpr std::string_view kFeature{"feature"}; +inline constexpr std::string_view kFee{"fee"}; +inline constexpr std::string_view kFetchInfo{"fetch_info"}; +inline constexpr std::string_view kGatewayBalances{"gateway_balances"}; +inline constexpr std::string_view kGetAggregatePrice{"get_aggregate_price"}; +inline constexpr std::string_view kGetCounts{"get_counts"}; +inline constexpr std::string_view kInternal{"internal"}; // command-line wrapper +inline constexpr std::string_view kJson{"json"}; // command-line wrapper +inline constexpr std::string_view kJson2{"json2"}; // command-line wrapper +inline constexpr std::string_view kLedger{"ledger"}; +inline constexpr std::string_view kLedgerAccept{"ledger_accept"}; +inline constexpr std::string_view kLedgerCleaner{"ledger_cleaner"}; +inline constexpr std::string_view kLedgerClosed{"ledger_closed"}; +inline constexpr std::string_view kLedgerCurrent{"ledger_current"}; +inline constexpr std::string_view kLedgerData{"ledger_data"}; +inline constexpr std::string_view kLedgerEntry{"ledger_entry"}; +inline constexpr std::string_view kLedgerHeader{"ledger_header"}; +inline constexpr std::string_view kLedgerRequest{"ledger_request"}; +inline constexpr std::string_view kLogLevel{"log_level"}; +inline constexpr std::string_view kLogrotate{"logrotate"}; +inline constexpr std::string_view kManifest{"manifest"}; +inline constexpr std::string_view kNftBuyOffers{"nft_buy_offers"}; +inline constexpr std::string_view kNftSellOffers{"nft_sell_offers"}; +inline constexpr std::string_view kNorippleCheck{"noripple_check"}; +inline constexpr std::string_view kOwnerInfo{"owner_info"}; +inline constexpr std::string_view kPathFind{"path_find"}; +inline constexpr std::string_view kPeerReservationsAdd{"peer_reservations_add"}; +inline constexpr std::string_view kPeerReservationsDel{"peer_reservations_del"}; +inline constexpr std::string_view kPeerReservationsList{"peer_reservations_list"}; +inline constexpr std::string_view kPeers{"peers"}; +inline constexpr std::string_view kPing{"ping"}; +inline constexpr std::string_view kPrint{"print"}; +inline constexpr std::string_view kRandom{"random"}; +inline constexpr std::string_view kRipplePathFind{"ripple_path_find"}; +inline constexpr std::string_view kServerDefinitions{"server_definitions"}; +inline constexpr std::string_view kServerInfo{"server_info"}; +inline constexpr std::string_view kServerState{"server_state"}; +inline constexpr std::string_view kSign{"sign"}; +inline constexpr std::string_view kSignFor{"sign_for"}; +inline constexpr std::string_view kSimulate{"simulate"}; +inline constexpr std::string_view kStop{"stop"}; +inline constexpr std::string_view kSubmit{"submit"}; +inline constexpr std::string_view kSubmitMultisigned{"submit_multisigned"}; +inline constexpr std::string_view kSubscribe{"subscribe"}; +inline constexpr std::string_view kTransactionEntry{"transaction_entry"}; +inline constexpr std::string_view kTx{"tx"}; +inline constexpr std::string_view kTxHistory{"tx_history"}; +inline constexpr std::string_view kTxReduceRelay{"tx_reduce_relay"}; +inline constexpr std::string_view kUnlList{"unl_list"}; +inline constexpr std::string_view kUnsubscribe{"unsubscribe"}; +inline constexpr std::string_view kValidationCreate{"validation_create"}; +inline constexpr std::string_view kValidatorInfo{"validator_info"}; +inline constexpr std::string_view kValidatorListSites{"validator_list_sites"}; +inline constexpr std::string_view kValidators{"validators"}; +inline constexpr std::string_view kVaultInfo{"vault_info"}; +inline constexpr std::string_view kVersion{"version"}; +inline constexpr std::string_view kWalletPropose{"wallet_propose"}; + +} // namespace xrpl::rpc::method diff --git a/src/xrpld/rpc/RPCCall.h b/src/xrpld/rpc/RPCCall.h index 2fec78f93b..b3678efa25 100644 --- a/src/xrpld/rpc/RPCCall.h +++ b/src/xrpld/rpc/RPCCall.h @@ -10,7 +10,9 @@ #include #include +#include #include +#include #include #include #include @@ -56,6 +58,15 @@ rpcCmdToJson( unsigned int apiVersion, beast::Journal j); +/** + * Return the names of all methods accepted on the command line. + * + * The names view refers to storage that outlives the program, so it is safe to + * hold on to. + */ +std::span +commandLineMethodNames(); + /** * Internal invocation of RPC client. * Used by both xrpld command line as well as xrpld unit tests diff --git a/src/xrpld/rpc/RPCHandler.h b/src/xrpld/rpc/RPCHandler.h index 637a492943..483e7e5baa 100644 --- a/src/xrpld/rpc/RPCHandler.h +++ b/src/xrpld/rpc/RPCHandler.h @@ -6,7 +6,7 @@ #include -#include +#include namespace xrpl::rpc { @@ -19,6 +19,6 @@ Status doCommand(rpc::JsonContext&, json::Value&); Role -roleRequired(unsigned int version, bool betaEnabled, std::string const& method); +roleRequired(unsigned int version, bool betaEnabled, std::string_view method); } // namespace xrpl::rpc diff --git a/src/xrpld/rpc/detail/Handler.cpp b/src/xrpld/rpc/detail/Handler.cpp index 326af4f4ee..3c0b41a149 100644 --- a/src/xrpld/rpc/detail/Handler.cpp +++ b/src/xrpld/rpc/detail/Handler.cpp @@ -1,50 +1,57 @@ #include #include +#include #include +#include #include #include #include -#include +#include #include #include #include #include +#include #include -#include -#include -#include +#include +#include +#include #include namespace xrpl::rpc { namespace { +// Shorthand: the tables below name this type once per entry. +using Method = Handler::Method; + /** * Adjust an old-style handler to be call-by-reference. + * + * The handler is a template parameter rather than an argument, so that byRef + * names a plain function instead of returning a closure over it. */ -template -Handler::Method -byRef(Function const& f) +template +Status +byRef(JsonContext& context, json::Value& result) { - return [f](JsonContext& context, json::Value& result) { - result = f(context); - if (result.type() != json::ValueType::Object) - { - // LCOV_EXCL_START - UNREACHABLE("xrpl::rpc::byRef : result is object"); - result = rpc::makeObjectValue(result); - // LCOV_EXCL_STOP - } + result = Function(context); + if (result.type() != json::ValueType::Object) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::rpc::byRef : result is object"); + result = rpc::makeObjectValue(result); + // LCOV_EXCL_STOP + } - return Status(); - }; + return Status(); } -template +template Status -handle(JsonContext& context, Object& object) +handle(JsonContext& context, json::Value& object) { XRPL_ASSERT( context.apiVersion >= HandlerImpl::minApiVer && @@ -65,427 +72,581 @@ handle(JsonContext& context, Object& object) } template -Handler +constexpr Handler handlerFrom() { + static_assert(HandlerImpl::minApiVer <= HandlerImpl::maxApiVer); + static_assert(HandlerImpl::maxApiVer <= rpc::kApiMaximumValidVersion); + static_assert(rpc::kApiMinimumSupportedVersion <= HandlerImpl::minApiVer); + return { HandlerImpl::name, - &handle, + Method::of<&handle>(), HandlerImpl::role, HandlerImpl::condition, HandlerImpl::minApiVer, - HandlerImpl::maxApiVer}; + HandlerImpl::maxApiVer, + }; } -Handler const kHandlerArray[]{ - // Some handlers not specified here are added to the table via addHandler() +// The handlers that name the function they dispatch to. The order is free: +// getHandler() searches kHandlers below, which is this array and the next one +// sorted together. +constexpr auto kFunctionHandlerArray = std::to_array({ // Request-response methods - {.name = "account_info", - .valueMethod = byRef(&doAccountInfo), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "account_currencies", - .valueMethod = byRef(&doAccountCurrencies), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "account_lines", - .valueMethod = byRef(&doAccountLines), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "account_channels", - .valueMethod = byRef(&doAccountChannels), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "account_nfts", - .valueMethod = byRef(&doAccountNFTs), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "account_objects", - .valueMethod = byRef(&doAccountObjects), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "account_offers", - .valueMethod = byRef(&doAccountOffers), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "account_tx", - .valueMethod = byRef(&doAccountTx), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "amm_info", - .valueMethod = byRef(&doAMMInfo), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "blacklist", - .valueMethod = byRef(&doBlackList), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "book_changes", - .valueMethod = byRef(&doBookChanges), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "book_offers", - .valueMethod = byRef(&doBookOffers), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "can_delete", - .valueMethod = byRef(&doCanDelete), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "channel_authorize", - .valueMethod = byRef(&doChannelAuthorize), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "channel_verify", - .valueMethod = byRef(&doChannelVerify), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "connect", - .valueMethod = byRef(&doConnect), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "consensus_info", - .valueMethod = byRef(&doConsensusInfo), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "deposit_authorized", - .valueMethod = byRef(&doDepositAuthorized), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "feature", - .valueMethod = byRef(&doFeature), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "fee", - .valueMethod = byRef(&doFee), - .role = Role::USER, - .condition = Condition::NeedsCurrentLedger}, - {.name = "fetch_info", - .valueMethod = byRef(&doFetchInfo), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "gateway_balances", - .valueMethod = byRef(&doGatewayBalances), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "get_counts", - .valueMethod = byRef(&doGetCounts), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "get_aggregate_price", - .valueMethod = byRef(&doGetAggregatePrice), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "ledger_accept", - .valueMethod = byRef(&doLedgerAccept), - .role = Role::ADMIN, - .condition = Condition::NeedsCurrentLedger}, - {.name = "ledger_cleaner", - .valueMethod = byRef(&doLedgerCleaner), - .role = Role::ADMIN, - .condition = Condition::NeedsNetworkConnection}, - {.name = "ledger_closed", - .valueMethod = byRef(&doLedgerClosed), - .role = Role::USER, - .condition = Condition::NeedsClosedLedger}, - {.name = "ledger_current", - .valueMethod = byRef(&doLedgerCurrent), - .role = Role::USER, - .condition = Condition::NeedsCurrentLedger}, - {.name = "ledger_data", - .valueMethod = byRef(&doLedgerData), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "ledger_entry", - .valueMethod = byRef(&doLedgerEntry), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "ledger_header", - .valueMethod = byRef(&doLedgerHeader), - .role = Role::USER, - .condition = Condition::NoCondition, - .minApiVer = 1, - .maxApiVer = 1}, - {.name = "ledger_request", - .valueMethod = byRef(&doLedgerRequest), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "log_level", - .valueMethod = byRef(&doLogLevel), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "logrotate", - .valueMethod = byRef(&doLogRotate), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "manifest", - .valueMethod = byRef(&doManifest), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "nft_buy_offers", - .valueMethod = byRef(&doNFTBuyOffers), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "nft_sell_offers", - .valueMethod = byRef(&doNFTSellOffers), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "noripple_check", - .valueMethod = byRef(&doNoRippleCheck), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "owner_info", - .valueMethod = byRef(&doOwnerInfo), - .role = Role::USER, - .condition = Condition::NeedsCurrentLedger}, - {.name = "peers", - .valueMethod = byRef(&doPeers), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "path_find", - .valueMethod = byRef(&doPathFind), - .role = Role::USER, - .condition = Condition::NeedsCurrentLedger}, - {.name = "ping", - .valueMethod = byRef(&doPing), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "print", - .valueMethod = byRef(&doPrint), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - // { "profile", byRef (&doProfile), Role::USER, - // NEEDS_CURRENT_LEDGER }, - {.name = "random", - .valueMethod = byRef(&doRandom), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "peer_reservations_add", - .valueMethod = byRef(&doPeerReservationsAdd), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "peer_reservations_del", - .valueMethod = byRef(&doPeerReservationsDel), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "peer_reservations_list", - .valueMethod = byRef(&doPeerReservationsList), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "ripple_path_find", - .valueMethod = byRef(&doRipplePathFind), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "server_definitions", - .valueMethod = byRef(&doServerDefinitions), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "server_info", - .valueMethod = byRef(&doServerInfo), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "server_state", - .valueMethod = byRef(&doServerState), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "sign", - .valueMethod = byRef(&doSign), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "sign_for", - .valueMethod = byRef(&doSignFor), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "simulate", - .valueMethod = byRef(&doSimulate), - .role = Role::USER, - .condition = Condition::NeedsCurrentLedger}, - {.name = "stop", - .valueMethod = byRef(&doStop), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "submit", - .valueMethod = byRef(&doSubmit), - .role = Role::USER, - .condition = Condition::NeedsCurrentLedger}, - {.name = "submit_multisigned", - .valueMethod = byRef(&doSubmitMultiSigned), - .role = Role::USER, - .condition = Condition::NeedsCurrentLedger}, - {.name = "transaction_entry", - .valueMethod = byRef(&doTransactionEntry), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "tx", - .valueMethod = byRef(&doTxJson), - .role = Role::USER, - .condition = Condition::NeedsNetworkConnection}, - {.name = "tx_history", - .valueMethod = byRef(&doTxHistory), - .role = Role::USER, - .condition = Condition::NoCondition, - .minApiVer = 1, - .maxApiVer = 1}, - {.name = "tx_reduce_relay", - .valueMethod = byRef(&doTxReduceRelay), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "unl_list", - .valueMethod = byRef(&doUnlList), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "validation_create", - .valueMethod = byRef(&doValidationCreate), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "validators", - .valueMethod = byRef(&doValidators), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "validator_list_sites", - .valueMethod = byRef(&doValidatorListSites), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "validator_info", - .valueMethod = byRef(&doValidatorInfo), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, - {.name = "vault_info", - .valueMethod = byRef(&doVaultInfo), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "wallet_propose", - .valueMethod = byRef(&doWalletPropose), - .role = Role::ADMIN, - .condition = Condition::NoCondition}, + { + .name = method::kAccountInfo, + .valueMethod = Method::of<&byRef<&doAccountInfo>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kAccountCurrencies, + .valueMethod = Method::of<&byRef<&doAccountCurrencies>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kAccountLines, + .valueMethod = Method::of<&byRef<&doAccountLines>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kAccountChannels, + .valueMethod = Method::of<&byRef<&doAccountChannels>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kAccountNfts, + .valueMethod = Method::of<&byRef<&doAccountNFTs>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kAccountObjects, + .valueMethod = Method::of<&byRef<&doAccountObjects>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kAccountOffers, + .valueMethod = Method::of<&byRef<&doAccountOffers>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kAccountTx, + .valueMethod = Method::of<&byRef<&doAccountTx>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kAmmInfo, + .valueMethod = Method::of<&byRef<&doAMMInfo>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kBlacklist, + .valueMethod = Method::of<&byRef<&doBlackList>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + .hasCommandLineForm = false, + }, + { + .name = method::kBookChanges, + .valueMethod = Method::of<&byRef<&doBookChanges>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kBookOffers, + .valueMethod = Method::of<&byRef<&doBookOffers>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kCanDelete, + .valueMethod = Method::of<&byRef<&doCanDelete>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kChannelAuthorize, + .valueMethod = Method::of<&byRef<&doChannelAuthorize>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kChannelVerify, + .valueMethod = Method::of<&byRef<&doChannelVerify>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kConnect, + .valueMethod = Method::of<&byRef<&doConnect>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kConsensusInfo, + .valueMethod = Method::of<&byRef<&doConsensusInfo>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kDepositAuthorized, + .valueMethod = Method::of<&byRef<&doDepositAuthorized>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kFeature, + .valueMethod = Method::of<&byRef<&doFeature>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kFee, + .valueMethod = Method::of<&byRef<&doFee>>(), + .role = Role::USER, + .condition = Condition::NeedsCurrentLedger, + .hasCommandLineForm = false, + }, + { + .name = method::kFetchInfo, + .valueMethod = Method::of<&byRef<&doFetchInfo>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kGatewayBalances, + .valueMethod = Method::of<&byRef<&doGatewayBalances>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kGetCounts, + .valueMethod = Method::of<&byRef<&doGetCounts>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kGetAggregatePrice, + .valueMethod = Method::of<&byRef<&doGetAggregatePrice>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + .hasCommandLineForm = false, + }, + { + .name = method::kLedgerAccept, + .valueMethod = Method::of<&byRef<&doLedgerAccept>>(), + .role = Role::ADMIN, + .condition = Condition::NeedsCurrentLedger, + }, + { + .name = method::kLedgerCleaner, + .valueMethod = Method::of<&byRef<&doLedgerCleaner>>(), + .role = Role::ADMIN, + .condition = Condition::NeedsNetworkConnection, + .hasCommandLineForm = false, + }, + { + .name = method::kLedgerClosed, + .valueMethod = Method::of<&byRef<&doLedgerClosed>>(), + .role = Role::USER, + .condition = Condition::NeedsClosedLedger, + }, + { + .name = method::kLedgerCurrent, + .valueMethod = Method::of<&byRef<&doLedgerCurrent>>(), + .role = Role::USER, + .condition = Condition::NeedsCurrentLedger, + }, + { + .name = method::kLedgerData, + .valueMethod = Method::of<&byRef<&doLedgerData>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + .hasCommandLineForm = false, + }, + { + .name = method::kLedgerEntry, + .valueMethod = Method::of<&byRef<&doLedgerEntry>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kLedgerHeader, + .valueMethod = Method::of<&byRef<&doLedgerHeader>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + .minApiVer = 1, + .maxApiVer = 1, + }, + { + .name = method::kLedgerRequest, + .valueMethod = Method::of<&byRef<&doLedgerRequest>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kLogLevel, + .valueMethod = Method::of<&byRef<&doLogLevel>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kLogrotate, + .valueMethod = Method::of<&byRef<&doLogRotate>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kManifest, + .valueMethod = Method::of<&byRef<&doManifest>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kNftBuyOffers, + .valueMethod = Method::of<&byRef<&doNFTBuyOffers>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + .hasCommandLineForm = false, + }, + { + .name = method::kNftSellOffers, + .valueMethod = Method::of<&byRef<&doNFTSellOffers>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + .hasCommandLineForm = false, + }, + { + .name = method::kNorippleCheck, + .valueMethod = Method::of<&byRef<&doNoRippleCheck>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + .hasCommandLineForm = false, + }, + { + .name = method::kOwnerInfo, + .valueMethod = Method::of<&byRef<&doOwnerInfo>>(), + .role = Role::USER, + .condition = Condition::NeedsCurrentLedger, + }, + { + .name = method::kPeers, + .valueMethod = Method::of<&byRef<&doPeers>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kPathFind, + .valueMethod = Method::of<&byRef<&doPathFind>>(), + .role = Role::USER, + .condition = Condition::NeedsCurrentLedger, + }, + { + .name = method::kPing, + .valueMethod = Method::of<&byRef<&doPing>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kPrint, + .valueMethod = Method::of<&byRef<&doPrint>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kRandom, + .valueMethod = Method::of<&byRef<&doRandom>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kPeerReservationsAdd, + .valueMethod = Method::of<&byRef<&doPeerReservationsAdd>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kPeerReservationsDel, + .valueMethod = Method::of<&byRef<&doPeerReservationsDel>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kPeerReservationsList, + .valueMethod = Method::of<&byRef<&doPeerReservationsList>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kRipplePathFind, + .valueMethod = Method::of<&byRef<&doRipplePathFind>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kServerDefinitions, + .valueMethod = Method::of<&byRef<&doServerDefinitions>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kServerInfo, + .valueMethod = Method::of<&byRef<&doServerInfo>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kServerState, + .valueMethod = Method::of<&byRef<&doServerState>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kSign, + .valueMethod = Method::of<&byRef<&doSign>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kSignFor, + .valueMethod = Method::of<&byRef<&doSignFor>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kSimulate, + .valueMethod = Method::of<&byRef<&doSimulate>>(), + .role = Role::USER, + .condition = Condition::NeedsCurrentLedger, + }, + { + .name = method::kStop, + .valueMethod = Method::of<&byRef<&doStop>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kSubmit, + .valueMethod = Method::of<&byRef<&doSubmit>>(), + .role = Role::USER, + .condition = Condition::NeedsCurrentLedger, + }, + { + .name = method::kSubmitMultisigned, + .valueMethod = Method::of<&byRef<&doSubmitMultiSigned>>(), + .role = Role::USER, + .condition = Condition::NeedsCurrentLedger, + }, + { + .name = method::kTransactionEntry, + .valueMethod = Method::of<&byRef<&doTransactionEntry>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kTx, + .valueMethod = Method::of<&byRef<&doTxJson>>(), + .role = Role::USER, + .condition = Condition::NeedsNetworkConnection, + }, + { + .name = method::kTxHistory, + .valueMethod = Method::of<&byRef<&doTxHistory>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + .minApiVer = 1, + .maxApiVer = 1, + }, + { + .name = method::kTxReduceRelay, + .valueMethod = Method::of<&byRef<&doTxReduceRelay>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + .hasCommandLineForm = false, + }, + { + .name = method::kUnlList, + .valueMethod = Method::of<&byRef<&doUnlList>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kValidationCreate, + .valueMethod = Method::of<&byRef<&doValidationCreate>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kValidators, + .valueMethod = Method::of<&byRef<&doValidators>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + .hasCommandLineForm = false, + }, + { + .name = method::kValidatorListSites, + .valueMethod = Method::of<&byRef<&doValidatorListSites>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + .hasCommandLineForm = false, + }, + { + .name = method::kValidatorInfo, + .valueMethod = Method::of<&byRef<&doValidatorInfo>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, + { + .name = method::kVaultInfo, + .valueMethod = Method::of<&byRef<&doVaultInfo>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kWalletPropose, + .valueMethod = Method::of<&byRef<&doWalletPropose>>(), + .role = Role::ADMIN, + .condition = Condition::NoCondition, + }, // Event methods - {.name = "subscribe", - .valueMethod = byRef(&doSubscribe), - .role = Role::USER, - .condition = Condition::NoCondition}, - {.name = "unsubscribe", - .valueMethod = byRef(&doUnsubscribe), - .role = Role::USER, - .condition = Condition::NoCondition}, -}; + { + .name = method::kSubscribe, + .valueMethod = Method::of<&byRef<&doSubscribe>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, + { + .name = method::kUnsubscribe, + .valueMethod = Method::of<&byRef<&doUnsubscribe>>(), + .role = Role::USER, + .condition = Condition::NoCondition, + }, +}); -class HandlerTable +// The class-based handlers, which carry their name and API range as static +// members rather than as a table entry, so they cannot go in the array above. +constexpr auto kClassHandlerArray = std::to_array({ + handlerFrom(), + handlerFrom(), +}); + +/** + * Join the two handler arrays above into one. + * + * Handler has no default constructor, so every entry is built in place from an + * index pack rather than the array being sized and then copied into. The packs + * come from the arrays themselves, so adding a handler to either needs no change + * here. + * + * @return kFunctionHandlerArray followed by kClassHandlerArray. + */ +constexpr auto +joinHandlers() { -private: - using handler_table_t = std::multimap; + constexpr auto kFunctionIndices = std::make_index_sequence{}; + constexpr auto kClassIndices = std::make_index_sequence{}; - // Use with equal_range to enforce that API range of a newly added handler - // does not overlap with API range of an existing handler with same name - [[nodiscard]] static bool - overlappingApiVersion( - std::pair range, - unsigned minVer, - unsigned maxVer) - { - XRPL_ASSERT(minVer <= maxVer, "xrpl::rpc::HandlerTable : valid API version range"); - XRPL_ASSERT( - maxVer <= rpc::kApiMaximumValidVersion, - "xrpl::rpc::HandlerTable : valid max API version"); + return []( + std::index_sequence, std::index_sequence) { + return std::array{ + kFunctionHandlerArray[Function]..., kClassHandlerArray[Class]...}; + }(kFunctionIndices, kClassIndices); +} - return std::any_of( - range.first, - range.second, // - [minVer, maxVer](auto const& item) { - return item.second.minApiVer <= maxVer && item.second.maxApiVer >= minVer; - }); - } +// The whole dispatch table. +constexpr auto kHandlers = [] { + auto all = joinHandlers(); - template - explicit HandlerTable(Handler const (&entries)[N]) - { - for (auto const& entry : entries) + // Sorted by name, so a handler can be found by binary search. + std::ranges::sort(all, {}, &Handler::name); + return all; +}(); + +// getHandler() relies on this being sorted to binary search it, and +// kHandlerNames below inherits the order. +static_assert( + std::ranges::is_sorted(kHandlers, {}, &Handler::name), + "xrpl::rpc : kHandlers must be sorted by name"); + +// A name must select exactly one handler, otherwise a request would have two +// answers. Where a method's behaviour differs by API version, the handler +// branches on context.apiVersion rather than being registered once per range. +// Checked here, at compile time, rather than on the first dispatch. +// +// The method is not checked: Handler::Method has no default constructor, so an +// entry that omits it does not compile. +static_assert( + [] { + for (std::size_t i = 0; i < kHandlers.size(); ++i) { - if (overlappingApiVersion( - table_.equal_range(entry.name), entry.minApiVer, entry.maxApiVer)) - { - logicError( - std::string("Handler for ") + entry.name + - " overlaps with an existing handler"); - } + auto const& h = kHandlers[i]; + if (h.name.empty() || h.minApiVer > h.maxApiVer || + h.maxApiVer > rpc::kApiMaximumValidVersion || + h.minApiVer < rpc::kApiMinimumSupportedVersion) + return false; - table_.insert({entry.name, entry}); + // Sorted, so a repeat can only be of the preceding entry. + if (i > 0 && kHandlers[i - 1].name == h.name) + return false; } + return true; + }(), + "xrpl::rpc : every handler needs a unique name and a valid API version range"); - // This is where the new-style handlers are added. - addHandler(); - addHandler(); - } +/** + * Convert the handler names to a form that may be read as C strings. + * + * NullTerminatedView's constructor rejects a name that does not reach its + * terminating null, so this replaces the separate assertion that used to check + * the same property. It is consteval because that constructor is. + * + * @tparam I The indices of kHandlers. + * @return The names, in the order kHandlers holds them, which is sorted. + */ +template +consteval auto +checkedHandlerNames(std::index_sequence) +{ + return std::array{NullTerminatedView{kHandlers[I].name}...}; +} -public: - static HandlerTable const& - instance() - { - static HandlerTable const kHandlerTable(kHandlerArray); - return kHandlerTable; - } - - [[nodiscard]] Handler const* - getHandler(unsigned version, bool betaEnabled, std::string const& name) const - { - if (version < rpc::kApiMinimumSupportedVersion || - version > (betaEnabled ? rpc::kApiBetaVersion : rpc::kApiMaximumSupportedVersion)) - return nullptr; - - auto const range = table_.equal_range(name); - auto const i = std::find_if(range.first, range.second, [version](auto const& entry) { - return entry.second.minApiVer <= version && version <= entry.second.maxApiVer; - }); - - return i == range.second ? nullptr : &i->second; - } - - [[nodiscard]] std::set - getHandlerNames() const - { - std::set ret; - for (auto const& i : table_) - ret.insert(i.second.name); - - return ret; - } - -private: - handler_table_t table_; - - template - void - addHandler() - { - static_assert(HandlerImpl::minApiVer <= HandlerImpl::maxApiVer); - static_assert(HandlerImpl::maxApiVer <= rpc::kApiMaximumValidVersion); - static_assert(rpc::kApiMinimumSupportedVersion <= HandlerImpl::minApiVer); - - if (overlappingApiVersion( - table_.equal_range(HandlerImpl::name), - HandlerImpl::minApiVer, - HandlerImpl::maxApiVer)) - { - logicError( - std::string("Handler for ") + HandlerImpl::name + - " overlaps with an existing handler"); - } - - table_.insert({HandlerImpl::name, handlerFrom()}); - } -}; +// The handler names, which are already distinct and sorted. +constexpr auto kHandlerNames = checkedHandlerNames(std::make_index_sequence{}); } // namespace Handler const* -getHandler(unsigned version, bool betaEnabled, std::string const& name) +getHandler(unsigned version, bool betaEnabled, std::string_view name) { - return HandlerTable::instance().getHandler(version, betaEnabled, name); + if (version < rpc::kApiMinimumSupportedVersion || + version > (betaEnabled ? rpc::kApiBetaVersion : rpc::kApiMaximumSupportedVersion)) + return nullptr; + + // Names are unique, so the binary search finds the only candidate; it then + // answers this request only if it serves this version. + auto const i = std::ranges::lower_bound(kHandlers, name, {}, &Handler::name); + if (i == kHandlers.end() || i->name != name) + return nullptr; + + if (i->minApiVer <= version && version <= i->maxApiVer) + return &*i; + + return nullptr; } -std::set +std::span getHandlerNames() { - return HandlerTable::instance().getHandlerNames(); + return kHandlerNames; } } // namespace xrpl::rpc diff --git a/src/xrpld/rpc/detail/Handler.h b/src/xrpld/rpc/detail/Handler.h index 7342c5fcbf..593eafb4a4 100644 --- a/src/xrpld/rpc/detail/Handler.h +++ b/src/xrpld/rpc/detail/Handler.h @@ -6,19 +6,15 @@ #include #include +#include #include #include #include #include #include -#include -#include -#include - -namespace json { -class Object; -} // namespace json +#include +#include namespace xrpl::rpc { @@ -32,20 +28,89 @@ enum class Condition { struct Handler { - template - using Method = std::function; + /** + * The function a handler dispatches to. + * + * A plain function pointer, not a std::function: every method is a free + * function known at compile time, so nothing needs to be captured. That + * keeps Handler a literal type, letting the dispatch table be built and + * checked at compile time. + * + * of() takes the function as a template argument, and there is no default + * constructor, so a table entry that omits its method does not compile. + * + * The pointer is not also checked against null, because gcc under + * -fsanitize=undefined does not fold the address of a function template + * instantiation in a constant expression. A null check in the table + * assertion, or a requires clause on Fn, both fail to compile there. + */ + class Method + { + public: + using Function = Status (*)(JsonContext&, json::Value&); - char const* name; - Method valueMethod; + /** + * Build a Method that calls a given function. + * + * @tparam Fn The function to call. + * @return The Method. + */ + template + static constexpr Method + of() noexcept + { + return Method{Fn}; + } + + /** + * Call the function. + * + * @param context The request being served. + * @param result The object the function writes its reply into. + * @return The status the function returns. + */ + Status + operator()(JsonContext& context, json::Value& result) const + { + return fn_(context, result); + } + + private: + constexpr explicit Method(Function fn) noexcept : fn_(fn) + { + } + + Function fn_; + }; + + std::string_view name; + Method valueMethod; Role role; rpc::Condition condition; unsigned minApiVer = kApiMinimumSupportedVersion; unsigned maxApiVer = kApiMaximumValidVersion; + + // Whether the command-line client accepts this method as a command. The + // exceptions are methods whose arguments have no positional form. A field + // rather than a comment, so that RPCCall_test can check it against the + // command-line table in both directions. + bool hasCommandLineForm = true; }; +/** + * Find the handler that answers a method at an API version. + * + * @param version The API version the request asks for. + * @param betaEnabled Whether the beta API version is enabled, without which + * @p version cannot exceed kApiMaximumSupportedVersion. + * @param name The method name, matched exactly. + * @return The handler, or nullptr if the version is not served, no method has + * this name, or the method is not served at this version. The pointer is + * into the dispatch table, so it outlives every caller. + */ Handler const* -getHandler(unsigned int version, bool betaEnabled, std::string const&); +getHandler(unsigned int version, bool betaEnabled, std::string_view name); /** * Return a json::ValueType::Object with a single entry. @@ -60,9 +125,13 @@ makeObjectValue(Value const& value, json::StaticString const& field = jss::messa } /** - * Return names of all methods. + * Return the names of all methods, sorted and without duplicates. + * + * The names refer to storage that outlives the program, so they are safe to + * hold on to, and each reaches its terminating null, so a caller may read one + * as a C string. */ -std::set +std::span getHandlerNames(); template diff --git a/src/xrpld/rpc/detail/Pathfinder.cpp b/src/xrpld/rpc/detail/Pathfinder.cpp index 1f530a1165..c3e74fa4eb 100644 --- a/src/xrpld/rpc/detail/Pathfinder.cpp +++ b/src/xrpld/rpc/detail/Pathfinder.cpp @@ -862,10 +862,11 @@ Pathfinder::addPathsForType( return it->second; // Otherwise, if the type has no nodes, return the empty path. - if (pathType.empty()) - return paths_[pathType]; - if (continueCallback && !continueCallback()) - return paths_[{}]; + if (pathType.empty() || (continueCallback && !continueCallback())) + { + static auto const kEmptyPath = PathType{}; + return paths_.try_emplace(kEmptyPath, STPathSet::DeduplicationTag{}).first->second; + } // Otherwise, get the paths for the parent PathType by calling // addPathsForType recursively. @@ -873,7 +874,7 @@ Pathfinder::addPathsForType( parentPathType.pop_back(); STPathSet const& parentPaths = addPathsForType(parentPathType, continueCallback); - STPathSet& pathsOut = paths_[pathType]; + STPathSet& pathsOut = paths_.try_emplace(pathType, STPathSet::DeduplicationTag{}).first->second; JLOG(j_.debug()) << "getPaths< adding onto '" << pathTypeToString(parentPathType) << "' to get '" << pathTypeToString(pathType) << "'"; @@ -959,15 +960,6 @@ Pathfinder::isNoRippleOut(STPath const& currentPath) return endElement.hasCurrency() && isNoRipple(fromAccount, toAccount, endElement.getCurrency()); } -void -addUniquePath(STPathSet& pathSet, STPath const& path) -{ - if (!pathSet.contains(path)) - { - pathSet.pushBack(path); - } -} - void Pathfinder::addLink( STPath const& currentPath, // The path to build from @@ -999,7 +991,7 @@ Pathfinder::addLink( { // non-default path to XRP destination JLOG(j_.trace()) << "complete path found ax: " << currentPath.getJson(JsonOptions::Values::None); - addUniquePath(completePaths_, currentPath); + completePaths_.pushBack(currentPath); } } else @@ -1107,7 +1099,7 @@ Pathfinder::addLink( JLOG(j_.trace()) << "complete path found ae: " << currentPath.getJson(JsonOptions::Values::None); - addUniquePath(completePaths_, currentPath); + completePaths_.pushBack(currentPath); } } else if (!bDestOnly) @@ -1237,11 +1229,12 @@ Pathfinder::addLink( // complete JLOG(j_.trace()) << "complete path found bx: " << currentPath.getJson(JsonOptions::Values::None); - addUniquePath(completePaths_, newPath); + completePaths_.pushBack(newPath); } else { - incompletePaths.pushBack(newPath); + [[maybe_unused]] auto result = incompletePaths.pushBack(newPath); + XRPL_ASSERT(result, "xrpl::Pathfinder::addLink : unique path"); } } else if (!currentPath.hasSeen( @@ -1283,7 +1276,7 @@ Pathfinder::addLink( // complete JLOG(j_.trace()) << "complete path found ba: " << currentPath.getJson(JsonOptions::Values::None); - addUniquePath(completePaths_, newPath); + completePaths_.pushBack(newPath); } else { diff --git a/src/xrpld/rpc/detail/Pathfinder.h b/src/xrpld/rpc/detail/Pathfinder.h index aeacd218d2..0b4da6abde 100644 --- a/src/xrpld/rpc/detail/Pathfinder.h +++ b/src/xrpld/rpc/detail/Pathfinder.h @@ -207,7 +207,7 @@ private: std::shared_ptr rLCache_; STPathElement source_; - STPathSet completePaths_; + STPathSet completePaths_{STPathSet::DeduplicationTag{}}; std::vector pathRanks_; std::map paths_; diff --git a/src/xrpld/rpc/detail/RPCCall.cpp b/src/xrpld/rpc/detail/RPCCall.cpp index a752858527..09d244b7e7 100644 --- a/src/xrpld/rpc/detail/RPCCall.cpp +++ b/src/xrpld/rpc/detail/RPCCall.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include @@ -44,10 +45,13 @@ #include #include #include +#include #include +#include #include #include #include +#include #include #include #include @@ -56,6 +60,77 @@ namespace xrpl { class RPCParser; +namespace { + +/** + * The member function a command dispatches to. + * + * of() takes the function as a template argument, and there is no default + * constructor, so a table entry that omits its parser does not compile. + * + * The pointer is not also checked against null, because gcc under + * -fsanitize=undefined does not fold a pointer to a member function in a + * constant expression. A null check in commandsValid(), or a requires clause on + * Fn, both fail to compile there. + */ +class Parse +{ +public: + using Function = json::Value (RPCParser::*)(json::Value const& jvParams); + + /** + * Build a Parse that calls a given member function. + * + * @tparam Fn The member function to call. + * @return The Parse. + */ + template + static constexpr Parse + of() noexcept + { + return Parse{Fn}; + } + + /** + * Call the parser. + * + * Defined below RPCParser, because calling one of its members needs the + * complete class. + * + * @param parser The parser to call the member function on. + * @param jvParams The command line arguments, as an array. + * @return The request, or an error. + */ + json::Value + operator()(RPCParser& parser, json::Value const& jvParams) const; + +private: + constexpr explicit Parse(Function fn) noexcept : fn_(fn) + { + } + + Function fn_; +}; + +// One command the command line accepts: the method it names, the parser that +// turns arguments into a request, and how many arguments that parser needs. +// +// Declared out here, rather than nested in RPCParser, so that the defaults +// below can be used: a default member initializer is not available while the +// enclosing class is still incomplete, which is when the table is built. +struct Command +{ + // For a command that accepts any number of parameters. + static constexpr unsigned kUnlimitedParams = std::numeric_limits::max(); + + std::string_view name; + Parse parse; + unsigned minParams = 0; + unsigned maxParams = kUnlimitedParams; +}; + +} // namespace + // // HTTP protocol // @@ -1239,7 +1314,429 @@ private: return jvRequest; } + // An omitted minParams means the command takes no arguments; an omitted + // maxParams means it takes any number. See Command. + // + // The commands. The order is free: parseCommand() searches kSortedCommands + // below. + static constexpr auto kCommandArray = std::to_array({ + // Request-response methods + // - Returns an error, or the request. + // - To modify the method, provide a new method in the request. + { + .name = rpc::method::kAccountCurrencies, + .parse = Parse::of<&RPCParser::parseAccountCurrencies>(), + .minParams = 1, + .maxParams = 3, + }, + { + .name = rpc::method::kAccountInfo, + .parse = Parse::of<&RPCParser::parseAccountItems>(), + .minParams = 1, + .maxParams = 3, + }, + { + .name = rpc::method::kAccountLines, + .parse = Parse::of<&RPCParser::parseAccountLines>(), + .minParams = 1, + .maxParams = 5, + }, + { + .name = rpc::method::kAccountChannels, + .parse = Parse::of<&RPCParser::parseAccountChannels>(), + .minParams = 1, + .maxParams = 3, + }, + { + .name = rpc::method::kAccountNfts, + .parse = Parse::of<&RPCParser::parseAccountItems>(), + .minParams = 1, + .maxParams = 5, + }, + { + .name = rpc::method::kAccountObjects, + .parse = Parse::of<&RPCParser::parseAccountItems>(), + .minParams = 1, + .maxParams = 5, + }, + { + .name = rpc::method::kAccountOffers, + .parse = Parse::of<&RPCParser::parseAccountItems>(), + .minParams = 1, + .maxParams = 4, + }, + { + .name = rpc::method::kAccountTx, + .parse = Parse::of<&RPCParser::parseAccountTransactions>(), + .minParams = 1, + .maxParams = 8, + }, + { + .name = rpc::method::kAmmInfo, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .minParams = 1, + .maxParams = 2, + }, + { + .name = rpc::method::kVaultInfo, + .parse = Parse::of<&RPCParser::parseVault>(), + .minParams = 1, + .maxParams = 2, + }, + { + .name = rpc::method::kBookChanges, + .parse = Parse::of<&RPCParser::parseLedgerId>(), + .minParams = 1, + .maxParams = 1, + }, + { + .name = rpc::method::kBookOffers, + .parse = Parse::of<&RPCParser::parseBookOffers>(), + .minParams = 2, + .maxParams = 7, + }, + { + .name = rpc::method::kCanDelete, + .parse = Parse::of<&RPCParser::parseCanDelete>(), + .maxParams = 1, + }, + { + .name = rpc::method::kChannelAuthorize, + .parse = Parse::of<&RPCParser::parseChannelAuthorize>(), + .minParams = 3, + .maxParams = 4, + }, + { + .name = rpc::method::kChannelVerify, + .parse = Parse::of<&RPCParser::parseChannelVerify>(), + .minParams = 4, + .maxParams = 4, + }, + { + .name = rpc::method::kConnect, + .parse = Parse::of<&RPCParser::parseConnect>(), + .minParams = 1, + .maxParams = 2, + }, + { + .name = rpc::method::kConsensusInfo, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .maxParams = 0, + }, + { + .name = rpc::method::kDepositAuthorized, + .parse = Parse::of<&RPCParser::parseDepositAuthorized>(), + .minParams = 2, + .maxParams = 11, + }, + { + .name = rpc::method::kFeature, + .parse = Parse::of<&RPCParser::parseFeature>(), + .maxParams = 2, + }, + { + .name = rpc::method::kFetchInfo, + .parse = Parse::of<&RPCParser::parseFetchInfo>(), + .maxParams = 1, + }, + { + .name = rpc::method::kGatewayBalances, + .parse = Parse::of<&RPCParser::parseGatewayBalances>(), + .minParams = 1, + }, + { + .name = rpc::method::kGetCounts, + .parse = Parse::of<&RPCParser::parseGetCounts>(), + .maxParams = 1, + }, + { + .name = rpc::method::kJson, + .parse = Parse::of<&RPCParser::parseJson>(), + .minParams = 2, + .maxParams = 2, + }, + { + .name = rpc::method::kJson2, + .parse = Parse::of<&RPCParser::parseJson2>(), + .minParams = 1, + .maxParams = 1, + }, + { + .name = rpc::method::kLedger, + .parse = Parse::of<&RPCParser::parseLedger>(), + .maxParams = 2, + }, + { + .name = rpc::method::kLedgerAccept, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .maxParams = 0, + }, + { + .name = rpc::method::kLedgerClosed, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .maxParams = 0, + }, + { + .name = rpc::method::kLedgerCurrent, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .maxParams = 0, + }, + { + .name = rpc::method::kLedgerEntry, + .parse = Parse::of<&RPCParser::parseLedgerEntry>(), + .minParams = 1, + .maxParams = 2, + }, + { + .name = rpc::method::kLedgerHeader, + .parse = Parse::of<&RPCParser::parseLedgerId>(), + .minParams = 1, + .maxParams = 1, + }, + { + .name = rpc::method::kLedgerRequest, + .parse = Parse::of<&RPCParser::parseLedgerId>(), + .minParams = 1, + .maxParams = 1, + }, + { + .name = rpc::method::kLogLevel, + .parse = Parse::of<&RPCParser::parseLogLevel>(), + .maxParams = 2, + }, + { + .name = rpc::method::kLogrotate, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .maxParams = 0, + }, + { + .name = rpc::method::kManifest, + .parse = Parse::of<&RPCParser::parseManifest>(), + .minParams = 1, + .maxParams = 1, + }, + { + .name = rpc::method::kOwnerInfo, + .parse = Parse::of<&RPCParser::parseAccountItems>(), + .minParams = 1, + .maxParams = 3, + }, + { + .name = rpc::method::kPeers, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .maxParams = 0, + }, + { + .name = rpc::method::kPing, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .maxParams = 0, + }, + { + .name = rpc::method::kPrint, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .maxParams = 1, + }, + { + .name = rpc::method::kRandom, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .maxParams = 0, + }, + { + .name = rpc::method::kPeerReservationsAdd, + .parse = Parse::of<&RPCParser::parsePeerReservationsAdd>(), + .minParams = 1, + .maxParams = 2, + }, + { + .name = rpc::method::kPeerReservationsDel, + .parse = Parse::of<&RPCParser::parsePeerReservationsDel>(), + .minParams = 1, + .maxParams = 1, + }, + { + .name = rpc::method::kPeerReservationsList, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .maxParams = 0, + }, + { + .name = rpc::method::kRipplePathFind, + .parse = Parse::of<&RPCParser::parseRipplePathFind>(), + .minParams = 1, + .maxParams = 2, + }, + { + .name = rpc::method::kServerDefinitions, + .parse = Parse::of<&RPCParser::parseServerDefinitions>(), + .maxParams = 1, + }, + { + .name = rpc::method::kServerInfo, + .parse = Parse::of<&RPCParser::parseServerInfo>(), + .maxParams = 1, + }, + { + .name = rpc::method::kServerState, + .parse = Parse::of<&RPCParser::parseServerInfo>(), + .maxParams = 1, + }, + { + .name = rpc::method::kSign, + .parse = Parse::of<&RPCParser::parseSignSubmit>(), + .minParams = 2, + .maxParams = 4, + }, + { + .name = rpc::method::kSignFor, + .parse = Parse::of<&RPCParser::parseSignFor>(), + .minParams = 3, + .maxParams = 4, + }, + { + .name = rpc::method::kStop, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .maxParams = 0, + }, + { + .name = rpc::method::kSimulate, + .parse = Parse::of<&RPCParser::parseSimulate>(), + .minParams = 1, + .maxParams = 2, + }, + { + .name = rpc::method::kSubmit, + .parse = Parse::of<&RPCParser::parseSignSubmit>(), + .minParams = 1, + .maxParams = 4, + }, + { + .name = rpc::method::kSubmitMultisigned, + .parse = Parse::of<&RPCParser::parseSubmitMultiSigned>(), + .minParams = 1, + .maxParams = 1, + }, + { + .name = rpc::method::kTransactionEntry, + .parse = Parse::of<&RPCParser::parseTransactionEntry>(), + .minParams = 2, + .maxParams = 2, + }, + { + .name = rpc::method::kTx, + .parse = Parse::of<&RPCParser::parseTx>(), + .minParams = 1, + .maxParams = 4, + }, + { + .name = rpc::method::kTxHistory, + .parse = Parse::of<&RPCParser::parseTxHistory>(), + .minParams = 1, + .maxParams = 1, + }, + { + .name = rpc::method::kUnlList, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .maxParams = 0, + }, + { + .name = rpc::method::kValidationCreate, + .parse = Parse::of<&RPCParser::parseValidationCreate>(), + .maxParams = 1, + }, + { + .name = rpc::method::kValidatorInfo, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .maxParams = 0, + }, + { + .name = rpc::method::kVersion, + .parse = Parse::of<&RPCParser::parseAsIs>(), + .maxParams = 0, + }, + { + .name = rpc::method::kWalletPropose, + .parse = Parse::of<&RPCParser::parseWalletPropose>(), + .maxParams = 1, + }, + { + .name = rpc::method::kInternal, + .parse = Parse::of<&RPCParser::parseInternal>(), + .minParams = 1, + }, + + // Event methods, rejected below, so the parameter range is unconstrained + { + .name = rpc::method::kPathFind, + .parse = Parse::of<&RPCParser::parseEvented>(), + }, + { + .name = rpc::method::kSubscribe, + .parse = Parse::of<&RPCParser::parseEvented>(), + }, + { + .name = rpc::method::kUnsubscribe, + .parse = Parse::of<&RPCParser::parseEvented>(), + }, + }); + + // kCommandArray sorted by name, so a command can be found by binary search. + static constexpr auto kSortedCommands = [] { + auto commands = kCommandArray; + std::ranges::sort(commands, {}, &Command::name); + return commands; + }(); + + // parseCommand() relies on this being sorted to binary search it, and + // kCommandNames below inherits the order. + static_assert( + std::ranges::is_sorted(kSortedCommands, {}, &Command::name), + "xrpl::RPCParser : kSortedCommands must be sorted"); + + // The command names, which are already distinct and sorted. + static constexpr auto kCommandNames = [] { + std::array names{}; + std::ranges::transform(kSortedCommands, names.begin(), &Command::name); + return names; + }(); + public: + /** + * Names of every method the command line accepts. + */ + static std::span + methodNames() + { + return kCommandNames; + } + + /** + * Whether the command table is well formed. + * + * A name must select exactly one command, and must name a method the server + * can dispatch, or the command line would accept a command it cannot + * answer. RPCCall_test checks the second property, because it can see the + * handler table. This checks the first, and the parameter range. + * + * The parser is not checked: Parse has no default constructor, so an entry + * that omits it does not compile. + * + * This is a function the static_assert below the class calls, rather than + * the assert itself, because the table names members of RPCParser, and that + * is only a constant expression once RPCParser is complete. + */ + static constexpr bool + commandsValid() + { + for (std::size_t i = 0; i < kSortedCommands.size(); ++i) + { + auto const& command = kSortedCommands[i]; + if (command.name.empty() || command.minParams > command.maxParams) + return false; + if (i > 0 && kSortedCommands[i - 1].name == command.name) + return false; + } + return true; + } + //-------------------------------------------------------------------------- explicit RPCParser(unsigned apiVersion, beast::Journal j) : apiVersion_(apiVersion), j_(j) @@ -1251,7 +1748,7 @@ public: // Convert a rpc method and params to a request. // <-- { method: xyz, params: [... ] } or { error: ..., ... } json::Value - parseCommand(std::string strMethod, json::Value jvParams, bool allowAnyCommand) + parseCommand(std::string_view strMethod, json::Value const& jvParams, bool allowAnyCommand) { if (auto stream = j_.trace()) { @@ -1259,254 +1756,48 @@ public: stream << "Params: " << jvParams; } - struct Command + auto const found = std::ranges::lower_bound(kSortedCommands, strMethod, {}, &Command::name); + + if (found == kSortedCommands.end() || found->name != strMethod) { - char const* name; - parseFuncPtr parse; - int minParams; - int maxParams; - }; + // The command could not be found + if (!allowAnyCommand) + return rpcError(RpcUnknownCommand); - static constexpr Command kCommands[] = { - // Request-response methods - // - Returns an error, or the request. - // - To modify the method, provide a new method in the request. - {.name = "account_currencies", - .parse = &RPCParser::parseAccountCurrencies, - .minParams = 1, - .maxParams = 3}, - {.name = "account_info", - .parse = &RPCParser::parseAccountItems, - .minParams = 1, - .maxParams = 3}, - {.name = "account_lines", - .parse = &RPCParser::parseAccountLines, - .minParams = 1, - .maxParams = 5}, - {.name = "account_channels", - .parse = &RPCParser::parseAccountChannels, - .minParams = 1, - .maxParams = 3}, - {.name = "account_nfts", - .parse = &RPCParser::parseAccountItems, - .minParams = 1, - .maxParams = 5}, - {.name = "account_objects", - .parse = &RPCParser::parseAccountItems, - .minParams = 1, - .maxParams = 5}, - {.name = "account_offers", - .parse = &RPCParser::parseAccountItems, - .minParams = 1, - .maxParams = 4}, - {.name = "account_tx", - .parse = &RPCParser::parseAccountTransactions, - .minParams = 1, - .maxParams = 8}, - {.name = "amm_info", .parse = &RPCParser::parseAsIs, .minParams = 1, .maxParams = 2}, - {.name = "vault_info", .parse = &RPCParser::parseVault, .minParams = 1, .maxParams = 2}, - {.name = "book_changes", - .parse = &RPCParser::parseLedgerId, - .minParams = 1, - .maxParams = 1}, - {.name = "book_offers", - .parse = &RPCParser::parseBookOffers, - .minParams = 2, - .maxParams = 7}, - {.name = "can_delete", - .parse = &RPCParser::parseCanDelete, - .minParams = 0, - .maxParams = 1}, - {.name = "channel_authorize", - .parse = &RPCParser::parseChannelAuthorize, - .minParams = 3, - .maxParams = 4}, - {.name = "channel_verify", - .parse = &RPCParser::parseChannelVerify, - .minParams = 4, - .maxParams = 4}, - {.name = "connect", .parse = &RPCParser::parseConnect, .minParams = 1, .maxParams = 2}, - {.name = "consensus_info", - .parse = &RPCParser::parseAsIs, - .minParams = 0, - .maxParams = 0}, - {.name = "deposit_authorized", - .parse = &RPCParser::parseDepositAuthorized, - .minParams = 2, - .maxParams = 11}, - {.name = "feature", .parse = &RPCParser::parseFeature, .minParams = 0, .maxParams = 2}, - {.name = "fetch_info", - .parse = &RPCParser::parseFetchInfo, - .minParams = 0, - .maxParams = 1}, - {.name = "gateway_balances", - .parse = &RPCParser::parseGatewayBalances, - .minParams = 1, - .maxParams = -1}, - {.name = "get_counts", - .parse = &RPCParser::parseGetCounts, - .minParams = 0, - .maxParams = 1}, - {.name = "json", .parse = &RPCParser::parseJson, .minParams = 2, .maxParams = 2}, - {.name = "json2", .parse = &RPCParser::parseJson2, .minParams = 1, .maxParams = 1}, - {.name = "ledger", .parse = &RPCParser::parseLedger, .minParams = 0, .maxParams = 2}, - {.name = "ledger_accept", - .parse = &RPCParser::parseAsIs, - .minParams = 0, - .maxParams = 0}, - {.name = "ledger_closed", - .parse = &RPCParser::parseAsIs, - .minParams = 0, - .maxParams = 0}, - {.name = "ledger_current", - .parse = &RPCParser::parseAsIs, - .minParams = 0, - .maxParams = 0}, - {.name = "ledger_entry", - .parse = &RPCParser::parseLedgerEntry, - .minParams = 1, - .maxParams = 2}, - {.name = "ledger_header", - .parse = &RPCParser::parseLedgerId, - .minParams = 1, - .maxParams = 1}, - {.name = "ledger_request", - .parse = &RPCParser::parseLedgerId, - .minParams = 1, - .maxParams = 1}, - {.name = "log_level", - .parse = &RPCParser::parseLogLevel, - .minParams = 0, - .maxParams = 2}, - {.name = "logrotate", .parse = &RPCParser::parseAsIs, .minParams = 0, .maxParams = 0}, - {.name = "manifest", - .parse = &RPCParser::parseManifest, - .minParams = 1, - .maxParams = 1}, - {.name = "owner_info", - .parse = &RPCParser::parseAccountItems, - .minParams = 1, - .maxParams = 3}, - {.name = "peers", .parse = &RPCParser::parseAsIs, .minParams = 0, .maxParams = 0}, - {.name = "ping", .parse = &RPCParser::parseAsIs, .minParams = 0, .maxParams = 0}, - {.name = "print", .parse = &RPCParser::parseAsIs, .minParams = 0, .maxParams = 1}, - // { "profile", &RPCParser::parseProfile, 1, 9 - // }, - {.name = "random", .parse = &RPCParser::parseAsIs, .minParams = 0, .maxParams = 0}, - {.name = "peer_reservations_add", - .parse = &RPCParser::parsePeerReservationsAdd, - .minParams = 1, - .maxParams = 2}, - {.name = "peer_reservations_del", - .parse = &RPCParser::parsePeerReservationsDel, - .minParams = 1, - .maxParams = 1}, - {.name = "peer_reservations_list", - .parse = &RPCParser::parseAsIs, - .minParams = 0, - .maxParams = 0}, - {.name = "ripple_path_find", - .parse = &RPCParser::parseRipplePathFind, - .minParams = 1, - .maxParams = 2}, - {.name = "server_definitions", - .parse = &RPCParser::parseServerDefinitions, - .minParams = 0, - .maxParams = 1}, - {.name = "server_info", - .parse = &RPCParser::parseServerInfo, - .minParams = 0, - .maxParams = 1}, - {.name = "server_state", - .parse = &RPCParser::parseServerInfo, - .minParams = 0, - .maxParams = 1}, - {.name = "sign", .parse = &RPCParser::parseSignSubmit, .minParams = 2, .maxParams = 4}, - {.name = "sign_for", .parse = &RPCParser::parseSignFor, .minParams = 3, .maxParams = 4}, - {.name = "stop", .parse = &RPCParser::parseAsIs, .minParams = 0, .maxParams = 0}, - {.name = "simulate", - .parse = &RPCParser::parseSimulate, - .minParams = 1, - .maxParams = 2}, - {.name = "submit", - .parse = &RPCParser::parseSignSubmit, - .minParams = 1, - .maxParams = 4}, - {.name = "submit_multisigned", - .parse = &RPCParser::parseSubmitMultiSigned, - .minParams = 1, - .maxParams = 1}, - {.name = "transaction_entry", - .parse = &RPCParser::parseTransactionEntry, - .minParams = 2, - .maxParams = 2}, - {.name = "tx", .parse = &RPCParser::parseTx, .minParams = 1, .maxParams = 4}, - {.name = "tx_history", - .parse = &RPCParser::parseTxHistory, - .minParams = 1, - .maxParams = 1}, - {.name = "unl_list", .parse = &RPCParser::parseAsIs, .minParams = 0, .maxParams = 0}, - {.name = "validation_create", - .parse = &RPCParser::parseValidationCreate, - .minParams = 0, - .maxParams = 1}, - {.name = "validator_info", - .parse = &RPCParser::parseAsIs, - .minParams = 0, - .maxParams = 0}, - {.name = "version", .parse = &RPCParser::parseAsIs, .minParams = 0, .maxParams = 0}, - {.name = "wallet_propose", - .parse = &RPCParser::parseWalletPropose, - .minParams = 0, - .maxParams = 1}, - {.name = "internal", - .parse = &RPCParser::parseInternal, - .minParams = 1, - .maxParams = -1}, - - // Event methods - {.name = "path_find", - .parse = &RPCParser::parseEvented, - .minParams = -1, - .maxParams = -1}, - {.name = "subscribe", - .parse = &RPCParser::parseEvented, - .minParams = -1, - .maxParams = -1}, - {.name = "unsubscribe", - .parse = &RPCParser::parseEvented, - .minParams = -1, - .maxParams = -1}, - }; - - auto const count = jvParams.size(); - - for (auto const& command : kCommands) - { - if (strMethod == command.name) - { - if ((command.minParams >= 0 && count < command.minParams) || - (command.maxParams >= 0 && count > command.maxParams)) - { - JLOG(j_.debug()) << "Wrong number of parameters for " << command.name - << " minimum=" << command.minParams - << " maximum=" << command.maxParams << " actual=" << count; - - return rpcError(RpcBadSyntax); - } - - return (this->*(command.parse))(jvParams); - } + return parseAsIs(jvParams); } - // The command could not be found - if (!allowAnyCommand) - return rpcError(RpcUnknownCommand); + auto const count = jvParams.size(); + if (count < found->minParams || count > found->maxParams) + { + JLOG(j_.debug()) << "Wrong number of parameters for " << found->name + << " minimum=" << found->minParams << " maximum=" << found->maxParams + << " actual=" << count; - return parseAsIs(jvParams); + return rpcError(RpcBadSyntax); + } + + return found->parse(*this, jvParams); } }; +namespace { + +// Out of line because RPCParser is incomplete where Parse is declared. +json::Value +Parse::operator()(RPCParser& parser, json::Value const& jvParams) const +{ + return (parser.*fn_)(jvParams); +} + +} // namespace + +// See the comment on commandsValid() for why this is out here. +static_assert( + RPCParser::commandsValid(), + "xrpl::RPCParser : every command needs a unique name and a valid parameter " + "count range"); + //------------------------------------------------------------------------------ // @@ -1614,6 +1905,12 @@ struct RPCCallImp //------------------------------------------------------------------------------ +std::span +commandLineMethodNames() +{ + return RPCParser::methodNames(); +} + // Used internally by rpcClient. json::Value rpcCmdToJson( diff --git a/src/xrpld/rpc/detail/RPCHandler.cpp b/src/xrpld/rpc/detail/RPCHandler.cpp index 317adee22a..fc58d81af8 100644 --- a/src/xrpld/rpc/detail/RPCHandler.cpp +++ b/src/xrpld/rpc/detail/RPCHandler.cpp @@ -158,9 +158,8 @@ fillHandler(JsonContext& context, Handler const*& result) return RpcSuccess; } -template Status -callMethod(JsonContext& context, Method method, std::string const& name, Object& result) +callMethod(JsonContext& context, Handler::Method method, std::string_view name, json::Value& result) { // Scoped so this command nests under rpc.process and becomes the ambient // parent of any command-internal spans (e.g. pathfind.request). Coro-aware @@ -168,7 +167,7 @@ callMethod(JsonContext& context, Method method, std::string const& name, Object& // rather than Server: the inbound boundary is above rpc.process. auto span = ScopedSpanGuard(TraceCategory::Rpc, rpc_span::prefix::command, name, SpanRole::Internal); - span.setAttribute(rpc_span::attr::command, name.c_str()); + span.setAttribute(rpc_span::attr::command, name); span.setAttribute(rpc_span::attr::version, static_cast(context.apiVersion)); span.setAttribute( rpc_span::attr::rpcRole, @@ -181,7 +180,8 @@ callMethod(JsonContext& context, Method method, std::string const& name, Object& try { perfLog.rpcStart(name, curId); - auto v = context.app.getJobQueue().makeLoadEvent(JtGeneric, "cmd:" + name); + auto v = + context.app.getJobQueue().makeLoadEvent(JtGeneric, std::string{"cmd:"}.append(name)); auto start = std::chrono::system_clock::now(); auto ret = method(context, result); @@ -304,32 +304,28 @@ doCommand(rpc::JsonContext& context, json::Value& result) return error; } - if (auto method = handler->valueMethod) + // No null check on the method: Handler::Method has no default constructor, so + // every entry in the dispatch table names one. + if (!context.headers.user.empty() || !context.headers.forwardedFor.empty()) { - if (!context.headers.user.empty() || !context.headers.forwardedFor.empty()) - { - JLOG(context.j.debug()) - << "start command: " << handler->name << ", user: " << context.headers.user - << ", forwarded for: " << context.headers.forwardedFor; + JLOG(context.j.debug()) << "start command: " << handler->name + << ", user: " << context.headers.user + << ", forwarded for: " << context.headers.forwardedFor; - auto ret = callMethod(context, method, handler->name, result); + auto const ret = callMethod(context, handler->valueMethod, handler->name, result); - JLOG(context.j.debug()) - << "finish command: " << handler->name << ", user: " << context.headers.user - << ", forwarded for: " << context.headers.forwardedFor; + JLOG(context.j.debug()) << "finish command: " << handler->name + << ", user: " << context.headers.user + << ", forwarded for: " << context.headers.forwardedFor; - return ret; - } - - auto ret = callMethod(context, method, handler->name, result); return ret; } - return RpcUnknownCommand; + return callMethod(context, handler->valueMethod, handler->name, result); } Role -roleRequired(unsigned int version, bool betaEnabled, std::string const& method) +roleRequired(unsigned int version, bool betaEnabled, std::string_view method) { auto handler = rpc::getHandler(version, betaEnabled, method); diff --git a/src/xrpld/rpc/detail/TransactionSign.cpp b/src/xrpld/rpc/detail/TransactionSign.cpp index 3e9f62214e..6ea879aa2c 100644 --- a/src/xrpld/rpc/detail/TransactionSign.cpp +++ b/src/xrpld/rpc/detail/TransactionSign.cpp @@ -887,7 +887,10 @@ getTxFee(Application const& app, Config const& config, json::Value tx) if (!passesLocalChecks(stTx, reason)) return config.fees.referenceFee; - return calculateBaseFee(*app.getOpenLedger().current(), stTx); + // This fee is only a suggestion returned to the caller, so fall back to + // the reference fee as the other failure paths in this function do. + return calculateBaseFee(*app.getOpenLedger().current(), stTx) + .value_or(config.fees.referenceFee); } catch (std::exception& e) { diff --git a/src/xrpld/rpc/handlers/ledger/Ledger.h b/src/xrpld/rpc/handlers/ledger/Ledger.h index 07d24b497d..17c95e2653 100644 --- a/src/xrpld/rpc/handlers/ledger/Ledger.h +++ b/src/xrpld/rpc/handlers/ledger/Ledger.h @@ -3,6 +3,7 @@ #include #include // IWYU pragma: keep #include +#include #include #include #include @@ -12,12 +13,9 @@ #include #include +#include #include -namespace json { -class Object; -} // namespace json - namespace xrpl::rpc { struct JsonContext; @@ -40,7 +38,7 @@ public: writeResult(json::Value&); // NOLINTBEGIN(readability-identifier-naming) - static constexpr char name[] = "ledger"; + static constexpr std::string_view name = method::kLedger; static constexpr unsigned minApiVer = rpc::kApiMinimumSupportedVersion; diff --git a/src/xrpld/rpc/handlers/server_info/Version.h b/src/xrpld/rpc/handlers/server_info/Version.h index 40ad4e5e71..98c9fa987c 100644 --- a/src/xrpld/rpc/handlers/server_info/Version.h +++ b/src/xrpld/rpc/handlers/server_info/Version.h @@ -2,6 +2,7 @@ #include // IWYU pragma: keep #include +#include #include #include #include @@ -9,6 +10,8 @@ #include #include +#include + namespace xrpl::rpc { class VersionHandler @@ -32,7 +35,7 @@ public: } // NOLINTBEGIN(readability-identifier-naming) - static constexpr char const* name = "version"; + static constexpr std::string_view name = method::kVersion; static constexpr unsigned minApiVer = rpc::kApiMinimumSupportedVersion;