Compare commits

...

21 Commits

Author SHA1 Message Date
Ed Hennis
affea3d371 Merge branch 'develop' into ximinez/fix-getkeys 2026-07-13 17:22:40 -04:00
Ayaz Salikhov
cd06ee221d chore: Run clang_tidy_check with pass_filenames: false from pre-commit (#7800) 2026-07-13 19:19:18 +00:00
Peter Chen
752dab8b30 feat: Add delegate filter param for account_tx RPC (#6126) 2026-07-13 18:44:59 +00:00
Mayukha Vadari
86583bc34e refactor: Move jss.h include out of Indexes.h (#7799) 2026-07-13 17:44:47 +00:00
Ed Hennis
78f241d23c Merge branch 'develop' into ximinez/fix-getkeys 2026-07-08 17:23:33 -04:00
Ed Hennis
85c201a264 Merge branch 'develop' into ximinez/fix-getkeys 2026-07-07 19:32:44 -04:00
Ed Hennis
8938d26da5 Merge branch 'develop' into ximinez/fix-getkeys 2026-07-07 16:51:42 -04:00
Ed Hennis
098ae7e70c Merge branch 'develop' into ximinez/fix-getkeys 2026-07-06 13:40:43 -04:00
Ed Hennis
956ed0b1a8 Merge branch 'develop' into ximinez/fix-getkeys 2026-07-02 11:18:36 -04:00
Ed Hennis
5d2bc88a2e Document the reasons for the post-allocation assert
- Hopefully the AI bots will stop telling me to change it now.
2026-07-01 19:05:01 -04:00
Ed Hennis
9c03931190 Merge branch 'develop' into ximinez/fix-getkeys 2026-07-01 16:03:04 -04:00
Ed Hennis
4f0738fff3 Merge branch 'develop' into ximinez/fix-getkeys 2026-06-30 16:27:25 -04:00
Ed Hennis
1a3d460046 Add missed header 2026-06-30 16:26:43 -04:00
Ed Hennis
c2e54d12e9 Use the right type in include/xrpl/basics/TaggedCache.ipp
Co-authored-by: xrplf-ai-reviewer[bot] <266832837+xrplf-ai-reviewer[bot]@users.noreply.github.com>
2026-06-30 16:25:18 -04:00
Ed Hennis
0ded97ba5b Make the getKeys() allocation more robust
- Make it very unlikely that the allocation loop will ever need to run
  more than once. Recover gracefully if it does.
- Prevent livelock by limiting the total possible number of iterations.
  - If this ever happens, throw our metaphorical hands in the air, and
    allocate under lock.
- Pad the size before allocating to give the cache a little room to grow
  while not holding the lock.
  - Pad by less on each iteration.
- Assert that no more than two iterations occur. Even two is probably
  overkill, but this allows for rare edge case scenarios. e.g. The
  cache is very small, the allocation takes a long time (which is
  contradictory) and a handful of items get added, overcoming the
  padding.
2026-06-30 16:13:11 -04:00
Ed Hennis
0e8714af73 Merge branch 'develop' into ximinez/fix-getkeys 2026-06-25 11:43:38 -04:00
Ed Hennis
d62ad9a8e7 Merge branch 'develop' into ximinez/fix-getkeys 2026-06-23 11:10:09 -04:00
Ed Hennis
d7e7baa675 Merge branch 'develop' into ximinez/fix-getkeys 2026-06-22 16:42:07 -04:00
Ed Hennis
054284701e Apply suggestion from @xrplf-ai-reviewer[bot]
Co-authored-by: xrplf-ai-reviewer[bot] <266832837+xrplf-ai-reviewer[bot]@users.noreply.github.com>
2026-06-17 15:20:48 -04:00
Ed Hennis
eb4681da51 Merge branch 'develop' into ximinez/fix-getkeys 2026-06-17 15:19:55 -04:00
Ed Hennis
9b3dd7002d fix: Allocate TaggedCache::getKeys() memory outside of lock
- Uses a loop in case the size grows while the lock is free. Guarantees
  the result vector will not need to allocate under lock.
2026-06-17 13:06:40 -04:00
14 changed files with 809 additions and 49 deletions

View File

@@ -65,6 +65,7 @@ words:
- Btrfs
- Buildx
- canonicality
- canonicalised
- changespq
- checkme
- choco

View File

@@ -32,6 +32,11 @@ repos:
# as standalone translation units, so they have no compile_commands.json
# entry to lint (verify_headers checks them transitively).
exclude: '^include/xrpl/protocol_autogen|\.ipp$'
# run-clang-tidy --fix may edit headers included by files it is not run on,
# so pre-commit must not split the files across parallel hook invocations.
# The script determines the staged files itself and lets run-clang-tidy
# handle parallelism internally.
pass_filenames: false
- id: fix-include-style
name: fix include style
entry: ./bin/pre-commit/fix_include_style.py

View File

@@ -28,6 +28,9 @@ This section contains changes targeting a future version.
### Additions
- `account_tx`: Added an optional `delegate` request object to filter delegated transactions. The object requires `delegate_filter`, which must be either `actor` for transactions owned by the requested account but signed by another account, or `authorizer` for transactions signed by the requested account on behalf of another account. The optional `counter_party` account narrows the results to a specific signer/delegate for `actor` or a specific owner/delegator for `authorizer`. Malformed `delegate`, `delegate_filter`, and `counter_party` values return standard invalid field errors, and invalid account IDs return `actMalformed`.
When paginating delegate-filtered queries, a marker from a delegate-filtered query includes a `delegate` flag and is only valid for follow-up requests that also supply `delegate` (mixing marker conventions returns `invalidParams`). Because filtering is applied after the ledger scan, a page may contain fewer results than `limit` (possibly zero) while still returning a marker, so callers must continue until no marker is present.
- `ledger_entry`, `account_objects`: The `Delegate` ledger entry now includes an optional `DestinationNode` field, which stores the index into the authorized account's owner directory. This field is present on entries created after bidirectional directory tracking was introduced and may appear in RPC responses for those entries. ([#6681](https://github.com/XRPLF/rippled/pull/6681))
- `server_definitions`: Added the following new sections to the response ([#6321](https://github.com/XRPLF/rippled/pull/6321)):

View File

@@ -1,27 +1,46 @@
#!/usr/bin/env python3
"""Pre-commit hook that runs clang-tidy on changed files using run-clang-tidy.
"""Pre-commit hook that runs clang-tidy on staged files using run-clang-tidy.
The set of files is chosen by pre-commit (see .pre-commit-config.yaml), which
filters to C/C++ sources and excludes `.ipp` fragments. Headers are linted
directly: the `verify_headers` build option (ON by default) compiles every
`.h`/`.hpp` on its own, so each header is the main file of its own
compile_commands.json entry and run-clang-tidy can analyse it just like a
`.cpp`.
The script determines the staged files itself (see `pass_filenames: false` in
.pre-commit-config.yaml) so run-clang-tidy is run once and handles parallelism
internally: pre-commit would otherwise split the files across parallel hook
invocations that race when fixes edit a shared header.
Fixes are collected with `-export-fixes` and applied by clang-apply-replacements
in a separate step rather than with run-clang-tidy's `-fix`. The `add_module`
build isolates each module's headers behind a per-module symlink directory
(build/modules/<module>/...), so a header reachable from several translation
units is referenced through different paths that all resolve to the same source
file. clang-apply-replacements deduplicates identical replacements by their
literal path, so those paths must be canonicalised to the real source path
first; otherwise the same fix is applied once per path and corrupts the header.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
CLANG_TIDY_VERSION = 22
# Extensions run-clang-tidy can analyse: `.cpp` translation units and, thanks to
# the `verify_headers` build option, `.h`/`.hpp` headers (each has its own
# compile_commands.json entry). `.ipp` fragments have no entry and are skipped.
TIDY_EXTENSIONS = {".cpp", ".h", ".hpp"}
def find_run_clang_tidy() -> str | None:
for candidate in (f"run-clang-tidy-{CLANG_TIDY_VERSION}", "run-clang-tidy"):
# A single-quoted `FilePath:` entry in an -export-fixes YAML file, allowing the
# `- ` marker that precedes it inside a `Replacements:` sequence. clang-tidy
# emits paths single-quoted and doubles any embedded quote per YAML rules.
FILEPATH_RE = re.compile(r"^(\s*(?:-\s+)?FilePath:\s*)'((?:[^']|'')*)'\s*$")
def find_tool(name: str) -> str | None:
for candidate in (f"{name}-{CLANG_TIDY_VERSION}", name):
if path := shutil.which(candidate):
return path
return None
@@ -35,23 +54,43 @@ def find_build_dir(repo_root: Path) -> Path | None:
return None
def staged_files(repo_root: Path) -> list[Path]:
"""Return absolute paths of staged, lint-able C/C++ files.
`--diff-filter=d` excludes deletions so we never lint a removed file.
"""
output = subprocess.check_output(
["git", "diff", "--staged", "--name-only", "--diff-filter=d", "--"]
+ [f"*{ext}" for ext in TIDY_EXTENSIONS],
text=True,
cwd=repo_root,
)
return [repo_root / rel for rel in output.splitlines() if rel]
def canonicalize_fix_paths(fixes_dir: Path) -> None:
"""Rewrite every `FilePath` in the exported fixes to its real source path.
A header included through a module's isolation symlink is recorded under that
symlink's path; collapsing all paths to the same real file lets
clang-apply-replacements recognise the per-translation-unit duplicates and
apply each fix once.
"""
for yaml in fixes_dir.glob("*.yaml"):
lines = []
for line in yaml.read_text().splitlines():
if m := FILEPATH_RE.match(line):
path = m.group(2).replace("''", "'")
real = os.path.realpath(path).replace("'", "''")
line = f"{m.group(1)}'{real}'"
lines.append(line)
yaml.write_text("\n".join(lines) + "\n")
def main():
if not os.environ.get("TIDY"):
return 0
files = sys.argv[1:]
if not files:
return 0
run_clang_tidy = find_run_clang_tidy()
if not run_clang_tidy:
print(
f"clang-tidy check failed: TIDY is enabled but neither "
f"'run-clang-tidy-{CLANG_TIDY_VERSION}' nor 'run-clang-tidy' was found in PATH.",
file=sys.stderr,
)
return 1
repo_root = Path(
subprocess.check_output(
["git", "rev-parse", "--show-toplevel"],
@@ -59,6 +98,29 @@ def main():
text=True,
).strip()
)
files = staged_files(repo_root)
if not files:
return 0
run_clang_tidy = find_tool("run-clang-tidy")
clang_apply_replacements = find_tool("clang-apply-replacements")
missing = [
name
for name, path in (
("run-clang-tidy", run_clang_tidy),
("clang-apply-replacements", clang_apply_replacements),
)
if not path
]
if missing:
print(
f"clang-tidy check failed: TIDY is enabled but {' and '.join(missing)} "
f"was not found in PATH (tried the '-{CLANG_TIDY_VERSION}' suffix too).",
file=sys.stderr,
)
return 1
build_dir = find_build_dir(repo_root)
if not build_dir:
print(
@@ -68,11 +130,23 @@ def main():
)
return 1
result = subprocess.run(
[run_clang_tidy, "-quiet", "-p", str(build_dir), "-fix", "-allow-no-checks"]
+ files
)
return result.returncode
with tempfile.TemporaryDirectory() as fixes_dir:
result = subprocess.run(
[
run_clang_tidy,
"-quiet",
"-p",
build_dir,
"-export-fixes",
fixes_dir,
"-allow-no-checks",
]
+ files
)
canonicalize_fix_paths(Path(fixes_dir))
applied = subprocess.run([clang_apply_replacements, fixes_dir])
return result.returncode or applied.returncode
if __name__ == "__main__":

View File

@@ -3,6 +3,9 @@
#include <xrpl/basics/IntrusivePointer.ipp>
#include <xrpl/basics/Log.h> // IWYU pragma: keep
#include <xrpl/basics/TaggedCache.h>
#include <xrpl/basics/scope.h>
#include <algorithm>
namespace xrpl {
@@ -601,8 +604,39 @@ TaggedCache<Key, T, IsKeyCache, SharedWeakUnionPointer, SharedPointerType, Hash,
std::vector<key_type> v;
{
std::scoped_lock const lock(mutex_);
v.reserve(cache_.size());
// Keep track of how many iterations are needed. Exit the loop if the number of retries gets
// absurd. (Note that if this somehow ever happens, one more allocation will be done under
// lock, which is undesirable, but really should be almost impossible.)
std::size_t allocationIterations = 0;
std::unique_lock lock(mutex_);
for (auto size = cache_.size(); v.capacity() < size && allocationIterations < 20;
size = cache_.size())
{
ScopeUnlock const unlock(lock);
// Allocate the current size plus a little extra, in case the cache grows while
// allocating. Each time another allocation is needed, the extra also gets bigger until
// it ultimately doubles the size + 1.
size += (size >> (4 - std::min(allocationIterations, std::size_t{4}))) + 1;
v.reserve(size);
++allocationIterations;
}
// In a normal operating environment, because of the padding added to size before
// allocating, even 2 iterations is going to be very rare. If 3 or more are ever needed,
// that's unusual enough that I want to know about it. Don't ask me to change it without
// empirical data. - Ed H.
XRPL_ASSERT(
allocationIterations < 3,
"xrpl::TaggedCache::getKeys(): limited allocation iterations");
if (v.capacity() < cache_.size())
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::TaggedCache::getKeys(): failed to allocate sufficient capacity");
v.reserve(cache_.size());
// LCOV_EXCL_STOP
}
XRPL_ASSERT(lock.owns_lock(), "xrpl::TaggedCache::getKeys(): owns lock");
XRPL_ASSERT(
v.capacity() >= cache_.size(), "xrpl::TaggedCache::getKeys(): sufficient capacity");
for (auto const& _ : cache_)
v.push_back(_.first);
}

View File

@@ -13,7 +13,6 @@
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/STXChainBridge.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/jss.h>
#include <array>
#include <cstdint>
@@ -423,21 +422,8 @@ struct KeyletDesc
bool includeInTests{};
};
// This list should include all of the keylet functions that take a single
// AccountID parameter.
std::array<KeyletDesc<AccountID const&>, 6> const kDirectAccountKeylets{
{{.function = &keylet::account, .expectedLEName = jss::AccountRoot, .includeInTests = false},
{.function = &keylet::ownerDir, .expectedLEName = jss::DirectoryNode, .includeInTests = true},
{.function = &keylet::signerList, .expectedLEName = jss::SignerList, .includeInTests = true},
// It's normally impossible to create an item at nftpage_min, but
// test it anyway, since the invariant checks for it.
{.function = &keylet::nftokenPageMin,
.expectedLEName = jss::NFTokenPage,
.includeInTests = true},
{.function = &keylet::nftokenPageMax,
.expectedLEName = jss::NFTokenPage,
.includeInTests = true},
{.function = &keylet::did, .expectedLEName = jss::DID, .includeInTests = true}}};
// This list should include all of the keylet functions that take a single AccountID parameter.
extern std::array<KeyletDesc<AccountID const&>, 6> const kDirectAccountKeylets;
MPTID
makeMptID(std::uint32_t sequence, AccountID const& account);

View File

@@ -110,6 +110,7 @@ JSS(accounts); // in: LedgerEntry, Subscribe, handlers/Ledger
JSS(accounts_proposed); // in: Subscribe, Unsubscribe
JSS(action); //
JSS(active); // out: OverlayImpl
JSS(actor); // in/out: AccountTx
JSS(acquiring); // out: LedgerRequest
JSS(address); // out: PeerImp
JSS(affected); // out: AcceptedLedgerTx
@@ -133,6 +134,7 @@ JSS(attestation_reward_account); //
JSS(auction_slot); // out: amm_info
JSS(authorized); // out: AccountLines
JSS(authorize); // out: delegate
JSS(authorizer); // in/out: AccountTx
JSS(authorized_credentials); // in: ledger_entry DepositPreauth
JSS(auth_accounts); // out: amm_info
JSS(auth_change); // out: AccountInfo
@@ -191,6 +193,7 @@ JSS(converge_time); // out: NetworkOPs
JSS(converge_time_s); // out: NetworkOPs
JSS(cookie); // out: NetworkOPs
JSS(count); // in: AccountTx*, ValidatorList
JSS(counter_party); // in/out: AccountTx
JSS(counters); // in/out: retrieve counters
JSS(credentials); // in: deposit_authorized
JSS(credential_type); // in: LedgerEntry DepositPreauth
@@ -270,6 +273,7 @@ JSS(freeze); // out: AccountLines
JSS(freeze_peer); // out: AccountLines
JSS(deep_freeze); // out: AccountLines
JSS(deep_freeze_peer); // out: AccountLines
JSS(delegate_filter); // in/out: AccountTx
JSS(frozen_balances); // out: GatewayBalances
JSS(full); // in: LedgerClearer, handlers/Ledger
JSS(full_reply); // out: PathFind

View File

@@ -46,6 +46,22 @@ struct LedgerRange
uint32_t max;
};
/**
* @brief Enumeration of possible delegate types that can occur during filtering in account_tx
*/
enum class DelegateType {
Actor, ///< Another account signed and submitted transactions on behalf of this account (this
///< account is the owner/delegator).
Authorizer ///< This account signed and submitted transactions on behalf of another account
///< (this account is the signer/delegatee).
};
struct DelegateFilter
{
DelegateType type = DelegateType::Actor;
std::optional<AccountID> counterparty;
};
class RelationalDatabase
{
public:
@@ -82,6 +98,7 @@ public:
std::optional<AccountTxMarker> marker;
std::uint32_t limit = 0;
bool bAdmin = false;
std::optional<DelegateFilter> delegate;
};
using AccountTx = std::pair<std::shared_ptr<Transaction>, std::shared_ptr<TxMeta>>;
@@ -101,6 +118,7 @@ public:
bool forward = false;
uint32_t limit = 0;
std::optional<AccountTxMarker> marker;
std::optional<DelegateFilter> delegate;
};
struct AccountTxResult
@@ -109,6 +127,7 @@ public:
LedgerRange ledgerRange{};
uint32_t limit = 0;
std::optional<AccountTxMarker> marker;
std::optional<DelegateFilter> delegate;
};
virtual ~RelationalDatabase() = default;

View File

@@ -29,7 +29,7 @@ src:test/beast/beast_PropertyStream_test.cpp
src:src/test/app/Invariants_test.cpp
# ASan false positive: stack-use-after-scope in ErrorCodes.h inline functions.
# When Clang inlines the StaticString overloads (e.g. invalid_field_error(StaticString)),
# When Clang inlines the StaticString overloads (e.g. invalidFieldError(StaticString)),
# ASan scope-poisons the temporary std::string before the inlined callee finishes reading
# through the const ref. This corrupts the coroutine stack and crashes the Simulate test.
# See asan.supp comments for full explanation and planned fix.

View File

@@ -17,6 +17,7 @@
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/digest.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/protocol/nftPageMask.h>
#include <boost/endian/conversion.hpp>
@@ -32,6 +33,23 @@
namespace xrpl {
// This list should include all of the keylet functions that take a single
// AccountID parameter. Declared in Indexes.h; defined here so the header need
// not include jss.h.
std::array<KeyletDesc<AccountID const&>, 6> const kDirectAccountKeylets{
{{.function = &keylet::account, .expectedLEName = jss::AccountRoot, .includeInTests = false},
{.function = &keylet::ownerDir, .expectedLEName = jss::DirectoryNode, .includeInTests = true},
{.function = &keylet::signerList, .expectedLEName = jss::SignerList, .includeInTests = true},
// It's normally impossible to create an item at nftpage_min, but
// test it anyway, since the invariant checks for it.
{.function = &keylet::nftokenPageMin,
.expectedLEName = jss::NFTokenPage,
.includeInTests = true},
{.function = &keylet::nftokenPageMax,
.expectedLEName = jss::NFTokenPage,
.includeInTests = true},
{.function = &keylet::did, .expectedLEName = jss::DID, .includeInTests = true}}};
/**
* Type-specific prefix for calculating ledger indices.
*

View File

@@ -5,6 +5,7 @@
#include <test/jtx/amount.h>
#include <test/jtx/balance.h> // IWYU pragma: keep
#include <test/jtx/check.h>
#include <test/jtx/delegate.h>
#include <test/jtx/deposit.h>
#include <test/jtx/envconfig.h>
#include <test/jtx/fee.h>
@@ -28,6 +29,7 @@
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/ApiVersion.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/Indexes.h>
@@ -45,6 +47,7 @@
#include <initializer_list>
#include <iterator>
#include <memory>
#include <optional>
#include <string>
#include <utility>
@@ -889,6 +892,426 @@ class AccountTx_test : public beast::unit_test::Suite
checkAliceAcctTx(9, jss::Payment);
}
void
testDelegation()
{
testcase("Delegation Filtering");
using namespace test::jtx;
Env env(*this);
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
env.fund(XRP(10000), alice, bob, carol);
env.close();
// Normal TX: Alice pays Carol (Signed by Alice's Master Key)
env(pay(alice, carol, XRP(10)));
env.close();
// Setup Delegation: Alice allows Bob to sign Payments for her
env(delegate::set(alice, bob, {"Payment"}));
env.close();
// Delegated TX: Alice pays Bob (Signed by Bob using Delegation)
env(pay(alice, bob, XRP(20)), delegate::As(bob));
env.close();
// Normal TX: Bob pays Carol (Signed by Bob for himself)
env(pay(bob, carol, XRP(30)));
env.close();
auto const countTxs = [&](AccountID const& account,
json::Value const& delegateParams,
std::optional<std::uint32_t> const limit = std::nullopt) -> int {
int count = 0;
json::Value marker;
bool haveMarker = false;
int pages = 0;
while (true)
{
json::Value params;
params[jss::account] = toBase58(account);
params[jss::ledger_index_min] = -1;
params[jss::ledger_index_max] = -1;
if (!delegateParams.isNull())
params[jss::delegate] = delegateParams;
if (limit)
params[jss::limit] = *limit;
if (haveMarker)
params[jss::marker] = marker;
auto const res = env.rpc("json", "account_tx", to_string(params));
auto const& result = res[jss::result];
if (result.isMember(jss::transactions))
count += result[jss::transactions].size();
if (!limit || !result.isMember(jss::marker))
break;
marker = result[jss::marker];
haveMarker = true;
++pages;
if (!BEAST_EXPECT(pages < 20))
break;
}
return count;
};
auto const checkError = [&](json::Value const& delegateParams,
std::string const& errToken) {
json::Value params;
params[jss::account] = alice.human();
params[jss::delegate] = delegateParams;
auto res = env.rpc("json", "account_tx", to_string(params));
BEAST_EXPECT(res[jss::result][jss::error] == errToken);
};
// Filter: Delegatee. Expects TX #2 (Signed by Bob)
{
json::Value p;
p[jss::delegate_filter] = "actor";
BEAST_EXPECT(countTxs(alice.id(), p) == 1);
}
// Filter: Delegatee + Counterparty Bob. Expects TX #2.
{
json::Value p;
p[jss::delegate_filter] = "actor";
p[jss::counter_party] = bob.human();
BEAST_EXPECT(countTxs(alice.id(), p) == 1);
}
// Filter: Delegatee + Counterparty Carol. Expects 0.
{
json::Value p;
p[jss::delegate_filter] = "actor";
p[jss::counter_party] = carol.human();
BEAST_EXPECT(countTxs(alice.id(), p) == 0);
}
// Filter: Delegator. Expects TX #2.
// (Bob signed it, but Alice is the owner).
{
json::Value p;
p[jss::delegate_filter] = "authorizer";
BEAST_EXPECT(countTxs(bob.id(), p) == 1);
}
// Filter: Delegator + Counterparty Alice. Expects TX #2.
{
json::Value p;
p[jss::delegate_filter] = "authorizer";
p[jss::counter_party] = alice.human();
BEAST_EXPECT(countTxs(bob.id(), p) == 1);
}
// Filter: Authorizer. Expect: None.
// TX #2 has sfDelegate present, but Alice is the delegator/owner, not
// the delegate signer
{
json::Value p;
p[jss::delegate_filter] = "authorizer";
BEAST_EXPECT(countTxs(alice.id(), p) == 0);
}
// Query Bob (Signer), Filter: Delegator, Counterparty: Carol
// Expect: None (Alice is Owner, not Carol)
{
json::Value p;
p[jss::delegate_filter] = "authorizer";
p[jss::counter_party] = carol.human();
BEAST_EXPECT(countTxs(bob.id(), p) == 0);
}
// Query Bob (Signer), Filter: Delegatee
// Expect: None. Bob did not employ a delegatee for his own TXs (TX C).
{
json::Value p;
p[jss::delegate_filter] = "actor";
BEAST_EXPECT(countTxs(bob.id(), p) == 0);
}
// "delegate" is not an object (e.g., string)
{
json::Value const p = "not_an_object";
checkError(p, "invalidParams");
}
// Missing "delegate_filter" inside object
{
json::Value const p(json::ValueType::Object);
checkError(p, "invalidParams");
}
// "delegate_filter" is not a string (e.g., int)
{
json::Value p;
p[jss::delegate_filter] = 123;
checkError(p, "invalidParams");
}
// "delegate_filter" has invalid value
{
json::Value p;
p[jss::delegate_filter] = "random_string";
checkError(p, "invalidParams");
}
// "counterparty" is not a string
{
json::Value p;
p[jss::delegate_filter] = "actor";
p[jss::counter_party] = 123;
checkError(p, "invalidParams");
}
// "counterparty" is malformed base58
{
json::Value p;
p[jss::delegate_filter] = "actor";
p[jss::counter_party] = "not_an_account";
checkError(p, "actMalformed");
}
// Multi-signed non-delegated TX: Alice pays Carol via multi-sig.
// sfDelegate is absent and sfSigningPubKey is empty — the filter
// must skip it without crashing.
{
Account const daria{"daria"};
Account const edward{"edward"};
env.fund(XRP(1000), daria, edward);
env.close();
env(signers(alice, 2, {{daria, 1}, {edward, 1}}));
env.close();
env(pay(alice, carol, XRP(1)),
Fee(drops(env.current()->fees().increment * 2)),
Msig(daria, edward));
env.close();
// Alice's actor filter should still see only the 1 delegated tx,
// not the multi-signed one.
json::Value p;
p[jss::delegate_filter] = "actor";
BEAST_EXPECT(countTxs(alice.id(), p) == 1);
}
// Regular-key-signed non-delegated TX: Alice pays Bob, signed by Bob
// as Alice's regular key. This must not be treated as delegation.
{
env(regkey(alice, bob));
env.close();
env(pay(alice, bob, XRP(1)));
env.close();
json::Value actorFilter;
actorFilter[jss::delegate_filter] = "actor";
BEAST_EXPECT(countTxs(alice.id(), actorFilter) == 1);
// limit: 1 forces pagination past newer non-delegated rows.
BEAST_EXPECT(countTxs(alice.id(), actorFilter, 1) == 1);
actorFilter[jss::counter_party] = bob.human();
BEAST_EXPECT(countTxs(alice.id(), actorFilter) == 1);
BEAST_EXPECT(countTxs(alice.id(), actorFilter, 1) == 1);
json::Value authorizerFilter;
authorizerFilter[jss::delegate_filter] = "authorizer";
BEAST_EXPECT(countTxs(bob.id(), authorizerFilter) == 1);
BEAST_EXPECT(countTxs(bob.id(), authorizerFilter, 1) == 1);
authorizerFilter[jss::counter_party] = alice.human();
BEAST_EXPECT(countTxs(bob.id(), authorizerFilter) == 1);
BEAST_EXPECT(countTxs(bob.id(), authorizerFilter, 1) == 1);
}
// Pagination marker/delegate-filter consistency. A marker returned by a
// delegate-filtered query carries a `delegate` flag and is only valid
// for a follow-up request that repeats the filter; mixing the two
// marker conventions must be rejected with invalidParams.
{
json::Value actorFilter;
actorFilter[jss::delegate_filter] = "actor";
// Obtain a delegate-filtered marker (limit 1 forces pagination).
json::Value dp;
dp[jss::account] = alice.human();
dp[jss::ledger_index_min] = -1;
dp[jss::ledger_index_max] = -1;
dp[jss::delegate] = actorFilter;
dp[jss::limit] = 1;
auto const dRes = env.rpc("json", "account_tx", to_string(dp));
BEAST_EXPECT(dRes[jss::result].isMember(jss::marker));
json::Value const delegateMarker = dRes[jss::result][jss::marker];
BEAST_EXPECT(
delegateMarker.isMember(jss::delegate) && delegateMarker[jss::delegate].asBool());
// Reusing a delegate marker without the delegate filter is rejected.
{
json::Value p;
p[jss::account] = alice.human();
p[jss::ledger_index_min] = -1;
p[jss::ledger_index_max] = -1;
p[jss::limit] = 1;
p[jss::marker] = delegateMarker;
auto const r = env.rpc("json", "account_tx", to_string(p));
BEAST_EXPECT(r[jss::result][jss::error] == "invalidParams");
}
// Obtain a non-delegate marker; it must not carry the flag.
json::Value np;
np[jss::account] = alice.human();
np[jss::ledger_index_min] = -1;
np[jss::ledger_index_max] = -1;
np[jss::limit] = 1;
auto const nRes = env.rpc("json", "account_tx", to_string(np));
BEAST_EXPECT(nRes[jss::result].isMember(jss::marker));
json::Value const normalMarker = nRes[jss::result][jss::marker];
BEAST_EXPECT(!normalMarker.isMember(jss::delegate));
// Reusing a non-delegate marker with a delegate filter is rejected.
{
json::Value p;
p[jss::account] = alice.human();
p[jss::ledger_index_min] = -1;
p[jss::ledger_index_max] = -1;
p[jss::limit] = 1;
p[jss::delegate] = actorFilter;
p[jss::marker] = normalMarker;
auto const r = env.rpc("json", "account_tx", to_string(p));
BEAST_EXPECT(r[jss::result][jss::error] == "invalidParams");
}
}
}
void
testDelegationMultiSign()
{
testcase("Delegation filter with multi-signed delegatee");
using namespace test::jtx;
Env env(*this);
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
Account const daria{"daria"};
Account const edward{"edward"};
env.fund(XRP(10000), alice, bob, carol, daria, edward);
env.close();
// Bob's identity is established via multi-sig (daria + edward)
env(signers(bob, 2, {{daria, 1}, {edward, 1}}));
env.close();
env(delegate::set(alice, bob, {"Payment"}));
env.close();
// Delegated tx: Alice pays Carol, Bob signs via multi-sig
env(pay(alice, carol, XRP(10)), Fee(XRP(1)), delegate::As(bob), Msig(daria, edward));
env.close();
auto const countTxs = [&](AccountID const& account,
json::Value const& delegateParams) -> int {
json::Value params;
params[jss::account] = toBase58(account);
params[jss::ledger_index_min] = -1;
params[jss::ledger_index_max] = -1;
params[jss::delegate] = delegateParams;
auto const res = env.rpc("json", "account_tx", to_string(params));
if (res[jss::result].isMember(jss::transactions))
return res[jss::result][jss::transactions].size();
return 0;
};
// Alice (owner) finds the tx with actor filter
{
json::Value p;
p[jss::delegate_filter] = "actor";
BEAST_EXPECT(countTxs(alice.id(), p) == 1);
}
// Alice (owner) + counterparty Bob finds the tx
{
json::Value p;
p[jss::delegate_filter] = "actor";
p[jss::counter_party] = bob.human();
BEAST_EXPECT(countTxs(alice.id(), p) == 1);
}
// Bob (delegatee) finds the tx with authorizer filter
{
json::Value p;
p[jss::delegate_filter] = "authorizer";
BEAST_EXPECT(countTxs(bob.id(), p) == 1);
}
// Bob (delegatee) + counterparty Alice finds the tx
{
json::Value p;
p[jss::delegate_filter] = "authorizer";
p[jss::counter_party] = alice.human();
BEAST_EXPECT(countTxs(bob.id(), p) == 1);
}
}
void
testDelegationMarkerWithinPage()
{
testcase("Delegation filter marker within a single query page");
using namespace test::jtx;
Env env(*this);
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
env.fund(XRP(10000), alice, bob, carol);
env.close();
env(delegate::set(alice, bob, {"Payment"}));
env.close();
auto const startLedger = env.closed()->header().seq + 1;
env(pay(alice, carol, XRP(1)), delegate::As(bob));
env.close();
env(pay(alice, carol, XRP(1)), delegate::As(bob));
env.close();
json::Value p;
p[jss::delegate_filter] = "actor";
json::Value params;
params[jss::account] = alice.human();
params[jss::ledger_index_min] = startLedger;
params[jss::ledger_index_max] = -1;
params[jss::delegate] = p;
params[jss::limit] = 1;
auto const res = env.rpc("json", "account_tx", to_string(params));
auto const& result = res[jss::result];
// The first page emits only the first delegated payment. (as page limit is set to 1)
BEAST_EXPECT(result[jss::transactions].size() == 1);
BEAST_EXPECT(result.isMember(jss::marker));
// Following the marker resumes right after the first payment and
// returns the second one.
json::Value page2 = params;
page2[jss::marker] = result[jss::marker];
auto const res2 = env.rpc("json", "account_tx", to_string(page2));
BEAST_EXPECT(res2[jss::result][jss::transactions].size() == 1);
}
void
testSponsorship()
{
@@ -973,6 +1396,9 @@ public:
testContents();
testAccountDelete();
testMPT();
testDelegation();
testDelegationMultiSign();
testDelegationMarkerWithinPage();
testSponsorship();
}
};

View File

@@ -3880,7 +3880,8 @@ NetworkOPsImp::addAccountHistoryJob(SubAccountHistoryInfoWeak subInfo)
.ledgerRange = {.min = minLedger, .max = maxLedger},
.marker = marker,
.limit = 0,
.bAdmin = true};
.bAdmin = true,
.delegate = std::nullopt};
return db.newestAccountTxPage(options);
};

View File

@@ -11,6 +11,7 @@
#include <xrpl/basics/ByteUtilities.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/RangeSet.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/basics/contract.h>
@@ -28,6 +29,7 @@
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/LedgerHeader.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/TxMeta.h>
@@ -990,6 +992,57 @@ getNewestAccountTxsB(
return getAccountTxsB(session, app, options, true, j);
}
/**
* @brief Determines whether a transaction should be included in account_tx
* results based on a delegation filter.
* @param rawData Serialized transaction blob.
* @param filter The delegate filter specifying the role of the queried account
* (Actor or Authorizer) and an optional counterparty to match against.
* @param contextAccount The account passed to account_tx (the queried account).
* @return True if the transaction passes the filter and should be included,
* false if it should be skipped.
*/
static bool
passesDelegateFilter(
Blob const& rawData,
DelegateFilter const& filter,
AccountID const& contextAccount)
{
SerialIter sit{makeSlice(rawData)};
STTx const tx{sit};
AccountID const txOwner = tx.getAccountID(sfAccount);
if (!tx.isFieldPresent(sfDelegate))
return false;
AccountID const txSigner = tx.getAccountID(sfDelegate);
switch (filter.type)
{
case DelegateType::Actor: {
// Keep txns where the queried account (A) is the owner but
// another account (C) was the delegatee that signed.
bool const isDelegated = (txOwner == contextAccount) && (txSigner != contextAccount);
if (!isDelegated)
return false;
return !filter.counterparty || (txSigner == *filter.counterparty);
}
case DelegateType::Authorizer: {
// Keep txns where the queried account (C) is the signer acting
// on behalf of another account (A, the delegator/owner).
bool const isActingAsDelegate =
(txSigner == contextAccount) && (txOwner != contextAccount);
if (!isActingAsDelegate)
return false;
return !filter.counterparty || (txOwner == *filter.counterparty);
}
}
return false; // LCOV_EXCL_LINE
}
/**
* @brief accountTxPage Searches for the oldest or newest transactions for the
* account that matches the given criteria starting from the provided
@@ -1020,6 +1073,7 @@ accountTxPage(
{
int total = 0;
bool const hasDelegateFilter = options.delegate.has_value();
bool lookingForMarker = options.marker.has_value();
std::uint32_t numberOfResults = 0;
@@ -1107,6 +1161,11 @@ accountTxPage(
{
Blob rawData;
Blob rawMeta;
// Delegate filtering happens after SQL, so skipped rows need their own
// continuation marker accounting.
std::uint32_t fetchedRows = 0;
std::optional<RelationalDatabase::AccountTxMarker> lastEmitted;
std::optional<RelationalDatabase::AccountTxMarker> lastScanned;
// SOCI requires boost::optional (not std::optional) as parameters.
boost::optional<std::uint64_t> ledgerSeq;
@@ -1128,18 +1187,31 @@ accountTxPage(
while (st.fetch())
{
if (hasDelegateFilter)
{
++fetchedRows;
lastScanned = {
.ledgerSeq = rangeCheckedCast<std::uint32_t>(ledgerSeq.value_or(0)),
.txnSeq = txnSeq.value_or(0)};
}
if (lookingForMarker)
{
if (findLedger == ledgerSeq.value_or(0) && findSeq == txnSeq.value_or(0))
{
lookingForMarker = false;
// Delegate markers are continuation cursors for the last
// scanned row, so resume after the marker row.
if (hasDelegateFilter)
continue;
}
else
{
continue;
}
}
else if (numberOfResults == 0)
if (!hasDelegateFilter && numberOfResults == 0)
{
newmarker = {
.ledgerSeq = rangeCheckedCast<std::uint32_t>(ledgerSeq.value_or(0)),
@@ -1165,6 +1237,23 @@ accountTxPage(
rawMeta.clear();
}
if (hasDelegateFilter)
{
if (rawData.empty() ||
!passesDelegateFilter(rawData, options.delegate.value(), options.account))
{
rawData.clear();
rawMeta.clear();
continue;
}
if (numberOfResults == 0)
{
newmarker = lastEmitted;
break;
}
}
// Work around a bug that could leave the metadata missing
if (rawMeta.empty())
onUnsavedLedger(ledgerSeq.value_or(0));
@@ -1186,7 +1275,18 @@ accountTxPage(
--numberOfResults;
total++;
if (hasDelegateFilter)
{
lastEmitted = {
.ledgerSeq = rangeCheckedCast<std::uint32_t>(ledgerSeq.value_or(0)),
.txnSeq = txnSeq.value_or(0)};
}
}
// If this filtered page did not fill the requested number of results,
// still return a marker so the caller can continue scanning later rows.
if (hasDelegateFilter && !newmarker && !lookingForMarker && fetchedRows == queryLimit)
newmarker = lastScanned;
}
return {newmarker, total};

View File

@@ -30,6 +30,7 @@
#include <xrpl/resource/Fees.h>
#include <cstdint>
#include <expected>
#include <memory>
#include <optional>
#include <type_traits>
@@ -38,6 +39,48 @@
namespace xrpl {
static std::expected<DelegateFilter, json::Value>
parseDelegateFilter(json::Value const& delegateNode)
{
if (!delegateNode.isObject())
return std::unexpected(RPC::invalidFieldError(jss::delegate));
if (!delegateNode.isMember(jss::delegate_filter) ||
!delegateNode[jss::delegate_filter].isString())
return std::unexpected(RPC::invalidFieldError(jss::delegate_filter));
auto const& delegateFilterStr = delegateNode[jss::delegate_filter].asString();
auto typeResult = [&] -> std::expected<DelegateType, json::Value> {
if (delegateFilterStr == "actor")
return DelegateType::Actor;
if (delegateFilterStr == "authorizer")
return DelegateType::Authorizer;
return std::unexpected(RPC::invalidFieldError(jss::delegate_filter));
}();
if (!typeResult)
return std::unexpected(typeResult.error());
DelegateType const type = *typeResult;
std::optional<AccountID> counterparty;
if (delegateNode.isMember(jss::counter_party))
{
if (!delegateNode[jss::counter_party].isString())
return std::unexpected(RPC::invalidFieldError(jss::counter_party));
counterparty = parseBase58<AccountID>(delegateNode[jss::counter_party].asString());
if (!counterparty)
return std::unexpected(rpcError(RpcActMalformed));
}
return DelegateFilter{.type = type, .counterparty = counterparty};
}
using TxnsData = RelationalDatabase::AccountTxs;
using TxnsDataBinary = RelationalDatabase::MetaTxsList;
using TxnDataBinary = RelationalDatabase::txnMetaLedgerType;
@@ -231,7 +274,8 @@ doAccountTxHelp(RPC::Context& context, AccountTxArgs const& args)
.ledgerRange = result.ledgerRange,
.marker = result.marker,
.limit = args.limit,
.bAdmin = isUnlimited(context.role)};
.bAdmin = isUnlimited(context.role),
.delegate = args.delegate};
auto& db = context.app.getRelationalDatabase();
@@ -370,6 +414,9 @@ populateJsonResponse(
response[jss::marker] = json::ValueType::Object;
response[jss::marker][jss::ledger] = result.marker->ledgerSeq;
response[jss::marker][jss::seq] = result.marker->txnSeq;
if (args.delegate)
response[jss::marker][jss::delegate] = true;
}
}
@@ -386,7 +433,17 @@ populateJsonResponse(
// limit: integer, // optional
// marker: object {ledger: ledger_index, seq: txn_sequence} // optional,
// resume previous query
// delegate: object { // optional
// delegate_filter: string, // required; "actor" or "authorizer"
// counter_party: account // optional
// }
// }
//
// Pagination note for delegate-filtered queries: the `delegate` object (both
// `delegate_filter` and `counter_party`) must be supplied unchanged on every
// paginated request until the query completes. A marker returned by a
// delegate-filtered query is only valid for a follow-up request that repeats
// the same `delegate` object
json::Value
doAccountTx(RPC::JsonContext& context)
{
@@ -454,6 +511,38 @@ doAccountTx(RPC::JsonContext& context)
.ledgerSeq = token[jss::ledger].asUInt(), .txnSeq = token[jss::seq].asUInt()};
}
if (params.isMember(jss::delegate))
{
if (auto const filter = parseDelegateFilter(params[jss::delegate]); filter.has_value())
{
args.delegate = *filter;
}
else
{
return filter.error();
}
}
// A marker produced by a delegate-filtered query uses a different
// pagination cursor than a normal query, so it is only valid when the same
// `delegate` object is supplied again. Reject any mismatch so pagination
// cannot silently skip or duplicate results.
if (args.marker)
{
bool const markerFromDelegate = params[jss::marker].isMember(jss::delegate) &&
params[jss::marker][jss::delegate].isBool() &&
params[jss::marker][jss::delegate].asBool();
if (markerFromDelegate != args.delegate.has_value())
{
RPC::Status const status{
RpcInvalidParams,
"Do not mix delegate and non-delegate pagination markers in account_tx; "
"repeat the same `delegate` object when using a delegate marker."};
status.inject(response);
return response;
}
}
auto res = doAccountTxHelp(context, args);
JLOG(context.j.debug()) << __func__ << " populating response";
return populateJsonResponse(res, args, context);