mirror of
https://github.com/Xahau/xahaud.git
synced 2026-09-17 04:58:30 +00:00
Compare commits
4 Commits
manifest-p
...
json-tx
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99bd33b81d | ||
|
|
f07bc01b0b | ||
|
|
2dea0b7031 | ||
|
|
0149fe03aa |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -129,8 +129,3 @@ generated
|
||||
|
||||
# Suggested in-tree build directory
|
||||
/.build/
|
||||
|
||||
# x-testnet runtime state; scenario definitions remain tracked.
|
||||
/.testnet/output/
|
||||
/.testnet/nodes/
|
||||
/testnet/
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
"""Revoke one of five validators live and watch the terminal update propagate.
|
||||
|
||||
Five validators are used deliberately: revoking one leaves four of five —
|
||||
exactly the 80% quorum — so the network keeps closing ledgers and the
|
||||
scenario can assert liveness with the revoked member excluded, not merely a
|
||||
halt. (On a three-node UNL the same revocation stops the network: small-net
|
||||
quorums round up to everyone.)
|
||||
|
||||
The revocation is installed by config on the OLD release node deliberately:
|
||||
under the new transport slice a config-loaded revocation on an upgraded node
|
||||
sits in its durable cache and is never gossiped (there is no connect-time
|
||||
dump and no ambient manifest relay), while the old binary still dumps its
|
||||
manifest cache on every fresh connection and relays what it accepts. The old
|
||||
node is therefore the only in-topology seeder for a config-injected
|
||||
revocation — that asymmetry is itself part of the documented durability
|
||||
boundary.
|
||||
"""
|
||||
|
||||
from xahaud_scripts.testnet.scenario import (
|
||||
AssertionError as ScenarioAssertion,
|
||||
)
|
||||
|
||||
|
||||
async def _await_log(ctx, log, pattern, *, nodes, name, since=None, deadline=36):
|
||||
# Catch the runner's ScenarioAssertion, not the builtin. The scenario
|
||||
# module shadows AssertionError without subclassing it.
|
||||
for attempt in range(deadline):
|
||||
try:
|
||||
ctx.assert_log(pattern, nodes=nodes, **({"since": since} if since else {}))
|
||||
return
|
||||
except ScenarioAssertion:
|
||||
if attempt == deadline - 1:
|
||||
raise
|
||||
if attempt % 6 == 5:
|
||||
log(f"{name}: attempt {attempt + 1}/{deadline}")
|
||||
await ctx.sleep(5, name=name)
|
||||
|
||||
|
||||
async def scenario(ctx, log):
|
||||
nodes = [0, 1, 2, 3, 4, 5]
|
||||
# Validators 0-4 meet at n0. The old release node n5 is a pendant on n4
|
||||
# so it can seed the revocation without joining the UNL mesh.
|
||||
expected = ctx.topology_edges(
|
||||
[(0, 1), (0, 2), (0, 3), (0, 4), (4, 5)]
|
||||
)
|
||||
|
||||
await ctx.apply_topology(expected, nodes=nodes, exact=False)
|
||||
|
||||
# Normal operation first: everyone validates and validator 4's manifest
|
||||
# is durably known, so the revocation supersedes real retained state.
|
||||
await ctx.wait_for_ledgers(2, node_id=0, timeout=180)
|
||||
|
||||
revoked = ctx.mark("revoked")
|
||||
result = await ctx.revoke_validator(4, 5)
|
||||
log(
|
||||
"revoked validator n4 master via the old relay n5: "
|
||||
f"{result['public_key']}"
|
||||
)
|
||||
|
||||
# The restarted old node loads the revocation and seeds it through its
|
||||
# legacy connect-time dump and accept-relay; its peer n4 spreads it into
|
||||
# the mesh through the normal revocation relay lane. The old node's
|
||||
# reconnect interval dominates the latency, so wait on the log fact.
|
||||
await _await_log(
|
||||
ctx,
|
||||
log,
|
||||
"Revoked",
|
||||
nodes=[4],
|
||||
name="await-revocation-arrival",
|
||||
since=revoked,
|
||||
deadline=36,
|
||||
)
|
||||
|
||||
# Terminal manifest applied and relayed onward by upgraded nodes: first
|
||||
# at the old seeder's direct peer, then across the mesh.
|
||||
ctx.assert_log(
|
||||
"manifest_revocation accepted_for_relay",
|
||||
since=revoked,
|
||||
nodes=[4],
|
||||
)
|
||||
await _await_log(
|
||||
ctx,
|
||||
log,
|
||||
"Revoked",
|
||||
nodes=[0],
|
||||
name="await-mesh-revocation",
|
||||
since=revoked,
|
||||
deadline=36,
|
||||
)
|
||||
ctx.assert_log(
|
||||
"manifest_revocation accepted_for_relay",
|
||||
since=revoked,
|
||||
nodes=[0],
|
||||
)
|
||||
|
||||
# Liveness with the revoked member excluded: four of five is exactly
|
||||
# quorum, so ledgers keep closing.
|
||||
await ctx.wait_for_ledgers(2, node_id=0, timeout=180)
|
||||
|
||||
ctx.assert_not_log("Validation forwarded by peer is invalid", nodes=nodes)
|
||||
ctx.assert_not_log("Validation: Too small", nodes=nodes)
|
||||
|
||||
log(
|
||||
"PASS: a config-injected revocation seeded by the old relay reached"
|
||||
" the mesh, applied terminally on upgraded nodes, relayed onward,"
|
||||
" and the remaining four validators kept the network live at exact"
|
||||
" quorum"
|
||||
)
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Rotate a validator's manifest mid-run and watch the bump propagate.
|
||||
|
||||
"Heals on a sequence bump" is the load-bearing healing claim of the
|
||||
manifest-before-validation transport slice. This exercises it live for the
|
||||
first time: the validator mints sequence 2 and restarts; its validations then
|
||||
carry the new manifest; an old release relay forwards the existing envelopes;
|
||||
the upgraded observer supersedes its retained knowledge and admits sequence 2;
|
||||
and the observer's repair lane re-arms at the newer sequence when the old
|
||||
relay's later validations arrive naked.
|
||||
"""
|
||||
|
||||
from xahaud_scripts.testnet.scenario import (
|
||||
AssertionError as ScenarioAssertion,
|
||||
)
|
||||
|
||||
|
||||
async def _await_log(ctx, log, pattern, *, nodes, name, since=None, deadline=36):
|
||||
# Catch the runner's ScenarioAssertion, not the builtin. The scenario
|
||||
# module shadows AssertionError without subclassing it.
|
||||
for attempt in range(deadline):
|
||||
try:
|
||||
ctx.assert_log(pattern, nodes=nodes, **({"since": since} if since else {}))
|
||||
return
|
||||
except ScenarioAssertion:
|
||||
if attempt == deadline - 1:
|
||||
raise
|
||||
if attempt % 6 == 5:
|
||||
log(f"{name}: attempt {attempt + 1}/{deadline}")
|
||||
await ctx.sleep(5, name=name)
|
||||
|
||||
|
||||
async def scenario(ctx, log):
|
||||
nodes = [0, 1, 2]
|
||||
expected = ctx.topology_edges([(0, 1), (1, 2)])
|
||||
|
||||
await ctx.apply_topology(expected, nodes=nodes, exact=False)
|
||||
|
||||
# Baseline: sequence 1 propagates through the old relay and is admitted
|
||||
# by the upgraded observer before we rotate anything. The validator can
|
||||
# race several ledgers ahead of propagation under fast bootstrap, so
|
||||
# wait on the log fact itself.
|
||||
await ctx.wait_for_ledgers(2, node_id=0, timeout=120)
|
||||
await _await_log(
|
||||
ctx,
|
||||
log,
|
||||
"manifest_validation single_manifest_processed .*sequence=1",
|
||||
nodes=[2],
|
||||
name="await-baseline-admission",
|
||||
deadline=24,
|
||||
)
|
||||
|
||||
rotated = ctx.mark("rotated")
|
||||
rotation = await ctx.rotate_validator_manifest(0)
|
||||
assert rotation["sequence"] == 2, rotation
|
||||
log(f"rotated validator n0 to manifest sequence {rotation['sequence']}")
|
||||
|
||||
# The restarted validator re-joins and validates under the new signing
|
||||
# key; always-send carries the sequence-2 prerequisite with each
|
||||
# validation, and the old relay's one-shot manifest forward plus later
|
||||
# naked relays exercise both the supersede and the repair re-arm. Wait
|
||||
# on the terminal log fact rather than ledger counts: in this topology
|
||||
# only the validator advances its ledger, and its own restart closed the
|
||||
# node-0 WebSocket ledger feed. The repair re-arm is the last event in
|
||||
# the causal chain, so everything else must precede it.
|
||||
# polls at 5s => 180s budget at ~16s consensus rounds
|
||||
await _await_log(
|
||||
ctx,
|
||||
log,
|
||||
"manifest_validation repair_sent .*sequence=2",
|
||||
nodes=[2],
|
||||
name="await-repair-rearm",
|
||||
since=rotated,
|
||||
deadline=36,
|
||||
)
|
||||
|
||||
ctx.assert_log(
|
||||
"manifest_validation pair_enqueued .*sequence=2",
|
||||
since=rotated,
|
||||
nodes=[0],
|
||||
)
|
||||
ctx.assert_log(
|
||||
"manifest_validation candidate_staged .*sequence=2",
|
||||
since=rotated,
|
||||
nodes=[2],
|
||||
)
|
||||
ctx.assert_log(
|
||||
"manifest_validation candidate_matched",
|
||||
since=rotated,
|
||||
nodes=[2],
|
||||
)
|
||||
ctx.assert_log(
|
||||
"manifest_validation single_manifest_processed .*sequence=2"
|
||||
" .*disposition=accepted",
|
||||
since=rotated,
|
||||
nodes=[2],
|
||||
)
|
||||
ctx.assert_log_order(
|
||||
[
|
||||
"manifest_validation single_manifest_processed .*sequence=2",
|
||||
"manifest_validation repair_sent .*sequence=2",
|
||||
],
|
||||
since=rotated,
|
||||
nodes=[2],
|
||||
)
|
||||
|
||||
ctx.assert_not_log("Validation forwarded by peer is invalid", nodes=nodes)
|
||||
ctx.assert_not_log("Validation: Too small", nodes=nodes)
|
||||
|
||||
log(
|
||||
"PASS: mid-run rotation to sequence 2 propagated through an old"
|
||||
" relay; the upgraded observer superseded and admitted the new"
|
||||
" manifest and re-armed its repair lane at the new sequence"
|
||||
)
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Exercise manifest/validation traffic across old and new binaries."""
|
||||
|
||||
|
||||
async def scenario(ctx, log):
|
||||
nodes = [0, 1, 2]
|
||||
expected = ctx.topology_edges([(0, 1), (1, 2)])
|
||||
|
||||
await ctx.apply_topology(expected, nodes=nodes, exact=False)
|
||||
# The first close produces the validator traffic under test; later closes
|
||||
# produce naked relays from the old middle node (it forwards a manifest
|
||||
# singleton at most once), which must draw the bounded repair. These
|
||||
# binaries are different product revisions, so multi-ledger convergence is
|
||||
# deliberately not used as the protocol-compatibility oracle.
|
||||
await ctx.wait_for_ledgers(3, node_id=0, timeout=180)
|
||||
|
||||
# n0 is the sole validator and uses the new ordered prerequisite path
|
||||
# toward the old middle node. The old node processes and relays the two
|
||||
# existing envelope types normally. n2 is a new observer and must retain
|
||||
# the relayed candidate across unrelated traffic until its validation.
|
||||
ctx.assert_log("manifest_validation pair_enqueued", nodes=[0])
|
||||
ctx.assert_log("manifest_validation candidate_staged", nodes=[2])
|
||||
ctx.assert_log("manifest_validation candidate_matched", nodes=[2])
|
||||
ctx.assert_log("manifest_validation validation_parsed", nodes=[2])
|
||||
ctx.assert_log("manifest_validation single_manifest_processed", nodes=[2])
|
||||
ctx.assert_log_order(
|
||||
[
|
||||
"manifest_validation candidate_staged",
|
||||
"manifest_validation candidate_matched",
|
||||
"manifest_validation single_manifest_processed",
|
||||
],
|
||||
nodes=[2],
|
||||
)
|
||||
|
||||
# After durable admission, the old relay's later validations arrive
|
||||
# naked; an authenticated naked validation is an implicit request, so
|
||||
# the upgraded observer repairs its sender once per master/sequence.
|
||||
ctx.assert_log("manifest_validation repair_sent", nodes=[2])
|
||||
ctx.assert_log_order(
|
||||
[
|
||||
"manifest_validation single_manifest_processed",
|
||||
"manifest_validation repair_sent",
|
||||
],
|
||||
nodes=[2],
|
||||
)
|
||||
|
||||
ctx.assert_not_log("Validation forwarded by peer is invalid", nodes=nodes)
|
||||
ctx.assert_not_log("Validation: Too small", nodes=nodes)
|
||||
|
||||
log(
|
||||
"PASS: a release relay accepted the upgraded sender's existing "
|
||||
"manifest/validation envelopes; the upgraded observer retained, "
|
||||
"matched, verified, and admitted the relayed prerequisite, then "
|
||||
"repaired the old middle node's later naked validations"
|
||||
)
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Manifest packets precede validations across a real two-hop relay."""
|
||||
|
||||
|
||||
async def scenario(ctx, log):
|
||||
nodes = [0, 1, 2]
|
||||
expected = ctx.topology_edges([(0, 1), (1, 2)])
|
||||
|
||||
ctx.mark("before-manifest-validation-connect")
|
||||
# A live TCP peer session is visible from both endpoints, so require the
|
||||
# two links without treating their reverse views as extra connections.
|
||||
await ctx.apply_topology(expected, nodes=nodes, exact=False)
|
||||
await ctx.wait_for_ledgers(3, timeout=90)
|
||||
ctx.mark("after-manifest-validation-ledgers")
|
||||
|
||||
send_pattern = r"manifest_validation send_prerequisite"
|
||||
pair_pattern = r"manifest_validation pair_enqueued .*order=manifest,validation"
|
||||
staged_pattern = r"manifest_validation candidate_staged"
|
||||
matched_pattern = r"manifest_validation candidate_matched"
|
||||
processed_pattern = r"manifest_validation single_manifest_processed"
|
||||
validation_pattern = r"manifest_validation validation_parsed"
|
||||
|
||||
# This is a fresh per-test network, so the complete node logs are the
|
||||
# scenario range. Avoid timestamp filtering: xahaud's custom local-time
|
||||
# prefix is not yet understood by x-testnet's Marker parser.
|
||||
for node in nodes:
|
||||
sent = ctx.assert_log(send_pattern, nodes=[node])
|
||||
paired = ctx.assert_log(pair_pattern, nodes=[node])
|
||||
assert paired.count == sent.count, (
|
||||
f"n{node} logged {sent.count} prerequisite sends but "
|
||||
f"{paired.count} completed pairs"
|
||||
)
|
||||
ctx.assert_log_order([send_pattern, pair_pattern], nodes=[node])
|
||||
|
||||
staged = ctx.assert_log(staged_pattern, nodes=[node])
|
||||
matched = ctx.assert_log(matched_pattern, nodes=[node])
|
||||
assert matched.count <= staged.count, (
|
||||
f"n{node} matched {matched.count} candidates after staging only "
|
||||
f"{staged.count}"
|
||||
)
|
||||
ctx.assert_log(validation_pattern, nodes=[node])
|
||||
ctx.assert_log_order(
|
||||
[
|
||||
staged_pattern,
|
||||
validation_pattern,
|
||||
matched_pattern,
|
||||
],
|
||||
nodes=[node],
|
||||
)
|
||||
|
||||
# n0 originates the sole listed validator's manifest and already owns it.
|
||||
# The relay and observer must each admit that manifest; other valid pairs
|
||||
# may remain ephemeral, so matched and durably processed counts are not
|
||||
# expected to be equal.
|
||||
for node in [1, 2]:
|
||||
processed = ctx.assert_log(processed_pattern, nodes=[node])
|
||||
matched = ctx.assert_log(matched_pattern, nodes=[node])
|
||||
assert processed.count <= matched.count, (
|
||||
f"n{node} processed {processed.count} manifests after matching "
|
||||
f"only {matched.count} candidates"
|
||||
)
|
||||
ctx.assert_log_order(
|
||||
[staged_pattern, matched_pattern, processed_pattern], nodes=[node]
|
||||
)
|
||||
|
||||
log(
|
||||
"PASS: every live peer enqueued manifest before validation and every "
|
||||
"receiver staged and matched its one candidate, then applied it only "
|
||||
"after the associated validation passed signature verification"
|
||||
)
|
||||
@@ -1,54 +0,0 @@
|
||||
defaults:
|
||||
network:
|
||||
node_count: 3
|
||||
validators: 1
|
||||
launcher: tmux
|
||||
fixed_peers: false
|
||||
log_levels:
|
||||
Protocol: debug
|
||||
|
||||
tests:
|
||||
- name: manifest_validation_order
|
||||
script: .testnet/scenarios/manifest_validation_order.py
|
||||
|
||||
- name: manifest_validation_mixed_binaries
|
||||
script: .testnet/scenarios/manifest_validation_mixed_binaries.py
|
||||
network:
|
||||
validators: 1
|
||||
node_binaries:
|
||||
1: "@release-3350"
|
||||
log_levels:
|
||||
Protocol: debug
|
||||
Validations: debug
|
||||
|
||||
- name: manifest_rotation_propagation
|
||||
script: .testnet/scenarios/manifest_rotation_propagation.py
|
||||
network:
|
||||
validators: 1
|
||||
node_binaries:
|
||||
1: "@release-3350"
|
||||
log_levels:
|
||||
Protocol: debug
|
||||
Validations: debug
|
||||
|
||||
- name: manifest_revocation_live
|
||||
script: .testnet/scenarios/manifest_revocation_live.py
|
||||
network:
|
||||
node_count: 6
|
||||
validators: 5
|
||||
node_binaries:
|
||||
5: "@release-3350"
|
||||
log_levels:
|
||||
Protocol: debug
|
||||
Validations: debug
|
||||
|
||||
- name: manifest_wipe_recovery
|
||||
script: .testnet/scenarios/manifest_wipe_recovery.py
|
||||
network:
|
||||
node_count: 4
|
||||
validators: 1
|
||||
node_binaries:
|
||||
1: "@release-3350"
|
||||
log_levels:
|
||||
Protocol: debug
|
||||
Validations: debug
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Wipe a mid-chain node's wallet and watch both generations heal it.
|
||||
|
||||
The forever cache's informal replicated history is gone from new nodes, so
|
||||
this pins what replaces it. A wiped upgraded node reconnects with empty
|
||||
durable state; its old-release upstream re-seeds it through the legacy
|
||||
connect-time dump lane, its own validation traffic re-pairs through
|
||||
always-send, and it resumes forwarding paired prerequisites downstream. The
|
||||
recovery, not a starvation, is the honest live boundary: every reachable
|
||||
topology heals, because old peers dump at connect and new peers re-send the
|
||||
prerequisite with every validation.
|
||||
"""
|
||||
|
||||
from xahaud_scripts.testnet.scenario import (
|
||||
AssertionError as ScenarioAssertion,
|
||||
)
|
||||
|
||||
|
||||
async def _await_log(ctx, log, pattern, *, nodes, name, since=None, deadline=36):
|
||||
# Catch the runner's ScenarioAssertion, not the builtin. The scenario
|
||||
# module shadows AssertionError without subclassing it.
|
||||
for attempt in range(deadline):
|
||||
try:
|
||||
ctx.assert_log(pattern, nodes=nodes, **({"since": since} if since else {}))
|
||||
return
|
||||
except ScenarioAssertion:
|
||||
if attempt == deadline - 1:
|
||||
raise
|
||||
if attempt % 6 == 5:
|
||||
log(f"{name}: attempt {attempt + 1}/{deadline}")
|
||||
await ctx.sleep(5, name=name)
|
||||
|
||||
|
||||
async def scenario(ctx, log):
|
||||
nodes = [0, 1, 2, 3]
|
||||
expected = ctx.topology_edges([(0, 1), (1, 2), (2, 3)])
|
||||
|
||||
await ctx.apply_topology(expected, nodes=nodes, exact=False)
|
||||
|
||||
# Baseline: knowledge reaches the end of the chain. The tail observer
|
||||
# receives paired traffic from the upgraded mid-chain node. Admission at
|
||||
# the tail implies the whole upstream chain, so wait on that log fact —
|
||||
# three hops can trail the validator's fast-bootstrap ledger count.
|
||||
await ctx.wait_for_ledgers(2, node_id=0, timeout=120)
|
||||
await _await_log(
|
||||
ctx,
|
||||
log,
|
||||
"manifest_validation single_manifest_processed .*sequence=1",
|
||||
nodes=[3],
|
||||
name="await-baseline-chain",
|
||||
deadline=24,
|
||||
)
|
||||
ctx.assert_log(
|
||||
"manifest_validation single_manifest_processed .*sequence=1",
|
||||
nodes=[2],
|
||||
)
|
||||
|
||||
wiped = ctx.mark("wiped")
|
||||
await ctx.restart_node(2, wipe_wallet_db=True)
|
||||
log("restarted n2 with a wiped wallet database")
|
||||
|
||||
await ctx.wait_for_ledgers(3, node_id=0, timeout=180)
|
||||
|
||||
# Wait on the terminal log fact: resumed paired forwarding downstream is
|
||||
# the last event in the recovery chain, so re-learning and re-admission
|
||||
# must precede it.
|
||||
await _await_log(
|
||||
ctx,
|
||||
log,
|
||||
"manifest_validation send_prerequisite .*sequence=1",
|
||||
nodes=[2],
|
||||
name="await-resumed-forwarding",
|
||||
since=wiped,
|
||||
deadline=36,
|
||||
)
|
||||
|
||||
# Recovery: the wiped node re-learned the validator identity from live
|
||||
# traffic (the old upstream's connect-time dump arrives as a singleton
|
||||
# candidate; the next validation proves it) and admitted it durably
|
||||
# again.
|
||||
ctx.assert_log(
|
||||
"manifest_validation candidate_staged .*sequence=1",
|
||||
since=wiped,
|
||||
nodes=[2],
|
||||
)
|
||||
ctx.assert_log(
|
||||
"manifest_validation single_manifest_processed .*sequence=1"
|
||||
" .*disposition=accepted",
|
||||
since=wiped,
|
||||
nodes=[2],
|
||||
)
|
||||
|
||||
ctx.assert_not_log("Validation forwarded by peer is invalid", nodes=nodes)
|
||||
ctx.assert_not_log("Validation: Too small", nodes=nodes)
|
||||
|
||||
log(
|
||||
"PASS: a wallet-wiped mid-chain node re-learned the validator"
|
||||
" identity from live traffic through an old upstream, re-admitted it"
|
||||
" durably, and resumed paired forwarding to the tail observer"
|
||||
)
|
||||
@@ -77,11 +77,6 @@ test.ledger > xrpld.app
|
||||
test.ledger > xrpld.core
|
||||
test.ledger > xrpld.ledger
|
||||
test.ledger > xrpl.protocol
|
||||
test.net > test.toplevel
|
||||
test.net > xrpl.basics
|
||||
test.net > xrpld.core
|
||||
test.net > xrpld.net
|
||||
test.net > xrpl.json
|
||||
test.nodestore > test.jtx
|
||||
test.nodestore > test.toplevel
|
||||
test.nodestore > test.unit_test
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
81
include/xrpl/protocol/JsonTx.h
Normal file
81
include/xrpl/protocol/JsonTx.h
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
20
json-tx-py/pyproject.toml
Normal file
20
json-tx-py/pyproject.toml
Normal 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",
|
||||
]
|
||||
24
json-tx-py/src/json_tx/__init__.py
Normal file
24
json-tx-py/src/json_tx/__init__.py
Normal 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",
|
||||
]
|
||||
79
json-tx-py/src/json_tx/cli.py
Normal file
79
json-tx-py/src/json_tx/cli.py
Normal 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()
|
||||
384
json-tx-py/src/json_tx/codec.py
Normal file
384
json-tx-py/src/json_tx/codec.py
Normal 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
|
||||
50
json-tx-py/src/json_tx/patch.py
Normal file
50
json-tx-py/src/json_tx/patch.py
Normal 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()
|
||||
63
json-tx-py/tests/test_roundtrip.py
Normal file
63
json-tx-py/tests/test_roundtrip.py
Normal 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
476
json-tx-py/uv.lock
generated
Normal 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" },
|
||||
]
|
||||
187
src/libxrpl/protocol/JsonTx.cpp
Normal file
187
src/libxrpl/protocol/JsonTx.cpp
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
436
src/test/app/JsonTx_test.cpp
Normal file
436
src/test/app/JsonTx_test.cpp
Normal 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
|
||||
@@ -454,8 +454,6 @@ public:
|
||||
auto const sk = randomSecretKey();
|
||||
auto const pk = derivePublicKey(KeyType::ed25519, sk);
|
||||
|
||||
BEAST_EXPECT(!cache.getManifestSnapshot(pk));
|
||||
|
||||
// getSigningKey should return same key if there is no manifest
|
||||
BEAST_EXPECT(cache.getSigningKey(pk) == pk);
|
||||
|
||||
@@ -470,17 +468,6 @@ public:
|
||||
sk, KeyType::ed25519, kp0.second, KeyType::secp256k1, 0)));
|
||||
BEAST_EXPECT(cache.getSigningKey(pk) == kp0.first);
|
||||
BEAST_EXPECT(cache.getMasterKey(kp0.first) == pk);
|
||||
if (auto const snapshot = cache.getManifestSnapshot(kp0.first))
|
||||
{
|
||||
BEAST_EXPECT(snapshot->masterKey == pk);
|
||||
BEAST_EXPECT(snapshot->signingKey == kp0.first);
|
||||
BEAST_EXPECT(snapshot->sequence == 0);
|
||||
BEAST_EXPECT(!snapshot->revoked());
|
||||
}
|
||||
else
|
||||
{
|
||||
fail("current signing key resolves its manifest snapshot");
|
||||
}
|
||||
|
||||
// getSigningKey should return the latest ephemeral public key
|
||||
// for the listed validator master public key
|
||||
@@ -494,16 +481,6 @@ public:
|
||||
BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first);
|
||||
BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk);
|
||||
BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first);
|
||||
BEAST_EXPECT(!cache.getManifestSnapshot(kp0.first));
|
||||
if (auto const snapshot = cache.getManifestSnapshot(pk))
|
||||
{
|
||||
BEAST_EXPECT(snapshot->signingKey == kp1.first);
|
||||
BEAST_EXPECT(snapshot->sequence == 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
fail("master key resolves its current manifest snapshot");
|
||||
}
|
||||
|
||||
// getSigningKey and getMasterKey should fail if a new manifest is
|
||||
// applied with the same signing key but a higher sequence
|
||||
@@ -525,16 +502,6 @@ public:
|
||||
BEAST_EXPECT(cache.getSigningKey(pk) == pk);
|
||||
BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first);
|
||||
BEAST_EXPECT(cache.getMasterKey(kp1.first) == kp1.first);
|
||||
if (auto const snapshot = cache.getManifestSnapshot(pk))
|
||||
{
|
||||
BEAST_EXPECT(snapshot->revoked());
|
||||
BEAST_EXPECT(!snapshot->signingKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
fail("master key resolves its revocation snapshot");
|
||||
}
|
||||
BEAST_EXPECT(!cache.getManifestSnapshot(kp1.first));
|
||||
}
|
||||
|
||||
void
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,351 +0,0 @@
|
||||
//------------------------------------------------------------------------------
|
||||
/*
|
||||
This file is part of rippled: https://github.com/ripple/rippled
|
||||
Copyright (c) 2024 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.
|
||||
*/
|
||||
//==============================================================================
|
||||
|
||||
#include <test/jtx.h>
|
||||
#include <xrpld/core/Job.h>
|
||||
#include <xrpld/core/JobQueue.h>
|
||||
#include <xrpld/net/RPCSub.h>
|
||||
#include <xrpl/json/json_value.h>
|
||||
|
||||
#include <boost/asio.hpp>
|
||||
#include <boost/asio/ip/tcp.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace ripple {
|
||||
namespace test {
|
||||
|
||||
// Minimal HTTP endpoint that counts received webhook POSTs and replies
|
||||
// with a configurable status. Responses are EOF-delimited (no
|
||||
// Content-Length) and the socket is closed right after writing — the
|
||||
// exact shape that triggered the original handleData EOF-completion
|
||||
// leak. So these tests exercise RPCSub flow control AND the HTTPClient
|
||||
// EOF fix end to end: if either regressed, delivery would stall and the
|
||||
// expected count would never be reached within the timeout.
|
||||
class MockWebhookEndpoint
|
||||
{
|
||||
boost::asio::io_service ios_;
|
||||
std::unique_ptr<boost::asio::io_service::work> work_;
|
||||
boost::asio::ip::tcp::acceptor acceptor_;
|
||||
std::thread thread_;
|
||||
unsigned short port_;
|
||||
|
||||
std::atomic<int> received_{0};
|
||||
std::atomic<int> status_{200};
|
||||
std::atomic<int> delayMs_{0};
|
||||
|
||||
public:
|
||||
MockWebhookEndpoint()
|
||||
: work_(std::make_unique<boost::asio::io_service::work>(ios_))
|
||||
, acceptor_(
|
||||
ios_,
|
||||
boost::asio::ip::tcp::endpoint(
|
||||
boost::asio::ip::address::from_string("127.0.0.1"),
|
||||
0))
|
||||
{
|
||||
port_ = acceptor_.local_endpoint().port();
|
||||
accept();
|
||||
thread_ = std::thread([this] { ios_.run(); });
|
||||
}
|
||||
|
||||
~MockWebhookEndpoint()
|
||||
{
|
||||
work_.reset();
|
||||
boost::system::error_code ec;
|
||||
acceptor_.close(ec);
|
||||
ios_.stop();
|
||||
if (thread_.joinable())
|
||||
thread_.join();
|
||||
}
|
||||
|
||||
unsigned short
|
||||
port() const
|
||||
{
|
||||
return port_;
|
||||
}
|
||||
|
||||
int
|
||||
received() const
|
||||
{
|
||||
return received_;
|
||||
}
|
||||
|
||||
void
|
||||
setStatus(int s)
|
||||
{
|
||||
status_ = s;
|
||||
}
|
||||
|
||||
// Delay each reply so delivery is deterministically slower than the
|
||||
// microsecond-fast enqueue loop — keeps the deque full for the
|
||||
// queue-cap drop test regardless of scheduling.
|
||||
void
|
||||
setResponseDelay(int ms)
|
||||
{
|
||||
delayMs_ = ms;
|
||||
}
|
||||
|
||||
private:
|
||||
void
|
||||
accept()
|
||||
{
|
||||
auto sock = std::make_shared<boost::asio::ip::tcp::socket>(ios_);
|
||||
acceptor_.async_accept(*sock, [this, sock](auto ec) {
|
||||
if (ec)
|
||||
return;
|
||||
handle(sock);
|
||||
accept();
|
||||
});
|
||||
}
|
||||
|
||||
void
|
||||
handle(std::shared_ptr<boost::asio::ip::tcp::socket> sock)
|
||||
{
|
||||
auto buf = std::make_shared<boost::asio::streambuf>();
|
||||
boost::asio::async_read_until(
|
||||
*sock, *buf, "\r\n\r\n", [this, sock, buf](auto ec, std::size_t) {
|
||||
if (ec)
|
||||
return;
|
||||
|
||||
++received_;
|
||||
|
||||
auto const delay = delayMs_.load();
|
||||
if (delay > 0)
|
||||
{
|
||||
auto timer =
|
||||
std::make_shared<boost::asio::steady_timer>(ios_);
|
||||
timer->expires_from_now(std::chrono::milliseconds(delay));
|
||||
timer->async_wait(
|
||||
[this, sock, timer](auto) { reply(sock); });
|
||||
}
|
||||
else
|
||||
{
|
||||
reply(sock);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void
|
||||
reply(std::shared_ptr<boost::asio::ip::tcp::socket> sock)
|
||||
{
|
||||
// EOF-delimited reply: no Content-Length, close after writing.
|
||||
// This is the realistic failing-webhook shape.
|
||||
auto resp = std::make_shared<std::string>(
|
||||
"HTTP/1.0 " + std::to_string(status_.load()) +
|
||||
" Reply\r\n\r\n{\"result\":{}}");
|
||||
boost::asio::async_write(
|
||||
*sock, boost::asio::buffer(*resp), [sock, resp](auto, std::size_t) {
|
||||
boost::system::error_code ig;
|
||||
sock->shutdown(boost::asio::ip::tcp::socket::shutdown_both, ig);
|
||||
sock->close(ig);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
class RPCSub_test : public beast::unit_test::suite
|
||||
{
|
||||
// Generous ceiling: the instrumented Debug (coverage) build is much
|
||||
// slower than Release, so timeouts are sized for that, not Release.
|
||||
template <class Cond>
|
||||
bool
|
||||
waitFor(Cond cond, std::chrono::seconds timeout = std::chrono::seconds{30})
|
||||
{
|
||||
auto const deadline = std::chrono::steady_clock::now() + timeout;
|
||||
while (!cond() && std::chrono::steady_clock::now() < deadline)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
return cond();
|
||||
}
|
||||
|
||||
std::shared_ptr<RPCSub>
|
||||
makeSub(
|
||||
jtx::Env& env,
|
||||
MockWebhookEndpoint& ep,
|
||||
std::size_t maxQueueSize = 16384)
|
||||
{
|
||||
return make_RPCSub(
|
||||
env.app().getOPs(),
|
||||
env.app().getJobQueue(),
|
||||
"http://127.0.0.1:" + std::to_string(ep.port()) + "/",
|
||||
"",
|
||||
"",
|
||||
env.app().logs(),
|
||||
maxQueueSize);
|
||||
}
|
||||
|
||||
// True once no RPCSub sending job is queued or running. sendThread
|
||||
// captures a raw `this`, so the RPCSub must not be destroyed while a
|
||||
// job is still in flight — wait on this before letting the sub die.
|
||||
bool
|
||||
sendingIdle(jtx::Env& env)
|
||||
{
|
||||
return env.app().getJobQueue().getJobCountTotal(jtCLIENT_SUBSCRIBE) ==
|
||||
0;
|
||||
}
|
||||
|
||||
// Wait for all events to reach the endpoint AND the sending job to
|
||||
// finish, so the sub can be torn down without racing sendThread.
|
||||
void
|
||||
drainAndSettle(jtx::Env& env, MockWebhookEndpoint& ep, int expected)
|
||||
{
|
||||
bool const delivered =
|
||||
waitFor([&] { return ep.received() >= expected; });
|
||||
bool const idle = waitFor([&] { return sendingIdle(env); });
|
||||
log << " drainAndSettle: received=" << ep.received() << "/" << expected
|
||||
<< " idle=" << idle << std::endl;
|
||||
BEAST_EXPECT(delivered);
|
||||
BEAST_EXPECT(idle);
|
||||
}
|
||||
|
||||
void
|
||||
send(std::shared_ptr<RPCSub> const& sub, int n)
|
||||
{
|
||||
Json::Value ev(Json::objectValue);
|
||||
ev["n"] = n;
|
||||
sub->send(ev, false);
|
||||
}
|
||||
|
||||
void
|
||||
testDelivery()
|
||||
{
|
||||
testcase("Webhook events are delivered");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this};
|
||||
MockWebhookEndpoint ep;
|
||||
|
||||
static constexpr int N = 10;
|
||||
{
|
||||
auto sub = makeSub(env, ep);
|
||||
for (int i = 0; i < N; ++i)
|
||||
send(sub, i);
|
||||
drainAndSettle(env, ep, N);
|
||||
}
|
||||
|
||||
BEAST_EXPECT(ep.received() == N);
|
||||
}
|
||||
|
||||
void
|
||||
testErrorsDoNotStall()
|
||||
{
|
||||
testcase("Delivery continues when endpoint returns HTTP 500");
|
||||
|
||||
// The original bug (xrpld #6341): an endpoint returning errors
|
||||
// without Content-Length never completed, stalling delivery to
|
||||
// ALL subscribers. Here every response is a 500 with no
|
||||
// Content-Length (EOF-delimited) — all N must still arrive.
|
||||
using namespace jtx;
|
||||
Env env{*this};
|
||||
MockWebhookEndpoint ep;
|
||||
ep.setStatus(500);
|
||||
|
||||
static constexpr int N = 10;
|
||||
{
|
||||
auto sub = makeSub(env, ep);
|
||||
for (int i = 0; i < N; ++i)
|
||||
send(sub, i);
|
||||
drainAndSettle(env, ep, N);
|
||||
}
|
||||
|
||||
BEAST_EXPECT(ep.received() == N);
|
||||
}
|
||||
|
||||
void
|
||||
testRestartAfterDrain()
|
||||
{
|
||||
testcase("Sending restarts after the queue drains");
|
||||
|
||||
// After a batch drains, sendThread clears mSending and returns.
|
||||
// A later send() must start a fresh sending job; if mSending were
|
||||
// left set (the #6341 failure mode) the second burst would never
|
||||
// be delivered.
|
||||
using namespace jtx;
|
||||
Env env{*this};
|
||||
MockWebhookEndpoint ep;
|
||||
|
||||
{
|
||||
auto sub = makeSub(env, ep);
|
||||
|
||||
// First burst, then wait for the sending job to fully drain
|
||||
// and exit (mSending cleared) — deterministically, not via a
|
||||
// sleep.
|
||||
for (int i = 0; i < 5; ++i)
|
||||
send(sub, i);
|
||||
drainAndSettle(env, ep, 5);
|
||||
|
||||
// Second burst must start a fresh sending job.
|
||||
for (int i = 5; i < 10; ++i)
|
||||
send(sub, i);
|
||||
drainAndSettle(env, ep, 10);
|
||||
}
|
||||
|
||||
BEAST_EXPECT(ep.received() == 10);
|
||||
}
|
||||
|
||||
void
|
||||
testQueueCapDrops()
|
||||
{
|
||||
testcase("Events past the queue cap are dropped");
|
||||
|
||||
// With a tiny cap, pushing far more events than delivery can keep
|
||||
// up with forces send() down the drop path: enqueue is microsecond
|
||||
// -fast while each (delayed) HTTP delivery is a full round-trip, so
|
||||
// the deque sits at the cap and excess events are dropped. The
|
||||
// delay makes "delivery slower than enqueue" hold regardless of
|
||||
// scheduling, so this isn't timing-dependent. We just need some
|
||||
// delivered (cap works) and some dropped (drop path exercised).
|
||||
using namespace jtx;
|
||||
Env env{*this};
|
||||
MockWebhookEndpoint ep;
|
||||
ep.setResponseDelay(50);
|
||||
|
||||
static constexpr int pushed = 50;
|
||||
{
|
||||
auto sub = makeSub(env, ep, /*maxQueueSize*/ 2);
|
||||
for (int i = 0; i < pushed; ++i)
|
||||
send(sub, i);
|
||||
BEAST_EXPECT(waitFor([&] { return sendingIdle(env); }));
|
||||
}
|
||||
|
||||
log << " queue cap: received " << ep.received() << "/" << pushed
|
||||
<< std::endl;
|
||||
BEAST_EXPECT(ep.received() > 0);
|
||||
BEAST_EXPECT(ep.received() < pushed);
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
{
|
||||
testDelivery();
|
||||
testErrorsDoNotStall();
|
||||
testRestartAfterDrain();
|
||||
testQueueCapDrops();
|
||||
}
|
||||
};
|
||||
|
||||
BEAST_DEFINE_TESTSUITE(RPCSub, net, ripple);
|
||||
|
||||
} // namespace test
|
||||
} // namespace ripple
|
||||
File diff suppressed because it is too large
Load Diff
@@ -903,7 +903,7 @@ RCLConsensus::Adaptor::validate(
|
||||
// Broadcast to all our peers:
|
||||
protocol::TMValidation val;
|
||||
val.set_validation(serialized.data(), serialized.size());
|
||||
app_.overlay().broadcast(val, v->getSignerPublic());
|
||||
app_.overlay().broadcast(val);
|
||||
|
||||
// Publish to all our subscribers:
|
||||
app_.getOPs().pubValidation(v);
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
#include <xrpl/protocol/PublicKey.h>
|
||||
#include <xrpl/protocol/SecretKey.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <shared_mutex>
|
||||
#include <string>
|
||||
@@ -255,26 +254,6 @@ class DatabaseCon;
|
||||
/** Remembers manifests with the highest sequence number. */
|
||||
class ManifestCache
|
||||
{
|
||||
public:
|
||||
/** An atomic read of one cached validator manifest.
|
||||
|
||||
The snapshot may represent a revocation. If a signing key is supplied,
|
||||
it resolves only while that key is the current key for the manifest.
|
||||
*/
|
||||
struct Snapshot
|
||||
{
|
||||
PublicKey masterKey;
|
||||
std::optional<PublicKey> signingKey;
|
||||
std::uint32_t sequence;
|
||||
std::string serialized;
|
||||
|
||||
bool
|
||||
revoked() const
|
||||
{
|
||||
return Manifest::revoked(sequence);
|
||||
}
|
||||
};
|
||||
|
||||
private:
|
||||
beast::Journal j_;
|
||||
std::shared_mutex mutable mutex_;
|
||||
@@ -287,9 +266,6 @@ private:
|
||||
|
||||
std::atomic<std::uint32_t> seq_{0};
|
||||
|
||||
std::optional<ManifestDisposition>
|
||||
checkKeyRolesUnlocked(Manifest const& m) const;
|
||||
|
||||
public:
|
||||
explicit ManifestCache(
|
||||
beast::Journal j = beast::Journal(beast::Journal::getNullSink()))
|
||||
@@ -354,17 +330,6 @@ public:
|
||||
std::optional<std::string>
|
||||
getManifest(PublicKey const& pk) const;
|
||||
|
||||
/** Return one internally consistent view of the current manifest.
|
||||
|
||||
@param pk A master key or its current ephemeral signing key.
|
||||
|
||||
Unlike getManifest(), revocations are returned. This is used by the
|
||||
overlay when ordering a manifest immediately before a validation and
|
||||
when repairing a peer that sent an authenticated naked validation.
|
||||
*/
|
||||
std::optional<Snapshot>
|
||||
getManifestSnapshot(PublicKey const& pk) const;
|
||||
|
||||
/** Returns `true` if master key has been revoked in a manifest.
|
||||
|
||||
@param pk Master public key
|
||||
@@ -376,22 +341,6 @@ public:
|
||||
bool
|
||||
revoked(PublicKey const& pk) const;
|
||||
|
||||
/** Check whether a manifest's keys conflict with retained key roles.
|
||||
|
||||
This does not verify signatures, compare sequences, or mutate the
|
||||
cache. Transport uses it before relaying an unlisted pair: an
|
||||
ephemeral path must not endorse an association the authoritative
|
||||
cache would reject.
|
||||
|
||||
@return A key-role disposition, or `std::nullopt` when admissible.
|
||||
|
||||
@par Thread Safety
|
||||
|
||||
May be called concurrently.
|
||||
*/
|
||||
std::optional<ManifestDisposition>
|
||||
checkKeyRoles(Manifest const& m) const;
|
||||
|
||||
/** Add manifest to cache.
|
||||
|
||||
@param m Manifest to add
|
||||
|
||||
@@ -354,28 +354,6 @@ ManifestCache::getManifest(PublicKey const& pk) const
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<ManifestCache::Snapshot>
|
||||
ManifestCache::getManifestSnapshot(PublicKey const& pk) const
|
||||
{
|
||||
std::shared_lock lock{mutex_};
|
||||
|
||||
auto masterKey = pk;
|
||||
if (auto const signing = signingToMasterKeys_.find(pk);
|
||||
signing != signingToMasterKeys_.end())
|
||||
masterKey = signing->second;
|
||||
|
||||
auto const manifest = map_.find(masterKey);
|
||||
if (manifest == map_.end())
|
||||
return std::nullopt;
|
||||
|
||||
auto const& current = manifest->second;
|
||||
return Snapshot{
|
||||
current.masterKey,
|
||||
current.signingKey,
|
||||
current.sequence,
|
||||
current.serialized};
|
||||
}
|
||||
|
||||
bool
|
||||
ManifestCache::revoked(PublicKey const& pk) const
|
||||
{
|
||||
@@ -388,56 +366,6 @@ ManifestCache::revoked(PublicKey const& pk) const
|
||||
return false;
|
||||
}
|
||||
|
||||
std::optional<ManifestDisposition>
|
||||
ManifestCache::checkKeyRolesUnlocked(Manifest const& m) const
|
||||
{
|
||||
if (auto const x = signingToMasterKeys_.find(m.masterKey);
|
||||
x != signingToMasterKeys_.end())
|
||||
{
|
||||
JLOG(j_.warn()) << to_string(m)
|
||||
<< ": Master key already used as ephemeral key for "
|
||||
<< toBase58(TokenType::NodePublic, x->second);
|
||||
return ManifestDisposition::badMasterKey;
|
||||
}
|
||||
|
||||
if (m.revoked())
|
||||
return std::nullopt;
|
||||
|
||||
if (!m.signingKey)
|
||||
{
|
||||
JLOG(j_.warn()) << to_string(m)
|
||||
<< ": is not revoked and the manifest has no signing "
|
||||
"key. Hence, the manifest is invalid";
|
||||
return ManifestDisposition::invalid;
|
||||
}
|
||||
|
||||
if (auto const x = signingToMasterKeys_.find(*m.signingKey);
|
||||
x != signingToMasterKeys_.end())
|
||||
{
|
||||
JLOG(j_.warn()) << to_string(m)
|
||||
<< ": Ephemeral key already used as ephemeral key for "
|
||||
<< toBase58(TokenType::NodePublic, x->second);
|
||||
return ManifestDisposition::badEphemeralKey;
|
||||
}
|
||||
|
||||
if (auto const x = map_.find(*m.signingKey); x != map_.end())
|
||||
{
|
||||
JLOG(j_.warn()) << to_string(m)
|
||||
<< ": Ephemeral key used as master key for "
|
||||
<< to_string(x->second);
|
||||
return ManifestDisposition::badEphemeralKey;
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<ManifestDisposition>
|
||||
ManifestCache::checkKeyRoles(Manifest const& m) const
|
||||
{
|
||||
std::shared_lock lock{mutex_};
|
||||
return checkKeyRolesUnlocked(m);
|
||||
}
|
||||
|
||||
ManifestDisposition
|
||||
ManifestCache::applyManifest(Manifest m)
|
||||
{
|
||||
@@ -490,8 +418,51 @@ ManifestCache::applyManifest(Manifest m)
|
||||
if (auto stream = j_.warn(); stream && revoked)
|
||||
LOG_MANIFEST_ACTION(stream, "Revoked", m.masterKey, m.sequence);
|
||||
|
||||
if (auto const disposition = checkKeyRolesUnlocked(m))
|
||||
return *disposition;
|
||||
// Sanity check: the master key of this manifest should not be used as
|
||||
// the ephemeral key of another manifest:
|
||||
if (auto const x = signingToMasterKeys_.find(m.masterKey);
|
||||
x != signingToMasterKeys_.end())
|
||||
{
|
||||
JLOG(j_.warn()) << to_string(m)
|
||||
<< ": Master key already used as ephemeral key for "
|
||||
<< toBase58(TokenType::NodePublic, x->second);
|
||||
|
||||
return ManifestDisposition::badMasterKey;
|
||||
}
|
||||
|
||||
if (!revoked)
|
||||
{
|
||||
if (!m.signingKey)
|
||||
{
|
||||
JLOG(j_.warn()) << to_string(m)
|
||||
<< ": is not revoked and the manifest has no "
|
||||
"signing key. Hence, the manifest is "
|
||||
"invalid";
|
||||
return ManifestDisposition::invalid;
|
||||
}
|
||||
|
||||
// Sanity check: the ephemeral key of this manifest should not be
|
||||
// used as the master or ephemeral key of another manifest:
|
||||
if (auto const x = signingToMasterKeys_.find(*m.signingKey);
|
||||
x != signingToMasterKeys_.end())
|
||||
{
|
||||
JLOG(j_.warn())
|
||||
<< to_string(m)
|
||||
<< ": Ephemeral key already used as ephemeral key for "
|
||||
<< toBase58(TokenType::NodePublic, x->second);
|
||||
|
||||
return ManifestDisposition::badEphemeralKey;
|
||||
}
|
||||
|
||||
if (auto const x = map_.find(*m.signingKey); x != map_.end())
|
||||
{
|
||||
JLOG(j_.warn())
|
||||
<< to_string(m) << ": Ephemeral key used as master key for "
|
||||
<< to_string(x->second);
|
||||
|
||||
return ManifestDisposition::badEphemeralKey;
|
||||
}
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
};
|
||||
@@ -553,6 +524,7 @@ ManifestCache::applyManifest(Manifest m)
|
||||
signingToMasterKeys_.emplace(*m.signingKey, m.masterKey);
|
||||
|
||||
iter->second = std::move(m);
|
||||
|
||||
// Something has changed. Keep track of it.
|
||||
seq_++;
|
||||
|
||||
@@ -612,13 +584,8 @@ ManifestCache::load(
|
||||
|
||||
auto mo = deserializeManifest(base64_decode(revocationStr));
|
||||
|
||||
if (!mo || !mo->revoked())
|
||||
{
|
||||
JLOG(j_.error()) << "Invalid validator key revocation in config";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (applyManifest(std::move(*mo)) == ManifestDisposition::invalid)
|
||||
if (!mo || !mo->revoked() ||
|
||||
applyManifest(std::move(*mo)) == ManifestDisposition::invalid)
|
||||
{
|
||||
JLOG(j_.error()) << "Invalid validator key revocation in config";
|
||||
return false;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
#include <xrpld/core/JobQueue.h>
|
||||
#include <xrpld/net/InfoSub.h>
|
||||
#include <boost/asio/io_service.hpp>
|
||||
|
||||
namespace ripple {
|
||||
|
||||
@@ -38,17 +39,16 @@ protected:
|
||||
explicit RPCSub(InfoSub::Source& source);
|
||||
};
|
||||
|
||||
// VFALCO Why is the io_service needed?
|
||||
std::shared_ptr<RPCSub>
|
||||
make_RPCSub(
|
||||
InfoSub::Source& source,
|
||||
boost::asio::io_service& io_service,
|
||||
JobQueue& jobQueue,
|
||||
std::string const& strUrl,
|
||||
std::string const& strUsername,
|
||||
std::string const& strPassword,
|
||||
Logs& logs,
|
||||
// Max events buffered before new ones are dropped. Configurable so
|
||||
// tests can exercise the drop path without queueing the full default.
|
||||
std::size_t maxQueueSize = 16384);
|
||||
Logs& logs);
|
||||
|
||||
} // namespace ripple
|
||||
|
||||
|
||||
@@ -122,20 +122,12 @@ public:
|
||||
mComplete = complete;
|
||||
mTimeout = timeout;
|
||||
|
||||
// Bind a non-owning `this` (not shared_from_this()) into mBuild.
|
||||
// mBuild is a member, so capturing a shared_ptr to self here would
|
||||
// form a reference cycle (this -> mBuild -> shared_ptr<this>) that
|
||||
// never breaks, leaking the object and its socket FD after the
|
||||
// request completes. mBuild is only ever invoked from
|
||||
// handleRequest(), which always runs inside an async handler that
|
||||
// already holds a shared_from_this(), so the object is guaranteed
|
||||
// alive whenever mBuild fires — a raw `this` is safe.
|
||||
request(
|
||||
bSSL,
|
||||
deqSites,
|
||||
std::bind(
|
||||
&HTTPClientImp::makeGet,
|
||||
this,
|
||||
shared_from_this(),
|
||||
strPath,
|
||||
std::placeholders::_1,
|
||||
std::placeholders::_2),
|
||||
@@ -401,12 +393,8 @@ public:
|
||||
if (boost::regex_match(strHeader, smMatch, reBody)) // we got some body
|
||||
mBody = smMatch[1];
|
||||
|
||||
bool const hasContentLength =
|
||||
boost::regex_match(strHeader, smMatch, reSize);
|
||||
mReceivedContentLength = hasContentLength;
|
||||
|
||||
std::size_t const responseSize = [&] {
|
||||
if (hasContentLength)
|
||||
if (boost::regex_match(strHeader, smMatch, reSize))
|
||||
return beast::lexicalCast<std::size_t>(
|
||||
std::string(smMatch[1]), maxResponseSize_);
|
||||
return maxResponseSize_;
|
||||
@@ -457,24 +445,22 @@ public:
|
||||
JLOG(j_.trace()) << "Read error: " << mShutdown.message();
|
||||
|
||||
invokeComplete(mShutdown);
|
||||
return;
|
||||
}
|
||||
|
||||
// Either the read completed normally or it ended at EOF. EOF is a
|
||||
// successful completion for EOF-delimited responses, but it is an
|
||||
// error when the server promised a Content-Length and closed early.
|
||||
JLOG(j_.trace()) << "Complete.";
|
||||
|
||||
mResponse.commit(bytes_transferred);
|
||||
std::string strBody{
|
||||
{std::istreambuf_iterator<char>(&mResponse)},
|
||||
std::istreambuf_iterator<char>()};
|
||||
|
||||
auto completeEc = ecResult;
|
||||
if (completeEc == boost::asio::error::eof && !mReceivedContentLength)
|
||||
completeEc.clear();
|
||||
|
||||
invokeComplete(completeEc, mStatus, mBody + strBody);
|
||||
else
|
||||
{
|
||||
if (mShutdown)
|
||||
{
|
||||
JLOG(j_.trace()) << "Complete.";
|
||||
}
|
||||
else
|
||||
{
|
||||
mResponse.commit(bytes_transferred);
|
||||
std::string strBody{
|
||||
{std::istreambuf_iterator<char>(&mResponse)},
|
||||
std::istreambuf_iterator<char>()};
|
||||
invokeComplete(ecResult, mStatus, mBody + strBody);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call cancel the deadline timer and invoke the completion routine.
|
||||
@@ -530,7 +516,6 @@ private:
|
||||
boost::asio::streambuf mHeader;
|
||||
boost::asio::streambuf mResponse;
|
||||
std::string mBody;
|
||||
bool mReceivedContentLength = false;
|
||||
const unsigned short mPort;
|
||||
std::size_t const maxResponseSize_;
|
||||
int mStatus;
|
||||
|
||||
@@ -1585,10 +1585,6 @@ struct RPCCallImp
|
||||
// callbackFuncP.
|
||||
|
||||
// Receive reply
|
||||
if (ecResult)
|
||||
Throw<std::runtime_error>(
|
||||
"RPC transport error: " + ecResult.message());
|
||||
|
||||
if (strData.empty())
|
||||
Throw<std::runtime_error>(
|
||||
"no response from server. Please "
|
||||
@@ -1752,7 +1748,6 @@ rpcClient(
|
||||
}
|
||||
|
||||
{
|
||||
//@@start blocking-request
|
||||
boost::asio::io_service isService;
|
||||
RPCCall::fromNetwork(
|
||||
isService,
|
||||
@@ -1776,7 +1771,6 @@ rpcClient(
|
||||
headers);
|
||||
isService.run(); // This blocks until there are no more
|
||||
// outstanding async calls.
|
||||
//@@end blocking-request
|
||||
}
|
||||
if (jvOutput.isMember("result"))
|
||||
{
|
||||
@@ -1887,21 +1881,15 @@ fromNetwork(
|
||||
|
||||
// Send request
|
||||
|
||||
// Number of bytes to try to receive if no Content-Length header is
|
||||
// received. Webhook event deliveries ("event") ignore the response
|
||||
// body, so a missing Content-Length must not pre-allocate the full
|
||||
// 256MB RPC reply budget per in-flight delivery (maxInFlight can be
|
||||
// 32 -> 8GB). Cap those small; genuine RPC replies (CLI) keep the
|
||||
// large budget.
|
||||
auto const RPC_REPLY_MAX_BYTES =
|
||||
(strMethod == "event") ? megabytes(1) : megabytes(256);
|
||||
// Number of bytes to try to receive if no
|
||||
// Content-Length header received
|
||||
constexpr auto RPC_REPLY_MAX_BYTES = megabytes(256);
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
// auto constexpr RPC_NOTIFY = 10min; // Wietse: lolwut 10 minutes for one
|
||||
// HTTP call?
|
||||
auto constexpr RPC_NOTIFY = 30s;
|
||||
|
||||
//@@start async-request
|
||||
HTTPClient::request(
|
||||
bSSL,
|
||||
io_service,
|
||||
@@ -1926,7 +1914,6 @@ fromNetwork(
|
||||
std::placeholders::_3,
|
||||
j),
|
||||
j);
|
||||
//@@end async-request
|
||||
}
|
||||
|
||||
} // namespace RPCCall
|
||||
|
||||
@@ -24,30 +24,29 @@
|
||||
#include <xrpl/basics/contract.h>
|
||||
#include <xrpl/json/to_string.h>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
|
||||
namespace ripple {
|
||||
|
||||
// Subscription object for JSON-RPC
|
||||
class RPCSubImp : public RPCSub, public std::enable_shared_from_this<RPCSubImp>
|
||||
class RPCSubImp : public RPCSub
|
||||
{
|
||||
public:
|
||||
RPCSubImp(
|
||||
InfoSub::Source& source,
|
||||
boost::asio::io_service& io_service,
|
||||
JobQueue& jobQueue,
|
||||
std::string const& strUrl,
|
||||
std::string const& strUsername,
|
||||
std::string const& strPassword,
|
||||
Logs& logs,
|
||||
std::size_t maxQueueSize)
|
||||
Logs& logs)
|
||||
: RPCSub(source)
|
||||
, m_io_service(io_service)
|
||||
, m_jobQueue(jobQueue)
|
||||
, mUrl(strUrl)
|
||||
, mSSL(false)
|
||||
, mUsername(strUsername)
|
||||
, mPassword(strPassword)
|
||||
, mSending(false)
|
||||
, maxQueueSize_(maxQueueSize)
|
||||
, j_(logs.journal("RPCSub"))
|
||||
, logs_(logs)
|
||||
{
|
||||
@@ -79,26 +78,14 @@ public:
|
||||
{
|
||||
std::lock_guard sl(mLock);
|
||||
|
||||
if (mDeque.size() >= maxQueueSize_)
|
||||
{
|
||||
// Always advance mSeq so consumers can detect the gap, but
|
||||
// rate-limit the log: a hopelessly behind endpoint drops on
|
||||
// every send() and would otherwise flood the log. Warn on
|
||||
// the first drop of a run and then once per dropLogInterval.
|
||||
if (mDropped++ % dropLogInterval == 0)
|
||||
{
|
||||
JLOG(j_.warn())
|
||||
<< "RPCCall::fromNetwork drop: queue full ("
|
||||
<< mDeque.size() << "), seq=" << mSeq
|
||||
<< ", endpoint=" << mIp << ", dropped=" << mDropped;
|
||||
}
|
||||
++mSeq;
|
||||
return;
|
||||
}
|
||||
|
||||
// Endpoint caught up enough to accept again; reset so the next
|
||||
// overflow burst logs its first drop immediately.
|
||||
mDropped = 0;
|
||||
// Wietse: we're not going to limit this, this is admin-port only, scale
|
||||
// accordingly Dropping events just like this results in inconsistent
|
||||
// data on the receiving end if (mDeque.size() >= eventQueueMax)
|
||||
// {
|
||||
// // Drop the previous event.
|
||||
// JLOG(j_.warn()) << "RPCCall::fromNetwork drop";
|
||||
// mDeque.pop_back();
|
||||
// }
|
||||
|
||||
auto jm = broadcast ? j_.debug() : j_.info();
|
||||
JLOG(jm) << "RPCCall::fromNetwork push: " << jvObj;
|
||||
@@ -110,7 +97,10 @@ public:
|
||||
// Start a sending thread.
|
||||
JLOG(j_.info()) << "RPCCall::fromNetwork start";
|
||||
|
||||
startSendingJob();
|
||||
mSending = m_jobQueue.addJob(
|
||||
jtCLIENT_SUBSCRIBE, "RPCSub::sendThread", [this]() {
|
||||
sendThread();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,66 +121,48 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
// Maximum concurrent HTTP deliveries per batch. Bounds file
|
||||
// descriptor usage while still allowing parallel delivery to
|
||||
// capable endpoints. With a 1024 FD process limit shared across
|
||||
// peers, clients, and the node store, 32 per subscriber is a
|
||||
// meaningful but survivable chunk even with multiple subscribers.
|
||||
static constexpr int maxInFlight = 32;
|
||||
|
||||
// Log one drop warning per this many drops while the queue stays
|
||||
// full, to avoid flooding the log on a persistently behind endpoint.
|
||||
static constexpr std::size_t dropLogInterval = 1000;
|
||||
|
||||
// Schedule a sending job. Must be called under mLock. The job holds a
|
||||
// weak_ptr and re-locks it on entry, so the RPCSub is kept alive for
|
||||
// the duration of the batch even if it is unsubscribed (and would
|
||||
// otherwise be destroyed) concurrently — sendThread dereferences this
|
||||
// only via that strong ref. mDeque events are delivered until the sub
|
||||
// is gone, after which weak.lock() fails and the job is a no-op.
|
||||
void
|
||||
startSendingJob()
|
||||
{
|
||||
std::weak_ptr<RPCSubImp> weak = weak_from_this();
|
||||
mSending = m_jobQueue.addJob(
|
||||
jtCLIENT_SUBSCRIBE, "RPCSub::sendThread", [weak]() {
|
||||
if (auto self = weak.lock())
|
||||
self->sendThread();
|
||||
});
|
||||
}
|
||||
|
||||
// XXX Could probably create a bunch of send jobs in a single get of the
|
||||
// lock.
|
||||
void
|
||||
sendThread()
|
||||
{
|
||||
// Process exactly ONE batch per job, then re-queue if more events
|
||||
// remain, rather than draining the whole backlog in a single job.
|
||||
// A local io_service's .run() blocks this worker thread for the
|
||||
// batch (up to the per-request timeout), so re-queueing between
|
||||
// batches keeps one slow/hung subscriber from monopolising a
|
||||
// job-queue worker and starving consensus/ledger/RPC work.
|
||||
//
|
||||
// mSending must be cleared under the lock on every non-requeue
|
||||
// exit path; if it ever stays set without a job in flight, send()
|
||||
// sees mSending == true and never restarts us, stalling the queue
|
||||
// forever — the original bug (xrpld issue #6341).
|
||||
boost::asio::io_service io_service;
|
||||
int dispatched = 0;
|
||||
Json::Value jvEvent;
|
||||
bool bSend;
|
||||
|
||||
try
|
||||
do
|
||||
{
|
||||
{
|
||||
// Obtain the lock to manipulate the queue and change sending.
|
||||
std::lock_guard sl(mLock);
|
||||
|
||||
while (!mDeque.empty() && dispatched < maxInFlight)
|
||||
if (mDeque.empty())
|
||||
{
|
||||
mSending = false;
|
||||
bSend = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
auto const [seq, env] = mDeque.front();
|
||||
|
||||
mDeque.pop_front();
|
||||
|
||||
Json::Value jvEvent = env;
|
||||
jvEvent = env;
|
||||
jvEvent["seq"] = seq;
|
||||
|
||||
bSend = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Send outside of the lock.
|
||||
if (bSend)
|
||||
{
|
||||
// XXX Might not need this in a try.
|
||||
try
|
||||
{
|
||||
JLOG(j_.info()) << "RPCCall::fromNetwork: " << mIp;
|
||||
|
||||
RPCCall::fromNetwork(
|
||||
io_service,
|
||||
m_io_service,
|
||||
mIp,
|
||||
mPort,
|
||||
mUsername,
|
||||
@@ -201,51 +173,21 @@ private:
|
||||
mSSL,
|
||||
true,
|
||||
logs_);
|
||||
++dispatched;
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
JLOG(j_.info())
|
||||
<< "RPCCall::fromNetwork exception: " << e.what();
|
||||
}
|
||||
}
|
||||
|
||||
// dispatched is always > 0 here (send() only starts a job
|
||||
// after enqueuing, and the re-queue below only fires with a
|
||||
// non-empty deque), but guard anyway so an empty batch can't
|
||||
// log/spin — it falls straight through to clear mSending.
|
||||
if (dispatched > 0)
|
||||
{
|
||||
JLOG(j_.info()) << "RPCCall::fromNetwork: " << mIp
|
||||
<< " dispatching " << dispatched << " events";
|
||||
|
||||
io_service.run();
|
||||
}
|
||||
}
|
||||
catch (std::exception const& e)
|
||||
{
|
||||
// Bail rather than re-queue: a persistently failing endpoint
|
||||
// would otherwise spin the job queue. mSending is reset so the
|
||||
// next send() restarts delivery.
|
||||
JLOG(j_.warn()) << "RPCSub::sendThread exception: " << e.what();
|
||||
std::lock_guard sl(mLock);
|
||||
mSending = false;
|
||||
return;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
JLOG(j_.warn()) << "RPCSub::sendThread unknown exception";
|
||||
std::lock_guard sl(mLock);
|
||||
mSending = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Batch complete: re-queue for the next one (mSending stays set)
|
||||
// or clear mSending if the queue drained — both under the lock to
|
||||
// avoid a lost-wakeup race with send().
|
||||
std::lock_guard sl(mLock);
|
||||
if (mDeque.empty())
|
||||
mSending = false;
|
||||
else
|
||||
startSendingJob();
|
||||
} while (bSend);
|
||||
}
|
||||
|
||||
private:
|
||||
// Wietse: we're not going to limit this, this is admin-port only, scale
|
||||
// accordingly enum { eventQueueMax = 32 };
|
||||
|
||||
boost::asio::io_service& m_io_service;
|
||||
JobQueue& m_jobQueue;
|
||||
|
||||
std::string mUrl;
|
||||
@@ -258,15 +200,8 @@ private:
|
||||
|
||||
int mSeq; // Next id to allocate.
|
||||
|
||||
std::size_t mDropped = 0; // Consecutive drops while queue is full.
|
||||
|
||||
bool mSending; // Sending threead is active.
|
||||
|
||||
// Maximum queued events before dropping. The default (16384) is a
|
||||
// ~10-minute buffer at 100+ events/ledger; a hopelessly behind
|
||||
// endpoint trips it and consumers detect the gap via the seq field.
|
||||
std::size_t const maxQueueSize_;
|
||||
|
||||
std::deque<std::pair<int, Json::Value>> mDeque;
|
||||
|
||||
beast::Journal const j_;
|
||||
@@ -282,21 +217,21 @@ RPCSub::RPCSub(InfoSub::Source& source) : InfoSub(source, Consumer())
|
||||
std::shared_ptr<RPCSub>
|
||||
make_RPCSub(
|
||||
InfoSub::Source& source,
|
||||
boost::asio::io_service& io_service,
|
||||
JobQueue& jobQueue,
|
||||
std::string const& strUrl,
|
||||
std::string const& strUsername,
|
||||
std::string const& strPassword,
|
||||
Logs& logs,
|
||||
std::size_t maxQueueSize)
|
||||
Logs& logs)
|
||||
{
|
||||
return std::make_shared<RPCSubImp>(
|
||||
std::ref(source),
|
||||
std::ref(io_service),
|
||||
std::ref(jobQueue),
|
||||
strUrl,
|
||||
strUsername,
|
||||
strPassword,
|
||||
logs,
|
||||
maxQueueSize);
|
||||
logs);
|
||||
}
|
||||
|
||||
} // namespace ripple
|
||||
|
||||
@@ -145,12 +145,9 @@ public:
|
||||
virtual void
|
||||
broadcast(protocol::TMProposeSet& m) = 0;
|
||||
|
||||
/** Broadcast a validation.
|
||||
* @param m the serialized validation
|
||||
* @param validator The pubkey that signed the validation
|
||||
*/
|
||||
/** Broadcast a validation. */
|
||||
virtual void
|
||||
broadcast(protocol::TMValidation& m, PublicKey const& validator) = 0;
|
||||
broadcast(protocol::TMValidation& m) = 0;
|
||||
|
||||
/** Relay a proposal.
|
||||
* @param m the serialized proposal
|
||||
@@ -174,8 +171,7 @@ public:
|
||||
relay(
|
||||
protocol::TMValidation& m,
|
||||
uint256 const& uid,
|
||||
PublicKey const& validator,
|
||||
std::shared_ptr<protocol::TMManifests const> const& prerequisite) = 0;
|
||||
PublicKey const& validator) = 0;
|
||||
|
||||
/** Relay a transaction. If the tx reduce-relay feature is enabled then
|
||||
* randomly select peers to relay to and queue transaction's hash
|
||||
|
||||
@@ -631,14 +631,12 @@ OverlayImpl::onPeerDeactivate(Peer::id_t id)
|
||||
void
|
||||
OverlayImpl::onManifests(
|
||||
std::shared_ptr<protocol::TMManifests> const& m,
|
||||
std::shared_ptr<PeerImp> const& from,
|
||||
ManifestAdmission admission)
|
||||
std::shared_ptr<PeerImp> const& from)
|
||||
{
|
||||
auto const n = m->list_size();
|
||||
auto const& journal = from->pjournal();
|
||||
|
||||
protocol::TMManifests relay;
|
||||
std::vector<std::pair<PublicKey, std::uint32_t>> relayAssertions;
|
||||
|
||||
for (std::size_t i = 0; i < n; ++i)
|
||||
{
|
||||
@@ -647,72 +645,13 @@ OverlayImpl::onManifests(
|
||||
if (auto mo = deserializeManifest(s))
|
||||
{
|
||||
auto const serialized = mo->serialized;
|
||||
auto const masterKey = mo->masterKey;
|
||||
auto const sequence = mo->sequence;
|
||||
auto const revoked = mo->revoked();
|
||||
|
||||
// Cache membership is observation, not authority: a legacy row
|
||||
// cannot perpetuate itself by presenting a newer normal
|
||||
// signature. Current local policy admits ordinary updates; the
|
||||
// one response-gated exception can only terminate an existing
|
||||
// retained master.
|
||||
auto const listed = app_.validators().listed(masterKey);
|
||||
auto const retainedRevocationResponse = !listed && revoked &&
|
||||
admission == ManifestAdmission::retainedRevocationResponse &&
|
||||
[&]() {
|
||||
auto const current =
|
||||
app_.validatorManifests().getManifestSnapshot(
|
||||
masterKey);
|
||||
return current && current->sequence < sequence;
|
||||
}();
|
||||
if (!listed && !retainedRevocationResponse)
|
||||
{
|
||||
if (n == 1)
|
||||
{
|
||||
JLOG(journal.debug())
|
||||
<< "manifest_validation single_manifest_ignored master="
|
||||
<< toBase58(TokenType::NodePublic, masterKey)
|
||||
<< " sequence=" << sequence
|
||||
<< " reason=unlisted_new_identity";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
auto const result =
|
||||
app_.validatorManifests().applyManifest(std::move(*mo));
|
||||
|
||||
if (result == ManifestDisposition::invalid)
|
||||
from->charge(
|
||||
Resource::feeInvalidSignature,
|
||||
"invalid validator manifest signature");
|
||||
else if (
|
||||
result == ManifestDisposition::badMasterKey ||
|
||||
result == ManifestDisposition::badEphemeralKey)
|
||||
from->charge(
|
||||
Resource::feeInvalidData,
|
||||
"invalid validator manifest key role");
|
||||
|
||||
if (n == 1)
|
||||
{
|
||||
JLOG(journal.debug())
|
||||
<< "manifest_validation single_manifest_processed master="
|
||||
<< toBase58(TokenType::NodePublic, masterKey)
|
||||
<< " sequence=" << sequence
|
||||
<< " disposition=" << to_string(result);
|
||||
}
|
||||
|
||||
if (result == ManifestDisposition::accepted)
|
||||
{
|
||||
if (revoked)
|
||||
{
|
||||
// A revocation has no associated validation to carry it
|
||||
// onward, so it retains immediate network-wide relay.
|
||||
relay.add_list()->set_stobject(s);
|
||||
relayAssertions.emplace_back(masterKey, sequence);
|
||||
JLOG(journal.debug())
|
||||
<< "manifest_revocation accepted_for_relay master="
|
||||
<< toBase58(TokenType::NodePublic, masterKey);
|
||||
}
|
||||
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
|
||||
@@ -741,11 +680,8 @@ OverlayImpl::onManifests(
|
||||
}
|
||||
|
||||
if (!relay.list().empty())
|
||||
for_each([m2 = std::make_shared<Message>(relay, protocol::mtMANIFESTS),
|
||||
assertions = std::move(relayAssertions)](
|
||||
std::shared_ptr<PeerImp>&& p) {
|
||||
p->sendManifestAssertions(m2, assertions);
|
||||
});
|
||||
for_each([m2 = std::make_shared<Message>(relay, protocol::mtMANIFESTS)](
|
||||
std::shared_ptr<PeerImp>&& p) { p->send(m2); });
|
||||
}
|
||||
|
||||
void
|
||||
@@ -1219,21 +1155,17 @@ OverlayImpl::relay(
|
||||
}
|
||||
|
||||
void
|
||||
OverlayImpl::broadcast(protocol::TMValidation& m, PublicKey const& validator)
|
||||
OverlayImpl::broadcast(protocol::TMValidation& m)
|
||||
{
|
||||
auto const sm =
|
||||
std::make_shared<Message>(m, protocol::mtVALIDATION, validator);
|
||||
for_each([sm, validator](std::shared_ptr<PeerImp>&& p) {
|
||||
p->sendValidation(sm, validator);
|
||||
});
|
||||
auto const sm = std::make_shared<Message>(m, protocol::mtVALIDATION);
|
||||
for_each([sm](std::shared_ptr<PeerImp>&& p) { p->send(sm); });
|
||||
}
|
||||
|
||||
std::set<Peer::id_t>
|
||||
OverlayImpl::relay(
|
||||
protocol::TMValidation& m,
|
||||
uint256 const& uid,
|
||||
PublicKey const& validator,
|
||||
std::shared_ptr<protocol::TMManifests const> const& prerequisite)
|
||||
PublicKey const& validator)
|
||||
{
|
||||
if (auto const toSkip = app_.getHashRouter().shouldRelay(uid))
|
||||
{
|
||||
@@ -1241,13 +1173,43 @@ OverlayImpl::relay(
|
||||
std::make_shared<Message>(m, protocol::mtVALIDATION, validator);
|
||||
for_each([&](std::shared_ptr<PeerImp>&& p) {
|
||||
if (toSkip->find(p->id()) == toSkip->end())
|
||||
p->sendValidation(sm, validator, prerequisite);
|
||||
p->send(sm);
|
||||
});
|
||||
return *toSkip;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::shared_ptr<Message>
|
||||
OverlayImpl::getManifestsMessage()
|
||||
{
|
||||
std::lock_guard g(manifestLock_);
|
||||
|
||||
if (auto seq = app_.validatorManifests().sequence();
|
||||
seq != manifestListSeq_)
|
||||
{
|
||||
protocol::TMManifests tm;
|
||||
|
||||
app_.validatorManifests().for_each_manifest(
|
||||
[&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());
|
||||
});
|
||||
|
||||
manifestMessage_.reset();
|
||||
|
||||
if (tm.list_size() != 0)
|
||||
manifestMessage_ =
|
||||
std::make_shared<Message>(tm, protocol::mtMANIFESTS);
|
||||
|
||||
manifestListSeq_ = seq;
|
||||
}
|
||||
|
||||
return manifestMessage_;
|
||||
}
|
||||
|
||||
void
|
||||
OverlayImpl::relay(
|
||||
uint256 const& hash,
|
||||
|
||||
@@ -124,6 +124,13 @@ private:
|
||||
// Transaction reduce-relay metrics
|
||||
metrics::TxMetrics txMetrics_;
|
||||
|
||||
// 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 message and the sequence list of manifests
|
||||
std::mutex manifestLock_;
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
public:
|
||||
@@ -214,7 +221,7 @@ public:
|
||||
broadcast(protocol::TMProposeSet& m) override;
|
||||
|
||||
void
|
||||
broadcast(protocol::TMValidation& m, PublicKey const& validator) override;
|
||||
broadcast(protocol::TMValidation& m) override;
|
||||
|
||||
std::set<Peer::id_t>
|
||||
relay(
|
||||
@@ -226,9 +233,7 @@ public:
|
||||
relay(
|
||||
protocol::TMValidation& m,
|
||||
uint256 const& uid,
|
||||
PublicKey const& validator,
|
||||
std::shared_ptr<protocol::TMManifests const> const& prerequisite)
|
||||
override;
|
||||
PublicKey const& validator) override;
|
||||
|
||||
void
|
||||
relay(
|
||||
@@ -236,6 +241,9 @@ public:
|
||||
std::optional<std::reference_wrapper<protocol::TMTransaction>> m,
|
||||
std::set<Peer::id_t> const& skip) override;
|
||||
|
||||
std::shared_ptr<Message>
|
||||
getManifestsMessage();
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
//
|
||||
// OverlayImpl
|
||||
@@ -285,14 +293,11 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
enum class ManifestAdmission { localPolicy, retainedRevocationResponse };
|
||||
|
||||
// Called when TMManifests is received from a peer
|
||||
void
|
||||
onManifests(
|
||||
std::shared_ptr<protocol::TMManifests> const& m,
|
||||
std::shared_ptr<PeerImp> const& from,
|
||||
ManifestAdmission admission = ManifestAdmission::localPolicy);
|
||||
std::shared_ptr<PeerImp> const& from);
|
||||
|
||||
static bool
|
||||
isPeerUpgrade(http_request_type const& request);
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
#include <xrpl/basics/base64.h>
|
||||
#include <xrpl/basics/random.h>
|
||||
#include <xrpl/basics/safe_cast.h>
|
||||
#include <xrpl/basics/scope.h>
|
||||
#include <xrpl/beast/core/LexicalCast.h>
|
||||
#include <xrpl/protocol/digest.h>
|
||||
|
||||
@@ -48,7 +47,6 @@
|
||||
#include <mutex>
|
||||
#include <numeric>
|
||||
#include <sstream>
|
||||
#include <utility>
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
@@ -60,7 +58,6 @@ std::chrono::milliseconds constexpr peerHighLatency{300};
|
||||
|
||||
/** How often we PING the peer to check for latency and sendq probe */
|
||||
std::chrono::seconds constexpr peerTimerInterval{60};
|
||||
std::size_t constexpr maxManifestBytes = 4096;
|
||||
} // namespace
|
||||
|
||||
// TODO: Remove this exclusion once unit tests are added after the hotfix
|
||||
@@ -294,92 +291,6 @@ PeerImp::send(std::shared_ptr<Message> const& m)
|
||||
std::placeholders::_2)));
|
||||
}
|
||||
|
||||
void
|
||||
PeerImp::sendValidation(
|
||||
std::shared_ptr<Message> const& validation,
|
||||
PublicKey const& signingKey,
|
||||
std::shared_ptr<protocol::TMManifests const> const& prerequisite)
|
||||
{
|
||||
if (!strand_.running_in_this_thread())
|
||||
return post(
|
||||
strand_,
|
||||
std::bind(
|
||||
&PeerImp::sendValidation,
|
||||
shared_from_this(),
|
||||
validation,
|
||||
signingKey,
|
||||
prerequisite));
|
||||
|
||||
if (gracefulClose_ || detaching_)
|
||||
return;
|
||||
|
||||
// Admit the pair as one operation. Otherwise send(manifest) could succeed,
|
||||
// send(validation) could be squelched, and the connection would observe a
|
||||
// credential with no associated validation.
|
||||
if (!squelch_.expireSquelch(signingKey))
|
||||
return;
|
||||
|
||||
std::optional<PublicKey> prerequisiteMaster;
|
||||
std::uint32_t prerequisiteSequence = 0;
|
||||
std::shared_ptr<protocol::TMManifests const> manifest = prerequisite;
|
||||
if (!manifest)
|
||||
{
|
||||
if (auto const snapshot =
|
||||
app_.validatorManifests().getManifestSnapshot(signingKey);
|
||||
snapshot && !snapshot->revoked() && snapshot->signingKey &&
|
||||
*snapshot->signingKey == signingKey)
|
||||
{
|
||||
auto value = std::make_shared<protocol::TMManifests>();
|
||||
value->add_list()->set_stobject(snapshot->serialized);
|
||||
manifest = value;
|
||||
prerequisiteMaster = snapshot->masterKey;
|
||||
prerequisiteSequence = snapshot->sequence;
|
||||
}
|
||||
}
|
||||
else if (manifest->list_size() == 1)
|
||||
{
|
||||
if (auto parsed = deserializeManifest(manifest->list(0).stobject()))
|
||||
{
|
||||
prerequisiteMaster = parsed->masterKey;
|
||||
prerequisiteSequence = parsed->sequence;
|
||||
}
|
||||
}
|
||||
|
||||
// UNLs are local. This sender cannot infer that the receiver retains the
|
||||
// same identities, and there is deliberately no association ACK or
|
||||
// per-connection receiver table in this cut. Therefore every validation
|
||||
// with an available prerequisite carries it immediately beforehand.
|
||||
bool const sendPrerequisite = manifest && prerequisiteMaster;
|
||||
|
||||
if (sendPrerequisite)
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation send_prerequisite peer=" << id_
|
||||
<< " master="
|
||||
<< (prerequisiteMaster
|
||||
? toBase58(TokenType::NodePublic, *prerequisiteMaster)
|
||||
: "unknown")
|
||||
<< " sequence=" << prerequisiteSequence;
|
||||
if (auto const retained = app_.validatorManifests().getManifestSnapshot(
|
||||
*prerequisiteMaster);
|
||||
retained && retained->sequence >= prerequisiteSequence)
|
||||
recordManifestAssertion(*prerequisiteMaster, prerequisiteSequence);
|
||||
send(std::make_shared<Message>(*manifest, protocol::mtMANIFESTS));
|
||||
}
|
||||
|
||||
send(validation);
|
||||
if (sendPrerequisite)
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation pair_enqueued peer=" << id_
|
||||
<< " order=manifest,validation master="
|
||||
<< (prerequisiteMaster
|
||||
? toBase58(TokenType::NodePublic, *prerequisiteMaster)
|
||||
: "unknown")
|
||||
<< " sequence=" << prerequisiteSequence;
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
PeerImp::sendTxQueue()
|
||||
{
|
||||
@@ -959,6 +870,9 @@ PeerImp::doProtocolStart()
|
||||
});
|
||||
}
|
||||
|
||||
if (auto m = overlay_.getManifestsMessage())
|
||||
send(m);
|
||||
|
||||
setTimer();
|
||||
}
|
||||
|
||||
@@ -1144,162 +1058,10 @@ PeerImp::onMessage(std::shared_ptr<protocol::TMManifests> const& m)
|
||||
}
|
||||
|
||||
if (s > 100)
|
||||
{
|
||||
fee_.update(Resource::feeModerateBurdenPeer, "oversize");
|
||||
return;
|
||||
}
|
||||
|
||||
for (auto const& item : m->list())
|
||||
{
|
||||
if (item.stobject().size() > maxManifestBytes)
|
||||
{
|
||||
fee_.update(
|
||||
Resource::feeModerateBurdenPeer, "oversized manifest object");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
auto const that = shared_from_this();
|
||||
|
||||
// A single normal manifest is a bounded, connection-scoped candidate for
|
||||
// a later matching validation. Structural decoding is cheap enough
|
||||
// to identify its signing key; signature verification waits until that
|
||||
// validation claims the same key. Revocations have no following validation
|
||||
// and therefore retain immediate verification/application semantics.
|
||||
if (s == 1)
|
||||
{
|
||||
auto const& serialized = m->list(0).stobject();
|
||||
auto manifest = deserializeManifest(serialized, p_journal_);
|
||||
if (!manifest)
|
||||
{
|
||||
fee_.update(Resource::feeMalformedRequest, "malformed manifest");
|
||||
return;
|
||||
}
|
||||
|
||||
if (manifest->revoked())
|
||||
{
|
||||
// A fresh self-signed revocation has no more claim on permanent
|
||||
// state than a fresh ordinary manifest. Current local policy may
|
||||
// admit it directly; an unlisted response may only terminate a
|
||||
// master already retained here after this connection asserted an
|
||||
// older sequence.
|
||||
auto const listed = app_.validators().listed(manifest->masterKey);
|
||||
auto const current = app_.validatorManifests().getManifestSnapshot(
|
||||
manifest->masterKey);
|
||||
auto const retainedResponse = !listed && current &&
|
||||
current->sequence < manifest->sequence &&
|
||||
assertedOlderManifest(manifest->masterKey, manifest->sequence);
|
||||
if (!listed && !retainedResponse)
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_revocation ignored_unlisted master="
|
||||
<< toBase58(TokenType::NodePublic, manifest->masterKey);
|
||||
return;
|
||||
}
|
||||
|
||||
app_.getJobQueue().addJob(
|
||||
jtMANIFEST,
|
||||
"receiveManifestRevocation",
|
||||
[this, that, m, retainedResponse]() {
|
||||
overlay_.onManifests(
|
||||
m,
|
||||
that,
|
||||
retainedResponse
|
||||
? OverlayImpl::ManifestAdmission::
|
||||
retainedRevocationResponse
|
||||
: OverlayImpl::ManifestAdmission::localPolicy);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
XRPL_ASSERT(
|
||||
manifest->signingKey,
|
||||
"ripple::PeerImp::onMessage(TMManifests) : normal manifest has "
|
||||
"signing key");
|
||||
|
||||
if (publicKeyType(*manifest->signingKey) != KeyType::secp256k1)
|
||||
{
|
||||
fee_.update(
|
||||
Resource::feeInvalidData,
|
||||
"validator manifest signing key is not secp256k1");
|
||||
return;
|
||||
}
|
||||
|
||||
if (auto const current = app_.validatorManifests().getManifestSnapshot(
|
||||
manifest->masterKey);
|
||||
current && current->sequence >= manifest->sequence)
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation candidate_ignored peer=" << id_
|
||||
<< " reason=global_sequence master="
|
||||
<< toBase58(TokenType::NodePublic, manifest->masterKey);
|
||||
// A strictly lower sequence claims that the sender is behind for
|
||||
// a master this node retains. Answer with the retained manifest —
|
||||
// or the revocation, the freshest possible answer — without
|
||||
// treating this unverified trigger as ingress authority. The
|
||||
// shared repair ledger suppresses repeats while its row survives;
|
||||
// equal-sequence always-send traffic draws nothing.
|
||||
if (current->sequence > manifest->sequence)
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation stale_correction peer=" << id_
|
||||
<< " master="
|
||||
<< toBase58(TokenType::NodePublic, manifest->masterKey)
|
||||
<< " theirs=" << manifest->sequence
|
||||
<< " ours=" << current->sequence;
|
||||
sendManifestRepair(
|
||||
current->masterKey, current->sequence, current->serialized);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingManifest_)
|
||||
{
|
||||
auto const current = app_.validatorManifests().getManifestSnapshot(
|
||||
pendingManifest_->masterKey);
|
||||
if (current && current->sequence >= pendingManifest_->sequence)
|
||||
pendingManifest_.reset();
|
||||
}
|
||||
|
||||
if (pendingManifest_)
|
||||
{
|
||||
auto const pendingRelevant =
|
||||
app_.validators().listed(pendingManifest_->masterKey);
|
||||
auto const incomingRelevant =
|
||||
app_.validators().listed(manifest->masterKey);
|
||||
auto const sameMasterNewer =
|
||||
pendingManifest_->masterKey == manifest->masterKey &&
|
||||
manifest->sequence > pendingManifest_->sequence;
|
||||
if (!sameMasterNewer && (!incomingRelevant || pendingRelevant))
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation candidate_ignored peer=" << id_
|
||||
<< " reason=pending_candidate";
|
||||
return;
|
||||
}
|
||||
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation candidate_replaced peer=" << id_
|
||||
<< " reason="
|
||||
<< (sameMasterNewer ? "same_master_newer" : "listed_priority")
|
||||
<< " master="
|
||||
<< toBase58(TokenType::NodePublic, manifest->masterKey);
|
||||
}
|
||||
pendingManifest_.emplace(PendingManifest{
|
||||
m, manifest->masterKey, *manifest->signingKey, manifest->sequence});
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation candidate_staged peer=" << id_ << " master="
|
||||
<< toBase58(TokenType::NodePublic, manifest->masterKey)
|
||||
<< " signing="
|
||||
<< toBase58(TokenType::NodePublic, *manifest->signingKey)
|
||||
<< " sequence=" << manifest->sequence;
|
||||
return;
|
||||
}
|
||||
|
||||
// Compatibility lane for legacy handshake-era batches. New propagation
|
||||
// sends exactly one manifest immediately before its validation.
|
||||
app_.getJobQueue().addJob(
|
||||
jtMANIFEST, "receiveManifests", [this, that, m]() {
|
||||
jtMANIFEST, "receiveManifests", [this, that = shared_from_this(), m]() {
|
||||
overlay_.onManifests(m, that);
|
||||
});
|
||||
}
|
||||
@@ -2527,59 +2289,12 @@ PeerImp::onMessage(std::shared_ptr<protocol::TMValidation> const& m)
|
||||
{
|
||||
auto const closeTime = app_.timeKeeper().closeTime();
|
||||
|
||||
if (pendingManifest_)
|
||||
{
|
||||
auto const current = app_.validatorManifests().getManifestSnapshot(
|
||||
pendingManifest_->masterKey);
|
||||
if (current && current->sequence >= pendingManifest_->sequence)
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation candidate_dropped peer=" << id_
|
||||
<< " reason=retained_sequence master="
|
||||
<< toBase58(
|
||||
TokenType::NodePublic, pendingManifest_->masterKey);
|
||||
pendingManifest_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
// Claim phase: copy the strand-owned waiting row so admission can be
|
||||
// decided. Ownership has not moved and no job right exists yet.
|
||||
std::optional<PendingManifest> manifestContext;
|
||||
SerialIter claimIter(makeSlice(m->validation()));
|
||||
STObject claim(claimIter, sfValidation);
|
||||
auto const claimedKeyBytes = claim.getFieldVL(sfSigningPubKey);
|
||||
if (publicKeyType(makeSlice(claimedKeyBytes)) == KeyType::secp256k1)
|
||||
{
|
||||
PublicKey const claimedKey(makeSlice(claimedKeyBytes));
|
||||
if (pendingManifest_ &&
|
||||
claimedKey == pendingManifest_->signingKey &&
|
||||
!manifestVerificationInFlight_)
|
||||
{
|
||||
manifestContext = pendingManifest_;
|
||||
}
|
||||
else if (
|
||||
pendingManifest_ && claimedKey == pendingManifest_->signingKey)
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation candidate_retained peer=" << id_
|
||||
<< " reason=verification_in_flight";
|
||||
}
|
||||
else if (pendingManifest_)
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation candidate_retained peer=" << id_
|
||||
<< " reason=signing_key_mismatch";
|
||||
}
|
||||
}
|
||||
|
||||
std::shared_ptr<STValidation> val;
|
||||
{
|
||||
SerialIter sit(makeSlice(m->validation()));
|
||||
val = std::make_shared<STValidation>(
|
||||
std::ref(sit),
|
||||
[this, &manifestContext](PublicKey const& pk) {
|
||||
if (manifestContext && pk == manifestContext->signingKey)
|
||||
return calcNodeID(manifestContext->masterKey);
|
||||
[this](PublicKey const& pk) {
|
||||
return calcNodeID(
|
||||
app_.validatorManifests().getMasterKey(pk));
|
||||
},
|
||||
@@ -2587,15 +2302,6 @@ PeerImp::onMessage(std::shared_ptr<protocol::TMValidation> const& m)
|
||||
val->setSeen(closeTime);
|
||||
}
|
||||
|
||||
auto const& signingKey = val->getSignerPublic();
|
||||
auto const masterKey = manifestContext
|
||||
? manifestContext->masterKey
|
||||
: app_.validatorManifests().getMasterKey(signingKey);
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation validation_parsed peer=" << id_
|
||||
<< " signing=" << toBase58(TokenType::NodePublic, signingKey)
|
||||
<< " master=" << toBase58(TokenType::NodePublic, masterKey);
|
||||
|
||||
if (!isCurrent(
|
||||
app_.getValidations().parms(),
|
||||
app_.timeKeeper().closeTime(),
|
||||
@@ -2610,85 +2316,26 @@ PeerImp::onMessage(std::shared_ptr<protocol::TMValidation> const& m)
|
||||
// RH TODO: when isTrusted = false we should probably also cache a key
|
||||
// suppression for 30 seconds to avoid doing a relatively expensive
|
||||
// lookup every time a spam packet is received
|
||||
// A pending manifest is still only a claim here. Do not let its
|
||||
// claimed master key promote this work to the trusted queue. The job
|
||||
// verifies both signatures before applying the manifest and making
|
||||
// the final trust decision.
|
||||
auto const isTrusted =
|
||||
app_.validators().trusted(val->getSignerPublic());
|
||||
auto const candidateListed = manifestContext &&
|
||||
app_.validators().listed(manifestContext->masterKey);
|
||||
|
||||
// A naked validation with neither a trusted signer nor a retained
|
||||
// signing-to-master mapping cannot contribute to consensus and cannot
|
||||
// be repaired locally. Drop it before it claims the validation hash;
|
||||
// a later manifest+validation pair can then be verified normally.
|
||||
if (!manifestContext && !isTrusted && masterKey == signingKey &&
|
||||
!app_.validators().listed(signingKey))
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation naked_unknown_dropped peer=" << id_
|
||||
<< " signing=" << toBase58(TokenType::NodePublic, signingKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the operator has specified that untrusted validations be dropped
|
||||
// then this happens here I.e. before further wasting CPU verifying the
|
||||
// signature of an untrusted key
|
||||
if (!isTrusted && !candidateListed &&
|
||||
app_.config().RELAY_UNTRUSTED_VALIDATIONS == -1)
|
||||
if (!isTrusted && app_.config().RELAY_UNTRUSTED_VALIDATIONS == -1)
|
||||
return;
|
||||
|
||||
if (!isTrusted && !candidateListed &&
|
||||
tracking_.load() == Tracking::diverged)
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "Dropping untrusted validation from diverged peer";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTrusted && !candidateListed &&
|
||||
app_.getFeeTrack().isLoadedLocal())
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "Dropping untrusted validation for load";
|
||||
return;
|
||||
}
|
||||
|
||||
if (manifestContext)
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation candidate_matched peer=" << id_
|
||||
<< " master="
|
||||
<< toBase58(TokenType::NodePublic, manifestContext->masterKey)
|
||||
<< " signing=" << toBase58(TokenType::NodePublic, signingKey)
|
||||
<< " sequence=" << manifestContext->sequence;
|
||||
}
|
||||
|
||||
auto const key = sha512Half(makeSlice(m->validation()));
|
||||
auto const suppressionKey = manifestContext
|
||||
? sha512Half(
|
||||
std::uint32_t{0x4d565031}, // "MVP1"
|
||||
makeSlice(m->validation()),
|
||||
makeSlice(manifestContext->message->list(0).stobject()))
|
||||
: key;
|
||||
auto key = sha512Half(makeSlice(m->validation()));
|
||||
|
||||
if (auto [added, relayed] =
|
||||
app_.getHashRouter().addSuppressionPeerWithStatus(
|
||||
suppressionKey, id_);
|
||||
app_.getHashRouter().addSuppressionPeerWithStatus(key, id_);
|
||||
!added)
|
||||
{
|
||||
// A previously relayed copy of these exact bytes has already
|
||||
// passed validation. Repair this particular naked sender even
|
||||
// though another peer won global admission for the hash.
|
||||
if (!manifestContext && relayed)
|
||||
sendManifestRepairForSigningKey(val->getSignerPublic());
|
||||
|
||||
// Count unique messages (Slots has it's own 'HashRouter'), which a
|
||||
// peer receives within IDLED seconds since the message has been
|
||||
// relayed. Wait WAIT_ON_BOOTUP time to let the server establish
|
||||
// connections to peers.
|
||||
if (!manifestContext && reduceRelayReady() && relayed &&
|
||||
if (reduceRelayReady() && relayed &&
|
||||
(stopwatch().now() - *relayed) < reduce_relay::IDLED)
|
||||
overlay_.updateSlotAndSquelch(
|
||||
key, val->getSignerPublic(), id_, protocol::mtVALIDATION);
|
||||
@@ -2696,67 +2343,39 @@ PeerImp::onMessage(std::shared_ptr<protocol::TMValidation> const& m)
|
||||
return;
|
||||
}
|
||||
|
||||
std::string const name = [isTrusted, val]() {
|
||||
std::string ret =
|
||||
isTrusted ? "Trusted validation" : "Untrusted validation";
|
||||
if (!isTrusted && (tracking_.load() == Tracking::diverged))
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "Dropping untrusted validation from diverged peer";
|
||||
}
|
||||
else if (isTrusted || !app_.getFeeTrack().isLoadedLocal())
|
||||
{
|
||||
std::string const name = [isTrusted, val]() {
|
||||
std::string ret =
|
||||
isTrusted ? "Trusted validation" : "Untrusted validation";
|
||||
|
||||
#ifdef DEBUG
|
||||
ret += " " + std::to_string(val->getFieldU32(sfLedgerSequence)) +
|
||||
": " + to_string(val->getNodeID());
|
||||
ret += " " +
|
||||
std::to_string(val->getFieldU32(sfLedgerSequence)) + ": " +
|
||||
to_string(val->getNodeID());
|
||||
#endif
|
||||
|
||||
return ret;
|
||||
}();
|
||||
return ret;
|
||||
}();
|
||||
|
||||
std::weak_ptr<PeerImp> weak = shared_from_this();
|
||||
auto pairedPeer = manifestContext ? shared_from_this() : nullptr;
|
||||
bool const pairedJob = manifestContext.has_value();
|
||||
if (pairedJob)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
pendingManifest_ &&
|
||||
pendingManifest_->message == manifestContext->message,
|
||||
"ripple::PeerImp::onMessage(TMValidation) : pending manifest "
|
||||
"claim is current");
|
||||
// Admission commit: move authority out of the strand-owned
|
||||
// waiting row and mint this connection's sole active job token.
|
||||
pendingManifest_.reset();
|
||||
manifestVerificationInFlight_ = true;
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation candidate_claimed peer=" << id_
|
||||
<< " state=verification_in_flight";
|
||||
}
|
||||
|
||||
bool queued = false;
|
||||
try
|
||||
{
|
||||
queued = app_.getJobQueue().addJob(
|
||||
std::weak_ptr<PeerImp> weak = shared_from_this();
|
||||
app_.getJobQueue().addJob(
|
||||
isTrusted ? jtVALIDATION_t : jtVALIDATION_ut,
|
||||
name,
|
||||
[weak,
|
||||
pairedPeer = std::move(pairedPeer),
|
||||
val,
|
||||
m,
|
||||
key,
|
||||
manifestContext = std::move(manifestContext)]() mutable {
|
||||
if (auto peer = pairedPeer ? pairedPeer : weak.lock())
|
||||
peer->checkValidation(
|
||||
val, key, m, std::move(manifestContext));
|
||||
[weak, val, m, key]() {
|
||||
if (auto peer = weak.lock())
|
||||
peer->checkValidation(val, key, m);
|
||||
});
|
||||
}
|
||||
catch (...)
|
||||
else
|
||||
{
|
||||
if (pairedJob)
|
||||
finishManifestVerification();
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!queued && pairedJob)
|
||||
{
|
||||
finishManifestVerification();
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation candidate_rejected peer=" << id_
|
||||
<< " reason=job_queue_refused";
|
||||
<< "Dropping untrusted validation for load";
|
||||
}
|
||||
}
|
||||
catch (std::exception const& e)
|
||||
@@ -3348,138 +2967,12 @@ PeerImp::checkPropose(
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
PeerImp::sendManifestRepairForSigningKey(PublicKey const& signingKey)
|
||||
{
|
||||
if (auto const snapshot =
|
||||
app_.validatorManifests().getManifestSnapshot(signingKey);
|
||||
snapshot && !snapshot->revoked() && snapshot->signingKey &&
|
||||
*snapshot->signingKey == signingKey)
|
||||
sendManifestRepair(
|
||||
snapshot->masterKey, snapshot->sequence, snapshot->serialized);
|
||||
}
|
||||
|
||||
void
|
||||
PeerImp::sendManifestRepair(
|
||||
PublicKey const& masterKey,
|
||||
std::uint32_t sequence,
|
||||
std::string serialized)
|
||||
{
|
||||
if (!strand_.running_in_this_thread())
|
||||
return post(
|
||||
strand_,
|
||||
std::bind(
|
||||
&PeerImp::sendManifestRepair,
|
||||
shared_from_this(),
|
||||
masterKey,
|
||||
sequence,
|
||||
std::move(serialized)));
|
||||
|
||||
if (gracefulClose_ || detaching_)
|
||||
return;
|
||||
|
||||
auto const it = manifestAssertionSequences_.find(masterKey);
|
||||
if (it != manifestAssertionSequences_.end() && it->second >= sequence)
|
||||
return;
|
||||
|
||||
recordManifestAssertion(masterKey, sequence);
|
||||
|
||||
protocol::TMManifests tm;
|
||||
tm.add_list()->set_stobject(serialized);
|
||||
send(std::make_shared<Message>(tm, protocol::mtMANIFESTS));
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation repair_sent peer=" << id_
|
||||
<< " master=" << toBase58(TokenType::NodePublic, masterKey)
|
||||
<< " sequence=" << sequence;
|
||||
}
|
||||
|
||||
void
|
||||
PeerImp::recordManifestAssertion(
|
||||
PublicKey const& masterKey,
|
||||
std::uint32_t sequence)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
strand_.running_in_this_thread(),
|
||||
"ripple::PeerImp::recordManifestAssertion : on strand");
|
||||
|
||||
auto const it = manifestAssertionSequences_.find(masterKey);
|
||||
if (it != manifestAssertionSequences_.end())
|
||||
{
|
||||
if (it->second < sequence)
|
||||
it->second = sequence;
|
||||
return;
|
||||
}
|
||||
|
||||
if (manifestAssertionSequences_.size() >= maxManifestAssertionEntries)
|
||||
manifestAssertionSequences_.clear();
|
||||
manifestAssertionSequences_.emplace(masterKey, sequence);
|
||||
}
|
||||
|
||||
bool
|
||||
PeerImp::assertedOlderManifest(
|
||||
PublicKey const& masterKey,
|
||||
std::uint32_t sequence) const
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
strand_.running_in_this_thread(),
|
||||
"ripple::PeerImp::assertedOlderManifest : on strand");
|
||||
|
||||
auto const it = manifestAssertionSequences_.find(masterKey);
|
||||
return it != manifestAssertionSequences_.end() && it->second < sequence;
|
||||
}
|
||||
|
||||
void
|
||||
PeerImp::sendManifestAssertions(
|
||||
std::shared_ptr<Message> const& message,
|
||||
std::vector<std::pair<PublicKey, std::uint32_t>> assertions)
|
||||
{
|
||||
if (!strand_.running_in_this_thread())
|
||||
return post(
|
||||
strand_,
|
||||
std::bind(
|
||||
&PeerImp::sendManifestAssertions,
|
||||
shared_from_this(),
|
||||
message,
|
||||
std::move(assertions)));
|
||||
|
||||
if (gracefulClose_ || detaching_)
|
||||
return;
|
||||
|
||||
for (auto const& [masterKey, sequence] : assertions)
|
||||
recordManifestAssertion(masterKey, sequence);
|
||||
send(message);
|
||||
}
|
||||
|
||||
void
|
||||
PeerImp::finishManifestVerification()
|
||||
{
|
||||
// Instrumentation is compiled out in NDEBUG builds; the state transition
|
||||
// must not live inside XRPL_ASSERT.
|
||||
auto const wasInFlight = manifestVerificationInFlight_.exchange(false);
|
||||
XRPL_ASSERT(
|
||||
wasInFlight,
|
||||
"ripple::PeerImp::finishManifestVerification : verification in "
|
||||
"flight");
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation verification_finished peer=" << id_;
|
||||
}
|
||||
|
||||
void
|
||||
PeerImp::checkValidation(
|
||||
std::shared_ptr<STValidation> const& val,
|
||||
uint256 const& key,
|
||||
std::shared_ptr<protocol::TMValidation> const& packet,
|
||||
std::optional<PendingManifest> manifestContext)
|
||||
std::shared_ptr<protocol::TMValidation> const& packet)
|
||||
{
|
||||
bool const pairedJob = manifestContext.has_value();
|
||||
// The queued job owns the moved association. This scope guard is its
|
||||
// all-exits terminal: success, refusal, and exception consume the active
|
||||
// connection token exactly once.
|
||||
scope_exit finishPairVerification([this, pairedJob]() {
|
||||
if (pairedJob)
|
||||
finishManifestVerification();
|
||||
});
|
||||
|
||||
if (!val->isValid())
|
||||
{
|
||||
std::string desc{"Validation forwarded by peer is invalid"};
|
||||
@@ -3488,100 +2981,6 @@ PeerImp::checkValidation(
|
||||
return;
|
||||
}
|
||||
|
||||
std::shared_ptr<protocol::TMManifests const> prerequisite;
|
||||
if (manifestContext)
|
||||
{
|
||||
auto manifest = deserializeManifest(
|
||||
manifestContext->message->list(0).stobject(), p_journal_);
|
||||
if (!manifest || manifest->revoked() || !manifest->signingKey ||
|
||||
manifest->masterKey != manifestContext->masterKey ||
|
||||
*manifest->signingKey != manifestContext->signingKey ||
|
||||
val->getSignerPublic() != manifestContext->signingKey ||
|
||||
!manifest->verify())
|
||||
{
|
||||
std::string const desc{
|
||||
"Validation prerequisite manifest is invalid"};
|
||||
JLOG(p_journal_.debug()) << desc;
|
||||
charge(Resource::feeInvalidSignature, desc);
|
||||
return;
|
||||
}
|
||||
|
||||
// A valid signature does not make a key association admissible. In
|
||||
// particular, an unlisted pair must not traverse ephemerally when its
|
||||
// signing key is already retained for another master. Use the same
|
||||
// key-role rules as durable cache admission before either path can
|
||||
// relay the pair.
|
||||
if (app_.validatorManifests().checkKeyRoles(*manifest))
|
||||
{
|
||||
std::string const desc{
|
||||
"Validation prerequisite manifest has conflicting key roles"};
|
||||
JLOG(p_journal_.debug()) << desc;
|
||||
charge(Resource::feeInvalidData, desc);
|
||||
return;
|
||||
}
|
||||
|
||||
auto const cacheEligible =
|
||||
app_.validators().listed(manifestContext->masterKey);
|
||||
|
||||
if (cacheEligible)
|
||||
{
|
||||
// Only current local policy may affect the durable cache in this
|
||||
// slice. Cache membership alone is not provenance.
|
||||
overlay_.onManifests(manifestContext->message, shared_from_this());
|
||||
|
||||
auto const current = app_.validatorManifests().getManifestSnapshot(
|
||||
manifestContext->signingKey);
|
||||
if (!current || current->revoked() || !current->signingKey ||
|
||||
current->masterKey != manifestContext->masterKey ||
|
||||
*current->signingKey != manifestContext->signingKey ||
|
||||
current->sequence < manifest->sequence)
|
||||
{
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation candidate_rejected peer=" << id_
|
||||
<< " reason=not_current_after_application";
|
||||
// Listed admission failed or raced with another update. Do
|
||||
// not reinterpret that failure as permission to relay the
|
||||
// same association ephemerally.
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
auto message = std::make_shared<protocol::TMManifests>();
|
||||
message->add_list()->set_stobject(current->serialized);
|
||||
prerequisite = std::move(message);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unlisted validators may still traverse a node configured to
|
||||
// relay untrusted validations, but the pair remains ephemeral.
|
||||
prerequisite = manifestContext->message;
|
||||
JLOG(p_journal_.debug())
|
||||
<< "manifest_validation candidate_ephemeral peer=" << id_
|
||||
<< " master="
|
||||
<< toBase58(TokenType::NodePublic, manifestContext->masterKey);
|
||||
}
|
||||
|
||||
if (!app_.validators().trusted(manifestContext->masterKey) &&
|
||||
app_.config().RELAY_UNTRUSTED_VALIDATIONS == -1)
|
||||
return;
|
||||
|
||||
// The pre-verification suppression identity includes both packets.
|
||||
// Only after both signatures pass may this pair claim the actual
|
||||
// validation hash. An invalid prerequisite therefore cannot poison a
|
||||
// later valid validation for the hash-router hold interval.
|
||||
if (auto [added, relayed] =
|
||||
app_.getHashRouter().addSuppressionPeerWithStatus(key, id_);
|
||||
!added)
|
||||
{
|
||||
if (reduceRelayReady() && relayed &&
|
||||
(stopwatch().now() - *relayed) < reduce_relay::IDLED)
|
||||
overlay_.updateSlotAndSquelch(
|
||||
key, val->getSignerPublic(), id_, protocol::mtVALIDATION);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME it should be safe to remove this try/catch. Investigate codepaths.
|
||||
try
|
||||
{
|
||||
@@ -3592,8 +2991,8 @@ PeerImp::checkValidation(
|
||||
// are the source of the message, consequently the message should
|
||||
// not be relayed to these peers. But the message must be counted
|
||||
// as part of the squelch logic.
|
||||
auto haveMessage = overlay_.relay(
|
||||
*packet, key, val->getSignerPublic(), prerequisite);
|
||||
auto haveMessage =
|
||||
overlay_.relay(*packet, key, val->getSignerPublic());
|
||||
if (reduceRelayReady() && !haveMessage.empty())
|
||||
{
|
||||
overlay_.updateSlotAndSquelch(
|
||||
@@ -3602,16 +3001,6 @@ PeerImp::checkValidation(
|
||||
std::move(haveMessage),
|
||||
protocol::mtVALIDATION);
|
||||
}
|
||||
|
||||
// A naked validation that just authenticated against the current
|
||||
// cached manifest is an implicit request for that manifest.
|
||||
// Repair the sender on this connection only; the strand-owned
|
||||
// repair ledger bounds it to one singleton per master/sequence.
|
||||
// Forged, malformed, and unknown-signer traffic never reaches
|
||||
// this point, and paired traffic proves the sender already holds
|
||||
// the prerequisite.
|
||||
if (!pairedJob)
|
||||
sendManifestRepairForSigningKey(val->getSignerPublic());
|
||||
}
|
||||
}
|
||||
catch (std::exception const& ex)
|
||||
|
||||
@@ -175,42 +175,6 @@ private:
|
||||
http_response_type response_;
|
||||
boost::beast::http::fields const& headers_;
|
||||
std::queue<std::shared_ptr<Message>> send_queue_;
|
||||
/** One unverified manifest awaiting a later matching validation.
|
||||
|
||||
Owner/executor: this peer's strand. Normal manifests are structurally
|
||||
decoded on receipt but are not
|
||||
signature-verified or admitted to the global cache until a validation
|
||||
on this connection claims the same signing key. An admitted claim
|
||||
moves the association into the sole in-flight verification job, which
|
||||
frees this slot for one later candidate. Access is serialized by
|
||||
strand_.
|
||||
*/
|
||||
struct PendingManifest
|
||||
{
|
||||
std::shared_ptr<protocol::TMManifests> message;
|
||||
PublicKey masterKey;
|
||||
PublicKey signingKey;
|
||||
std::uint32_t sequence;
|
||||
};
|
||||
std::optional<PendingManifest> pendingManifest_;
|
||||
|
||||
// The one active verification-obligation token for this connection. The
|
||||
// strand mints it when pending ownership moves into a job; admission
|
||||
// rollback or that job's terminal clears it exactly once. Atomic because
|
||||
// the job terminal executes on a JobQueue worker, not the peer strand.
|
||||
std::atomic_bool manifestVerificationInFlight_{false};
|
||||
|
||||
// The bounded assertion ledger for this connection. Every retained
|
||||
// manifest sent here records its master/sequence. Backward repair hints
|
||||
// therefore deduplicate against prior prerequisites and each other, and a
|
||||
// terminal response can prove it answers state this connection actually
|
||||
// asserted. Ephemeral pairs do not allocate rows.
|
||||
// Owner/executor: this peer's strand. The ledger is cleared wholesale only
|
||||
// when a new master would exceed the cap; forgetting is safe and may cost
|
||||
// one duplicate repair or one missed best-effort correction.
|
||||
static constexpr std::size_t maxManifestAssertionEntries = 256;
|
||||
hash_map<PublicKey, std::uint32_t> manifestAssertionSequences_;
|
||||
|
||||
bool gracefulClose_ = false;
|
||||
int large_sendq_ = 0;
|
||||
std::unique_ptr<LoadEvent> load_event_;
|
||||
@@ -478,14 +442,6 @@ public:
|
||||
return txReduceRelayEnabled_;
|
||||
}
|
||||
|
||||
protected:
|
||||
/** Dispatch derived-class work through this peer's serialized executor. */
|
||||
void
|
||||
dispatchOnStrand(std::function<void()> work)
|
||||
{
|
||||
boost::asio::dispatch(strand_, std::move(work));
|
||||
}
|
||||
|
||||
private:
|
||||
void
|
||||
close();
|
||||
@@ -530,18 +486,6 @@ private:
|
||||
void
|
||||
doProtocolStart();
|
||||
|
||||
/** Send a validation after its current manifest on this connection.
|
||||
|
||||
Both existing protocol envelopes are enqueued on strand_ in wire
|
||||
order. No receiver-side association or acknowledgement is assumed, so
|
||||
an available prerequisite is sent with every validation.
|
||||
*/
|
||||
void
|
||||
sendValidation(
|
||||
std::shared_ptr<Message> const& validation,
|
||||
PublicKey const& signingKey,
|
||||
std::shared_ptr<protocol::TMManifests const> const& prerequisite = {});
|
||||
|
||||
// Called when protocol message bytes are received
|
||||
void
|
||||
onReadMessage(error_code ec, std::size_t bytes_transferred);
|
||||
@@ -691,45 +635,7 @@ private:
|
||||
checkValidation(
|
||||
std::shared_ptr<STValidation> const& val,
|
||||
uint256 const& key,
|
||||
std::shared_ptr<protocol::TMValidation> const& packet,
|
||||
std::optional<PendingManifest> manifestContext);
|
||||
|
||||
/** Consume the connection's sole active verification-obligation token.
|
||||
|
||||
This is the only terminal operation for both failed job admission and
|
||||
every exit from an admitted verification job.
|
||||
*/
|
||||
void
|
||||
finishManifestVerification();
|
||||
|
||||
/** Return a cached manifest to the peer that sent its validation naked.
|
||||
|
||||
A naked validation that has authenticated against the current cached
|
||||
manifest for its signing key is an implicit request for that
|
||||
manifest. The strand consults the bounded repair ledger and sends at
|
||||
most one singleton per master/sequence on this connection. Callable
|
||||
from a verification job; the send hops to the strand.
|
||||
*/
|
||||
void
|
||||
sendManifestRepairForSigningKey(PublicKey const& signingKey);
|
||||
|
||||
void
|
||||
sendManifestRepair(
|
||||
PublicKey const& masterKey,
|
||||
std::uint32_t sequence,
|
||||
std::string serialized);
|
||||
|
||||
void
|
||||
recordManifestAssertion(PublicKey const& masterKey, std::uint32_t sequence);
|
||||
|
||||
bool
|
||||
assertedOlderManifest(PublicKey const& masterKey, std::uint32_t sequence)
|
||||
const;
|
||||
|
||||
void
|
||||
sendManifestAssertions(
|
||||
std::shared_ptr<Message> const& message,
|
||||
std::vector<std::pair<PublicKey, std::uint32_t>> assertions);
|
||||
std::shared_ptr<protocol::TMValidation> const& packet);
|
||||
|
||||
void
|
||||
sendLedgerBase(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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&);
|
||||
|
||||
239
src/xrpld/rpc/handlers/SubmitJsonTx.cpp
Normal file
239
src/xrpld/rpc/handlers/SubmitJsonTx.cpp
Normal 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
|
||||
@@ -76,6 +76,7 @@ doSubscribe(RPC::JsonContext& context)
|
||||
{
|
||||
auto rspSub = make_RPCSub(
|
||||
context.app.getOPs(),
|
||||
context.app.getIOService(),
|
||||
context.app.getJobQueue(),
|
||||
strUrl,
|
||||
strUsername,
|
||||
|
||||
Reference in New Issue
Block a user