Compare commits

...

14 Commits

Author SHA1 Message Date
Mayukha Vadari
09d7e75686 Merge branch 'develop' into mvadari/ai-setup 2026-08-25 16:56:04 -04:00
Mayukha Vadari
502bc13a9a docs: Drop duplicated nix devshell variant list from AGENTS.md
Point to docs/build/nix.md instead, consistent with the other doc pointers.
2026-08-25 16:52:51 -04:00
Mayukha Vadari
65e7a6c14c docs: Address review feedback on AGENTS.md
Point Build/Testing/Lint/Code Style sections at BUILD.md,
CONTRIBUTING.md, and docs/CodingStyle.md instead of duplicating them
(the duplicated 80-char line length was already stale vs. the 100-char
clang-format limit). Require Nix on Linux/macOS, add gtest-in-src/tests
guidance for new tests, note amendment retirement in tx/AGENTS.md, drop
the CLAUDE.md-symlink/CONTRIBUTING.md sentences, and drop the
unconfirmed .aider.* gitignore entries.
2026-08-25 16:34:29 -04:00
Ayaz Salikhov
fee4bfc22e build: Implement packaging in Python (#8109) 2026-08-25 19:32:03 +00:00
Vito Tumas
5e3d20b3ed fix: Prevent vault clawback and withdraw overrun (#8075) 2026-08-25 17:59:37 +00:00
Ayaz Salikhov
9e2aaf6f60 build: Add rust-toolchain.toml to .envrc (#8107) 2026-08-25 17:09:03 +00:00
Vito Tumas
ec042fefee fix: Absorb Vault invariant rounding noise (#8055) 2026-08-25 17:07:58 +00:00
Ayaz Salikhov
45e4b8899d build: Update packaging images; add Python (#8106) 2026-08-25 14:34:41 +00:00
Jingchen
c5dc408596 fix: Remove explicit from std/boost hash specialisation default constructors (#8100) 2026-08-25 14:13:02 +00:00
Sergey Kuznetsov
473fe44a85 chore: Upgrade rust toolchain to 1.97.1 (#8105) 2026-08-25 13:48:26 +00:00
Mayukha Vadari
df31ff54e6 docs: Add tx/rpc AGENTS.md files and symlink convention
Documents the amendment workflow (features.macro) under src/libxrpl/tx
and the API-CHANGELOG.md requirement under src/xrpld/rpc. Also makes
it an explicit rule in CONTRIBUTING.md that any new AGENTS.md gets a
CLAUDE.md symlink alongside it.
2026-08-20 13:10:45 -04:00
Mayukha Vadari
cf4f627b33 docs: Correct xrpld unittest invocation in AGENTS.md 2026-08-20 12:57:06 -04:00
Mayukha Vadari
3e2e1d58d6 docs: Reflow paragraphs to single lines 2026-08-20 12:28:46 -04:00
Mayukha Vadari
79dcb83d5e docs: Add AGENTS.md/CLAUDE.md for AI coding agent guidance
Adds shared build/test/lint/architecture guidance for AI coding agents,
with CLAUDE.md symlinked to AGENTS.md for Claude Code. Updates
.gitignore so only personal/local AI-tool config is excluded, and
documents the shared vs. local convention in CONTRIBUTING.md.
2026-08-20 12:21:04 -04:00
36 changed files with 2043 additions and 605 deletions

3
.envrc
View File

@@ -1,5 +1,8 @@
watch_file nix/*.nix
# Pinned Rust toolchain, read by nix/packages.nix via fromRustupToolchainFile.
watch_file rust-toolchain.toml
# The dev shell derivation includes all of conan/ (see nix/devshell.nix), so any
# change in there has to invalidate direnv's cached environment.
watch_dir conan

View File

@@ -57,7 +57,9 @@ class LinuxConfig:
sanitizers: list[str] = dataclasses.field(default_factory=list)
suffix: str = ""
extra_cmake_args: str = ""
image: str = "" # only used by package_configs entries
# The two below are only used by package_configs entries.
image: str = ""
package_type: str = "" # "deb" or "rpm"; has to match what image provides
@dataclasses.dataclass
@@ -156,7 +158,7 @@ class PackagingEntry:
xrpld_artifact_name: str
validator_keys_artifact_name: str
image: str
distro: str # e.g. "debian" or "rhel"; drives package-format-specific steps
package_type: str # "deb" or "rpm"; drives the format-specific steps
# ---------------------------------------------------------------------------
@@ -243,7 +245,7 @@ def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]:
xrpld_artifact_name=f"xrpld-{config_name}",
validator_keys_artifact_name=f"validator-keys-{config_name}",
image=cfg.image,
distro=distro,
package_type=cfg.package_type,
)
)

View File

@@ -92,7 +92,8 @@
"build_type": ["Release"],
"arch": ["amd64"],
"minimal": false,
"image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-a6983f8"
"image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-45e4b88",
"package_type": "deb"
}
],
@@ -102,7 +103,8 @@
"build_type": ["Release"],
"arch": ["amd64"],
"minimal": false,
"image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-a6983f8"
"image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-45e4b88",
"package_type": "rpm"
}
]
}

View File

@@ -33,12 +33,14 @@ jobs:
strategy:
fail-fast: false
matrix:
# Newest of each distro: these images only wrap pre-built binaries, so
# they set no floor for consumers. build_pkg.py pins the RPM dist tag.
distro:
- name: debian
base_image: debian:bookworm
# AlmaLinux rather than UBI9, which does not ship rpm-sign.
base_image: debian:trixie
# AlmaLinux rather than UBI, which does not ship rpm-sign.
- name: rhel
base_image: almalinux:9
base_image: almalinux:10
uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a
with:
image_name: xrpld/packaging-${{ matrix.distro.name }}

View File

@@ -1,9 +1,9 @@
# Build Linux packages from the pre-built xrpld and validator-keys artifacts:
#
# - one job per distro, taken from "package_configs" in linux.json
# - each job runs in that distro's container, which is what decides DEB or RPM
# - each entry names its container image and the format it builds there
# - with 'publish: true' a job also uploads what it built
# (see package/publish_pkg.sh)
# (see package/publish_pkg.py)
#
# Only linux/amd64 is supported; the runner is hardcoded in the job below.
name: Package
@@ -97,17 +97,23 @@ jobs:
- name: Build package
env:
PACKAGE_TYPE: ${{ matrix.package_type }}
PKG_RELEASE: ${{ steps.release_info.outputs.pkg_release }}
PKG_CHANNEL: ${{ steps.release_info.outputs.channel }}
run: ./package/build_pkg.sh
CHANNEL: ${{ steps.release_info.outputs.channel }}
run: |
./package/build_pkg.py \
--package-type "${PACKAGE_TYPE}" \
--build-dir "${BUILD_DIR}" \
--pkg-release "${PKG_RELEASE}" \
--channel "${CHANNEL}"
# Before the upload, so the artifact and the published package are the
# same bytes. DEBs are not signed, so the key is never set on that job.
- name: Sign RPM
if: ${{ inputs.publish && matrix.distro == 'rhel' }}
if: ${{ inputs.publish && matrix.package_type == 'rpm' }}
env:
PKG_SIGNING_KEY: ${{ secrets.signing_key }}
run: ./package/sign_rpm.sh "${BUILD_DIR}"
run: ./package/sign_rpm.py --package-dir "${BUILD_DIR}"
- name: Upload package artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -126,4 +132,8 @@ jobs:
NEXUS_URL: ${{ inputs.nexus_url }}
NEXUS_USERNAME: ${{ secrets.remote_username }}
NEXUS_PASSWORD: ${{ secrets.remote_password }}
run: ./package/publish_pkg.sh "${CHANNEL}" "${BUILD_DIR}"
run: |
./package/publish_pkg.py \
--channel "${CHANNEL}" \
--package-dir "${BUILD_DIR}" \
--nexus-url "${NEXUS_URL}"

9
.gitignore vendored
View File

@@ -72,11 +72,16 @@ DerivedData
/.zed/
# AI tools.
# Shared/committable AI agent config (AGENTS.md, CLAUDE.md, GEMINI.md, .claude/settings.json,
# tool-specific rules files, etc.) should be checked in — see CONTRIBUTING.md. Only the
# personal/local variants below are ignored.
/.agent
/.agents
/.augment
/.claude
/CLAUDE.md
/.claude/settings.local.json
AGENTS.local.md
CLAUDE.local.md
GEMINI.local.md
# Python
__pycache__

View File

@@ -82,11 +82,27 @@ repos:
- id: prettier
args: [--end-of-line=auto]
# Scoped to package/: the rest of the repo's Python has pre-existing findings,
# so widening these is its own change.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 7c55798a78262d14b2074abf623d8a992ebb70d4 # frozen: v0.16.2
hooks:
- id: ruff-check
args: [--fix]
files: ^package/.*\.py$
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 4160603246a6b365d4a2af661c6d71b0a0f50478 # frozen: 26.5.1
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-mypy
rev: 41e691678310dfd3833f7ab4e180ddb014310356 # frozen: v2.3.0
hooks:
- id: mypy
args: [--strict]
files: ^package/.*\.py$
- repo: https://github.com/scop/pre-commit-shfmt
rev: 05c1426671b9237fb5e1444dd63aa5731bec0dfb # frozen: v3.13.1-1
hooks:

42
AGENTS.md Normal file
View File

@@ -0,0 +1,42 @@
# AGENTS.md
This file provides guidance to AI coding agents (Claude Code, and other AGENTS.md-compatible tools) when working with code in this repository.
## Build
Required on Linux/macOS: use the Nix devshell, which sets up the compiler, Conan, ccache, and (optionally) Rust automatically.
```bash
nix develop
```
For alternate devshell variants (specific compiler, no-compiler, coverage), see [docs/build/nix.md](./docs/build/nix.md). For the manual build steps, CMake options, and protocol codegen commands, see [BUILD.md](./BUILD.md) (`## Steps`, `## Options`, `## Code generation`).
Rust crate tests (independent of the CMake build): `cargo test --manifest-path crates/Cargo.toml --workspace` (CI uses `cargo nextest`).
## Testing
Unit tests are a custom framework built into the `xrpld` binary itself (not Boost.Test/GTest/Catch); see [CONTRIBUTING.md](./CONTRIBUTING.md#unit-tests) for the basic invocation. Notes not covered there:
- A suite's `--unittest` name is built from the arguments to its `BEAST_DEFINE_TESTSUITE`/`BEAST_DEFINE_TESTSUITE_PRIO` macro (usually at the bottom of the test file), in reverse order and joined with `.`: `BEAST_DEFINE_TESTSUITE(Credentials, app, xrpl)``xrpl.app.Credentials`.
- `--unittest-arg` does nothing — don't use it.
- Tests that run offline in under a minute should be automatic `--unittest` suites; anything else is a manual/integration test.
- New tests should be written using `gtest` under `src/tests/` unless that isn't possible, in which case fall back to the legacy Beast framework under `src/test/`. `tests/` (top-level) holds integration tests exercised against `libxrpl`/`xrpld`.
## Lint/Format
See [CONTRIBUTING.md](./CONTRIBUTING.md#pre-commit-hooks) for `pre-commit` setup and [CONTRIBUTING.md](./CONTRIBUTING.md#clang-tidy) for `clang-tidy` (opt-in, needs local `clang-tidy` and generated headers).
## Code Style
New file placement and header levelization: see [CONTRIBUTING.md](./CONTRIBUTING.md#before-making-a-pull-request). Braces, whitespace, member order, and other conventions: see [docs/CodingStyle.md](./docs/CodingStyle.md). `XRPL_ASSERT`/`UNREACHABLE` contracts: see [CONTRIBUTING.md](./CONTRIBUTING.md#contracts-and-instrumentation). Commit messages: see [CONTRIBUTING.md](./CONTRIBUTING.md#good-commit-messages).
## Architecture
Paths below reflect the current layout; update this section if modularization moves a subsystem to a different directory.
- `include/xrpl/` + `src/libxrpl/` — the core protocol library: ledger, shamap, consensus, crypto, json, resource, nodestore, rdb, peerfinder, and `tx/` (transaction application: `Transactor.cpp`, `applySteps.cpp`, invariants, payment paths). `tx/transactors/` has one file per transaction type, grouped by subsystem: `escrow/`, `vault/`, `lending/`, `sponsor/`, `nft/`, `token/` (MPT), `payment_channel/`, `permissioned_domain/`, `dex/`, `oracle/`, `did/`, `credentials/`, `bridge/`, `check/`, `delegate/`, `account/`, `system/`. Any change to transaction-processing behavior must be gated behind an Amendment.
- `src/xrpld/` — the server application built on top of `libxrpl`: `app`, `core`, `overlay` (P2P networking), `peerfinder`, `perflog`, `rpc`, `shamap`. `main` builds an `ApplicationImp` implementing `Application`; most components hold a reference to it (`app_`), giving broad cross-component access — expect to trace call chains through `Application&`.
- `src/test/` — unit tests mirroring the subsystems above, plus `jtx/` (the transaction-building test DSL — e.g. `jtx/escrow.h`, `jtx/vault.h`, `jtx/sponsor.h`, `jtx/permissioned_dex.h`) and `unit_test/` (the custom test framework itself, derived from Beast).
- `src/tests/` — a second, separate tree of integration-style tests for `libxrpl`.
- `crates/` — a Rust workspace (only built with `-Dxrpld -Drust=ON`) bridged into C++ via `cxxbridge`/the `cxx` crate; currently just a `hello_world` interop scaffold. Requires the Rust toolchain pinned in `rust-toolchain.toml` (the Nix devshell provides it automatically).

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -59,6 +59,12 @@ to an existing XLS. Neither change will be released (in an amendment's
case, marked as `Supported::yes`) until the corresponding XLS's status
is `Final`.
## AI coding agents
[`AGENTS.md`](./AGENTS.md) (and its `CLAUDE.md` symlink, for Claude Code) holds shared, checked-in guidance for AI coding agents working in this repository — build/test/lint commands and architecture notes. Additional `AGENTS.md` files may exist in subdirectories to give agents context specific to that part of the codebase; whenever you add one, also add a `CLAUDE.md` symlink pointing to it (`ln -s AGENTS.md CLAUDE.md`) so Claude Code picks it up too.
If you want to give an agent personal instructions that shouldn't be shared with other contributors (e.g. your own workflow preferences), put them in `AGENTS.local.md` or `CLAUDE.local.md` instead — those are gitignored. Likewise, `.claude/settings.local.json` is for personal, untracked Claude Code settings, while `.claude/settings.json` is shared.
## Before making a pull request
(Or marking a draft pull request as ready.)

View File

@@ -1,7 +1,7 @@
#[===================================================================[
Linux packaging support: 'package' target.
The packaging script (package/build_pkg.sh) installs to FHS-standard
The packaging script (package/build_pkg.py) installs to FHS-standard
paths (/usr/bin, /etc/xrpld, etc.) regardless of CMAKE_INSTALL_PREFIX,
so no prefix guard is needed here.
#]===================================================================]
@@ -38,19 +38,19 @@ if(NOT TARGET validator-keys)
return()
endif()
set(package_env
SRC_DIR=${CMAKE_SOURCE_DIR}
BUILD_DIR=${CMAKE_BINARY_DIR}
PKG_RELEASE=${pkg_release}
)
if(DPKG_BUILDPACKAGE_EXECUTABLE)
set(pkg_type deb)
else()
set(pkg_type rpm)
endif()
add_custom_target(
package
COMMAND
${CMAKE_COMMAND} -E env ${package_env}
${CMAKE_SOURCE_DIR}/package/build_pkg.sh
${CMAKE_SOURCE_DIR}/package/build_pkg.py --package-type ${pkg_type}
--build-dir ${CMAKE_BINARY_DIR} --pkg-release ${pkg_release}
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
DEPENDS xrpld validator-keys
COMMENT "Building Linux package (deb/rpm inferred from host tooling)"
COMMENT "Building Linux ${pkg_type} package"
VERBATIM
)

View File

@@ -103,7 +103,7 @@ namespace boost {
template <>
struct hash<::beast::ip::Address>
{
explicit hash() = default;
hash() = default;
std::size_t
operator()(::beast::ip::Address const& addr) const

View File

@@ -133,7 +133,7 @@ private:
using id_hash_type = boost::base_from_member<std::hash<xrpl::MPTID>, 0>;
public:
explicit hash() = default;
hash() = default;
using value_type = std::size_t;
using argument_type = xrpl::MPTIssue;
@@ -160,7 +160,7 @@ private:
mptissue_hasher mMptissueHasher_;
public:
explicit hash() = default;
hash() = default;
value_type
operator()(argument_type const& asset) const
@@ -227,7 +227,7 @@ struct hash<xrpl::Issue> : std::hash<xrpl::Issue>
template <>
struct hash<xrpl::MPTIssue> : std::hash<xrpl::MPTIssue>
{
explicit hash() = default;
hash() = default;
using Base = std::hash<xrpl::MPTIssue>;
};
@@ -235,7 +235,7 @@ struct hash<xrpl::MPTIssue> : std::hash<xrpl::MPTIssue>
template <>
struct hash<xrpl::Asset> : std::hash<xrpl::Asset>
{
explicit hash() = default;
hash() = default;
using Base = std::hash<xrpl::Asset>;
};

View File

@@ -151,7 +151,7 @@ namespace std {
template <>
struct hash<xrpl::MPTID> : xrpl::MPTID::hasher
{
explicit hash() = default;
hash() = default;
};
} // namespace std

View File

@@ -114,8 +114,8 @@ Development tooling:
Rust toolchain:
✅ cargo
cargo 1.95.0 (f2d3ce0bd 2026-03-21)
/nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/cargo
cargo 1.97.1 (c980f4866 2026-06-30)
/nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/cargo
✅ cargo-audit
cargo-audit-audit 0.22.1
/nix/store/snwkga2f5gyf404h7mmp9wriwxb8v65f-cargo-audit-0.22.1/bin/cargo-audit
@@ -126,17 +126,17 @@ Rust toolchain:
cargo-nextest 0.9.137
/nix/store/ylz7m947mhkgsp6i7611id3s3gcd58nq-cargo-nextest-0.9.137/bin/cargo-nextest
✅ clippy-driver
clippy 0.1.95 (59807616e1 2026-04-14)
/nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/clippy-driver
clippy 0.1.97 (8bab26f4f6 2026-07-14)
/nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/clippy-driver
✅ rust-analyzer
rust-analyzer 1.95.0 (59807616 2026-04-14)
/nix/store/jqvjap2727r9cjpr25fkw5glv2kbxrdx-rust-analyzer-preview-1.95.0-aarch64-apple-darwin/bin/rust-analyzer
rust-analyzer 1.97.1 (8bab26f4 2026-07-14)
/nix/store/j6apc5pmd0giy15da9p650r8zklslmvi-rust-analyzer-preview-1.97.1-aarch64-apple-darwin/bin/rust-analyzer
✅ rustc
rustc 1.95.0 (59807616e 2026-04-14)
/nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/rustc
rustc 1.97.1 (8bab26f4f 2026-07-14)
/nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/rustc
✅ rustfmt
rustfmt 1.9.0-stable (59807616e1 2026-04-14)
/nix/store/03x750yj6fakl7shbhicpnkxiwqxjrrs-rustfmt-preview-1.95.0-aarch64-apple-darwin/bin/rustfmt
rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14)
/nix/store/5ymwgr9jqjz7zzbmj0j5vqbwcd3kp0vm-rustfmt-preview-1.97.1-aarch64-apple-darwin/bin/rustfmt
Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set).

View File

@@ -8,9 +8,9 @@ a build configured with `-Dvalidator_keys=ON`.
```
package/
build_pkg.sh Staging and build script (called by the CMake `package` target and CI)
sign_rpm.sh Signs the built RPMs (called by CI when publishing)
publish_pkg.sh Uploads built packages to the XRPLF Nexus repositories (called by CI)
build_pkg.py Staging and build script (called by the CMake `package` target and CI)
sign_rpm.py Signs the built RPMs (called by CI when publishing)
publish_pkg.py Uploads built packages to the XRPLF Nexus repositories (called by CI)
rpm/
xrpld.spec RPM spec
debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, source/format)
@@ -28,8 +28,9 @@ Packaging targets and their container images are declared in
under `package_configs`, one entry per distro. Today only `linux/amd64` is
emitted. Each entry pins its full container image in an `image` field; to move
to a new image, edit that field and both CI and local builds pick it up. The
package format (deb or rpm) is inferred at build time from the container's
package manager (`apt-get` -> deb, `dnf`/`yum` -> rpm).
entry also declares the format that image builds in a `package_type` field,
which CI passes to `build_pkg.py` as `--package-type`; the two have to stay in
step.
| Package type | Image (`package_configs.<distro>[].image` in `linux.json`) | Tools required |
| ------------ | ---------------------------------------------------------- | --------------------------------------------------- |
@@ -51,10 +52,10 @@ Caller workflows (`on-pr.yml`, `on-tag.yml`, `on-trigger.yml`) call
`reusable-package.yml`. That workflow generates its own packaging matrix from
`package_configs` in `linux.json` (via `generate.py --packaging`) and fans out
one job per distro. Each job downloads the pre-built `xrpld` and `validator-keys`
binary artifacts and runs in that distro's container, so the package format
follows from the container's package manager. The packaging script derives the
package version from the downloaded binary's `xrpld --version` output; no CMake
configure or build step is needed inside the packaging job.
binary artifacts and runs in that distro's container, building the format its
`package_type` declares. The packaging script derives the package version from
the downloaded binary's `xrpld --version` output; no CMake configure or build
step is needed inside the packaging job.
The binaries come from the `debian` and `rhel` build configurations in
`linux.json`'s `configs` section, which pass `-Dvalidator_keys=ON` so that the
@@ -75,9 +76,8 @@ The image tag is derived from `linux.json` so you don't need to hardcode a SHA.
```bash
# From the repo root. Each distro's container image is the `image` field of its
# package_configs entry in linux.json; the package format is inferred from the
# container's package manager. Example for the rpm-producing image (use
# .package_configs.debian[0].image for the deb image):
# package_configs entry in linux.json. Example for the rpm-producing image (use
# .package_configs.debian[0].image and --package-type deb for the other one):
IMAGE=$(jq -r '.package_configs.rhel[0].image' .github/scripts/strategy-matrix/linux.json)
PKG_RELEASE=1
@@ -86,7 +86,7 @@ docker run --rm \
-v "$(pwd):/src" \
-w /src \
"${IMAGE}" \
./package/build_pkg.sh --pkg-release "${PKG_RELEASE}"
./package/build_pkg.py --package-type rpm --pkg-release "${PKG_RELEASE}"
# Output:
# build/debbuild/*.deb (DEB + dbgsym; Debian names both .deb)
@@ -113,12 +113,12 @@ cmake --build . --target package # deb on Debian/Ubuntu, rpm on RHEL
The `cmake/XrplPackaging.cmake` module defines the `package` target only if at
least one of `rpmbuild` / `dpkg-buildpackage` is present and both the `xrpld` and
`validator-keys` targets exist (`-Dxrpld=ON -Dvalidator_keys=ON`); the target
builds both binaries before packaging. `build_pkg.sh` then infers the package
format from the host's package manager. The packaging script installs to
builds both binaries before packaging, passing `--package-type deb` when
`dpkg-buildpackage` is present and `rpm` otherwise. The packaging script installs to
FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of
`CMAKE_INSTALL_PREFIX`.
The package version is not a CMake input on this path: `build_pkg.sh` derives it
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`.
@@ -126,7 +126,7 @@ release defaults to 1 and is overridable with `-Dpkg_release=N`.
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.sh` maps that channel to its repositories:
the event, and `publish_pkg.py` maps that channel to its repositories:
| Event | Version | Channel | DEB repository | RPM upload repository |
| ------------------------ | ----------------- | -------------- | ------------------ | ------------------------- |
@@ -162,7 +162,7 @@ Nexus owns the repository metadata; nothing here indexes anything. Worth knowing
repository sits behind a `rpm-<channel>` 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
themselves, so `sign_rpm.sh` signs them before they are uploaded, and clients
themselves, so `sign_rpm.py` signs them before they are uploaded, and clients
verify them with `gpgcheck=1`.
- yum metadata is rebuilt asynchronously, so a successful publish is not
immediately installable.
@@ -172,20 +172,20 @@ Nexus owns the repository metadata; nothing here indexes anything. Worth knowing
- The `develop` repositories gain a package per push, so they need a cleanup
policy to stay bounded; tagged channels publish each version once.
## How `build_pkg.sh` works
## How `build_pkg.py` works
`build_pkg.sh` derives the `xrpld` software version from
`build_pkg.py` derives the `xrpld` software version from
`${BUILD_DIR}/xrpld --version` in both package formats.
The binary's version is already SemVer-validated by `BuildInfo`.
`build_pkg.sh` converts pre-release versions such as `3.2.0-b1` or
`build_pkg.py` converts pre-release versions such as `3.2.0-b1` or
`3.2.0-rc1` from `-` to `~` for package metadata so pre-releases sort before
the final release. If that normalized package version still contains `-`,
packaging fails because RPM forbids `-` in `Version`, and Debian uses `-` as
the upstream/revision separator.
`pkg_version` is the normalized package metadata version derived inside
`build_pkg.sh` from the binary-reported `xrpld` version (`-` pre-release
`build_pkg.py` from the binary-reported `xrpld` version (`-` pre-release
separator converted to `~`). It is not a separate user input.
`PKG_RELEASE` is a different value: the package release iteration for that
@@ -203,35 +203,39 @@ With `PKG_RELEASE=1`, the package metadata becomes:
| `3.2.0-b1` | `3.2.0~b1-1%{?dist}` | `3.2.0~b1-1` |
| `3.2.0-rc1` | `3.2.0~rc1-1%{?dist}` | `3.2.0~rc1-1` |
The Debian changelog entry carries the channel passed as `--channel`
(`PKG_CHANNEL`), defaulting to `unstable`. An unsupported pre-release, and build
metadata on a final release such as `3.2.0+abc123`, are both rejected.
`build_pkg.py` defines `dist` as `.el9` rather than letting rpmbuild take it
from the build host, so the RHEL image can track a newer release without
changing what the packages claim to target.
The Debian changelog entry carries the channel passed as `--channel`,
defaulting to `unstable`. An unsupported pre-release, and build metadata on a
final release such as `3.2.0+abc123`, are both rejected.
The RPM path intentionally uses `~` in `Version`, matching the Debian
pre-release ordering convention, so RPM filenames/NVRs begin with forms like
`xrpld-3.2.0~b1-...` and `xrpld-3.2.0~rc1-...` instead of encoding
pre-releases with an older `0.<release>.<suffix>` RPM `Release` value.
The package format (`deb` or `rpm`) is inferred from the host's package
manager (`apt-get` -> deb, `dnf`/`yum` -> rpm). Hosts without one of those
fail early.
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.
Flags are for explicit invocation; environment variables are intended for
CMake/CI integration. The CI workflow and the CMake `package` target both invoke
`build_pkg.sh` with no flags; CMake supplies `SRC_DIR`, `BUILD_DIR`, and
`PKG_RELEASE` via env, while CI supplies `BUILD_DIR`, `PKG_RELEASE` and
`PKG_CHANNEL` via env and lets the script use defaults for the rest.
Every input is a named argument. CMake passes `--package-type`, `--build-dir`
and `--pkg-release`; CI adds `--channel`. The repository root is not an argument
at all: the script reads it from its own location. Only secrets stay in the
environment, so they never reach the process list -- `PKG_SIGNING_KEY` for
`sign_rpm.py`, and `NEXUS_USERNAME` / `NEXUS_PASSWORD` for `publish_pkg.py`.
Signing is not part of this script. `sign_rpm.sh` does it in a separate CI step
Signing is not part of this script. `sign_rpm.py` does it in a separate CI step
that only runs when publishing, so a published RPM is always signed and a local
build never needs a key.
It resolves `SRC_DIR` and `BUILD_DIR` to absolute paths, then calls
It resolves the build directory to an absolute path, then calls
`stage_common()` to copy the `xrpld` and `validator-keys` binaries, config files,
and shared support files into the staging area, and invokes the platform build
tool. Both binaries must be present in `BUILD_DIR` and must run in the packaging
environment; a missing or non-runnable one fails early. That runtime check is
what catches a binary still linked against the Nix store's ELF loader (see
tool. Both binaries must be present in the build directory and must run in the
packaging environment; a missing or non-runnable one fails early. That runtime
check is what catches a binary still linked against the Nix store's ELF loader (see
`patch_nix_binary` in `cmake/PatchNixBinary.cmake`).
### RPM
@@ -277,10 +281,9 @@ lintian -I debbuild/*.deb
## Reproducibility
`build_pkg.sh` already defaults `SOURCE_DATE_EPOCH` to the latest git commit
time, or the current time outside a git tree, and exports it (override with
`--source-date-epoch` / `SOURCE_DATE_EPOCH`); the RPM spec clamps file
modification times to it via `%build_mtime_policy`. The remaining variables
`build_pkg.py` sets `SOURCE_DATE_EPOCH` from the latest git commit time and
exports it; the RPM spec clamps file modification times to it via
`%build_mtime_policy`. The remaining variables
below further improve reproducibility but are _not_ set by the script — export
them yourself if needed:

263
package/build_pkg.py Executable file
View File

@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Build an RPM or Debian package from the pre-built xrpld and validator-keys binaries.
The build tool for the chosen format has to be on PATH, so this runs in the
vanilla distro image that matches it.
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import textwrap
from datetime import datetime, timezone
from pathlib import Path
# This script lives in the repository it packages.
SRC_DIR = Path(__file__).resolve().parents[1]
PRE_RELEASE = re.compile(r"^(b0|b[1-9][0-9]*|rc[0-9]+)(\+.*)?$")
# Files both packaging systems consume, staged under the same names.
STAGED_FROM_BUILD = ("xrpld", "validator-keys", "validator-keys-LICENSE")
STAGED_FROM_SRC = {
"cfg/xrpld-example.cfg": "xrpld.cfg",
"cfg/validators-example.txt": "validators.txt",
"LICENSE.md": "LICENSE.md",
"README.md": "README.md",
}
STAGED_UNITS = ("xrpld.service", "xrpld.sysusers", "xrpld.tmpfiles", "xrpld.logrotate")
def run(*command: object, cwd: Path | None = None) -> None:
"""Echo a command and run it."""
argv = [str(part) for part in command]
print("+ " + " ".join(argv), flush=True)
subprocess.run(argv, check=True, cwd=cwd)
def capture(*command: object) -> str:
"""Run a command and return its stdout, stripped."""
argv = [str(part) for part in command]
# stderr is left alone so a failing command explains itself.
return subprocess.run(
argv, stdout=subprocess.PIPE, text=True, check=True
).stdout.strip()
def package_version(reported: str) -> str:
"""Normalise a reported version into one the package formats accept.
A pre-release switches to '~' (3.2.0-b1 -> 3.2.0~b1), which also sorts before
the final 3.2.0; a no-op for a final release.
"""
base, _, pre_release = reported.partition("-")
version = f"{base}~{pre_release}" if pre_release else base
# BuildInfo already SemVer-validates the version. Packaging adds one narrower
# constraint: after normalisation the version must not contain '-', because
# RPM forbids it in Version and Debian reads it as the revision separator.
assert "-" not in version, (
f"unsupported version {reported!r}: {version!r} cannot contain '-'. "
"Use a single-token pre-release like 3.2.0-b1 or 3.2.0-rc2."
)
assert pre_release or "+" not in reported, (
f"unsupported version {reported!r}: "
"build metadata is only supported on bN/rcN pre-releases."
)
assert not pre_release or PRE_RELEASE.match(pre_release), (
f"unsupported pre-release {pre_release!r}: use bN or rcN, "
"e.g. 3.2.0-b1 or 3.2.0-rc2."
)
return version
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()
assert len(fields) >= 3, f"cannot read a version from {xrpld} --version"
return fields[2]
def check_binaries(build_dir: Path) -> None:
"""Fail unless the binaries and their notices are present and runnable."""
missing = [
name
for name in ("xrpld", "validator-keys")
if not os.access(build_dir / name, os.X_OK)
]
assert not missing, (
f"missing or not executable in {build_dir}: {' '.join(missing)}. "
"Both binaries come from a single CMake build directory configured with "
"-Dxrpld=ON -Dvalidator_keys=ON."
)
# No package goes out without the attribution.
notice = build_dir / "validator-keys-LICENSE"
assert notice.is_file(), (
f"missing {notice}. cmake/XrplValidatorKeys.cmake copies it out of the "
"fetched validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON."
)
# Catches a binary still pointing at the Nix store's ELF loader, since
# packaging runs in a vanilla distro container.
capture(build_dir / "validator-keys", "--version")
def source_date_epoch() -> int:
"""The last commit's timestamp."""
# git refuses to read a checkout owned by another user, which is what a CI
# container or a bind mount hands it.
return int(
capture(
"git",
"-c",
f"safe.directory={SRC_DIR}",
"-C",
SRC_DIR,
"log",
"-1",
"--format=%ct",
)
)
def stage_common(build_dir: Path, dest: Path) -> None:
"""Copy everything both packaging systems consume into dest."""
dest.mkdir(parents=True, exist_ok=True)
for name in STAGED_FROM_BUILD:
shutil.copy2(build_dir / name, dest / name)
for source, name in STAGED_FROM_SRC.items():
shutil.copy2(SRC_DIR / source, dest / name)
for name in STAGED_UNITS:
shutil.copy2(SRC_DIR / "package" / "shared" / name, dest / name)
def build_rpm(build_dir: Path, *, version: str, pkg_release: 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"):
(topdir / name).mkdir(parents=True, exist_ok=True)
spec = topdir / "SPECS" / "xrpld.spec"
shutil.copy2(SRC_DIR / "package" / "rpm" / "xrpld.spec", spec)
stage_common(build_dir, topdir / "SOURCES")
run(
"rpmbuild",
"-bb",
"--define",
f"_topdir {topdir}",
"--define",
f"pkg_version {version}",
"--define",
f"pkg_release {pkg_release}",
# The image tracks the newest distro, but the packages target el9.
"--define",
"dist .el9",
spec,
)
def build_deb(
build_dir: Path,
*,
version: str,
reported: str,
pkg_release: str,
channel: str,
epoch: int,
) -> 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")
# debhelper picks these up from debian/ automatically.
for name in STAGED_UNITS:
shutil.copy2(staging / name, staging / "debian" / 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
* Release {reported}.
-- XRPL Foundation <contact@xrplf.org> {date}
""")
(staging / "debian" / "changelog").write_text(changelog)
(staging / "debian" / "rules").chmod(0o755)
run("dpkg-buildpackage", "-b", "--no-sign", "-d", cwd=staging)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--package-type",
required=True,
choices=("deb", "rpm"),
help="the package format to build",
)
parser.add_argument(
"--build-dir",
type=Path,
default=Path("build"),
help="directory holding the xrpld and validator-keys binaries (default: %(default)s)",
)
parser.add_argument(
"--pkg-release",
default="1",
help="package release iteration (default: %(default)s)",
)
parser.add_argument(
"--channel",
default="unstable",
help="release channel, written to debian/changelog (default: %(default)s)",
)
args = parser.parse_args()
package_type: str = args.package_type
build_dir: Path = args.build_dir.resolve()
pkg_release: str = args.pkg_release
channel: str = args.channel
assert build_dir.is_dir(), (
f"build directory not found: {build_dir}. Build the binaries before "
"packaging, or point --build-dir at the directory holding them."
)
check_binaries(build_dir)
reported = read_version(build_dir / "xrpld")
version = package_version(reported)
epoch = source_date_epoch()
# rpmbuild and dpkg-buildpackage both honour this for file timestamps.
os.environ["SOURCE_DATE_EPOCH"] = str(epoch)
# Remove both build trees, because a package left from an earlier build would
# otherwise be picked up and published alongside this one.
for tree in ("debbuild", "rpmbuild"):
shutil.rmtree(build_dir / tree, ignore_errors=True)
if package_type == "deb":
build_deb(
build_dir,
version=version,
reported=reported,
pkg_release=pkg_release,
channel=channel,
epoch=epoch,
)
else:
build_rpm(build_dir, version=version, pkg_release=pkg_release)
if __name__ == "__main__":
main()

View File

@@ -1,252 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Build an RPM or Debian package from the pre-built xrpld and validator-keys
# binaries.
#
# Flags override env vars; env vars override defaults.
usage() {
cat <<'EOF'
Usage: build_pkg.sh [options]
Options (each can also be set via the env var shown):
--src-dir DIR repo root [SRC_DIR; default: ${PWD}]
--build-dir DIR directory holding the
xrpld and validator-keys
binaries [BUILD_DIR; default: ${PWD}/build]
--pkg-release N package release iteration [PKG_RELEASE; default: 1]
--channel NAME release channel, written
to debian/changelog [PKG_CHANNEL; default: unstable]
--source-date-epoch SECS reproducibility timestamp [SOURCE_DATE_EPOCH; latest git ctime; fallback: current time]
-h, --help show this help and exit
EOF
}
need_arg() {
if [[ $# -lt 2 || "$2" == --* ]]; then
echo "Missing value for $1" >&2
exit 2
fi
}
# Seed from env. CLI parsing below overrides these directly.
SRC_DIR="${SRC_DIR:-}"
BUILD_DIR="${BUILD_DIR:-}"
PKG_RELEASE="${PKG_RELEASE:-1}"
PKG_CHANNEL="${PKG_CHANNEL:-unstable}"
SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-}"
while [[ $# -gt 0 ]]; do
case "$1" in
--src-dir)
need_arg "$@"
SRC_DIR="$2"
shift 2
;;
--build-dir)
need_arg "$@"
BUILD_DIR="$2"
shift 2
;;
--pkg-release)
need_arg "$@"
PKG_RELEASE="$2"
shift 2
;;
--channel)
need_arg "$@"
PKG_CHANNEL="$2"
shift 2
;;
--source-date-epoch)
need_arg "$@"
SOURCE_DATE_EPOCH="$2"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
SRC_DIR="$(cd "${SRC_DIR:-${PWD}}" && pwd)"
BUILD_DIR="${BUILD_DIR:-${PWD}/build}"
if [[ ! -d "${BUILD_DIR}" ]]; then
echo "build_pkg.sh: build directory not found: ${BUILD_DIR}" >&2
echo "Build the binaries before packaging, or set BUILD_DIR to the directory containing them." >&2
exit 1
fi
BUILD_DIR="$(cd "${BUILD_DIR}" && pwd)"
xrpld_binary="${BUILD_DIR}/xrpld"
validator_keys_binary="${BUILD_DIR}/validator-keys"
# Report both binaries at once: they share a single BUILD_DIR, so telling the
# reader to point it at one of them in isolation is advice they cannot follow.
missing=()
[[ -x "${xrpld_binary}" ]] || missing+=(xrpld)
[[ -x "${validator_keys_binary}" ]] || missing+=(validator-keys)
if [[ ${#missing[@]} -gt 0 ]]; then
echo "build_pkg.sh: missing or not executable in ${BUILD_DIR}: ${missing[*]}" >&2
echo "Both binaries come from a single CMake build directory configured with" >&2
echo "-Dxrpld=ON -Dvalidator_keys=ON. Build them, then point BUILD_DIR at that" >&2
echo "directory." >&2
exit 1
fi
# Shipping validator-keys means shipping its notice, so treat it as required
# rather than letting a package go out without the attribution.
validator_keys_license="${BUILD_DIR}/validator-keys-LICENSE"
if [[ ! -f "${validator_keys_license}" ]]; then
echo "build_pkg.sh: missing ${validator_keys_license}." >&2
echo "cmake/XrplValidatorKeys.cmake copies it out of the fetched" >&2
echo "validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." >&2
exit 1
fi
# The binary must also *run* here. Packaging happens in a vanilla distro
# container, so this is what catches a binary still pointing at the Nix store's
# ELF loader (see patch_nix_binary in cmake/PatchNixBinary.cmake); xrpld is
# covered implicitly by the version query below.
if ! "${validator_keys_binary}" --version >/dev/null; then
echo "build_pkg.sh: ${validator_keys_binary} exists but does not run here." >&2
exit 1
fi
xrpld_version="$("${xrpld_binary}" --version | awk 'NR == 1 { print $3 }')"
if [[ -z "${xrpld_version}" ]]; then
echo "build_pkg.sh: unable to derive xrpld version from ${xrpld_binary} --version." >&2
exit 1
fi
# The version as the package formats consume it: identical to xrpld_version
# except a pre-release uses '~' (3.2.0-b1 -> 3.2.0~b1), which also sorts before
# the final 3.2.0; a no-op for a final release. Lowercase = derived internally,
# not an input (cf. pkg_type).
pkg_version="${xrpld_version}"
pre_release=""
if [[ "${xrpld_version}" == *-* ]]; then
pre_release="${xrpld_version#*-}"
pkg_version="${xrpld_version%%-*}~${pre_release}"
fi
# BuildInfo already SemVer-validates the binary's version. Packaging adds one
# narrower constraint: after pre-release normalization, the package version must
# not contain '-' because RPM forbids it in Version and Debian uses it as the
# upstream/revision separator.
if [[ "${pkg_version}" == *-* ]]; then
echo "build_pkg.sh: unsupported xrpld version '${xrpld_version}'." >&2
echo "Package version '${pkg_version}' cannot contain '-'." >&2
echo "Use a single-token pre-release like 3.2.0-b1 or 3.2.0-rc2." >&2
exit 1
fi
if [[ -z "${pre_release}" && "${xrpld_version}" == *+* ]]; then
echo "build_pkg.sh: unsupported xrpld version '${xrpld_version}'." >&2
echo "Build metadata is only supported on bN/rcN pre-releases." >&2
exit 1
fi
if [[ -n "${pre_release}" && ! "${pre_release}" =~ ^(b0|b[1-9][0-9]*|rc[0-9]+)(\+.*)?$ ]]; then
echo "build_pkg.sh: unsupported xrpld pre-release '${pre_release}'." >&2
echo "Use bN or rcN, e.g. 3.2.0-b1 or 3.2.0-rc2." >&2
exit 1
fi
if command -v apt-get >/dev/null 2>&1; then
pkg_type=deb
elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then
pkg_type=rpm
else
echo "Cannot infer pkg_type: no apt-get, dnf, or yum on PATH." >&2
exit 1
fi
if [[ -z "${SOURCE_DATE_EPOCH}" ]]; then
if git -C "${SRC_DIR}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
SOURCE_DATE_EPOCH="$(git -C "${SRC_DIR}" log -1 --format=%ct)"
else
SOURCE_DATE_EPOCH="$(date +%s)"
fi
fi
export SOURCE_DATE_EPOCH
CHANGELOG_DATE="$(date -u -R -d "@${SOURCE_DATE_EPOCH}")"
SHARED="${SRC_DIR}/package/shared"
DEBIAN_DIR="${SRC_DIR}/package/debian"
# Stage files that both packaging systems consume using the same filenames.
stage_common() {
local dest="$1"
mkdir -p "${dest}"
cp "${xrpld_binary}" "${dest}/xrpld"
cp "${validator_keys_binary}" "${dest}/validator-keys"
cp "${validator_keys_license}" "${dest}/validator-keys-LICENSE"
cp "${SRC_DIR}/cfg/xrpld-example.cfg" "${dest}/xrpld.cfg"
cp "${SRC_DIR}/cfg/validators-example.txt" "${dest}/validators.txt"
cp "${SRC_DIR}/LICENSE.md" "${dest}/LICENSE.md"
cp "${SRC_DIR}/README.md" "${dest}/README.md"
cp "${SHARED}/xrpld.service" "${dest}/xrpld.service"
cp "${SHARED}/xrpld.sysusers" "${dest}/xrpld.sysusers"
cp "${SHARED}/xrpld.tmpfiles" "${dest}/xrpld.tmpfiles"
cp "${SHARED}/xrpld.logrotate" "${dest}/xrpld.logrotate"
}
build_rpm() {
local topdir="${BUILD_DIR}/rpmbuild"
mkdir -p "${topdir}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
cp "${SRC_DIR}/package/rpm/xrpld.spec" "${topdir}/SPECS/xrpld.spec"
stage_common "${topdir}/SOURCES"
set -x
rpmbuild -bb \
--define "_topdir ${topdir}" \
--define "pkg_version ${pkg_version}" \
--define "pkg_release ${PKG_RELEASE}" \
"${topdir}/SPECS/xrpld.spec"
}
build_deb() {
local staging="${BUILD_DIR}/debbuild/source"
mkdir -p "${staging}"
stage_common "${staging}"
cp -r "${DEBIAN_DIR}" "${staging}/debian"
cp "${staging}/xrpld.service" "${staging}/debian/xrpld.service"
cp "${staging}/xrpld.sysusers" "${staging}/debian/xrpld.sysusers"
cp "${staging}/xrpld.tmpfiles" "${staging}/debian/xrpld.tmpfiles"
cp "${staging}/xrpld.logrotate" "${staging}/debian/xrpld.logrotate"
# Debian version is <upstream>[~<pre>]-<pkg release>.
cat >"${staging}/debian/changelog" <<EOF
xrpld (${pkg_version}-${PKG_RELEASE}) ${PKG_CHANNEL}; urgency=medium
* Release ${xrpld_version}.
-- XRPL Foundation <contact@xrplf.org> ${CHANGELOG_DATE}
EOF
chmod +x "${staging}/debian/rules"
set -x
(cd "${staging}" && dpkg-buildpackage -b --no-sign -d)
}
# Remove both build directories, because a package left from an earlier build
# would otherwise be picked up and published alongside this one.
rm -rf "${BUILD_DIR}/debbuild" "${BUILD_DIR}/rpmbuild"
"build_${pkg_type}"

View File

@@ -28,31 +28,31 @@ esac
# - debhelper and dpkg-dev build the DEB
# - rpm-build builds the RPM, with systemd-rpm-macros and redhat-rpm-config
# supplying the systemd and find-debuginfo macros the spec uses
# - rpm-sign signs the built RPM
# - git gives build_pkg.sh a real history to read SOURCE_DATE_EPOCH from;
# without one the timestamp falls back to the wall clock
# - curl uploads the finished packages in publish_pkg.sh
# - ca-certificates lets curl and git verify TLS
# - rpm-sign and gnupg2 sign the built RPM
# - python3 runs the packaging scripts
# - git gives build_pkg.py the commit timestamp it stamps files with
# - ca-certificates lets git and the packaging scripts verify TLS
function install() {
case "${ID}" in
debian | ubuntu)
apt-get update -y
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
debhelper \
debhelper-compat \
dpkg-dev \
git
git \
python3
;;
rhel | centos | rocky | almalinux)
dnf install -y --setopt=install_weak_deps=False \
curl-minimal \
git \
gnupg2 \
python3 \
redhat-rpm-config \
rpm-build \
rpm-sign \
redhat-rpm-config \
systemd-rpm-macros
;;
esac

153
package/publish_pkg.py Executable file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Publish the packages built by build_pkg.py to the XRPLF repositories on Nexus.
RPMs are uploaded to the hosted repository, but yum clients install from the
'rpm-<channel>' group repository in front of it, which serves signed metadata.
NEXUS_USERNAME and NEXUS_PASSWORD are read from the environment, so the
credentials never reach the process list.
"""
import argparse
import base64
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
SUFFIXES = (".deb", ".ddeb", ".rpm")
# No progress for this long ends an attempt. urlopen applies the timeout per
# socket operation, so a stalled transfer fails while a merely slow one carries
# on -- the debuginfo package is large enough for that distinction to matter.
STALL_TIMEOUT = 300
ATTEMPTS = 4
RETRY_DELAY = 5
def build_opener() -> urllib.request.OpenerDirector:
"""An opener with no redirect handler, so a 3xx raises instead of being followed.
A redirected upload is silently downgraded to a GET, turning it into a no-op
that still answers 200.
"""
opener = urllib.request.OpenerDirector()
opener.add_handler(urllib.request.HTTPHandler())
opener.add_handler(urllib.request.HTTPSHandler())
opener.add_handler(urllib.request.HTTPErrorProcessor())
opener.add_handler(urllib.request.HTTPDefaultErrorHandler())
return opener
def upload(url: str, method: str, headers: dict[str, str], package: Path) -> None:
"""Send one package, retrying only what is worth retrying.
A 4xx is a deterministic rejection, so it is reported at once rather than
re-sending the whole body three more times. Nexus explains what it rejected
in the response body, so that body is always surfaced.
"""
opener = build_opener()
for attempt in range(1, ATTEMPTS + 1):
try:
with package.open("rb") as body:
request = urllib.request.Request(
url,
data=body,
method=method,
headers={**headers, "Content-Length": str(package.stat().st_size)},
)
opener.open(request, timeout=STALL_TIMEOUT)
return
except urllib.error.HTTPError as error:
detail = error.read().decode(errors="replace").strip()
reason = f"HTTP {error.code}: {detail}"
retryable = error.code >= 500
except (urllib.error.URLError, OSError) as error:
reason = str(error)
retryable = True
assert (
retryable and attempt < ATTEMPTS
), f"upload of {package.name} failed: {reason}"
print(f" attempt {attempt} failed ({reason}), retrying")
time.sleep(RETRY_DELAY)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--channel",
required=True,
help="release channel, selecting the deb-<channel> and rpm-<channel>-hosted repositories",
)
parser.add_argument(
"--package-dir",
type=Path,
default=Path("build"),
help=f"searched recursively for {', '.join(SUFFIXES)} (default: %(default)s)",
)
parser.add_argument(
"--nexus-url",
default="https://packages.xrplf.org",
help="the Nexus instance to publish to (default: %(default)s)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="list the uploads without performing them",
)
args = parser.parse_args()
channel: str = args.channel
package_dir: Path = args.package_dir
nexus_url: str = args.nexus_url
dry_run: bool = args.dry_run
nexus = nexus_url.rstrip("/")
deb_repo = f"deb-{channel}"
rpm_repo = f"rpm-{channel}-hosted"
auth: dict[str, str] = {}
if not dry_run:
username = os.environ.get("NEXUS_USERNAME")
password = os.environ.get("NEXUS_PASSWORD")
assert username and password, "NEXUS_USERNAME and NEXUS_PASSWORD are required"
token = base64.b64encode(f"{username}:{password}".encode()).decode()
auth = {"Authorization": f"Basic {token}"}
packages = sorted(
path
for path in package_dir.rglob("*")
if path.is_file() and path.suffix in SUFFIXES
)
# Uploading nothing would otherwise look like a successful publish.
assert packages, f"no packages found in {package_dir}"
print(f"Publishing {package_dir} to {deb_repo} and {rpm_repo} on {nexus}:")
for package in packages:
if package.suffix == ".rpm":
# yum repositories are addressed by path, and the arch comes from
# the name, e.g. xrpld-3.4.0-1.el9.x86_64.rpm.
destination = f"{rpm_repo}/{package.stem.rsplit('.', 1)[-1]}"
url = f"{nexus}/repository/{destination}/{package.name}"
method, content_type = "PUT", "application/octet-stream"
else:
# A raw body with a multipart Content-Type, POSTed to the repository
# root, is the documented upload for a hosted apt repository:
# https://help.sonatype.com/en/apt-repositories.html#deploying-packages-to-hosted-apt-repositories
destination = deb_repo
url = f"{nexus}/repository/{destination}/"
method, content_type = "POST", "multipart/form-data"
print(f" {package.name} -> {destination}")
if not dry_run:
upload(url, method, {"Content-Type": content_type, **auth}, package)
verb = "would be published" if dry_run else "published"
print(f"{len(packages)} package(s) {verb}.")
if __name__ == "__main__":
main()

View File

@@ -1,109 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Publish the DEB and RPM packages built by build_pkg.sh to the XRPLF package
# repositories on Sonatype Nexus.
#
# Usage: publish_pkg.sh <channel> [package-dir]
#
# channel release channel, selecting the 'deb-<channel>' and
# 'rpm-<channel>-hosted' repositories
# package-dir searched recursively for *.deb, *.ddeb and *.rpm ('build' by
# default)
#
# RPMs are uploaded to the hosted repository, but yum clients install from the
# 'rpm-<channel>' group repository in front of it, which serves signed metadata.
#
# NEXUS_USERNAME and NEXUS_PASSWORD are required. NEXUS_URL overrides the target
# instance, and DRY_RUN=1 lists the uploads without performing them.
channel="${1:-}"
pkg_dir="${2:-build}"
nexus_url="${NEXUS_URL:-https://packages.xrplf.org}"
if [[ -z "${channel}" ]]; then
echo "usage: publish_pkg.sh <channel> [package-dir]" >&2
exit 2
fi
deb_repo="deb-${channel}"
rpm_repo="rpm-${channel}-hosted"
if [[ -z "${DRY_RUN:-}" ]]; then
: "${NEXUS_USERNAME:?is required}" "${NEXUS_PASSWORD:?is required}"
fi
# Deliberate curl choices:
#
# - no --fail, which would hide the response body where Nexus explains what it
# rejected
# - no --location, since curl downgrades a redirected POST to GET and turns an
# upload into a no-op that still answers 200
# - credentials on stdin, to keep them out of the process list
upload() {
local url="$1"
shift
[[ -z "${DRY_RUN:-}" ]] || return 0
local body code status=0
body="$(mktemp)"
code="$(
printf 'user = %s:%s\n' "${NEXUS_USERNAME}" "${NEXUS_PASSWORD}" |
curl \
--config - \
--silent \
--show-error \
--retry 3 \
--retry-delay 5 \
--retry-all-errors \
--output "${body}" \
--write-out '%{http_code}' \
"$@" \
"${url}"
)" || status=$?
if [[ ${status} -ne 0 || ! "${code}" =~ ^2[0-9][0-9]$ ]]; then
echo "publish_pkg.sh: upload failed (curl ${status}, HTTP ${code}): ${url}" >&2
cat "${body}" >&2
echo >&2
rm -f "${body}"
exit 1
fi
rm -f "${body}"
}
echo "Publishing ${pkg_dir} to ${deb_repo} and ${rpm_repo} on ${nexus_url}:"
count=0
while IFS= read -r -d '' file; do
name="${file##*/}"
case "${name}" in
# A raw body with a multipart Content-Type, POSTed to the repository root,
# is the documented upload for a hosted apt repository:
# https://help.sonatype.com/en/apt-repositories.html#deploying-packages-to-hosted-apt-repositories
*.deb | *.ddeb)
echo " ${name} -> ${deb_repo}"
upload "${nexus_url}/repository/${deb_repo}/" \
--header 'Content-Type: multipart/form-data' \
--data-binary "@${file}"
;;
# yum repositories are addressed by path; the arch comes from the name.
*.rpm)
arch="${name%.rpm}"
arch="${arch##*.}"
echo " ${name} -> ${rpm_repo}/${arch}"
upload "${nexus_url}/repository/${rpm_repo}/${arch}/${name}" \
--upload-file "${file}"
;;
esac
count=$((count + 1))
done < <(find "${pkg_dir}" -type f \( -name '*.deb' -o -name '*.ddeb' -o -name '*.rpm' \) -print0)
# Uploading nothing would otherwise look like a successful publish.
if [[ ${count} -eq 0 ]]; then
echo "publish_pkg.sh: no packages found in ${pkg_dir}." >&2
exit 1
fi
echo "${count} package(s) ${DRY_RUN:+would be }published."

128
package/sign_rpm.py Executable file
View File

@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Sign the RPMs built by build_pkg.py.
Nexus signs the yum repository metadata (via the 'rpm-<channel>' group
repository), but never the packages themselves, so they carry their own
signature. Clients verify the packages with gpgcheck=1 and the metadata with
repo_gpgcheck=1.
The DEBs are deliberately not signed: embedded DEB signatures exist (debsigs),
but apt does not verify them by default and trusts the repository metadata,
which Nexus signs, instead.
PKG_SIGNING_KEY is read from the environment, so the key never reaches the
process list.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import tempfile
from pathlib import Path
# An RSA signature lands in the RSAHEADER tag, a DSA or EdDSA one in DSAHEADER,
# so both are queried; checking only the first would reject a signed package.
SIGNATURE_QUERY = "%{RSAHEADER:pgpsig}%{DSAHEADER:pgpsig}"
UNSIGNED = "(none)(none)"
def gpg(gnupghome: Path, *args: str, stdin: str | None = None) -> str:
"""Run gpg against a throwaway keyring and return its stdout."""
return subprocess.run(
["gpg", "--batch", "--quiet", *args],
input=stdin,
# stderr is left alone so a failing gpg explains itself.
stdout=subprocess.PIPE,
text=True,
check=True,
env={**os.environ, "GNUPGHOME": str(gnupghome)},
).stdout
def import_key(gnupghome: Path, key: str) -> str:
"""Import the armoured private key and return its fingerprint."""
gpg(gnupghome, "--import", stdin=key)
records = [
line.split(":")
for line in gpg(gnupghome, "--list-secret-keys", "--with-colons").splitlines()
]
# Exactly one, so the fingerprint picked below is not a guess.
secrets = [record for record in records if record[0] == "sec"]
assert (
len(secrets) == 1
), f"PKG_SIGNING_KEY must hold exactly one secret key, found {len(secrets)}"
# The first fingerprint belongs to the primary key; subkeys follow.
fingerprints = [record[9] for record in records if record[0] == "fpr"]
assert fingerprints, "PKG_SIGNING_KEY holds a secret key with no fingerprint"
return fingerprints[0]
def sign(gnupghome: Path, rpms: list[Path], fingerprint: str) -> None:
"""Attach a signature to every RPM in one rpmsign invocation."""
subprocess.run(
[
"rpmsign",
"--define",
f"_gpg_name {fingerprint}",
# Loopback pinentry: the key is unattended, so there is no tty to
# prompt on.
"--define",
"_gpg_sign_cmd_extra_args --pinentry-mode loopback --batch --yes",
"--addsign",
*(str(rpm) for rpm in rpms),
],
check=True,
env={**os.environ, "GNUPGHOME": str(gnupghome)},
)
def verify(rpms: list[Path]) -> None:
"""Fail unless every RPM now carries a signature.
rpmsign can exit 0 having attached nothing, and an unsigned package is only
rejected later, on the installing machine.
"""
for rpm in rpms:
signature = subprocess.run(
["rpm", "--query", "--queryformat", SIGNATURE_QUERY, "--package", str(rpm)],
stdout=subprocess.PIPE,
text=True,
check=True,
).stdout.strip()
assert signature != UNSIGNED, f"{rpm} is unsigned after rpmsign"
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--package-dir",
type=Path,
default=Path("build"),
help="searched recursively for *.rpm (default: %(default)s)",
)
args = parser.parse_args()
package_dir: Path = args.package_dir
rpms = sorted(path for path in package_dir.rglob("*.rpm") if path.is_file())
# Signing nothing would otherwise look like a successful signing.
assert rpms, f"no RPMs found in {package_dir}"
key = os.environ.get("PKG_SIGNING_KEY")
assert key, "PKG_SIGNING_KEY is required"
# The keyring holds an unencrypted private key, so it goes even if signing
# fails.
with tempfile.TemporaryDirectory() as tmp:
gnupghome = Path(tmp)
fingerprint = import_key(gnupghome, key)
print(f"Signing {len(rpms)} RPM(s) with {fingerprint}.")
sign(gnupghome, rpms, fingerprint)
verify(rpms)
if __name__ == "__main__":
main()

View File

@@ -1,67 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Sign the RPMs built by build_pkg.sh. Nexus signs the yum repository metadata
# (via the 'rpm-<channel>' group repository), but never the packages themselves,
# so they carry their own signature. Clients verify the packages with gpgcheck=1
# and the metadata with repo_gpgcheck=1.
#
# Usage: sign_rpm.sh [package-dir]
#
# package-dir searched recursively for *.rpm ('build' by default)
#
# PKG_SIGNING_KEY must hold an armoured PGP private key. It has no flag, to keep
# the key out of the process list.
#
# The DEBs are deliberately not signed: embedded DEB signatures exist (debsigs),
# but apt does not verify them by default and trusts the repository metadata,
# which Nexus signs, instead.
pkg_dir="${1:-build}"
mapfile -d '' rpms < <(find "${pkg_dir}" -type f -name '*.rpm' -print0)
# Signing nothing would otherwise look like a successful signing.
if [[ ${#rpms[@]} -eq 0 ]]; then
echo "sign_rpm.sh: no RPMs found in ${pkg_dir}." >&2
exit 1
fi
: "${PKG_SIGNING_KEY:?is required}"
# Global, and expanded by the trap when it fires: the keyring holds an
# unencrypted private key, so it must go even if signing fails.
signing_home="$(mktemp -d)"
trap 'rm -rf "${signing_home}"' EXIT
export GNUPGHOME="${signing_home}"
printf '%s' "${PKG_SIGNING_KEY}" | gpg --batch --quiet --import
# Exactly one secret key, so that picking the first below is not a guess between
# several.
secrets="$(gpg --list-secret-keys --with-colons | grep -c '^sec:' || true)"
if [[ "${secrets}" -ne 1 ]]; then
echo "sign_rpm.sh: PKG_SIGNING_KEY must hold exactly one secret key, found ${secrets}." >&2
exit 1
fi
key="$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/ { print $10; exit }')"
echo "Signing ${#rpms[@]} RPM(s) with ${key}."
# Loopback pinentry: the key is unattended, so there is no tty to prompt on.
rpmsign \
--define "_gpg_name ${key}" \
--define "_gpg_sign_cmd_extra_args --pinentry-mode loopback --batch --yes" \
--addsign "${rpms[@]}"
# rpmsign can exit 0 having attached nothing, and an unsigned package is only
# rejected later, on the installing machine. Both header tags are checked
# because an RSA signature lands in RSAHEADER and a DSA or EdDSA one in
# DSAHEADER.
for pkg in "${rpms[@]}"; do
signature="$(rpm --query --queryformat '%{RSAHEADER:pgpsig}%{DSAHEADER:pgpsig}' --package "${pkg}")"
if [[ "${signature}" == "(none)(none)" ]]; then
echo "sign_rpm.sh: ${pkg} is unsigned after rpmsign." >&2
exit 1
fi
done

View File

@@ -1,4 +1,4 @@
[toolchain]
channel = "1.95"
components = ["rustfmt", "clippy", "rust-analyzer", "llvm-tools-preview"]
channel = "1.97.1"
components = ["rustfmt", "clippy", "rust-analyzer", "llvm-tools-preview", "rust-src"]
profile = "minimal"

5
src/libxrpl/tx/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
# AGENTS.md — tx
See the repo-level [AGENTS.md](../../../AGENTS.md) for general build/test/style guidance.
Any change to transaction-processing behavior must be gated behind an amendment. New amendments (and fixes, i.e. `fix*` amendments) are added to [`include/xrpl/protocol/detail/features.macro`](../../../include/xrpl/protocol/detail/features.macro), as an `XRPL_FEATURE(...)` or `XRPL_FIX(...)` entry added to the top of the list (the list is kept in reverse chronological order). Once the pre-amendment code path for a retired amendment is removed, move its entry to `XRPL_RETIRE_FEATURE(...)`/`XRPL_RETIRE_FIX(...)` instead of deleting it.

1
src/libxrpl/tx/CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -3,6 +3,7 @@
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
@@ -305,6 +306,45 @@ ValidVault::finalizeLoanSet(ReadView const& view, beast::Journal const& j) const
return true;
}
namespace {
// sfAssetsTotal, sfAssetsAvailable and sfLossUnrealized are STNumber fields
// with kSmdNeedsAsset, so IOU writes go through associateAsset -> roundToAsset
// -> STAmount quantization. Since assetsTotal is the largest number, it lands
// on the coarsest decimal grid, and strict equality on the deltas can fire on
// a single unit of quantization noise even when the underlying flow is
// correct. Absorb one unit at the coarsest scale.
//
// XRP and MPT are integer-domain assets (Asset::integral() is true) with no
// sub-ULP quantization; treating a whole drop / MPT unit as "noise" would
// hide real accounting bugs. Keep the strict comparison there. Note that
// gating on the sign of `scale` would be wrong: IOU amounts >= 1e15 have a
// non-negative STAmount exponent but still quantize.
[[nodiscard]] bool
agreesWithinOneUnit(Number const& lhs, Number const& rhs, Asset const& asset, std::int32_t scale)
{
if (asset.integral())
return lhs == rhs;
auto const diff = lhs - rhs;
Number const tolerance{1, scale};
return (diff < beast::kZero ? -diff : diff) <= tolerance;
}
// L, T and A are each independently quantized; the strict L <= T - A check
// can fire on residual noise even when the true relationship holds. Tolerate
// one unit at scale(assetsTotal) - the coarsest of the three grids. As with
// the delta check above, the tolerance is meaningful only for IOU
// (Asset::integral() is false); XRP and MPT keep the strict comparison.
[[nodiscard]] bool
lessOrEqualPlusOneUnit(Number const& lhs, Number const& rhs, Asset const& asset, std::int32_t scale)
{
if (asset.integral())
return lhs <= rhs;
return lhs <= rhs + Number{1, scale};
}
} // namespace
std::int32_t
ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const
{
@@ -340,6 +380,7 @@ ValidVault::finalize(
beast::Journal const& j)
{
bool const enforce = view.rules().enabled(featureSingleAssetVault);
bool const fixEnabled = view.rules().enabled(fixCleanup3_4_0);
if (!isTesSuccess(ret))
return true; // Do not perform checks
@@ -527,15 +568,32 @@ ValidVault::finalize(
"not be greater than assets outstanding";
result = false;
}
else if (afterVault.lossUnrealized > afterVault.assetsTotal - afterVault.assetsAvailable)
else
{
JLOG(j.fatal()) //
<< "Invariant failed: loss unrealized must not exceed "
"the difference between assets outstanding and available";
result = false;
bool const gapExceeded = [&] {
if (!fixEnabled)
{
return afterVault.lossUnrealized >
afterVault.assetsTotal - afterVault.assetsAvailable;
}
auto const s = scale(afterVault.assetsTotal, afterVault.asset);
return !lessOrEqualPlusOneUnit(
afterVault.lossUnrealized,
afterVault.assetsTotal - afterVault.assetsAvailable,
afterVault.asset,
s);
}();
if (gapExceeded)
{
JLOG(j.fatal()) //
<< "Invariant failed: loss unrealized must not exceed "
"the difference between assets outstanding and available";
result = false;
}
}
if (view.rules().enabled(fixCleanup3_4_0) && afterVault.lossUnrealized < kZero)
if (fixEnabled && afterVault.lossUnrealized < kZero)
{
JLOG(j.fatal()) << "Invariant failed: loss unrealized must not be negative";
result = false;
@@ -821,7 +879,14 @@ ValidVault::finalize(
result = false;
}
if (localVaultDeltaAssets * -1 != accountDeltaAssets)
bool const acctVaultAddsUp = fixEnabled
? agreesWithinOneUnit(
localVaultDeltaAssets * -1,
accountDeltaAssets,
vaultAsset,
localMinScale)
: localVaultDeltaAssets * -1 == accountDeltaAssets;
if (!acctVaultAddsUp)
{
JLOG(j.fatal()) << "Invariant failed: " << //
"deposit must change vault and depositor balance by equal amount";
@@ -869,7 +934,10 @@ ValidVault::finalize(
auto const assetTotalDelta = roundToAsset(
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
if (assetTotalDelta != vaultDeltaAssets)
bool const totalAddsUp = fixEnabled
? agreesWithinOneUnit(assetTotalDelta, vaultDeltaAssets, vaultAsset, minScale)
: assetTotalDelta == vaultDeltaAssets;
if (!totalAddsUp)
{
JLOG(j.fatal())
<< "Invariant failed: deposit and assets outstanding must add up";
@@ -878,7 +946,11 @@ ValidVault::finalize(
auto const assetAvailableDelta = roundToAsset(
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
if (assetAvailableDelta != vaultDeltaAssets)
bool const availableAddsUp = fixEnabled
? agreesWithinOneUnit(
assetAvailableDelta, vaultDeltaAssets, vaultAsset, minScale)
: assetAvailableDelta == vaultDeltaAssets;
if (!availableAddsUp)
{
JLOG(j.fatal()) << "Invariant failed: deposit and assets available must add up";
result = false;
@@ -920,8 +992,8 @@ ValidVault::finalize(
// value merely rounds down to zero, so a missing delta while
// the pool still held positive effective value indicates a
// real accounting bug, not this exception.
bool const zeroDeltaIsLegitimate = view.rules().enabled(fixCleanup3_4_0) &&
!maybeVaultDeltaAssets && beforeVault.assetsTotal == beforeVault.lossUnrealized;
bool const zeroDeltaIsLegitimate = fixEnabled && !maybeVaultDeltaAssets &&
beforeVault.assetsTotal == beforeVault.lossUnrealized;
if (!maybeVaultDeltaAssets && !zeroDeltaIsLegitimate)
{
@@ -1027,8 +1099,14 @@ ValidVault::finalize(
vaultDeltaAssets.delta * -1 - destinationDelta.delta,
destinationScale,
Number::RoundingMode::Downward) == kZero;
if (!destroyedIsSubUlp &&
localPseudoDeltaAssets * -1 != roundedDestinationDelta)
bool const withdrawAddsUp = fixEnabled
? agreesWithinOneUnit(
localPseudoDeltaAssets * -1,
roundedDestinationDelta,
vaultAsset,
localMinScale)
: localPseudoDeltaAssets * -1 == roundedDestinationDelta;
if (!destroyedIsSubUlp && !withdrawAddsUp)
{
JLOG(j.fatal()) << "Invariant failed: " << //
"withdrawal must change vault and destination balance by equal "
@@ -1071,7 +1149,11 @@ ValidVault::finalize(
auto const assetTotalDelta = roundToAsset(
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
// Note, vaultBalance is negative (see check above)
if (assetTotalDelta != vaultPseudoDeltaAssets)
bool const totalAddsUp = fixEnabled
? agreesWithinOneUnit(
assetTotalDelta, vaultPseudoDeltaAssets, vaultAsset, minScale)
: assetTotalDelta == vaultPseudoDeltaAssets;
if (!totalAddsUp)
{
JLOG(j.fatal())
<< "Invariant failed: withdrawal and assets outstanding must add up";
@@ -1081,7 +1163,11 @@ ValidVault::finalize(
auto const assetAvailableDelta = roundToAsset(
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
if (assetAvailableDelta != vaultPseudoDeltaAssets)
bool const availableAddsUp = fixEnabled
? agreesWithinOneUnit(
assetAvailableDelta, vaultPseudoDeltaAssets, vaultAsset, minScale)
: assetAvailableDelta == vaultPseudoDeltaAssets;
if (!availableAddsUp)
{
JLOG(j.fatal())
<< "Invariant failed: withdrawal and assets available must add up";
@@ -1126,7 +1212,11 @@ ValidVault::finalize(
auto const assetsTotalDelta = roundToAsset(
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
if (assetsTotalDelta != vaultDeltaAssets)
bool const totalAddsUp = fixEnabled
? agreesWithinOneUnit(
assetsTotalDelta, vaultDeltaAssets, vaultAsset, minScale)
: assetsTotalDelta == vaultDeltaAssets;
if (!totalAddsUp)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback and assets outstanding must add up";
@@ -1137,7 +1227,11 @@ ValidVault::finalize(
vaultAsset,
afterVault.assetsAvailable - beforeVault.assetsAvailable,
minScale);
if (assetAvailableDelta != vaultDeltaAssets)
bool const availableAddsUp = fixEnabled
? agreesWithinOneUnit(
assetAvailableDelta, vaultDeltaAssets, vaultAsset, minScale)
: assetAvailableDelta == vaultDeltaAssets;
if (!availableAddsUp)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback and assets available must add up";

View File

@@ -271,8 +271,15 @@ VaultClawback::assetsToClawback(
}
else
{
// Pre-fixCleanup3_4_0: shares were rounded to nearest, so the
// round-trip back to assets could exceed clawbackAmount.
// Post-amendment: truncate shares so assetsRecovered <=
// clawbackAmount by construction (matches the clamp branch
// below).
auto const truncate = ctx_.view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes
: TruncateShares::No;
auto const maybeShares =
assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount);
assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount, truncate);
if (!maybeShares)
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
sharesDestroyed = *maybeShares;
@@ -353,11 +360,6 @@ VaultClawback::doApply()
auto assetsAvailable = vault->at(sfAssetsAvailable);
auto assetsTotal = vault->at(sfAssetsTotal);
[[maybe_unused]] auto const lossUnrealized = vault->at(sfLossUnrealized);
XRPL_ASSERT(
lossUnrealized <= (assetsTotal - assetsAvailable),
"xrpl::VaultClawback::doApply : loss and assets do balance");
AccountID const holder = tx[sfHolder];
STAmount sharesDestroyed = {share};
STAmount assetsRecovered = {vault->at(sfAsset)};

View File

@@ -305,9 +305,20 @@ VaultWithdraw::doApply()
if (amount.asset() == vaultAsset)
{
// Fixed assets, variable shares.
//
// Pre-fixCleanup3_4_0: shares were rounded to nearest, so the
// round-trip back to assets could exceed the requested amount.
// That over-delivers to the depositor and can bypass the
// preclaim canWithdraw check on the destination, which was
// validated against the requested amount only.
// Post-amendment: truncate shares so assetsWithdrawn <=
// requested amount by construction. If truncation yields zero
// shares, the tecPRECISION_LOSS guard below fires.
auto const truncate =
view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes : TruncateShares::No;
{
auto const maybeShares = assetsToSharesWithdraw(
vault, sleIssuance, amount, TruncateShares::No, waiveUnrealizedLoss);
vault, sleIssuance, amount, truncate, waiveUnrealizedLoss);
if (!maybeShares)
return tecINTERNAL; // LCOV_EXCL_LINE
sharesRedeemed = *maybeShares;
@@ -357,9 +368,6 @@ VaultWithdraw::doApply()
auto assetsAvailable = vault->at(sfAssetsAvailable);
auto assetsTotal = vault->at(sfAssetsTotal);
auto const lossUnrealized = vault->at(sfLossUnrealized);
XRPL_ASSERT(
lossUnrealized <= (assetsTotal - assetsAvailable),
"xrpl::VaultWithdraw::doApply : loss and assets do balance");
if (view().rules().enabled(fixCleanup3_4_0) && !isFinalWithdrawal)
{

View File

@@ -3,7 +3,10 @@
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/fee.h>
#include <test/jtx/pay.h>
#include <test/jtx/sig.h>
#include <test/jtx/trust.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
@@ -1947,6 +1950,181 @@ class InvariantsVault_test : public InvariantsBase
});
}
// Minimal impaired-loan setup for testVaultLossExceedsGap. Kept
// inline here so this file has no dependency on LoanTestBase.
Keylet
makeImpairedVault(
test::jtx::Account const& owner,
test::jtx::Account const& borrower,
test::jtx::Account const& issuer,
test::jtx::Env& env)
{
using namespace test::jtx;
env.fund(XRP(1'000'000), issuer, borrower);
env.close();
PrettyAsset const usd = issuer["USD"];
STAmount const trustLimit{usd.raw(), Number{9'999'999'999'999'999LL}};
env(trust(owner, trustLimit));
env(trust(borrower, trustLimit));
env.close();
env(pay(issuer, owner, usd(100'000)));
env(pay(issuer, borrower, usd(1'000)));
env.close();
Vault const vault{env};
auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd});
env(vaultTx);
env.close();
env(vault.deposit(
{.depositor = owner, .id = vaultKeylet.key, .amount = usd(1'000).value()}));
env.close();
auto const brokerKeylet =
keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
{
using namespace loan_broker;
env(set(owner, vaultKeylet.key),
kCoverRateMinimum(percentageToTenthBips(1)),
kCoverRateLiquidation(xrpl::lending::kMaxCoverRate),
Fee(env.current()->fees().base * 2));
env.close();
env(coverDeposit(owner, brokerKeylet.key, usd(10'000).value()),
Fee(env.current()->fees().base * 2));
env.close();
}
auto const brokerSle = env.le(brokerKeylet);
if (!BEAST_EXPECT(brokerSle))
return vaultKeylet;
auto const loanKeylet =
keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
{
using namespace loan;
env(set(borrower, brokerKeylet.key, usd(100).value()),
kCounterparty(owner),
kInterestRate(TenthBips32{1000}),
kPaymentTotal(120),
kPaymentInterval(86400u * 30u),
kGracePeriod(86400u * 30u),
Sig(sfCounterpartySignature, owner),
Fee(env.current()->fees().base * 200));
env.close();
env(manage(owner, loanKeylet.key, tfLoanImpair));
env.close();
}
return vaultKeylet;
}
// Regression test for the loss-vs-gap invariant relaxation introduced
// by fixCleanup3_4_0. Even with the one-unit tolerance, a loss value
// exceeding (T - A) by more than one ULP must still fire. Two
// mutations exercise this:
// 1. L = (T - A) * 2 — fires under both amendment settings.
// 2. L = (T - A) + 2 * oneUnit — fires post-amendment, catching
// any accidental widening of the tolerance beyond one unit.
void
testVaultLossExceedsGap()
{
testcase("vault loss exceeds gap (fixCleanup3_4_0 tolerance)");
using namespace test::jtx;
auto const kExpectedLog = std::vector<std::string>{
"loss unrealized must not exceed the difference between assets "
"outstanding and available"};
for (auto const withFix : {false, true})
{
FeatureBitset amendments = all_;
if (!withFix)
amendments = amendments - fixCleanup3_4_0;
// Variant 1: L = (T - A) * 2. Fires under both settings.
{
Keylet vaultKeylet = keylet::vault(uint256{});
Account const issuer{"issuer_loss_gap"};
Account const borrower{"borrower_loss_gap"};
auto preclose = [&, this](Account const& owner, Account const&, Env& env) -> bool {
vaultKeylet = this->makeImpairedVault(owner, borrower, issuer, env);
return BEAST_EXPECT(env.le(vaultKeylet));
};
doInvariantCheck(
makeEnv(amendments),
kExpectedLog,
[&vaultKeylet](Account const&, Account const&, ApplyContext& ac) -> bool {
auto sle = ac.view().peek(vaultKeylet);
if (!sle)
return false;
Number const total = sle->at(sfAssetsTotal);
Number const available = sle->at(sfAssetsAvailable);
(*sle)[sfLossUnrealized] = (total - available) * 2;
ac.view().update(sle);
return true;
},
XRPAmount{},
STTx{
ttVAULT_DEPOSIT,
[&vaultKeylet](STObject& tx) {
tx.setFieldH256(sfVaultID, vaultKeylet.key);
}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
preclose,
TxAccount::A1);
}
// Variant 2: L = (T - A) + 2 * oneUnit at scale(T). Must fire
// post-fix because the tolerance is exactly one unit. A
// regression that widened it to two units would silently accept
// this state.
{
Keylet vaultKeylet = keylet::vault(uint256{});
Account const issuer{"issuer_loss_gap2"};
Account const borrower{"borrower_loss_gap2"};
auto preclose = [&, this](Account const& owner, Account const&, Env& env) -> bool {
vaultKeylet = this->makeImpairedVault(owner, borrower, issuer, env);
return BEAST_EXPECT(env.le(vaultKeylet));
};
doInvariantCheck(
makeEnv(amendments),
kExpectedLog,
[&vaultKeylet](Account const&, Account const&, ApplyContext& ac) -> bool {
auto sle = ac.view().peek(vaultKeylet);
if (!sle)
return false;
Number const total = sle->at(sfAssetsTotal);
Number const available = sle->at(sfAssetsAvailable);
Asset const asset = sle->at(sfAsset);
Number const oneUnit{1, scale(total, asset)};
(*sle)[sfLossUnrealized] = (total - available) + oneUnit * 2;
ac.view().update(sle);
return true;
},
XRPAmount{},
STTx{
ttVAULT_DEPOSIT,
[&vaultKeylet](STObject& tx) {
tx.setFieldH256(sfVaultID, vaultKeylet.key);
}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
preclose,
TxAccount::A1);
}
}
}
void
testVaultComputeCoarsestScale()
{
@@ -2082,6 +2260,7 @@ class InvariantsVault_test : public InvariantsBase
run() override
{
testVault();
testVaultLossExceedsGap();
testVaultComputeCoarsestScale();
}
};

View File

@@ -21,6 +21,7 @@
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
@@ -32,6 +33,7 @@
#include <chrono>
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <tuple>
#include <utility>
@@ -974,6 +976,242 @@ private:
}
}
// Shared setup for testBugClawbackRoundTripOvershoot and
// testBugWithdrawRoundTripOvershoot, which both need a vault at
// assetsTotal=7, sharesTotal=5 and differ only in what they do once
// that state is reached.
//
// The (7, 5) state is reached through ordinary transactions: a 5 USD
// deposit mints 5 shares 1:1, then a loan broker on the vault issues a
// single-payment bullet loan for the full 5 USD at 40% interest. When
// the borrower repays a year later, LoanPay books the 2 USD of accrued
// interest into sfAssetsTotal without minting shares, leaving
// assetsTotal=7 against sharesTotal=5 (see
// testBugDepositShareTruncationSubUlp for the same technique in more
// detail).
struct RoundTripOvershootVault
{
test::jtx::Account issuer;
test::jtx::Account holder;
PrettyAsset usd;
test::jtx::Vault vault;
Keylet vaultKeylet;
Number initialAssetsTotal;
Number initialAssetsAvailable;
};
std::optional<RoundTripOvershootVault>
makeRoundTripOvershootVault(test::jtx::Env& env)
{
using namespace test::jtx;
using namespace loan_broker;
using namespace loan;
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const holder{"holder"};
Account const borrower{"borrower"};
env.fund(XRP(10'000), issuer, owner, holder, borrower);
env.close();
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const usd = issuer["USD"];
env.trust(usd(1'000), owner);
env.trust(usd(1'000), holder);
env.trust(usd(1'000), borrower);
env.close();
env(pay(issuer, holder, usd(100)));
env(pay(issuer, borrower, usd(100)));
env.close();
Vault const vault{env};
auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd});
vaultTx[sfScale] = 0;
env(vaultTx);
env.close();
// Holder deposits 5 USD, minting 5 shares 1:1.
env(vault.deposit({.depositor = holder, .id = vaultKeylet.key, .amount = usd(5)}));
env.close();
// A loan broker on the vault, then a single bullet loan for the
// entire deposit at 40% interest, one payment, one year out.
auto const brokerKeylet =
keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
env(set(owner, vaultKeylet.key));
env.close();
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
env(set(borrower, brokerKeylet.key, usd(5).value()),
loan::kInterestRate(percentageToTenthBips(40)),
kGracePeriod(60),
kPaymentInterval(365 * 24 * 60 * 60),
kPaymentTotal(1),
Sig(sfCounterpartySignature, owner),
Fee(env.current()->fees().base * 2),
Ter(tesSUCCESS));
env.close();
// Advance to just before the single payment falls due and let the
// borrower repay principal plus interest. Share supply stays at 5,
// so assetsTotal/sharesTotal becomes 7/5.
env.close(std::chrono::seconds{(365 * 24 * 60 * 60) - 3600});
env(pay(borrower, loanKeylet.key, usd(10).value()), Ter(tesSUCCESS));
env.close();
auto const vaultSle = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultSle))
return std::nullopt;
auto const mptIssuanceID = vaultSle->at(sfShareMPTID);
Number const initialAssetsTotal = vaultSle->at(sfAssetsTotal);
Number const initialAssetsAvailable = vaultSle->at(sfAssetsAvailable);
BEAST_EXPECT(initialAssetsTotal == usd(7).number());
BEAST_EXPECT(initialAssetsAvailable == usd(7).number());
{
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptIssuanceID));
if (!BEAST_EXPECT(sleIssuance))
return std::nullopt;
BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == 5);
}
return RoundTripOvershootVault{
.issuer = issuer,
.holder = holder,
.usd = usd,
.vault = vault,
.vaultKeylet = vaultKeylet,
.initialAssetsTotal = initialAssetsTotal,
.initialAssetsAvailable = initialAssetsAvailable};
}
// VaultClawback::assetsToClawback converts clawbackAmount to shares
// with round-to-nearest, then round-trips back to assets. When shares
// round up, assetsRecovered can exceed clawbackAmount.
//
// Repro: assetsTotal=7, sharesTotal=5, request 4:
// shares = round(20/7) = 3, assets = 7*3/5 = 4.2 > 4.
//
// Post-fixCleanup3_4_0: truncate shares so assetsRecovered <=
// clawbackAmount by construction.
void
testBugClawbackRoundTripOvershoot()
{
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, bool withFix) {
Env env{*this, features};
auto const setup = makeRoundTripOvershootVault(env);
if (!BEAST_EXPECT(setup))
return;
auto const clawbackAmount = setup->usd(4);
env(setup->vault.clawback(
{.issuer = setup->issuer,
.id = setup->vaultKeylet.key,
.holder = setup->holder,
.amount = clawbackAmount.value()}));
auto const vaultSleAfter = env.current()->read(setup->vaultKeylet);
if (!BEAST_EXPECT(vaultSleAfter))
return;
Number const finalAssetsTotal = vaultSleAfter->at(sfAssetsTotal);
Number const assetsRecovered = setup->initialAssetsTotal - finalAssetsTotal;
Number const clawbackNum = clawbackAmount.number();
Number const expectedPost{28LL, -1};
Number const expectedPre{42LL, -1};
if (withFix)
{
BEAST_EXPECT(assetsRecovered <= clawbackNum);
BEAST_EXPECT(assetsRecovered == expectedPost);
}
else
{
BEAST_EXPECT(assetsRecovered > clawbackNum);
BEAST_EXPECT(assetsRecovered == expectedPre);
}
};
{
testcase(
"bug: VaultClawback round-trip overshoot lets issuer recover "
"more than requested (pre-fixCleanup3_4_0)");
runScenario(testableAmendments() - fixCleanup3_4_0, false);
}
{
testcase(
"bug: VaultClawback round-trip overshoot is clamped so "
"assetsRecovered <= clawbackAmount (post-fixCleanup3_4_0)");
runScenario(testableAmendments(), true);
}
}
// Same root cause as testBugClawbackRoundTripOvershoot on the
// withdraw path. Also bypasses the preclaim canWithdraw check, which
// validates destination limits against the requested amount only.
//
// Repro: assetsTotal=7, sharesTotal=5, request 4:
// pre-fix : shares = round(20/7) = 3, assets = 7*3/5 = 4.2 > 4.
// post-fix: shares = floor(20/7) = 2, assets = 7*2/5 = 2.8 <= 4.
void
testBugWithdrawRoundTripOvershoot()
{
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, bool withFix) {
Env env{*this, features};
auto const setup = makeRoundTripOvershootVault(env);
if (!BEAST_EXPECT(setup))
return;
auto const requested = setup->usd(4);
env(setup->vault.withdraw(
{.depositor = setup->holder,
.id = setup->vaultKeylet.key,
.amount = requested.value()}));
auto const vaultSleAfter = env.current()->read(setup->vaultKeylet);
if (!BEAST_EXPECT(vaultSleAfter))
return;
Number const finalAssetsTotal = vaultSleAfter->at(sfAssetsTotal);
Number const assetsWithdrawn = setup->initialAssetsTotal - finalAssetsTotal;
Number const requestedNum = requested.number();
Number const expectedPost{28LL, -1};
Number const expectedPre{42LL, -1};
if (withFix)
{
BEAST_EXPECT(assetsWithdrawn <= requestedNum);
BEAST_EXPECT(assetsWithdrawn == expectedPost);
}
else
{
BEAST_EXPECT(assetsWithdrawn > requestedNum);
BEAST_EXPECT(assetsWithdrawn == expectedPre);
}
};
{
testcase(
"bug: VaultWithdraw round-trip overshoot delivers more than "
"requested (pre-fixCleanup3_4_0)");
runScenario(testableAmendments() - fixCleanup3_4_0, false);
}
{
testcase(
"bug: VaultWithdraw round-trip overshoot is clamped so "
"assetsWithdrawn <= requested (post-fixCleanup3_4_0)");
runScenario(testableAmendments(), true);
}
}
void
testCredentialPinsPseudoAccount()
{
@@ -1101,6 +1339,8 @@ public:
testCredentialPinsPseudoAccount();
testCredentialPinOverflow();
testBug6LimitBypassWithShares();
testBugClawbackRoundTripOvershoot();
testBugWithdrawRoundTripOvershoot();
}
};

View File

@@ -0,0 +1,458 @@
#include <test/app/vault/VaultPrecisionFixture.h>
#include <test/jtx/Env.h>
#include <test/jtx/amount.h>
#include <test/jtx/envconfig.h>
#include <test/jtx/ter.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/TER.h>
#include <algorithm>
#include <array>
#include <cstdint>
#include <string>
#include <tuple>
namespace xrpl::test {
// With fixCleanup3_4_0 disabled the six delta invariants and the
// lossUnrealized > (assetsTotal - assetsAvailable) gap invariant spuriously
// fire on legitimate flows; with the amendment enabled the one-unit
// tolerance absorbs the sub-ULP drift and every one of these transactions
// must succeed. Exactness (assetsTotal delta == assetsAvailable delta
// exactly) is covered by VaultTransactorPrecision_test.
class VaultInvariantPrecision_test : public VaultPrecisionFixture
{
// Deposit small integer amounts into an A-1 vault. Pre-amendment,
// deposits of 1, 7, and 10'000'000 land on assetsTotal/assetsAvailable
// grids that disagree by one ULP and the invariant fires. Post-
// amendment the tolerance-widened check accepts the same states.
void
testDepositBoundaryInvariant(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
testcase(
std::string("A-1 deposit boundary invariant") +
(fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
std::array<int, 3> const kAmounts{1, 7, 10'000'000};
for (auto const amount : kAmounts)
{
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
if (!f.asset || !f.broker)
{
BEAST_EXPECT(f.asset && f.broker);
continue;
}
auto const& asset = *f.asset;
auto const before = read(env, f);
Vault const v{env};
env(v.deposit(
{.depositor = f.depositor,
.id = f.vaultKeylet.key,
.amount = asset(amount).value()}),
Ter(std::ignore));
env.close();
TER const actual = env.ter();
if (fixEnabled)
{
BEAST_EXPECTS(
actual == tesSUCCESS,
"amount=" + std::to_string(amount) + " expected tesSUCCESS, got " +
transToken(actual));
auto const after = read(env, f);
Number const tDelta = after.assetsTotal - before.assetsTotal;
Number const aDelta = after.assetsAvailable - before.assetsAvailable;
Number const requested = asset(amount).number();
BEAST_EXPECT(tDelta <= requested);
Number const gap = tDelta > aDelta ? tDelta - aDelta : aDelta - tDelta;
BEAST_EXPECT(gap <= oneUnit(asset, after.assetsTotal));
}
else
{
BEAST_EXPECTS(
actual == tecINVARIANT_FAILED,
"amount=" + std::to_string(amount) + " expected tecINVARIANT_FAILED, got " +
transToken(actual));
}
}
}
// Withdraw long-mantissa share counts from an A-1 vault. Pre-fix
// some counts trip the withdraw delta invariants; post-fix none does.
void
testWithdrawBoundaryInvariant(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
testcase(
std::string("A-1 withdraw boundary invariant") +
(fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
std::array<std::uint64_t, 6> const kShareCounts{
99'999u, 100'001u, 333'333u, 1'234'567u, 142'857'142u, 333'333'333u};
// Fill the vault with enough shares that every count below is
// available to the depositor.
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
if (!f.asset || !f.broker)
{
BEAST_EXPECT(f.asset && f.broker);
return;
}
auto const& asset = *f.asset;
Vault const v{env};
// Deposit a large amount so we can afford every withdrawal below.
env(v.deposit(
{.depositor = f.depositor,
.id = f.vaultKeylet.key,
.amount = asset(1'000'000).value()}),
Ter(std::ignore));
env.close();
for (auto const count : kShareCounts)
{
auto const before = read(env, f);
if (before.sharesTotal < count)
continue;
STAmount const shareAmount{MPTIssue{f.share}, Number{static_cast<std::int64_t>(count)}};
env(v.withdraw(
{.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = shareAmount}),
Ter(std::ignore));
env.close();
TER const actual = env.ter();
if (fixEnabled)
{
BEAST_EXPECTS(
actual != tecINVARIANT_FAILED,
"shares=" + std::to_string(count) + " unexpected invariant failure");
if (actual == tesSUCCESS)
{
auto const after = read(env, f);
Number const tDelta = before.assetsTotal - after.assetsTotal;
Number const pDelta = before.pseudo - after.pseudo;
Number const gap = tDelta > pDelta ? tDelta - pDelta : pDelta - tDelta;
// VaultTransactorPrecision_test tightens this to strict
// equality.
BEAST_EXPECT(gap <= oneUnit(asset, before.assetsTotal));
}
}
// Pre-fix behaviour is fixture-dependent: some share counts may
// succeed even without the amendment. The important property is
// that post-fix no legitimate withdrawal is rejected by the
// widened invariant.
}
}
// Clawback of small IOU amounts against a live-loan vault. Pre-fix
// some amounts trip the clawback delta invariants; post-fix none does.
// Also assert the owner force-burn path returns tecNO_PERMISSION
// under both amendment states (it never enters assetsToClawback).
void
testClawbackBoundaryInvariant(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
testcase(
std::string("A-1 clawback boundary invariant") +
(fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
std::array<int, 6> const kAmounts{1, 7, 99, 333, 993, 2000};
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false, /*allowClawback=*/true);
if (!f.asset || !f.broker)
{
BEAST_EXPECT(f.asset && f.broker);
return;
}
auto const& asset = *f.asset;
Vault const v{env};
// Give the depositor a stake so that the issuer has something to
// claw back.
env(v.deposit(
{.depositor = f.depositor,
.id = f.vaultKeylet.key,
.amount = asset(2'000).value()}),
Ter(std::ignore));
env.close();
for (auto const amount : kAmounts)
{
auto const before = read(env, f);
if (before.sharesTotal == 0)
continue;
env(v.clawback(
{.issuer = f.issuer,
.id = f.vaultKeylet.key,
.holder = f.depositor,
.amount = asset(amount).value()}),
Ter(std::ignore));
env.close();
TER const actual = env.ter();
if (fixEnabled)
{
BEAST_EXPECTS(
actual != tecINVARIANT_FAILED,
"amount=" + std::to_string(amount) + " unexpected invariant failure");
}
// Pre-fix behaviour is fixture-dependent: some clawback amounts
// may succeed even without the amendment. The important
// property is that post-fix no legitimate clawback is rejected
// by the widened invariant.
}
// Owner force-burn only succeeds against an EMPTY vault (see
// VaultClawback::preclaim). Our fixture keeps a live loan, so
// this must return tecNO_PERMISSION regardless of the amendment.
env(v.clawback({.issuer = f.lender, .id = f.vaultKeylet.key, .holder = f.depositor}),
Ter(tecNO_PERMISSION));
env.close();
}
// Deposit into an A-3 vault where the impaired-loan gap plus the
// interest earned from the sibling repayment lands L > (T - A) by
// sub-ULP. Pre-fix the loss invariant fires; post-fix it does not.
void
testLossInvariantA3(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
testcase(
std::string("A-3 loss invariant sweep") +
(fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
std::array<int, 3> const kAmounts{1, 7, 10'000'000};
for (auto const amount : kAmounts)
{
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/true);
if (!f.asset || !f.broker)
{
BEAST_EXPECT(f.asset && f.broker);
continue;
}
auto const& asset = *f.asset;
Vault const v{env};
env(v.deposit(
{.depositor = f.depositor,
.id = f.vaultKeylet.key,
.amount = asset(amount).value()}),
Ter(std::ignore));
env.close();
TER const actual = env.ter();
if (fixEnabled)
{
BEAST_EXPECTS(
actual == tesSUCCESS,
"amount=" + std::to_string(amount) + " expected tesSUCCESS, got " +
transToken(actual));
auto const after = read(env, f);
BEAST_EXPECT(
after.lossUnrealized <= (after.assetsTotal - after.assetsAvailable) +
oneUnit(asset, after.assetsTotal));
}
else
{
BEAST_EXPECTS(
actual == tecINVARIANT_FAILED,
"amount=" + std::to_string(amount) + " expected tecINVARIANT_FAILED, got " +
transToken(actual));
}
}
}
// Full 17-magnitude A-1 deposit sweep. Pre-fix {1, 7, 10'000'000}
// are the boundary amounts that fail; post-fix every amount succeeds.
void
testA1DepositMagnitudes(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
testcase(
std::string("A-1 deposit magnitude sweep") +
(fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
std::array<int, 17> const kAmounts{
1,
2,
5,
7,
10,
50,
100,
500,
1'000,
5'000,
10'000,
50'000,
100'000,
500'000,
1'000'000,
5'000'000,
10'000'000};
std::array<int, 3> const kPreFixFailures{1, 7, 10'000'000};
for (auto const amount : kAmounts)
{
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
if (!f.asset || !f.broker)
{
BEAST_EXPECT(f.asset && f.broker);
continue;
}
auto const& asset = *f.asset;
Vault const v{env};
env(v.deposit(
{.depositor = f.depositor,
.id = f.vaultKeylet.key,
.amount = asset(amount).value()}),
Ter(std::ignore));
env.close();
TER const actual = env.ter();
if (fixEnabled)
{
BEAST_EXPECTS(
actual == tesSUCCESS,
"amount=" + std::to_string(amount) + " expected tesSUCCESS, got " +
transToken(actual));
}
else
{
bool const shouldFail =
std::ranges::find(kPreFixFailures, amount) != kPreFixFailures.end();
if (shouldFail)
{
BEAST_EXPECTS(
actual == tecINVARIANT_FAILED,
"pre-fix amount=" + std::to_string(amount) +
" expected tecINVARIANT_FAILED, got " + transToken(actual));
}
// For other amounts pre-fix, we accept any outcome; the
// interesting property is only asserted for the known-failing
// ones.
}
}
}
// A-3 deposit sweep. Pre-fix {1, 7, 10'000, 10'000'000} fail; post-fix
// every amount succeeds. 99'999 (delta tolerance) and 10'000'000
// (loss tolerance) are the two boundary cases that motivate this PR.
void
testA3DepositMagnitudes(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
testcase(
std::string("A-3 deposit magnitude sweep") +
(fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
std::array<int, 9> const kAmounts{
1, 7, 100, 1'000, 10'000, 100'000, 1'000'000, 10'000'000, 99'999};
std::array<int, 4> const kPreFixFailures{1, 7, 10'000, 10'000'000};
for (auto const amount : kAmounts)
{
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/true);
if (!f.asset || !f.broker)
{
BEAST_EXPECT(f.asset && f.broker);
continue;
}
auto const& asset = *f.asset;
Vault const v{env};
env(v.deposit(
{.depositor = f.depositor,
.id = f.vaultKeylet.key,
.amount = asset(amount).value()}),
Ter(std::ignore));
env.close();
TER const actual = env.ter();
if (fixEnabled)
{
BEAST_EXPECTS(
actual == tesSUCCESS,
"amount=" + std::to_string(amount) + " expected tesSUCCESS, got " +
transToken(actual));
}
else
{
bool const shouldFail =
std::ranges::find(kPreFixFailures, amount) != kPreFixFailures.end();
if (shouldFail)
{
BEAST_EXPECTS(
actual == tecINVARIANT_FAILED,
"pre-fix amount=" + std::to_string(amount) +
" expected tecINVARIANT_FAILED, got " + transToken(actual));
}
}
}
}
public:
void
run() override
{
for (auto const& features : {all_ - fixCleanup3_4_0, all_})
{
testDepositBoundaryInvariant(features);
testWithdrawBoundaryInvariant(features);
testClawbackBoundaryInvariant(features);
testLossInvariantA3(features);
testA1DepositMagnitudes(features);
testA3DepositMagnitudes(features);
}
}
};
BEAST_DEFINE_TESTSUITE(VaultInvariantPrecision, app, xrpl);
} // namespace xrpl::test

View File

@@ -0,0 +1,242 @@
#pragma once
#include <test/app/lending/LoanTestBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/fee.h>
#include <test/jtx/flags.h>
#include <test/jtx/pay.h>
#include <test/jtx/sig.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/Units.h>
#include <cstdint>
#include <optional>
namespace xrpl::test {
// Shared fixture for VaultInvariantPrecision_test and
// VaultTransactorPrecision_test.
//
// Layout:
// - A-1 (impairAndPaySibling=false): 1000 USD vault + one ordinary loan.
// assetsTotal ~= 1000.353..., assetsAvailable == 993, lossUnrealized == 0.
// - A-3 (impairAndPaySibling=true): add a second loan of principal 11,
// impair the first loan, and pay off the second in full. This drives
// the vault to the lossUnrealized == (assetsTotal - assetsAvailable)
// boundary where the loss invariant used to spuriously fire.
class VaultPrecisionFixture : public LoanTestBase
{
protected:
static constexpr std::uint32_t kFixturePaymentInterval = 86400u * 30u;
static constexpr std::uint32_t kFixtureGracePeriod = 86400u * 30u;
static constexpr std::uint32_t kFixturePaymentTotal = 120u;
// 10% APR, expressed in tenth-bips (1000 = 10.00 %).
static constexpr std::uint32_t kFixtureInterestTenthBips = 1000u;
struct Fixture
{
// Every account is initialised with a placeholder name because
// jtx::Account has no default constructor; setupSingleLoanVault
// overwrites them.
jtx::Account issuer{"vp_issuer_placeholder"};
jtx::Account lender{"vp_lender_placeholder"};
jtx::Account borrower{"vp_borrower_placeholder"};
// Distinct account used to deposit into the vault. Keeps share
// ownership independent of the initial vault seeding.
jtx::Account depositor{"vp_depositor_placeholder"};
// Optional so callers can BEAST_EXPECT(f.asset && f.broker)
// after setup; both are populated in the happy path.
std::optional<jtx::PrettyAsset> asset;
std::optional<BrokerInfo> broker;
// Keylet has no default constructor. Fill with an obviously
// meaningless placeholder; setupSingleLoanVault overwrites the
// fields that matter.
Keylet vaultKeylet{ltACCOUNT_ROOT, uint256{}};
Keylet loan1Keylet{ltACCOUNT_ROOT, uint256{}};
// Only meaningful when impairAndPaySibling == true.
Keylet loan2Keylet{ltACCOUNT_ROOT, uint256{}};
jtx::Account vaultAccount{"vp_vault_pseudo_placeholder"};
MPTID share;
};
// Read-only snapshot of the vault + share issuance at a point in time.
// Uses Number for exact arithmetic (no re-quantization).
struct Numbers
{
Asset asset;
MPTIssue share;
Number assetsTotal{}; // sfAssetsTotal
Number assetsAvailable{}; // sfAssetsAvailable
Number lossUnrealized{}; // sfLossUnrealized
Number pseudo{}; // vault pseudo-account balance in the asset
Number sharesTotal{}; // sfOutstandingAmount on the share MPT
};
static Numbers
read(jtx::Env const& env, Fixture const& f)
{
Numbers n{.asset = f.asset ? f.asset->raw() : Asset{}, .share = MPTIssue{f.share}};
if (auto const vaultSle = env.le(f.vaultKeylet))
{
n.assetsTotal = vaultSle->at(sfAssetsTotal);
n.assetsAvailable = vaultSle->at(sfAssetsAvailable);
n.lossUnrealized = vaultSle->at(sfLossUnrealized);
}
if (auto const issuanceSle = env.le(keylet::mptokenIssuance(f.share)))
{
n.sharesTotal = issuanceSle->at(sfOutstandingAmount);
}
if (f.asset)
n.pseudo = env.balance(f.vaultAccount, *f.asset).number();
return n;
}
// One unit at the STAmount scale of `assetsTotalAfter`. Used as the
// tolerance in one-unit-band assertions.
static Number
oneUnit(Asset const& asset, Number const& assetsTotalAfter)
{
return Number{1, scale(assetsTotalAfter, asset)};
}
// Build the shared vault + loan(s) layout. The caller constructs
// `env` with whatever FeatureBitset they want to exercise; this helper
// just uses it. If `allowClawback` is true, the issuer's
// asfAllowTrustLineClawback flag is set BEFORE any trust line is
// established for that issuer. A separate env.close() runs so the
// flag lands in the ledger before the trust lines are set up.
static Fixture
setupSingleLoanVault(jtx::Env& env, bool impairAndPaySibling, bool allowClawback = false)
{
using namespace jtx;
using namespace jtx::loan;
using namespace jtx::loan_broker;
Fixture f;
f.issuer = Account{"vp_issuer"};
f.lender = Account{"vp_lender"};
f.borrower = Account{"vp_borrower"};
f.depositor = Account{"vp_depositor"};
env.fund(XRP(1'000'000), f.issuer, f.lender, f.borrower, f.depositor);
env.close();
// Must be set BEFORE any trust line to `issuer` is created.
if (allowClawback)
{
env(fset(f.issuer, asfAllowTrustLineClawback));
env.close();
}
PrettyAsset const asset = f.issuer["USD"];
f.asset = asset;
env.trust(asset(1'000'000'000), f.lender);
env.trust(asset(1'000'000'000), f.borrower);
env.trust(asset(1'000'000'000), f.depositor);
env(pay(f.issuer, f.lender, asset(100'000'000)));
env(pay(f.issuer, f.borrower, asset(100'000'000)));
env(pay(f.issuer, f.depositor, asset(100'000'000)));
env.close();
BrokerParameters const brokerParams{
.vaultDeposit = 1'000,
.debtMax = 0,
.coverRateMin = percentageToTenthBips(1),
.coverDeposit = 10'000,
.managementFeeRate = TenthBips16{100},
.coverRateLiquidation = xrpl::lending::kMaxCoverRate};
// Build the vault + broker manually (rather than calling
// createVaultAndBroker) so we can seed only the lender/depositor
// trust lines we set up above, and skip the LoanTestBase auto
// funding that assumes an XRP asset.
Vault const vault{env};
auto [createTx, vaultKeylet] = vault.create({.owner = f.lender, .asset = asset});
env(createTx);
env.close();
f.vaultKeylet = vaultKeylet;
env(vault.deposit(
{.depositor = f.lender,
.id = vaultKeylet.key,
.amount = asset(brokerParams.vaultDeposit)}));
env.close();
auto const brokerKeylet =
keylet::loanBroker(f.lender.id(), SeqProxy::rawSequence(env.seq(f.lender)));
env(set(f.lender, vaultKeylet.key, brokerParams.flags),
kManagementFeeRate(brokerParams.managementFeeRate),
kDebtMaximum(asset(brokerParams.debtMax).value()),
kCoverRateMinimum(brokerParams.coverRateMin),
kCoverRateLiquidation(TenthBips32(brokerParams.coverRateLiquidation)));
env(coverDeposit(f.lender, brokerKeylet.key, asset(brokerParams.coverDeposit).value()));
env.close();
f.broker = BrokerInfo{asset, brokerKeylet, vaultKeylet, brokerParams};
auto const vaultSle = env.le(vaultKeylet);
f.vaultAccount = Account{"vp_vault_pseudo", vaultSle->at(sfAccount)};
f.share = vaultSle->at(sfShareMPTID);
Fee const bigFee{env.current()->fees().base * 200};
auto const setLoan = [&](Number const& principal) -> Keylet {
auto const brokerSle = env.le(brokerKeylet);
auto const loanKeylet = keylet::loan(
brokerKeylet.key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
env(loan::set(f.borrower, brokerKeylet.key, asset(principal).number()),
Sig(sfCounterpartySignature, f.lender),
jtx::loan::kInterestRate(TenthBips32{kFixtureInterestTenthBips}),
jtx::loan::kPaymentTotal(kFixturePaymentTotal),
jtx::loan::kPaymentInterval(kFixturePaymentInterval),
jtx::loan::kGracePeriod(kFixtureGracePeriod),
bigFee);
env.close();
return loanKeylet;
};
// Loan 1: principal 7, the one ordinary loan in both fixtures.
// With vault deposit 1000, this leaves A ≈ 993 (see plan).
f.loan1Keylet = setLoan(Number{7});
if (!impairAndPaySibling)
return f;
// Loan 2: sibling loan of principal 11.
f.loan2Keylet = setLoan(Number{11});
// Impair loan 1 → drives sfLossUnrealized to loan 1's value.
env(jtx::loan::manage(f.lender, f.loan1Keylet.key, tfLoanImpair), bigFee);
env.close();
// Pay off loan 2 in full so its total value flows into the vault
// and pushes T-A upward, meeting the residual loss. Generous
// upper bound; the transactor takes only what is due.
auto const payoff = asset(Number{50}).value();
env(pay(f.borrower, f.loan2Keylet.key, payoff, tfLoanFullPayment), bigFee);
env.close();
return f;
}
};
} // namespace xrpl::test

View File

@@ -546,13 +546,13 @@ private:
}
{
testcase("Scale withdraw with rounding shares up");
// assetsToSharesWithdraw:
// shares = sharesTotal * (assets / assetsTotal)
// shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5
// sharesToAssetsWithdraw:
// assets = assetsTotal * (shares / sharesTotal)
// assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8
testcase("Scale withdraw with rounding shares up (truncated post-fixCleanup3_4_0)");
// Pre-fixCleanup3_4_0:
// shares = round(875 * 3.75 / 87.5) = 38
// assets = 87.5 * 38 / 875 = 3.8 > 3.75 requested.
// Post-fixCleanup3_4_0:
// shares = floor(37.5) = 37
// assets = 87.5 * 37 / 875 = 3.7 <= 3.75 requested.
auto const start = env.balance(d.depositor, d.assets).number();
auto tx = d.vault.withdraw(
@@ -561,26 +561,23 @@ private:
.amount = STAmount(d.asset, Number(375, -2))});
env(tx);
env.close();
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38));
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 37));
BEAST_EXPECT(
env.balance(d.depositor, d.assets) ==
STAmount(d.asset, start + Number(38, -1)));
STAmount(d.asset, start + Number(37, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.assets) ==
STAmount(d.asset, Number(875 - 38, -1)));
STAmount(d.asset, Number(875 - 37, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.shares) ==
STAmount(d.share, -Number(875 - 38, 0)));
STAmount(d.share, -Number(875 - 37, 0)));
}
{
testcase("Scale withdraw with rounding shares down");
// assetsToSharesWithdraw:
// shares = sharesTotal * (assets / assetsTotal)
// shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2
// sharesToAssetsWithdraw:
// assets = assetsTotal * (shares / sharesTotal)
// assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7
// Chained state: 838 shares outstanding, 83.8 assets.
// shares = floor(838 * 3.72 / 83.8) = floor(37.199...) = 37
// assets = 83.8 * 37 / 838 = 3.7 <= 3.72 requested.
auto const start = env.balance(d.depositor, d.assets).number();
auto tx = d.vault.withdraw(
@@ -589,37 +586,37 @@ private:
.amount = STAmount(d.asset, Number(372, -2))});
env(tx);
env.close();
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37));
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(838 - 37));
BEAST_EXPECT(
env.balance(d.depositor, d.assets) ==
STAmount(d.asset, start + Number(37, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.assets) ==
STAmount(d.asset, Number(837 - 37, -1)));
STAmount(d.asset, Number(838 - 37, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.shares) ==
STAmount(d.share, -Number(837 - 37, 0)));
STAmount(d.share, -Number(838 - 37, 0)));
}
{
testcase("Scale withdraw tiny amount");
testcase("Scale withdraw tiny amount rejected post-fixCleanup3_4_0");
// Chained state: 801 shares outstanding, 80.1 assets.
// shares = floor(801 * 0.09 / 80.1) = floor(0.9) = 0
// Zero shares => tecPRECISION_LOSS. State is unchanged.
auto const start = env.balance(d.depositor, d.assets).number();
auto tx = d.vault.withdraw(
{.depositor = d.depositor,
.id = d.keylet.key,
.amount = STAmount(d.asset, Number(9, -2))});
env(tx);
env(tx, Ter{tecPRECISION_LOSS});
env.close();
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(801));
BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
BEAST_EXPECT(
env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(1, -1)));
env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(801, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.assets) ==
STAmount(d.asset, Number(800 - 1, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.shares) ==
STAmount(d.share, -Number(800 - 1, 0)));
env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(801, 0)));
}
{
@@ -738,13 +735,13 @@ private:
}
{
testcase("Scale clawback with rounding shares up");
// assetsToSharesWithdraw:
// shares = sharesTotal * (assets / assetsTotal)
// shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5
// sharesToAssetsWithdraw:
// assets = assetsTotal * (shares / sharesTotal)
// assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8
testcase("Scale clawback with rounding shares up (truncated post-fixCleanup3_4_0)");
// Pre-fixCleanup3_4_0:
// shares = round(875 * 3.75 / 87.5) = 38
// assets = 87.5 * 38 / 875 = 3.8 > 3.75 requested.
// Post-fixCleanup3_4_0:
// shares = floor(37.5) = 37
// assets = 87.5 * 37 / 875 = 3.7 <= 3.75 requested.
auto const start = env.balance(d.depositor, d.assets).number();
auto tx = d.vault.clawback(
@@ -754,24 +751,21 @@ private:
.amount = STAmount(d.asset, Number(375, -2))});
env(tx);
env.close();
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38));
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 37));
BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.assets) ==
STAmount(d.asset, Number(875 - 38, -1)));
STAmount(d.asset, Number(875 - 37, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.shares) ==
STAmount(d.share, -Number(875 - 38, 0)));
STAmount(d.share, -Number(875 - 37, 0)));
}
{
testcase("Scale clawback with rounding shares down");
// assetsToSharesWithdraw:
// shares = sharesTotal * (assets / assetsTotal)
// shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2
// sharesToAssetsWithdraw:
// assets = assetsTotal * (shares / sharesTotal)
// assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7
// Chained state: 838 shares outstanding, 83.8 assets.
// shares = floor(838 * 3.72 / 83.8) = floor(37.199...) = 37
// assets = 83.8 * 37 / 838 = 3.7 <= 3.72 requested.
auto const start = env.balance(d.depositor, d.assets).number();
auto tx = d.vault.clawback(
@@ -781,18 +775,21 @@ private:
.amount = STAmount(d.asset, Number(372, -2))});
env(tx);
env.close();
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37));
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(838 - 37));
BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.assets) ==
STAmount(d.asset, Number(837 - 37, -1)));
STAmount(d.asset, Number(838 - 37, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.shares) ==
STAmount(d.share, -Number(837 - 37, 0)));
STAmount(d.share, -Number(838 - 37, 0)));
}
{
testcase("Scale clawback tiny amount");
testcase("Scale clawback tiny amount rejected post-fixCleanup3_4_0");
// Chained state: 801 shares outstanding, 80.1 assets.
// shares = floor(801 * 0.09 / 80.1) = floor(0.9) = 0
// Zero shares => tecPRECISION_LOSS. State is unchanged.
auto const start = env.balance(d.depositor, d.assets).number();
auto tx = d.vault.clawback(
@@ -800,16 +797,14 @@ private:
.id = d.keylet.key,
.holder = d.depositor,
.amount = STAmount(d.asset, Number(9, -2))});
env(tx);
env(tx, Ter{tecPRECISION_LOSS});
env.close();
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(801));
BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.assets) ==
STAmount(d.asset, Number(800 - 1, -1)));
env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(801, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.shares) ==
STAmount(d.share, -Number(800 - 1, 0)));
env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(801, 0)));
}
{

5
src/xrpld/rpc/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
# AGENTS.md — rpc
See the repo-level [AGENTS.md](../../../AGENTS.md) for general build/test/style guidance.
Any change to a public RPC method's behavior (new/changed/removed fields, parameters, or error conditions) needs a corresponding entry in [`API-CHANGELOG.md`](../../../API-CHANGELOG.md), under the `## Unreleased` section (`### Additions`, `### Deprecations`, etc. as appropriate).

1
src/xrpld/rpc/CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md