Compare commits

..

9 Commits

Author SHA1 Message Date
Bart
f95f7889f9 refactor: Search config directories over a candidate list
Config::setup searched the working directory, the XDG config directory
and the system config directory with a do/while(false) block and breaks,
trying both file names in each.

Replace it with a list of `Candidate` values, each naming a config
directory and its data directory, and a loop that takes the first
holding a config file. Add a docstring for the search order.

The directories chosen and the file reported are unchanged. The last
candidate is now also checked under the legacy name, which the old code
assigned without checking, so one more filesystem check runs when
nothing is found.
2026-10-02 21:19:48 +00:00
Ayaz Salikhov
a9027bb997 ci: Make release always go into stable channel (#8469) 2026-10-02 19:02:46 +00:00
Bart
0a6da4de74 test: Restore config test environment variables with a guard (#8333)
Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 18:24:41 +00:00
Ayaz Salikhov
6a05339c6c ci: Add more guardrails for merging releases back to develop (#8466) 2026-10-02 15:43:18 +00:00
Shawn Xie
0c41a87604 feat: Add MPT transaction stream to subscribe RPC (#5671) 2026-10-02 14:55:23 +00:00
Ayaz Salikhov
f5938e4097 docs: Document new release process (#8467) 2026-10-02 14:55:23 +00:00
Pratik Mankawde
c7d10c1b60 docs: Document the LedgerMaster class (#8160)
Signed-off-by: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com>
2026-10-02 14:50:09 +00:00
Kassaking7
84e2a155b9 fix: Paginate account_lines/offers/channels past new owner-dir types (#8274) 2026-10-02 14:48:48 +00:00
Félix
cbade49976 feat: Add lean toolchain to nix (#8186) 2026-10-02 14:48:17 +00:00
41 changed files with 2383 additions and 849 deletions

View File

@@ -166,7 +166,6 @@ words:
- legleux
- levelization
- levelized
- lgrdb
- libpb
- libxrpl
- llection

4
.envrc
View File

@@ -8,3 +8,7 @@ watch_file rust-toolchain.toml
watch_dir conan
use flake
# Optional, untracked local overrides. To use a different shell, put e.g.
# `use flake .#formal-verification` in .envrc.local.
source_env_if_exists .envrc.local

View File

@@ -36,4 +36,4 @@ runs:
- name: Determine release channel and package release
id: release_info
uses: XRPLF/actions/release-info@ebcf6cea14eee258697308a51fea55cad777581b
uses: XRPLF/actions/release-info@a9f2eeca6fb3980ba3a84cf68566f1c69ad30674

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Exit the script as soon as an error occurs.
set -euo pipefail
# This script fails if <head> merges a release back into <base>,
# but also adds commits that aren't on a release or staging branch, see RELEASING.md.
# Merge commits are allowed.
# Usage: .github/scripts/releasing/check-merge-back-commits.sh <base> <head>
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <base> <head>"
exit 1
fi
BASE=$1
HEAD=$2
SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}")
# shellcheck source=.github/scripts/releasing/common.sh
source "${SCRIPT_DIR}/common.sh"
load_release_branches
# A PR is a merge-back if some of its commits are on a release or staging branch.
PR_COUNT=$(git rev-list --no-merges --count "${BASE}..${HEAD}")
NEW_COUNT=$(git rev-list --no-merges --count "${BASE}..${HEAD}" --not "${BRANCHES[@]}")
if ((NEW_COUNT == PR_COUNT)); then
echo "This PR doesn't merge a release back."
exit 0
fi
if ((NEW_COUNT == 0)); then
echo "This merge-back adds no commits of its own."
exit 0
fi
echo "This PR merges a release back, but also adds commits that aren't on a release or staging branch:"
git log --no-merges --format=' %h %s' "${BASE}..${HEAD}" --not "${BRANCHES[@]}"
echo
echo "Make these changes in a separate PR, see RELEASING.md."
exit 1

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Exit the script as soon as an error occurs.
set -euo pipefail
@@ -21,11 +21,10 @@ patch_ids() {
git log --no-merges --patch --no-color --no-ext-diff "$@" | git patch-id --stable | sort
}
mapfile -t BRANCHES < <(git for-each-ref --format='%(refname)' 'refs/remotes/*/release/*' 'refs/remotes/*/staging/*')
if [ "${#BRANCHES[@]}" -eq 0 ]; then
echo "Error: No release or staging branches found."
exit 1
fi
SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}")
# shellcheck source=.github/scripts/releasing/common.sh
source "${SCRIPT_DIR}/common.sh"
load_release_branches
# Each line is "<patch-id> <commit>".
RELEASE_PATCHES=$(patch_ids "${BRANCHES[@]}" --not "${HEAD}")

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Exit the script as soon as an error occurs.
set -euo pipefail
# This script fails if <head> contains release or staging commits that <base> does not,
# i.e. if <head> merges a release back into <base>.
# Used in the merge queue, which squashes PRs and would drop the merge commit, see RELEASING.md.
# Usage: .github/scripts/releasing/check-no-merge-back.sh <base> <head>
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <base> <head>"
exit 1
fi
BASE=$1
HEAD=$2
SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}")
# shellcheck source=.github/scripts/releasing/common.sh
source "${SCRIPT_DIR}/common.sh"
load_release_branches
RELEASE_COMMITS=$(git rev-list "${BRANCHES[@]}" --not "${BASE}")
HEAD_COMMITS=$(git rev-list "${BASE}..${HEAD}")
# The release commits in <head>, newest first.
MERGED=$(grep -xF -f <(echo "${RELEASE_COMMITS}") <<<"${HEAD_COMMITS}" || true)
if [ -z "${MERGED}" ]; then
echo "No release commits are merged back."
exit 0
fi
echo "This PR merges $(wc -l <<<"${MERGED}" | tr -d ' ') release commits back, e.g.:"
head -5 <<<"${MERGED}" | xargs git log --no-walk --format=' %h %s'
echo
echo "Merge-backs must not go through the merge queue, which squashes them."
echo "Fast-forward develop to the PR branch instead, see RELEASING.md."
exit 1

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Exit the script as soon as an error occurs.
set -euo pipefail

12
.github/scripts/releasing/common.sh vendored Normal file
View File

@@ -0,0 +1,12 @@
# shellcheck shell=bash
# Helpers shared by the release checks in this directory, see RELEASING.md.
# Sets BRANCHES to all release and staging branches, and fails if there are none.
load_release_branches() {
mapfile -t BRANCHES < <(git for-each-ref --format='%(refname)' 'refs/remotes/*/release/*' 'refs/remotes/*/staging/*')
if [ "${#BRANCHES[@]}" -eq 0 ]; then
echo "Error: No release or staging branches found."
exit 1
fi
}

View File

@@ -166,10 +166,31 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Check for copied release commits
if: ${{ github.event_name == 'pull_request' }}
env:
BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
HEAD: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
run: .github/scripts/releasing/check-no-copied-release-commits.sh "${BASE}" "${HEAD}"
# Runs even if the previous check fails, so that both problems are reported at once.
- name: Check merge-back has no new commits
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
env:
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
run: .github/scripts/releasing/check-merge-back-commits.sh "${BASE}" "${HEAD}"
# The queue squashes PRs, so check the PR's own branch, named in the queue branch.
- name: Check the merge queue doesn't merge a release back
if: ${{ github.event_name == 'merge_group' }}
env:
BASE: ${{ github.event.merge_group.base_sha }}
HEAD_REF: ${{ github.event.merge_group.head_ref }}
run: |
if ! [[ "${HEAD_REF}" =~ /pr-([0-9]+)-[0-9a-f]+$ ]]; then
echo "Error: Can't find the PR number in '${HEAD_REF}'."
exit 1
fi
git fetch --no-tags origin "refs/pull/${BASH_REMATCH[1]}/head"
.github/scripts/releasing/check-no-merge-back.sh "${BASE}" FETCH_HEAD
clang-tidy:
needs: should-run

View File

@@ -269,14 +269,23 @@ jobs:
env:
CONTEXT: image-context
IMAGE: ${{ matrix.target == 'voidstar' && 'xrpld-voidstar' || 'xrplf/xrpld' }}:${{ github.ref_type == 'tag' && github.ref_name || 'develop' }}
# Docker Hub is public, so a private build never reaches it;
# the Antithesis registry is not.
PUSH: ${{ inputs.publish && (matrix.target == 'voidstar' || github.event.repository.visibility == 'public') }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Determine release info
id: release_info
uses: ./.github/actions/release-info
# Docker Hub is public, so it only gets builds whose packages are public:
# those of a public codebase, and stable releases.
# The Antithesis registry is private, so it gets every build.
- name: Decide whether to push
env:
PUSH: ${{ inputs.publish && (matrix.target == 'voidstar' || github.event.repository.visibility == 'public' || steps.release_info.outputs.channel == 'stable') }}
run: echo "PUSH=${PUSH}" | tee -a "${GITHUB_ENV}"
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:

3
.gitignore vendored
View File

@@ -92,6 +92,9 @@ target/
# Direnv's directory
/.direnv
# Direnv's local, per-developer overrides
/.envrc.local
# clangd cache
/.cache

View File

@@ -22,6 +22,14 @@ API version 2 is available in `xrpld` version 2.0.0 and later. See [API-VERSION-
This version is supported by all `xrpld` versions. For WebSocket and HTTP JSON-RPC requests, it is currently the default API version used when no `api_version` is specified.
## XRP Ledger server version 3.5.0
Version 3.5.0 is not yet released.
### Additions in 3.5.0
- `subscribe`, `unsubscribe`: Added an optional `mpt_issuances` request field, an array of MPT issuance IDs (hex strings). Subscribers receive a message with `type` `mptTransaction` for each validated transaction whose metadata affects a subscribed issuance; the message has the same fields as the `transactions` stream. MPT issuance subscriptions count toward the per-connection subscription limit. An empty array, a non-array value, or an invalid ID returns `invalidParams`. ([#5671](https://github.com/XRPLF/rippled/pull/5671))
## XRP Ledger server version 3.4.0
Version 3.4.0 is not yet released. These changes are available in the 3.4.0 beta releases.

View File

@@ -41,15 +41,13 @@ branch.
git checkout develop
```
For a release candidate, choose the relevant release branch, e.g.
`release/3.2.x`.
For a release or release candidate, check out its [tag](https://github.com/XRPLF/rippled/releases), e.g.:
```bash
git checkout release/3.2.x
git checkout 3.4.0
```
For a stable release, choose one of the [tagged
releases](https://github.com/XRPLF/rippled/releases).
See [RELEASING.md](./RELEASING.md) for how branches and releases are organized.
A build reports `0.0.0-dev` with its short commit hash as build metadata, e.g.
`0.0.0-dev+0123abc`. Only builds of a release, from a tag in CI or a versioned

View File

@@ -14,9 +14,13 @@ The following branches exist in the main project repository:
- `develop`: The latest set of unreleased features, and the most common
starting point for contributions.
- `release/*` (e.g. `release/3.2.x`): Release branches, one per release line,
holding the latest release candidate, or stable release for that line.
Stable releases are published as [tagged releases](https://github.com/XRPLF/rippled/releases).
- `staging/*` (e.g. `staging/3.4.x`): Staging branches, one per release line,
where fixes for that line are developed.
- `release/*` (e.g. `release/3.4.x`): Release branches, one per release line,
holding the latest public release candidate or release for that line.
Releases are published as [tagged releases](https://github.com/XRPLF/rippled/releases).
See [RELEASING.md](./RELEASING.md) for how these branches are used.
The tip of each branch must be signed. In order for GitHub to sign a
squashed commit that it builds from your pull request, GitHub must know
@@ -145,8 +149,8 @@ tl;dr
In general, pull requests use `develop` as the base branch.
The exceptions are fixes, improvements, and hotfixes for an existing release,
which use that release's branch (e.g. `release/3.2.x`) as the base.
The exceptions are fixes for an existing release line,
which use that line's staging branch (e.g. `staging/3.4.x`) as the base.
If your changes are not quite ready, but you want to make it easily available
for preliminary examination or review, you can create a "Draft" pull request.
@@ -591,15 +595,16 @@ the suggested commit message, or modify it as needed.
#### Slightly more complicated pull requests
Some pull requests need to be pushed to `develop` as more than one
commit. A PR author may _request_ to merge as separate commits. They
Some pull requests need to be pushed to their base branch (usually `develop`)
as more than one commit.
A PR author may _request_ to merge as separate commits. They
must _justify_ why separate commits are needed, and _specify_ how they
would like the commits to be merged. If you disagree with the author,
discuss it with them directly.
If the process is reasonable, follow it. The simplest option is to do a
fast forward only merge (`--ff-only`) on the command line and push to
`develop`.
fast forward only merge (`--ff-only`) on the command line
and push to the base branch.
Some examples of when separate commits are worthwhile are:
@@ -612,9 +617,10 @@ Some examples of when separate commits are worthwhile are:
Either way, check that:
- The commits are based on the current tip of `develop`.
- The commits are clean: No merge commits (except when reverse
merging), no "[FOLD]" or "fixup!" messages.
- The commits are based on the current tip of the base branch.
- The commits are clean:
No merge commits (except when merging a release, see [RELEASING.md](./RELEASING.md)),
no "[FOLD]" or "fixup!" messages.
- All commits are signed. If the commits are not signed by the author, use
`git commit --amend -S` to sign them yourself.
- At least one (but preferably all) of the commits has the PR number
@@ -626,578 +632,8 @@ use them!**
### Releases
All releases, including release candidates and betas, are handled
differently from typical PRs. Most importantly, never use
the Github UI to merge a release.
Xrpld uses a linear workflow model that can be summarized as:
1. In between releases, developers work against the `develop` branch.
2. Periodically, a maintainer will build and tag a beta version from
`develop`, which is pushed to `release`.
- Betas are usually released every two to three weeks, though that
schedule can vary depending on progress, availability, and other
factors.
3. When the changes in `develop` are considered stable and mature enough
to be ready to release, a release candidate (RC) is built and tagged
from `develop`, and merged to `release`.
- Further development for that release (primarily fixes) then
continues against `release`, while other development continues on
`develop`. Effectively, `release` is forked from `develop`. Changes
to `release` must be reverse merged to `develop`.
4. When the candidate has passed testing and is ready for release, the
final release is merged to `master`.
5. If any issues are found post-release, a hotfix / point release may be
created, which is merged to `master`, and then reverse merged to
`develop`.
#### Betas, and the first release candidate
##### Preparing the `develop` branch
1. Optimally, the `develop` branch will be ready to go, with all
relevant PRs already merged.
2. If there are any PRs pending, merge them **BEFORE** preparing the beta.
1. If only one or two PRs need to be merged, merge those PRs [as
normal](#when-and-how-to-merge-pull-requests), updating the second
one, and waiting for CI to finish in between.
2. If there are several pending PRs, do not use the Github UI,
because the delays waiting for CI in between each merge will be
unnecessarily onerous. (Incidentally, this process can also be
used to merge if the Github UI has issues.) Merge each PR branch
directly to a `release-next` on your local machine and create a single
PR, then push your branch to `develop`.
1. Squash the changes from each PR, one commit each (unless more
are needed), being sure to sign each commit and update the
commit message to include the PR number. You may be able to use
a fast-forward merge for the first PR.
2. Push your branch.
3. Continue to [Making the release](#making-the-release) to update
the version number, etc.
The workflow may look something like:
```
git fetch --multiple upstreams user1 user2 user3 [...]
git checkout -B release-next --no-track upstream/develop
# Only do an ff-only merge if pr-branch1 is either already
# squashed, or needs to be merged with separate commits,
# and has no merge commits.
# Use -S on the ff-only merge if pr-branch1 isn't signed.
git merge [-S] --ff-only user1/pr-branch1
git merge --squash user2/pr-branch2
git commit -S # Use the commit message provided on the PR
git merge --squash user3/pr-branch3
git commit -S # Use the commit message provided on the PR
[...]
# Make sure the commits look right
git log --show-signature "upstream/develop..HEAD"
git push --set-upstream origin
# Continue to "Making the release" to update the version number, so
# everything can be done in one PR.
```
You can also use the [squash-branches] script.
You may also need to manually close the open PRs after the changes are
merged to `develop`. Be sure to include the commit ID.
##### Making the release
This includes, betas, and the first release candidate (RC).
1. If you didn't create one [preparing the `develop`
branch](#preparing-the-develop-branch), Ensure there is no old
`release-next` branch hanging around. Then make a `release-next`
branch that only changes the version number. e.g.
```
git fetch upstreams
git checkout --no-track -B release-next upstream/develop
v="A.B.C-bD"
build=$( find -name BuildInfo.cpp )
sed 's/\(^.*versionString =\).*$/\1 "'${v}'"/' ${build} > version.cpp && mv -vi version.cpp ${build}
git diff
git add ${build}
git commit -S -m "Set version to ${v}"
# You could use your "origin" repo, but some CI tests work better on upstream.
git push upstream-push
git fetch upstreams
git branch --set-upstream-to=upstream/release-next
```
You can also use the [update-version] script. 2. Create a Pull Request for `release-next` with **`develop`** as
the base branch.
1. Use the title "[TRIVIAL] Set version to X.X.X-bX".
2. Instead of the default description template, use the following:
```
## High Level Overview of Change
This PR only changes the version number. It will be merged as
soon as Github CI actions successfully complete.
```
3. Wait for CI to successfully complete, and get someone to approve
the PR. (It is safe to ignore known CI issues.)
4. Push the updated `develop` branch using your `release-next`
branch. **Do not use the Github UI. It's important to preserve
commit IDs.**
```
git push upstream-push release-next:develop
```
5. In the unlikely event that the push fails because someone has merged
something else in the meantime, rebase your branch onto the updated
`develop` branch, push again, and go back to step 3.
6. Ensure that your PR against `develop` is closed. Github should do it
automatically.
7. Once this is done, forward progress on `develop` can continue
(other PRs may be merged).
8. Now create a Pull Request for `release-next` with **`release`** as
the base branch. Instead of the default template, reuse and update
the message from the previous release. Include the following verbiage
somewhere in the description:
```
The base branch is `release`. [All releases (including
betas)](https://github.com/XRPLF/rippled/blob/develop/CONTRIBUTING.md#before-you-start)
go in `release`. This PR branch will be pushed directly to `release` (not
squashed or rebased, and not using the GitHub UI).
```
7. Sign-offs for the three platforms (Linux, Mac, Windows) usually occur
offline, but at least one approval will be needed on the PR.
- If issues are discovered during testing, simply abandon the
release. It's easy to start a new release, it should be easy to
abandon one. **DO NOT REUSE THE VERSION NUMBER.** e.g. If you
abandon 2.4.0-b1, the next attempt will be 2.4.0-b2.
8. Once everything is ready to go, push to `release`.
```
git fetch upstreams
# Just to be safe, do a dry run first:
git push --dry-run upstream-push release-next:release
# If everything looks right, push the branch
git push upstream-push release-next:release
# Check that all of the branches are updated
git fetch upstreams
git log -1 --oneline
# The output should look like:
# 0123456789 (HEAD -> upstream/release-next, upstream/release,
# upstream/develop) Set version to 2.4.0-b1
# Note that upstream/develop may not be on this commit, but
# upstream/release must be.
# Other branches, including some from upstream-push, may also be
# present.
```
9. Tag the release, too.
```
git tag <version number>
git push upstream-push <version number>
```
10. Delete the `release-next` branch on the repo. Use the Github UI or:
```
git push --delete upstream-push release-next
```
11. Finally [create a new release on
Github](https://github.com/XRPLF/rippled/releases).
#### Release candidates after the first
Once the first release candidate is [merged into
release](#making-the-release), then `release` and `develop` _are allowed
to diverge_.
If a bug or issue is discovered in a version that has a release
candidate being tested, any fix and new version will need to be applied
against `release`, then reverse-merged to `develop`. This helps keep git
history as linear as possible.
A `release-next` branch will be created from `release`, and any further
work for that release must be based on `release-next`. Specifically,
PRs must use `release-next` as the base, and those PRs will be merged
directly to `release-next` when approved. Changes should be restricted
to bug fixes, but other changes may be necessary from time to time.
1. Open any PRs for the pending release using `release-next` as the base,
so they can be merged directly in to it. Unlike `develop`, though,
`release-next` can be thrown away and recreated if necessary.
2. Once a new release candidate is ready, create a version commit as in
step 1 [above](#making-the-release) on `release-next`. You can use
the [update-version] script for this, too.
3. Jump to step 8 ("Now create a Pull Request for `release-next` with
**`release`** as the base") from the process
[above](#making-the-release) to merge `release-next` into `release`.
##### Follow up: reverse merge
Once the RC is merged and tagged, it needs to be reverse merged into
`develop` as soon as possible.
1. Create a branch, based on `upstream/develop`.
The branch name is not important, but could include "mergeNNNrcN".
E.g. For release A.B.C-rcD, use `mergeABCrcD`.
```
git fetch upstreams
git checkout --no-track -b mergeABCrcD upstream/develop
```
2. Merge `release` into your branch.
```
# I like the "--edit --log --verbose" parameters, but they are
# not required.
git merge upstream/release
```
3. `BuildInfo.cpp` will have a conflict with the version number.
Resolve it with the version from `develop` - the higher version.
4. Push your branch to your repo (or `upstream` if you have permission),
and open a normal PR against `develop`. The "High level overview" can
simply indicate that this is a merge of the RC. The "Context" should
summarize the changes from the RC. Include the following text
prominently:
```
This PR must be merged manually using a push. Do not use the Github UI.
```
5. Depending on the complexity of the changes, and/or merge conflicts,
the PR may need a thorough review, or just a sign-off that the
merge was done correctly.
6. If `develop` is updated before this PR is merged, do not merge
`develop` back into your branch. Instead rebase preserving merges,
or do the merge again. (See also the `rerere` git config setting.)
```
git rebase --rebase-merges upstream/develop
# OR
git reset --hard upstream/develop
git merge upstream/release
```
7. When the PR is ready, push it to `develop`.
```
git fetch upstreams
# Make sure the commits look right
git log --show-signature "upstream/develop^..HEAD"
git push upstream-push mergeABCrcD:develop
git fetch upstreams
```
Development on `develop` can proceed as normal.
#### Final releases
A final release is any release that is not a beta or RC, such as 2.2.0.
Only code that has already been tested and vetted across all three
platforms should be included in a final release. Most of the time, that
means that the commit immediately preceding the commit setting the
version number will be an RC. Occasionally, there may be last-minute bug
fixes included as well. If so, those bug fixes must have been tested
internally as if they were RCs (at minimum, ensuring unit tests pass,
and the app starts, syncs, and stops cleanly across all three
platforms.)
_If in doubt, make an RC first._
The process for building a final release is very similar to [the process
for building a beta](#making-the-release), except the code will be
moving from `release` to `master` instead of from `develop` to
`release`, and both branches will be pushed at the same time.
1. Ensure there is no old `master-next` branch hanging around.
Then make a `master-next` branch that only changes the version
number. As above, or using the
[update-version] script.
2. Create a Pull Request for `master-next` with **`master`** as
the base branch. Instead of the default template, reuse and update
the message from the previous final release. Include the following verbiage
somewhere in the description:
```
The base branch is `master`. This PR branch will be pushed directly to
`release` and `master` (not squashed or rebased, and not using the
GitHub UI).
```
7. Sign-offs for the three platforms (Linux, Mac, Windows) usually occur
offline, but at least one approval will be needed on the PR.
- If issues are discovered during testing, close the PR, delete
`master-next`, and move development back to `release`, [issuing
more RCs as necessary](#release-candidates-after-the-first)
8. Once everything is ready to go, push to `release` and `master`.
```
git fetch upstreams
# Just to be safe, do dry runs first:
git push --dry-run upstream-push master-next:release
git push --dry-run upstream-push master-next:master
# If everything looks right, push the branch
git push upstream-push master-next:release
git push upstream-push master-next:master
# Check that all of the branches are updated
git fetch upstreams
git log -1 --oneline
# The output should look like:
# 0123456789 (HEAD -> upstream/master-next, upstream/master,
# upstream/release) Set version to A.B.0
# Note that both upstream/release and upstream/master must be on this
# commit.
# Other branches, including some from upstream-push, may also be
# present.
```
9. Tag the release, too.
```
git tag <version number>
git push upstream-push <version number>
```
10. Delete the `master-next` branch on the repo. Use the Github UI or:
```
git push --delete upstream-push master-next
```
11. [Create a new release on
Github](https://github.com/XRPLF/rippled/releases). Be sure that
"Set as the latest release" is checked.
12. Open a PR to update the [API-CHANGELOG](API-CHANGELOG.md) and `API-VERSION-[n].md` with the changes for this release (if any are missing).
13. Finally, [reverse merge the release into `develop`](#follow-up-reverse-merge).
#### Special cases: point releases, hotfixes, etc.
On occasion, a bug or issue is discovered in a version that already
had a final release. Most of the time, development will have started
on the next version, and will usually have changes in `develop`
and often in `release`.
Because git history is kept as linear as possible, any fix and new
version will need to be applied against `master`.
The process for building a hotfix release is very similar to [the
process for building release candidates after the
first](#release-candidates-after-the-first) and [for building a final
release](#final-releases), except the changes will be done against
`master` instead of `release`.
If there is only a single issue for the hotfix, the work can be done in
any branch. When it's ready to merge, jump to step 3 using your branch
instead of `master-next`.
1. Create a `master-next` branch from `master`.
```
git checkout --no-track -b master-next upstream/master
git push upstream-push
git fetch upstreams
```
2. Open any PRs for the pending hotfix using `master-next` as the base,
so they can be merged directly in to it. Unlike `develop`, though,
`master-next` can be thrown away and recreated if necessary.
3. Once the hotfix is ready, create a version commit using the same
steps as above, or use the
[update-version] script.
4. Create a Pull Request for `master-next` with **`master`** as
the base branch. Instead of the default template, reuse and update
the message from the previous final release. Include the following verbiage
somewhere in the description:
```
The base branch is `master`. This PR branch will be pushed directly to
`master` (not squashed or rebased, and not using the GitHub UI).
```
7. Sign-offs for the three platforms (Linux, Mac, Windows) usually occur
offline, but at least one approval will be needed on the PR.
- If issues are discovered during testing, update `master-next` as
needed, but ensure that the changes are properly squashed, and the
version setting commit remains last
8. Once everything is ready to go, push to `master` **only**.
```
git fetch upstreams
# Just to be safe, do a dry run first:
git push --dry-run upstream-push master-next:master
# If everything looks right, push the branch
git push upstream-push master-next:master
# Check that all of the branches are updated
git fetch upstreams
git log -1 --oneline
# The output should look like:
# 0123456789 (HEAD -> upstream/master-next, upstream/master) Set version
# to 2.4.1
# Note that upstream/master must be on this commit. upstream/release and
# upstream/develop should not.
# Other branches, including some from upstream-push, may also be
# present.
```
9. Tag the release, too.
```
git tag <version number>
git push upstream-push <version number>
```
9. Delete the `master-next` branch on the repo.
```
git push --delete upstream-push master-next
```
10. [Create a new release on
Github](https://github.com/XRPLF/rippled/releases). Be sure that
"Set as the latest release" is checked.
Once the hotfix is released, it needs to be reverse merged into
`develop` as soon as possible. It may also need to be merged into
`release` if a release candidate is under development.
1. Create a branch in your own repo, based on `upstream/develop`.
The branch name is not important, but could include "mergeNNN".
E.g. For release 2.2.3, use `merge223`.
```
git fetch upstreams
git checkout --no-track -b merge223 upstream/develop
```
2. Merge master into your branch.
```
# I like the "--edit --log --verbose" parameters, but they are
# not required.
git merge upstream/master
```
3. `BuildInfo.cpp` will have a conflict with the version number.
Resolve it with the version from `develop` - the higher version.
4. Push your branch to your repo, and open a normal PR against
`develop`. The "High level overview" can simply indicate that this
is a merge of the hotfix version. The "Context" should summarize
the changes from the hotfix. Include the following text
prominently:
```
This PR must be merged manually using a --ff-only merge. Do not use the Github UI.
```
5. Depending on the complexity of the hotfix, and/or merge conflicts,
the PR may need a thorough review, or just a sign-off that the
merge was done correctly.
6. If `develop` is updated before this PR is merged, do not merge
`develop` back into your branch. Instead rebase preserving merges,
or do the merge again. (See also the `rerere` git config setting.)
```
git rebase --rebase-merges upstream/develop
# OR
git reset --hard upstream/develop
git merge upstream/master
```
7. When the PR is ready, push it to `develop`.
```
git fetch upstreams
# Make sure the commits look right
git log --show-signature "upstream/develop..HEAD"
git push upstream-push HEAD:develop
```
Development on `develop` can proceed as normal. It is recommended to
create a beta (or RC) immediately to ensure that everything worked as
expected.
##### An even rarer scenario: A hotfix on an old release
Historically, once a final release is tagged and packages are released,
versions older than the latest final release are no longer supported.
However, there is a possibility that a very high severity bug may occur
in a non-amendment blocked version that is still being run by
a significant fraction of users, which would necessitate a hotfix / point
release to that version as well as any later versions.
This scenario would follow the same basic procedure as above,
except that _none_ of `develop`, `release`, or `master`
would be touched during the release process.
In this example, consider if version 2.1.1 needed to be patched.
1. Create two branches in the main (`upstream`) repo.
```
git fetch upstreams
# Create a base branch off the tag
git checkout --no-track -b master-2.1.2 2.1.1
git push upstream-push
# Create a working branch
git checkout --no-track -b master212-next master-2.1.2
git push upstream-push
git fetch upstreams
```
2. Work continues as above, except using `master-2.1.2`as
the base branch for any merging, packaging, etc.
3. After the release is tagged and packages are built, you could
potentially delete both branches, e.g. `master-2.1.2` and
`master212-next`. However, it may be useful to keep `master-2.1.2`
around indefinitely for reference.
4. Assuming that a hotfix is also released for the latest
version in parallel with this one, or if the issue is
already fixed in the latest version, do no do any
reverse merges. However, if it is not, it probably makes
sense to reverse merge `master-2.1.2` into `master`,
release a hotfix for _that_ version, then reverse merge
from `master` to `develop`. (Please don't do this unless absolutely
necessary.)
Releases, release branches, and merging releases back into `develop`
are described in [RELEASING.md](./RELEASING.md).
[contrib]: https://docs.github.com/en/get-started/quickstart/contributing-to-projects
[squash]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-pull-request-merges#squash-and-merge-your-commits
@@ -1205,5 +641,3 @@ git fetch upstreams
[xrpld]: https://github.com/XRPLF/rippled
[signing]: https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification
[setup-upstreams]: ./bin/git/setup-upstreams.sh
[squash-branches]: ./bin/git/squash-branches.sh
[update-version]: ./bin/git/update-version.sh

152
RELEASING.md Normal file
View File

@@ -0,0 +1,152 @@
# Branching and Release Management
This document describes how we branch, release, and merge releases back into `develop`.
It does not define version naming
(e.g., what constitutes a major-minor, patch, or beta release).
Examples use `X.Y` for a release line:
`X` and `Y` are placeholders, while the trailing `x` is literal,
e.g., `release/X.Y.x` is `release/3.4.x` for the `3.4` line.
## Branches
| Branch | Purpose |
| :-------------- | :---------------------------------------------------------------------------------------- |
| `develop` | Main development branch. Betas and the first RC of a major-minor release are tagged here. |
| `staging/X.Y.x` | Where fixes for the `X.Y` line are developed and RCs are prepared. |
| `release/X.Y.x` | The last public RC or release of the `X.Y` line. |
A release line is named `X.Y.x`
because one branch serves every patch release of that line (`X.Y.0`, `X.Y.1`, `X.Y.2`, ...).
The `/` groups branches hierarchically,
so tools can filter them and protection rules can target `release/*` and `staging/*`.
## Principles
- **Releases are merged back into `develop`, never cherry-picked or rebased onto it.**
Cherry-picked and rebased commits get new hashes,
so Git can't tell that `develop` already has them.
Future merges then replay them and produce artificial conflicts,
and it's hard to verify that every fix actually reached `develop`.
Merging keeps a single history:
Git knows exactly which release commits `develop` contains,
and no fix is left behind.
- **Branches only move forward.**
`develop`, `staging/X.Y.x`, and `release/X.Y.x` are never rewritten.
- **Cherry-picking only goes from `develop` to a staging branch**,
for fixes that must get into a release after the code freeze
(see [Emergency Fixes From `develop`](#emergency-fixes-from-develop)).
- **Security fixes are prepared privately and published with the release that contains them**,
so vulnerabilities are not disclosed prematurely.
## Release Lifecycle
This diagram shows a major-minor release and its first patch release.
The staging and release branches are drawn as one line.
```text
develop staging/X.Y.x & release/X.Y.x
│
├── Tag: X.Y.0-b1
├── Tag: X.Y.0-bN
├── Tag: X.Y.0-rc1 ───────────────┐ (branches created)
│ │
│ (development continues) ├── Fixes
│ ├── Tag: X.Y.0-rcN
│ ├── Tag: X.Y.0 (final)
◀──── (merge) ────────────────────┤
│ ├── Fixes
│ ├── Tag: X.Y.1-rcN
│ ├── Tag: X.Y.1 (final)
◀──── (merge) ────────────────────┤
│
▼
```
### Betas, First RC & Branching
> [!NOTE]
> This phase applies only to a new major-minor release (e.g., `X.Y.0`).
> Patch releases work on the existing branches of the line.
1. **Betas:** All beta versions (e.g., `X.Y.0-b1`) are built and tagged directly on `develop`.
2. **First RC:** We release the first RC (`X.Y.0-rc1`)
once everything that should be included in the release has been merged.
3. **Branches:** `staging/X.Y.x` and `release/X.Y.x` are created from `develop`
at the commit tagged `X.Y.0-rc1`.
The first RC also kicks off the QE process.
4. **Code freeze:** No new features or unrelated changes are pulled from `develop`
into `staging/X.Y.x` or `release/X.Y.x`.
Only critical stabilization fixes go into the line.
5. **No large changes on `develop`:** Until `X.Y.0` is [merged back](#merging-back-into-develop),
large changes (e.g., big refactors, moving or renaming many files, mass reformatting)
are not merged into `develop`,
so that fixes on the line and the merge back don't run into conflicts.
### Release Candidates
1. Fixes are developed against `staging/X.Y.x`.
2. When ready, a new RC is created on `staging/X.Y.x`,
and `release/X.Y.x` is fast-forwarded to it.
RCs that contain unpublished security fixes are not published,
and don't touch the public branches.
RCs are not merged back into `develop`:
`X.Y.0-rc1` is tagged on `develop` itself,
and all later changes reach `develop` with the [final release](#final-release).
### Final Release
Security fixes become public as soon as they reach the public repo,
so these steps happen only once the release is ready to be published,
one right after the other.
1. Unpublished security fixes, if any, are merged into `staging/X.Y.x`.
2. `release/X.Y.x` is fast-forwarded to `staging/X.Y.x`,
and the release is tagged on it.
3. The release is immediately [merged back into `develop`](#merging-back-into-develop).
For a major-minor release, this lifts the freeze on large changes in `develop`.
### Merging Back Into `develop`
The merge back is a regular PR into `develop`
whose branch contains a real merge commit of the release tag:
1. Create a branch from `develop`, run `git merge --no-ff <tag>`, and resolve any conflicts.
2. Once the PR is approved, `develop` is fast-forwarded to the PR branch,
so the merge commit lands as it is.
Never squash or rebase it.
Never add it to the merge queue either: the queue squashes PRs, which would drop the merge commit.
3. If `develop` moves while the PR is open, redo the merge on top of the new `develop`.
After the merge, `git log develop..<tag>` must be empty.
## Special Cases
### Emergency Fixes From `develop`
If a commit was merged to `develop`
and needs to be included in a release after the code freeze:
1. Create a PR that cherry-picks the commit onto `staging/X.Y.x`.
2. Leave `develop` as it is, with no reverts.
3. Follow the [release candidates](#release-candidates) process as usual.
When the release is later merged back into `develop`,
both sides already contain the same change,
so the merge usually resolves it cleanly or with a trivial conflict.
From Git's perspective, the cherry-picked commit then becomes part of `develop` too.
### Several Supported Lines
When a fix must ship in more than one supported line (e.g., `X.Y` and `X.(Y+1)`),
the lines are merged upwards rather than cherry-picked between:
1. The fix goes into the oldest line first.
2. After that line's release,
its `release/X.Y.x` is merged into the staging branch of the next newer line,
and so on up to the newest line.
3. The newest line is merged back into `develop` as usual.
This way every newer line, and eventually `develop`, contains the history of the older lines.

View File

@@ -24,9 +24,13 @@
# `g++-15`, ...) are probed under both names: a suffixed name can break while
# the plain one still works (see mkVersionedToolLinks in nix/packages.nix).
#
# Tools scoped to a single dev shell rather than to commonPackages are checked
# only in that shell, keyed off XRPL_DEVSHELL.
#
# Environment variables:
# CI if set, skip the tools above when on macOS.
# CHECK_TOOLS_SKIP_CLONE if set, skip the git-over-HTTPS connectivity check.
# XRPL_DEVSHELL active dev shell; selects shell-specific tools.
set -uo pipefail
@@ -163,6 +167,14 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then
check rustfmt
fi
# Lean4 is in the formal-verification shell only, not in commonPackages.
if [ "${XRPL_DEVSHELL:-}" = "formal-verification" ]; then
echo
echo "Formal verification toolchain:"
check lean
check lake
fi
# GCC is the default compiler on Linux. macOS uses the system Apple Clang
# instead, so GCC/g++/gcov are not expected there.
if [ "${os}" = "linux" ]; then

View File

@@ -95,10 +95,6 @@ public:
private:
beast::Journal mutable journal_;
std::mutex mutable mutex_;
// Initialized to nullptr for safety. Set by load() during the second
// phase of ApplicationImp initialization. Methods that dereference
// this pointer must validate it first, since two-phase init means
// load() may not have been called yet.
DatabaseCon* connection_{};
std::unordered_set<PeerReservation, beast::Uhash<>, KeyEqual> table_;
};

View File

@@ -12,10 +12,12 @@
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/UintTypes.h>
#include <boost/container/flat_set.hpp>
#include <cstdint>
#include <flat_set>
#include <optional>
namespace xrpl {
@@ -115,6 +117,9 @@ public:
parentBatchID_ = id;
}
[[nodiscard]] std::flat_set<MPTID>
getAffectedMPTs() const;
private:
UInt256 transactionID_;
std::uint32_t ledgerSeq_;

View File

@@ -411,6 +411,7 @@ JSS(minimum_level); // out: TxQ
JSS(missingCommand); // error
JSS(mpt_amount); // out: mpt_holders
JSS(mpt_issuance_id); // in: Payment, mpt_holders
JSS(mpt_issuances); // in: Subscribe, Unsubscribe
JSS(mptoken_index); // out: mpt_holders
JSS(mpt_issuance_id_a); // out: BookChanges
JSS(mpt_issuance_id_b); // out: BookChanges

View File

@@ -7,6 +7,7 @@
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Book.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/resource/Consumer.h>
#include <xrpl/server/Manifest.h>
@@ -26,11 +27,11 @@ namespace xrpl {
/**
* Maximum number of subscriptions a single client connection may hold at once.
*
* Applies to the account, real-time account, and account-history subscriptions
* tracked on one InfoSub (the sets counted by totalSubscriptionCount), bounding
* the disconnect-time cleanup of those sets. Book subscriptions are tracked
* separately (OrderBookDB) and are not counted here. Generous enough for
* legitimate power users such as block explorers.
* Applies to the account, real-time account, account-history, and MPT issuance
* subscriptions tracked on one InfoSub (the sets counted by
* totalSubscriptionCount), bounding the disconnect-time cleanup of those sets.
* Book subscriptions are tracked separately (OrderBookDB) and are not counted
* here. Generous enough for legitimate power users such as block explorers.
*/
constexpr std::size_t kMaxSubscriptionsPerConnection = 100'000;
@@ -151,6 +152,21 @@ public:
AccountID const& account,
bool historyOnly) = 0;
/**
* Remove an MPT issuance subscription during InfoSub teardown.
*
* Removes only the server-side entry from subMPT_. Does NOT touch
* InfoSub::mptSubscriptions_ because the InfoSub is being destroyed.
* Called by ~InfoSub() for each issuance in mptSubscriptions_.
*
* @param uListener The sequence number of the subscriber being torn down.
* @param mptID The MPT issuance to remove.
*
* @note Thread-safety: acquires mptLock_ internally.
*/
virtual void
unsubMPTInternal(std::uint64_t uListener, MPTID const& mptID) = 0;
/**
* Schedule the server-side teardown of a disconnecting connection's
* account subscriptions off the destructor thread.
@@ -270,6 +286,11 @@ public:
virtual bool
unsubConsensus(std::uint64_t uListener) = 0;
virtual void
subMPT(InfoSub::Ref ispListener, HashSet<MPTID> const& mptIDs) = 0;
virtual void
unsubMPT(InfoSub::Ref ispListener, HashSet<MPTID> const& mptIDs) = 0;
// VFALCO TODO Remove
// This was added for one particular partner, it
// "pushes" subscription data to a particular URL.
@@ -309,9 +330,9 @@ public:
* Return the number of subscriptions currently tracked on this
* connection.
*
* The combined size of the per-connection account, real-time account, and
* account-history subscription sets. `doSubscribe` reads this to enforce
* the per-connection subscription cap before admitting more.
* The combined size of the per-connection account, real-time account,
* account-history, and MPT issuance subscription sets. `doSubscribe` reads
* this to enforce the per-connection subscription cap before admitting more.
*
* @return The total tracked subscription count for this connection.
*
@@ -342,6 +363,25 @@ public:
HashSet<AccountID> const& normalAccounts,
std::size_t cap);
/**
* Enforce the cap and reserve a request's net-new MPT issuances, atomically.
*
* The MPT analogue of tryReserveAccountSubscriptions: under one hold of
* `lock_`, count the net-new issuances, check the total against @p cap, and
* insert them only if it fits. All-or-nothing, so a rejected request records
* nothing. Doing check and insert together stops two concurrent requests
* sharing an InfoSub (the admin subscribe-by-url path) from both passing the
* check before either records its issuances. The server-side map is
* populated afterwards by subMPT, whose re-insert is a no-op.
*
* @param mptIDs The MPT issuance ids to reserve.
* @param cap The effective per-connection cap.
* @return true if reserved; false if the request must be rejected.
* @note Thread-safe: takes `lock_`.
*/
[[nodiscard]] bool
tryReserveMPTSubscriptions(HashSet<MPTID> const& mptIDs, std::size_t cap);
/**
* Whether this connection already tracks an account-history for @p account.
*
@@ -413,12 +453,29 @@ public:
[[nodiscard]] unsigned int
getApiVersion() const noexcept;
void
insertSubMPTInfo(MPTID const& mptID);
void
deleteSubMPTInfo(MPTID const& mptID);
protected:
// Mutable so the read-only totalSubscriptionCount() accessor can lock it
// from a const method; locking semantics are otherwise unchanged.
mutable std::mutex lock_;
private:
// The lock type guarding this instance's subscription sets.
using ScopedLock = std::scoped_lock<decltype(lock_)>;
/**
* The combined tally the per-connection cap is enforced against.
*
* @param lock Proof that `lock_` is held; unused otherwise.
*/
[[nodiscard]] std::size_t
subscriptionCount(ScopedLock const& lock) const;
Consumer consumer_;
Source& source_;
HashSet<AccountID> realTimeSubscriptions_;
@@ -427,6 +484,7 @@ private:
std::uint64_t seq_;
HashSet<AccountID> accountHistorySubscriptions_;
HashSet<Book> bookSubscriptions_;
HashSet<MPTID> mptSubscriptions_;
unsigned int apiVersion_ = 0;
static int

View File

@@ -26,6 +26,10 @@ The store paths carry their derivation hash, so they change whenever a tool is
rebuilt — a `flake.lock` update generally rewrites most of them even when no
version moves. That is deliberate: it makes tooling changes visible in review.
Tools scoped to a single dev shell rather than to `commonPackages` do not appear
here: `check-tools.sh` keys those off `XRPL_DEVSHELL`, and none of the three
environments above is such a shell. Adding to one needs no snapshot update.
## Regenerating
The two Linux snapshots come from the `nix-ubuntu` image (Docker or a compatible

View File

@@ -147,6 +147,19 @@ rec {
versionedTools = clangVersionedTools;
};
# The gcc shell plus the Lean4 formal verification toolchain
formal-verification = makeShell {
shellName = "formal-verification";
stdenv = customGccStdenv;
compilerName = "gcc";
version = gccVersion;
versionedTools = gccVersionedTools;
extraPackages = [
customGccGcov
pkgs.lean4
];
};
# Nix provides no compiler; use the one from your system (e.g. Apple Clang).
no-compiler = makeShell {
shellName = "no-compiler";

View File

@@ -217,12 +217,13 @@ The `release-info` action decides the channel from the event, and
| tag | `X.Y.Z-bN` | `beta` | `deb-beta` | `rpm-beta-hosted` |
| tag, any other | `xrpld --version` | `custom` | `deb-custom` | `rpm-custom-hosted` |
| push to `develop` | `0.0.0-dev+<hash>` | `develop` | `deb-develop` | `rpm-develop-hosted` |
| tag, non-public codebase | _any_ | `private` | `deb-private` | `rpm-private-hosted` |
| tag, non-public codebase | _any but `X.Y.Z`_ | `private` | `deb-private` | `rpm-private-hosted` |
A variant is published to the same channel under its own name, so
`xrpld-assert` never overwrites `xrpld`.
Only a tag picks a release channel. Versions sort in row order, so moving to a
Only a tag picks a release channel. A final release, `X.Y.Z`, goes to `stable`
even from a non-public codebase. Versions sort in row order, so moving to a
more mature channel never downgrades. A tag matching none of the release
patterns, such as `X.Y.Z-hotfix1`, publishes to `custom`, which sits outside
that order.
@@ -284,8 +285,9 @@ checks that the server starts in each image. A tag's images are tagged with the
tag name, a develop image as `develop`. With `publish: true`:
- `xrpld` is pushed to `xrplf/xrpld` on Docker Hub using the `DOCKERHUB_TOKEN`
secret, an organization access token for `xrplf`. Private builds are never
pushed there.
secret, an organization access token for `xrplf`. Builds of a non-public
codebase are pushed there only for `stable` releases, whose packages are
public too.
- `voidstar` replaces `/usr/bin/xrpld` with the binary of the `voidstar` build
config, adds `libvoidstar.so` and links the binary into `/symbols`, as
Antithesis expects. It is pushed as `xrpld-voidstar` to the Antithesis

View File

@@ -6,6 +6,8 @@
#include <xrpl/basics/contract.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAccount.h>
#include <xrpl/protocol/STAmount.h>
@@ -13,10 +15,12 @@
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/UintTypes.h>
#include <boost/container/flat_set.hpp>
#include <cstdint>
#include <flat_set>
#include <limits>
#include <stdexcept>
@@ -145,6 +149,52 @@ TxMeta::getAffectedAccounts() const
return list;
}
std::flat_set<MPTID>
TxMeta::getAffectedMPTs() const
{
std::flat_set<MPTID> list;
for (auto const& it : nodes_)
{
int const index =
it.getFieldIndex((it.getFName() == sfCreatedNode) ? sfNewFields : sfFinalFields);
if (index != -1)
{
auto inner = dynamic_cast<STObject const*>(&it.peekAtIndex(index));
XRPL_ASSERT(inner, "xrpl::getAffectedMPTs : STObject type cast succeeded");
if (inner != nullptr)
{
// An MPTokenIssuance entry does not store its own issuance id;
// the id is derived from the issuer and the sequence that
// created the issuance.
if (it.getFieldU16(sfLedgerEntryType) == ltMPTOKEN_ISSUANCE)
{
list.insert(
makeMptID(inner->getFieldU32(sfSequence), inner->getAccountID(sfIssuer)));
}
for (auto const& field : *inner)
{
if (auto mptID = dynamic_cast<STBitString<192> const*>(&field);
field.getFName() == sfMPTokenIssuanceID && (mptID != nullptr))
{
list.insert(mptID->value());
}
else if (
auto amount = dynamic_cast<STAmount const*>(&field);
(amount != nullptr) && amount->holds<MPTIssue>())
{
list.insert(amount->get<MPTIssue>().getMptID());
}
}
}
}
}
return list;
}
STObject&
TxMeta::getAffectedNode(SLE::Ref node, SField const& type)
{

View File

@@ -1,10 +1,12 @@
#include <xrpl/server/InfoSub.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/UnorderedContainers.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Book.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/resource/Consumer.h>
#include <cstddef>
@@ -42,6 +44,22 @@ safeUnsub(std::uint64_t seq, F&& f, beast::Journal j) noexcept
}
}
// Number of requested entries not already tracked in `existing`. Only these are
// charged against the cap, so re-subscribing entries a connection already holds
// is free.
template <typename T>
[[nodiscard]] std::size_t
countNew(HashSet<T> const& requested, HashSet<T> const& existing)
{
std::size_t fresh = 0;
for (auto const& entry : requested)
{
if (!existing.contains(entry))
++fresh;
}
return fresh;
}
} // namespace
// This is the primary interface into the "client" portion of the program.
@@ -84,6 +102,16 @@ InfoSub::~InfoSub()
safeUnsub(seq_, [&] { source_.unsubPeerStatus(seq_); }, j);
safeUnsub(seq_, [&] { source_.unsubConsensus(seq_); }, j);
// MPT subscriptions are torn down inline here, keyed on seq_, like books
// below. The set is capped; each unsubMPTInternal takes mptLock_ for a
// single O(1) erase and releases it, so a competing MPT publish can
// interleave between erases. Use the internal variant so it does not write
// back to mptSubscriptions_ on this partially-destroyed object.
for (auto const& mptID : mptSubscriptions_)
{
safeUnsub(seq_, [&] { source_.unsubMPTInternal(seq_, mptID); }, j);
}
// Book subscriptions are torn down inline here, keyed on seq_, rather than
// through the chunked account cleanup below. The book set is not capped, so
// it can be large; but each unsubBookInternal takes bookLock_ for a single
@@ -145,16 +173,21 @@ InfoSub::onSendEmpty()
{
}
std::size_t
InfoSub::subscriptionCount(ScopedLock const&) const
{
return normalSubscriptions_.size() + realTimeSubscriptions_.size() +
accountHistorySubscriptions_.size() + mptSubscriptions_.size();
}
std::size_t
InfoSub::totalSubscriptionCount() const
{
// Hold lock_ for the whole read so the three sets cannot be mutated
// Hold lock_ for the whole read so the counted sets cannot be mutated
// mid-count by a concurrent (un)subscribe on this connection.
std::scoped_lock const sl(lock_);
// Combined tally the per-connection cap is enforced against.
return normalSubscriptions_.size() + realTimeSubscriptions_.size() +
accountHistorySubscriptions_.size();
return subscriptionCount(sl);
}
bool
@@ -166,25 +199,10 @@ InfoSub::tryReserveAccountSubscriptions(
// One lock hold covers the count, the check and the insert.
std::scoped_lock const sl(lock_);
// Entries not already tracked; re-subscribing held accounts is not charged.
auto const countNew = [](HashSet<AccountID> const& requested,
HashSet<AccountID> const& existing) {
std::size_t fresh = 0;
for (auto const& account : requested)
{
if (!existing.contains(account))
++fresh;
}
return fresh;
};
std::size_t const additional = countNew(proposedAccounts, realTimeSubscriptions_) +
countNew(normalAccounts, normalSubscriptions_);
std::size_t const current = normalSubscriptions_.size() + realTimeSubscriptions_.size() +
accountHistorySubscriptions_.size();
if (exceedsSubscriptionCap(current, additional, cap))
if (exceedsSubscriptionCap(subscriptionCount(sl), additional, cap))
return false;
realTimeSubscriptions_.insert(proposedAccounts.begin(), proposedAccounts.end());
@@ -192,6 +210,19 @@ InfoSub::tryReserveAccountSubscriptions(
return true;
}
bool
InfoSub::tryReserveMPTSubscriptions(HashSet<MPTID> const& mptIDs, std::size_t cap)
{
// One lock hold covers the count, the check and the insert.
std::scoped_lock const sl(lock_);
if (exceedsSubscriptionCap(subscriptionCount(sl), countNew(mptIDs, mptSubscriptions_), cap))
return false;
mptSubscriptions_.insert(mptIDs.begin(), mptIDs.end());
return true;
}
void
InfoSub::insertSubAccountInfo(AccountID const& account, bool rt)
{
@@ -288,4 +319,20 @@ InfoSub::getApiVersion() const noexcept
return apiVersion_;
}
void
InfoSub::insertSubMPTInfo(MPTID const& mptID)
{
std::scoped_lock const sl(lock_);
mptSubscriptions_.insert(mptID);
}
void
InfoSub::deleteSubMPTInfo(MPTID const& mptID)
{
std::scoped_lock const sl(lock_);
mptSubscriptions_.erase(mptID);
}
} // namespace xrpl

View File

@@ -122,6 +122,83 @@ backend=sqlite
return std::format(kConfigContentsTemplate, dbPathSection, valFileSection);
}
// setenv and unsetenv are POSIX only, and MSVC does not have them.
#ifndef _MSC_VER
/**
* Read an environment variable into a value the caller owns.
*
* The value is copied, because POSIX allows a later `setenv` to release the
* string `getenv` returned.
*
* @param name Name of the environment variable to read.
* @return The value, or std::nullopt when the variable is not set.
*/
[[nodiscard]] std::optional<std::string>
envVar(char const* name)
{
if (char const* const value = std::getenv(name); value != nullptr)
return std::string{value};
return std::nullopt;
}
/**
* Set an environment variable and restore its previous value when done.
*/
class EnvVarGuard
{
private:
std::string const name_;
std::optional<std::string> const saved_;
/**
* Give an environment variable a value, or remove it.
*
* @param name Name of the environment variable to write.
* @param value Value to set, or std::nullopt to remove the variable.
*/
static void
apply(char const* name, std::optional<std::string> const& value) noexcept
{
if (value)
{
setenv(name, value->c_str(), 1);
}
else
{
unsetenv(name);
}
}
public:
/**
* Save the variable's current value, then write the given one.
*
* @param name Name of the environment variable to set.
* @param value Value to set, or std::nullopt to remove the variable.
*/
[[nodiscard]] EnvVarGuard(char const* name, std::optional<std::string> const& value)
: name_(name), saved_(envVar(name))
{
apply(name_.c_str(), value);
}
EnvVarGuard(EnvVarGuard const&) = delete;
EnvVarGuard&
operator=(EnvVarGuard const&) = delete;
/**
* Restore the value the variable held before construction.
*/
~EnvVarGuard()
{
apply(name_.c_str(), saved_);
}
};
#endif // _MSC_VER
/**
* Write an xrpld config file and remove when done.
*/
@@ -335,7 +412,11 @@ port_wss_admin
}
// Config file in HOME or XDG_CONFIG_HOME directory.
#if BOOST_OS_LINUX || BOOST_OS_MACOS
#ifndef _MSC_VER
// Save the values the guards below must put back.
auto const home = detail::envVar("HOME");
auto const xdgConfigHome = detail::envVar("XDG_CONFIG_HOME");
for (auto const& configFile : configFiles)
{
// Point the current working directory to a temporary directory, so
@@ -348,13 +429,11 @@ port_wss_admin
{
TempDir const tc;
// Set the HOME and XDG_CONFIG_HOME environment variables. The
// HOME variable is not used when XDG_CONFIG_HOME is set, but
// must be set.
char const* h = getenv("HOME");
setenv("HOME", tc.path().c_str(), 1);
char const* x = getenv("XDG_CONFIG_HOME");
setenv("XDG_CONFIG_HOME", tc.path().c_str(), 1);
// The HOME variable is not used when XDG_CONFIG_HOME is set,
// but must be set. Both guards are declared after tc, so they
// restore before it is removed.
detail::EnvVarGuard const homeGuard("HOME", tc.path());
detail::EnvVarGuard const xdgGuard("XDG_CONFIG_HOME", tc.path());
// Create the config file in '${XDG_CONFIG_HOME}/[systemName]'.
Path p = tc.file(systemName());
@@ -371,22 +450,21 @@ port_wss_admin
BEAST_EXPECT(
c.section(Sections::kDebugLogfile).values()[0] ==
"/Users/dummy/xrpld/config/log/debug.log");
// Restore the environment variables.
(h != nullptr) ? setenv("HOME", h, 1) : unsetenv("HOME");
(x != nullptr) ? setenv("XDG_CONFIG_HOME", x, 1) : unsetenv("XDG_CONFIG_HOME");
}
// Both guards are gone: both variables must hold their old values.
BEAST_EXPECT(detail::envVar("HOME") == home);
BEAST_EXPECT(detail::envVar("XDG_CONFIG_HOME") == xdgConfigHome);
// The XDG config directory is not set: the config file must be in a
// subdirectory named .config followed by the system name.
{
TempDir const tc;
// Set only the HOME environment variable.
char const* h = getenv("HOME");
setenv("HOME", tc.path().c_str(), 1);
char const* x = getenv("XDG_CONFIG_HOME");
unsetenv("XDG_CONFIG_HOME");
// Both guards are declared after tc, so they restore before it
// is removed.
detail::EnvVarGuard const homeGuard("HOME", tc.path());
detail::EnvVarGuard const xdgGuard("XDG_CONFIG_HOME", std::nullopt);
// Create the config file in '${HOME}/.config/[systemName]'.
std::string s = ".config";
@@ -407,14 +485,13 @@ port_wss_admin
BEAST_EXPECT(
c.section(Sections::kDebugLogfile).values()[0] ==
"/Users/dummy/xrpld/config/log/debug.log");
// Restore the environment variables.
(h != nullptr) ? setenv("HOME", h, 1) : unsetenv("HOME");
if (x != nullptr)
setenv("XDG_CONFIG_HOME", x, 1);
}
// Both guards are gone: both variables must hold their old values.
BEAST_EXPECT(detail::envVar("HOME") == home);
BEAST_EXPECT(detail::envVar("XDG_CONFIG_HOME") == xdgConfigHome);
}
#endif
#endif // _MSC_VER
// Restore the current working directory.
current_path(cwd);

View File

@@ -3,6 +3,7 @@
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/credentials.h>
#include <test/jtx/deposit.h>
#include <test/jtx/escrow.h>
#include <test/jtx/flags.h>
@@ -32,6 +33,7 @@
#include <cstddef>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace xrpl::rpc {
@@ -517,6 +519,71 @@ public:
rpc::makeError(RpcInvalidParams)[jss::error_message]);
}
void
testMarkerNewObjectTypes()
{
// Regression: pagination across account_lines / account_offers /
// account_channels must not reject a marker whose SLE is a
// Credential (or any post-2023 owner-directory entry).
testcase("Marker on new owner-directory entry types (Credential)");
using namespace test::jtx;
// Walk the owner directory via `rpcMethod` at limit=1 and return
// the total number of paged calls plus whether the RPC ever
// returned "invalidParams" during pagination.
auto walkPages = [](Env& env, Account const& account, char const* rpcMethod) {
std::optional<std::string> marker;
int iterations = 0;
bool hitInvalidParams = false;
for (int guard = 0; guard < 20; ++guard)
{
json::Value params;
params[jss::account] = account.human();
params[jss::limit] = 1;
if (marker)
params[jss::marker] = *marker;
auto const resp = env.rpc("json", rpcMethod, to_string(params))[jss::result];
++iterations;
if (resp.isMember(jss::error))
{
hitInvalidParams = resp[jss::error].asString() == "invalidParams";
break;
}
if (!resp.isMember(jss::marker))
break;
marker = resp[jss::marker].asString();
}
return std::pair{iterations, hitInvalidParams};
};
for (char const* rpcMethod : {"account_lines", "account_offers", "account_channels"})
{
Env env(*this);
Account const alice{"alice"};
Account const issuer{"issuer"};
env.fund(XRP(10000), alice, issuer);
env.close();
auto const usd = issuer["USD"];
env(trust(alice, usd(200)));
for (int i = 0; i < 4; ++i)
{
env(credentials::create(alice, issuer, std::string("Cred") + std::to_string(i)));
}
env.close();
auto const [iterations, hitInvalidParams] = walkPages(env, alice, rpcMethod);
BEAST_EXPECTS(!hitInvalidParams, rpcMethod);
// 1 trust line + 4 credentials — pagination walks all owner-dir
// entries regardless of which the RPC includes in its results.
BEAST_EXPECTS(
iterations == 5, std::string(rpcMethod) + ": " + std::to_string(iterations));
}
}
void
testAccountLinesWalkMarkers()
{
@@ -625,8 +692,8 @@ public:
env(ticket::create(alice, 2));
// Add another trustline for good measure
auto const btCbecky = becky["BTC"];
env(trust(alice, btCbecky(200)));
auto const btcBecky = becky["BTC"];
env(trust(alice, btcBecky(200)));
env.close();
@@ -1325,6 +1392,7 @@ public:
testAccountLines();
testAccountLinesMarker();
testAccountLineDelete();
testMarkerNewObjectTypes();
testAccountLinesWalkMarkers();
testAccountLines2();
testAccountLineDelete2();

View File

@@ -5,8 +5,12 @@
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/jss.h>
#include <set>
#include <string>
namespace xrpl::test {
class RPCHelpers_test : public beast::unit_test::Suite
@@ -66,10 +70,40 @@ public:
BEAST_EXPECT(result.second == 0);
}
void
testOwnerDirNodeFieldsCoverage()
{
// Every UINT64 sf*Node field must be classified: either it is an
// owner-directory page hint (rpc::isOwnerDirNodeField()) or it
// belongs to a book directory (exclusion set below). Adding a new
// sf*Node UINT64 without updating one of these lists trips this
// test.
testcase("Owner-directory Node fields coverage");
// Book-directory page hints, not owner-directory. sfBookNode: offer
// book. sfNFTokenOfferNode: NFT bid/ask book.
std::set<SField const*> const nonOwnerDirNodes{
&sfBookNode,
&sfNFTokenOfferNode,
};
for (auto const& [_, sf] : SField::getKnownCodeToField())
{
if (sf->fieldType != STI_UINT64 || !sf->getName().ends_with("Node"))
continue;
BEAST_EXPECTS(
rpc::isOwnerDirNodeField(*sf) || nonOwnerDirNodes.contains(sf),
"sf" + sf->getName() +
": add to kOwnerDirNodeFields (owner-dir page hint) "
"or to nonOwnerDirNodes (book-dir).");
}
}
void
run() override
{
testChooseLedgerEntryType();
testOwnerDirNodeFieldsCoverage();
}
};

View File

@@ -1,9 +1,12 @@
#include <test/jtx/Env.h>
#include <test/jtx/WSClient.h>
#include <test/jtx/amount.h>
#include <test/jtx/batch.h>
#include <test/jtx/domain.h>
#include <test/jtx/envconfig.h>
#include <test/jtx/escrow.h>
#include <test/jtx/fee.h>
#include <test/jtx/mpt.h>
#include <test/jtx/offer.h>
#include <test/jtx/owners.h> // IWYU pragma: keep
#include <test/jtx/paths.h>
@@ -32,11 +35,14 @@
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STValidation.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/protocol/tokens.h>
#include <xrpl/server/LoadFeeTrack.h>
@@ -647,6 +653,42 @@ public:
}
}
for (auto const& nonArray : nonArrays)
{
json::Value jv;
jv[jss::mpt_issuances] = nonArray;
auto jr = wsc->invoke(method, jv)[jss::result];
BEAST_EXPECT(jr[jss::error] == "invalidParams");
BEAST_EXPECT(jr[jss::error_message] == "Invalid parameters.");
}
{
json::Value jv;
jv[jss::mpt_issuances] = json::ValueType::Array;
jv[jss::mpt_issuances][0u] = 1;
auto jr = wsc->invoke(method, jv)[jss::result];
BEAST_EXPECT(jr[jss::error] == "invalidParams");
BEAST_EXPECT(jr[jss::error_message] == "Invalid parameters.");
}
{
json::Value jv;
jv[jss::mpt_issuances] = json::ValueType::Array;
jv[jss::mpt_issuances][0u] = "not-an-mpt-issuance-id";
auto jr = wsc->invoke(method, jv)[jss::result];
BEAST_EXPECT(jr[jss::error] == "invalidParams");
BEAST_EXPECT(jr[jss::error_message] == "Invalid parameters.");
}
{
json::Value jv;
jv[jss::mpt_issuances] = json::ValueType::Array;
jv[jss::mpt_issuances][0u] = "0123456789ABCDEF";
auto jr = wsc->invoke(method, jv)[jss::result];
BEAST_EXPECT(jr[jss::error] == "invalidParams");
BEAST_EXPECT(jr[jss::error_message] == "Invalid parameters.");
}
for (auto const& nonArray : nonArrays)
{
json::Value jv;
@@ -1616,6 +1658,38 @@ public:
return jv;
}
// Build `count` distinct, valid MPT issuance id strings the same cheap way
// as makeAccountStrings: by incrementing an MPTID. doSubscribe only parses
// these (it does not require the issuances to exist in the ledger), and
// parseMPTIssuanceIDs dedups into a HashSet, so distinctness is what makes
// the cap arithmetic exact.
static std::vector<std::string>
makeMPTIssuanceStrings(std::size_t count, std::uint32_t seed = 1)
{
std::vector<std::string> out;
out.reserve(count);
// Start at `seed` so separate calls produce non-overlapping ranges.
MPTID id{static_cast<std::uint64_t>(seed)};
for (std::size_t i = 0; i < count; ++i)
{
out.push_back(to_string(id));
++id;
}
return out;
}
// Append the given issuance ids as a jss::mpt_issuances array onto a fresh
// subscribe request object.
static json::Value
mptIssuancesRequest(std::vector<std::string> const& mptIDs)
{
json::Value jv{json::ValueType::Object};
jv[jss::mpt_issuances] = json::ValueType::Array;
for (auto const& id : mptIDs)
jv[jss::mpt_issuances].append(id);
return jv;
}
// A single, valid XRP/USD order book request, as one entry of a
// jss::books array.
static json::Value
@@ -1842,6 +1916,149 @@ public:
}
}
void
testMPTCapRejects()
{
// MPT issuance subscriptions are per-connection tracked state, so they
// are bounded by the same cap as the account branches. A request that
// alone exceeds the cap is rejected with the cap error, and nothing is
// recorded. (Pre-fix the mpt_issuances branch skipped the cap check
// entirely, so a client could hold unbounded issuance subscriptions.)
testcase("mpt_issuances cap rejects an over-cap request");
using namespace jtx;
Env env{*this, envconfig(cappedConfig(5))};
auto wsc = makeWSClient(env.app().config());
// Six issuances against a cap of five: rejected.
{
auto const jr = wsc->invoke(
"subscribe", mptIssuancesRequest(makeMPTIssuanceStrings(6)))[jss::result];
BEAST_EXPECT(jr[jss::error] == "invalidParams");
BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection.");
}
// The rejected request reserved nothing: a five-issuance request (the
// cap exactly) still fits. If the rejected one had leaked entries this
// would be rejected too.
{
auto const r = wsc->invoke("subscribe", mptIssuancesRequest(makeMPTIssuanceStrings(5)));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
}
void
testMPTReSubscribeNotOvercounted()
{
// Like the account branches, the MPT charge counts only NET-NEW
// issuances: re-subscribing issuances already held adds no tracked
// state, so it must be admitted even sitting exactly at the cap.
testcase("mpt_issuances re-subscribe at the cap is not over-counted");
using namespace jtx;
Env env{*this, envconfig(cappedConfig(5))};
auto wsc = makeWSClient(env.app().config());
// Fill the cap exactly with five distinct issuances.
auto const five = makeMPTIssuanceStrings(5);
{
auto const r = wsc->invoke("subscribe", mptIssuancesRequest(five));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
// Re-subscribe the same five: net-new is zero, so it stays within the
// cap and must succeed.
{
auto const r = wsc->invoke("subscribe", mptIssuancesRequest(five));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
// A single net-new issuance on top of a full cap IS rejected.
{
auto const jr = wsc->invoke(
"subscribe", mptIssuancesRequest(makeMPTIssuanceStrings(1, 100)))[jss::result];
BEAST_EXPECT(jr[jss::error] == "invalidParams");
BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection.");
}
}
void
testMPTSharesCapWithAccounts()
{
// The cap is one budget over all tracked kinds, so MPT issuances and
// accounts consume the same allowance in either order.
testcase("mpt_issuances and accounts share one cap");
using namespace jtx;
// Accounts first, then MPTs: 3 + 3 over a cap of 5 is rejected.
{
Env env{*this, envconfig(cappedConfig(5))};
auto wsc = makeWSClient(env.app().config());
auto const r = wsc->invoke("subscribe", accountsRequest(makeAccountStrings(3)));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
auto const jr = wsc->invoke(
"subscribe", mptIssuancesRequest(makeMPTIssuanceStrings(3)))[jss::result];
BEAST_EXPECT(jr[jss::error] == "invalidParams");
BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection.");
// Two more issuances do fit the remaining allowance.
auto const r2 =
wsc->invoke("subscribe", mptIssuancesRequest(makeMPTIssuanceStrings(2)));
BEAST_EXPECTS(r2[jss::status] == "success", to_string(r2));
}
// MPTs first, then accounts: the tally MPTs contribute is what rejects
// the accounts, proving mptSubscriptions_ is part of the count and not
// just checked against it.
{
Env env{*this, envconfig(cappedConfig(5))};
auto wsc = makeWSClient(env.app().config());
auto const r = wsc->invoke("subscribe", mptIssuancesRequest(makeMPTIssuanceStrings(5)));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
auto const jr =
wsc->invoke("subscribe", accountsRequest(makeAccountStrings(1)))[jss::result];
BEAST_EXPECT(jr[jss::error] == "invalidParams");
BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection.");
}
}
void
testMPTUnsubscribeFreesCap()
{
// Unsubscribing releases the allowance: after dropping the issuances,
// the same connection can subscribe a fresh batch. Guards against a
// reserve that is never given back (which would turn the cap into a
// permanent per-connection budget).
testcase("mpt_issuances unsubscribe frees cap allowance");
using namespace jtx;
Env env{*this, envconfig(cappedConfig(5))};
auto wsc = makeWSClient(env.app().config());
auto const five = makeMPTIssuanceStrings(5);
{
auto const r = wsc->invoke("subscribe", mptIssuancesRequest(five));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
{
auto const r = wsc->invoke("unsubscribe", mptIssuancesRequest(five));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
// The cap is free again, so a disjoint batch of five is admitted.
{
auto const r =
wsc->invoke("subscribe", mptIssuancesRequest(makeMPTIssuanceStrings(5, 100)));
BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
}
}
void
testAsyncTeardownDoesNotStall()
{
@@ -1964,6 +2181,275 @@ public:
wscB->invoke("unsubscribe", accountsRequest({alice.human()}));
}
void
testSubMPT()
{
// subscribe to multiple MPTs
testcase("SubMPT");
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this);
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
Account const dan{"dan"};
auto wsc = makeWSClient(env.app().config());
MPTTester mptAlice(env, alice, {.holders = {bob}});
MPTTester mptCarol(env, carol, {.holders = {dan}});
// Transfer fee is 10%
mptAlice.create(
{.transferFee = 10'000, .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock});
mptCarol.create({.ownerCount = 1, .flags = tfMPTCanTransfer});
json::Value stream;
stream = json::ValueType::Object;
stream[jss::mpt_issuances] = json::ValueType::Array;
stream[jss::mpt_issuances].append(to_string(mptAlice.issuanceID()));
stream[jss::mpt_issuances].append(to_string(mptCarol.issuanceID()));
auto jv = wsc->invoke("subscribe", stream);
BEAST_EXPECT(jv[jss::status] == "success");
// bob create MPToken
mptAlice.authorize({.account = bob});
BEAST_EXPECT(wsc->findMsg(5s, [&](auto const& jv) {
return jv[jss::engine_result] == "tesSUCCESS" &&
jv[jss::transaction][jss::Account] == bob.human() &&
jv[jss::transaction][jss::Flags] == 0 &&
jv[jss::transaction][sfMPTokenIssuanceID.jsonName] ==
to_string(mptAlice.issuanceID()) &&
jv[jss::transaction][jss::TransactionType] == "MPTokenAuthorize" &&
jv[jss::type] == "mptTransaction";
}));
// dan create MPToken
mptCarol.authorize({.account = dan});
BEAST_EXPECT(wsc->findMsg(5s, [&](auto const& jv) {
return jv[jss::engine_result] == "tesSUCCESS" &&
jv[jss::transaction][jss::Account] == dan.human() &&
jv[jss::transaction][jss::Flags] == 0 &&
jv[jss::transaction][sfMPTokenIssuanceID.jsonName] ==
to_string(mptCarol.issuanceID()) &&
jv[jss::transaction][jss::Sequence] == 5 &&
jv[jss::transaction][jss::TransactionType] == "MPTokenAuthorize" &&
jv[jss::type] == "mptTransaction";
}));
// subscribe stream sees alice's MPT
mptAlice.pay(alice, bob, 2000);
BEAST_EXPECT(wsc->findMsg(5s, [&](auto const& jv) {
return jv[jss::engine_result] == "tesSUCCESS" &&
jv[jss::transaction][jss::Account] == alice.human() &&
jv[jss::transaction][jss::Amount][jss::mpt_issuance_id] ==
to_string(mptAlice.issuanceID()) &&
jv[jss::transaction][jss::Amount][jss::value] == "2000" &&
jv[jss::transaction][jss::DeliverMax][jss::mpt_issuance_id] ==
to_string(mptAlice.issuanceID()) &&
jv[jss::transaction][jss::DeliverMax][jss::value] == "2000" &&
jv[jss::transaction][jss::Destination] == bob.human() &&
jv[jss::transaction][jss::Flags] == tfFullyCanonicalSig &&
jv[jss::transaction][jss::TransactionType] == "Payment" &&
jv[jss::type] == "mptTransaction";
}));
// subscribe stream sees carol's MPT
mptCarol.pay(carol, dan, 1000);
BEAST_EXPECT(wsc->findMsg(5s, [&](auto const& jv) {
return jv[jss::engine_result] == "tesSUCCESS" &&
jv[jss::transaction][jss::Account] == carol.human() &&
jv[jss::transaction][jss::Amount][jss::mpt_issuance_id] ==
to_string(mptCarol.issuanceID()) &&
jv[jss::transaction][jss::Amount][jss::value] == "1000" &&
jv[jss::transaction][jss::DeliverMax][jss::mpt_issuance_id] ==
to_string(mptCarol.issuanceID()) &&
jv[jss::transaction][jss::DeliverMax][jss::value] == "1000" &&
jv[jss::transaction][jss::Destination] == dan.human() &&
jv[jss::transaction][jss::Flags] == tfFullyCanonicalSig &&
jv[jss::transaction][jss::Sequence] == 6 &&
jv[jss::transaction][jss::TransactionType] == "Payment" &&
jv[jss::type] == "mptTransaction";
}));
// subscribe stream sees alice's MPT lock
mptAlice.set({.account = alice, .flags = tfMPTLock});
BEAST_EXPECT(wsc->findMsg(5s, [&](auto const& jv) {
return jv[jss::engine_result] == "tesSUCCESS" &&
jv[jss::transaction][jss::Account] == alice.human() &&
jv[jss::transaction][jss::Flags] == tfMPTLock &&
jv[jss::transaction][sfMPTokenIssuanceID.jsonName] ==
to_string(mptAlice.issuanceID()) &&
jv[jss::transaction][jss::TransactionType] == "MPTokenIssuanceSet" &&
jv[jss::type] == "mptTransaction";
}));
// subscribe stream sees alice's MPT unlock
mptAlice.set({.account = alice, .flags = tfMPTUnlock});
BEAST_EXPECT(wsc->findMsg(5s, [&](auto const& jv) {
return jv[jss::engine_result] == "tesSUCCESS" &&
jv[jss::transaction][jss::Account] == alice.human() &&
jv[jss::transaction][jss::Flags] == tfMPTUnlock &&
jv[jss::transaction][sfMPTokenIssuanceID.jsonName] ==
to_string(mptAlice.issuanceID()) &&
jv[jss::transaction][jss::TransactionType] == "MPTokenIssuanceSet" &&
jv[jss::type] == "mptTransaction";
}));
// unsub alice's MPT from the stream
json::Value unsubStream;
unsubStream = json::ValueType::Object;
unsubStream[jss::mpt_issuances] = json::ValueType::Array;
unsubStream[jss::mpt_issuances].append(to_string(mptAlice.issuanceID()));
auto unsubJv = wsc->invoke("unsubscribe", unsubStream);
BEAST_EXPECT(unsubJv[jss::status] == "success");
// bob pays alice, this txn should no longer be sent by the stream
mptAlice.pay(bob, alice, 500);
// we don't expect to find alice's mpt in the stream
BEAST_EXPECT(!wsc->findMsg(5s, [&](auto const& jv) {
return jv[jss::transaction][jss::Amount][jss::mpt_issuance_id] ==
to_string(mptAlice.issuanceID()) &&
jv[jss::transaction][jss::Amount][jss::value] == "500";
}));
// this txn should be seen
mptCarol.pay(dan, carol, 100);
// only carol's MPT txn will be seen
BEAST_EXPECT(wsc->findMsg(5s, [&](auto const& jv) {
return jv[jss::engine_result] == "tesSUCCESS" &&
jv[jss::transaction][jss::Account] == dan.human() &&
jv[jss::transaction][jss::Amount][jss::mpt_issuance_id] ==
to_string(mptCarol.issuanceID()) &&
jv[jss::transaction][jss::Amount][jss::value] == "100" &&
jv[jss::transaction][jss::DeliverMax][jss::mpt_issuance_id] ==
to_string(mptCarol.issuanceID()) &&
jv[jss::transaction][jss::DeliverMax][jss::value] == "100" &&
jv[jss::transaction][jss::Destination] == carol.human() &&
jv[jss::transaction][jss::Flags] == tfFullyCanonicalSig &&
jv[jss::transaction][jss::TransactionType] == "Payment" &&
jv[jss::type] == "mptTransaction";
}));
}
void
testSubMPTBatch()
{
// MPTs destroyed by the inner transactions of a Batch are still seen by
// the stream. An MPTokenIssuance entry does not carry its own issuance
// id, so this exercises deriving the id from the metadata.
testcase("SubMPT batch");
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this);
Account const alice{"alice"};
auto wsc = makeWSClient(env.app().config());
// A Batch requires at least two inner transactions, so destroy two
// issuances at once.
MPTTester mptAlice1(env, alice, MPTInit{});
mptAlice1.create({.ownerCount = 1});
auto const mptID1 = mptAlice1.issuanceID();
MPTTester mptAlice2(env, alice, MPTInit{.fund = false});
mptAlice2.create({.ownerCount = 2});
auto const mptID2 = mptAlice2.issuanceID();
json::Value stream;
stream = json::ValueType::Object;
stream[jss::mpt_issuances] = json::ValueType::Array;
stream[jss::mpt_issuances].append(to_string(mptID1));
stream[jss::mpt_issuances].append(to_string(mptID2));
auto const jv = wsc->invoke("subscribe", stream);
BEAST_EXPECT(jv[jss::status] == "success");
// destroy both issuances from inside a Batch
auto const seq = env.seq(alice);
auto const batchFee = batch::calcBatchFee(env, 0, 2);
env(batch::outer(alice, seq, batchFee, tfAllOrNothing),
batch::Inner(mptAlice1.destroyJV({.issuer = alice, .id = mptID1}), seq + 1),
batch::Inner(mptAlice2.destroyJV({.issuer = alice, .id = mptID2}), seq + 2));
env.close();
// each inner transaction is published on the stream for its own issuance
for (auto const& mptID : {mptID1, mptID2})
{
BEAST_EXPECT(wsc->findMsg(5s, [&](auto const& jv) {
return jv[jss::engine_result] == "tesSUCCESS" &&
jv[jss::transaction][jss::Account] == alice.human() &&
jv[jss::transaction][sfMPTokenIssuanceID.jsonName] == to_string(mptID) &&
jv[jss::transaction][jss::TransactionType] == "MPTokenIssuanceDestroy" &&
jv[jss::type] == "mptTransaction";
}));
}
// the outer Batch transaction is not itself published on the stream;
// only the inner transactions touch the issuances
BEAST_EXPECT(!wsc->findMsg(1s, [](auto const& jv) {
return jv[jss::transaction][jss::TransactionType] == "Batch";
}));
json::Value unsubStream;
unsubStream = json::ValueType::Object;
unsubStream[jss::mpt_issuances] = json::ValueType::Array;
unsubStream[jss::mpt_issuances].append(to_string(mptID1));
unsubStream[jss::mpt_issuances].append(to_string(mptID2));
BEAST_EXPECT(wsc->invoke("unsubscribe", unsubStream)[jss::status] == "success");
}
void
testSubMPTAmountFields()
{
testcase("SubMPT amount fields");
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this);
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
MPTTester mpt(env, alice, {.holders = {bob, carol}});
mpt.create({.pay = {{{bob}, 100}}, .flags = kMptDexFlags | tfMPTCanEscrow});
auto wsc = makeWSClient(env.app().config());
auto const subscribed =
wsc->invoke("subscribe", mptIssuancesRequest({to_string(mpt.issuanceID())}));
BEAST_EXPECT(subscribed[jss::status] == "success");
auto expectTransaction = [&](std::string const& type) {
BEAST_EXPECT(wsc->findMsg(5s, [&](auto const& jv) {
return jv[jss::engine_result] == "tesSUCCESS" &&
jv[jss::transaction][jss::TransactionType] == type &&
jv[jss::type] == "mptTransaction";
}));
};
// Created offers carry the issuance only inside their amount fields.
env(offer(bob, XRP(10), mpt(10)));
env.close();
expectTransaction("OfferCreate");
env(offer(carol, mpt(5), XRP(10)));
env.close();
expectTransaction("OfferCreate");
// Escrow entries similarly carry the issuance in sfAmount.
auto const baseFee = env.current()->fees().base;
env(escrow::create(bob, carol, mpt(10)),
escrow::kFinishTime(env.now() + 1s),
Fee(baseFee * 150));
env.close();
expectTransaction("EscrowCreate");
}
void
run() override
{
@@ -1985,6 +2471,9 @@ public:
testSubBookChanges();
testNFToken(all);
testNFToken(all - featureNFTokenMintOffer);
testSubMPT();
testSubMPTBatch();
testSubMPTAmountFields();
testAsyncTeardownDoesNotStall();
testResubscribeAfterDisconnect();
testSubscriptionCapRejects();
@@ -1993,6 +2482,10 @@ public:
testMultiFieldNoPartialSubscribe();
testHistoryReSubscribeNotOvercounted();
testHistoryCapRejectsNetNew();
testMPTCapRejects();
testMPTReSubscribeNotOvercounted();
testMPTSharesCapWithAccounts();
testMPTUnsubscribeFreesCap();
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -82,17 +82,41 @@
namespace xrpl {
// Don't catch up more than 100 ledgers (cannot exceed 256)
/**
* Don't catch up more than 100 ledgers (cannot exceed 256).
*
* A wider gap between the published and validated ledgers is abandoned rather
* than filled, and publication jumps straight to the validated ledger. The 256
* ceiling is how far back hashOfSeq() can reach in one skip-list lookup.
*/
static constexpr int kMaxLedgerGap{100};
// Don't acquire history if ledger is too old
/**
* Don't acquire history if ledger is too old. Age of the validated ledger,
* above which back-filling is skipped so the node can catch up first.
*/
static constexpr std::chrono::minutes kMaxLedgerAgeAcquire{1};
// Don't acquire history if write load is too high
/**
* Don't acquire history if write load is too high. The load is the number of
* node objects queued or being written by the backend's batch writer; NuDB
* always reports 0.
*/
static constexpr int kMaxWriteLoadAcquire{8192};
// Helper function for LedgerMaster::doAdvance()
// Return true if candidateLedger should be fetched from the network.
/**
* Helper function for LedgerMaster::doAdvance()
* Return true if candidateLedger should be fetched from the network.
*
* @param currentLedger Sequence of the newest validated ledger.
* @param ledgerHistory How many ledgers of history the operator asked to keep.
* @param minimumOnline Online-delete floor, or the oldest SQL ledger when
* online delete is off; nullopt if unknown.
* @param candidateLedger Sequence of the missing ledger being considered.
* @param j Log sink for the decision.
* @return true when the candidate may be the current ledger, falls inside the
* configured history window, or is at or above the minimum to keep online.
*/
static bool
shouldAcquire(
std::uint32_t const currentLedger,
@@ -1126,12 +1150,28 @@ LedgerMaster::consensusBuilt(
auto validations =
app_.getValidators().negativeUNLFilter(app_.getValidations().currentTrusted());
// Track validation counts with sequence numbers
/** @cond */
/**
* Track validation counts with sequence numbers.
*
* One tally per ledger hash, built while scanning the current trusted
* validations. Every validation carries a sequence; the first nonzero one
* seen for a hash is kept.
*
* @note Not thread-safe, and not intended to be: instances live only inside
* this function.
*/
class ValSeq
{
public:
ValSeq() = default;
/**
* Counts one more validation for this ledger.
*
* @param seq Sequence the validation reported. Adopted only if no
* sequence is known yet; zero leaves the tally's sequence unknown.
*/
void
mergeValidation(LedgerIndex seq)
{
@@ -1142,9 +1182,17 @@ LedgerMaster::consensusBuilt(
ledgerSeq = seq;
}
/**
* How many trusted validations named this ledger.
*/
std::size_t valCount{0};
/**
* Sequence of this ledger, or 0 while still unknown.
*/
LedgerIndex ledgerSeq{0};
};
/** @endcond */
// Count the number of current, trusted validations
HashMap<UInt256, ValSeq> count;

View File

@@ -104,6 +104,7 @@
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/Units.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/protocol/jss.h>
@@ -158,9 +159,10 @@ namespace xrpl {
/**
* Concrete NetworkOPs: server sequencer, network tracker, and owner of all
* client subscription state (accounts, books, streams). Subscriptions use three
* independent non-recursive locks (accountLock_, bookLock_, streamLock_); see
* their declarations for the locking and deferred-destruction rules.
* client subscription state (accounts, books, MPTs, streams). Subscriptions use
* four independent non-recursive locks (accountLock_, bookLock_, mptLock_,
* streamLock_); see their declarations for the locking and deferred-destruction
* rules.
*/
class NetworkOPsImp final : public NetworkOPs
{
@@ -570,6 +572,13 @@ public:
unsubAccountInternal(std::uint64_t seq, HashSet<AccountID> const& vnaAccountIDs, bool rt)
override;
void
subMPT(InfoSub::Ref ispListener, HashSet<MPTID> const& mptIDs) override;
void
unsubMPT(InfoSub::Ref ispListener, HashSet<MPTID> const& mptIDs) override;
void
unsubMPTInternal(std::uint64_t seq, MPTID const& mptID) override;
ErrorCodeI
subAccountHistory(InfoSub::Ref ispListener, AccountID const& account) override;
void
@@ -799,6 +808,8 @@ private:
pubServer();
void
pubConsensus(ConsensusPhase phase);
void
pubMPTTransaction(AcceptedLedgerTx const& transaction, MultiApiJson const& jvObj);
std::string
getHostId(bool forAdmin);
@@ -807,6 +818,7 @@ private:
using SubMapType = HashMap<std::uint64_t, InfoSub::Wptr>;
using SubInfoMapType = HashMap<AccountID, SubMapType>;
using SubRpcMapType = HashMap<std::string, InfoSub::pointer>;
using SubMPTInfoMapType = HashMap<MPTID, SubMapType>;
/*
* With a validated ledger to separate history and future, the node
@@ -928,16 +940,18 @@ private:
// Independent lock domains so a long cleanup/publish on one does not stall
// the others. Hold at most one at a time; if ever more, order: accountLock_,
// bookLock_, streamLock_.
// bookLock_, mptLock_, streamLock_.
//
// Deferred-destruction rule (non-recursive mutexes): under bookLock_ or
// streamLock_, never let the last InfoSub pointer die inside the lock -
// ~InfoSub re-acquires it via unsub* -> self-deadlock. Publishers collect the
// locked pointers in a vector declared before the lock and destruct after
// release (see pubServer / pubBookTransaction). accountLock_ is exempt:
// ~InfoSub offloads account teardown to scheduleAccountCleanup.
// Deferred-destruction rule (non-recursive mutexes): under bookLock_,
// mptLock_, or streamLock_, never let the last InfoSub pointer die inside the
// lock - ~InfoSub re-acquires it via unsub* -> self-deadlock. Publishers
// collect the locked pointers in a container declared before the lock and
// destruct after release (see pubServer / pubBookTransaction /
// pubMPTTransaction). accountLock_ is exempt: ~InfoSub offloads account
// teardown to scheduleAccountCleanup.
std::mutex accountLock_; ///< Guards subAccount_, subRTAccount_, subAccountHistory_.
std::mutex bookLock_; ///< Guards subBook_.
std::mutex mptLock_; ///< Guards subMPT_.
std::mutex streamLock_; ///< Guards streamMaps_[] and rpcSubMap_.
std::atomic<OperatingMode> mode_;
@@ -974,7 +988,8 @@ private:
SubInfoMapType subAccount_;
SubInfoMapType subRTAccount_;
SubBookMapType subBook_; ///< Guarded by bookLock_.
SubBookMapType subBook_; ///< Guarded by bookLock_.
SubMPTInfoMapType subMPT_; ///< Guarded by mptLock_.
SubRpcMapType rpcSubMap_;
@@ -3633,6 +3648,7 @@ NetworkOPsImp::pubValidatedTransaction(
pubBookTransaction(transaction, jvObj);
pubAccountTransaction(ledger, transaction, last);
pubMPTTransaction(transaction, jvObj);
}
void
@@ -4124,6 +4140,133 @@ NetworkOPsImp::scheduleAccountCleanup(
});
}
void
NetworkOPsImp::pubMPTTransaction(AcceptedLedgerTx const& alTx, MultiApiJson const& jvObj)
{
{
std::scoped_lock const sl(mptLock_);
if (subMPT_.empty())
return;
}
// getAffectedMPTs derives the issuance id for MPTokenIssuance entries from
// the metadata, so it also covers transactions whose top-level STTx carries
// no MPTokenIssuanceID, such as the inner transactions of a Batch. It reads
// only the metadata, so it runs outside mptLock_.
auto const affectedMPTs = alTx.getMeta().getAffectedMPTs();
if (affectedMPTs.empty())
return;
// Declared before the lock so a last-reference ~InfoSub runs after
// mptLock_ is released (see the deferred-destruction rule).
HashSet<InfoSub::pointer> notify;
{
std::scoped_lock const sl(mptLock_);
for (auto const& affectedMPT : affectedMPTs)
{
if (auto simiIt = subMPT_.find(affectedMPT); simiIt != subMPT_.end())
{
auto it = simiIt->second.begin();
while (it != simiIt->second.end())
{
InfoSub::pointer const p = it->second.lock();
if (p)
{
notify.insert(p);
++it;
}
else
{
it = simiIt->second.erase(it);
}
}
}
}
}
if (notify.empty())
return;
// Reuse the transaction JSON built by pubValidatedTransaction; only the
// message type differs for this stream.
MultiApiJson jvMPT = jvObj;
jvMPT.set(jss::type, "mptTransaction");
for (InfoSub::Ref isrListener : notify)
{
jvMPT.visit(isrListener->getApiVersion(), [&](json::Value const& jv) {
isrListener->send(jv, true);
});
}
}
void
NetworkOPsImp::subMPT(InfoSub::Ref isrListener, HashSet<MPTID> const& mptIDs)
{
// Insert into subMPT_ before the InfoSub (unsubMPT removes in reverse), as
// subBook does, so a racing unsubMPT cannot leave a subMPT_ entry that the
// InfoSub doesn't know about.
{
std::scoped_lock const sl(mptLock_);
for (auto const& mptID : mptIDs)
{
JLOG(journal_.trace()) << "subMPT: MPT: " << to_string(mptID);
auto simIterator = subMPT_.find(mptID);
if (simIterator == subMPT_.end())
{
// Not found, note that the MPT issuance has a new single listener.
SubMapType usisElement;
usisElement[isrListener->getSeq()] = isrListener;
subMPT_.insert(simIterator, make_pair(mptID, usisElement));
}
else
{
// Found, note that the MPT issuance has another listener.
simIterator->second[isrListener->getSeq()] = isrListener;
}
}
}
for (auto const& mptID : mptIDs)
isrListener->insertSubMPTInfo(mptID);
}
void
NetworkOPsImp::unsubMPT(InfoSub::Ref isrListener, HashSet<MPTID> const& mptIDs)
{
for (auto const& mptID : mptIDs)
{
// Remove from the InfoSub first so ~InfoSub does not re-issue an
// unsubMPTInternal for an issuance the caller already removed, then
// remove from the server.
isrListener->deleteSubMPTInfo(mptID);
unsubMPTInternal(isrListener->getSeq(), mptID);
}
}
void
NetworkOPsImp::unsubMPTInternal(std::uint64_t uSeq, MPTID const& mptID)
{
// Only weak_ptrs are erased, so no InfoSub can be destroyed under the lock.
std::scoped_lock const sl(mptLock_);
auto simIterator = subMPT_.find(mptID);
if (simIterator == subMPT_.end())
return;
simIterator->second.erase(uSeq);
if (simIterator->second.empty())
{
// Don't need hash entry.
subMPT_.erase(simIterator);
}
}
void
NetworkOPsImp::addAccountHistoryJob(SubAccountHistoryInfoWeak subInfo)
{

View File

@@ -1,9 +1,7 @@
#pragma once
#include <xrpl/basics/Log.h>
#include <xrpl/basics/RangeSet.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/contract.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/LedgerHeader.h>
@@ -18,7 +16,6 @@
#include <map>
#include <memory>
#include <optional>
#include <stdexcept>
#include <utility>
#include <variant>
#include <vector>
@@ -401,6 +398,15 @@ public:
SQLiteDatabase&
operator=(SQLiteDatabase&&) = delete;
/**
* @brief ledgerDbHasSpace Checks if the ledger database has available
* space.
* @param config Config object.
* @return True if space is available.
*/
bool
ledgerDbHasSpace(Config const& config);
/**
* @brief transactionDbHasSpace Checks if the transaction database has
* available space.
@@ -452,27 +458,13 @@ private:
}
/**
* @brief checkoutLedger Checks out and returns node store ledger
* @brief checkoutTransaction Checks out and returns node store ledger
* database.
* @return Session to the node store ledger database.
* @throws std::runtime_error if ledger database is not available.
*
* @note Callers typically guard with existsLedger() before calling
* this method. The explicit null check here provides
* defense-in-depth so that safety does not depend solely on
* an implicit caller contract. See PR #6029 for context on
* the pattern of relying on config settings instead of
* validating actual objects.
*/
auto
checkoutLedger()
{
if (!ledgerDb_)
{
constexpr auto msg = "Ledger database is not available";
JLOG(j_.fatal()) << msg;
Throw<std::runtime_error>(msg);
}
return ledgerDb_->checkoutDb();
}
@@ -480,23 +472,10 @@ private:
* @brief checkoutTransaction Checks out and returns the node store
* transaction database.
* @return Session to the node store transaction database.
* @throws std::runtime_error if transaction database is not available.
*
* @note Callers typically guard with existsTransaction() and/or
* useTxTables_ before calling this method. The explicit null
* check here provides defense-in-depth so that safety does
* not depend solely on an implicit caller contract or config
* settings. See PR #6029 for context.
*/
auto
checkoutTransaction()
{
if (!txdb_)
{
constexpr auto msg = "Transaction database is not available";
JLOG(j_.fatal()) << msg;
Throw<std::runtime_error>(msg);
}
return txdb_->checkoutDb();
}
};

View File

@@ -559,6 +559,18 @@ SQLiteDatabase::SQLiteDatabase(SQLiteDatabase&& rhs) noexcept
std::exchange(txdb_, std::move(rhs.txdb_));
}
bool
SQLiteDatabase::ledgerDbHasSpace(Config const& config)
{
if (existsLedger())
{
auto db = checkoutLedger();
return detail::dbHasSpace(*db, config, j_);
}
return true;
}
bool
SQLiteDatabase::transactionDbHasSpace(Config const& config)
{

View File

@@ -330,8 +330,21 @@ public:
public:
Config();
/* Be very careful to make sure these bool params
are in the right order. */
/**
* Set up the configuration and determine the config and data directories.
*
* When no config file is named, searches the working directory, then the XDG
* config directory when HOME is set, then the system config directory, and
* takes the first holding a config file under either name. The system config
* directory is the default when none does.
*
* Be very careful to make sure these bool params are in the right order.
*
* @param strConf Path of the config file, or empty to search for it.
* @param bQuiet Whether to minimize logging verbosity.
* @param bSilent Whether to write no console output after startup.
* @param bStandalone Whether to operate in stand-alone mode.
*/
void
setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStandalone);

View File

@@ -43,6 +43,7 @@
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <system_error>
#include <thread>
#include <type_traits>
@@ -319,59 +320,66 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
}
else
{
do
// A config directory, paired with its data directory.
struct Candidate
{
// Check if either of the config files exist in the current working
// directory, in which case the databases will be stored in a
// subdirectory.
configDir = std::filesystem::current_path();
dataDir = configDir / kDatabaseDirName;
configFile_ = configDir / kConfigFileName;
if (std::filesystem::exists(configFile_))
break;
configFile_ = configDir / kConfigLegacyName;
if (std::filesystem::exists(configFile_))
break;
std::filesystem::path configDir;
std::filesystem::path dataDir;
};
// Check if the home directory is set, and optionally the XDG config
// and/or data directories, as the config may be there. See
// http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html.
auto const strHome = getEnvVar("HOME");
if (!strHome.empty())
// The candidates to search, in order.
std::vector<Candidate> candidates;
candidates.reserve(3);
// The current working directory, with the databases in a subdirectory.
auto const cwd = std::filesystem::current_path();
candidates.emplace_back(cwd, cwd / kDatabaseDirName);
// The XDG config and data directories, which only apply when the home
// directory is set. See
// http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html.
if (auto const strHome = getEnvVar("HOME"); !strHome.empty())
{
auto strXdgConfigHome = getEnvVar("XDG_CONFIG_HOME");
if (strXdgConfigHome.empty())
{
auto strXdgConfigHome = getEnvVar("XDG_CONFIG_HOME");
auto strXdgDataHome = getEnvVar("XDG_DATA_HOME");
if (strXdgConfigHome.empty())
{
// $XDG_CONFIG_HOME was not set, use default based on $HOME.
strXdgConfigHome = strHome + "/.config";
}
if (strXdgDataHome.empty())
{
// $XDG_DATA_HOME was not set, use default based on $HOME.
strXdgDataHome = strHome + "/.local/share";
}
// $XDG_CONFIG_HOME was not set, use default based on $HOME.
strXdgConfigHome = strHome + "/.config";
}
auto strXdgDataHome = getEnvVar("XDG_DATA_HOME");
if (strXdgDataHome.empty())
{
// $XDG_DATA_HOME was not set, use default based on $HOME.
strXdgDataHome = strHome + "/.local/share";
}
candidates.emplace_back(
strXdgConfigHome + "/" + systemName(), strXdgDataHome + "/" + systemName());
}
// Check if either of the config files exist in the XDG config
// dir.
dataDir = strXdgDataHome + "/" + systemName();
configDir = strXdgConfigHome + "/" + systemName();
configFile_ = configDir / kConfigFileName;
if (std::filesystem::exists(configFile_))
break;
configFile_ = configDir / kConfigLegacyName;
// The system config directory, as a last resort.
candidates.emplace_back("/etc/" + systemName(), "/var/lib/" + systemName());
// Take the first candidate holding a config file under either name.
// When none does, the last candidate's values stand.
for (auto const& candidate : candidates)
{
configDir = candidate.configDir;
dataDir = candidate.dataDir;
bool found = false;
for (std::string_view const fileName : {kConfigFileName, kConfigLegacyName})
{
configFile_ = configDir / fileName;
if (std::filesystem::exists(configFile_))
{
found = true;
break;
}
}
// As a last resort, check the system config directory.
dataDir = "/var/lib/" + systemName();
configDir = "/etc/" + systemName();
configFile_ = configDir / kConfigFileName;
if (std::filesystem::exists(configFile_))
if (found)
break;
configFile_ = configDir / kConfigLegacyName;
} while (false);
}
}
// Update default values

View File

@@ -1,6 +1,5 @@
#include <xrpl/core/PeerReservationTable.h>
#include <xrpl/basics/contract.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/jss.h>
@@ -11,7 +10,6 @@
#include <iterator>
#include <mutex>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>
@@ -89,15 +87,6 @@ PeerReservationTable::insertOrAssign(PeerReservation const& reservation)
}
table_.insert(hint, reservation);
// connection_ is set by load() during two-phase init. Validate
// before dereferencing to guard against use-before-load or a reset
// connection. See PR #6029 for the general pattern discussion.
if (connection_ == nullptr)
{
Throw<std::runtime_error>(
"PeerReservationTable::insertOrAssign: database connection is "
"not available");
}
auto db = connection_->checkoutDb();
insertPeerReservation(*db, reservation.nodeId, reservation.description);
@@ -116,14 +105,6 @@ PeerReservationTable::erase(PublicKey const& nodeId)
{
previous = *it;
table_.erase(it);
// Validate connection_ before dereferencing — see comment in
// insertOrAssign above.
if (connection_ == nullptr)
{
Throw<std::runtime_error>(
"PeerReservationTable::erase: database connection is not "
"available");
}
auto db = connection_->checkoutDb();
deletePeerReservation(*db, nodeId);
}

View File

@@ -14,13 +14,13 @@
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/RPCErr.h>
@@ -66,38 +66,47 @@ getStartHint(SLE::ConstRef sle, AccountID const& accountID)
return sle->getFieldU64(sfOwnerNode);
}
namespace {
// UINT64 sf*Node fields that record a page number in an owner directory.
// Keep in sync with sfields.macro; the xrpl.rpc.RPCHelpers test enforces it.
constexpr std::array<SField const*, 9> kOwnerDirNodeFields{
&sfOwnerNode,
&sfLowNode,
&sfHighNode,
&sfDestinationNode,
&sfIssuerNode,
&sfSubjectNode,
&sfSponseeNode,
&sfLoanBrokerNode,
&sfVaultNode,
};
} // namespace
bool
isOwnerDirNodeField(SField const& field)
{
return std::ranges::contains(kOwnerDirNodeFields, &field);
}
bool
isRelatedToAccount(ReadView const& ledger, SLE::ConstRef sle, AccountID const& accountID)
{
if (sle->getType() == ltRIPPLE_STATE)
{
return (sle->getFieldAmount(sfLowLimit).getIssuer() == accountID) ||
(sle->getFieldAmount(sfHighLimit).getIssuer() == accountID);
}
if (sle->isFieldPresent(sfAccount))
{
// If there's an sfAccount present, also test the sfDestination, if
// present. This will match objects such as Escrows (ltESCROW), Payment
// Channels (ltPAYCHAN), and Checks (ltCHECK) because those are added to
// the Destination account's directory. It intentionally EXCLUDES
// NFToken Offers (ltNFTOKEN_OFFER). NFToken Offers are NOT added to the
// Destination account's directory.
return sle->getAccountID(sfAccount) == accountID ||
(sle->isFieldPresent(sfDestination) && sle->getAccountID(sfDestination) == accountID);
}
if (sle->getType() == ltSIGNER_LIST)
{
Keylet const accountSignerList = keylet::signerList(accountID);
return sle->key() == accountSignerList.key;
}
if (sle->getType() == ltNFTOKEN_OFFER)
{
// Do not check the sfDestination field. NFToken Offers are NOT added to
// the Destination account's directory.
return sle->getAccountID(sfOwner) == accountID;
}
// Marker validator for account_lines / account_offers / account_channels
// pagination: probes each owner-directory page-hint field on `sle` and
// returns true iff `sle`'s key is present on that page in `accountID`'s
// owner directory. Bounded by kOwnerDirNodeFields.size() ledger reads.
auto const ownerDir = keylet::ownerDir(accountID);
return false;
auto const pageContainsKey = [&](std::uint64_t node) {
auto const page = ledger.read(keylet::page(ownerDir, node));
return page && std::ranges::contains(page->getFieldV256(sfIndexes), sle->key());
};
return std::ranges::any_of(kOwnerDirNodeFields, [&](SField const* field) {
return sle->isFieldPresent(*field) && pageContainsKey(sle->getFieldU64(*field));
});
}
HashSet<AccountID>
@@ -116,6 +125,25 @@ parseAccountIds(json::Value const& jvArray)
return result;
}
HashSet<MPTID>
parseMPTIssuanceIDs(json::Value const& jvArray)
{
HashSet<MPTID> result;
for (auto const& jv : jvArray)
{
if (!jv.isString())
return HashSet<MPTID>();
auto const mptIssuanceIdStr = jv.asString();
MPTID mptIssuanceID;
if (!mptIssuanceID.parseHex(mptIssuanceIdStr))
return HashSet<MPTID>();
result.insert(mptIssuanceID);
}
return result;
}
std::optional<json::Value>
readLimitField(unsigned int& limit, tuning::LimitRange const& range, JsonContext const& context)
{

View File

@@ -14,9 +14,11 @@
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STLedgerEntry.h> // IWYU pragma: keep
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/server/NetworkOPs.h>
#include <cstdint>
@@ -60,6 +62,17 @@ getStartHint(SLE::ConstRef sle, AccountID const& accountID);
bool
isRelatedToAccount(ReadView const& ledger, SLE::ConstRef sle, AccountID const& accountID);
/**
* @brief Checks whether an SField is a UINT64 sf*Node owner-directory
* page-hint field probed by `isRelatedToAccount`.
*
* @param field The SField to test.
* @return true if the field is one of the owner-directory page-hint
* fields, false otherwise.
*/
bool
isOwnerDirNodeField(SField const& field);
/**
* @brief Parses an array of account IDs from a JSON value.
*
@@ -71,6 +84,17 @@ isRelatedToAccount(ReadView const& ledger, SLE::ConstRef sle, AccountID const& a
HashSet<AccountID>
parseAccountIds(json::Value const& jvArray);
/**
* @brief Parses an array of MPT issuance IDs from a JSON value.
*
* Extracts and returns a set of MPTID objects from the provided JSON array.
*
* @param jvArray The JSON value containing an array of MPT issuance IDs.
* @return A HashSet containing the parsed MPTID objects.
*/
HashSet<MPTID>
parseMPTIssuanceIDs(json::Value const& jvArray);
/**
* @brief Retrieves the limit value from a JsonContext or sets a default.
*

View File

@@ -194,12 +194,12 @@ doSubscribe(rpc::JsonContext& context)
// Parse the proposed (real-time) and normal account sets first, then check
// the cap against their COMBINED net-new total before subscribing either.
// This keeps the account pair all-or-nothing: it never subscribes one set
// and then rejects on the other. Other fields (streams and account_history)
// are still checked and subscribed independently, as they always have been,
// so a later field can be rejected after an earlier one subscribed. The cap
// counts only NET-NEW accounts (those not already tracked on this
// connection), so re-subscribing accounts already held is never wrongly
// rejected.
// and then rejects on the other. Other fields (streams, account_history and
// mpt_issuances) are still checked and subscribed independently, as they
// always have been, so a later field can be rejected after an earlier one
// subscribed. The cap counts only NET-NEW accounts (those not already
// tracked on this connection), so re-subscribing accounts already held is
// never wrongly rejected.
auto accountsProposed = context.params.isMember(jss::accounts_proposed)
? jss::accounts_proposed
: jss::rt_accounts; // DEPRECATED
@@ -394,6 +394,25 @@ doSubscribe(rpc::JsonContext& context)
}
}
if (context.params.isMember(jss::mpt_issuances))
{
if (!context.params[jss::mpt_issuances].isArray())
return rpcError(RpcInvalidParams);
auto ids = rpc::parseMPTIssuanceIDs(context.params[jss::mpt_issuances]);
if (ids.empty())
return rpcError(RpcInvalidParams);
// MPT issuance subscriptions are tracked per connection (and counted by
// totalSubscriptionCount), so they are capped like the account branches:
// an atomic check-and-reserve of the net-new issuances, all-or-nothing.
if (!ispSub->tryReserveMPTSubscriptions(ids, subscriptionCap))
return rpc::makeParamError("Too many subscriptions for this connection.");
context.netOps.subMPT(ispSub, ids);
JLOG(context.j.debug()) << "doSubscribe: mpts: " << ids.size();
}
return jvResult;
}

View File

@@ -207,6 +207,18 @@ doUnsubscribe(rpc::JsonContext& context)
}
}
if (context.params.isMember(jss::mpt_issuances))
{
if (!context.params[jss::mpt_issuances].isArray())
return rpcError(RpcInvalidParams);
auto ids = rpc::parseMPTIssuanceIDs(context.params[jss::mpt_issuances]);
if (ids.empty())
return rpcError(RpcInvalidParams);
context.netOps.unsubMPT(ispSub, ids);
JLOG(context.j.debug()) << "doUnsubscribe: mpts: " << ids.size();
}
if (removeUrl)
{
context.netOps.tryRemoveRpcSub(context.params[jss::url].asString());