Compare commits

..

4 Commits

Author SHA1 Message Date
Nicholas Dudfield
99bd33b81d chore(json-tx): clang-format pass + regen hook/sfcodes.h
* clang-format-18 pass over the json-tx touchpoints so the
  clang-format CI workflow is green.
* regenerate hook/sfcodes.h (adds the sfJsonTxBody entry) so the
  verify-generated-headers CI workflow is green.
2026-06-19 09:21:33 +07:00
Nicholas Dudfield
f07bc01b0b refactor(json-tx): split hasBody from empty-body rejection
hasBody was conflating 'field is declared' with 'field is non-empty'.
a classical tx could ride along with a spurious empty sfJsonTxBody and
silently fall through to the classical sig path.

now:
* hasBody = sfJsonTxBody is present (pure routing predicate)
* checkSignature / checkStructuralEquivalence reject an empty body up
  front with an explicit 'JsonTxBody is empty.' error string

behaviour for well-formed json-tx and well-formed classical-tx is
unchanged; the classical+empty-body edge case now fails cleanly instead
of silently routing classical.
2026-06-19 09:21:09 +07:00
Nicholas Dudfield
2dea0b7031 chore: add json-tx-py prototype
python exploration of the client-side (any json) and node-side (field-aware
codec: OP_FIELD / OP_NAME / OP_VALUE / OP_TAG / OP_RAW). not wired into
the c++ build; kept for design reference and size measurements.
2026-06-19 09:21:09 +07:00
Nicholas Dudfield
0149fe03aa feat: json-tx -- submit ascii-signed json directly
introduces a signing scheme where the signature covers the raw utf-8
bytes of a tx_json_str the client produced, rather than the classical
binary signing payload. clients need no codec library: dump json,
sign bytes, post.

protocol:
* new sfJsonTxBody VL field (notSigning) carrying the exact ascii bytes
* new featureJsonTx amendment gating the new sign path
* STTx::checkSign routes to jsonTx::checkSignature when the amendment
  is active and the body is present
* passesLocalChecks runs jsonTx::checkStructuralEquivalence so the body
  must parse to the same canonical fields as the tx itself

helper:
* include/xrpl/protocol/JsonTx.h -- hasBody / body / bodyHash
  (sha512half over the body) / checkSignature / checkStructuralEquivalence

rpc:
* submit_json_tx handler: { tx_json_str, signature } -> verify ascii sig,
  stuff body + sig into the tx, forceValidity(SigGoodOnly), route through
  the normal processTransaction flow. gated on featureJsonTx.

tests:
* ripple.app.JsonTx: feature gate, basic roundtrip, invalid params,
  invalid json, bad signature, sig-over-different-bytes, wrong pubkey,
  helper unit tests including structural-equivalence tamper case.
2026-06-19 09:21:09 +07:00
39 changed files with 2275 additions and 3145 deletions

View File

@@ -95,16 +95,8 @@ if [[ "$4" == "" ]]; then
echo "Non GH, local building, no Action runner magic"
else
# GH Action, runner
if [[ "$(git rev-parse --abbrev-ref HEAD)" == "release" ]]; then
echo "building on the release branch... placing it in builds/candidate"
mkdir /data/builds/candidate
cp /io/release-build/xahaud /data/builds/candidate/$(date +%Y).$(date +%-m).$(date +%-d)-$(git rev-parse --abbrev-ref HEAD)+$4
cp /io/release-build/release.info /data/builds/candidate/$(date +%Y).$(date +%-m).$(date +%-d)-$(git rev-parse --abbrev-ref HEAD)+$4.releaseinfo
else
echo "building non-release branch, placing it in builds root"
cp /io/release-build/xahaud /data/builds/$(date +%Y).$(date +%-m).$(date +%-d)-$(git rev-parse --abbrev-ref HEAD)+$4
cp /io/release-build/release.info /data/builds/$(date +%Y).$(date +%-m).$(date +%-d)-$(git rev-parse --abbrev-ref HEAD)+$4.releaseinfo
fi
cp /io/release-build/xahaud /data/builds/$(date +%Y).$(date +%-m).$(date +%-d)-$(git rev-parse --abbrev-ref HEAD)+$4
cp /io/release-build/release.info /data/builds/$(date +%Y).$(date +%-m).$(date +%-d)-$(git rev-parse --abbrev-ref HEAD)+$4.releaseinfo
echo "Published build to: http://build.xahau.tech/"
echo $(date +%Y).$(date +%-m).$(date +%-d)-$(git rev-parse --abbrev-ref HEAD)+$4
fi

View File

@@ -221,6 +221,7 @@
#define sfProvider ((7U << 16U) + 30U)
#define sfMPTokenMetadata ((7U << 16U) + 31U)
#define sfCredentialType ((7U << 16U) + 32U)
#define sfJsonTxBody ((7U << 16U) + 33U)
#define sfHookName ((7U << 16U) + 97U)
#define sfRemarkValue ((7U << 16U) + 98U)
#define sfRemarkName ((7U << 16U) + 99U)

View File

@@ -62,24 +62,6 @@
namespace ripple {
namespace base64 {
/** Returns the maximum number of characters needed to base64-encode bytes. */
constexpr std::size_t
encoded_size(std::size_t const numBytes)
{
return 4 * ((numBytes + 2) / 3);
}
/** Returns an upper bound on bytes decoded from base64 characters. */
constexpr std::size_t
decoded_size(std::size_t const numChars)
{
return ((numChars / 4) * 3) + 2;
}
} // namespace base64
std::string
base64_encode(std::uint8_t const* data, std::size_t len);

View File

@@ -0,0 +1,81 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2025 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
*/
//==============================================================================
#ifndef RIPPLE_PROTOCOL_JSONTX_H_INCLUDED
#define RIPPLE_PROTOCOL_JSONTX_H_INCLUDED
#include <xrpl/basics/Blob.h>
#include <xrpl/basics/Expected.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/protocol/STTx.h>
namespace ripple {
namespace jsonTx {
/** Returns true iff the STObject declares a sfJsonTxBody field.
Used as a routing predicate: a transaction with this field present
is claimed to use the "json-tx" signing scheme and must be validated
through jsonTx::checkSignature / checkStructuralEquivalence. An
empty body field still counts as "claimed json-tx" so the empty
case is reported as a clean failure instead of silently falling
back to the classical sig path.
*/
[[nodiscard]] bool
hasBody(STObject const& obj) noexcept;
/** Borrow a Slice over the ASCII body bytes.
Returns an empty slice if sfJsonTxBody is not present. The slice is
valid for as long as the STObject the field belongs to.
*/
[[nodiscard]] Slice
body(STObject const& obj);
/** SHA-512-Half of the body bytes.
This is the deterministic "ASCII signing digest" used by json-tx:
the bytes the client sees as their message are hashed with SHA-512
and truncated to 256 bits, the same digest convention rippled uses
elsewhere. Returns a zero-valued hash if sfJsonTxBody is absent.
*/
[[nodiscard]] uint256
bodyHash(STObject const& obj);
/** Signature check only: verify sfTxnSignature against the raw bytes of
sfJsonTxBody using sfSigningPubKey.
The classical signing payload is NOT used. This is the json-tx
analogue of STTx::checkSingleSign and is intended to be called from
the same code path (e.g. STTx::checkSign).
Precondition: `stx` carries a non-empty sfJsonTxBody.
*/
[[nodiscard]] Expected<void, std::string>
checkSignature(STTx const& stx);
/** Structural-equivalence check: parse sfJsonTxBody as JSON and confirm
it serialises to the same canonical binary as the other structural
fields of `stx` (excluding sfTxnSignature and sfJsonTxBody).
This is a local-check style rule -- it should run alongside
passesLocalChecks, not inside the signature verification path.
Precondition: `stx` carries a non-empty sfJsonTxBody.
*/
[[nodiscard]] Expected<void, std::string>
checkStructuralEquivalence(STTx const& stx);
} // namespace jsonTx
} // namespace ripple
#endif

View File

@@ -40,6 +40,7 @@ XRPL_FEATURE(NamedHooks, Supported::yes, VoteBehavior::DefaultNo
XRPL_FEATURE(IOURewardClaim, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (IOULockedBalanceInvariant, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (ImportIssuer, Supported::yes, VoteBehavior::DefaultYes)
XRPL_FEATURE(JsonTx, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(HookAPISerializedType240, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(PermissionedDomains, Supported::no, VoteBehavior::DefaultNo)
XRPL_FEATURE(DynamicNFT, Supported::no, VoteBehavior::DefaultNo)

View File

@@ -293,6 +293,10 @@ TYPED_SFIELD(sfAssetClass, VL, 29)
TYPED_SFIELD(sfProvider, VL, 30)
TYPED_SFIELD(sfMPTokenMetadata, VL, 31)
TYPED_SFIELD(sfCredentialType, VL, 32)
// json-tx: the exact ASCII bytes the client signed; authoritative over
// the classical signing payload when present. Not part of the classical
// signing-payload computation (the bytes ARE the signing payload).
TYPED_SFIELD(sfJsonTxBody, VL, 33, SField::sMD_Default, SField::notSigning)
TYPED_SFIELD(sfHookName, VL, 97)
TYPED_SFIELD(sfRemarkValue, VL, 98)
TYPED_SFIELD(sfRemarkName, VL, 99)

View File

@@ -212,7 +212,6 @@ JSS(build_version); // out: NetworkOPs
JSS(bytes_written);
JSS(cancel_after); // out: AccountChannels
JSS(can_delete); // out: CanDelete
JSS(candidates); // in: ValidatorList
JSS(mpt_amount); // out: mpt_holders
JSS(mpt_issuance_id); // in: Payment, mpt_holders
JSS(mptoken_index); // out: mpt_holders

20
json-tx-py/pyproject.toml Normal file
View File

@@ -0,0 +1,20 @@
[project]
name = "json-tx"
version = "0.0.1"
description = "Prototype: canonical packing of (tx_json_str, signature) using ripple-binary-codec output as an LZ dictionary"
requires-python = ">=3.10"
dependencies = [
"xrpl-py>=4.0.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/json_tx"]
[tool.uv]
dev-dependencies = [
"pytest>=8.0",
]

View File

@@ -0,0 +1,24 @@
from json_tx import patch # noqa: F401 -- side-effect: register JsonTxCompressed
from json_tx.codec import (
JSON_TX_FIELD,
TAGS,
canonical_json,
compress_stream,
decompress_stream,
pack,
pack_wire,
unpack,
unpack_wire,
)
__all__ = [
"JSON_TX_FIELD",
"TAGS",
"canonical_json",
"compress_stream",
"decompress_stream",
"pack",
"pack_wire",
"unpack",
"unpack_wire",
]

View File

@@ -0,0 +1,79 @@
"""Demo: compare classical binary, raw JSON+sig, and JsonTxCompressed wire."""
from __future__ import annotations
import json
from xrpl.core.binarycodec import encode, encode_for_signing
from xrpl.core.keypairs import (
derive_classic_address,
derive_keypair,
generate_seed,
sign,
)
from json_tx import canonical_json, compress_stream, pack_wire, unpack_wire
SAMPLE_TX = {
"TransactionType": "Payment",
"Account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh",
"Destination": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
"Amount": "1000000",
"Fee": "12",
"Sequence": 1,
"Flags": 2147483648,
"SigningPubKey": "",
}
def _report(label: str, tx: dict, tx_json_str: str, priv: str) -> None:
signature = bytes.fromhex(sign(tx_json_str.encode().hex(), priv))
# 1. Classical signed wire: full binary with TxnSignature (what xrpl does today).
classical_signing = bytes.fromhex(encode_for_signing(tx))
classical_signed_dict = dict(tx)
classical_signed_dict["TxnSignature"] = signature.hex().upper()
classical_wire = bytes.fromhex(encode(classical_signed_dict))
# 2. Naive JSON submission: tx_json_str + signature (what json-tx wants to replace).
raw_json_plus_sig = len(tx_json_str) + len(signature)
# 3. json-tx wire: classical binary (ex TxnSignature) + JsonTxCompressed + TxnSignature.
stream = compress_stream(tx_json_str, tx_json=tx)
jsontx_wire = pack_wire(tx_json_str, signature)
print(f"\n== {label} ==")
print(f" tx_json_str : {len(tx_json_str):5d} bytes")
print(f" signature : {len(signature):5d} bytes")
print(f" classical binary (signing payload): {len(classical_signing):5d} bytes")
print(f" classical wire (binary + sig) : {len(classical_wire):5d} bytes <- today")
print(f" raw JSON + sig (bytes sent) : {raw_json_plus_sig:5d} bytes <- naive json submit")
print(f" JsonTxCompressed stream alone : {len(stream):5d} bytes [mode=0x{stream[0]:02x}]")
print(f" json-tx wire (classical + stream) : {len(jsontx_wire):5d} bytes <- proposed")
delta_vs_classical = len(jsontx_wire) - len(classical_wire)
print(f" overhead vs classical wire : {delta_vs_classical:+5d} bytes")
delta_vs_raw = len(jsontx_wire) - raw_json_plus_sig
print(f" overhead vs raw JSON+sig : {delta_vs_raw:+5d} bytes")
recovered_tx, recovered_str, recovered_sig = unpack_wire(jsontx_wire)
assert recovered_str == tx_json_str
assert recovered_sig == signature
assert recovered_tx == tx
def main() -> None:
seed = generate_seed()
pub, priv = derive_keypair(seed)
tx = dict(SAMPLE_TX)
tx["Account"] = derive_classic_address(pub)
tx["SigningPubKey"] = pub
_report("canonical tx_json_str (ordinal order, no whitespace)",
tx, canonical_json(tx), priv)
_report("non-canonical tx_json_str (insertion order + spaces)",
tx, json.dumps(tx, separators=(", ", ": ")), priv)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,384 @@
"""
json-tx: field-aware packer for (tx_json_str, signature).
The ripple binary codec already decomposes a transaction into ordered
(field_name, canonical_bytes) pairs. We reuse that as the dictionary.
Opcode stream:
OP_FIELD i -> render field i exactly as it appears in tx_json_str
OP_TAG t -> emit a glue snippet from TAGS (',', ':', '"', ...)
OP_RAW n <bytes> -> n bytes of literal passthrough
OP_END -> terminator
The stream is what gets stored in the `JsonTxCompressed` Blob field on
the wire. The TxnSignature field still carries the ed25519/secp256k1
signature, but that signature is now over the ASCII `tx_json_str`, not
the classical signing payload.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from xrpl.core.binarycodec.definitions.field_instance import FieldInstance
from xrpl.core.binarycodec.types.st_object import STObject
# `patch` registers the JsonTxCompressed field. Imported for its side effect.
from json_tx import patch as _patch # noqa: F401
OP_FIELD = 0x01 # emit `"<name>":<canonical-value>` (tight pair)
OP_TAG = 0x02 # emit a structural glue byte
OP_RAW = 0x03 # length-prefixed literal bytes
OP_NAME = 0x04 # emit `"<name>"` for field i
OP_VALUE = 0x05 # emit canonical rendering of field i's value
OP_END = 0x00
# Mode byte at the head of every stream.
MODE_CANONICAL = 0x00 # body is an OP_* stream; tx_json_str reconstructs via dictionary
MODE_VERBATIM = 0x01 # body is raw UTF-8 tx_json_str, length-prefixed
JSON_TX_FIELD = _patch.FIELD_NAME
# Structural glue. INVARIANT: no tag may end in `"` -- otherwise it would
# eat the leading `"` of a field NAME/FIELD rendering and prevent re-align.
# Order: longest first so the greedy matcher picks the most specific glue.
TAGS: list[bytes] = [
# comma-based field separators
b",\n ",
b",\n\t",
b",\n ",
b",\n ",
b",\n",
b", ",
# colon-based name:value separators
b": ",
b": ",
# leading indent after `{`
b"\n ",
b"\n\t",
b"\n ",
b"\n ",
# single chars
b"{",
b"}",
b"[",
b"]",
b",",
b":",
b'"',
b" ",
b"\n",
b"\t",
]
# ---------- varint (unsigned LEB128) ----------
def _vw(n: int) -> bytes:
if n < 0:
raise ValueError("varint must be non-negative")
out = bytearray()
while True:
b = n & 0x7F
n >>= 7
if n:
out.append(b | 0x80)
else:
out.append(b)
return bytes(out)
def _vr(buf: bytes, i: int) -> tuple[int, int]:
n = 0
shift = 0
while True:
b = buf[i]
i += 1
n |= (b & 0x7F) << shift
if not (b & 0x80):
return n, i
shift += 7
# ---------- canonical field extraction ----------
@dataclass
class CanonField:
name: str
instance: FieldInstance
canonical_bytes: bytes
value: Any
def _ordered_fields_from_dict(tx_json: dict, *, skip: set[str]) -> list[CanonField]:
"""Serialize tx_json once through STObject, then re-parse to slice each field."""
from xrpl.core.binarycodec.binary_wrappers.binary_parser import BinaryParser
from xrpl.core.binarycodec.definitions import definitions
tx_for_enc = {k: v for k, v in tx_json.items() if k not in skip}
st = STObject.from_value(tx_for_enc)
blob = bytes(st)
parser = BinaryParser(blob.hex())
total = len(parser)
fields: list[CanonField] = []
while not parser.is_end():
start = total - len(parser)
fi = parser.read_field()
parser.read_field_value(fi)
end = total - len(parser)
fields.append(
CanonField(
name=fi.name,
instance=definitions.get_field_instance(fi.name),
canonical_bytes=blob[start:end],
value=tx_json[fi.name],
)
)
return fields
def _render_name(name: str) -> bytes:
"""Render `"Name"` including the enclosing double-quotes."""
return json.dumps(name, separators=(",", ":")).encode()
def _render_value(value: Any) -> bytes:
"""Render the canonical JSON form of a value."""
return json.dumps(value, separators=(",", ":")).encode()
def _render_field_json(name: str, value: Any) -> bytes:
"""Render one tight `"Name":<value>` pair -- no whitespace."""
return _render_name(name) + b":" + _render_value(value)
def canonical_json(tx_json: dict) -> str:
"""Serialize tx_json with fields in canonical (ordinal) order.
The signed ASCII JSON must match this ordering so the opcode stream
can walk fields in lock-step with the binary dictionary. Any field the
codec does not recognize falls to the tail in insertion order.
"""
from xrpl.core.binarycodec.definitions import definitions
known, unknown = [], []
for k, v in tx_json.items():
try:
fi = definitions.get_field_instance(k)
known.append((fi.ordinal, k, v))
except Exception:
unknown.append((k, v))
known.sort(key=lambda x: x[0])
ordered = [(k, v) for _o, k, v in known] + unknown
body = ",".join(
f"{json.dumps(k, separators=(',', ':'))}:"
f"{json.dumps(v, separators=(',', ':'))}"
for k, v in ordered
)
return "{" + body + "}"
# ---------- stream codec (opcode layer only) ----------
def compress_stream(tx_json_str: str, *, tx_json: dict | None = None) -> bytes:
"""Encode tx_json_str using tx_json's fields as dictionary.
Opcodes (after the mode byte):
OP_FIELD i -- `"Name":<canonical-value>` tight pair (no whitespace)
OP_NAME i -- `"Name"` alone (enclosing quotes included)
OP_VALUE i -- canonical rendering of field i's value
OP_TAG t -- structural glue from TAGS
OP_RAW n.. -- literal passthrough
The matcher at each cursor position tries, longest-match first:
1. OP_FIELD against any unused field pair
2. OP_NAME against any unused field name
3. OP_VALUE against any unused field value
4. OP_TAG
5. OP_RAW (one byte, coalesced)
If the resulting OP stream is not shorter than a verbatim copy, we
emit MODE_VERBATIM instead.
"""
if tx_json is None:
tx_json = json.loads(tx_json_str)
src = tx_json_str.encode()
fields = _ordered_fields_from_dict(
tx_json, skip={"TxnSignature", JSON_TX_FIELD}
)
name_render = [_render_name(f.name) for f in fields]
value_render = [_render_value(f.value) for f in fields]
pair_render = [name_render[i] + b":" + value_render[i] for i in range(len(fields))]
unused_pair = set(range(len(fields)))
unused_name = set(range(len(fields)))
unused_value = set(range(len(fields)))
body = bytearray()
raw_buf = bytearray()
def flush_raw() -> None:
if raw_buf:
body.append(OP_RAW)
body.extend(_vw(len(raw_buf)))
body.extend(raw_buf)
raw_buf.clear()
def best_match(candidates: set[int], renders: list[bytes], at: int) -> tuple[int, int]:
best_idx, best_len = -1, 0
for idx in candidates:
r = renders[idx]
if len(r) > best_len and src.startswith(r, at):
best_idx, best_len = idx, len(r)
return best_idx, best_len
i = 0
while i < len(src):
# Try the tight FIELD match first -- cheapest per byte of output.
idx, hit = best_match(unused_pair, pair_render, i)
if idx >= 0:
flush_raw()
body.append(OP_FIELD)
body.extend(_vw(idx))
i += hit
unused_pair.discard(idx)
unused_name.discard(idx)
unused_value.discard(idx)
continue
# Then NAME (standalone), preferring longer names over shorter ones.
idx, hit = best_match(unused_name, name_render, i)
if idx >= 0:
flush_raw()
body.append(OP_NAME)
body.extend(_vw(idx))
i += hit
unused_name.discard(idx)
unused_pair.discard(idx) # no longer a "pair" candidate
continue
# Then VALUE (standalone).
idx, hit = best_match(unused_value, value_render, i)
if idx >= 0:
flush_raw()
body.append(OP_VALUE)
body.extend(_vw(idx))
i += hit
unused_value.discard(idx)
unused_pair.discard(idx)
continue
# Structural glue.
tag_hit = -1
for t_idx, tag in enumerate(TAGS):
if src.startswith(tag, i):
tag_hit = t_idx
break
if tag_hit >= 0:
flush_raw()
body.append(OP_TAG)
body.extend(_vw(tag_hit))
i += len(TAGS[tag_hit])
continue
raw_buf.append(src[i])
i += 1
flush_raw()
body.append(OP_END)
op_form = bytes([MODE_CANONICAL]) + bytes(body)
verbatim = bytes([MODE_VERBATIM]) + _vw(len(src)) + src
return op_form if len(op_form) <= len(verbatim) else verbatim
def decompress_stream(stream: bytes, tx_json_for_dict: dict) -> str:
"""Rebuild tx_json_str from a json-tx stream + a parsed tx dict (dictionary source)."""
mode = stream[0]
i = 1
if mode == MODE_VERBATIM:
ln, i = _vr(stream, i)
return stream[i : i + ln].decode()
if mode != MODE_CANONICAL:
raise ValueError(f"unknown json-tx stream mode 0x{mode:02x}")
fields = _ordered_fields_from_dict(
tx_json_for_dict,
skip={"TxnSignature", JSON_TX_FIELD},
)
name_render = [_render_name(f.name) for f in fields]
value_render = [_render_value(f.value) for f in fields]
pair_render = [name_render[i] + b":" + value_render[i] for i in range(len(fields))]
out = bytearray()
while i < len(stream):
op = stream[i]
i += 1
if op == OP_END:
break
if op == OP_FIELD:
idx, i = _vr(stream, i)
out += pair_render[idx]
elif op == OP_NAME:
idx, i = _vr(stream, i)
out += name_render[idx]
elif op == OP_VALUE:
idx, i = _vr(stream, i)
out += value_render[idx]
elif op == OP_TAG:
idx, i = _vr(stream, i)
out += TAGS[idx]
elif op == OP_RAW:
ln, i = _vr(stream, i)
out += stream[i : i + ln]
i += ln
else:
raise ValueError(f"unknown opcode 0x{op:02x} at offset {i - 1}")
return out.decode()
# ---------- wire-tx pack/unpack (full binary with JsonTxCompressed) ----------
def pack_wire(tx_json_str: str, signature: bytes) -> bytes:
"""Build the on-wire binary tx: canonical binary + JsonTxCompressed + TxnSignature.
`tx_json_str` is the exact ASCII bytes the client signed -- any field
order / whitespace. `signature` is the raw signature over those bytes.
"""
from xrpl.core.binarycodec.main import encode
tx_json = json.loads(tx_json_str)
stream = compress_stream(tx_json_str, tx_json=tx_json)
wire_dict = dict(tx_json)
wire_dict[JSON_TX_FIELD] = stream.hex().upper()
wire_dict["TxnSignature"] = signature.hex().upper()
return bytes.fromhex(encode(wire_dict))
def unpack_wire(wire: bytes) -> tuple[dict, str, bytes]:
"""Decode the wire tx back into (tx_json_dict, tx_json_str, signature)."""
from xrpl.core.binarycodec.main import decode
decoded = decode(wire.hex().upper())
stream_hex = decoded.pop(JSON_TX_FIELD)
sig_hex = decoded.pop("TxnSignature")
# The dictionary is the other fields of the tx, i.e. the decoded dict
# minus the scaffolding keys (already removed above).
tx_json_str = decompress_stream(bytes.fromhex(stream_hex), decoded)
return json.loads(tx_json_str), tx_json_str, bytes.fromhex(sig_hex)
# ---------- convenience ----------
def pack(tx_json: dict, signature: bytes) -> bytes:
"""Alias for pack_wire for the common case."""
return pack_wire(tx_json, signature)
def unpack(wire: bytes) -> tuple[dict, bytes]:
tx_json, _, sig = unpack_wire(wire)
return tx_json, sig

View File

@@ -0,0 +1,50 @@
"""Runtime monkey-patch: register a `JsonTxCompressed` Blob field.
Import this module (or call `register_json_tx_field()`) before using the
binary codec so that a transaction dict containing `JsonTxCompressed`
will serialize it as a Blob and parse it back out.
The field is intentionally `isSigningField=False` — the ASCII JSON is
what TxnSignature signs, not the binary form, so this field must not
participate in any classical signing payload.
"""
from __future__ import annotations
from xrpl.core.binarycodec.definitions import definitions as _d
from xrpl.core.binarycodec.definitions.field_header import FieldHeader
from xrpl.core.binarycodec.definitions.field_info import FieldInfo
FIELD_NAME = "JsonTxCompressed"
_TYPE_NAME = "Blob"
def _pick_free_nth_for_type(type_name: str) -> int:
"""Find an unused `nth` code within the given type so there's no header clash."""
type_code = _d._TYPE_ORDINAL_MAP[type_name]
taken = {
h.field_code for h in _d._FIELD_HEADER_NAME_MAP if h.type_code == type_code
}
for n in range(1, 255):
if n not in taken:
return n
raise RuntimeError(f"no free nth code for type {type_name}")
def register_json_tx_field() -> None:
if FIELD_NAME in _d._FIELD_INFO_MAP:
return
nth = _pick_free_nth_for_type(_TYPE_NAME)
info = FieldInfo(
nth=nth,
is_variable_length_encoded=True,
is_serialized=True,
is_signing_field=False,
type_name=_TYPE_NAME,
)
header = FieldHeader(_d._TYPE_ORDINAL_MAP[_TYPE_NAME], nth)
_d._FIELD_INFO_MAP[FIELD_NAME] = info
_d._FIELD_HEADER_NAME_MAP[header] = FIELD_NAME
register_json_tx_field()

View File

@@ -0,0 +1,63 @@
import json
from xrpl.core.keypairs import derive_classic_address, derive_keypair, generate_seed, sign
from json_tx import compress_stream, decompress_stream, pack_wire, unpack_wire
def _signed(tx: dict) -> tuple[dict, str, bytes]:
seed = generate_seed()
pub, priv = derive_keypair(seed)
tx = dict(tx)
tx["Account"] = derive_classic_address(pub)
tx["SigningPubKey"] = pub
from json_tx import canonical_json
tx_json_str = canonical_json(tx)
sig = bytes.fromhex(sign(tx_json_str.encode().hex(), priv))
return tx, tx_json_str, sig
def test_wire_roundtrip():
tx, tx_json_str, sig = _signed({
"TransactionType": "Payment",
"Destination": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
"Amount": "1000000",
"Fee": "12",
"Sequence": 1,
"Flags": 2147483648,
})
wire = pack_wire(tx_json_str, sig)
recovered_tx, recovered_str, recovered_sig = unpack_wire(wire)
assert recovered_str == tx_json_str
assert recovered_sig == sig
assert recovered_tx == tx
def test_stream_roundtrip_direct():
tx, tx_json_str, _ = _signed({
"TransactionType": "Payment",
"Destination": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
"Amount": "2500000",
"Fee": "15",
"Sequence": 42,
"Flags": 0,
})
stream = compress_stream(tx_json_str, tx_json=tx)
rebuilt = decompress_stream(stream, tx)
assert rebuilt == tx_json_str
def test_raw_fallback_preserved():
# Unusual whitespace -> RAW opcodes. Dictionary still reconstructs losslessly.
tx = {
"TransactionType": "Payment",
"Account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh",
"Destination": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
"Amount": "1",
"Fee": "12",
"Sequence": 1,
"SigningPubKey": "",
}
odd = '{ "TransactionType" : "Payment" }'
stream = compress_stream(odd, tx_json=tx)
assert decompress_stream(stream, tx) == odd

476
json-tx-py/uv.lock generated Normal file
View File

@@ -0,0 +1,476 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "base58"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/45/8ae61209bb9015f516102fa559a2914178da1d5868428bd86a1b4421141d/base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c", size = 6528, upload-time = "2021-10-30T22:12:17.858Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621, upload-time = "2021-10-30T22:12:16.658Z" },
]
[[package]]
name = "certifi"
version = "2026.4.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "deprecated"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
]
[[package]]
name = "ecpy"
version = "1.2.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/48/3f8c1a252e3a46fd04e6fabc5e11c933b9c39cf84edd4e7c906e29c23750/ECPy-1.2.5.tar.gz", hash = "sha256:9635cffb9b6ecf7fd7f72aea1665829ac74a1d272006d0057d45a621aae20228", size = 38458, upload-time = "2020-10-26T11:56:16.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/35/4a113189f7138035a21bd255d30dc7bffc77c942c93b7948d2eac2e22429/ECPy-1.2.5-py3-none-any.whl", hash = "sha256:559c92e42406d9d1a6b2b8fc26e6ad7bc985f33903b72f426a56cb1073a25ce3", size = 43075, upload-time = "2020-10-26T11:56:13.613Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "json-tx"
version = "0.0.1"
source = { editable = "." }
dependencies = [
{ name = "xrpl-py" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [{ name = "xrpl-py", specifier = ">=4.0.0" }]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.0" }]
[[package]]
name = "packaging"
version = "26.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
{ url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" },
{ url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" },
{ url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" },
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "types-deprecated"
version = "1.3.1.20260408"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/db/076de3e81b106d3cec17aec9640ab1b2d02f29bad441de280459c161ce65/types_deprecated-1.3.1.20260408.tar.gz", hash = "sha256:62d6a86d0cc754c14bb2de31162d069b1c6a07ce11ee65e5258f8f75308eb3a3", size = 8524, upload-time = "2026-04-08T04:26:39.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/d0/d3258379deb749d949c3c72313981c9d2cceec518b87dcf506f022f5d49f/types_deprecated-1.3.1.20260408-py3-none-any.whl", hash = "sha256:b64e1eab560d4fa9394a27a3099211344b0e0f2f3ac8026d825c86e70d65cdd5", size = 9079, upload-time = "2026-04-08T04:26:38.752Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" },
{ url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" },
{ url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" },
{ url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" },
{ url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" },
{ url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" },
{ url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" },
{ url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
{ url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
{ url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
{ url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
{ url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "wrapt"
version = "2.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/d2/387594fb592d027366645f3d7cc9b4d7ca7be93845fbaba6d835a912ef3c/wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c", size = 60669, upload-time = "2026-03-06T02:52:40.671Z" },
{ url = "https://files.pythonhosted.org/packages/c9/18/3f373935bc5509e7ac444c8026a56762e50c1183e7061797437ca96c12ce/wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f", size = 61603, upload-time = "2026-03-06T02:54:21.032Z" },
{ url = "https://files.pythonhosted.org/packages/c2/7a/32758ca2853b07a887a4574b74e28843919103194bb47001a304e24af62f/wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb", size = 113632, upload-time = "2026-03-06T02:53:54.121Z" },
{ url = "https://files.pythonhosted.org/packages/1d/d5/eeaa38f670d462e97d978b3b0d9ce06d5b91e54bebac6fbed867809216e7/wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e", size = 115644, upload-time = "2026-03-06T02:54:53.33Z" },
{ url = "https://files.pythonhosted.org/packages/e3/09/2a41506cb17affb0bdf9d5e2129c8c19e192b388c4c01d05e1b14db23c00/wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba", size = 112016, upload-time = "2026-03-06T02:54:43.274Z" },
{ url = "https://files.pythonhosted.org/packages/64/15/0e6c3f5e87caadc43db279724ee36979246d5194fa32fed489c73643ba59/wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f", size = 114823, upload-time = "2026-03-06T02:54:29.392Z" },
{ url = "https://files.pythonhosted.org/packages/56/b2/0ad17c8248f4e57bedf44938c26ec3ee194715f812d2dbbd9d7ff4be6c06/wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394", size = 111244, upload-time = "2026-03-06T02:54:02.149Z" },
{ url = "https://files.pythonhosted.org/packages/ff/04/bcdba98c26f2c6522c7c09a726d5d9229120163493620205b2f76bd13c01/wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45", size = 113307, upload-time = "2026-03-06T02:54:12.428Z" },
{ url = "https://files.pythonhosted.org/packages/0e/1b/5e2883c6bc14143924e465a6fc5a92d09eeabe35310842a481fb0581f832/wrapt-2.1.2-cp310-cp310-win32.whl", hash = "sha256:d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d", size = 57986, upload-time = "2026-03-06T02:54:26.823Z" },
{ url = "https://files.pythonhosted.org/packages/42/5a/4efc997bccadd3af5749c250b49412793bc41e13a83a486b2b54a33e240c/wrapt-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71", size = 60336, upload-time = "2026-03-06T02:54:18Z" },
{ url = "https://files.pythonhosted.org/packages/c1/f5/a2bb833e20181b937e87c242645ed5d5aa9c373006b0467bfe1a35c727d0/wrapt-2.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc", size = 58757, upload-time = "2026-03-06T02:53:51.545Z" },
{ url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" },
{ url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" },
{ url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" },
{ url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" },
{ url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" },
{ url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" },
{ url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" },
{ url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" },
{ url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" },
{ url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" },
{ url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" },
{ url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" },
{ url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" },
{ url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" },
{ url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" },
{ url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" },
{ url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" },
{ url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" },
{ url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" },
{ url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" },
{ url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" },
{ url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" },
{ url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" },
{ url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" },
{ url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" },
{ url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" },
{ url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" },
{ url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" },
{ url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" },
{ url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" },
{ url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" },
{ url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" },
{ url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" },
{ url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" },
{ url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" },
{ url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" },
{ url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" },
{ url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" },
{ url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" },
{ url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" },
{ url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" },
{ url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" },
{ url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" },
{ url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" },
{ url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" },
{ url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" },
{ url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" },
{ url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" },
{ url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" },
{ url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" },
{ url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" },
{ url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" },
{ url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" },
{ url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" },
{ url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" },
{ url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" },
{ url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
]
[[package]]
name = "xrpl-py"
version = "4.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "base58" },
{ name = "deprecated" },
{ name = "ecpy" },
{ name = "httpx" },
{ name = "pycryptodome" },
{ name = "types-deprecated" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/51/e7/8faf71e5b9b314d329a13cdc7966ad565a6e52a875adeaee0f778b1b8ef1/xrpl_py-4.5.0.tar.gz", hash = "sha256:3ee25fcb748bdf6afe18aad8f74ba71ffa23bf681409fda3a9eb029e4381fc74", size = 175681, upload-time = "2026-02-12T23:41:52.176Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/3c/65c853f906f5003c06c5e0cbc50e47bac2ecca17957a9d3a3d823efd8c38/xrpl_py-4.5.0-py3-none-any.whl", hash = "sha256:aa4720b5bf8070d8303346111f1095ec9afe13abdf49b2dc4b988d28ebc227ca", size = 314897, upload-time = "2026-02-12T23:41:50.609Z" },
]

View File

@@ -111,6 +111,18 @@ get_inverse()
return &tab[0];
}
/// Returns max chars needed to encode a base64 string
inline std::size_t constexpr encoded_size(std::size_t n)
{
return 4 * ((n + 2) / 3);
}
/// Returns max bytes needed to decode a base64 string
inline std::size_t constexpr decoded_size(std::size_t n)
{
return ((n / 4) * 3) + 2;
}
/** Encode a series of octets as a padded, base64 string.
The resulting string will not be null terminated.

View File

@@ -0,0 +1,187 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2025 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
*/
//==============================================================================
#include <xrpl/protocol/JsonTx.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/json/json_reader.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STBase.h>
#include <xrpl/protocol/STBlob.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/digest.h>
#include <xrpl/protocol/jss.h>
#include <algorithm>
#include <initializer_list>
#include <string>
#include <vector>
namespace ripple {
namespace jsonTx {
namespace {
/** Canonical serialization of `obj` with the given fields removed.
STObject's own serialization already sorts by field code, but we
have to walk the fields ourselves to skip the json-tx wrapper
entries rather than mutate the object. */
Blob
canonicalSerialization(
STObject const& obj,
std::initializer_list<SField const*> skip)
{
std::vector<STBase const*> fields;
for (auto const& entry : obj)
{
if (entry.getSType() == STI_NOTPRESENT)
continue;
bool skipped = false;
for (SField const* s : skip)
if (entry.getFName() == *s)
{
skipped = true;
break;
}
if (!skipped)
fields.push_back(&entry);
}
std::sort(
fields.begin(), fields.end(), [](STBase const* a, STBase const* b) {
return a->getFName().fieldCode < b->getFName().fieldCode;
});
Serializer s;
for (STBase const* f : fields)
{
f->addFieldID(s);
f->add(s);
auto const sType = f->getSType();
if (sType == STI_ARRAY || sType == STI_OBJECT)
s.addFieldID(sType, 1);
}
return s.getData();
}
} // namespace
bool
hasBody(STObject const& obj) noexcept
{
try
{
return obj.isFieldPresent(sfJsonTxBody);
}
catch (...)
{
return false;
}
}
Slice
body(STObject const& obj)
{
if (!obj.isFieldPresent(sfJsonTxBody))
return Slice{};
// peekAtField gives us a view into the STObject's owned storage;
// STBlob::value() returns a Slice over that storage directly.
auto const& field = obj.peekAtField(sfJsonTxBody);
return static_cast<STBlob const&>(field).value();
}
uint256
bodyHash(STObject const& obj)
{
auto const s = body(obj);
if (s.empty())
return uint256{};
return sha512Half(s);
}
Expected<void, std::string>
checkSignature(STTx const& stx)
{
if (!hasBody(stx))
return Unexpected<std::string>("JsonTxBody field is missing.");
auto const bodySlice = body(stx);
if (bodySlice.empty())
return Unexpected<std::string>("JsonTxBody is empty.");
if (!stx.isFieldPresent(sfSigningPubKey))
return Unexpected<std::string>("SigningPubKey is missing.");
Blob const spk = stx.getFieldVL(sfSigningPubKey);
if (!publicKeyType(makeSlice(spk)))
return Unexpected<std::string>("SigningPubKey is not a valid key.");
if (!stx.isFieldPresent(sfTxnSignature))
return Unexpected<std::string>("TxnSignature is missing.");
Blob const sig = stx.getFieldVL(sfTxnSignature);
if (sig.empty())
return Unexpected<std::string>("TxnSignature is empty.");
if (!verify(PublicKey(makeSlice(spk)), bodySlice, makeSlice(sig)))
return Unexpected<std::string>(
"Signature over JsonTxBody failed verification.");
return {};
}
Expected<void, std::string>
checkStructuralEquivalence(STTx const& stx)
{
if (!hasBody(stx))
return Unexpected<std::string>("JsonTxBody field is missing.");
auto const bodySlice = body(stx);
if (bodySlice.empty())
return Unexpected<std::string>("JsonTxBody is empty.");
std::string const bodyStr(
reinterpret_cast<char const*>(bodySlice.data()), bodySlice.size());
Json::Value parsed;
Json::Reader reader;
if (!reader.parse(bodyStr, parsed) || !parsed.isObject())
return Unexpected<std::string>(
"JsonTxBody is not a valid JSON object.");
STParsedJSONObject parsedObj("JsonTxBody", parsed);
if (!parsedObj.object)
return Unexpected<std::string>(
"JsonTxBody does not parse into a valid STObject: " +
(parsedObj.error.isMember(jss::error_message)
? parsedObj.error[jss::error_message].asString()
: std::string("unknown parse error")));
// The json-tx wrapper fields (TxnSignature, JsonTxBody) are excluded
// from both sides: TxnSignature covers the body bytes (not the
// binary), and JsonTxBody is the body itself.
std::initializer_list<SField const*> const skip{
&sfTxnSignature, &sfJsonTxBody};
if (canonicalSerialization(stx, skip) !=
canonicalSerialization(*parsedObj.object, skip))
return Unexpected<std::string>(
"JsonTxBody content does not match the structural fields "
"of the transaction.");
return {};
}
} // namespace jsonTx
} // namespace ripple

View File

@@ -25,6 +25,7 @@
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/JsonTx.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/STAccount.h>
@@ -216,6 +217,14 @@ STTx::checkSign(
{
try
{
// json-tx: when sfJsonTxBody is present and the amendment is
// active, the signature covers the raw ASCII bytes of the body
// instead of the classical signing payload. Structural
// equivalence between the body and the other STTx fields is
// enforced separately in passesLocalChecks.
if (rules.enabled(featureJsonTx) && jsonTx::hasBody(*this))
return jsonTx::checkSignature(*this);
// Determine whether we're single- or multi-signing by looking
// at the SigningPubKey. If it's empty we must be
// multi-signing. Otherwise we're single-signing.
@@ -663,6 +672,21 @@ passesLocalChecks(STObject const& st, std::string& reason)
return false;
}
// json-tx: if the tx carries sfJsonTxBody, its parsed content must
// match the other structural fields. We can only run this when the
// object is actually an STTx -- passesLocalChecks is also called on
// nested STObjects that don't participate in the json-tx scheme.
if (auto const* stx = dynamic_cast<STTx const*>(&st);
stx && jsonTx::hasBody(*stx))
{
if (auto const result = jsonTx::checkStructuralEquivalence(*stx);
!result)
{
reason = result.error();
return false;
}
}
return true;
}

View File

@@ -43,7 +43,8 @@ TxFormats::TxFormats()
{sfSigningPubKey, soeREQUIRED},
{sfTicketSequence, soeOPTIONAL},
{sfTxnSignature, soeOPTIONAL},
{sfSigners, soeOPTIONAL}, // submit_multisigned
{sfJsonTxBody, soeOPTIONAL}, // json-tx: ASCII bytes that were signed
{sfSigners, soeOPTIONAL}, // submit_multisigned
{sfEmitDetails, soeOPTIONAL},
{sfFirstLedgerSequence, soeOPTIONAL},
{sfNetworkID, soeOPTIONAL},

View File

@@ -0,0 +1,436 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2025 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
*/
//==============================================================================
#include <test/jtx.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/json/json_reader.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/JsonTx.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Sign.h>
#include <xrpl/protocol/digest.h>
#include <xrpl/protocol/jss.h>
namespace ripple {
namespace test {
struct JsonTx_test : public beast::unit_test::suite
{
// Build a canonical Payment tx_json_str for `from` -> `to` with the given
// drops amount and sequence. Field order follows XRPL ordinal order so
// the json-tx codec can compress it maximally; tests here don't care
// about compression but the node accepts any valid JSON shape.
static std::string
buildPaymentJson(
jtx::Account const& from,
jtx::Account const& to,
std::uint64_t amountDrops,
std::uint32_t sequence,
std::uint64_t fee)
{
std::string const pkHex = strHex(from.pk().slice());
std::ostringstream os;
os << "{"
<< R"("TransactionType":"Payment",)"
<< R"("Flags":2147483648,)"
<< R"("Sequence":)" << sequence << ","
<< R"("Amount":")" << amountDrops << R"(",)"
<< R"("Fee":")" << fee << R"(",)"
<< R"("SigningPubKey":")" << pkHex << R"(",)"
<< R"("Account":")" << from.human() << R"(",)"
<< R"("Destination":")" << to.human() << R"(")"
<< "}";
return os.str();
}
// Sign the UTF-8 bytes of tx_json_str with `from`'s secret key.
static Buffer
signBody(jtx::Account const& from, std::string const& tx_json_str)
{
return sign(
from.pk(),
from.sk(),
Slice{tx_json_str.data(), tx_json_str.size()});
}
static Json::Value
rpcSubmit(
jtx::Env& env,
std::string const& tx_json_str,
Buffer const& signature)
{
Json::Value params(Json::objectValue);
params["tx_json_str"] = tx_json_str;
params[jss::signature] = strHex(signature);
return env.rpc("json", "submit_json_tx", to_string(params));
}
// ---------- RPC-level tests ----------
void
testEnabledGate(FeatureBitset features)
{
testcase("enabled (feature gate)");
using namespace jtx;
for (bool const withFeature : {true, false})
{
auto const amend =
withFeature ? features : features - featureJsonTx;
Env env{*this, amend};
Account const alice{"alice"};
Account const bob{"bob"};
env.fund(XRP(1000), alice, bob);
env.close();
auto const fee = env.current()->fees().base.drops();
auto const txJson =
buildPaymentJson(alice, bob, 1'000'000, env.seq(alice), fee);
auto const sig = signBody(alice, txJson);
auto const result = rpcSubmit(env, txJson, sig);
env.close();
auto const& inner = result[jss::result];
if (withFeature)
{
BEAST_EXPECT(inner[jss::engine_result] == "tesSUCCESS");
BEAST_EXPECT(inner[jss::applied].asBool());
}
else
{
// Amendment is off -> the RPC itself rejects the
// submission before any classical verification.
BEAST_EXPECT(inner[jss::error].asString() == "notEnabled");
}
}
}
void
testBasicRoundtrip(FeatureBitset features)
{
testcase("basic payment roundtrip");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"};
Account const bob{"bob"};
env.fund(XRP(1000), alice, bob);
env.close();
auto const preAlice = env.balance(alice);
auto const preBob = env.balance(bob);
auto const feeDrops = env.current()->fees().base;
auto const txJson = buildPaymentJson(
alice, bob, 1'000'000, env.seq(alice), feeDrops.drops());
auto const sig = signBody(alice, txJson);
auto const result = rpcSubmit(env, txJson, sig);
env.close();
auto const& inner = result[jss::result];
BEAST_EXPECT(inner[jss::engine_result] == "tesSUCCESS");
BEAST_EXPECT(inner[jss::applied].asBool());
BEAST_EXPECT(inner["tx_json_str"].asString() == txJson);
BEAST_EXPECT(env.balance(alice) == preAlice - XRP(1) - feeDrops);
BEAST_EXPECT(env.balance(bob) == preBob + XRP(1));
}
void
testMissingParams(FeatureBitset features)
{
testcase("missing params");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"};
env.fund(XRP(1000), alice);
env.close();
auto const txJson = buildPaymentJson(
alice,
Account{"bob"},
1'000'000,
env.seq(alice),
env.current()->fees().base.drops());
auto const sig = signBody(alice, txJson);
// No tx_json_str.
{
Json::Value p(Json::objectValue);
p[jss::signature] = strHex(sig);
auto const r = env.rpc("json", "submit_json_tx", to_string(p));
BEAST_EXPECT(r[jss::result][jss::error] == "invalidParams");
}
// No signature.
{
Json::Value p(Json::objectValue);
p["tx_json_str"] = txJson;
auto const r = env.rpc("json", "submit_json_tx", to_string(p));
BEAST_EXPECT(r[jss::result][jss::error] == "invalidParams");
}
// Signature is not valid hex.
{
Json::Value p(Json::objectValue);
p["tx_json_str"] = txJson;
p[jss::signature] = "notahex";
auto const r = env.rpc("json", "submit_json_tx", to_string(p));
BEAST_EXPECT(r[jss::result][jss::error] == "invalidParams");
}
}
void
testInvalidJson(FeatureBitset features)
{
testcase("invalid tx_json_str");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"};
env.fund(XRP(1000), alice);
env.close();
// Syntactically invalid JSON.
{
std::string const bad = "{not valid json";
auto const sig = signBody(alice, bad);
auto const r = rpcSubmit(env, bad, sig);
BEAST_EXPECT(
r[jss::result][jss::error].asString() == "invalidTransaction");
}
// Valid JSON but not an object.
{
std::string const arr = R"([1,2,3])";
auto const sig = signBody(alice, arr);
auto const r = rpcSubmit(env, arr, sig);
BEAST_EXPECT(
r[jss::result][jss::error].asString() == "invalidTransaction");
}
}
void
testBadSignature(FeatureBitset features)
{
testcase("bad signature");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"};
Account const bob{"bob"};
env.fund(XRP(1000), alice, bob);
env.close();
auto const txJson = buildPaymentJson(
alice,
bob,
1'000'000,
env.seq(alice),
env.current()->fees().base.drops());
auto sig = signBody(alice, txJson);
// Flip a bit in the signature.
std::vector<std::uint8_t> corrupted(
sig.data(), sig.data() + sig.size());
corrupted.at(0) ^= 0x01;
Buffer bad(corrupted.data(), corrupted.size());
auto const result = rpcSubmit(env, txJson, bad);
BEAST_EXPECT(
result[jss::result][jss::error].asString() == "invalidTransaction");
}
void
testSignatureOverDifferentBytesFails(FeatureBitset features)
{
testcase("signature over different bytes");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"};
Account const bob{"bob"};
env.fund(XRP(1000), alice, bob);
env.close();
auto const txJson = buildPaymentJson(
alice,
bob,
1'000'000,
env.seq(alice),
env.current()->fees().base.drops());
// Sign a different string -- same tx, different bytes.
auto const otherJson = txJson + " ";
auto const sig = signBody(alice, otherJson);
auto const result = rpcSubmit(env, txJson, sig);
BEAST_EXPECT(
result[jss::result][jss::error].asString() == "invalidTransaction");
}
void
testWrongSigningPubKey(FeatureBitset features)
{
testcase("wrong SigningPubKey");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"};
Account const bob{"bob"};
Account const mallory{"mallory"};
env.fund(XRP(1000), alice, bob, mallory);
env.close();
// Alice's account, but signed with mallory's key: claim alice's
// SigningPubKey -> sig fails to verify. Then also try with
// mallory's SigningPubKey: sig verifies but Account mismatch
// causes downstream failure (tecNO_AUTH or similar).
auto txJson = buildPaymentJson(
alice,
bob,
1'000'000,
env.seq(alice),
env.current()->fees().base.drops());
auto const badSig = signBody(mallory, txJson);
auto const r = rpcSubmit(env, txJson, badSig);
BEAST_EXPECT(
r[jss::result][jss::error].asString() == "invalidTransaction");
}
// ---------- helper-level unit tests (no RPC) ----------
void
testHelperDirectly(FeatureBitset features)
{
testcase("helper functions direct");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"};
Account const bob{"bob"};
env.fund(XRP(1000), alice, bob);
env.close();
std::string const txJson = buildPaymentJson(
alice,
bob,
1'000'000,
env.seq(alice),
env.current()->fees().base.drops());
auto const sig = signBody(alice, txJson);
// Assemble an STTx mirroring what the node would produce from
// submit_json_tx.
Json::Value parsed;
Json::Reader reader;
BEAST_EXPECT(reader.parse(txJson, parsed) && parsed.isObject());
parsed[jss::TxnSignature] = strHex(sig);
parsed[sfJsonTxBody.jsonName] = strHex(txJson);
STParsedJSONObject parsedObj("tx", parsed);
BEAST_EXPECT(static_cast<bool>(parsedObj.object));
STTx const stx(std::move(*parsedObj.object));
// hasBody / body / bodyHash
BEAST_EXPECT(jsonTx::hasBody(stx));
auto const slice = jsonTx::body(stx);
BEAST_EXPECT(slice.size() == txJson.size());
BEAST_EXPECT(
std::memcmp(slice.data(), txJson.data(), txJson.size()) == 0);
auto const h = jsonTx::bodyHash(stx);
BEAST_EXPECT(h == sha512Half(Slice{txJson.data(), txJson.size()}));
// Signature check passes on a well-formed tx.
BEAST_EXPECT(static_cast<bool>(jsonTx::checkSignature(stx)));
// Structural equivalence passes.
BEAST_EXPECT(
static_cast<bool>(jsonTx::checkStructuralEquivalence(stx)));
// Tamper with the body -- change Amount in the ASCII without
// touching the structural fields. Signature will still be over
// the original bytes, so we re-sign over the new body so we
// isolate the structural-equivalence check.
std::string tampered = txJson;
auto const needle = std::string(R"("Amount":"1000000")");
auto const pos = tampered.find(needle);
BEAST_EXPECT(pos != std::string::npos);
tampered.replace(pos, needle.size(), R"("Amount":"9000000")");
auto const tamperedSig = signBody(alice, tampered);
Json::Value mismatched = parsed;
mismatched[jss::TxnSignature] = strHex(tamperedSig);
mismatched[sfJsonTxBody.jsonName] = strHex(tampered);
STParsedJSONObject mismatchedObj("tx", mismatched);
BEAST_EXPECT(static_cast<bool>(mismatchedObj.object));
STTx const mismatchedTx(std::move(*mismatchedObj.object));
// Signature now verifies over the tampered body...
BEAST_EXPECT(static_cast<bool>(jsonTx::checkSignature(mismatchedTx)));
// ...but structural equivalence fails.
BEAST_EXPECT(!jsonTx::checkStructuralEquivalence(mismatchedTx));
}
void
testHelperEmptyAndMissing(FeatureBitset features)
{
testcase("helpers on non-jsontx STTx");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"};
env.fund(XRP(1000), alice);
env.close();
// A noop signed classically carries no sfJsonTxBody.
auto const jt = env.jt(noop(alice));
BEAST_EXPECT(!jsonTx::hasBody(*jt.stx));
BEAST_EXPECT(jsonTx::body(*jt.stx).empty());
BEAST_EXPECT(jsonTx::bodyHash(*jt.stx) == uint256{});
}
void
testWithFeats(FeatureBitset features)
{
testEnabledGate(features);
testBasicRoundtrip(features);
testMissingParams(features);
testInvalidJson(features);
testBadSignature(features);
testSignatureOverDifferentBytesFails(features);
testWrongSigningPubKey(features);
testHelperDirectly(features);
testHelperEmptyAndMissing(features);
}
public:
void
run() override
{
using namespace jtx;
auto const sa = supported_amendments();
testWithFeats(sa);
}
};
BEAST_DEFINE_TESTSUITE(JsonTx, app, ripple);
} // namespace test
} // namespace ripple

View File

@@ -243,20 +243,6 @@ public:
return m2;
}
static std::optional<ManifestRetention>
retentionOf(ManifestCache const& cache, PublicKey const& master)
{
std::optional<ManifestRetention> result;
cache.for_each_manifest(
[](std::size_t) {},
[&result, &master](
Manifest const& manifest, ManifestRetention retention) {
if (manifest.masterKey == master)
result = retention;
});
return result;
}
void
testLoadStore(ManifestCache& m)
{
@@ -301,71 +287,25 @@ public:
env.journal);
{
// Protection is current-source policy, not a second permanent
// manifest authority. Source removal demotes revocations into
// the ordinary bounded population. The existing wallet format
// still saves retained revocations.
ManifestCache persistence;
auto const protectedSecret = randomSecretKey();
auto const protectedSigning = randomKeyPair(KeyType::secp256k1);
auto const protectedOld = makeManifest(
protectedSecret,
KeyType::ed25519,
protectedSigning.second,
KeyType::secp256k1,
0);
auto const protectedRevocation =
makeRevocation(protectedSecret, KeyType::ed25519);
auto const arbitrarySecret = randomSecretKey();
auto const arbitraryRevocation =
makeRevocation(arbitrarySecret, KeyType::ed25519);
auto const protectedMaster = protectedRevocation.masterKey;
auto const arbitraryMaster = arbitraryRevocation.masterKey;
BEAST_EXPECT(
persistence.applyManifest(
clone(protectedRevocation),
ManifestRetention::protected_) ==
ManifestDisposition::accepted);
BEAST_EXPECT(
persistence.applyManifest(
clone(arbitraryRevocation),
ManifestRetention::evictable) ==
ManifestDisposition::accepted);
persistence.reconcileRetention({});
BEAST_EXPECT(
retentionOf(persistence, protectedMaster) ==
ManifestRetention::evictable);
BEAST_EXPECT(
retentionOf(persistence, arbitraryMaster) ==
ManifestRetention::evictable);
persistence.save(
*dbCon, "ValidatorManifests", [](PublicKey const&) {
return false;
// save should not store untrusted master keys to db
// except for revocations
m.save(
*dbCon,
"ValidatorManifests",
[&unl](PublicKey const& pubKey) {
return unl->listed(pubKey);
});
ManifestCache loaded;
loaded.load(*dbCon, "ValidatorManifests");
BEAST_EXPECT(loaded.revoked(protectedMaster));
BEAST_EXPECT(loaded.revoked(arbitraryMaster));
BEAST_EXPECT(
retentionOf(loaded, protectedMaster) ==
ManifestRetention::protected_);
// Startup reconciliation is what returns database-only rows
// to the bounded population once live sources are known.
loaded.reconcileRetention({});
BEAST_EXPECT(
retentionOf(loaded, protectedMaster) ==
ManifestRetention::evictable);
BEAST_EXPECT(
retentionOf(loaded, arbitraryMaster) ==
ManifestRetention::evictable);
BEAST_EXPECT(
loaded.applyManifest(
clone(protectedOld), ManifestRetention::evictable) ==
ManifestDisposition::stale);
// check that all loaded manifests are revocations
std::vector<Manifest const*> const loadedManifests(
sort(getPopulatedManifests(loaded)));
for (auto const& man : loadedManifests)
BEAST_EXPECT(man->revoked());
}
{
// save should store all trusted master keys to db
@@ -475,63 +415,12 @@ public:
cfgRevocation));
BEAST_EXPECT(loaded.revoked(pk));
loaded.reconcileRetention({});
BEAST_EXPECT(
retentionOf(loaded, pk) == ManifestRetention::protected_);
}
}
boost::filesystem::remove(
getDatabasePath() / boost::filesystem::path(dbName));
}
void
testStartupRetentionReconciliation()
{
testcase("startup retention reconciliation");
jtx::Env env(*this);
ManifestCache manifests;
ManifestCache publisherManifests;
auto const formerSecret = randomSecretKey();
auto const formerSigning = randomKeyPair(KeyType::secp256k1);
auto const former = makeManifest(
formerSecret,
KeyType::ed25519,
formerSigning.second,
KeyType::secp256k1,
0);
auto const revocation =
makeRevocation(randomSecretKey(), KeyType::ed25519);
auto const formerMaster = former.masterKey;
auto const revokedMaster = revocation.masterKey;
// Database rows are provisionally loaded as protected before current
// local and publisher sources are known.
BEAST_EXPECT(
manifests.applyManifest(
clone(former), ManifestRetention::protected_) ==
ManifestDisposition::accepted);
BEAST_EXPECT(
manifests.applyManifest(
clone(revocation), ManifestRetention::protected_) ==
ManifestDisposition::accepted);
ValidatorList validators(
manifests,
publisherManifests,
env.timeKeeper(),
env.app().config().legacy("database_path"),
env.journal);
BEAST_EXPECT(validators.load({}, {}, {}));
BEAST_EXPECT(
retentionOf(manifests, formerMaster) ==
ManifestRetention::evictable);
BEAST_EXPECT(
retentionOf(manifests, revokedMaster) ==
ManifestRetention::evictable);
}
void
testGetSignature()
{
@@ -575,10 +464,8 @@ public:
auto const kp0 = randomKeyPair(KeyType::secp256k1);
BEAST_EXPECT(
ManifestDisposition::accepted ==
cache.applyManifest(
makeManifest(
sk, KeyType::ed25519, kp0.second, KeyType::secp256k1, 0),
ManifestRetention::evictable));
cache.applyManifest(makeManifest(
sk, KeyType::ed25519, kp0.second, KeyType::secp256k1, 0)));
BEAST_EXPECT(cache.getSigningKey(pk) == kp0.first);
BEAST_EXPECT(cache.getMasterKey(kp0.first) == pk);
@@ -589,10 +476,8 @@ public:
auto const kp1 = randomKeyPair(KeyType::secp256k1);
BEAST_EXPECT(
ManifestDisposition::accepted ==
cache.applyManifest(
makeManifest(
sk, KeyType::ed25519, kp1.second, KeyType::secp256k1, 1),
ManifestRetention::evictable));
cache.applyManifest(makeManifest(
sk, KeyType::ed25519, kp1.second, KeyType::secp256k1, 1)));
BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first);
BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk);
BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first);
@@ -601,10 +486,8 @@ public:
// applied with the same signing key but a higher sequence
BEAST_EXPECT(
ManifestDisposition::badEphemeralKey ==
cache.applyManifest(
makeManifest(
sk, KeyType::ed25519, kp1.second, KeyType::secp256k1, 2),
ManifestRetention::evictable));
cache.applyManifest(makeManifest(
sk, KeyType::ed25519, kp1.second, KeyType::secp256k1, 2)));
BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first);
BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk);
BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first);
@@ -614,9 +497,7 @@ public:
// key from a revoked master public key
BEAST_EXPECT(
ManifestDisposition::accepted ==
cache.applyManifest(
makeRevocation(sk, KeyType::ed25519),
ManifestRetention::evictable));
cache.applyManifest(makeRevocation(sk, KeyType::ed25519)));
BEAST_EXPECT(cache.revoked(pk));
BEAST_EXPECT(cache.getSigningKey(pk) == pk);
BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first);
@@ -1094,319 +975,6 @@ public:
".example.com"));
}
void
testUntrustedEviction()
{
testcase("untrusted eviction");
auto now = std::chrono::steady_clock::time_point{};
ManifestCache cache(
beast::Journal(beast::Journal::getNullSink()),
[&now] { return now; });
auto const trustedMasterSecret = randomSecretKey();
auto const trustedMaster =
derivePublicKey(KeyType::ed25519, trustedMasterSecret);
auto const trustedSigning = randomKeyPair(KeyType::secp256k1);
BEAST_EXPECT(
cache.applyManifest(
makeManifest(
trustedMasterSecret,
KeyType::ed25519,
trustedSigning.second,
KeyType::secp256k1,
0),
ManifestRetention::protected_) ==
ManifestDisposition::accepted);
std::vector<PublicKey> untrustedMasters;
std::vector<PublicKey> untrustedSigningKeys;
untrustedMasters.reserve(1000);
untrustedSigningKeys.reserve(1000);
for (std::size_t i = 0; i < 1000; ++i)
{
auto const masterSecret = randomSecretKey();
auto const master = derivePublicKey(KeyType::ed25519, masterSecret);
auto const signing = randomKeyPair(KeyType::secp256k1);
BEAST_EXPECT(
cache.applyManifest(
makeManifest(
masterSecret,
KeyType::ed25519,
signing.second,
KeyType::secp256k1,
0),
ManifestRetention::evictable) ==
ManifestDisposition::accepted);
untrustedMasters.push_back(master);
untrustedSigningKeys.push_back(signing.first);
}
auto retainedMasters = [&cache]() {
hash_set<PublicKey> result;
cache.for_each_manifest([&result](Manifest const& manifest) {
result.insert(manifest.masterKey);
});
return result;
};
auto const beforeInvalid = retainedMasters();
auto const invalidMasterSecret = randomSecretKey();
auto const invalidSigning = randomKeyPair(KeyType::secp256k1);
bool sampledCurrentValidations = false;
BEAST_EXPECT(
cache
.applyManifestWithEviction(
makeManifest(
invalidMasterSecret,
KeyType::ed25519,
invalidSigning.second,
KeyType::secp256k1,
0,
true),
[&sampledCurrentValidations] {
sampledCurrentValidations = true;
return hash_set<PublicKey>{};
})
.disposition == ManifestDisposition::invalid);
BEAST_EXPECT(!sampledCurrentValidations);
BEAST_EXPECT(retainedMasters() == beforeInvalid);
auto const incomingMasterSecret = randomSecretKey();
auto const incomingMaster =
derivePublicKey(KeyType::ed25519, incomingMasterSecret);
auto const incomingSigning = randomKeyPair(KeyType::secp256k1);
auto const incoming = makeManifest(
incomingMasterSecret,
KeyType::ed25519,
incomingSigning.second,
KeyType::secp256k1,
0);
BEAST_EXPECT(
cache.applyManifest(
clone(incoming), ManifestRetention::evictable) ==
ManifestDisposition::untrustedCapacity);
hash_set<PublicKey> const currentValidationKeys = {
untrustedSigningKeys.front()};
auto const incomingAdmission = cache.applyManifestWithEviction(
clone(incoming),
[&currentValidationKeys] { return currentValidationKeys; });
BEAST_EXPECT(
incomingAdmission.disposition == ManifestDisposition::accepted);
BEAST_EXPECT(!incomingAdmission.acceptedUpdate);
auto const afterEviction = retainedMasters();
BEAST_EXPECT(afterEviction.size() == 1001);
BEAST_EXPECT(afterEviction.contains(trustedMaster));
BEAST_EXPECT(afterEviction.contains(untrustedMasters.front()));
BEAST_EXPECT(afterEviction.contains(incomingMaster));
std::optional<std::size_t> evicted;
for (std::size_t i = 0; i < untrustedMasters.size(); ++i)
{
if (!afterEviction.contains(untrustedMasters[i]))
{
BEAST_EXPECT(!evicted);
evicted = i;
}
}
BEAST_EXPECT(evicted);
if (evicted)
{
BEAST_EXPECT(
cache.getMasterKey(untrustedSigningKeys[*evicted]) ==
untrustedSigningKeys[*evicted]);
}
auto const replacementSigning = randomKeyPair(KeyType::secp256k1);
auto const updateAdmission =
cache.applyManifestWithEviction(makeManifest(
incomingMasterSecret,
KeyType::ed25519,
replacementSigning.second,
KeyType::secp256k1,
1));
BEAST_EXPECT(
updateAdmission.disposition == ManifestDisposition::accepted);
BEAST_EXPECT(updateAdmission.acceptedUpdate);
BEAST_EXPECT(retainedMasters() == afterEviction);
hash_set<PublicKey> allCurrentSigningKeys;
cache.for_each_manifest(
[&allCurrentSigningKeys](Manifest const& manifest) {
if (manifest.signingKey)
allCurrentSigningKeys.insert(*manifest.signingKey);
});
allCurrentSigningKeys.erase(replacementSigning.first);
auto const secondIncomingMasterSecret = randomSecretKey();
auto const secondIncomingMaster =
derivePublicKey(KeyType::ed25519, secondIncomingMasterSecret);
auto const secondIncomingSigning = randomKeyPair(KeyType::secp256k1);
BEAST_EXPECT(
cache
.applyManifestWithEviction(
makeManifest(
secondIncomingMasterSecret,
KeyType::ed25519,
secondIncomingSigning.second,
KeyType::secp256k1,
0),
[&allCurrentSigningKeys] { return allCurrentSigningKeys; })
.disposition == ManifestDisposition::accepted);
auto const afterSoleDormantEviction = retainedMasters();
BEAST_EXPECT(afterSoleDormantEviction.size() == 1001);
BEAST_EXPECT(afterSoleDormantEviction.contains(trustedMaster));
BEAST_EXPECT(afterSoleDormantEviction.contains(secondIncomingMaster));
BEAST_EXPECT(!afterSoleDormantEviction.contains(incomingMaster));
// With no dormant victim, eviction falls back to the full evictable
// population.
allCurrentSigningKeys.clear();
cache.for_each_manifest(
[&allCurrentSigningKeys](Manifest const& manifest) {
if (manifest.signingKey)
allCurrentSigningKeys.insert(*manifest.signingKey);
});
auto const thirdIncomingMasterSecret = randomSecretKey();
auto const thirdIncomingMaster =
derivePublicKey(KeyType::ed25519, thirdIncomingMasterSecret);
auto const thirdIncomingSigning = randomKeyPair(KeyType::secp256k1);
BEAST_EXPECT(
cache
.applyManifestWithEviction(
makeManifest(
thirdIncomingMasterSecret,
KeyType::ed25519,
thirdIncomingSigning.second,
KeyType::secp256k1,
0),
[&allCurrentSigningKeys] { return allCurrentSigningKeys; })
.disposition == ManifestDisposition::accepted);
auto const afterFallbackEviction = retainedMasters();
BEAST_EXPECT(afterFallbackEviction.size() == 1001);
BEAST_EXPECT(afterFallbackEviction.contains(trustedMaster));
BEAST_EXPECT(afterFallbackEviction.contains(thirdIncomingMaster));
// Three eviction permits were consumed above. Time is held fixed while
// the remainder of the burst is consumed.
std::vector<Manifest> evictionBurst;
evictionBurst.reserve(8);
for (std::size_t i = 0; i < 8; ++i)
{
auto const masterSecret = randomSecretKey();
auto const signing = randomKeyPair(KeyType::secp256k1);
evictionBurst.push_back(makeManifest(
masterSecret,
KeyType::ed25519,
signing.second,
KeyType::secp256k1,
0));
}
for (std::size_t i = 0; i < 7; ++i)
{
BEAST_EXPECT(
cache.applyManifestWithEviction(std::move(evictionBurst[i]))
.disposition == ManifestDisposition::accepted);
}
BEAST_EXPECT(
cache.applyManifestWithEviction(std::move(evictionBurst.back()))
.disposition == ManifestDisposition::untrustedCapacity);
BEAST_EXPECT(retainedMasters().size() == 1001);
now += std::chrono::seconds{1};
auto const refilledMasterSecret = randomSecretKey();
auto const refilledSigning = randomKeyPair(KeyType::secp256k1);
BEAST_EXPECT(
cache
.applyManifestWithEviction(makeManifest(
refilledMasterSecret,
KeyType::ed25519,
refilledSigning.second,
KeyType::secp256k1,
0))
.disposition == ManifestDisposition::accepted);
// Exhausting the eviction budget must not block an existing entry.
auto const finalReplacementSigning = randomKeyPair(KeyType::secp256k1);
BEAST_EXPECT(
cache
.applyManifestWithEviction(makeManifest(
refilledMasterSecret,
KeyType::ed25519,
finalReplacementSigning.second,
KeyType::secp256k1,
1))
.disposition == ManifestDisposition::accepted);
}
void
testRetentionInvalidatesSnapshot()
{
testcase("retention changes invalidate snapshot");
ManifestCache cache;
auto const masterSecret = randomSecretKey();
auto const master = derivePublicKey(KeyType::ed25519, masterSecret);
auto const signing = randomKeyPair(KeyType::secp256k1);
BEAST_EXPECT(
cache.applyManifest(
makeManifest(
masterSecret,
KeyType::ed25519,
signing.second,
KeyType::secp256k1,
0),
ManifestRetention::evictable) == ManifestDisposition::accepted);
auto const admitted = cache.sequence();
cache.setRetention(randomMasterKey(), ManifestRetention::protected_);
BEAST_EXPECT(cache.sequence() == admitted);
cache.setRetention(master, ManifestRetention::protected_);
BEAST_EXPECT(cache.sequence() == admitted + 1);
cache.reconcileRetention({master});
BEAST_EXPECT(cache.sequence() == admitted + 1);
cache.reconcileRetention({});
BEAST_EXPECT(cache.sequence() == admitted + 2);
BEAST_EXPECT(cache.getSequence(master) == 0);
cache.reconcileRetention({master});
BEAST_EXPECT(cache.sequence() == admitted + 3);
cache.setRetention(master, ManifestRetention::protected_);
BEAST_EXPECT(cache.sequence() == admitted + 3);
ManifestCache retryCache;
auto const retryMasterSecret = randomSecretKey();
auto const retrySigning = randomKeyPair(KeyType::secp256k1);
BEAST_EXPECT(
retryCache.applyManifest(
makeManifest(
retryMasterSecret,
KeyType::ed25519,
retrySigning.second,
KeyType::secp256k1,
0),
ManifestRetention::evictable) == ManifestDisposition::accepted);
auto const retryAdmitted = retryCache.sequence();
BEAST_EXPECT(
retryCache.applyManifest(
makeManifest(
retryMasterSecret,
KeyType::ed25519,
retrySigning.second,
KeyType::secp256k1,
0),
ManifestRetention::protected_) == ManifestDisposition::stale);
BEAST_EXPECT(retryCache.sequence() == retryAdmitted + 1);
}
void
run() override
{
@@ -1447,30 +1015,21 @@ public:
// applyManifest should accept new manifests with
// higher sequence numbers
BEAST_EXPECT(
cache.applyManifest(
clone(s_a0), ManifestRetention::evictable) ==
cache.applyManifest(clone(s_a0)) ==
ManifestDisposition::accepted);
BEAST_EXPECT(
cache.applyManifest(
clone(s_a0), ManifestRetention::evictable) ==
ManifestDisposition::stale);
cache.applyManifest(clone(s_a0)) == ManifestDisposition::stale);
BEAST_EXPECT(
cache.applyManifest(
clone(s_a1), ManifestRetention::evictable) ==
cache.applyManifest(clone(s_a1)) ==
ManifestDisposition::accepted);
BEAST_EXPECT(
cache.applyManifest(
clone(s_a1), ManifestRetention::evictable) ==
ManifestDisposition::stale);
cache.applyManifest(clone(s_a1)) == ManifestDisposition::stale);
BEAST_EXPECT(
cache.applyManifest(
clone(s_a0), ManifestRetention::evictable) ==
ManifestDisposition::stale);
cache.applyManifest(clone(s_a0)) == ManifestDisposition::stale);
BEAST_EXPECT(
cache.applyManifest(
clone(s_a2), ManifestRetention::evictable) ==
cache.applyManifest(clone(s_a2)) ==
ManifestDisposition::badEphemeralKey);
// applyManifest should accept manifests with max sequence numbers
@@ -1478,40 +1037,29 @@ public:
BEAST_EXPECT(!cache.revoked(pk_a));
BEAST_EXPECT(s_aMax.revoked());
BEAST_EXPECT(
cache.applyManifest(
clone(s_aMax), ManifestRetention::evictable) ==
cache.applyManifest(clone(s_aMax)) ==
ManifestDisposition::accepted);
BEAST_EXPECT(
cache.applyManifest(
clone(s_aMax), ManifestRetention::evictable) ==
cache.applyManifest(clone(s_aMax)) ==
ManifestDisposition::stale);
BEAST_EXPECT(
cache.applyManifest(
clone(s_a1), ManifestRetention::evictable) ==
ManifestDisposition::stale);
cache.applyManifest(clone(s_a1)) == ManifestDisposition::stale);
BEAST_EXPECT(
cache.applyManifest(
clone(s_a0), ManifestRetention::evictable) ==
ManifestDisposition::stale);
cache.applyManifest(clone(s_a0)) == ManifestDisposition::stale);
BEAST_EXPECT(cache.revoked(pk_a));
// applyManifest should reject manifests with invalid signatures
BEAST_EXPECT(
cache.applyManifest(
clone(s_b0), ManifestRetention::evictable) ==
cache.applyManifest(clone(s_b0)) ==
ManifestDisposition::accepted);
BEAST_EXPECT(
cache.applyManifest(
clone(s_b0), ManifestRetention::evictable) ==
ManifestDisposition::stale);
cache.applyManifest(clone(s_b0)) == ManifestDisposition::stale);
BEAST_EXPECT(!deserializeManifest(fake));
BEAST_EXPECT(
cache.applyManifest(
clone(s_b1), ManifestRetention::evictable) ==
cache.applyManifest(clone(s_b1)) ==
ManifestDisposition::invalid);
BEAST_EXPECT(
cache.applyManifest(
clone(s_b2), ManifestRetention::evictable) ==
cache.applyManifest(clone(s_b2)) ==
ManifestDisposition::accepted);
auto const s_c0 = makeManifest(
@@ -1521,21 +1069,17 @@ public:
KeyType::ed25519,
47);
BEAST_EXPECT(
cache.applyManifest(
clone(s_c0), ManifestRetention::evictable) ==
cache.applyManifest(clone(s_c0)) ==
ManifestDisposition::badMasterKey);
}
testLoadStore(cache);
testStartupRetentionReconciliation();
testGetSignature();
testGetKeys();
testValidatorToken();
testManifestDeserialization();
testManifestDomainNames();
testManifestVersioning();
testUntrustedEviction();
testRetentionInvalidatesSnapshot();
}
};

View File

@@ -131,8 +131,7 @@ private:
std::vector<Validator> const& validators,
std::size_t sequence,
std::size_t validUntil,
std::optional<std::size_t> validFrom = {},
std::vector<Validator> const& candidates = {})
std::optional<std::size_t> validFrom = {})
{
std::string data = "{\"sequence\":" + std::to_string(sequence) +
",\"expiration\":" + std::to_string(validUntil);
@@ -143,31 +142,11 @@ private:
for (auto const& val : validators)
{
data += "{\"validation_public_key\":\"" + strHex(val.masterPublic) +
"\"";
if (!val.manifest.empty())
data += ",\"manifest\":\"" + val.manifest + "\"";
data += "},";
"\",\"manifest\":\"" + val.manifest + "\"},";
}
data.pop_back();
data += "]";
if (!candidates.empty())
{
data += ",\"candidates\":[";
for (auto const& val : candidates)
{
data += "{\"validation_public_key\":\"" +
strHex(val.masterPublic) + "\"";
if (!val.manifest.empty())
data += ",\"manifest\":\"" + val.manifest + "\"";
data += "},";
}
data.pop_back();
data += "]";
}
data += "}";
data += "]}";
return base64_encode(data);
}
@@ -306,9 +285,7 @@ private:
localSigningPublicOuter, emptyCfgKeys, emptyCfgPublishers));
BEAST_EXPECT(trustedKeys->listed(localSigningPublicOuter));
manifests.applyManifest(
*deserializeManifest(cfgManifest),
ManifestRetention::evictable);
manifests.applyManifest(*deserializeManifest(cfgManifest));
BEAST_EXPECT(trustedKeys->load(
localSigningPublicOuter, emptyCfgKeys, emptyCfgPublishers));
@@ -405,9 +382,7 @@ private:
app.config().legacy("database_path"),
env.journal);
manifests.applyManifest(
*deserializeManifest(cfgManifest),
ManifestRetention::evictable);
manifests.applyManifest(*deserializeManifest(cfgManifest));
BEAST_EXPECT(trustedKeys->load(
localSigningPublicOuter, cfgKeys, emptyCfgPublishers));
@@ -497,14 +472,12 @@ private:
auto const pubRevokedSigning = randomKeyPair(KeyType::secp256k1);
// make this manifest revoked (seq num = max)
// -- thus should not be loaded
pubManifests.applyManifest(
*deserializeManifest(makeManifestString(
pubRevokedPublic,
pubRevokedSecret,
pubRevokedSigning.first,
pubRevokedSigning.second,
std::numeric_limits<std::uint32_t>::max())),
ManifestRetention::evictable);
pubManifests.applyManifest(*deserializeManifest(makeManifestString(
pubRevokedPublic,
pubRevokedSecret,
pubRevokedSigning.first,
pubRevokedSigning.second,
std::numeric_limits<std::uint32_t>::max())));
// these two are not revoked (and not in the manifest cache at all.)
auto legitKey1 = randomMasterKey();
@@ -538,14 +511,12 @@ private:
auto const pubRevokedSigning = randomKeyPair(KeyType::secp256k1);
// make this manifest revoked (seq num = max)
// -- thus should not be loaded
pubManifests.applyManifest(
*deserializeManifest(makeManifestString(
pubRevokedPublic,
pubRevokedSecret,
pubRevokedSigning.first,
pubRevokedSigning.second,
std::numeric_limits<std::uint32_t>::max())),
ManifestRetention::evictable);
pubManifests.applyManifest(*deserializeManifest(makeManifestString(
pubRevokedPublic,
pubRevokedSecret,
pubRevokedSigning.first,
pubRevokedSigning.second,
std::numeric_limits<std::uint32_t>::max())));
// this one is not revoked (and not in the manifest cache at all.)
auto legitKey = randomMasterKey();
@@ -696,17 +667,12 @@ private:
checkResult(
trustedKeys->applyLists(
manifest1, version, {{expiredblob, expiredSig, {}}}, siteUri),
manifest1,
version,
{{expiredblob, expiredSig, {}}, {blob2, sig2, {}}},
siteUri),
publisherPublic,
ListDisposition::expired,
ListDisposition::expired);
expectUntrusted(lists.at(1));
checkResult(
trustedKeys->applyLists(
manifest1, version, {{blob2, sig2, {}}}, siteUri),
publisherPublic,
ListDisposition::accepted,
ListDisposition::accepted);
expectTrusted(lists.at(2));
@@ -1016,439 +982,6 @@ private:
checkAvailable(trustedKeys, hexPublic, manifest2, 0, {});
}
void
testCandidates()
{
testcase("Publisher candidate manifests");
using namespace std::chrono_literals;
ManifestCache validatorManifests;
ManifestCache publisherManifests;
jtx::Env env(*this);
auto& app = env.app();
auto trustedKeys = std::make_unique<ValidatorList>(
validatorManifests,
publisherManifests,
env.timeKeeper(),
app.config().legacy("database_path"),
env.journal);
auto const publisherSecret = randomSecretKey();
auto const publisherPublic =
derivePublicKey(KeyType::ed25519, publisherSecret);
auto const publisherSigning = randomKeyPair(KeyType::secp256k1);
auto const publisherManifest = base64_encode(makeManifestString(
publisherPublic,
publisherSecret,
publisherSigning.first,
publisherSigning.second,
1));
BEAST_EXPECT(trustedKeys->load(
{}, {}, std::vector<std::string>{strHex(publisherPublic)}));
// Fill the bounded population. A publisher candidate is still admitted
// because its tier changes retention policy, not manifest authority.
for (std::size_t i = 0; i < ValidatorList::maxPublisherCandidates; ++i)
{
auto const filler = randomValidator();
auto manifest = deserializeManifest(base64_decode(filler.manifest));
BEAST_EXPECT(manifest);
if (manifest)
BEAST_EXPECT(
validatorManifests.applyManifest(
std::move(*manifest), ManifestRetention::evictable) ==
ManifestDisposition::accepted);
}
auto const listed = randomValidator();
auto const candidate = randomValidator();
auto const validUntil = env.timeKeeper().now() + 1h;
auto const blob = makeList(
{listed},
1,
validUntil.time_since_epoch().count(),
{},
{candidate});
auto const signature = signList(blob, publisherSigning);
BEAST_EXPECT(
trustedKeys
->applyLists(
publisherManifest,
1,
{{blob, signature, {}}},
"testCandidates.test")
.bestDisposition() == ListDisposition::accepted);
BEAST_EXPECT(trustedKeys->listed(listed.masterPublic));
BEAST_EXPECT(!trustedKeys->listed(candidate.masterPublic));
BEAST_EXPECT(!trustedKeys->trusted(candidate.masterPublic));
auto const policy = trustedKeys->manifestPolicy(candidate.masterPublic);
BEAST_EXPECT(!policy.consensusListed);
BEAST_EXPECT(policy.publisherCandidate);
BEAST_EXPECT(policy.relayEligible());
// All provenance classes share ManifestCache's one high-water and
// signer-to-master index. Candidate status only protects retention.
BEAST_EXPECT(
validatorManifests.getMasterKey(candidate.signingPublic) ==
candidate.masterPublic);
BEAST_EXPECT(
validatorManifests.getSigningKey(candidate.masterPublic) ==
candidate.signingPublic);
BEAST_EXPECT(
validatorManifests.getSequence(candidate.masterPublic) == 1);
auto const candidateMasterSecret = randomSecretKey();
auto const candidateMaster =
derivePublicKey(KeyType::ed25519, candidateMasterSecret);
auto const candidateSigning1 = randomKeyPair(KeyType::secp256k1);
auto const candidateSigning2 = randomKeyPair(KeyType::secp256k1);
Validator const rotatingCandidate{
candidateMaster,
candidateSigning1.first,
base64_encode(makeManifestString(
candidateMaster,
candidateMasterSecret,
candidateSigning1.first,
candidateSigning1.second,
1))};
auto const blob2 = makeList(
{listed},
2,
validUntil.time_since_epoch().count(),
{},
{candidate, rotatingCandidate});
auto const signature2 = signList(blob2, publisherSigning);
BEAST_EXPECT(
trustedKeys
->applyLists(
publisherManifest,
1,
{{blob2, signature2, {}}},
"testCandidates.test")
.bestDisposition() == ListDisposition::accepted);
auto rotation = deserializeManifest(makeManifestString(
candidateMaster,
candidateMasterSecret,
candidateSigning2.first,
candidateSigning2.second,
2));
BEAST_EXPECT(rotation);
if (rotation)
BEAST_EXPECT(
validatorManifests.applyManifest(
std::move(*rotation), ManifestRetention::protected_) ==
ManifestDisposition::accepted);
BEAST_EXPECT(
validatorManifests.getSigningKey(candidateMaster) ==
candidateSigning2.first);
// Removing the candidate removes only its protection. Because the
// bounded population is already full, its transient manifest is
// discarded rather than creating a second retention pool.
auto const blob3 =
makeList({listed}, 3, validUntil.time_since_epoch().count());
auto const signature3 = signList(blob3, publisherSigning);
BEAST_EXPECT(
trustedKeys
->applyLists(
publisherManifest,
1,
{{blob3, signature3, {}}},
"testCandidates.test")
.bestDisposition() == ListDisposition::accepted);
BEAST_EXPECT(
!trustedKeys->manifestPolicy(candidateMaster).publisherCandidate);
BEAST_EXPECT(!validatorManifests.getSequence(candidateMaster));
// Protection follows the current union of configured/listed and
// candidate masters. Removing a tier-1 entry demotes it through the
// same reconciliation path; it does not remain protected forever.
auto const replacementListed = randomValidator();
auto const blob4 = makeList(
{replacementListed}, 4, validUntil.time_since_epoch().count());
auto const signature4 = signList(blob4, publisherSigning);
BEAST_EXPECT(
trustedKeys
->applyLists(
publisherManifest,
1,
{{blob4, signature4, {}}},
"testCandidates.test")
.bestDisposition() == ListDisposition::accepted);
BEAST_EXPECT(!trustedKeys->listed(listed.masterPublic));
BEAST_EXPECT(!validatorManifests.getSequence(listed.masterPublic));
BEAST_EXPECT(trustedKeys->listed(replacementListed.masterPublic));
}
void
testCandidateAggregateCap()
{
testcase("Publisher candidate aggregate cap");
using namespace std::chrono_literals;
struct Publisher
{
PublicKey master;
std::pair<PublicKey, SecretKey> signing;
std::string manifest;
};
auto makePublisher = []() {
auto const masterSecret = randomSecretKey();
auto const master = derivePublicKey(KeyType::ed25519, masterSecret);
auto signing = randomKeyPair(KeyType::secp256k1);
auto manifest = base64_encode(makeManifestString(
master, masterSecret, signing.first, signing.second, 1));
return Publisher{master, std::move(signing), std::move(manifest)};
};
std::array<Publisher, 2> const publishers{
makePublisher(), makePublisher()};
std::array<std::vector<Validator>, 2> candidates;
std::vector<PublicKey> candidateMasters;
candidateMasters.reserve(1200);
for (auto& publisherCandidates : candidates)
{
publisherCandidates.reserve(600);
for (std::size_t i = 0; i < 600; ++i)
{
auto candidate = randomValidator();
candidateMasters.push_back(candidate.masterPublic);
publisherCandidates.push_back(std::move(candidate));
}
}
std::sort(candidateMasters.begin(), candidateMasters.end());
jtx::Env env(*this);
auto const validUntil = env.timeKeeper().now() + 1h;
auto const listed = randomValidator();
std::array<std::string, 2> blobs;
std::array<std::string, 2> signatures;
for (std::size_t i = 0; i < publishers.size(); ++i)
{
blobs[i] = makeList(
{listed},
1,
validUntil.time_since_epoch().count(),
{},
candidates[i]);
signatures[i] = signList(blobs[i], publishers[i].signing);
}
auto checkOrder = [&](std::array<std::size_t, 2> const& order) {
ManifestCache validatorManifests;
ManifestCache publisherManifests;
auto trustedKeys = std::make_unique<ValidatorList>(
validatorManifests,
publisherManifests,
env.timeKeeper(),
env.app().config().legacy("database_path"),
env.journal);
BEAST_EXPECT(trustedKeys->load(
{},
{},
std::vector<std::string>{
strHex(publishers[0].master),
strHex(publishers[1].master)}));
for (auto const i : order)
{
BEAST_EXPECT(
trustedKeys
->applyLists(
publishers[i].manifest,
1,
{{blobs[i], signatures[i], {}}},
"testCandidateAggregateCap.test")
.bestDisposition() == ListDisposition::accepted);
}
hash_set<PublicKey> protectedMasters;
validatorManifests.for_each_manifest(
[&protectedMasters](std::size_t size) {
protectedMasters.reserve(size);
},
[&protectedMasters](
Manifest const& manifest, ManifestRetention retention) {
if (retention == ManifestRetention::protected_)
protectedMasters.insert(manifest.masterKey);
});
for (std::size_t i = 0; i < candidateMasters.size(); ++i)
{
auto const& master = candidateMasters[i];
auto const policy = trustedKeys->manifestPolicy(master);
bool const retained = i < ValidatorList::maxPublisherCandidates;
BEAST_EXPECT(policy.publisherCandidate == retained);
BEAST_EXPECT(policy.relayEligible() == retained);
BEAST_EXPECT(protectedMasters.contains(master) == retained);
}
};
checkOrder({0, 1});
checkOrder({1, 0});
}
void
testExpiredPendingAccounting()
{
testcase("Expired pending list preserves other publisher counts");
using namespace std::chrono_literals;
struct Publisher
{
PublicKey master;
std::pair<PublicKey, SecretKey> signing;
std::string manifest;
};
auto makePublisher = []() {
auto const masterSecret = randomSecretKey();
auto const master = derivePublicKey(KeyType::ed25519, masterSecret);
auto signing = randomKeyPair(KeyType::secp256k1);
auto manifest = base64_encode(makeManifestString(
master, masterSecret, signing.first, signing.second, 1));
return Publisher{master, std::move(signing), std::move(manifest)};
};
ManifestCache validatorManifests;
ManifestCache publisherManifests;
jtx::Env env(*this);
auto& app = env.app();
auto validators = std::make_unique<ValidatorList>(
validatorManifests,
publisherManifests,
env.timeKeeper(),
app.config().legacy("database_path"),
env.journal);
auto const publisherA = makePublisher();
auto const publisherB = makePublisher();
BEAST_EXPECT(validators->load(
{}, {}, {strHex(publisherA.master), strHex(publisherB.master)}));
auto const shared = randomValidator();
auto const replacement = randomValidator();
auto const now = env.timeKeeper().now();
std::string const siteUri = "expired-pending-accounting.test";
auto const currentBlob =
makeList({shared}, 1, (now + 1h).time_since_epoch().count());
auto const currentSig = signList(currentBlob, publisherB.signing);
checkResult(
validators->applyLists(
publisherB.manifest,
2,
{{currentBlob, currentSig, {}}},
siteUri),
publisherB.master,
ListDisposition::accepted,
ListDisposition::accepted);
BEAST_EXPECT(validators->listed(shared.masterPublic));
auto const pendingBlob = makeList(
{shared},
1,
(now + 20s).time_since_epoch().count(),
(now + 10s).time_since_epoch().count());
auto const pendingSig = signList(pendingBlob, publisherA.signing);
checkResult(
validators->applyLists(
publisherA.manifest,
2,
{{pendingBlob, pendingSig, {}}},
siteUri),
publisherA.master,
ListDisposition::pending,
ListDisposition::pending);
env.timeKeeper().set(now + 21s);
validators->updateTrusted(
{},
env.timeKeeper().now(),
app.getOPs(),
app.overlay(),
app.getHashRouter());
BEAST_EXPECT(app.getOPs().isUNLBlocked());
BEAST_EXPECT(validators->listed(shared.masterPublic));
auto const refreshBlob =
makeList({replacement}, 2, (now + 1h).time_since_epoch().count());
auto const refreshSig = signList(refreshBlob, publisherA.signing);
checkResult(
validators->applyLists(
publisherA.manifest,
2,
{{refreshBlob, refreshSig, {}}},
siteUri),
publisherA.master,
ListDisposition::accepted,
ListDisposition::accepted);
// Publisher A never contributed the shared key, so refreshing A must
// not remove publisher B's contribution.
BEAST_EXPECT(validators->listed(shared.masterPublic));
BEAST_EXPECT(validators->listed(replacement.masterPublic));
// Reapplying a pending list after its expiry exercises applyList's
// remaining-to-current recovery path rather than updateTrusted.
app.getOPs().clearUNLBlocked();
auto const expiredOnly = randomValidator();
auto const pendingAgain = makeList(
{expiredOnly},
3,
(env.timeKeeper().now() + 20s).time_since_epoch().count(),
(env.timeKeeper().now() + 10s).time_since_epoch().count());
auto const pendingAgainSig = signList(pendingAgain, publisherA.signing);
checkResult(
validators->applyLists(
publisherA.manifest,
2,
{{pendingAgain, pendingAgainSig, {}}},
siteUri),
publisherA.master,
ListDisposition::pending,
ListDisposition::pending);
env.timeKeeper().set(env.timeKeeper().now() + 21s);
checkResult(
validators->applyListsAndBroadcast(
publisherA.manifest,
2,
{{pendingAgain, pendingAgainSig, {}}},
siteUri,
uint256{},
app.overlay(),
app.getHashRouter(),
app.getOPs()),
publisherA.master,
ListDisposition::expired,
ListDisposition::expired);
BEAST_EXPECT(app.getOPs().isUNLBlocked());
BEAST_EXPECT(!validators->listed(replacement.masterPublic));
BEAST_EXPECT(!validators->listed(expiredOnly.masterPublic));
BEAST_EXPECT(validators->listed(shared.masterPublic));
// Publisher A never contributed expiredOnly. When publisher B moves
// away from shared, neither key may survive through phantom counts.
auto const replacementB = randomValidator();
auto const refreshB = makeList(
{replacementB},
2,
(env.timeKeeper().now() + 1h).time_since_epoch().count());
auto const refreshBSig = signList(refreshB, publisherB.signing);
checkResult(
validators->applyLists(
publisherB.manifest, 2, {{refreshB, refreshBSig, {}}}, siteUri),
publisherB.master,
ListDisposition::accepted,
ListDisposition::accepted);
BEAST_EXPECT(!validators->listed(shared.masterPublic));
BEAST_EXPECT(validators->listed(replacementB.masterPublic));
}
void
testGetAvailable()
{
@@ -1704,8 +1237,7 @@ private:
1));
BEAST_EXPECT(
manifestsOuter.applyManifest(
std::move(*m1), ManifestRetention::evictable) ==
manifestsOuter.applyManifest(std::move(*m1)) ==
ManifestDisposition::accepted);
BEAST_EXPECT(trustedKeysOuter->listed(masterPublic));
BEAST_EXPECT(trustedKeysOuter->trusted(masterPublic));
@@ -1723,8 +1255,7 @@ private:
signingKeys2.second,
2));
BEAST_EXPECT(
manifestsOuter.applyManifest(
std::move(*m2), ManifestRetention::evictable) ==
manifestsOuter.applyManifest(std::move(*m2)) ==
ManifestDisposition::accepted);
BEAST_EXPECT(trustedKeysOuter->listed(masterPublic));
BEAST_EXPECT(trustedKeysOuter->trusted(masterPublic));
@@ -1742,8 +1273,7 @@ private:
BEAST_EXPECT(mMax->revoked());
BEAST_EXPECT(
manifestsOuter.applyManifest(
std::move(*mMax), ManifestRetention::evictable) ==
manifestsOuter.applyManifest(std::move(*mMax)) ==
ManifestDisposition::accepted);
BEAST_EXPECT(
manifestsOuter.getSigningKey(masterPublic) == masterPublic);
@@ -3242,8 +2772,7 @@ private:
if (self)
{
valManifests.applyManifest(
*deserializeManifest(base64_decode(self->manifest)),
ManifestRetention::evictable);
*deserializeManifest(base64_decode(self->manifest)));
BEAST_EXPECT(result->load(
self->signingPublic,
emptyCfgKeys,
@@ -4605,9 +4134,6 @@ public:
testGenesisQuorum();
testConfigLoad();
testApplyLists();
testCandidates();
testCandidateAggregateCap();
testExpiredPendingAccounting();
testGetAvailable();
testUpdateTrusted();
testExpires();

View File

@@ -135,7 +135,7 @@ private:
std::string msg;
bool ssl;
bool failFetch = false;
bool expectUnlisted = false;
bool failApply = false;
int serverVersion = 1;
std::chrono::seconds expiresFromNow = detail::default_expires;
std::chrono::seconds effectiveOverlap =
@@ -252,11 +252,9 @@ private:
for (auto const& val : u.list)
{
BEAST_EXPECT(
trustedKeys.listed(val.masterPublic) !=
u.cfg.expectUnlisted);
trustedKeys.listed(val.masterPublic) != u.cfg.failApply);
BEAST_EXPECT(
trustedKeys.listed(val.signingPublic) !=
u.cfg.expectUnlisted);
trustedKeys.listed(val.signingPublic) != u.cfg.failApply);
}
Json::Value myStatus;
@@ -572,7 +570,7 @@ public:
"Applied 1 expired validator list(s)",
ssl,
false,
true,
false,
1,
0s}});
testFetchList(
@@ -581,7 +579,7 @@ public:
"Applied 1 expired validator list(s)",
ssl,
false,
true,
false,
1,
0s,
-1s}});

View File

@@ -564,10 +564,6 @@ class Validations_test : public beast::unit_test::suite
{
hash_set<PeerID> const expectedKeys = {a.nodeID(), b.nodeID()};
BEAST_EXPECT(harness.vals().getCurrentNodeIDs() == expectedKeys);
hash_set<PeerKey> const expectedSigningKeys = {
a.currKey(), b.currKey()};
BEAST_EXPECT(
harness.vals().getCurrentNodeKeys() == expectedSigningKeys);
}
harness.clock().advance(3s);
@@ -583,16 +579,11 @@ class Validations_test : public beast::unit_test::suite
{
hash_set<PeerID> const expectedKeys = {a.nodeID(), b.nodeID()};
BEAST_EXPECT(harness.vals().getCurrentNodeIDs() == expectedKeys);
hash_set<PeerKey> const expectedSigningKeys = {
a.currKey(), b.currKey()};
BEAST_EXPECT(
harness.vals().getCurrentNodeKeys() == expectedSigningKeys);
}
// Pass enough time for them to go stale
harness.clock().advance(harness.parms().validationCURRENT_LOCAL);
BEAST_EXPECT(harness.vals().getCurrentNodeIDs().empty());
BEAST_EXPECT(harness.vals().getCurrentNodeKeys().empty());
}
void

View File

@@ -45,7 +45,6 @@
#include <boost/beast/core/multi_buffer.hpp>
#include <boost/endian/conversion.hpp>
#include <algorithm>
#include <array>
namespace ripple {
@@ -75,95 +74,6 @@ class compression_test : public beast::unit_test::suite
using Compressed = compression::Compressed;
using Algorithm = compression::Algorithm;
class ProtocolHandler
{
public:
bool invoked = false;
bool
compressionEnabled() const
{
return true;
}
void
onMessageUnknown(std::uint16_t)
{
invoked = true;
}
void
onMessageBegin(
std::uint16_t,
std::shared_ptr<::google::protobuf::Message> const&,
std::size_t,
std::size_t,
bool)
{
invoked = true;
}
template <class T>
void
onMessage(std::shared_ptr<T> const&)
{
invoked = true;
}
void
onMessageEnd(
std::uint16_t,
std::shared_ptr<::google::protobuf::Message> const&)
{
invoked = true;
}
};
static void
put16(
std::vector<std::uint8_t>& frame,
std::size_t const offset,
std::uint16_t const value)
{
frame[offset] = static_cast<std::uint8_t>(value >> 8);
frame[offset + 1] = static_cast<std::uint8_t>(value);
}
static void
put32(
std::vector<std::uint8_t>& frame,
std::size_t const offset,
std::uint32_t const value)
{
frame[offset] = static_cast<std::uint8_t>(value >> 24);
frame[offset + 1] = static_cast<std::uint8_t>(value >> 16);
frame[offset + 2] = static_cast<std::uint8_t>(value >> 8);
frame[offset + 3] = static_cast<std::uint8_t>(value);
}
static std::vector<std::uint8_t>
uncompressedManifestFrame(std::size_t const payloadSize)
{
std::vector<std::uint8_t> frame(compression::headerBytes + payloadSize);
put32(frame, 0, static_cast<std::uint32_t>(payloadSize));
put16(frame, 4, protocol::mtMANIFESTS);
return frame;
}
static std::vector<std::uint8_t>
compressedManifestFrame(
std::size_t const payloadSize,
std::size_t const uncompressedSize)
{
std::vector<std::uint8_t> frame(
compression::headerBytesCompressed + payloadSize);
put32(frame, 0, static_cast<std::uint32_t>(payloadSize));
frame[0] |= static_cast<std::uint8_t>(Algorithm::LZ4);
put16(frame, 4, protocol::mtMANIFESTS);
put32(frame, 6, static_cast<std::uint32_t>(uncompressedSize));
return frame;
}
public:
compression_test()
{
@@ -619,55 +529,11 @@ public:
handshake(0, 0);
}
void
testManifestFrameLimit()
{
testcase("TMManifests frame limit");
auto invokeFrame = [](std::vector<std::uint8_t> const& frame,
ProtocolHandler& handler) {
std::array<boost::asio::const_buffer, 1> const buffers{
boost::asio::buffer(frame)};
std::size_t hint = 0;
return invokeProtocolMessage(buffers, handler, hint);
};
{
auto const frame =
uncompressedManifestFrame(maximumManifestsMessageSize + 1);
ProtocolHandler handler;
auto const [consumed, ec] = invokeFrame(frame, handler);
BEAST_EXPECT(consumed == frame.size());
BEAST_EXPECT(!ec);
BEAST_EXPECT(!handler.invoked);
}
{
auto const frame =
compressedManifestFrame(1, maximumManifestsMessageSize + 1);
ProtocolHandler handler;
auto const [consumed, ec] = invokeFrame(frame, handler);
BEAST_EXPECT(consumed == frame.size());
BEAST_EXPECT(!ec);
BEAST_EXPECT(!handler.invoked);
}
{
auto const frame =
uncompressedManifestFrame(maximumManifestsMessageSize);
ProtocolHandler handler;
auto const [consumed, ec] = invokeFrame(frame, handler);
BEAST_EXPECT(consumed == frame.size());
BEAST_EXPECT(
ec == make_error_code(boost::system::errc::bad_message));
BEAST_EXPECT(!handler.invoked);
}
}
void
run() override
{
testProtocol();
testHandshake();
testManifestFrameLimit();
}
};

View File

@@ -1,785 +0,0 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright 2026 Xahau Ledger Foundation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <test/jtx.h>
#include <test/jtx/Env.h>
#include <xrpld/app/misc/ValidatorList.h>
#include <xrpld/overlay/detail/OverlayImpl.h>
#include <xrpld/overlay/detail/PeerImp.h>
#include <xrpld/peerfinder/detail/SlotImp.h>
#include <xrpl/basics/make_SSLContext.h>
#include <xrpl/beast/unit_test.h>
#include <xrpl/protocol/STExchange.h>
#include <xrpl/protocol/Sign.h>
#include <limits>
#include <optional>
namespace ripple {
namespace test {
class manifest_relay_test : public beast::unit_test::suite
{
using socket_type = boost::asio::ip::tcp::socket;
using middle_type = boost::beast::tcp_stream;
using stream_type = boost::beast::ssl_stream<middle_type>;
class PeerTest : public PeerImp
{
public:
PeerTest(
Application& app,
std::shared_ptr<PeerFinder::Slot> const& slot,
http_request_type&& request,
PublicKey const& publicKey,
ProtocolVersion protocol,
Resource::Consumer consumer,
std::unique_ptr<manifest_relay_test::stream_type>&& stream,
OverlayImpl& overlay)
: PeerImp(
app,
nextId_++,
slot,
std::move(request),
publicKey,
protocol,
consumer,
std::move(stream),
overlay)
{
}
void
run() override
{
}
void
send(std::shared_ptr<Message> const& message) override
{
sent_.push_back(message);
}
std::vector<std::shared_ptr<Message>> sent_;
inline static Peer::id_t nextId_ = 1;
};
std::shared_ptr<boost::asio::ssl::context> context_{make_SSLContext("")};
std::uint16_t endpoint_{1};
struct ManifestData
{
std::string serialized;
PublicKey masterKey;
std::optional<PublicKey> signingKey;
};
ManifestData
makeManifest(
SecretKey const& masterSecret,
std::pair<PublicKey, SecretKey> const& signing,
std::uint32_t const sequence,
bool const large = false)
{
auto const masterPublic =
derivePublicKey(KeyType::ed25519, masterSecret);
STObject st{sfGeneric};
st[sfSequence] = sequence;
st[sfPublicKey] = masterPublic;
st[sfSigningPubKey] = signing.first;
if (large)
{
st[sfDomain] =
makeSlice(std::string(63, 'a') + "." + std::string(63, 'b'));
}
sign(st, HashPrefix::manifest, KeyType::secp256k1, signing.second);
sign(
st,
HashPrefix::manifest,
KeyType::ed25519,
masterSecret,
sfMasterSignature);
Serializer serialized;
st.add(serialized);
std::string const bytes{
static_cast<char const*>(serialized.data()), serialized.size()};
auto manifest = deserializeManifest(bytes);
BEAST_EXPECT(manifest.has_value());
return {bytes, masterPublic, signing.first};
}
ManifestData
makeManifest(
SecretKey const& masterSecret,
std::uint32_t const sequence,
bool const large = false)
{
return makeManifest(
masterSecret, randomKeyPair(KeyType::secp256k1), sequence, large);
}
ManifestData
makeRevocation(SecretKey const& masterSecret)
{
auto const masterPublic =
derivePublicKey(KeyType::ed25519, masterSecret);
STObject st{sfGeneric};
st[sfSequence] = std::numeric_limits<std::uint32_t>::max();
st[sfPublicKey] = masterPublic;
sign(
st,
HashPrefix::manifest,
KeyType::ed25519,
masterSecret,
sfMasterSignature);
Serializer serialized;
st.add(serialized);
std::string const bytes{
static_cast<char const*>(serialized.data()), serialized.size()};
auto manifest = deserializeManifest(bytes);
BEAST_EXPECT(manifest.has_value());
BEAST_EXPECT(manifest && manifest->revoked());
return {bytes, masterPublic, std::nullopt};
}
ManifestData
makeManifest(bool const large = false)
{
return makeManifest(randomSecretKey(), 0, large);
}
std::shared_ptr<PeerTest>
addPeer(jtx::Env& env)
{
auto& overlay = dynamic_cast<OverlayImpl&>(env.app().overlay());
boost::beast::http::request<boost::beast::http::dynamic_body> request;
auto stream = std::make_unique<stream_type>(
socket_type(env.app().getIOService()), *context_);
auto const octet = endpoint_++;
beast::IP::Endpoint const local{beast::IP::Address::from_string(
"172.1.1." + std::to_string(octet))};
beast::IP::Endpoint const remote{beast::IP::Address::from_string(
"172.1.2." + std::to_string(octet))};
auto const peerPublic = std::get<0>(randomKeyPair(KeyType::ed25519));
auto consumer = overlay.resourceManager().newInboundEndpoint(remote);
auto slot = overlay.peerFinder().new_inbound_slot(local, remote);
auto peer = std::make_shared<PeerTest>(
env.app(),
slot,
std::move(request),
peerPublic,
ProtocolVersion{1, 7},
consumer,
std::move(stream),
overlay);
overlay.add_active(peer);
return peer;
}
static std::optional<protocol::TMManifests>
unpack(std::shared_ptr<Message> const& message)
{
if (!message)
return std::nullopt;
auto const& buffer = message->getBuffer(compression::Compressed::Off);
if (buffer.size() <= compression::headerBytes)
return std::nullopt;
protocol::TMManifests manifests;
if (!manifests.ParseFromArray(
buffer.data() + compression::headerBytes,
static_cast<int>(buffer.size() - compression::headerBytes)))
return std::nullopt;
return manifests;
}
static std::optional<protocol::TMManifests>
unpackFirst(std::vector<std::shared_ptr<Message>> const& messages)
{
if (messages.empty())
return std::nullopt;
return unpack(messages.front());
}
void
expectBundle(std::shared_ptr<PeerTest> const& peer, std::size_t const count)
{
BEAST_EXPECT(peer->sent_.size() == 1);
if (peer->sent_.size() != 1)
return;
auto const relayed = unpack(peer->sent_.front());
BEAST_EXPECT(relayed.has_value());
if (relayed)
{
BEAST_EXPECT(
static_cast<std::size_t>(relayed->list_size()) == count);
}
}
void
installCandidate(jtx::Env& env, ManifestData const& candidate)
{
auto const publisherMasterSecret = randomSecretKey();
auto const publisherMaster =
derivePublicKey(KeyType::ed25519, publisherMasterSecret);
auto const publisherSigning = randomKeyPair(KeyType::secp256k1);
auto const publisherManifest =
makeManifest(publisherMasterSecret, publisherSigning, 1);
auto const listed = makeManifest();
BEAST_EXPECT(env.app().validators().load(
{}, {}, std::vector<std::string>{strHex(publisherMaster)}));
auto const expiration = (env.timeKeeper().now() + std::chrono::hours{1})
.time_since_epoch()
.count();
std::string const payload =
"{\"sequence\":1,\"expiration\":" + std::to_string(expiration) +
",\"validators\":[{\"validation_public_key\":\"" +
strHex(listed.masterKey) +
"\"}],\"candidates\":[{\"validation_public_key\":\"" +
strHex(candidate.masterKey) + "\",\"manifest\":\"" +
base64_encode(candidate.serialized) + "\"}]}";
auto const blob = base64_encode(payload);
auto const signature = strHex(sign(
publisherSigning.first,
publisherSigning.second,
makeSlice(payload)));
BEAST_EXPECT(
env.app()
.validators()
.applyLists(
base64_encode(publisherManifest.serialized),
1,
{{blob, signature, {}}},
"manifest_relay_test")
.bestDisposition() == ListDisposition::accepted);
auto const policy =
env.app().validators().manifestPolicy(candidate.masterKey);
BEAST_EXPECT(!policy.consensusListed);
BEAST_EXPECT(policy.publisherCandidate);
BEAST_EXPECT(policy.relayEligible());
}
void
testCandidateUpdateRelays()
{
testcase("publisher candidate rotations and revocations relay");
jtx::Env env{*this};
auto& overlay = dynamic_cast<OverlayImpl&>(env.app().overlay());
auto const source = addPeer(env);
auto const destination = addPeer(env);
auto const masterSecret = randomSecretKey();
auto const published = makeManifest(masterSecret, 1);
auto const rotation = makeManifest(masterSecret, 2);
auto const revocation = makeRevocation(masterSecret);
installCandidate(env, published);
BEAST_EXPECT(!env.app().validators().listed(published.masterKey));
BEAST_EXPECT(!env.app().validators().trusted(published.masterKey));
BEAST_EXPECT(
env.app().validatorManifests().getSequence(published.masterKey) ==
1);
auto send = [&](ManifestData const& data) {
auto incoming = std::make_shared<protocol::TMManifests>();
incoming->add_list()->set_stobject(data.serialized);
overlay.onManifests(incoming, source);
};
send(rotation);
expectBundle(destination, 1);
BEAST_EXPECT(
env.app().validatorManifests().getSequence(published.masterKey) ==
2);
BEAST_EXPECT(
env.app().validatorManifests().getSigningKey(published.masterKey) ==
rotation.signingKey);
BEAST_EXPECT(!env.app().validators().listed(published.masterKey));
BEAST_EXPECT(!env.app().validators().trusted(published.masterKey));
auto snapshot = unpackFirst(overlay.getManifestsMessages());
BEAST_EXPECT(snapshot.has_value());
if (snapshot)
{
BEAST_EXPECT(std::any_of(
snapshot->list().begin(),
snapshot->list().end(),
[&](auto const& entry) {
return entry.stobject() == rotation.serialized;
}));
}
destination->sent_.clear();
send(revocation);
expectBundle(destination, 1);
BEAST_EXPECT(
!env.app().validatorManifests().getSequence(published.masterKey));
BEAST_EXPECT(
env.app().validatorManifests().revoked(published.masterKey));
BEAST_EXPECT(!env.app().validators().listed(published.masterKey));
BEAST_EXPECT(!env.app().validators().trusted(published.masterKey));
}
void
testCandidateCannotRollbackOrdinaryHighWater()
{
testcase("candidate cannot roll back ordinary manifest high-water");
jtx::Env env{*this};
auto& overlay = dynamic_cast<OverlayImpl&>(env.app().overlay());
auto const source = addPeer(env);
auto const destination = addPeer(env);
auto const masterSecret = randomSecretKey();
auto const published = makeManifest(masterSecret, 1);
auto const staleRotation = makeManifest(masterSecret, 2);
auto const revocation = makeRevocation(masterSecret);
auto ordinary = deserializeManifest(revocation.serialized);
BEAST_EXPECT(ordinary.has_value());
if (ordinary)
{
BEAST_EXPECT(
env.app().validatorManifests().applyManifest(
std::move(*ordinary), ManifestRetention::evictable) ==
ManifestDisposition::accepted);
}
installCandidate(env, published);
auto incoming = std::make_shared<protocol::TMManifests>();
incoming->add_list()->set_stobject(staleRotation.serialized);
overlay.onManifests(incoming, source);
BEAST_EXPECT(destination->sent_.empty());
BEAST_EXPECT(
env.app().validatorManifests().revoked(published.masterKey));
auto const snapshot = unpackFirst(overlay.getManifestsMessages());
BEAST_EXPECT(snapshot.has_value());
if (snapshot)
{
BEAST_EXPECT(std::any_of(
snapshot->list().begin(),
snapshot->list().end(),
[&](auto const& entry) {
return entry.stobject() == revocation.serialized;
}));
BEAST_EXPECT(std::none_of(
snapshot->list().begin(),
snapshot->list().end(),
[&](auto const& entry) {
return entry.stobject() == staleRotation.serialized;
}));
}
}
void
testBoundedReceive()
{
testcase("bounded receive includes trusted entries after the cap");
auto const trusted = makeManifest();
jtx::Env env{
*this, jtx::envconfig([&trusted](std::unique_ptr<Config> config) {
config->section("validators")
.append(toBase58(TokenType::NodePublic, trusted.masterKey));
return config;
})};
auto& overlay = dynamic_cast<OverlayImpl&>(env.app().overlay());
auto const source = addPeer(env);
auto const destination = addPeer(env);
auto incoming = std::make_shared<protocol::TMManifests>();
for (std::size_t i = 0; i < kMaxManifestsPerMessage + 1; ++i)
incoming->add_list()->set_stobject(makeManifest().serialized);
incoming->add_list()->set_stobject(trusted.serialized);
auto const before = env.app().validatorManifests().sequence();
overlay.onManifests(incoming, source);
BEAST_EXPECT(
env.app().validatorManifests().sequence() ==
before + kMaxManifestsPerMessage + 1);
BEAST_EXPECT(source->sent_.empty());
expectBundle(destination, 1);
auto const snapshot = unpackFirst(overlay.getManifestsMessages());
BEAST_EXPECT(snapshot.has_value());
if (snapshot)
{
BEAST_EXPECT(snapshot->list_size() == kMaxManifestsPerMessage + 1);
BEAST_EXPECT(
Message::messageSize(*snapshot) <= maximumManifestsMessageSize);
}
}
void
testTotalEntryLimit()
{
testcase("total entry limit bounds malformed work");
jtx::Env env{*this};
auto& overlay = dynamic_cast<OverlayImpl&>(env.app().overlay());
auto const source = addPeer(env);
auto const destination = addPeer(env);
auto incoming = std::make_shared<protocol::TMManifests>();
for (std::size_t i = 0; i < kMaxManifestEntriesPerMessage + 1; ++i)
incoming->add_list()->set_stobject("");
BEAST_EXPECT(incoming->IsInitialized());
auto const before = env.app().validatorManifests().sequence();
overlay.onManifests(incoming, source);
BEAST_EXPECT(env.app().validatorManifests().sequence() == before);
BEAST_EXPECT(source->sent_.empty());
BEAST_EXPECT(destination->sent_.empty());
}
void
testKnownUpdateRelays()
{
testcase("known unlisted rotations relay");
jtx::Env env{*this};
auto& overlay = dynamic_cast<OverlayImpl&>(env.app().overlay());
auto const source = addPeer(env);
auto const destination = addPeer(env);
auto const masterSecret = randomSecretKey();
auto const first = makeManifest(masterSecret, 0);
auto const second = makeManifest(masterSecret, 1);
auto incoming = std::make_shared<protocol::TMManifests>();
incoming->add_list()->set_stobject(first.serialized);
overlay.onManifests(incoming, source);
BEAST_EXPECT(source->sent_.empty());
BEAST_EXPECT(destination->sent_.empty());
incoming = std::make_shared<protocol::TMManifests>();
incoming->add_list()->set_stobject(second.serialized);
overlay.onManifests(incoming, source);
BEAST_EXPECT(source->sent_.empty());
expectBundle(destination, 1);
}
void
testRevocationRelay()
{
testcase("revocation relay follows retained and listed policy");
auto const retainedSecret = randomSecretKey();
auto const retained = makeManifest(retainedSecret, 0);
auto const retainedRevocation = makeRevocation(retainedSecret);
auto const firstSeenRevocation = makeRevocation(randomSecretKey());
auto const listedRevocation = makeRevocation(randomSecretKey());
jtx::Env env{
*this,
jtx::envconfig([&listedRevocation](std::unique_ptr<Config> config) {
config->section("validators")
.append(toBase58(
TokenType::NodePublic, listedRevocation.masterKey));
return config;
})};
auto& overlay = dynamic_cast<OverlayImpl&>(env.app().overlay());
auto const source = addPeer(env);
auto const destination = addPeer(env);
auto send = [&](ManifestData const& data) {
auto incoming = std::make_shared<protocol::TMManifests>();
incoming->add_list()->set_stobject(data.serialized);
overlay.onManifests(incoming, source);
};
send(retained);
BEAST_EXPECT(destination->sent_.empty());
send(retainedRevocation);
expectBundle(destination, 1);
BEAST_EXPECT(
env.app().validatorManifests().revoked(retained.masterKey));
destination->sent_.clear();
send(firstSeenRevocation);
BEAST_EXPECT(destination->sent_.empty());
BEAST_EXPECT(env.app().validatorManifests().revoked(
firstSeenRevocation.masterKey));
send(listedRevocation);
expectBundle(destination, 1);
BEAST_EXPECT(
env.app().validatorManifests().revoked(listedRevocation.masterKey));
BEAST_EXPECT(source->sent_.empty());
}
void
testEvictedManifestStopsLocally()
{
testcase("reaccepted evicted manifest does not live relay");
jtx::Env env{*this};
auto& overlay = dynamic_cast<OverlayImpl&>(env.app().overlay());
auto const source = addPeer(env);
auto const destination = addPeer(env);
auto const target = makeManifest();
auto incoming = std::make_shared<protocol::TMManifests>();
incoming->add_list()->set_stobject(target.serialized);
overlay.onManifests(incoming, source);
BEAST_EXPECT(source->sent_.empty());
BEAST_EXPECT(destination->sent_.empty());
constexpr std::size_t untrustedCacheLimit = 1000;
std::vector<ManifestData> retained;
retained.reserve(untrustedCacheLimit);
retained.push_back(target);
auto& cache = env.app().validatorManifests();
for (std::size_t i = 1; i < untrustedCacheLimit; ++i)
{
auto const filler = makeManifest();
auto manifest = deserializeManifest(filler.serialized);
BEAST_EXPECT(manifest.has_value());
if (manifest)
{
BEAST_EXPECT(
cache.applyManifest(
std::move(*manifest), ManifestRetention::evictable) ==
ManifestDisposition::accepted);
}
retained.push_back(filler);
}
auto const replacement = makeManifest();
auto replacementManifest = deserializeManifest(replacement.serialized);
BEAST_EXPECT(replacementManifest.has_value());
if (replacementManifest)
{
BEAST_EXPECT(
cache.applyManifestWithEviction(std::move(*replacementManifest))
.disposition == ManifestDisposition::accepted);
}
auto const evicted = std::find_if(
retained.begin(), retained.end(), [&cache](ManifestData const& m) {
return !cache.getSequence(m.masterKey);
});
BEAST_EXPECT(evicted != retained.end());
if (evicted == retained.end())
return;
incoming = std::make_shared<protocol::TMManifests>();
incoming->add_list()->set_stobject(evicted->serialized);
overlay.onManifests(incoming, source);
BEAST_EXPECT(cache.getSequence(evicted->masterKey) == 0);
BEAST_EXPECT(source->sent_.empty());
BEAST_EXPECT(destination->sent_.empty());
}
void
testSnapshotByteBudget()
{
testcase("snapshot chunks every protected entry within byte budget");
constexpr std::size_t trustedCount = 300;
constexpr std::size_t untrustedCount = 100;
std::vector<ManifestData> trusted;
std::vector<ManifestData> untrusted;
trusted.reserve(trustedCount);
untrusted.reserve(untrustedCount);
for (std::size_t i = 0; i < trustedCount; ++i)
trusted.push_back(makeManifest(true));
for (std::size_t i = 0; i < untrustedCount; ++i)
untrusted.push_back(makeManifest());
jtx::Env env{
*this, jtx::envconfig([&trusted](std::unique_ptr<Config> config) {
auto& validators = config->section("validators");
for (auto const& manifest : trusted)
{
validators.append(
toBase58(TokenType::NodePublic, manifest.masterKey));
}
return config;
})};
hash_set<PublicKey> trustedMasters;
trustedMasters.reserve(trusted.size());
auto& cache = env.app().validatorManifests();
for (auto const& data : trusted)
{
BEAST_EXPECT(env.app().validators().listed(data.masterKey));
trustedMasters.insert(data.masterKey);
auto manifest = deserializeManifest(data.serialized);
BEAST_EXPECT(manifest.has_value());
if (manifest)
{
BEAST_EXPECT(
cache.applyManifest(
std::move(*manifest), ManifestRetention::protected_) ==
ManifestDisposition::accepted);
}
}
for (auto const& data : untrusted)
{
BEAST_EXPECT(!env.app().validators().listed(data.masterKey));
auto manifest = deserializeManifest(data.serialized);
BEAST_EXPECT(manifest.has_value());
if (manifest)
{
BEAST_EXPECT(
cache.applyManifest(
std::move(*manifest), ManifestRetention::evictable) ==
ManifestDisposition::accepted);
}
}
auto& overlay = dynamic_cast<OverlayImpl&>(env.app().overlay());
auto const messages = overlay.getManifestsMessages();
BEAST_EXPECT(messages.size() > 1);
hash_set<PublicKey> seenTrusted;
std::size_t seenUntrusted = 0;
for (auto const& message : messages)
{
auto const snapshot = unpack(message);
BEAST_EXPECT(snapshot.has_value());
if (!snapshot)
continue;
BEAST_EXPECT(
Message::messageSize(*snapshot) <= maximumManifestsMessageSize);
BEAST_EXPECT(
static_cast<std::size_t>(snapshot->list_size()) <=
kMaxManifestEntriesPerMessage);
for (auto const& entry : snapshot->list())
{
auto manifest = deserializeManifest(entry.stobject());
BEAST_EXPECT(manifest.has_value());
if (!manifest)
continue;
if (trustedMasters.contains(manifest->masterKey))
seenTrusted.insert(manifest->masterKey);
else
++seenUntrusted;
}
}
BEAST_EXPECT(seenTrusted.size() == trustedCount);
BEAST_EXPECT(seenUntrusted <= untrustedCount);
}
void
testListingChangeInvalidatesSnapshot()
{
testcase("retention change invalidates cached snapshot");
jtx::Env env{*this};
auto const target = makeManifest();
auto manifest = deserializeManifest(target.serialized);
BEAST_EXPECT(manifest.has_value());
if (!manifest)
return;
auto& cache = env.app().validatorManifests();
BEAST_EXPECT(
cache.applyManifest(
std::move(*manifest), ManifestRetention::evictable) ==
ManifestDisposition::accepted);
auto& overlay = dynamic_cast<OverlayImpl&>(env.app().overlay());
auto const first = overlay.getManifestsMessages();
BEAST_EXPECT(first.size() == 1);
auto const manifestSeq = cache.sequence();
BEAST_EXPECT(env.app().validators().load(
std::nullopt,
{toBase58(TokenType::NodePublic, target.masterKey)},
{},
std::nullopt));
BEAST_EXPECT(env.app().validators().listed(target.masterKey));
BEAST_EXPECT(cache.sequence() == manifestSeq + 1);
auto const second = overlay.getManifestsMessages();
BEAST_EXPECT(second.size() == 1);
if (first.size() == 1 && second.size() == 1)
BEAST_EXPECT(second.front() != first.front());
}
void
testConfigListingPromotesRetention()
{
testcase("config listing promotes retained manifest");
jtx::Env env{*this};
auto const target = makeManifest();
auto manifest = deserializeManifest(target.serialized);
BEAST_EXPECT(manifest.has_value());
if (!manifest)
return;
auto& cache = env.app().validatorManifests();
BEAST_EXPECT(
cache.applyManifest(
std::move(*manifest), ManifestRetention::evictable) ==
ManifestDisposition::accepted);
auto const before = cache.sequence();
BEAST_EXPECT(env.app().validators().load(
std::nullopt,
{toBase58(TokenType::NodePublic, target.masterKey)},
{},
std::nullopt));
BEAST_EXPECT(env.app().validators().listed(target.masterKey));
BEAST_EXPECT(cache.sequence() == before + 1);
cache.setRetention(target.masterKey, ManifestRetention::protected_);
BEAST_EXPECT(cache.sequence() == before + 1);
}
public:
void
run() override
{
testBoundedReceive();
testTotalEntryLimit();
testKnownUpdateRelays();
testCandidateUpdateRelays();
testCandidateCannotRollbackOrdinaryHighWater();
testRevocationRelay();
testEvictedManifestStopsLocally();
testSnapshotByteBudget();
testListingChangeInvalidatesSnapshot();
testConfigListingPromotesRetention();
}
};
BEAST_DEFINE_TESTSUITE(manifest_relay, overlay, ripple);
} // namespace test
} // namespace ripple

View File

@@ -21,22 +21,13 @@
#define RIPPLE_APP_MISC_MANIFEST_H_INCLUDED
#include <xrpl/basics/UnorderedContainers.h>
#include <xrpl/basics/base64.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SecretKey.h>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <shared_mutex>
#include <string>
#include <utility>
namespace ripple {
@@ -63,19 +54,12 @@ namespace ripple {
dynamically generates the signatureless form when it needs to verify
the signature.
An instance of ManifestCache stores, for each known validator, (a) its
An instance of ManifestCache stores, for each trusted validator, (a) its
master public key, and (b) the most senior of all valid manifests it has
seen for that validator, if any. On startup, the [validator_token] config
entry (which contains the manifest for this validator) is decoded and
added to the manifest cache. Other manifests are added as "gossip"
received from rippled peers, including ones for validators this node does
not trust. Manifests for untrusted validators are capped
(kMaxUntrustedCount) so peer gossip cannot grow the cache without bound.
Entries admitted as protected, or later reclassified as protected, are not
capped or evicted. Entries return to the evictable population when their
protected source disappears. At capacity, an evictable entry is evicted to
admit a new valid manifest, preferring one with no current validation when
activity information is available.
received from rippled peers.
When an ephemeral key is compromised, a new signing key pair is created,
along with a new manifest vouching for it (with a higher sequence number),
@@ -167,23 +151,6 @@ struct Manifest
std::string
to_string(Manifest const& m);
/** Largest a valid manifest can be, in decoded bytes. */
constexpr std::size_t kMaxManifestBytes = 358;
/** Largest a valid manifest can be, in base64 characters. */
constexpr std::size_t kMaxManifestBase64 =
base64::encoded_size(kMaxManifestBytes);
/** Maximum number of untrusted manifests processed from one message. */
constexpr std::size_t kMaxManifestsPerMessage = 200;
/** Maximum total manifest entries processed from one message.
This is separate from the wire-byte limit and the valid-untrusted limit:
malformed entries can be only a few bytes each and never reach the latter.
*/
constexpr std::size_t kMaxManifestEntriesPerMessage = 1000;
/** Constructs Manifest from serialized string
@param s Serialized manifest string
@@ -259,22 +226,7 @@ enum class ManifestDisposition {
badEphemeralKey,
/// Timely, but invalid signature
invalid,
/// Unlisted and limit reached
untrustedCapacity
};
/** Result of bounded untrusted-manifest admission.
`acceptedUpdate` is meaningful only when `disposition` is `accepted`. It is
computed under the cache write lock and distinguishes an update of a
retained identity from admission as a new identity.
*/
struct ManifestApplyResult
{
ManifestDisposition disposition;
bool acceptedUpdate;
invalid
};
inline std::string
@@ -292,72 +244,18 @@ to_string(ManifestDisposition m)
return "badEphemeralKey";
case ManifestDisposition::invalid:
return "invalid";
case ManifestDisposition::untrustedCapacity:
return "untrustedCapacity";
default:
return "unknown";
}
}
/** Retention policy for an accepted manifest.
This is deliberately independent of validator-list membership and
consensus trust. Protected entries are not part of the bounded eviction
population; evictable entries are.
*/
enum class ManifestRetention : std::uint8_t { evictable, protected_ };
class DatabaseCon;
/** Remembers manifests with the highest sequence number.
This is protocol state, not merely a payload cache. While an entry remains
resident, it supplies the sequence high-water mark, revocation state, and
master/signing-key collision checks for that validator. Evicting an
untrusted entry necessarily forgets those facts and can make an old
manifest cache-new again.
Entries admitted with protected retention, or later reclassified as
protected, are outside the eviction population. For other validators,
recent validation activity is an eviction preference, not a retention
guarantee or source of consensus trust. Tier-2 publisher provenance is the
explicit way to protect a monitored candidate. This preference makes an
actively validating untrusted identity harder to displace than a quiet one;
if every evictable identity appears active, selection is uniformly random.
This is not complete adversarial containment. Once full, the cache gives
valid novel identities a small eviction budget, bounding admitted identity
churn. Capacity rejection normally avoids verification when no permit is
available, although concurrent callers may race on observed availability.
Updates to an already retained identity do not consume the budget.
First-seen unlisted identities are not relayed live. A bounded selected
subset propagates through the cached connection snapshot.
Later rotations and revocations relay while the identity remains resident.
If eviction forgets one, its reappearance is first-seen again and therefore
does not immediately relay. A sustained sender can monopolize the eviction
budget and delay a legitimate novel untrusted validator; protected
validators are unaffected.
*/
/** Remembers manifests with the highest sequence number. */
class ManifestCache
{
private:
using TimePoint = std::chrono::steady_clock::time_point;
using Now = std::function<TimePoint()>;
beast::Journal j_;
Now now_;
/** Serialize post-verification admission of evictable manifests.
Signature verification remains concurrent. Once verified, evictable
admissions pass through this mutex so only the caller that can consume
an eviction permit samples activity and selects a victim. When both
mutexes are needed, this mutex is acquired before `mutex_`; caller code
is never invoked while `mutex_` is held. Manifest jobs use the shared
JobQueue worker pool, so work in this section must remain small.
*/
std::mutex evictionAdmissionMutex_;
std::shared_mutex mutable mutex_;
/** Active manifests stored by master public key. */
@@ -368,40 +266,10 @@ private:
std::atomic<std::uint32_t> seq_{0};
/** Master keys currently counted against the bounded eviction cap. */
hash_set<PublicKey> evictableKeys_;
/** Master keys explicitly protected by local validator configuration. */
hash_set<PublicKey> configuredKeys_;
/** Maximum number of untrusted master keys retained in memory. */
static constexpr std::size_t kMaxUntrustedCount = 1000;
/** Burst and refill rate for evictions after the untrusted cache fills. */
static constexpr std::size_t kMaxEvictionPermits = 10;
static constexpr std::chrono::seconds kEvictionPermitInterval{1};
std::size_t evictionPermits_ = kMaxEvictionPermits;
TimePoint evictionBudgetUpdated_;
/** Number of manifests rejected because the untrusted cache was full. */
std::atomic<std::uint64_t> untrustedRejectCount_{0};
/** Number of capacity rejections between warning summaries. */
static constexpr std::uint64_t kUntrustedRejectCount = 10000;
ManifestDisposition
applyManifestImpl(
Manifest m,
ManifestRetention retention,
bool mayEvict,
std::function<hash_set<PublicKey>()> const& currentValidationKeys,
bool* acceptedUpdate);
public:
explicit ManifestCache(
beast::Journal j = beast::Journal(beast::Journal::getNullSink()),
Now now = [] { return std::chrono::steady_clock::now(); })
: j_(j), now_(std::move(now)), evictionBudgetUpdated_(now_())
beast::Journal j = beast::Journal(beast::Journal::getNullSink()))
: j_(j)
{
}
@@ -477,8 +345,6 @@ public:
@param m Manifest to add
@param retention Whether the entry is protected or evictable
@return `ManifestDisposition::accepted` if successful, or
`stale` or `invalid` otherwise
@@ -487,54 +353,7 @@ public:
May be called concurrently
*/
ManifestDisposition
applyManifest(Manifest m, ManifestRetention retention);
/** Add an untrusted manifest, evicting another at capacity.
A dormant evictable entry is chosen at random when possible. If every
retained evictable signing key has a current validation, any evictable
entry may be chosen. The candidate is fully verified before current
validations are sampled or an entry is evicted.
Activity is matched to the manifest's current signing key. Immediately
after rotation, an evictable identity appears dormant until a
validation from its new key arrives.
@param m Manifest to add
@param currentValidationKeys Supplies signing keys with current
validations. It is called only when a verified novel manifest
needs and is permitted to evict an entry after serialized
admission rechecks. If omitted, victim selection is uniformly
random. The callback must not re-enter this method.
@return disposition and an atomic indication that an accepted manifest
updated an identity retained at admission time
*/
ManifestApplyResult
applyManifestWithEviction(
Manifest m,
std::function<hash_set<PublicKey>()> const& currentValidationKeys = {});
/** Change the retention class of an already cached master.
Consensus trust is not changed. If an entry becomes evictable while
the bounded population is full, it is discarded and can later be
reacquired from its signed source.
*/
void
setRetention(PublicKey const& pk, ManifestRetention retention);
/** Reconcile cached retention against the current protected-master set.
This changes retention only; it never grants validator-list membership
or consensus weight. Cached entries outside `protectedMasters` and
local validator configuration become evictable, subject to the
ordinary bounded population. Terminal revocations are treated like
other current high-water state: once every protected source
disappears, they become evictable.
*/
void
reconcileRetention(hash_set<PublicKey> const& protectedMasters);
applyManifest(Manifest m);
/** Populate manifest cache with manifests in database and config.
@@ -623,7 +442,7 @@ public:
@param pf Pre-function called with the maximum number of times f will be
called (useful for memory allocations)
@param f Function called for each manifest and its retention class
@param f Function called for each manifest
@par Thread Safety
@@ -635,11 +454,10 @@ public:
{
std::shared_lock lock{mutex_};
pf(map_.size());
for (auto const& [master, manifest] : map_)
for (auto const& [_, manifest] : map_)
{
f(manifest,
evictableKeys_.contains(master) ? ManifestRetention::evictable
: ManifestRetention::protected_);
(void)_;
f(manifest);
}
}
};

View File

@@ -31,12 +31,9 @@
#include <boost/iterator/counting_iterator.hpp>
#include <boost/range/adaptors.hpp>
#include <boost/thread/shared_mutex.hpp>
#include <atomic>
#include <memory>
#include <mutex>
#include <numeric>
#include <shared_mutex>
#include <utility>
namespace protocol {
class TMValidatorList;
@@ -127,23 +124,6 @@ struct ValidatorBlobInfo
std::optional<std::string> manifest;
};
/** Manifest transport/admission policy for a master key.
Publisher candidates receive protected manifest retention and relay, but
are not listed or trusted for consensus.
*/
struct ValidatorManifestPolicy
{
bool consensusListed = false;
bool publisherCandidate = false;
bool
relayEligible() const
{
return consensusListed || publisherCandidate;
}
};
/**
Trusted Validators List
-----------------------
@@ -191,26 +171,12 @@ struct ValidatorManifestPolicy
*/
class ValidatorList
{
struct PublisherValidator
{
PublicKey master;
std::shared_ptr<Manifest const> manifest;
};
struct PublisherCandidate
{
PublicKey master;
std::shared_ptr<Manifest const> manifest;
};
struct PublisherList
{
explicit PublisherList() = default;
std::vector<PublisherValidator> validators;
// Validators published for monitoring only. They never contribute to
// keyListings_, the trusted UNL, or quorum.
std::vector<PublisherCandidate> candidates;
std::vector<PublicKey> list;
std::vector<std::string> manifests;
std::size_t sequence;
TimeKeeper::time_point validFrom;
TimeKeeper::time_point validUntil;
@@ -270,11 +236,6 @@ class ValidatorList
// Published lists stored by publisher master public key
hash_map<PublicKey, PublisherListCollection> publisherLists_;
// Current publisher-selected candidate masters. Candidate manifests use
// the ordinary ManifestCache: tier membership changes retention and relay
// policy, not identity-resolution semantics.
hash_set<PublicKey> publisherCandidateMasters_;
// Listed master public keys with the number of lists they appear on
hash_map<PublicKey, std::size_t> keyListings_;
@@ -313,9 +274,6 @@ class ValidatorList
static const std::string filePrefix_;
public:
/** Per-generation and aggregate bound for monitoring candidates. */
static constexpr std::size_t maxPublisherCandidates = 1000;
ValidatorList(
ManifestCache& validatorManifests,
ManifestCache& publisherManifests,
@@ -598,15 +556,6 @@ public:
std::optional<PublicKey>
getListedKey(PublicKey const& identity) const;
/** Return manifest admission/relay policy for an asserted master key.
This is deliberately distinct from consensus listing and trust.
Current publisher candidates may relay valid self-signed manifest
updates, but never gain validation weight through this API.
*/
ValidatorManifestPolicy
manifestPolicy(PublicKey const& master) const;
/** Returns `true` if public key is a trusted publisher
@param identity Publisher public key
@@ -844,22 +793,10 @@ private:
void
updatePublisherList(
PublicKey const& pubKey,
std::vector<PublicKey> const& current,
PublisherList const& current,
std::vector<PublicKey> const& oldList,
lock_guard const&);
void
ingestPublisherManifests(
PublicKey const& pubKey,
PublisherList const& current,
lock_guard const&);
void
rebuildPublisherCandidates(lock_guard const&);
static std::vector<PublicKey>
validatorMasters(PublisherList const& list);
static void
buildBlobInfos(
std::map<std::size_t, ValidatorBlobInfo>& blobInfos,

View File

@@ -23,17 +23,14 @@
#include <xrpl/basics/Log.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/basics/base64.h>
#include <xrpl/basics/random.h>
#include <xrpl/json/json_reader.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/Sign.h>
#include <boost/algorithm/string/trim.hpp>
#include <iterator>
#include <numeric>
#include <stdexcept>
#include <vector>
namespace ripple {
@@ -58,9 +55,6 @@ deserializeManifest(Slice s, beast::Journal journal)
if (s.empty())
return std::nullopt;
if (s.size() > kMaxManifestBytes)
return std::nullopt;
static SOTemplate const manifestFormat{
// A manifest must include:
// - the master public key
@@ -373,40 +367,8 @@ ManifestCache::revoked(PublicKey const& pk) const
}
ManifestDisposition
ManifestCache::applyManifest(Manifest m, ManifestRetention const retention)
ManifestCache::applyManifest(Manifest m)
{
return applyManifestImpl(std::move(m), retention, false, {}, nullptr);
}
ManifestApplyResult
ManifestCache::applyManifestWithEviction(
Manifest m,
std::function<hash_set<PublicKey>()> const& currentValidationKeys)
{
bool acceptedUpdate = false;
auto const disposition = applyManifestImpl(
std::move(m),
ManifestRetention::evictable,
true,
currentValidationKeys,
&acceptedUpdate);
return {disposition, acceptedUpdate};
}
ManifestDisposition
ManifestCache::applyManifestImpl(
Manifest m,
ManifestRetention const retention,
bool const mayEvict,
std::function<hash_set<PublicKey>()> const& currentValidationKeys,
bool* const acceptedUpdate)
{
if (acceptedUpdate)
*acceptedUpdate = false;
bool const protectedRetention = retention == ManifestRetention::protected_;
bool checkSignature = true;
// Check the manifest against the conditions that do not require a
// `unique_lock` (write lock) on the `mutex_`. Since the signature can be
// relatively expensive, the `checkSignature` parameter determines if the
@@ -414,9 +376,8 @@ ManifestCache::applyManifestImpl(
// comment below), `checkSignature` only needs to be set to true on the
// first run.
auto prewriteCheck =
[this, &m, &checkSignature](
auto const& iter,
auto const& lock) -> std::optional<ManifestDisposition> {
[this, &m](auto const& iter, bool checkSignature, auto const& lock)
-> std::optional<ManifestDisposition> {
XRPL_ASSERT(
lock.owns_lock(),
"ripple::ManifestCache::applyManifest::prewriteCheck : locked");
@@ -438,16 +399,11 @@ ManifestCache::applyManifestImpl(
return ManifestDisposition::stale;
}
if (checkSignature)
if (checkSignature && !m.verify())
{
checkSignature = false;
if (!m.verify())
{
if (auto stream = j_.warn())
LOG_MANIFEST_ACTION(
stream, "Invalid", m.masterKey, m.sequence);
return ManifestDisposition::invalid;
}
if (auto stream = j_.warn())
LOG_MANIFEST_ACTION(stream, "Invalid", m.masterKey, m.sequence);
return ManifestDisposition::invalid;
}
// If the master key associated with a manifest is or might be
@@ -511,100 +467,15 @@ ManifestCache::applyManifestImpl(
return std::nullopt;
};
auto atEvictableCap = [this, protectedRetention](
auto const& iter, auto const& lock) {
XRPL_ASSERT(
lock.owns_lock(),
"ripple::ManifestCache::applyManifest::atEvictableCap : locked");
(void)lock;
return iter == map_.end() && !protectedRetention &&
evictableKeys_.size() >= kMaxUntrustedCount;
};
auto rejectAtUntrustedCap = [this, &m]() {
if (auto stream = j_.debug())
LOG_MANIFEST_ACTION(
stream, "UntrustedCapacity", m.masterKey, m.sequence);
if (auto const n = untrustedRejectCount_.fetch_add(1) + 1;
n % kUntrustedRejectCount == 0)
{
JLOG(j_.warn()) << "Untrusted manifest cap reached; " << n
<< " manifests rejected so far";
}
return ManifestDisposition::untrustedCapacity;
};
auto evictionPermitAvailable = [this](auto const& lock) {
XRPL_ASSERT(
lock.owns_lock(),
"ripple::ManifestCache::applyManifestImpl : eviction budget "
"locked");
(void)lock;
return evictionPermits_ > 0 ||
now_() - evictionBudgetUpdated_ >= kEvictionPermitInterval;
};
{
std::shared_lock sl{mutex_};
auto const iter = map_.find(m.masterKey);
if (atEvictableCap(iter, sl))
{
if (!mayEvict || !evictionPermitAvailable(sl))
return rejectAtUntrustedCap();
}
if (auto d = prewriteCheck(iter, sl); d.has_value())
{
// A protected application also carries retention policy. Defer a
// stale result to the write lock so an existing entry can be
// promoted atomically instead of racing eviction.
if (!protectedRetention || *d != ManifestDisposition::stale ||
!evictableKeys_.contains(m.masterKey))
return *d;
}
if (auto d =
prewriteCheck(map_.find(m.masterKey), /*checkSig*/ true, sl))
return *d;
}
// Signature verification above remains concurrent. Serialize the short
// post-verification admission path so racing callers cannot all sample
// activity after observing the same eviction permit. This lock is always
// acquired before mutex_, and caller code runs with mutex_ released.
std::unique_lock<std::mutex> evictionAdmissionLock{
evictionAdmissionMutex_, std::defer_lock};
if (mayEvict)
evictionAdmissionLock.lock();
bool needsCurrentValidationKeys = false;
if (mayEvict)
{
std::shared_lock sl{mutex_};
auto const iter = map_.find(m.masterKey);
if (atEvictableCap(iter, sl))
{
if (!evictionPermitAvailable(sl))
return rejectAtUntrustedCap();
needsCurrentValidationKeys =
static_cast<bool>(currentValidationKeys);
}
}
std::optional<hash_set<PublicKey>> activeSigningKeys;
if (needsCurrentValidationKeys)
activeSigningKeys = currentValidationKeys();
std::unique_lock sl{mutex_};
auto const iter = map_.find(m.masterKey);
bool const needsEviction = atEvictableCap(iter, sl);
if (needsEviction && !mayEvict)
return rejectAtUntrustedCap();
if (protectedRetention && iter != map_.end() &&
m.sequence <= iter->second.sequence)
{
if (evictableKeys_.erase(m.masterKey) != 0)
++seq_;
return ManifestDisposition::stale;
}
// Since we released the previously held read lock, it's possible that the
// collections have been written to. This means we need to run
// `prewriteCheck` again. This re-does work, but `prewriteCheck` is
@@ -614,79 +485,12 @@ ManifestCache::applyManifestImpl(
// doesn't need to happen again (signature checks are somewhat expensive).
// Note: It's a mistake to use an upgradable lock. This is a recipe for
// deadlock.
if (auto d = prewriteCheck(iter, sl); d.has_value())
if (auto d = prewriteCheck(iter, /*checkSig*/ false, sl))
return *d;
if (needsEviction)
{
XRPL_ASSERT(
mayEvict && !evictableKeys_.empty(),
"ripple::ManifestCache::applyManifestImpl : eviction inputs");
auto const now = now_();
auto const elapsed = now - evictionBudgetUpdated_;
auto const refillIntervals = elapsed / kEvictionPermitInterval;
if (refillIntervals > 0)
{
auto const refill = refillIntervals >= kMaxEvictionPermits
? kMaxEvictionPermits
: static_cast<std::size_t>(refillIntervals);
evictionPermits_ =
std::min(kMaxEvictionPermits, evictionPermits_ + refill);
evictionBudgetUpdated_ += refillIntervals * kEvictionPermitInterval;
}
if (evictionPermits_ == 0)
return rejectAtUntrustedCap();
--evictionPermits_;
std::optional<PublicKey> dormantVictim;
std::size_t dormantSeen = 0;
if (activeSigningKeys)
{
for (auto const& master : evictableKeys_)
{
auto const candidate = map_.find(master);
XRPL_ASSERT(
candidate != map_.end(),
"ripple::ManifestCache::applyManifestImpl : evictable "
"key retained");
if (candidate == map_.end())
continue;
if (!candidate->second.signingKey ||
!activeSigningKeys->contains(*candidate->second.signingKey))
{
++dormantSeen;
if (dormantSeen == 1 || rand_int(dormantSeen - 1) == 0)
dormantVictim = master;
}
}
}
PublicKey const victimMaster = [&]() {
if (dormantVictim)
return *dormantVictim;
auto victim = evictableKeys_.begin();
if (evictableKeys_.size() > 1)
std::advance(victim, rand_int(evictableKeys_.size() - 1));
return *victim;
}();
auto const victim = map_.find(victimMaster);
XRPL_ASSERT(
victim != map_.end(),
"ripple::ManifestCache::applyManifestImpl : victim retained");
if (victim == map_.end())
return rejectAtUntrustedCap();
if (victim->second.signingKey)
signingToMasterKeys_.erase(*victim->second.signingKey);
map_.erase(victim);
evictableKeys_.erase(victimMaster);
}
bool const revoked = m.revoked();
// This master key is not currently retained. An untrusted key may reach
// this path again after eviction.
// This is the first manifest we are seeing for a master key. This should
// only ever happen once per validator run.
if (iter == map_.end())
{
if (auto stream = j_.info())
@@ -696,8 +500,6 @@ ManifestCache::applyManifestImpl(
signingToMasterKeys_.emplace(*m.signingKey, m.masterKey);
auto masterKey = m.masterKey;
if (!protectedRetention)
evictableKeys_.insert(masterKey);
map_.emplace(std::move(masterKey), std::move(m));
// Increment sequence to invalidate cached manifest messages
@@ -716,9 +518,6 @@ ManifestCache::applyManifestImpl(
m.sequence,
iter->second.sequence);
if (protectedRetention)
evictableKeys_.erase(m.masterKey);
signingToMasterKeys_.erase(*iter->second.signingKey);
if (!revoked)
@@ -729,96 +528,9 @@ ManifestCache::applyManifestImpl(
// Something has changed. Keep track of it.
seq_++;
if (acceptedUpdate)
*acceptedUpdate = true;
return ManifestDisposition::accepted;
}
void
ManifestCache::setRetention(
PublicKey const& pk,
ManifestRetention const retention)
{
std::unique_lock sl{mutex_};
auto const iter = map_.find(pk);
if (iter == map_.end())
return;
if (retention == ManifestRetention::protected_)
{
if (evictableKeys_.erase(pk) != 0)
{
// Retention affects peer-snapshot priority even though the
// retained manifest is unchanged.
++seq_;
}
return;
}
if (evictableKeys_.contains(pk))
return;
if (configuredKeys_.contains(pk))
return;
if (evictableKeys_.size() < kMaxUntrustedCount)
{
evictableKeys_.insert(pk);
}
else
{
if (iter->second.signingKey)
signingToMasterKeys_.erase(*iter->second.signingKey);
map_.erase(iter);
}
// Retention changes peer-snapshot priority; removal also changes manifest
// resolution.
++seq_;
}
void
ManifestCache::reconcileRetention(hash_set<PublicKey> const& protectedMasters)
{
std::unique_lock sl{mutex_};
bool changed = false;
for (auto iter = map_.begin(); iter != map_.end();)
{
auto const master = iter->first;
if (configuredKeys_.contains(master) ||
protectedMasters.contains(master))
{
changed = evictableKeys_.erase(master) != 0 || changed;
++iter;
continue;
}
if (evictableKeys_.contains(master))
{
++iter;
continue;
}
if (evictableKeys_.size() < kMaxUntrustedCount)
{
evictableKeys_.insert(master);
changed = true;
++iter;
continue;
}
if (iter->second.signingKey)
signingToMasterKeys_.erase(*iter->second.signingKey);
iter = map_.erase(iter);
changed = true;
}
if (changed)
++seq_;
}
void
ManifestCache::load(DatabaseCon& dbCon, std::string const& dbTable)
{
@@ -849,16 +561,11 @@ ManifestCache::load(
JLOG(j_.warn()) << "Configured manifest revokes public key";
}
auto const masterKey = mo->masterKey;
if (applyManifest(std::move(*mo), ManifestRetention::protected_) ==
ManifestDisposition::invalid)
if (applyManifest(std::move(*mo)) == ManifestDisposition::invalid)
{
JLOG(j_.error()) << "Manifest in config was rejected";
return false;
}
std::unique_lock lock{mutex_};
configuredKeys_.insert(masterKey);
}
if (!configRevocation.empty())
@@ -877,22 +584,12 @@ ManifestCache::load(
auto mo = deserializeManifest(base64_decode(revocationStr));
if (!mo || !mo->revoked())
if (!mo || !mo->revoked() ||
applyManifest(std::move(*mo)) == ManifestDisposition::invalid)
{
JLOG(j_.error()) << "Invalid validator key revocation in config";
return false;
}
auto const masterKey = mo->masterKey;
if (applyManifest(std::move(*mo), ManifestRetention::protected_) ==
ManifestDisposition::invalid)
{
JLOG(j_.error()) << "Invalid validator key revocation in config";
return false;
}
std::unique_lock lock{mutex_};
configuredKeys_.insert(masterKey);
}
return true;
@@ -904,25 +601,10 @@ ManifestCache::save(
std::string const& dbTable,
std::function<bool(PublicKey const&)> const& isTrusted)
{
hash_map<PublicKey, Manifest> manifests;
{
std::shared_lock lock{mutex_};
manifests.reserve(map_.size());
for (auto const& [master, m] : map_)
{
manifests.emplace(
master,
Manifest{
m.serialized,
m.masterKey,
m.signingKey,
m.sequence,
m.domain});
}
}
std::shared_lock lock{mutex_};
auto db = dbCon.checkoutDb();
saveManifests(*db, dbTable, isTrusted, manifests, j_);
saveManifests(*db, dbTable, isTrusted, map_, j_);
}
// Clean up macros to avoid namespace pollution

View File

@@ -226,8 +226,6 @@ ValidatorList::load(
keyListings_.insert({*localPubKey_, listThreshold_});
if (inserted)
{
validatorManifests_.setRetention(
*localPubKey_, ManifestRetention::protected_);
JLOG(j_.debug()) << "Added own master key "
<< toBase58(TokenType::NodePublic, *localPubKey_);
}
@@ -267,8 +265,7 @@ ValidatorList::load(
JLOG(j_.warn()) << "Duplicate node identity: " << match[1];
continue;
}
validatorManifests_.setRetention(*id, ManifestRetention::protected_);
localPublisherList.validators.push_back(PublisherValidator{*id, {}});
localPublisherList.list.emplace_back(*id);
++count;
}
@@ -280,12 +277,6 @@ ValidatorList::load(
JLOG(j_.debug()) << "Loaded " << count << " entries";
// Wallet rows are loaded before local and publisher configuration is
// known. Reconcile that provisional startup retention now that the current
// local source set is complete. Available publisher lists, if any, are
// folded in by the same path.
rebuildPublisherCandidates(lock);
return true;
}
@@ -977,10 +968,6 @@ ValidatorList::applyListsAndBroadcast(
networkOPs.clearUNLBlocked();
}
}
else if (disposition == ListDisposition::expired)
{
networkOPs.setUNLBlocked();
}
bool broadcast = disposition <= ListDisposition::known_sequence;
// this function is only called for PublicKeys which are not specified
@@ -1081,24 +1068,16 @@ ValidatorList::applyLists(
return result;
}
std::vector<PublicKey>
ValidatorList::validatorMasters(PublisherList const& list)
{
std::vector<PublicKey> result;
result.reserve(list.validators.size());
for (auto const& entry : list.validators)
result.push_back(entry.master);
return result;
}
void
ValidatorList::updatePublisherList(
PublicKey const& pubKey,
std::vector<PublicKey> const& publisherList,
PublisherList const& current,
std::vector<PublicKey> const& oldList,
ValidatorList::lock_guard const&)
{
// Update keyListings_ for added and removed keys
std::vector<PublicKey> const& publisherList = current.list;
std::vector<std::string> const& manifests = current.manifests;
auto iNew = publisherList.begin();
auto iOld = oldList.begin();
while (iNew != publisherList.end() || iOld != oldList.end())
@@ -1108,8 +1087,6 @@ ValidatorList::updatePublisherList(
{
// Increment list count for added keys
++keyListings_[*iNew];
validatorManifests_.setRetention(
*iNew, ManifestRetention::protected_);
++iNew;
}
else if (
@@ -1134,34 +1111,19 @@ ValidatorList::updatePublisherList(
{
JLOG(j_.warn()) << "No validator keys included in valid list";
}
}
void
ValidatorList::ingestPublisherManifests(
PublicKey const& pubKey,
PublisherList const& current,
ValidatorList::lock_guard const&)
{
for (auto const& entry : current.validators)
for (auto const& valManifest : manifests)
{
if (!entry.manifest)
continue;
if (entry.manifest->masterKey != entry.master)
auto m = deserializeManifest(base64_decode(valManifest));
if (!m || !keyListings_.count(m->masterKey))
{
JLOG(j_.warn()) << "List for " << strHex(pubKey)
<< " contained mismatched validator manifest";
<< " contained untrusted validator manifest";
continue;
}
auto const& supplied = *entry.manifest;
Manifest m{
supplied.serialized,
supplied.masterKey,
supplied.signingKey,
supplied.sequence,
supplied.domain};
if (auto const r = validatorManifests_.applyManifest(
std::move(m), ManifestRetention::protected_);
if (auto const r = validatorManifests_.applyManifest(std::move(*m));
r == ManifestDisposition::invalid)
{
JLOG(j_.warn()) << "List for " << strHex(pubKey)
@@ -1170,98 +1132,6 @@ ValidatorList::ingestPublisherManifests(
}
}
void
ValidatorList::rebuildPublisherCandidates(ValidatorList::lock_guard const&)
{
hash_set<PublicKey> candidateMasters;
hash_set<PublicKey> contributingPublishers;
std::size_t supplied = 0;
for (auto const& [pubKey, collection] : publisherLists_)
{
if (collection.status != PublisherStatus::available)
continue;
for (auto const& candidate : collection.current.candidates)
{
++supplied;
contributingPublishers.insert(pubKey);
candidateMasters.insert(candidate.master);
}
}
std::vector<PublicKey> retained;
retained.reserve(candidateMasters.size());
for (auto const& master : candidateMasters)
retained.push_back(master);
std::sort(retained.begin(), retained.end());
auto const distinct = retained.size();
if (retained.size() > maxPublisherCandidates)
{
for (auto it = retained.begin() + maxPublisherCandidates;
it != retained.end();
++it)
candidateMasters.erase(*it);
retained.erase(
retained.begin() + maxPublisherCandidates, retained.end());
JLOG(j_.warn())
<< "Publisher candidate tier exceeded global capacity: supplied="
<< supplied << ", distinct=" << distinct
<< ", retained=" << retained.size()
<< ", dropped=" << (distinct - retained.size())
<< ", publishers=" << contributingPublishers.size();
}
// List tiers choose retention and relay policy only. ManifestCache remains
// the sole high-water and signer-to-master namespace. Reconcile the whole
// current source set so removed validators do not stay protected merely
// because an older publisher generation once named them.
hash_set<PublicKey> protectedMasters;
protectedMasters.reserve(keyListings_.size() + candidateMasters.size());
for (auto const& [master, _] : keyListings_)
{
(void)_;
protectedMasters.insert(master);
}
for (auto const& master : candidateMasters)
protectedMasters.insert(master);
validatorManifests_.reconcileRetention(protectedMasters);
// Apply candidate manifests only after enforcing the global candidate
// bound. They use ManifestCache's ordinary verification, collision and
// sequence rules. Protected retention grants no consensus weight.
for (auto const& [pubKey, collection] : publisherLists_)
{
if (collection.status != PublisherStatus::available)
continue;
for (auto const& candidate : collection.current.candidates)
{
if (!candidate.manifest ||
!candidateMasters.contains(candidate.master))
continue;
auto const& supplied = *candidate.manifest;
Manifest manifest{
supplied.serialized,
supplied.masterKey,
supplied.signingKey,
supplied.sequence,
supplied.domain};
if (auto const result = validatorManifests_.applyManifest(
std::move(manifest), ManifestRetention::protected_);
result == ManifestDisposition::invalid)
{
JLOG(j_.warn()) << "List for " << strHex(pubKey)
<< " contained invalid candidate manifest";
}
}
}
publisherCandidateMasters_ = std::move(candidateMasters);
}
ValidatorList::PublisherListStats
ValidatorList::applyList(
std::string const& globalManifest,
@@ -1273,16 +1143,10 @@ ValidatorList::applyList(
std::optional<uint256> const& hash,
ValidatorList::lock_guard const& lock)
{
auto const& manifest = localManifest ? *localManifest : globalManifest;
if (manifest.size() > kMaxManifestBase64)
{
JLOG(j_.warn()) << "UNL manifest exceeds maximum size";
return PublisherListStats{ListDisposition::invalid};
}
using namespace std::string_literals;
Json::Value list;
auto const& manifest = localManifest ? *localManifest : globalManifest;
auto [result, pubKeyOpt] = verify(lock, list, manifest, blob, signature);
if (!pubKeyOpt)
@@ -1324,23 +1188,21 @@ ValidatorList::applyList(
// Update publisher's list
auto& pubCollection = publisherLists_[pubKey];
auto const sequence = list[jss::sequence].asUInt();
auto const currentGeneration =
auto const accepted =
(result == ListDisposition::accepted ||
result == ListDisposition::expired);
bool const available = result == ListDisposition::accepted;
if (currentGeneration)
pubCollection.status =
available ? PublisherStatus::available : PublisherStatus::expired;
if (accepted)
pubCollection.status = result == ListDisposition::accepted
? PublisherStatus::available
: PublisherStatus::expired;
pubCollection.rawManifest = globalManifest;
if (!pubCollection.maxSequence || sequence > *pubCollection.maxSequence)
pubCollection.maxSequence = sequence;
Json::Value const& newList = list[jss::validators];
Json::Value const* const newCandidates =
list.isMember(jss::candidates) ? &list[jss::candidates] : nullptr;
std::vector<PublicKey> oldList;
if (currentGeneration && pubCollection.remaining.count(sequence) != 0)
if (accepted && pubCollection.remaining.count(sequence) != 0)
{
// We've seen this list before and stored it in "remaining". The
// normal expected process is that the processed list would have
@@ -1349,7 +1211,7 @@ ValidatorList::applyList(
// some of that work here.
auto& publisher = pubCollection.current;
// Copy the old validator list
oldList = validatorMasters(pubCollection.current);
oldList = std::move(pubCollection.current.list);
// Move the publisher info from "remaining" to "current"
publisher = std::move(pubCollection.remaining[sequence]);
// Remove the entry in "remaining"
@@ -1361,8 +1223,8 @@ ValidatorList::applyList(
}
else
{
auto& publisher = currentGeneration ? pubCollection.current
: pubCollection.remaining[sequence];
auto& publisher = accepted ? pubCollection.current
: pubCollection.remaining[sequence];
publisher.sequence = sequence;
publisher.validFrom = TimeKeeper::time_point{TimeKeeper::duration{
list.isMember(jss::effective) ? list[jss::effective].asUInt() : 0}};
@@ -1375,11 +1237,11 @@ ValidatorList::applyList(
if (hash)
publisher.hash = *hash;
std::vector<PublisherValidator>& publisherList = publisher.validators;
std::vector<PublisherCandidate>& candidates = publisher.candidates;
std::vector<PublicKey>& publisherList = publisher.list;
std::vector<std::string>& manifests = publisher.manifests;
// Copy the old validator list
oldList = validatorMasters(publisher);
oldList = std::move(publisherList);
// Build the new validator list from "newList"
publisherList.clear();
publisherList.reserve(newList.size());
@@ -1399,157 +1261,18 @@ ValidatorList::applyList(
}
else
{
PublicKey key{Slice{ret->data(), ret->size()}};
std::shared_ptr<Manifest const> validatorManifest;
if (val.isMember(jss::manifest))
{
if (!val[jss::manifest].isString() ||
val[jss::manifest].asString().size() >
kMaxManifestBase64)
{
JLOG(j_.warn())
<< "List for " << strHex(pubKey)
<< " contained malformed validator manifest";
}
else
{
auto m = deserializeManifest(
base64_decode(val[jss::manifest].asString()));
if (!m || m->masterKey != key)
{
JLOG(j_.warn())
<< "List for " << strHex(pubKey)
<< " contained invalid or mismatched "
"validator manifest";
}
else
{
validatorManifest =
std::make_shared<Manifest const>(
std::move(*m));
}
}
}
publisherList.push_back(
PublisherValidator{key, std::move(validatorManifest)});
PublicKey(Slice{ret->data(), ret->size()}));
}
if (val.isMember(jss::manifest) &&
val[jss::manifest].isString())
manifests.push_back(val[jss::manifest].asString());
}
}
// Standardize the list order by sorting
std::sort(
publisherList.begin(),
publisherList.end(),
[](PublisherValidator const& lhs, PublisherValidator const& rhs) {
return lhs.master < rhs.master;
});
candidates.clear();
if (newCandidates)
{
bool candidatePlaneValid = newCandidates->isArray() &&
newCandidates->size() <= maxPublisherCandidates;
if (!newCandidates->isArray())
{
JLOG(j_.error())
<< "List for " << strHex(pubKey)
<< " supplied a non-array candidate tier. Ignoring "
"candidate tier only.";
}
else if (!candidatePlaneValid)
{
JLOG(j_.error())
<< "List for " << strHex(pubKey) << " supplied "
<< newCandidates->size() << " candidates; maximum is "
<< maxPublisherCandidates
<< ". Ignoring candidate tier only.";
}
else
{
std::vector<PublisherCandidate> parsed;
parsed.reserve(newCandidates->size());
hash_set<PublicKey> candidateKeys;
candidateKeys.reserve(newCandidates->size());
hash_set<PublicKey> validatorKeys;
validatorKeys.reserve(publisherList.size());
for (auto const& entry : publisherList)
validatorKeys.insert(entry.master);
for (auto const& val : *newCandidates)
{
if (!val.isObject() ||
!val.isMember(jss::validation_public_key) ||
!val[jss::validation_public_key].isString())
{
candidatePlaneValid = false;
break;
}
auto const ret =
strUnHex(val[jss::validation_public_key].asString());
if (!ret || !publicKeyType(makeSlice(*ret)))
{
candidatePlaneValid = false;
break;
}
PublicKey key{Slice{ret->data(), ret->size()}};
if (!candidateKeys.insert(key).second ||
validatorKeys.contains(key))
{
candidatePlaneValid = false;
break;
}
std::shared_ptr<Manifest const> candidateManifest;
if (val.isMember(jss::manifest))
{
if (!val[jss::manifest].isString() ||
val[jss::manifest].asString().size() >
kMaxManifestBase64)
{
candidatePlaneValid = false;
break;
}
auto m = deserializeManifest(
base64_decode(val[jss::manifest].asString()));
if (!m || m->masterKey != key || !m->verify())
{
candidatePlaneValid = false;
break;
}
candidateManifest =
std::make_shared<Manifest const>(std::move(*m));
}
parsed.push_back(
PublisherCandidate{key, std::move(candidateManifest)});
}
if (candidatePlaneValid)
candidates = std::move(parsed);
}
if (!candidatePlaneValid)
{
candidates.clear();
JLOG(j_.error())
<< "List for " << strHex(pubKey)
<< " contained an invalid candidate tier; legacy "
"validators remain accepted";
}
else
{
std::sort(
candidates.begin(),
candidates.end(),
[](PublisherCandidate const& lhs,
PublisherCandidate const& rhs) {
return lhs.master < rhs.master;
});
}
}
std::sort(publisherList.begin(), publisherList.end());
}
// If this publisher has ever sent a more updated version than the one
// in this file, keep it. This scenario is unlikely, but legal.
@@ -1564,20 +1287,9 @@ ValidatorList::applyList(
PublisherListStats const applyResult{
result, pubKey, pubCollection.status, *pubCollection.maxSequence};
if (currentGeneration)
if (accepted)
{
// An expired generation is retained as newer publisher evidence, but
// contributes no effective membership or manifest protection.
if (!available)
{
pubCollection.current.validators.clear();
pubCollection.current.candidates.clear();
}
auto const currentMasters = validatorMasters(pubCollection.current);
updatePublisherList(pubKey, currentMasters, oldList, lock);
if (available)
ingestPublisherManifests(pubKey, pubCollection.current, lock);
rebuildPublisherCandidates(lock);
updatePublisherList(pubKey, pubCollection.current, oldList, lock);
}
return applyResult;
@@ -1654,15 +1366,13 @@ ValidatorList::verify(
PublicKey masterPubKey = m->masterKey;
auto const revoked = m->revoked();
auto const result = publisherManifests_.applyManifest(
std::move(*m), ManifestRetention::protected_);
auto const result = publisherManifests_.applyManifest(std::move(*m));
if (revoked && result == ManifestDisposition::accepted)
{
removePublisherList(lock, masterPubKey, PublisherStatus::revoked);
// If the manifest is revoked, no future list is valid either
publisherLists_[masterPubKey].remaining.clear();
rebuildPublisherCandidates(lock);
}
auto const signingKey = publisherManifests_.getSigningKey(masterPubKey);
@@ -1736,15 +1446,6 @@ ValidatorList::listed(PublicKey const& identity) const
return keyListings_.find(pubKey) != keyListings_.end();
}
ValidatorManifestPolicy
ValidatorList::manifestPolicy(PublicKey const& master) const
{
std::shared_lock readLock{mutex_};
return {
keyListings_.contains(master),
publisherCandidateMasters_.contains(master)};
}
bool
ValidatorList::trusted(
ValidatorList::shared_lock const&,
@@ -1808,7 +1509,7 @@ ValidatorList::localPublicKey() const
bool
ValidatorList::removePublisherList(
ValidatorList::lock_guard const& lock,
ValidatorList::lock_guard const&,
PublicKey const& publisherKey,
PublisherStatus reason)
{
@@ -1823,9 +1524,8 @@ ValidatorList::removePublisherList(
JLOG(j_.debug()) << "Removing validator list for publisher "
<< strHex(publisherKey);
for (auto const& entry : iList->second.current.validators)
for (auto const& val : iList->second.current.list)
{
auto const& val = entry.master;
auto const& iVal = keyListings_.find(val);
if (iVal == keyListings_.end())
continue;
@@ -1836,8 +1536,7 @@ ValidatorList::removePublisherList(
--iVal->second;
}
iList->second.current.validators.clear();
iList->second.current.candidates.clear();
iList->second.current.list.clear();
iList->second.status = reason;
return true;
@@ -1846,7 +1545,7 @@ ValidatorList::removePublisherList(
std::size_t
ValidatorList::count(ValidatorList::shared_lock const&) const
{
return publisherLists_.size() + (localPublisherList.validators.size() > 0);
return publisherLists_.size() + (localPublisherList.list.size() > 0);
}
std::size_t
@@ -1889,7 +1588,7 @@ ValidatorList::expires(ValidatorList::shared_lock const&) const
}
}
if (localPublisherList.validators.size() > 0)
if (localPublisherList.list.size() > 0)
{
PublisherList collection = localPublisherList;
// Unfetched
@@ -1956,8 +1655,8 @@ ValidatorList::getJson() const
Json::Value& jLocalStaticKeys =
(res[jss::local_static_keys] = Json::arrayValue);
for (auto const& entry : localPublisherList.validators)
jLocalStaticKeys.append(toBase58(TokenType::NodePublic, entry.master));
for (auto const& key : localPublisherList.list)
jLocalStaticKeys.append(toBase58(TokenType::NodePublic, key));
// Publisher lists
Json::Value& jPublisherLists =
@@ -1981,9 +1680,9 @@ ValidatorList::getJson() const
if (publisherList.validFrom != TimeKeeper::time_point{})
target[jss::effective] = to_string(publisherList.validFrom);
Json::Value& keys = (target[jss::list] = Json::arrayValue);
for (auto const& entry : publisherList.validators)
for (auto const& key : publisherList.list)
{
keys.append(toBase58(TokenType::NodePublic, entry.master));
keys.append(toBase58(TokenType::NodePublic, key));
}
};
{
@@ -2222,7 +1921,6 @@ ValidatorList::updateTrusted(
// Rotate pending and remove expired published lists
bool good = true;
bool publisherCandidatesDirty = false;
// localPublisherList is not processed here. This is because the
// Validators specified in the local config file do not expire nor do
// they have a "remaining" section of PublisherList.
@@ -2257,28 +1955,20 @@ ValidatorList::updateTrusted(
candidate.validFrom <= closeTime,
"ripple::ValidatorList::updateTrusted : maximum time");
auto const oldList = validatorMasters(current);
auto const oldList = current.list;
current = std::move(candidate);
if (collection.status != PublisherStatus::available)
collection.status = PublisherStatus::available;
XRPL_ASSERT(
current.sequence == sequence,
"ripple::ValidatorList::updateTrusted : sequence match");
// If the list is expired, remove the validators so they don't
// get processed in. The expiration check below will do the rest
// of the work
if (current.validUntil <= closeTime)
current.list.clear();
bool const expired = current.validUntil <= closeTime;
collection.status = PublisherStatus::available;
if (expired)
{
// Preserve the raw list for publisher history, but do not
// retain parsed identities that were never counted in
// keyListings_. A later refresh must not subtract them.
current.validators.clear();
current.candidates.clear();
}
auto const currentMasters = validatorMasters(current);
updatePublisherList(pubKey, currentMasters, oldList, lock);
if (!expired)
ingestPublisherManifests(pubKey, current, lock);
publisherCandidatesDirty = true;
updatePublisherList(pubKey, current, oldList, lock);
// Only broadcast the current, which will consequently only
// send to peers that don't understand v2, or which are
@@ -2305,14 +1995,11 @@ ValidatorList::updateTrusted(
collection.current.validUntil <= closeTime)
{
removePublisherList(lock, pubKey, PublisherStatus::expired);
publisherCandidatesDirty = true;
ops.setUNLBlocked();
}
if (collection.status != PublisherStatus::available)
good = false;
}
if (publisherCandidatesDirty)
rebuildPublisherCandidates(lock);
if (good)
ops.clearUNLBlocked();
@@ -2404,7 +2091,7 @@ ValidatorList::updateTrusted(
<< unlSize << ")";
}
if ((publisherLists_.size() || localPublisherList.validators.size()) &&
if ((publisherLists_.size() || localPublisherList.list.size()) &&
unlSize == 0)
{
// No validators. Lock down.

View File

@@ -20,8 +20,6 @@
#include <xrpld/app/rdb/Wallet.h>
#include <boost/format.hpp>
#include <cstddef>
namespace ripple {
std::unique_ptr<DatabaseCon>
@@ -67,7 +65,7 @@ getManifests(
continue;
}
mCache.applyManifest(std::move(*mo), ManifestRetention::protected_);
mCache.applyManifest(std::move(*mo));
}
else
{
@@ -101,27 +99,19 @@ saveManifests(
{
soci::transaction tr(session);
session << "DELETE FROM " << dbTable;
std::size_t skipped = 0;
for (auto const& v : map)
{
// Preserve the existing wallet contract: terminal revocations are
// saved, while non-revocation manifests are saved only for validators
// selected by the caller. Retention remains an in-memory policy.
// Save all revocation manifests,
// but only save trusted non-revocation manifests.
if (!v.second.revoked() && !isTrusted(v.second.masterKey))
{
++skipped;
JLOG(j.info()) << "Untrusted manifest in cache not saved to db";
continue;
}
saveManifest(session, dbTable, v.second.serialized);
}
tr.commit();
if (skipped != 0)
{
JLOG(j.info()) << skipped
<< " untrusted manifest(s) in cache not saved to db";
}
}
void

View File

@@ -1027,27 +1027,6 @@ public:
return ret;
}
/** Get the signing keys associated with current validations.
Includes trusted and untrusted validators. Calling this method also
removes validations that have aged out under the normal validation
freshness rules.
*/
auto
getCurrentNodeKeys() -> hash_set<NodeKey>
{
hash_set<NodeKey> ret;
std::lock_guard lock{mutex_};
current(
lock,
[&](std::size_t numValidations) { ret.reserve(numValidations); },
[&](NodeID const&, Validation const& validation) {
ret.insert(validation.key());
});
return ret;
}
/** Count the number of trusted full validations for the given ledger
@param ledgerID The identifier of ledger of interest

View File

@@ -20,7 +20,6 @@
#ifndef RIPPLE_OVERLAY_MESSAGE_H_INCLUDED
#define RIPPLE_OVERLAY_MESSAGE_H_INCLUDED
#include <xrpld/app/misc/Manifest.h>
#include <xrpld/overlay/Compression.h>
#include <xrpl/basics/ByteUtilities.h>
#include <xrpl/protocol/PublicKey.h>
@@ -38,10 +37,6 @@ namespace ripple {
constexpr std::size_t maximiumMessageSize = megabytes(64);
constexpr std::size_t manifestFramingBytes = 8;
constexpr std::size_t maximumManifestsMessageSize =
kMaxManifestsPerMessage * (kMaxManifestBytes + manifestFramingBytes);
// VFALCO NOTE If we forward declare Message and write out shared_ptr
// instead of using the in-class type alias, we can remove the
// entire ripple.pb.h from the main headers.

View File

@@ -17,7 +17,6 @@
*/
//==============================================================================
#include <xrpld/app/consensus/RCLValidations.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/misc/HashRouter.h>
#include <xrpld/app/misc/NetworkOPs.h>
@@ -37,7 +36,6 @@
#include <xrpl/basics/random.h>
#include <xrpl/beast/core/LexicalCast.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/resource/Fees.h>
#include <xrpl/server/SimpleWriter.h>
#include <xrpld/core/ConfigSections.h>
@@ -635,93 +633,26 @@ OverlayImpl::onManifests(
std::shared_ptr<protocol::TMManifests> const& m,
std::shared_ptr<PeerImp> const& from)
{
auto const n = m->list_size();
auto const& journal = from->pjournal();
auto const total = static_cast<std::size_t>(m->list_size());
if (total > kMaxManifestEntriesPerMessage)
{
from->charge(
Resource::feeMalformedRequest, "too many manifest entries");
JLOG(journal.warn())
<< "Manifests: message had " << total << " entries; maximum is "
<< kMaxManifestEntriesPerMessage;
return;
}
std::size_t untrusted = 0;
bool skippedUntrusted = false;
protocol::TMManifests relay;
for (std::size_t i = 0; i < total; ++i)
for (std::size_t i = 0; i < n; ++i)
{
auto& s = m->list().Get(i).stobject();
if (auto mo = deserializeManifest(s))
{
auto const serialized = mo->serialized;
auto const masterKey = mo->masterKey;
auto policy = app_.validators().manifestPolicy(masterKey);
if (!policy.relayEligible())
{
if (untrusted >= kMaxManifestsPerMessage)
{
skippedUntrusted = true;
continue;
}
++untrusted;
}
bool acceptedUpdate = false;
ManifestDisposition result;
if (policy.relayEligible())
{
result = app_.validatorManifests().applyManifest(
std::move(*mo), ManifestRetention::protected_);
}
else
{
auto const admission =
app_.validatorManifests().applyManifestWithEviction(
std::move(*mo), [this] {
return app_.getValidations().getCurrentNodeKeys();
});
acceptedUpdate = admission.acceptedUpdate;
result = admission.disposition;
}
auto const latestPolicy =
app_.validators().manifestPolicy(masterKey);
if (!policy.relayEligible() && latestPolicy.relayEligible() &&
(result == ManifestDisposition::accepted ||
result == ManifestDisposition::untrustedCapacity))
{
// Protected membership may race bounded admission. Reapply the
// already parsed bytes so the retained high-water is promoted
// atomically by ManifestCache.
mo = deserializeManifest(serialized);
XRPL_ASSERT(
mo,
"ripple::OverlayImpl::onManifests : manifest "
"deserialization succeeded for policy reconciliation");
auto const reconciled = app_.validatorManifests().applyManifest(
std::move(*mo), ManifestRetention::protected_);
if (result == ManifestDisposition::untrustedCapacity)
result = reconciled;
else if (
reconciled != ManifestDisposition::accepted &&
reconciled != ManifestDisposition::stale)
result = reconciled;
}
else if (policy.relayEligible() && !latestPolicy.relayEligible())
{
app_.validatorManifests().setRetention(
masterKey, ManifestRetention::evictable);
}
policy = latestPolicy;
auto const result =
app_.validatorManifests().applyManifest(std::move(*mo));
if (result == ManifestDisposition::accepted)
{
relay.add_list()->set_stobject(s);
// N.B.: this is important; the applyManifest call above moves
// the loaded Manifest out of the optional so we need to
// reload it here.
@@ -733,15 +664,7 @@ OverlayImpl::onManifests(
app_.getOPs().pubManifest(*mo);
// Do not live-relay a first-seen unlisted identity. Retained
// identities may relay later rotations and revocations, while
// listed identities always relay. If eviction later forgets
// this identity, a reappearance is first-seen again and stops
// locally instead of producing an immediate relay loop.
if (policy.relayEligible() || acceptedUpdate)
relay.add_list()->set_stobject(s);
if (policy.consensusListed)
if (app_.validators().listed(mo->masterKey))
{
auto db = app_.getWalletDB().checkoutDb();
addValidatorManifest(*db, serialized);
@@ -756,22 +679,9 @@ OverlayImpl::onManifests(
}
}
if (skippedUntrusted)
{
from->charge(
Resource::feeMalformedRequest, "too many untrusted manifests");
JLOG(journal.warn())
<< "Manifests: message had " << total
<< " entries; processed all trusted plus the first "
<< kMaxManifestsPerMessage << " untrusted";
}
if (!relay.list().empty())
for_each([m2 = std::make_shared<Message>(relay, protocol::mtMANIFESTS),
source = from->id()](std::shared_ptr<PeerImp>&& p) {
if (p->id() != source)
p->send(m2);
});
for_each([m2 = std::make_shared<Message>(relay, protocol::mtMANIFESTS)](
std::shared_ptr<PeerImp>&& p) { p->send(m2); });
}
void
@@ -1270,86 +1180,34 @@ OverlayImpl::relay(
return {};
}
std::vector<std::shared_ptr<Message>>
OverlayImpl::getManifestsMessages()
std::shared_ptr<Message>
OverlayImpl::getManifestsMessage()
{
std::lock_guard g(manifestLock_);
auto const seq = app_.validatorManifests().sequence();
if (seq != manifestListSeq_)
if (auto seq = app_.validatorManifests().sequence();
seq != manifestListSeq_)
{
std::vector<std::string> protectedManifests;
std::vector<std::string> evictableManifests;
protocol::TMManifests tm;
app_.validatorManifests().for_each_manifest(
[&protectedManifests, &evictableManifests](std::size_t s) {
protectedManifests.reserve(s);
evictableManifests.reserve(s);
},
[&protectedManifests, &evictableManifests](
Manifest const& manifest, ManifestRetention retention) {
auto& entries = retention == ManifestRetention::protected_
? protectedManifests
: evictableManifests;
entries.push_back(manifest.serialized);
[&tm](std::size_t s) { tm.mutable_list()->Reserve(s); },
[&tm, &hr = app_.getHashRouter()](Manifest const& manifest) {
tm.add_list()->set_stobject(
manifest.serialized.data(), manifest.serialized.size());
hr.addSuppression(manifest.hash());
});
std::shuffle(
protectedManifests.begin(),
protectedManifests.end(),
default_prng());
std::shuffle(
evictableManifests.begin(),
evictableManifests.end(),
default_prng());
manifestMessage_.reset();
manifestMessages_.clear();
protocol::TMManifests tm;
auto flush = [this, &tm]() {
if (tm.list_size() == 0)
return;
manifestMessages_.push_back(
std::make_shared<Message>(tm, protocol::mtMANIFESTS));
tm.Clear();
};
auto add = [this, &tm, &flush](std::string const& serialized) {
if (static_cast<std::size_t>(tm.list_size()) ==
kMaxManifestEntriesPerMessage)
flush();
tm.add_list()->set_stobject(serialized.data(), serialized.size());
if (Message::messageSize(tm) > maximumManifestsMessageSize)
{
tm.mutable_list()->RemoveLast();
flush();
tm.add_list()->set_stobject(
serialized.data(), serialized.size());
if (Message::messageSize(tm) > maximumManifestsMessageSize)
{
tm.mutable_list()->RemoveLast();
JLOG(journal_.warn())
<< "Manifest exceeds peer snapshot message limit";
}
}
};
// All protected entries are delivered across as many bounded frames as
// needed. This covers local, listed, and publisher-candidate sources
// without re-deriving provenance in the overlay.
// The bounded evictable population remains best-effort and sampled.
for (auto const& entry : protectedManifests)
add(entry);
auto const take =
std::min(kMaxManifestsPerMessage, evictableManifests.size());
for (std::size_t i = 0; i < take; ++i)
add(evictableManifests[i]);
flush();
if (tm.list_size() != 0)
manifestMessage_ =
std::make_shared<Message>(tm, protocol::mtMANIFESTS);
manifestListSeq_ = seq;
}
return manifestMessages_;
return manifestMessage_;
}
void

View File

@@ -49,7 +49,6 @@
#include <mutex>
#include <optional>
#include <unordered_map>
#include <vector>
namespace ripple {
@@ -125,11 +124,11 @@ private:
// Transaction reduce-relay metrics
metrics::TxMetrics txMetrics_;
// Bounded messages containing the manifest snapshot sent to new peers.
std::vector<std::shared_ptr<Message>> manifestMessages_;
// A message with the list of manifests we send to peers
std::shared_ptr<Message> manifestMessage_;
// Used to track whether we need to update the cached list of manifests
std::optional<std::uint32_t> manifestListSeq_;
// Protects the messages and the sequence list of manifests
// Protects the message and the sequence list of manifests
std::mutex manifestLock_;
//--------------------------------------------------------------------------
@@ -242,8 +241,8 @@ public:
std::optional<std::reference_wrapper<protocol::TMTransaction>> m,
std::set<Peer::id_t> const& skip) override;
std::vector<std::shared_ptr<Message>>
getManifestsMessages();
std::shared_ptr<Message>
getManifestsMessage();
//--------------------------------------------------------------------------
//

View File

@@ -24,7 +24,6 @@
#include <xrpld/app/ledger/TransactionMaster.h>
#include <xrpld/app/misc/HashRouter.h>
#include <xrpld/app/misc/LoadFeeTrack.h>
#include <xrpld/app/misc/Manifest.h>
#include <xrpld/app/misc/NetworkOPs.h>
#include <xrpld/app/misc/Transaction.h>
#include <xrpld/app/misc/ValidatorList.h>
@@ -871,7 +870,7 @@ PeerImp::doProtocolStart()
});
}
for (auto const& m : overlay_.getManifestsMessages())
if (auto m = overlay_.getManifestsMessage())
send(m);
setTimer();
@@ -1061,12 +1060,6 @@ PeerImp::onMessage(std::shared_ptr<protocol::TMManifests> const& m)
if (s > 100)
fee_.update(Resource::feeModerateBurdenPeer, "oversize");
// TODO(manifest): Charge proportionally to entry/signature-verification
// work. Repeated frames of up to 100 manifests currently incur only the
// trivial per-message peer charge.
// OverlayImpl bounds untrusted work and charges if the cap is exceeded;
// trusted manifests are always processed and do not count against it.
app_.getJobQueue().addJob(
jtMANIFEST, "receiveManifests", [this, that = shared_from_this(), m]() {
overlay_.onManifests(m, that);

View File

@@ -382,15 +382,6 @@ invokeProtocolMessage(
return result;
}
// Drop an oversized TMManifests without penalizing an unpatched peer.
if (header->message_type == protocol::mtMANIFESTS &&
(header->payload_wire_size > maximumManifestsMessageSize ||
header->uncompressed_size > maximumManifestsMessageSize))
{
result.first = header->total_wire_size;
return result;
}
bool success;
switch (header->message_type)

View File

@@ -170,6 +170,10 @@ Handler const handlerArray[]{
{"simulate", byRef(&doSimulate), Role::USER, NEEDS_CURRENT_LEDGER},
{"stop", byRef(&doStop), Role::ADMIN, NO_CONDITION},
{"submit", byRef(&doSubmit), Role::USER, NEEDS_CURRENT_LEDGER},
{"submit_json_tx",
byRef(&doSubmitJsonTx),
Role::USER,
NEEDS_CURRENT_LEDGER},
{"submit_multisigned",
byRef(&doSubmitMultiSigned),
Role::USER,

View File

@@ -147,6 +147,8 @@ doInject(RPC::JsonContext&);
Json::Value
doSubmit(RPC::JsonContext&);
Json::Value
doSubmitJsonTx(RPC::JsonContext&);
Json::Value
doSubmitMultiSigned(RPC::JsonContext&);
Json::Value
doSubscribe(RPC::JsonContext&);

View File

@@ -0,0 +1,239 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2012-2014 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
// submit_json_tx -- prototype json-tx submission path.
//
// Request:
// {
// "tx_json_str": "<exact ASCII bytes the client signed>",
// "signature": "<hex-encoded signature over tx_json_str>"
// }
//
// Pipeline:
// 1. Parse tx_json_str as JSON.
// 2. Verify `signature` over the UTF-8 bytes of tx_json_str using
// SigningPubKey from the parsed object -- NOT the classical
// signing payload.
// 3. Build an STTx from the parsed object + signature.
// 4. forceValidity(SigGoodOnly) so downstream code skips the
// classical sig check (which would fail -- TxnSignature is over
// the ASCII JSON, not over the binary signing payload).
// 5. Route through the normal Transaction / processTransaction flow.
//
// NB: for this to survive peer relay / re-validation a consensus-level
// change is required: a new `sfJsonTxBody` Blob field that carries
// tx_json_str on chain, plus an amendment that routes STTx::checkSign
// through the ASCII bytes when the field is present. This handler is
// the ingress-side prototype only.
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/misc/HashRouter.h>
#include <xrpld/app/misc/Transaction.h>
#include <xrpld/app/tx/apply.h>
#include <xrpld/rpc/Context.h>
#include <xrpld/rpc/detail/RPCHelpers.h>
#include <xrpl/json/json_reader.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/RPCErr.h>
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/resource/Fees.h>
namespace ripple {
namespace {
NetworkOPs::FailHard
getFailHard(RPC::JsonContext const& context)
{
return NetworkOPs::doFailHard(
context.params.isMember("fail_hard") &&
context.params["fail_hard"].asBool());
}
Json::Value
invalidTx(std::string const& reason)
{
Json::Value jv;
jv[jss::error] = "invalidTransaction";
jv[jss::error_exception] = reason;
return jv;
}
} // namespace
Json::Value
doSubmitJsonTx(RPC::JsonContext& context)
{
context.loadType = Resource::feeMediumBurdenRPC;
// Gate the whole RPC on the amendment. Without it, accepting a
// json-tx submission would just queue a tx that every peer node
// rejects as soon as it re-validates the signature classically.
if (!context.ledgerMaster.getCurrentLedger()->rules().enabled(
featureJsonTx))
return rpcError(rpcNOT_ENABLED);
if (!context.params.isMember("tx_json_str") ||
!context.params.isMember("signature"))
return rpcError(rpcINVALID_PARAMS);
std::string const txJsonStr = context.params["tx_json_str"].asString();
if (txJsonStr.empty())
return rpcError(rpcINVALID_PARAMS);
auto sigBlob = strUnHex(context.params["signature"].asString());
if (!sigBlob || sigBlob->empty())
return rpcError(rpcINVALID_PARAMS);
// 1. Parse the ASCII JSON.
Json::Value parsed;
Json::Reader reader;
if (!reader.parse(txJsonStr, parsed) || !parsed.isObject())
return invalidTx("tx_json_str is not a valid JSON object");
// 2. Verify the signature over tx_json_str using SigningPubKey.
if (!parsed.isMember(jss::SigningPubKey))
return invalidTx("missing SigningPubKey");
auto pkBlob = strUnHex(parsed[jss::SigningPubKey].asString());
if (!pkBlob || !publicKeyType(makeSlice(*pkBlob)))
return invalidTx("invalid SigningPubKey");
PublicKey const pk(makeSlice(*pkBlob));
Slice const msg(
reinterpret_cast<unsigned char const*>(txJsonStr.data()),
txJsonStr.size());
if (!verify(pk, msg, makeSlice(*sigBlob)))
return invalidTx("signature over tx_json_str failed verification");
// 3. Build the STTx from parsed + signature + JsonTxBody carrying
// the exact ASCII bytes the client signed. TxnSignature is the
// ASCII-level sig; the classical sig check must be skipped
// (see step 4).
Json::Value stTxJson = parsed;
stTxJson[jss::TxnSignature] = strHex(*sigBlob);
stTxJson[sfJsonTxBody.jsonName] = strHex(txJsonStr);
STParsedJSONObject parsedObj("tx_json", stTxJson);
if (!parsedObj.object)
return invalidTx(
parsedObj.error.isMember(jss::error_message)
? parsedObj.error[jss::error_message].asString()
: "failed to parse tx_json_str into STObject");
std::shared_ptr<STTx const> stTx;
try
{
stTx = std::make_shared<STTx const>(std::move(*parsedObj.object));
}
catch (std::exception& e)
{
return invalidTx(e.what());
}
// 4. Bypass the classical TxnSignature check -- we already verified
// the signature over the ASCII JSON above.
forceValidity(
context.app.getHashRouter(),
stTx->getTransactionID(),
Validity::SigGoodOnly);
auto [validity, reason] = checkValidity(
context.app.getHashRouter(),
*stTx,
context.ledgerMaster.getCurrentLedger()->rules(),
context.app.config());
if (validity != Validity::Valid)
return invalidTx("fails local checks: " + reason);
std::string buildReason;
auto transaction =
std::make_shared<Transaction>(stTx, buildReason, context.app);
if (transaction->getStatus() != NEW)
return invalidTx("fails local checks: " + buildReason);
try
{
auto const failType = getFailHard(context);
context.netOps.processTransaction(
transaction, isUnlimited(context.role), true, failType);
}
catch (std::exception& e)
{
Json::Value jv;
jv[jss::error] = "internalSubmit";
jv[jss::error_exception] = e.what();
return jv;
}
Json::Value jvResult;
try
{
jvResult[jss::tx_json] = transaction->getJson(JsonOptions::none);
jvResult[jss::tx_blob] =
strHex(transaction->getSTransaction()->getSerializer().peekData());
jvResult["tx_json_str"] = txJsonStr;
if (temUNCERTAIN != transaction->getResult())
{
std::string sToken, sHuman;
transResultInfo(transaction->getResult(), sToken, sHuman);
jvResult[jss::engine_result] = sToken;
jvResult[jss::engine_result_code] = transaction->getResult();
jvResult[jss::engine_result_message] = sHuman;
auto const submitResult = transaction->getSubmitResult();
jvResult[jss::accepted] = submitResult.any();
jvResult[jss::applied] = submitResult.applied;
jvResult[jss::broadcast] = submitResult.broadcast;
jvResult[jss::queued] = submitResult.queued;
jvResult[jss::kept] = submitResult.kept;
if (auto currentLedgerState = transaction->getCurrentLedgerState())
{
jvResult[jss::account_sequence_next] =
safe_cast<Json::Value::UInt>(
currentLedgerState->accountSeqNext);
jvResult[jss::account_sequence_available] =
safe_cast<Json::Value::UInt>(
currentLedgerState->accountSeqAvail);
jvResult[jss::open_ledger_cost] =
to_string(currentLedgerState->minFeeRequired);
jvResult[jss::validated_ledger_index] =
safe_cast<Json::Value::UInt>(
currentLedgerState->validatedLedger);
}
}
}
catch (std::exception& e)
{
jvResult[jss::error] = "internalJson";
jvResult[jss::error_exception] = e.what();
}
return jvResult;
}
} // namespace ripple