Compare commits

..

69 Commits

Author SHA1 Message Date
Sergey Kuznetsov
ee7ac58ffb Merge develop 2026-08-20 15:09:15 +01:00
Jingchen
959a186a0f build: Suppress MSVC linker warning LNK4099 (#8049) 2026-08-20 13:19:07 +00:00
dependabot[bot]
3b1c9e4320 chore: [DEPENDABOT] Bump cxx from 1.0.198 to 1.0.199 in /crates in the rust-dependencies group across 1 directory (#8050)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 13:12:14 +00:00
Ayaz Salikhov
a3a2c85c41 build: Use debian any distribution & signed (hosted) rpm repo (#8053) 2026-08-20 13:05:18 +00:00
Ayaz Salikhov
b1ac891210 ci: Do not cache cargo binaries (#8062) 2026-08-20 11:47:27 +00:00
Sergey Kuznetsov
579c44bb22 Merge develop 2026-08-19 17:49:41 +01:00
Peng Wang
226ec160bb Merge remote-tracking branch 'origin/develop' into ripple/wasmi 2026-08-09 14:34:11 -04:00
Sergey Kuznetsov
4d685d4bad Merge branch 'develop' into ripple/wasmi 2026-08-04 15:24:06 +01:00
Mayukha Vadari
9a8ec56d42 Merge branch 'develop' into ripple/wasmi 2026-07-23 11:29:49 -04:00
Sergey Kuznetsov
8950530b04 Merge branch 'develop' into ripple/wasmi 2026-07-21 12:39:31 +01:00
Mayukha Vadari
f6472347e9 Merge branch 'develop' into ripple/wasmi 2026-07-20 17:33:13 -04:00
Sergey Kuznetsov
9e427ae3ba Merge develop 2026-07-08 14:24:21 +01:00
Mayukha Vadari
ef7aeca6bf Merge branch 'develop' into ripple/wasmi 2026-05-18 18:25:09 -04:00
Mayukha Vadari
eec1d29b92 Merge branch 'develop' into ripple/wasmi 2026-05-15 11:36:56 -04:00
Mayukha Vadari
ecee732187 Merge branch 'develop' into ripple/wasmi 2026-04-22 17:22:28 -04:00
Mayukha Vadari
1259c1d5ca Merge branch 'develop' of https://github.com/XRPLF/rippled into ripple/wasmi 2026-04-08 13:48:41 -04:00
Mayukha Vadari
9917f96166 Merge remote-tracking branch 'upstream/develop' into ripple/wasmi 2026-04-07 16:02:56 -04:00
Pratik Mankawde
2cc9439fde fix: Handle WSClient write failure when server closes WebSocket (#6671)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 16:01:26 -04:00
Ayaz Salikhov
52af9582e2 ci: Change conditions for uploading artifacts in public/private/org repos (#6734) 2026-04-07 16:01:26 -04:00
Bart
46e88dc732 refactor: Rename non-functional uses of ripple(d) to xrpl(d) (#6676)
Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com>
2026-04-07 16:01:26 -04:00
Mayukha Vadari
bc24f2e211 refactor: Move more helper files into libxrpl/ledger/helpers (#6731)
Co-authored-by: xrplf-ai-reviewer[bot] <266832837+xrplf-ai-reviewer[bot]@users.noreply.github.com>
2026-04-07 16:01:26 -04:00
Mayukha Vadari
7a7c993b15 fix: Minor RPC fixes (#6730) 2026-04-07 16:01:26 -04:00
Zhiyuan Wang
9733ca8f91 fix: Prevent deletion of MPTokens with active escrow (#6635)
Co-authored-by: Bart <bthomee@users.noreply.github.com>
2026-04-07 16:01:26 -04:00
Vito Tumas
18d5e3e226 fix: Clamp VaultClawback to assetsAvailable for zero-amount clawback (#6646) 2026-04-07 16:01:25 -04:00
Vito Tumas
b30b4e1d65 fix: Add assorted Lending Protocol fixes (#6678)
Co-authored-by: Shawn Xie <35279399+shawnxie999@users.noreply.github.com>
2026-04-07 16:01:25 -04:00
Mayukha Vadari
d435893602 fix: Change variable signedness and correctly handle std::optional (#6657) 2026-04-07 16:01:25 -04:00
Olek
00b0cf50f6 Update wasmi to 1.0.9 (#6727) 2026-04-07 15:58:29 -04:00
Mayukha Vadari
1338062be7 Merge branch 'develop' of https://github.com/XRPLF/rippled into ripple/wasmi 2026-04-03 09:56:55 -04:00
Mayukha Vadari
1d4a3c00b8 Merge branch 'develop' of https://github.com/XRPLF/rippled into ripple/wasmi 2026-04-02 17:53:53 -04:00
Mayukha Vadari
2930ef217f Merge remote-tracking branch 'upstream/develop' into ripple/wasmi 2026-03-24 10:20:38 -07:00
Mayukha Vadari
913e4b919e Merge remote-tracking branch 'upstream/develop' into ripple/wasmi 2026-03-24 08:41:12 -07:00
Mayukha Vadari
bce5d91e45 Merge branch 'develop' into ripple/wasmi 2026-03-12 14:37:01 -04:00
Mayukha Vadari
654338fa66 Merge branch 'develop' into ripple/wasmi 2026-03-06 16:27:50 -04:00
Mayukha Vadari
3a825a41e1 Merge branch 'develop' into ripple/wasmi 2026-03-05 13:48:16 -04:00
Mayukha Vadari
61bcb7621f Merge branch 'develop' into ripple/wasmi 2026-03-03 11:18:26 -04:00
Mayukha Vadari
a3f71b1774 Merge branch 'develop' into ripple/wasmi 2026-03-02 17:06:17 -05:00
Mayukha Vadari
b08bcf5d21 Merge branch 'develop' into ripple/wasmi 2026-02-27 16:41:44 -05:00
Mayukha Vadari
77dfd56ace Merge branch 'develop' into ripple/wasmi 2026-02-27 13:49:25 -05:00
Mayukha Vadari
9c3c0280b1 Merge branch 'develop' into ripple/wasmi 2026-02-18 12:35:51 -05:00
Mayukha Vadari
bd16f7989d Merge branch 'develop' into ripple/wasmi 2026-02-10 17:26:33 -05:00
Mayukha Vadari
2ec4a1114e Merge branch 'develop' into ripple/wasmi 2026-02-04 18:13:00 -05:00
Mayukha Vadari
b2627039f6 Merge branch 'develop' into ripple/wasmi 2026-02-03 14:51:59 -05:00
Mayukha Vadari
e85e7b1b1a Merge branch 'develop' into ripple/wasmi 2026-01-29 13:53:55 -05:00
Mayukha Vadari
72fffb6e51 Merge branch 'develop' into ripple/wasmi 2026-01-28 15:56:18 -05:00
Mayukha Vadari
f7ee580f01 Merge commit '5f638f55536def0d88b970d1018a465a238e55f4' into ripple/wasmi 2026-01-28 15:56:11 -05:00
Mayukha Vadari
122d405750 Merge commit '92046785d1fea5f9efe5a770d636792ea6cab78b' into ripple/wasmi 2026-01-28 15:56:04 -05:00
Mayukha Vadari
d7ed6d6512 Merge branch 'develop' into ripple/wasmi 2026-01-27 13:26:39 -05:00
Mayukha Vadari
8bc6f9cd70 Merge branch 'develop' into ripple/wasmi 2026-01-23 13:13:11 -05:00
Mayukha Vadari
ed5139d4e3 Merge branch 'develop' into ripple/wasmi 2026-01-21 12:57:29 -05:00
Mayukha Vadari
7a9d245950 Merge branch 'develop' into ripple/wasmi 2026-01-14 13:01:35 -05:00
Olek
d83ec96848 Switch to wasmi v1.0.6 (#6204) 2026-01-12 13:36:02 -05:00
Mayukha Vadari
419d53ec4c Merge branch 'develop' into ripple/wasmi 2026-01-12 13:10:58 -05:00
Mayukha Vadari
d4d70d5675 Merge branch 'develop' into ripple/wasmi 2026-01-12 12:27:48 -05:00
Mayukha Vadari
bbc28b3b1c Merge branch 'develop' into ripple/wasmi 2026-01-08 11:42:28 -05:00
Mayukha Vadari
5aab274b7a Merge branch 'develop' into ripple/wasmi 2026-01-07 16:52:10 -05:00
Mayukha Vadari
2c30e41191 use the develop hashes 2026-01-07 16:50:45 -05:00
Mayukha Vadari
8ea5106b0b Merge branch 'develop' into ripple/wasmi 2026-01-07 14:34:49 -05:00
Mayukha Vadari
1977df9c2e Merge remote-tracking branch 'upstream/develop' into ripple/wasmi 2026-01-05 18:43:49 -05:00
Mayukha Vadari
6c95548df5 Merge remote-tracking branch 'upstream/develop' into ripple/wasmi 2025-12-22 15:51:19 -08:00
Mayukha Vadari
90e0bbd0fc Merge branch 'develop' into ripple/wasmi 2025-12-08 14:28:41 -05:00
Olek
b57df290de Use conan repo for wasmi lib (#6109)
* Use conan repo for wasmi lib
* Generate lockfile
2025-12-08 13:02:01 -05:00
Mayukha Vadari
8a403f1241 Merge branch 'develop' into ripple/wasmi 2025-12-05 14:32:48 -05:00
Mayukha Vadari
6d2640871d Merge branch 'develop' into ripple/wasmi 2025-12-02 18:40:54 -05:00
Olek
500bb68831 Fix win build (#6076) 2025-11-24 16:56:23 -05:00
Mayukha Vadari
16087c9680 fix merge issue 2025-11-25 02:57:47 +05:30
Mayukha Vadari
25c3060fef remove conan.lock (temporary) 2025-11-25 02:40:57 +05:30
Mayukha Vadari
ce9f0b38a4 Merge branch 'develop' into ripple/wasmi 2025-11-25 02:33:47 +05:30
Mayukha Vadari
35f7cbf772 update 2025-11-25 02:31:51 +05:30
Mayukha Vadari
0db564d261 WASMI data 2025-11-04 15:57:07 -05:00
30 changed files with 161 additions and 1509 deletions

38
.github/actions/cargo-cache/action.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Use cargo artifacts cache
description: >
Cache the cargo build artifacts with rust-cache. Never caches ~/.cargo/bin:
when saving the cache, rust-cache deletes all binaries that were already
present there, which on persistent self-hosted runners wipes the tools
installed by prepare-runner. Harmless on ephemeral runners, but kept
consistent everywhere.
inputs:
workspaces:
description: "Workspaces to cache, as 'workspace -> target' lines."
required: false
default: crates
key:
description: "Additional part of the cache key."
required: false
default: ""
cache-directories:
description: "Additional non-workspace directories to cache."
required: false
default: ""
save-if:
description: "Condition for saving the cache after the job."
required: false
default: "true"
runs:
using: composite
steps:
- name: Use cargo artifacts cache
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
cache-bin: "false"
cache-directories: ${{ inputs.cache-directories }}
key: ${{ inputs.key }}
save-if: ${{ inputs.save-if }}
workspaces: ${{ inputs.workspaces }}

View File

@@ -4,6 +4,7 @@ updates:
directories:
- /
- .github/actions/build-deps/
- .github/actions/cargo-cache/
- .github/actions/release-info/
- .github/actions/set-compiler-env/
- .github/actions/setup-conan/

View File

@@ -58,7 +58,7 @@ jobs:
base_image: debian:bookworm
- name: rhel
base_image: registry.access.redhat.com/ubi9/ubi:latest
uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@9e7e4e80af9e684c116b38369add8eea64451f32
uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a
with:
image_name: xrpld/nix-${{ matrix.distro.name }}
dockerfile: nix/docker/Dockerfile

View File

@@ -39,7 +39,7 @@ jobs:
# AlmaLinux rather than UBI9, which does not ship rpm-sign.
- name: rhel
base_image: almalinux:9
uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@9e7e4e80af9e684c116b38369add8eea64451f32
uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a
with:
image_name: xrpld/packaging-${{ matrix.distro.name }}
dockerfile: package/Dockerfile

View File

@@ -30,7 +30,7 @@ jobs:
permissions:
contents: read
packages: write
uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@9e7e4e80af9e684c116b38369add8eea64451f32
uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a
with:
image_name: xrpld/pre-commit
dockerfile: bin/pre-commit/Dockerfile

View File

@@ -79,7 +79,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Prepare runner
uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c
uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13
with:
enable_ccache: false

View File

@@ -14,7 +14,7 @@ on:
jobs:
# Call the workflow in the XRPLF/actions repo that runs the pre-commit hooks.
run-hooks:
uses: XRPLF/actions/.github/workflows/pre-commit.yml@3ba08d6ddf114092891d48491fc2e26c3ba15552
uses: XRPLF/actions/.github/workflows/pre-commit.yml@f1952595d212e86169935135efc66294b4574131
with:
runs_on: ubuntu-latest
container: '{ "image": "ghcr.io/xrplf/xrpld/pre-commit:sha-f56b79f" }'

View File

@@ -47,7 +47,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Prepare runner
uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c
uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13
with:
enable_ccache: false

View File

@@ -129,7 +129,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Prepare runner
uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c
uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13
with:
enable_ccache: ${{ inputs.ccache_enabled }}
@@ -163,7 +163,7 @@ jobs:
compiler: ${{ inputs.compiler }}
- name: Use cargo artifacts cache
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
uses: ./.github/actions/cargo-cache
with:
cache-directories: ${{ env.BUILD_DIR }}/corrosion
key: ${{ inputs.config_name }}

View File

@@ -27,7 +27,7 @@ jobs:
determine-files:
permissions:
contents: read
uses: XRPLF/actions/.github/workflows/determine-tidy-files.yml@d041ac9f1fa9f07a4ba335eb4c1c82233fb3fef6
uses: XRPLF/actions/.github/workflows/determine-tidy-files.yml@70145243b905dc3e040a61d39c00e178cfb96f71
run-clang-tidy:
name: Run clang tidy
@@ -43,7 +43,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Prepare runner
uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c
uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13
with:
enable_ccache: false
@@ -60,7 +60,7 @@ jobs:
compiler: ${{ env.COMPILER }}
- name: Use cargo artifacts cache
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
uses: ./.github/actions/cargo-cache
with:
cache-directories: ${{ env.BUILD_DIR }}/corrosion
save-if: ${{ github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release') }}

View File

@@ -33,9 +33,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Use cargo artifacts cache
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: crates
uses: ./.github/actions/cargo-cache
- name: Run clippy
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
@@ -48,9 +46,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Use cargo artifacts cache
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: crates
uses: ./.github/actions/cargo-cache
- name: Generate coverage report
run: cargo llvm-cov nextest --workspace --all-features --locked --no-tests=warn --lcov --output-path lcov.info
@@ -76,9 +72,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Use cargo artifacts cache
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: crates
uses: ./.github/actions/cargo-cache
- name: Build documentation
env:

View File

@@ -68,7 +68,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Prepare runner
uses: XRPLF/actions/prepare-runner@c00c22ada3bd6bcda48fcb0d62fbbab49fec8a0f
uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13
with:
enable_ccache: false

View File

@@ -114,6 +114,7 @@ find_package(OpenSSL REQUIRED)
find_package(secp256k1 REQUIRED)
find_package(SOCI REQUIRED)
find_package(SQLite3 REQUIRED)
find_package(wasmi REQUIRED)
find_package(xxHash REQUIRED)
target_link_libraries(

View File

@@ -120,7 +120,10 @@ if(MSVC)
_SILENCE_ALL_CXX17_DEPRECATION_WARNINGS
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<CONFIG:Debug>>:_CRTDBG_MAP_ALLOC>
)
target_link_libraries(common INTERFACE -errorreport:none -machine:X64)
target_link_libraries(
common
INTERFACE -errorreport:none -machine:X64 -ignore:4099
)
else()
target_compile_options(
common

View File

@@ -69,6 +69,7 @@ target_link_libraries(
Xrpl::opts
Xrpl::syslibs
secp256k1::secp256k1
wasmi::wasmi
xrpl.libpb
xxHash::xxhash
$<$<BOOL:${voidstar}>:antithesis-sdk-cpp>

View File

@@ -3,6 +3,7 @@
"requires": [
"zlib/1.3.2#1cb806da49011867778ffb6ac7190fcb%1782392402.122708",
"xxhash/0.8.3#681d36a0a6111fc56e5e45ea182c19cc%1782392402.420688",
"wasmi/1.0.9#1fecdab9b90c96698eb35ea99ca4f5cb%1782307153.343419",
"sqlite3/3.53.0#324ada52333108388a9a6108bfa96734%1782392403.185447",
"soci/4.0.3#e726491a03468795453f7c83fc924a96%1782392402.679521",
"snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1782307151.633168",

View File

@@ -36,6 +36,7 @@ class Xrpl(ConanFile):
"nudb/2.0.9",
"openssl/3.6.3",
"soci/4.0.3",
"wasmi/1.0.9",
"zlib/1.3.2",
]
@@ -224,6 +225,7 @@ class Xrpl(ConanFile):
"soci::soci",
"secp256k1::secp256k1",
"sqlite3::sqlite",
"wasmi::wasmi",
"xxhash::xxhash",
"zlib::zlib",
]

20
crates/Cargo.lock generated
View File

@@ -57,9 +57,9 @@ dependencies = [
[[package]]
name = "cxx"
version = "1.0.198"
version = "1.0.199"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fe442a792c7c736eea18b32a7f8a3b63cf8aafabda6760042dc2fdeda456291"
checksum = "824894a4a85dca76d4c95c2b9098c036f5a29f627b30c12780774f6654e60974"
dependencies = [
"cc",
"cxx-build",
@@ -72,9 +72,9 @@ dependencies = [
[[package]]
name = "cxx-build"
version = "1.0.198"
version = "1.0.199"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3184a94384c663718698311a78a51ac00c484c10b4eeac06fb0a068c5f64fa2"
checksum = "f1ae0b651ea5b0000b19513aef5a03f194d7e3486f2d9258b658da8677fe9036"
dependencies = [
"cc",
"codespan-reporting",
@@ -87,9 +87,9 @@ dependencies = [
[[package]]
name = "cxxbridge-cmd"
version = "1.0.198"
version = "1.0.199"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0148d8fd1199329ddf1d157a5e134e51ceff37c6a7ddd38615c399d81cb05d8d"
checksum = "fb05f91d3fb8435d9bab6ac5ce6ac1868be774325fb7fb2a91be39393b21388e"
dependencies = [
"clap",
"codespan-reporting",
@@ -101,15 +101,15 @@ dependencies = [
[[package]]
name = "cxxbridge-flags"
version = "1.0.198"
version = "1.0.199"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52850339faed2eaadd24e286dc1d8268cc6f8a7bd9524d713adc9099566b4c89"
checksum = "bf293202e0e3e98495785745389e8d0755b217e66f19194a5c695c25e03282ef"
[[package]]
name = "cxxbridge-macro"
version = "1.0.198"
version = "1.0.199"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c77c856545d886c9bd5215409ebb63b925e262135248b50c79e5a5f194ee47c"
checksum = "ca001d746947c7249ed9d332a10f7a59daedbafeb0ec68c5c18a7db7a93f6ccc"
dependencies = [
"indexmap",
"proc-macro2",

View File

@@ -65,7 +65,7 @@ wherever it appears in the repository configuration.
4. Add the repository, using the channel you picked in [Release channels](#release-channels):
```bash
echo "deb [signed-by=/etc/apt/keyrings/xrplf.asc] https://packages.xrplf.org/repository/deb-stable focal main" | \
echo "deb [signed-by=/etc/apt/keyrings/xrplf.asc] https://packages.xrplf.org/repository/deb-stable any main" | \
sudo tee /etc/apt/sources.list.d/xrplf.list
```
@@ -98,13 +98,13 @@ wherever it appears in the repository configuration.
enabled=1
baseurl=https://packages.xrplf.org/repository/rpm-stable/
gpgcheck=1
repo_gpgcheck=0
repo_gpgcheck=1
gpgkey=https://packages.xrplf.org/xrplf.asc
REPOFILE
```
`gpgcheck=1` verifies each package against the key above.
`repo_gpgcheck` is off because the repository metadata is generated by the server and is not signed.
`repo_gpgcheck=1` verifies the repository metadata, which the server signs with the same key.
3. Install the `xrpld` package:

View File

@@ -43,32 +43,6 @@ assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co
[[nodiscard]] std::optional<STAmount>
sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares);
/**
* Returns the effective change to sfAssetsTotal after canonicalizing
* `sfAssetsTotal + delta` under `mode`, as a non-negative magnitude.
* `delta` is positive when crediting the vault and negative when debiting
* it; only its sign is used to select the rounding direction. This is not
* the same as simply rounding `delta`'s magnitude to sfAssetsTotal's
* scale: both the before and after totals are canonicalized under `mode`
* before subtracting, so the result also accounts for sfAssetsTotal
* itself sitting mid-grid. The caller adds the returned magnitude to
* sfAssetsTotal for credits, or subtracts it for debits, and applies it
* the same way to any related field (for example sfAssetsAvailable) so
* all rails stay in sync; the other fields' scale is at least as fine as
* sfAssetsTotal's.
*
* @param vault The vault SLE.
* @param delta The signed amount by which sfAssetsTotal will change; only
* its sign selects the rounding direction. The magnitude is
* what is quantized and returned.
* @param mode The rounding mode to apply when quantizing to the
* sfAssetsTotal scale.
*
* @return The rounded magnitude, always non-negative.
*/
[[nodiscard]] STAmount
clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta, Number::RoundingMode mode);
/**
* Controls whether to truncate shares instead of rounding.
*/
@@ -84,30 +58,33 @@ enum class TruncateShares : bool { No = false, Yes = true };
enum class WaiveUnrealizedLoss : bool { No = false, Yes = true };
/**
* Returns the assets backing outstanding shares for a withdrawal:
* sfAssetsTotal minus sfLossUnrealized, or sfAssetsTotal alone when the
* unrealized loss is waived. Used by assetsToSharesWithdraw and
* sharesToAssetsWithdraw as the numerator of the share/asset exchange rate.
* Returns the effective total of assets backing outstanding shares for the
* purposes of a withdrawal, i.e. sfAssetsTotal, discounted by sfLossUnrealized
* unless waived. This is the numerator used by both withdraw conversion
* helpers (assetsToSharesWithdraw and sharesToAssetsWithdraw) to compute the
* share/asset exchange rate.
*
* @param vault The vault SLE.
* @param waive Whether to skip subtracting the unrealized loss.
* @param waive Whether to waive (i.e. not subtract) the vault's unrealized
* loss.
*/
[[nodiscard]] Number
assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive);
/**
* Returns true if debiting `amount` from `total` (the current value of a
* vault's sfAssetsTotal or sfAssetsAvailable) would canonicalize to the
* same STAmount value. This happens when `amount` is non-zero but too small
* to change the stored total at STAmount's precision. Shares would still
* move, so the ValidVault invariant would fail after apply; callers use
* this to reject the transaction upfront instead.
* Returns whether debiting `amount` from `total` the current value of a
* vault's sfAssetsTotal or sfAssetsAvailable field — would canonicalize back
* to the exact same STAmount value it started at. This happens when a
* genuinely non-zero debit is dust relative to a `total` large enough to
* exceed STAmount's significant-digit precision: the shares still move, but
* the stored total doesn't change, which otherwise trips the ValidVault
* invariant after the fact instead of failing cleanly upfront.
*
* @param asset The vault's underlying asset, used to canonicalize both
* sides the same way the ledger will when the field is stored.
* @param asset The vault's underlying asset, used to canonicalize both sides
* the same way the ledger will when the field is stored.
* @param total The field's current value.
* @param amount The amount to debit. Zero always returns false; that case
* is rejected separately.
* @param amount The amount to debit. A value of zero always returns false;
* that case is rejected separately and unconditionally.
*/
[[nodiscard]] bool
debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount);

View File

@@ -126,15 +126,15 @@ 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 a repository pair:
the event, and `publish_pkg.sh` maps that channel to its repositories:
| Event | Version | Channel | DEB repository | RPM repository |
| ------------------------ | ----------------- | -------------- | ------------------ | ------------------ |
| tag | `X.Y.Z` | `stable` | `deb-stable` | `rpm-stable` |
| tag | `X.Y.Z-rcN` | `unstable` | `deb-unstable` | `rpm-unstable` |
| tag | `X.Y.Z-bN` | `experimental` | `deb-experimental` | `rpm-experimental` |
| push to `develop` | `xrpld --version` | `develop` | `deb-develop` | `rpm-develop` |
| tag, non-public codebase | _any_ | `private` | `deb-private` | `rpm-private` |
| Event | Version | Channel | DEB repository | RPM upload repository |
| ------------------------ | ----------------- | -------------- | ------------------ | ------------------------- |
| tag | `X.Y.Z` | `stable` | `deb-stable` | `rpm-stable-hosted` |
| tag | `X.Y.Z-rcN` | `unstable` | `deb-unstable` | `rpm-unstable-hosted` |
| tag | `X.Y.Z-bN` | `experimental` | `deb-experimental` | `rpm-experimental-hosted` |
| push to `develop` | `xrpld --version` | `develop` | `deb-develop` | `rpm-develop-hosted` |
| tag, non-public codebase | _any_ | `private` | `deb-private` | `rpm-private-hosted` |
Only a tag names a channel — do not extend that to `develop`, where
`BuildInfo.cpp`'s `versionString` moves through `-bN`, `-rcN` and even the final
@@ -155,12 +155,15 @@ Conan remote.
Nexus owns the repository metadata; nothing here indexes anything. Worth knowing:
- Each apt-hosted repository needs a distribution and a PGP signing keypair
configured in Nexus, which rejects one created without a keypair. Nexus signs
the apt metadata with it, never the packages.
- Hosted yum repositories cannot be signed by Nexus at all, so `sign_rpm.sh`
signs the RPMs before they are uploaded, and rpm clients verify with
`gpgcheck=1` rather than `repo_gpgcheck=1`.
- Each apt-hosted repository needs a distribution (ours use `any`) and a PGP
signing keypair configured in Nexus, which rejects one created without a
keypair. Nexus signs the apt metadata with it, never the packages.
- Hosted yum repositories cannot be signed by Nexus, so each `rpm-<channel>-hosted`
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
verify them with `gpgcheck=1`.
- yum metadata is rebuilt asynchronously, so a successful publish is not
immediately installable.
- Each job uploads only what it built, and uploads are not transactional, so a

View File

@@ -7,10 +7,13 @@ set -euo pipefail
# Usage: publish_pkg.sh <channel> [package-dir]
#
# channel release channel, selecting the 'deb-<channel>' and
# 'rpm-<channel>' repository pair
# '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.
@@ -24,7 +27,7 @@ if [[ -z "${channel}" ]]; then
fi
deb_repo="deb-${channel}"
rpm_repo="rpm-${channel}"
rpm_repo="rpm-${channel}-hosted"
if [[ -z "${DRY_RUN:-}" ]]; then
: "${NEXUS_USERNAME:?is required}" "${NEXUS_PASSWORD:?is required}"

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
# Sign the RPMs built by build_pkg.sh. Nexus cannot sign hosted yum metadata, so
# the packages carry the signature themselves and rpm clients verify them with
# gpgcheck=1.
# 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]
#
@@ -12,8 +13,9 @@ set -euo pipefail
# PKG_SIGNING_KEY must hold an armoured PGP private key. It has no flag, to keep
# the key out of the process list.
#
# There is no DEB equivalent: apt trusts the repository metadata, which Nexus
# signs, rather than the packages themselves.
# 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}"

View File

@@ -67,22 +67,6 @@ sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co
return assets;
}
[[nodiscard]] STAmount
clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta, Number::RoundingMode mode)
{
XRPL_ASSERT(
delta.asset() == vault->at(sfAsset),
"xrpl::clampToAssetsTotalScale : delta and vault asset match");
Asset const asset = vault->at(sfAsset);
// Canonicalize both endpoints under the same rounding mode so the subtraction
// reflects only the effect of `delta`, never a rounding difference between them.
NumberRoundModeGuard const mg(mode);
STAmount const totalBefore{asset, vault->at(sfAssetsTotal)};
STAmount const totalAfter{asset, vault->at(sfAssetsTotal) + delta};
return delta.negative() ? totalBefore - totalAfter : totalAfter - totalBefore;
}
[[nodiscard]] Number
assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive)
{

View File

@@ -256,13 +256,10 @@ VaultClawback::assetsToClawback(
STAmount sharesDestroyed;
STAmount assetsRecovered;
// Number arithmetic can throw overflow_error when Scale and totals are large. Caught below.
try
{
if (clawbackAmount == beast::kZero)
{
// Zero amount means clawback all shares the holder has; derive the corresponding asset
// amount from the share balance.
sharesDestroyed = accountHolds(
view(), holder, share, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j_);
auto const maybeAssets =
@@ -274,8 +271,6 @@ VaultClawback::assetsToClawback(
}
else
{
// Non-zero amount: convert to shares, then convert shares back to assets so the
// recovery reflects exactly the shares to be burned, not the requested amount.
auto const maybeShares =
assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount);
if (!maybeShares)
@@ -288,11 +283,13 @@ VaultClawback::assetsToClawback(
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
assetsRecovered = *maybeAssets;
}
// Clamp assetsRecovered to sfAssetsAvailable, then re-derive shares and assets so the pair
// stays consistent;.
// Clamp to maximum.
if (assetsRecovered > *assetsAvailable)
{
assetsRecovered = *assetsAvailable;
// Note, it is important to truncate the number of shares,
// otherwise the corresponding assets might breach the
// AssetsAvailable
{
auto const maybeShares = assetsToSharesWithdraw(
vault, sleShareIssuance, assetsRecovered, TruncateShares::Yes);
@@ -306,8 +303,6 @@ VaultClawback::assetsToClawback(
if (!maybeAssets)
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
assetsRecovered = *maybeAssets;
// Truncation should guarantee the invariant holds. If it does not, a conversion
// helper is broken; refuse rather than over-recover.
if (assetsRecovered > *assetsAvailable)
{
// LCOV_EXCL_START
@@ -316,20 +311,6 @@ VaultClawback::assetsToClawback(
// LCOV_EXCL_STOP
}
}
// Post-fixCleanup3_4_0: round the recovery to the sfAssetsTotal scale so all rails change
// by the same representable delta. sharesDestroyed is intentionally NOT re-derived here:
// the holder's shares are burned for their pre-clamp value, so any sub-ULP trimmed off
// stays in the vault for the remaining shareholders.
if (ctx_.view().rules().enabled(fixCleanup3_4_0) && assetsRecovered > beast::kZero)
{
assetsRecovered =
clampToAssetsTotalScale(vault, -assetsRecovered, Number::RoundingMode::Upward);
// Recovery collapsed to zero. Return tecPRECISION_LOSS rather than burning shares for
// no asset return.
if (assetsRecovered <= beast::kZero)
return std::unexpected(tecPRECISION_LOSS);
}
}
catch (std::overflow_error const&)
{
@@ -341,8 +322,6 @@ VaultClawback::assetsToClawback(
<< ", assetsTotal=" << vault->at(sfAssetsTotal).value()
<< ", sharesTotal=" << sleShareIssuance->at(sfOutstandingAmount)
<< ", amount=" << clawbackAmount.value();
// Overflow means this transaction cannot apply, but ledger state is still consistent.
// Return tecPATH_DRY rather than a hard internal error.
return std::unexpected(tecPATH_DRY);
}
@@ -401,54 +380,23 @@ VaultClawback::doApply()
sharesDestroyed = clawbackParts->second;
}
// The holder has no shares (or the recovery clamped to zero). Nothing to burn; refuse rather
// than modifying vault state.
if (sharesDestroyed == beast::kZero)
return tecPRECISION_LOSS;
// Even a non-zero recovery can be too small to change the stored sfAssetsTotal or
// sfAssetsAvailable at STAmount's precision. Shares would still be burned, so ValidVault
// would fail after apply with "clawback must decrease vault balance"; reject here instead.
// On the issuer-clawback path this is effectively unreachable once fixCleanup3_4_0 is
// active, since assetsToClawback's own clampToAssetsTotalScale already snapped
// assetsRecovered to the grid; kept as defense in depth and to cover the owner-burn path,
// where assetsRecovered is not put through that clamp.
//
// Number arithmetic can throw overflow_error when Scale and totals are large. Caught
// below. debitIsNonZeroDust converts assetsTotal/assetsAvailable to STAmount, which is
// exactly what a sufficiently abused sfScale can push out of STAmount's representable
// range.
if (view().rules().enabled(fixCleanup3_4_0))
// A recovered amount can be genuinely non-zero yet still be dust relative to a
// sfAssetsTotal/sfAssetsAvailable large enough to exceed STAmount's significant-digit
// precision: subtracting it below rounds the stored total right back to where it started.
// The shares still move, so ValidVault would fail after the fact with "clawback must
// decrease vault balance" instead of a clean upfront rejection.
if (view().rules().enabled(fixCleanup3_4_0) &&
(debitIsNonZeroDust(vaultAsset, assetsTotal, assetsRecovered) ||
debitIsNonZeroDust(vaultAsset, assetsAvailable, assetsRecovered)))
{
try
{
if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsRecovered) ||
debitIsNonZeroDust(vaultAsset, assetsAvailable, assetsRecovered))
{
JLOG(j_.debug())
<< "VaultClawback: clawback amount too small to change stored vault"
" balance";
return tecPRECISION_LOSS;
}
}
catch (std::overflow_error const&)
{
// It's easy to hit this exception from Number with large enough Scale
// so we avoid spamming the log and only use debug here.
JLOG(j_.debug()) //
<< "VaultClawback: overflow error with"
<< " scale=" << (int)vault->at(sfScale).value() //
<< ", assetsTotal=" << vault->at(sfAssetsTotal).value()
<< ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount)
<< ", amount=" << amount.value();
// Overflow means this transaction cannot apply, but ledger state is still
// consistent. Return tecPATH_DRY rather than a hard internal error.
return tecPATH_DRY;
}
JLOG(j_.debug()) << "VaultClawback: clawback amount too small to change stored vault"
" balance";
return tecPRECISION_LOSS;
}
// Debit both rails by the same delta so sfAssetsTotal and sfAssetsAvailable stay in step,
// as required by the ValidVault invariant.
assetsTotal -= assetsRecovered;
assetsAvailable -= assetsRecovered;
view().update(vault);

View File

@@ -208,7 +208,6 @@ TER
VaultDeposit::doApply()
{
bool const fix320Enabled = view().rules().enabled(fixCleanup3_2_0);
bool const fix340Enabled = view().rules().enabled(fixCleanup3_4_0);
auto const vault = view().peek(keylet::vault(ctx_.tx[sfVaultID]));
auto applyViewContext = ctx_.getApplyViewContext();
if (!vault)
@@ -285,8 +284,6 @@ VaultDeposit::doApply()
}
STAmount sharesCreated = {vault->at(sfShareMPTID)}, assetsDeposited;
// Number arithmetic can throw overflow_error when Scale and totals are large. Caught below.
try
{
// Compute exchange before transferring any amounts.
@@ -296,20 +293,14 @@ VaultDeposit::doApply()
return tecINTERNAL; // LCOV_EXCL_LINE
sharesCreated = *maybeShares;
}
if (sharesCreated == beast::kZero)
return tecPRECISION_LOSS;
// Convert shares back to assets so the depositor is debited for the amount actually minted.
// The truncated share count is worth <= amount; without this the difference would be
// credited to the vault for free.
auto const maybeAssets = sharesToAssetsDeposit(vault, sleIssuance, sharesCreated);
if (!maybeAssets)
{
return tecINTERNAL; // LCOV_EXCL_LINE
}
// The round-trip must never return more than the original amount. If it does, a conversion
// helper is broken. Reject rather than overcharge the depositor.
if (*maybeAssets > amount)
{
// LCOV_EXCL_START
@@ -318,35 +309,6 @@ VaultDeposit::doApply()
// LCOV_EXCL_STOP
}
assetsDeposited = *maybeAssets;
// Post-fixCleanup3_4_0: round the deposit to the sfAssetsTotal scale so all accounting
// fields (trust line / MPT, sfAssetsAvailable, sfAssetsTotal) change by the same
// representable delta.
if (fix340Enabled)
{
// Round Downward so the vault is credited by at most what the depositor paid.
assetsDeposited =
clampToAssetsTotalScale(vault, assetsDeposited, Number::RoundingMode::Downward);
// Return tecPRECISION_LOSS instead of minting shares against a zero credit.
if (assetsDeposited <= beast::kZero)
{
JLOG(j_.warn()) << "VaultDeposit: deposit rounds to zero at "
"assets outstanding scale.";
return tecPRECISION_LOSS;
}
// The pre-clamp share count would over-issue by the trimmed ULP and give the depositor
// more value than they credited.
auto const maybeReShares = assetsToSharesDeposit(vault, sleIssuance, assetsDeposited);
if (!maybeReShares)
return tecINTERNAL; // LCOV_EXCL_LINE
sharesCreated = *maybeReShares;
if (sharesCreated == beast::kZero)
return tecPRECISION_LOSS;
}
}
catch (std::overflow_error const&)
{

View File

@@ -1,7 +1,6 @@
#include <xrpl/tx/transactors/vault/VaultWithdraw.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
@@ -204,7 +203,6 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
TER
VaultWithdraw::doApply()
{
bool const fix340Enabled = view().rules().enabled(fixCleanup3_4_0);
auto const vault = view().peek(keylet::vault(ctx_.tx[sfVaultID]));
auto applyViewContext = ctx_.getApplyViewContext();
if (!vault)
@@ -236,7 +234,6 @@ VaultWithdraw::doApply()
// We waive the unrealized-loss subtraction in this case to avoid user withdrawing all of their
// shares but keeping future value in the vault.
auto const waiveUnrealizedLoss = shouldWaiveWithdrawal(view(), accountID_, sleIssuance);
// Number arithmetic can throw overflow_error when Scale and totals are large. Caught below.
try
{
if (amount.asset() == vaultAsset)
@@ -250,12 +247,8 @@ VaultWithdraw::doApply()
sharesRedeemed = *maybeShares;
}
// Shares are MPT (integer). Small requested amounts truncate to zero; refuse rather
// than burn nothing while paying out assets.
if (sharesRedeemed == beast::kZero)
return tecPRECISION_LOSS;
// Convert shares back to assets so the payout matches the shares actually burned, not
// the requested amount. The extra would otherwise be paid from the vault for free.
auto const maybeAssets =
sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
if (!maybeAssets)
@@ -264,8 +257,7 @@ VaultWithdraw::doApply()
}
else if (amount.asset() == share)
{
// Fixed shares, variable assets. No round-trip: the share count is exactly what the
// caller specified; only the payout amount is derived.
// Fixed shares, variable assets.
sharesRedeemed = amount;
auto const maybeAssets =
sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
@@ -288,8 +280,6 @@ VaultWithdraw::doApply()
<< ", assetsTotal=" << vault->at(sfAssetsTotal).value()
<< ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount)
<< ", amount=" << amount.value();
// Overflow means this transaction cannot apply, but ledger state is still consistent.
// Return tecPATH_DRY rather than a hard internal error.
return tecPATH_DRY;
}
@@ -305,11 +295,12 @@ VaultWithdraw::doApply()
lossUnrealized <= (assetsTotal - assetsAvailable),
"xrpl::VaultWithdraw::doApply : loss and assets do balance");
if (fix340Enabled && !isFinalWithdrawal)
if (view().rules().enabled(fixCleanup3_4_0) && !isFinalWithdrawal)
{
// Fixed-shares path: a small share count can round to zero assets even though the vault
// still has backing value. Reject rather than burn shares for a zero payout. The
// fixed-assets branch above has already rejected zero via the sharesRedeemed check.
// A withdrawal for a fixed share amount (variable assets) has no requested-asset
// amount to check for rounding, unlike the fixed-assets branch above: a small enough
// share amount can round down to an exact zero even though the vault still holds
// positive effective value backing outstanding shares.
if (amount.asset() == share && assetsWithdrawn == beast::kZero &&
assetsTotalForWithdrawal(vault, waiveUnrealizedLoss) != beast::kZero)
{
@@ -317,36 +308,16 @@ VaultWithdraw::doApply()
return tecPRECISION_LOSS;
}
// Number arithmetic can throw overflow_error when Scale and totals are large. Caught
// below. debitIsNonZeroDust converts assetsTotal/assetsAvailable to STAmount, which is
// exactly what a sufficiently abused sfScale can push out of STAmount's representable
// range.
try
// assetsWithdrawn can also be genuinely non-zero and still too small to move
// sfAssetsTotal or sfAssetsAvailable once canonicalized to STAmount's precision. Either
// way the shares still move, so ValidVault would otherwise fail after the fact instead
// of a clean upfront rejection.
if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsWithdrawn) ||
debitIsNonZeroDust(vaultAsset, assetsAvailable, assetsWithdrawn))
{
// Even a non-zero withdrawal can be too small to change the stored sfAssetsTotal or
// sfAssetsAvailable at STAmount's precision. Shares would still move, so ValidVault
// would fail after apply; reject here instead.
if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsWithdrawn) ||
debitIsNonZeroDust(vaultAsset, assetsAvailable, assetsWithdrawn))
{
JLOG(j_.debug()) << "VaultWithdraw: withdrawal amount too small to change stored"
" vault balance";
return tecPRECISION_LOSS;
}
}
catch (std::overflow_error const&)
{
// It's easy to hit this exception from Number with large enough Scale
// so we avoid spamming the log and only use debug here.
JLOG(j_.debug()) //
<< "VaultWithdraw: overflow error with"
<< " scale=" << (int)vault->at(sfScale).value() //
<< ", assetsTotal=" << vault->at(sfAssetsTotal).value()
<< ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount)
<< ", amount=" << amount.value();
// Overflow means this transaction cannot apply, but ledger state is still consistent.
// Return tecPATH_DRY rather than a hard internal error.
return tecPATH_DRY;
JLOG(j_.debug()) << "VaultWithdraw: withdrawal amount too small to change stored"
" vault balance";
return tecPRECISION_LOSS;
}
}
@@ -364,51 +335,6 @@ VaultWithdraw::doApply()
return tecINSUFFICIENT_FUNDS;
}
// Post-fixCleanup3_4_0: round the payout to the sfAssetsTotal scale so all three rails
// (trust line / MPT, sfAssetsAvailable, sfAssetsTotal) change by the same representable delta.
// Skip when assetsWithdrawn is already zero: the earlier fix340 guard above deliberately
// permits fixed-share zero-asset withdrawals in a fully-impaired vault (where
// assetsTotalForWithdrawal == 0), and clamping-then-rejecting would undo that. Also skip on
// the final-withdrawal path, which overwrites assetsWithdrawn with sfAssetsAvailable below.
if (fix340Enabled && !isFinalWithdrawal && assetsWithdrawn > beast::kZero)
{
// Number arithmetic can throw overflow_error when Scale and totals are large. The
// debitIsNonZeroDust check above already performs the same STAmount conversion of
// assetsTotal/assetsAvailable under the ambient rounding mode and would have thrown
// (and been caught) first for any value that overflows under that mode. Only reachable
// if RoundingMode::Upward -- forced below to keep the clamp conservative -- carries a
// value that was in range under the ambient mode just past the max representable
// exponent. Kept for defense in depth; not realistically triggerable from a test.
try
{
// Round Upward on the negative delta: the stored total is decremented by no more than
// it can represent, so the payout is trimmed downward and the vault never pays out
// more than it can account for.
assetsWithdrawn =
clampToAssetsTotalScale(vault, -assetsWithdrawn, Number::RoundingMode::Upward);
// Payout collapsed to zero. Return tecPRECISION_LOSS instead of burning shares for
// nothing.
if (assetsWithdrawn <= beast::kZero)
return tecPRECISION_LOSS;
}
// LCOV_EXCL_START
catch (std::overflow_error const&)
{
// It's easy to hit this exception from Number with large enough Scale
// so we avoid spamming the log and only use debug here.
JLOG(j_.debug()) //
<< "VaultWithdraw: overflow error with"
<< " scale=" << (int)vault->at(sfScale).value() //
<< ", assetsTotal=" << vault->at(sfAssetsTotal).value()
<< ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount)
<< ", amount=" << amount.value();
// Overflow means this transaction cannot apply, but ledger state is still consistent.
// Return tecPATH_DRY rather than a hard internal error.
return tecPATH_DRY;
}
// LCOV_EXCL_STOP
}
// The vault must have enough assets on hand.
if (*assetsAvailable < assetsWithdrawn)
{
@@ -416,12 +342,14 @@ VaultWithdraw::doApply()
return tecINSUFFICIENT_FUNDS;
}
// Post-fixCleanup3_2_0: burning every outstanding share is only allowed when the vault has no
// unrealized loss. Otherwise the resulting (shares == 0, assetsTotal > 0) state would violate
// the zero-sized-vault invariant.
// Post-fixCleanup3_2_0 "final withdrawal" rule:
// a transaction that would burn every outstanding share is only permitted when the vault is in
// a clean state — no outstanding receivables and no unrealized loss. Otherwise the resulting
// (shares == 0, assetsTotal > 0) state would violate the zero-sized-vault invariant.
//
// The payout is set to the remaining sfAssetsAvailable. The helper result should already
// equal that value in a clean vault; any mismatch is a rounding artifact and is logged.
// When the rule applies, the payout is the remaining sfAssetsAvailable; in a clean vault
// the helper result should already equal that value, and any mismatch is a rounding artifact
// worth logging.
if (view().rules().enabled(fixCleanup3_2_0) && isFinalWithdrawal)
{
// Unreachable: a final withdrawal with lossUnrealized > 0 has
@@ -455,8 +383,6 @@ VaultWithdraw::doApply()
}
else
{
// Debit both rails by the same delta so sfAssetsTotal and sfAssetsAvailable stay in step,
// as required by the ValidVault invariant.
assetsTotal -= assetsWithdrawn;
assetsAvailable -= assetsWithdrawn;
}

View File

@@ -19,11 +19,9 @@
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <cstdint>
#include <memory>
#include <string>
#include <tuple>
@@ -413,11 +411,7 @@ private:
testcase(
"bug: VaultDeposit below Vault precision canonicalized to zero "
"(pre-fixCleanup3_2_0)");
// Also remove fixCleanup3_4_0 so the VaultDeposit clamp
// introduced by that amendment does not short-circuit this
// pre-fixCleanup3_2_0 scenario with tecPRECISION_LOSS.
runScenario(
testableAmendments() - fixCleanup3_2_0 - fixCleanup3_4_0, tecINVARIANT_FAILED);
runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
}
{
testcase(
@@ -623,164 +617,6 @@ private:
}
}
// Bug: an IOU VaultDeposit can credit the vault (and its pseudo-account trust line) with
// more than the depositor paid, when the exact new total needs 17 significant digits but
// STAmount only keeps 16. The pseudo-account trust line (accountSend) and sfAssetsTotal
// (associateAsset) both independently round the 17-digit sum UP to the nearest 16-digit
// value -- the same direction on both rails, so no invariant catches the mismatch.
//
// seed 9.999999999999999 at scale 15 (1 share = 1e-15 asset); depositing 5 buys exactly
// 5e15 shares.
// exact new total 9.999999999999999 + 5 = 14.999999999999999 (17 digits)
// stored 15 (rounds UP to the nearest 16-digit value)
// credited more than the depositor actually paid in -- value issued out of
// nothing
//
// Fix (fixCleanup3_4_0): VaultDeposit rounds the credited amount DOWN to what's exactly
// representable at the vault's sfAssetsTotal scale before storing it, so the vault (and
// its pseudo-account) can never be credited more than the depositor paid.
void
testBugVaultDepositOvercreditsAcrossScaleBoundary()
{
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, bool expectOvercredit) {
Env env(*this, features);
Account const owner{"owner"};
Account const issuer{"issuer"};
Account const depositor{"depositor"};
env.fund(XRP(1'000'000), owner, issuer, depositor);
env.close();
PrettyAsset const usd{issuer["USD"]};
Number const seed{9'999'999'999'999'999LL, -15};
Number const deposit{5};
env(trust(depositor, usd(1'000'000'000)));
env.close();
env(pay(issuer, depositor, usd(deposit)));
env.close();
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = usd.raw()});
tx[sfScale] = 15;
env(tx);
env.close();
env(vault.deposit({.depositor = issuer, .id = keylet.key, .amount = usd(seed)}));
env.close();
Number const totalBefore = env.le(keylet)->at(sfAssetsTotal);
Number const depositorBefore = env.balance(depositor, usd.raw()).number();
env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = usd(deposit)}));
env.close();
Number const totalAfter = env.le(keylet)->at(sfAssetsTotal);
Number const depositorAfter = env.balance(depositor, usd.raw()).number();
Number const paid = depositorBefore - depositorAfter;
Number const credited = totalAfter - totalBefore;
if (expectOvercredit)
{
BEAST_EXPECTS(
credited > paid,
"AssetsTotal credited " + to_string(credited) + " for a payment of " +
to_string(paid) + ", expected an overcredit");
}
else
{
BEAST_EXPECTS(
credited <= paid,
"AssetsTotal credited " + to_string(credited) + " for a payment of " +
to_string(paid));
}
};
// Also remove fixCleanup3_2_0: its roundToVaultScale trims the requested amount
// down to the vault's current scale before this bug's own 17-digit-sum boundary is
// ever reached, which would otherwise mask the overcredit behind a different
// (already-fixed) rounding path instead of reproducing it.
testcase(
"bug: VaultDeposit overcredits across an IOU scale boundary "
"(pre-fixCleanup3_4_0)");
runScenario(all_ - fixCleanup3_2_0 - fixCleanup3_4_0, true);
testcase(
"bug: VaultDeposit no longer overcredits across an IOU scale boundary "
"(post-fixCleanup3_4_0)");
runScenario(all_, false);
}
// Bug: a partial VaultWithdraw can permanently lock a large IOU vault. IOU amounts hold
// 16 significant digits, so once sfAssetsTotal grows large enough it's stored in steps of
// 1 ULP (e.g. steps of 100 at 1e17). If a single share is worth less than that step,
// withdrawing all-but-one share overpays: the payout rounds UP to the entire remaining
// balance, draining sfAssetsTotal to exactly zero while the last share stays outstanding.
// The vault is then insolvent (AssetsTotal == 0, sharesTotal > 0) and permanently stuck --
// withdrawing the last share pays 0 and can't change the balance (tecINVARIANT_FAILED),
// depositing to refill is blocked because the vault is insolvent (tecLOCKED), and deleting
// is impossible while a share is outstanding (tecHAS_OBLIGATIONS). A full withdrawal from
// the start would have emptied the vault cleanly; once this partial-withdrawal state is
// reached, no sequence of ordinary transactions recovers it.
//
// Fix (fixCleanup3_4_0): VaultWithdraw rounds the payout DOWN to what's exactly
// representable at the vault's sfAssetsTotal scale, so a partial withdrawal can never
// drain the vault to zero while shares remain outstanding.
void
testBugVaultLockedByPartialWithdraw()
{
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, TER expected) {
Env env(*this, features);
Account const owner{"owner"};
Account const issuer{"issuer"};
Account const holder{"holder"};
env.fund(XRP(1'000'000), owner, issuer, holder);
env.close();
PrettyAsset const usd{issuer["USD"]};
env(trust(holder, usd(Number{1, 18})));
env.close();
env(pay(issuer, holder, usd(Number{1, 17})));
env.close();
Vault const vault{env};
// scale=0: 1 share == 1 asset unit, so sharesTotal == assetsTotal == 1e17.
auto [tx, keylet] = vault.create({.owner = owner, .asset = usd.raw()});
tx[sfScale] = 0;
env(tx);
env.close();
env(vault.deposit(
{.depositor = holder, .id = keylet.key, .amount = usd(Number{1, 17})}));
env.close();
MPTIssue const share{env.le(keylet)->at(sfShareMPTID)};
std::int64_t const allButOne = 100'000'000'000'000'000LL - 1;
env(vault.withdraw(
{.depositor = holder, .id = keylet.key, .amount = STAmount{share, allButOne}}));
env.close();
// The holder now tries to withdraw the one remaining share. Pre-fix, the vault is
// already insolvent (AssetsTotal == 0, one share outstanding) and this fails with
// tecINVARIANT_FAILED forever. Post-fix, the earlier partial withdrawal never
// drained the vault, so this succeeds and empties it cleanly.
env(vault.withdraw(
{.depositor = holder, .id = keylet.key, .amount = STAmount{share, 1}}),
Ter(expected));
env.close();
};
testcase(
"bug: VaultWithdraw permanently locks a large IOU vault "
"(pre-fixCleanup3_4_0)");
runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
testcase(
"bug: VaultWithdraw no longer locks a large IOU vault "
"(post-fixCleanup3_4_0)");
runScenario(all_, tesSUCCESS);
}
// VaultDeposit::preclaim uses accountHolds(..., SpendableHandling::
// shFULL_BALANCE), which for an IOU asset adds the counterparty's
// LowLimit/HighLimit to the depositor's raw balance (TokenHelpers.cpp:
@@ -967,8 +803,6 @@ public:
testVaultDepositCanonicalizeToZero();
testVaultWithdrawCanonicalizeToZero();
testBugVaultDustDebitCanonicalizesToNoOp();
testBugVaultDepositOvercreditsAcrossScaleBoundary();
testBugVaultLockedByPartialWithdraw();
testVaultDepositNegativeBalanceFromOppositeLimit();
testBug6LimitBypassWithShares();
}

View File

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

View File

@@ -1,785 +0,0 @@
#include <test/app/vault/VaultPrecisionFixture.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/amount.h>
#include <test/jtx/envconfig.h>
#include <test/jtx/mpt.h>
#include <test/jtx/pay.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/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <string>
#include <tuple>
namespace xrpl::test {
// PR 2 tests: with fixCleanup3_4_0 enabled the transactor clamps make
// deposit / withdraw / clawback exact on the sfAssetsTotal grid. Every
// successful withdrawal and clawback satisfies STRICT equality of the
// assetsTotal / assetsAvailable / pseudo-account deltas -- no tolerance.
// Deposit is exact on the T side; A stays within one unit at the
// sfAssetsTotal grid.
class VaultTransactorPrecision_test : public VaultPrecisionFixture
{
static Number
absDiff(Number const& a, Number const& b)
{
return a > b ? a - b : b - a;
}
// ---- deposit ------------------------------------------------------
// A-1 magnitude sweep. Post-fix every successful deposit satisfies
// T_delta <= requested amount
// |T_delta - A_delta| <= oneUnit(asset, T_after)
// and the plan's three boundary amounts {1, 7, 10'000'000} now succeed.
// Pre-fix those three boundary amounts fail with tecINVARIANT_FAILED.
void
testDepositNeverOverCredited(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
testcase(
std::string("A-1 deposit never over-credited") +
(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};
// Plan's three boundary amounts that Part 1 alone closes on A-1.
std::array<int, 3> const kPlanBoundaryAmounts{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 (!BEAST_EXPECT(f.asset && f.broker) || !f.asset)
continue;
jtx::PrettyAsset 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();
bool const isBoundary =
std::ranges::find(kPlanBoundaryAmounts, amount) != kPlanBoundaryAmounts.end();
if (fixEnabled)
{
if (isBoundary)
{
BEAST_EXPECTS(
actual == tesSUCCESS,
"plan boundary amount=" + std::to_string(amount) +
" expected tesSUCCESS, got " + transToken(actual));
}
if (actual != tesSUCCESS)
continue;
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_EXPECTS(
tDelta <= requested,
"amount=" + std::to_string(amount) + " tDelta exceeds requested");
Number const gap = absDiff(tDelta, aDelta);
BEAST_EXPECTS(
gap <= oneUnit(asset, after.assetsTotal),
"amount=" + std::to_string(amount) + " |tDelta-aDelta| exceeds oneUnit");
}
else if (isBoundary)
{
BEAST_EXPECTS(
actual == tecINVARIANT_FAILED,
"pre-fix amount=" + std::to_string(amount) +
" expected tecINVARIANT_FAILED, got " + transToken(actual));
}
}
}
// Post-fix: the depositor never gets shares worth more than the assets
// they paid. Any overpay is bounded by the pre-deposit per-share value.
void
testDepositorNeverUnderpays(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
if (!fixEnabled)
return;
testcase("A-1 depositor never underpays (fixCleanup3_4_0)");
std::array<int, 8> const kAmounts{1, 7, 100, 1'000, 10'000, 100'000, 1'000'000, 10'000'000};
for (auto const amount : kAmounts)
{
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
if (!BEAST_EXPECT(f.asset && f.broker) || !f.asset)
continue;
jtx::PrettyAsset 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();
if (env.ter() != tesSUCCESS)
continue;
auto const after = read(env, f);
Number const sharesMinted = after.sharesTotal - before.sharesTotal;
Number const assetsTaken = after.assetsTotal - before.assetsTotal;
if (before.sharesTotal == Number{0})
continue;
Number const shareValue = (before.assetsTotal * sharesMinted) / before.sharesTotal;
BEAST_EXPECTS(
shareValue <= assetsTaken,
"amount=" + std::to_string(amount) + " shareValue > assetsTaken");
Number const perShareOverpay = before.assetsTotal / before.sharesTotal;
Number const overpay = assetsTaken > shareValue ? assetsTaken - shareValue : Number{0};
BEAST_EXPECTS(
overpay <= perShareOverpay,
"amount=" + std::to_string(amount) + " overpay exceeds per-share bound");
}
}
// Push T up to ~1e8, then attempt Number{1,-10} deposit. Post-fix:
// tecPRECISION_LOSS with the vault state unchanged. Confirms the
// deposit clamp cannot mint uncovered shares.
void
testZeroCreditRejected(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
if (!fixEnabled)
return;
testcase("A-1 zero credit rejected (fixCleanup3_4_0)");
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
if (!BEAST_EXPECT(f.asset && f.broker) || !f.asset)
return;
jtx::PrettyAsset const& asset = *f.asset;
Vault const v{env};
env(v.deposit(
{.depositor = f.depositor,
.id = f.vaultKeylet.key,
.amount = asset(99'000'000).value()}),
Ter(std::ignore));
env.close();
auto const before = read(env, f);
Number const kLowerBound{1, 6};
BEAST_EXPECT(before.assetsTotal > kLowerBound);
auto const tinyAmount = asset(Number{1, -10}).value();
env(v.deposit({.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = tinyAmount}),
Ter(std::ignore));
env.close();
BEAST_EXPECTS(
env.ter() == tecPRECISION_LOSS,
std::string{"expected tecPRECISION_LOSS, got "} + transToken(env.ter()));
auto const after = read(env, f);
BEAST_EXPECT(after.assetsTotal == before.assetsTotal);
BEAST_EXPECT(after.assetsAvailable == before.assetsAvailable);
BEAST_EXPECT(after.sharesTotal == before.sharesTotal);
}
// XRP and MPT (integral) vaults: the clamp is a no-op because
// STAmount(asset, N) already truncates to whole units. Every amount
// in the sweep succeeds pre- and post-amendment.
void
testIntegralAssetsUnchanged(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
testcase(
std::string("integral asset deposit sweep") +
(fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
std::array<int, 8> const kAmounts{1, 7, 100, 1'000, 10'000, 100'000, 1'000'000, 10'000'000};
auto runXrp = [&]() {
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
Account const owner{"xrp_owner"};
Account const depositor{"xrp_depositor"};
env.fund(XRP(1'000'000'000), owner, depositor);
env.close();
Vault const v{env};
auto [createTx, vaultKeylet] = v.create({.owner = owner, .asset = xrpIssue()});
env(createTx);
env.close();
env(v.deposit(
{.depositor = owner, .id = vaultKeylet.key, .amount = XRP(1'000).value()}));
env.close();
for (auto const amount : kAmounts)
{
env(v.deposit(
{.depositor = depositor,
.id = vaultKeylet.key,
.amount = XRP(amount).value()}),
Ter(std::ignore));
env.close();
BEAST_EXPECTS(
env.ter() == tesSUCCESS,
"XRP amount=" + std::to_string(amount) + " expected tesSUCCESS, got " +
transToken(env.ter()));
}
};
auto runMpt = [&]() {
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
Account const issuer{"mpt_issuer"};
Account const owner{"mpt_owner"};
Account const depositor{"mpt_depositor"};
env.fund(XRP(1'000'000), issuer, owner, depositor);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanTransfer});
PrettyAsset const asset = mptt.issuanceID();
mptt.authorize({.account = owner});
mptt.authorize({.account = depositor});
env(pay(issuer, depositor, asset(1'000'000'000)));
env.close();
Vault const v{env};
auto [createTx, vaultKeylet] = v.create({.owner = owner, .asset = asset});
env(createTx);
env.close();
env(pay(issuer, owner, asset(1'000)));
env.close();
env(v.deposit(
{.depositor = owner, .id = vaultKeylet.key, .amount = asset(1'000).value()}));
env.close();
for (auto const amount : kAmounts)
{
env(v.deposit(
{.depositor = depositor,
.id = vaultKeylet.key,
.amount = asset(amount).value()}),
Ter(std::ignore));
env.close();
BEAST_EXPECTS(
env.ter() == tesSUCCESS,
"MPT amount=" + std::to_string(amount) + " expected tesSUCCESS, got " +
transToken(env.ter()));
}
};
runXrp();
runMpt();
}
// ---- withdraw -----------------------------------------------------
// A-1 fixture withdrawals in both asset-fixed and share-fixed modes.
// Post-fix: never tecINVARIANT_FAILED and every successful withdrawal
// satisfies STRICT equality
// beforeT - afterT == beforeA - afterA == beforePseudo - afterPseudo
// in Number space. This is the strong claim of Part 1b of the plan.
void
testWithdrawDeltas(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
testcase(
std::string("A-1 withdraw delta exactness") +
(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};
std::array<int, 5> const kAssetAmounts{1, 7, 99, 333, 993};
auto runOnce = [&](bool useShares) {
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
if (!BEAST_EXPECT(f.asset && f.broker) || !f.asset)
return;
jtx::PrettyAsset const& asset = *f.asset;
Vault const v{env};
env(v.deposit(
{.depositor = f.depositor,
.id = f.vaultKeylet.key,
.amount = asset(1'000'000).value()}),
Ter(std::ignore));
env.close();
auto step = [&](STAmount const& amount, std::string const& tag) {
auto const before = read(env, f);
env(v.withdraw(
{.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = amount}),
Ter(std::ignore));
env.close();
TER const actual = env.ter();
if (fixEnabled)
{
BEAST_EXPECTS(
actual != tecINVARIANT_FAILED, tag + " unexpected invariant failure");
if (actual == tesSUCCESS)
{
auto const after = read(env, f);
Number const tDelta = before.assetsTotal - after.assetsTotal;
Number const aDelta = before.assetsAvailable - after.assetsAvailable;
Number const pDelta = before.pseudo - after.pseudo;
BEAST_EXPECTS(tDelta == aDelta, tag + " tDelta != aDelta");
BEAST_EXPECTS(tDelta == pDelta, tag + " tDelta != pDelta");
}
}
};
if (useShares)
{
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)}};
step(shareAmount, "shares=" + std::to_string(count));
}
}
else
{
for (auto const amount : kAssetAmounts)
{
step(asset(amount).value(), "assets=" + std::to_string(amount));
}
}
};
runOnce(/*useShares=*/true);
runOnce(/*useShares=*/false);
}
// Post-fix: withdrawer never receives more than the burned share value;
// any shortfall is bounded by one unit at the sfAssetsTotal scale.
void
testWithdrawNeverOverpays(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
if (!fixEnabled)
return;
testcase("A-1 withdraw never overpays (fixCleanup3_4_0)");
std::array<std::uint64_t, 5> const kShareCounts{
99'999u, 100'001u, 333'333u, 1'234'567u, 142'857'142u};
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
if (!BEAST_EXPECT(f.asset && f.broker) || !f.asset)
return;
jtx::PrettyAsset const& asset = *f.asset;
Vault const v{env};
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();
if (env.ter() != tesSUCCESS)
continue;
auto const after = read(env, f);
Number const sharesBurned = before.sharesTotal - after.sharesTotal;
if (before.sharesTotal == Number{0})
continue;
Number const shareValue = (before.assetsTotal * sharesBurned) / before.sharesTotal;
Number const payout = before.assetsTotal - after.assetsTotal;
BEAST_EXPECTS(
payout <= shareValue, "shares=" + std::to_string(count) + " payout > shareValue");
Number const shortfall = shareValue > payout ? shareValue - payout : Number{0};
BEAST_EXPECTS(
shortfall <= oneUnit(asset, after.assetsTotal),
"shares=" + std::to_string(count) + " shortfall exceeds oneUnit");
}
}
// Sub-ULP withdrawal from a ~1e8 vault; post-fix must return
// tecPRECISION_LOSS after the Upward clamp rounds the amount to zero.
void
testWithdrawSubUlpRejected(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
if (!fixEnabled)
return;
testcase("sub-ULP withdraw rejected (fixCleanup3_4_0)");
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
if (!BEAST_EXPECT(f.asset && f.broker) || !f.asset)
return;
jtx::PrettyAsset const& asset = *f.asset;
Vault const v{env};
env(v.deposit(
{.depositor = f.depositor,
.id = f.vaultKeylet.key,
.amount = asset(99'000'000).value()}),
Ter(std::ignore));
env.close();
auto const before = read(env, f);
Number const kLowerBound{1, 6};
BEAST_EXPECT(before.assetsTotal > kLowerBound);
auto const tinyAmount = asset(Number{1, -10}).value();
env(v.withdraw({.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = tinyAmount}),
Ter(std::ignore));
env.close();
BEAST_EXPECTS(
env.ter() == tecPRECISION_LOSS,
std::string{"expected tecPRECISION_LOSS, got "} + transToken(env.ter()));
}
// Depositor burns every share they hold, exercising the final-
// withdrawal branch of VaultWithdraw (line 336-368 -- deliberately
// untouched by this amendment). Must return tesSUCCESS: the clamp
// does not spuriously reject a legitimate full-share withdrawal.
//
// The plan describes reaching a "dust-only gap state" via the A-3
// fixture; empirically the A-3 fixture retains a non-zero
// sfLossUnrealized which blocks the final-withdrawal branch through
// the insufficient-funds guard. Verifying the branch from a clean
// A-1 state still exercises the amendment's non-interference claim.
void
testFinalWithdrawalDust(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
if (!fixEnabled)
return;
testcase("A-1 final withdrawal (fixCleanup3_4_0)");
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
if (!BEAST_EXPECT(f.asset && f.broker) || !f.asset)
return;
jtx::PrettyAsset const& asset = *f.asset;
Vault const v{env};
env(v.deposit(
{.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = asset(500).value()}),
Ter(std::ignore));
env.close();
auto const depositorMptSle = env.le(keylet::mptoken(f.share, f.depositor.id()));
if (!BEAST_EXPECT(depositorMptSle != nullptr))
return;
auto const held = depositorMptSle->at(sfMPTAmount);
if (held == 0)
return;
STAmount const shareAmount{MPTIssue{f.share}, Number{static_cast<std::int64_t>(held)}};
env(v.withdraw({.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = shareAmount}),
Ter(std::ignore));
env.close();
TER const actual = env.ter();
BEAST_EXPECTS(
actual == tesSUCCESS, std::string{"expected tesSUCCESS, got "} + transToken(actual));
}
// ---- deposit + withdraw under impairment (A-3) --------------------
// A-3 fixture drives sfLossUnrealized to the boundary
// lossUnrealized == assetsTotal - assetsAvailable
// A companion invariant-fix branch found that independent per-field
// rounding of assetsTotal/assetsAvailable can spuriously trip the
// XRPL_ASSERT (and, pre-fix, tecINVARIANT_FAILED) that compares
// lossUnrealized against assetsTotal - assetsAvailable in
// VaultWithdraw::doApply. This branch's clamp keeps assetsTotal and
// assetsAvailable exact on the sfAssetsTotal grid, which should keep
// that comparison stable even while churning the vault through this
// boundary state. Only meaningful post-fix -- the pre-fix boundary
// behaviour at A-3 is out of scope for this branch (it is the subject
// of the companion invariant-fix branch, not this one) -- so mirror
// testWithdrawNeverOverpays and early-return before the amendment.
void
testDepositWithdrawUnderImpairment(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
if (!fixEnabled)
return;
testcase("A-3 deposit/withdraw under impairment boundary (fixCleanup3_4_0)");
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/true);
if (!BEAST_EXPECT(f.asset && f.broker) || !f.asset)
return;
jtx::PrettyAsset const& asset = *f.asset;
Vault const v{env};
// Seed the depositor with an initial stake so later withdrawals
// have shares/assets to draw against.
env(v.deposit(
{.depositor = f.depositor,
.id = f.vaultKeylet.key,
.amount = asset(5'000).value()}),
Ter(std::ignore));
env.close();
std::array<int, 12> const kAmounts{1, 3, 7, 13, 29, 51, 97, 137, 251, 499, 991, 1'999};
auto checkInvariant = [&](std::string const& tag) {
TER const actual = env.ter();
BEAST_EXPECTS(actual != tecINVARIANT_FAILED, tag + " unexpected invariant failure");
if (actual == tesSUCCESS)
{
auto const after = read(env, f);
BEAST_EXPECTS(
after.lossUnrealized <= after.assetsTotal - after.assetsAvailable,
tag + " lossUnrealized exceeds assetsTotal - assetsAvailable");
}
};
// Alternate deposit then withdraw of a different amount so the
// vault's assetsTotal / assetsAvailable / lossUnrealized state
// churns through several transactions at the A-3 boundary.
for (std::size_t i = 0; i + 1 < kAmounts.size(); i += 2)
{
int const depositAmount = kAmounts[i];
int const withdrawAmount = kAmounts[i + 1];
env(v.deposit(
{.depositor = f.depositor,
.id = f.vaultKeylet.key,
.amount = asset(depositAmount).value()}),
Ter(std::ignore));
env.close();
checkInvariant("deposit=" + std::to_string(depositAmount));
env(v.withdraw(
{.depositor = f.depositor,
.id = f.vaultKeylet.key,
.amount = asset(withdrawAmount).value()}),
Ter(std::ignore));
env.close();
checkInvariant("withdraw=" + std::to_string(withdrawAmount));
}
}
// ---- clawback -----------------------------------------------------
// A-1 fixture with clawback enabled. Sweep amounts including
// sfAmount-absent (claw back everything) and amount-exceeds-available.
// Post-fix: never tecINVARIANT_FAILED and each success satisfies
// T_delta == A_delta == pseudo_delta in Number space.
// Owner force-burn against a vault with a live loan: must return
// tecNO_PERMISSION regardless of amendment (never enters
// assetsToClawback so the clamp is unreachable there).
void
testClawbackDeltas(FeatureBitset features)
{
using namespace jtx;
bool const fixEnabled = features[fixCleanup3_4_0];
testcase(
std::string("A-1 clawback delta exactness") +
(fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
std::array<int, 6> const kAmounts{1, 7, 99, 333, 993, 5'000'000};
Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
auto f = setupSingleLoanVault(
env,
/*impairAndPaySibling=*/false,
/*allowClawback=*/true);
if (!BEAST_EXPECT(f.asset && f.broker) || !f.asset)
return;
jtx::PrettyAsset const& asset = *f.asset;
Vault const v{env};
env(v.deposit(
{.depositor = f.depositor,
.id = f.vaultKeylet.key,
.amount = asset(2'000).value()}),
Ter(std::ignore));
env.close();
auto stepAmount = [&](int amount) {
auto const before = read(env, f);
if (before.sharesTotal == Number{0})
return;
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");
if (actual == tesSUCCESS)
{
auto const after = read(env, f);
Number const tDelta = before.assetsTotal - after.assetsTotal;
Number const aDelta = before.assetsAvailable - after.assetsAvailable;
Number const pDelta = before.pseudo - after.pseudo;
BEAST_EXPECTS(
tDelta == aDelta, "amount=" + std::to_string(amount) + " tDelta != aDelta");
BEAST_EXPECTS(
tDelta == pDelta, "amount=" + std::to_string(amount) + " tDelta != pDelta");
}
}
};
for (auto const amount : kAmounts)
stepAmount(amount);
{
auto const before = read(env, f);
if (before.sharesTotal > Number{0})
{
env(v.clawback(
{.issuer = f.issuer, .id = f.vaultKeylet.key, .holder = f.depositor}),
Ter(std::ignore));
env.close();
TER const actual = env.ter();
if (fixEnabled)
{
BEAST_EXPECTS(
actual != tecINVARIANT_FAILED,
"sfAmount-absent unexpected invariant failure");
if (actual == tesSUCCESS)
{
auto const after = read(env, f);
Number const tDelta = before.assetsTotal - after.assetsTotal;
Number const aDelta = before.assetsAvailable - after.assetsAvailable;
Number const pDelta = before.pseudo - after.pseudo;
BEAST_EXPECT(tDelta == aDelta);
BEAST_EXPECT(tDelta == pDelta);
}
}
}
}
env(v.clawback({.issuer = f.lender, .id = f.vaultKeylet.key, .holder = f.depositor}),
Ter(tecNO_PERMISSION));
env.close();
}
public:
void
run() override
{
for (auto const& features : {all_ - fixCleanup3_4_0, all_})
{
testDepositNeverOverCredited(features);
testDepositorNeverUnderpays(features);
testZeroCreditRejected(features);
testIntegralAssetsUnchanged(features);
testWithdrawDeltas(features);
testWithdrawNeverOverpays(features);
testWithdrawSubUlpRejected(features);
testFinalWithdrawalDust(features);
testDepositWithdrawUnderImpairment(features);
testClawbackDeltas(features);
}
}
};
BEAST_DEFINE_TESTSUITE(VaultTransactorPrecision, app, xrpl);
} // namespace xrpl::test