mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-28 17:50:55 +00:00
Compare commits
14 Commits
3.4.0-b1
...
mvadari/ai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09d7e75686 | ||
|
|
502bc13a9a | ||
|
|
65e7a6c14c | ||
|
|
fee4bfc22e | ||
|
|
5e3d20b3ed | ||
|
|
9e2aaf6f60 | ||
|
|
ec042fefee | ||
|
|
45e4b8899d | ||
|
|
c5dc408596 | ||
|
|
473fe44a85 | ||
|
|
df31ff54e6 | ||
|
|
cf4f627b33 | ||
|
|
3e2e1d58d6 | ||
|
|
79dcb83d5e |
3
.envrc
3
.envrc
@@ -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
|
||||
|
||||
8
.github/scripts/strategy-matrix/generate.py
vendored
8
.github/scripts/strategy-matrix/generate.py
vendored
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
6
.github/scripts/strategy-matrix/linux.json
vendored
6
.github/scripts/strategy-matrix/linux.json
vendored
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
8
.github/workflows/build-packaging-images.yml
vendored
8
.github/workflows/build-packaging-images.yml
vendored
@@ -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 }}
|
||||
|
||||
24
.github/workflows/reusable-package.yml
vendored
24
.github/workflows/reusable-package.yml
vendored
@@ -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
9
.gitignore
vendored
@@ -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__
|
||||
|
||||
@@ -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
42
AGENTS.md
Normal 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).
|
||||
@@ -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.)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -151,7 +151,7 @@ namespace std {
|
||||
template <>
|
||||
struct hash<xrpl::MPTID> : xrpl::MPTID::hasher
|
||||
{
|
||||
explicit hash() = default;
|
||||
hash() = default;
|
||||
};
|
||||
|
||||
} // namespace std
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
263
package/build_pkg.py
Executable 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()
|
||||
@@ -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}"
|
||||
@@ -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
153
package/publish_pkg.py
Executable 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()
|
||||
@@ -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
128
package/sign_rpm.py
Executable 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()
|
||||
@@ -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
|
||||
@@ -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
5
src/libxrpl/tx/AGENTS.md
Normal 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
1
src/libxrpl/tx/CLAUDE.md
Symbolic link
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -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";
|
||||
|
||||
@@ -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)};
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
458
src/test/app/vault/VaultInvariantPrecision_test.cpp
Normal file
458
src/test/app/vault/VaultInvariantPrecision_test.cpp
Normal 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
|
||||
242
src/test/app/vault/VaultPrecisionFixture.h
Normal file
242
src/test/app/vault/VaultPrecisionFixture.h
Normal 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
|
||||
@@ -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
5
src/xrpld/rpc/AGENTS.md
Normal 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
1
src/xrpld/rpc/CLAUDE.md
Symbolic link
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
Reference in New Issue
Block a user