diff --git a/.github/scripts/levelization/results/ordering.txt b/.github/scripts/levelization/results/ordering.txt index b54308853c..ee92c765ca 100644 --- a/.github/scripts/levelization/results/ordering.txt +++ b/.github/scripts/levelization/results/ordering.txt @@ -117,6 +117,7 @@ test.jtx > xrpl.config test.jtx > xrpl.core test.jtx > xrpld.app test.jtx > xrpld.core +test.jtx > xrpld.overlay test.jtx > xrpld.rpc test.jtx > xrpl.json test.jtx > xrpl.ledger diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8721ad7863..7212214f01 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,6 +53,11 @@ repos: entry: ./bin/pre-commit/check_doxygen_style.py language: python types_or: [c++, c] + - id: fix-gtest-names + name: "fix gtest names: CamelCase suite, snake_case test case" + entry: ./bin/pre-commit/fix_gtest_names.py + language: python + types_or: [c++, c] - repo: https://github.com/pre-commit/mirrors-clang-format rev: f4d7745e17a28aad7eed2f4874ca8d1568c11c4c # frozen: v22.1.8 diff --git a/OpenTelemetryPlan/00-tracing-fundamentals.md b/OpenTelemetryPlan/00-tracing-fundamentals.md index 1c7675243a..44ff504bcc 100644 --- a/OpenTelemetryPlan/00-tracing-fundamentals.md +++ b/OpenTelemetryPlan/00-tracing-fundamentals.md @@ -209,7 +209,9 @@ flowchart LR subgraph links["Span Links"] direction TB - X["Span X\n(Trace 1)"] -.-|link| Y["Span Y\n(Trace 2)"] + X["`Span X +(Trace 1)`"] -.-|link| Y["`Span Y +(Trace 2)`"] end parent_child ~~~ follows_from ~~~ links @@ -354,9 +356,11 @@ flowchart TB Fn["trace_id = f(ledger_hash)"]:::note --> F1["fetch.request"] --> F2["fetch.receive"] --> F3["fetch.apply"] end - C1 -.-|"span link\n(tx traces)"| T3 + C1 -.-|"`span link +(tx traces)`"| T3 C3 --> V1 - F1 -.-|"span link\n(target ledger)"| C3 + F1 -.-|"`span link +(target ledger)`"| C3 classDef note fill:none,stroke:#888,stroke-dasharray:5 5,color:#333,font-style:italic style T1 fill:#0d47a1,stroke:#082f6a,color:#ffffff diff --git a/OpenTelemetryPlan/02-design-decisions.md b/OpenTelemetryPlan/02-design-decisions.md index c5c25cea75..b88040ba89 100644 --- a/OpenTelemetryPlan/02-design-decisions.md +++ b/OpenTelemetryPlan/02-design-decisions.md @@ -285,16 +285,17 @@ via `resource/stripsdk`. See [05 §5.5.1](./05-configuration-reference.md). #### Transaction Attributes -| Key | Type | Description | -| -------------------- | ------ | ------------------------------------- | -| `tx_hash` | string | Transaction hash (hex) | -| `tx_type` | string | `"Payment"`, `"OfferCreate"`, etc. | -| `tx_account` | string | Source account (redacted in prod) | -| `tx_sequence` | int64 | Account sequence number | -| `tx_fee` | int64 | Fee in drops | -| `tx_result` | string | `"tesSUCCESS"`, `"tecPATH_DRY"`, etc. | -| `current_ledger_seq` | int64 | Open ledger the transaction targeted | -| `relay_count` | int64 | Peers the transaction was relayed to | +| Key | Type | Description | +| -------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tx_hash` | string | Transaction hash (hex) | +| `tx_type` | string | `"Payment"`, `"OfferCreate"`, etc. | +| `tx_account` | string | Sending account, raw r-address | +| `tx_` | string | One per account-typed top-level field the transaction carries (`tx_destination`, `tx_owner`, `tx_issuer`, ...), raw r-address; keys in `TxAccountSpanNames.h` | +| `tx_sequence` | int64 | Account sequence number | +| `tx_fee` | int64 | Fee in drops | +| `tx_result` | string | `"tesSUCCESS"`, `"tecPATH_DRY"`, etc. | +| `current_ledger_seq` | int64 | Open ledger the transaction targeted | +| `relay_count` | int64 | Peers the transaction was relayed to | > **Note:** `current_ledger_seq` and `ledger_seq` are the same concept — a ledger's sequence number — but they name different ledgers, so the design keeps two keys rather than one. `current_ledger_seq` is the open or in-flight ledger a transaction's work was applied into; it is named after the RPC field `ledger_current_index`. `ledger_seq` (see [Ledger & Job Attributes](#ledger--job-attributes)) is a closed or validated ledger, set by the ledger and consensus spans. Neither is spelled `ledger_index`: per rule 2 of [Telemetry span attribute naming](../CONTRIBUTING.md#telemetry-span-attribute-naming), one concept gets one key reused verbatim, and a different referent is disambiguated with a prefix rather than a synonym. @@ -365,12 +366,14 @@ Establish-phase gap fill and cross-node correlation attributes (Phase 4a): #### PathFinding Attributes -| Key | Type | Description | -| -------------------------- | ------ | ------------------------- | -| `pathfind_source_currency` | string | Source currency code | -| `pathfind_dest_currency` | string | Destination currency code | -| `pathfind_path_count` | int64 | Number of paths found | -| `pathfind_cache_hit` | bool | RippleLineCache hit | +| Key | Type | Description | +| -------------------------- | ------ | ---------------------------------------------------------------------- | +| `pathfind_source_account` | string | Source r-address, raw | +| `pathfind_dest_account` | string | Destination r-address, raw | +| `pathfind_source_currency` | string | Source currency code | +| `pathfind_dest_currency` | string | Destination asset: `XRP`, `/`, or an MPT issuance id | +| `pathfind_path_count` | int64 | Number of paths found | +| `pathfind_cache_hit` | bool | RippleLineCache hit | #### TxQ Attributes @@ -523,36 +526,35 @@ The following data is explicitly **excluded** from telemetry collection: | Mechanism | Description | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Account Hashing** | Account addresses are hashed both SDK-side (`pathfind_source_account`, `pathfind_dest_account` — always hashed before emission) and again at the collector level, so raw addresses never reach storage | -| **Unconditional Redaction** | Account redaction is **not** configurable and cannot be turned off: `redactAccount()` (`Redaction.cpp:14-29`) hashes every **non-empty** address handed to it, with no flag and no bypass (an empty input returns empty — `Redaction.cpp:18-19` — so there is no raw value to leak either way). That is a stronger guarantee than a config switch: there is no insecure-by-default state to misconfigure | +| **Account Addresses** | Emitted raw (`pathfind_source_account`, `pathfind_dest_account`). An account address is a public ledger identifier; hashing it protects nothing and breaks the join against explorers, RPC and logs | | **Collector Tail Sampling** | **Optional, and OFF in the base stack.** xrpld head sampling is fixed at 1.0 (`Telemetry.h:234` `static constexpr double samplingRatio = 1.0;`), so 100% of traces leave the node. `docker/telemetry/otel-collector-config.yaml` has **no** `tail_sampling` processor either, so the local stack stores 100%. The only shipped policy is in the Grafana Cloud overlay (`otel-collector-config.grafanacloud.yaml:60-67`, wired at `:261`): one `probabilistic` policy at **0.5%**, on the trace-storage branch only so spanmetrics still see every span. Treat sampling as a cost control you opt into — not as a privacy control | | **Local Control** | Node operators have full control over what gets exported | | **No Raw Payloads** | Transaction content is never recorded, only metadata (hash, type, result) | -| **Collector-Level Filtering** | Additional redaction/hashing can be configured at OTel Collector | +| **Collector-Level Filtering** | Available for a future genuinely sensitive attribute via an `attributes` processor. None is shipped, and none must be added for account addresses | -#### Account Address Hashing +#### Account Addresses -Account addresses are **always** hashed before they reach the telemetry -backend — there is no opt-out flag and therefore no insecure-by-default -failure mode. Protection is applied in two independent layers: +Account addresses are emitted **raw**, at every layer: -1. **SDK-side** (this node): the path-finding RPC handlers call - `redactAccount()` (`xrpl::telemetry`, `Redaction.h`) before setting the - `pathfind_source_account` / `pathfind_dest_account` span attributes. For a - non-empty address the helper emits the first 16 characters of - `sha512Half(address)` as lowercase hex — deterministic (spans for one - account still correlate) but non-reversible. An empty address returns empty - rather than the hash of the empty string (`Redaction.cpp:18-19`). -2. **Collector-side** (defense-in-depth): an `attributes/hash` processor in - the OpenTelemetry Collector re-hashes those same attributes, so any node - that emitted a raw value is still redacted before storage. +1. **SDK-side** (this node): the path-finding RPC handlers set + `pathfind_source_account` / `pathfind_dest_account` to the request's + r-address, only when it parses as one, and `pathfind_dest_currency` to `to_string(Asset)`, + which carries the IOU issuer's r-address. The rationale sits on the attribute + constants in `PathFindSpanNames.h`. +2. **Collector-side**: no collector configuration in this repository hashes + or deletes these attributes. + +Why raw: an r-address is a public, enumerable identifier on the ledger. An +unsalted hash of it is reversible by table lookup, so it protects nothing, and +it breaks the one thing the attribute is for: joining a span to the account as +explorers, RPC responses and logs show it. The helper `redactAccount()` +(`xrpl::telemetry`, `Redaction.h`) remains available for a value that is +genuinely private, but it is applied to no span. #### Collector-Level Data Protection -The shipped base config does exactly one thing here, and it is the -defense-in-depth layer described above: an `attributes/hash` processor -(`otel-collector-config.yaml:105-110`) hashing `pathfind_source_account` and -`pathfind_dest_account`. +No hashing or redaction processor is shipped: account addresses are public +identifiers and stay as emitted. **No `peer_address` or `params` scrubbing rule is needed on the trace pipeline, and none is shipped.** Earlier drafts prescribed `delete` actions for both. @@ -578,12 +580,10 @@ should be added to the §2.4 catalogue in the same change. In `xrpld.cfg`, operators control data collection granularity through the `[telemetry]` section. Besides `enabled`, per-component toggles (`trace_transactions`, `trace_consensus`, `trace_rpc`, `trace_peer` — the last -often disabled due to high volume) select which spans are emitted. Account -address hashing is not configurable: addresses are hashed unconditionally by -the SDK helper described above, with collector-level hashing as a second -layer. +often disabled due to high volume) select which spans are emitted. There is no +redaction setting: account addresses are public and are emitted raw. -> **Key Principle**: Telemetry collects **operational metadata** (timing, counts, hashes) — never **sensitive content** (keys, balances, amounts, raw payloads). +> **Key Principle**: Telemetry collects **operational metadata** (timing, counts, hashes, public identifiers) — never **sensitive content** (keys, balances, amounts, raw payloads). --- diff --git a/OpenTelemetryPlan/03-implementation-strategy.md b/OpenTelemetryPlan/03-implementation-strategy.md index 5eb5750905..ea82406cad 100644 --- a/OpenTelemetryPlan/03-implementation-strategy.md +++ b/OpenTelemetryPlan/03-implementation-strategy.md @@ -27,7 +27,7 @@ include/xrpl/telemetry/ # libxrpl layer: tracing SDK wrapper ├── DeterministicIdGenerator.h # trace_id from txHash / prevLedgerHash ├── TraceContextPropagator.h # protobuf TraceContext inject/extract (P2P) ├── TraceContextValidation.h # Validation of peer-supplied trace context -├── Redaction.h # redactAccount() — unconditional address hashing +├── Redaction.h # redactAccount() — hashing helper, applied to no span └── GetObjectMetricNames.h # getobject_* metric name constants src/libxrpl/telemetry/ diff --git a/OpenTelemetryPlan/05-configuration-reference.md b/OpenTelemetryPlan/05-configuration-reference.md index 95179dba85..8ff8d68c3a 100644 --- a/OpenTelemetryPlan/05-configuration-reference.md +++ b/OpenTelemetryPlan/05-configuration-reference.md @@ -235,11 +235,11 @@ The authoritative collector config lives in the repo at `docker/telemetry/otel-c `docker/telemetry/otel-collector-config.yaml` is the base config used by the local stack and by CI. It carries **three** pipelines, not one: -| Pipeline | Receivers | Processors | Exporters | -| --------- | ---------------------- | ---------------------------------------------------------------- | ------------------------------------------ | -| `traces` | `otlp` | `resource/tier`, `resource/stripsdk`, `attributes/hash`, `batch` | `debug`, `otlp_grpc/tempo`, `span_metrics` | -| `metrics` | `otlp`, `span_metrics` | `resource/tier`, `resource/stripsdk`, `batch` | `prometheus` | -| `logs` | `file_log` | `resource/logs`, `resource/tier`, `resource/stripsdk`, `batch` | `otlp_http/loki` | +| Pipeline | Receivers | Processors | Exporters | +| --------- | ---------------------- | -------------------------------------------------------------- | ------------------------------------------ | +| `traces` | `otlp` | `resource/tier`, `resource/stripsdk`, `batch` | `debug`, `otlp_grpc/tempo`, `span_metrics` | +| `metrics` | `otlp`, `span_metrics` | `resource/tier`, `resource/stripsdk`, `batch` | `prometheus` | +| `logs` | `file_log` | `resource/logs`, `resource/tier`, `resource/stripsdk`, `batch` | `otlp_http/loki` | Component detail: @@ -253,8 +253,8 @@ Component detail: `xrpl.network.type` only when absent); `resource/stripsdk` (drops the `telemetry.sdk.*` attributes); `resource/logs` (`action: upsert` on `service.name` and `job` — only the former becomes a Loki stream label, see - the known issue in §5.8.5); `attributes/hash` (hashes - `pathfind_source_account` and `pathfind_dest_account`). + the known issue in §5.8.5). No processor hashes or drops span attributes: + account addresses are public identifiers and are stored as emitted. - **Connector.** `span_metrics` with `namespace: "span"` (`otel-collector-config.yaml:114`) — this is why the derived RED metrics are `span_calls_total` / `span_duration_milliseconds_*`. The connector's own @@ -280,8 +280,7 @@ Component detail: Deliberately absent from the base config — do not document them as present: no `memory_limiter`, no `tail_sampling`, no Elastic APM exporter, and no -`tx_account` attribute rule (the hashed keys are the two `pathfind_*_account` -ones). +attribute hashing or redaction rule (account addresses are emitted raw). ### 5.5.2 Production Configuration @@ -298,10 +297,9 @@ graph. The full delta: | `otlp_http/grafanacloud` | `:236` | Single OTLP/HTTP exporter fanning all three signals to Grafana Cloud | | `metrics_flush_interval` | `:136` | `spanmetrics` flushes every 15s instead of the 60s default | -| Removed by the overlay | Consequence | -| ---------------------- | ---------------------------------------------------------------------------- | -| `attributes/hash` | **Pathfinding account attributes are not hashed on this config** — see below | -| `debug` | No console span dump; collector logs alone when diagnosing ingest | +| Removed by the overlay | Consequence | +| ---------------------- | ----------------------------------------------------------------- | +| `debug` | No console span dump; collector logs alone when diagnosing ingest | Pipelines go from **three** (`traces`, `metrics`, `logs`) to **five** (`:253-280`): `traces/metrics`, `traces/store`, `metrics/local`, @@ -311,16 +309,6 @@ named `traces`, which does not exist in the overlay. The `traces/metrics` branch feeds `spanmetrics` unsampled, so the derived RED metrics stay exact while stored traces are ~1/200 of ingested ones. -> **Known issue — the cloud path does not hash pathfinding accounts.** The base -> config runs `attributes/hash` on its `traces` pipeline -> (`otel-collector-config.yaml:105-110`), hashing `pathfind_source_account` and -> `pathfind_dest_account` as defense in depth behind the node-side hashing. The -> overlay declares no such processor and lists none on any of its five -> pipelines, so on the Grafana Cloud config those two attributes reach **both** -> Grafana Cloud and the local Tempo with whatever value the node sent. Any node -> that emits raw addresses loses its second line of defense. Adding -> `attributes/hash` to `traces/store` and `traces/metrics` would close the gap. - Hardening a collector for a real deployment (TLS/mTLS on the receiver, NetworkPolicy, peer trace-context validation) is covered in [Securing the OTel Pipeline](./secure-OTel.md) — not by any config file in diff --git a/OpenTelemetryPlan/09-data-collection-reference.md b/OpenTelemetryPlan/09-data-collection-reference.md index 20ef33c776..992b88cada 100644 --- a/OpenTelemetryPlan/09-data-collection-reference.md +++ b/OpenTelemetryPlan/09-data-collection-reference.md @@ -345,22 +345,24 @@ The tables below list one row per attribute per subsystem, so a key shared by tw #### Transaction Attributes -| Attribute | Type | Set On | Description | -| --------------------- | ------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -| `tx_hash` | string | `tx.process`, `tx.receive` | Transaction hash (hex-encoded) | -| `local` | boolean | `tx.process` | `true` if locally submitted, `false` if peer-relayed | -| `path` | string | `tx.process` | Submission path: `"sync"` or `"async"` | -| `tx_type` | string | `tx.process`, `tx.preflight`, `tx.preclaim`, `tx.transactor` | Transaction type name (e.g., `Payment`) | -| `fee` | int64 | `tx.process` | Transaction fee in drops | -| `sequence` | int64 | `tx.process` | Transaction sequence number | -| `tx_status` | string | `tx.receive` | Transaction status (e.g., `"known_bad"`) | -| `peer_id` | int64 | `tx.receive` | Peer identifier (also set on peer spans) | -| `peer_version` | string | `tx.receive` | Peer protocol version string | -| `stage` | string | `tx.preflight`, `tx.preclaim`, `tx.transactor` | Apply-pipeline stage: `preflight`, `preclaim`, or `apply` | -| `ter_result` | string | `tx.preflight`, `tx.preclaim`, `tx.transactor` | Engine result token for that stage (e.g., `tesSUCCESS`, `terPRE_SEQ`) | -| `applied` | boolean | `tx.transactor` | `true` if the transaction was applied to the ledger | -| `current_ledger_seq` | int64 | `tx.process`, `tx.receive`, `tx.preclaim`, `tx.transactor` | Seq of the ledger being worked on (open/in-flight, not established) — joins the txID-keyed spans to the ledger trace | -| `current_ledger_hash` | string | `tx.preclaim`, `tx.transactor` | Parent hash of that ledger (= `consensus.round` trace-id seed on the build path). View-bearing stages only; `tx.preflight` omits both | +| Attribute | Type | Set On | Description | +| --------------------- | ------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| `tx_hash` | string | `tx.process`, `tx.receive` | Transaction hash (hex-encoded) | +| `local` | boolean | `tx.process` | `true` if locally submitted, `false` if peer-relayed | +| `path` | string | `tx.process` | Submission path: `"sync"` or `"async"` | +| `tx_type` | string | `tx.process`, `tx.preflight`, `tx.preclaim`, `tx.transactor` | Transaction type name (e.g., `Payment`) | +| `fee` | int64 | `tx.process` | Transaction fee in drops | +| `sequence` | int64 | `tx.process` | Transaction sequence number | +| `tx_account` | string | `tx.process` | Sending account, raw r-address | +| `tx_` | string | `tx.process` | One per other account-typed top-level field the transaction carries (`tx_destination`, `tx_owner`, ...); keys in `TxAccountSpanNames.h` | +| `tx_status` | string | `tx.receive` | Transaction status (e.g., `"known_bad"`) | +| `peer_id` | int64 | `tx.receive` | Peer identifier (also set on peer spans) | +| `peer_version` | string | `tx.receive` | Peer protocol version string | +| `stage` | string | `tx.preflight`, `tx.preclaim`, `tx.transactor` | Apply-pipeline stage: `preflight`, `preclaim`, or `apply` | +| `ter_result` | string | `tx.preflight`, `tx.preclaim`, `tx.transactor` | Engine result token for that stage (e.g., `tesSUCCESS`, `terPRE_SEQ`) | +| `applied` | boolean | `tx.transactor` | `true` if the transaction was applied to the ledger | +| `current_ledger_seq` | int64 | `tx.process`, `tx.receive`, `tx.preclaim`, `tx.transactor` | Seq of the ledger being worked on (open/in-flight, not established) — joins the txID-keyed spans to the ledger trace | +| `current_ledger_hash` | string | `tx.preclaim`, `tx.transactor` | Parent hash of that ledger (= `consensus.round` trace-id seed on the build path). View-bearing stages only; `tx.preflight` omits both | **Tempo query**: `{span.tx_hash=""}` to trace a specific transaction across nodes. Join a transaction's work to its ledger with `{span.current_ledger_seq=}`. @@ -522,8 +524,8 @@ a destructor must not depend on still existing. A query that only groups by | Attribute | Type | Set On | Description | | ------------------------- | ------- | --------------------- | ---------------------------------------- | -| `pathfind_source_account` | string | `pathfind.request` | Originating account for the path search | -| `pathfind_dest_account` | string | `pathfind.request` | Destination account | +| `pathfind_source_account` | string | `pathfind.request` | Originating account, raw r-address | +| `pathfind_dest_account` | string | `pathfind.request` | Destination account, raw r-address | | `pathfind_fast` | boolean | `pathfind.compute` | Whether fast pathfinding mode is enabled | | `pathfind_search_level` | int64 | `pathfind.discover` | Depth of graph exploration | | `pathfind_num_paths` | int64 | `pathfind.discover` | Total paths produced | @@ -2263,6 +2265,7 @@ The telemetry system is designed with privacy in mind: - **No private keys** are ever included in spans or metrics - **No account balances** or financial data is traced - **Transaction hashes** are included (public on-ledger data) but not transaction contents +- **Account addresses** are public ledger identifiers and are emitted raw, never hashed; a request value is emitted only when it parses as an r-address - **Peer IDs** are internal identifiers, not IP addresses - **All telemetry is opt-in** — disabled by default at build time (`-Dtelemetry=OFF`) - **Sampling** — head sampling is fixed at 1.0 (sample everything); reduce data volume with collector-side tail sampling diff --git a/OpenTelemetryPlan/OpenTelemetryPlan.md b/OpenTelemetryPlan/OpenTelemetryPlan.md index 223ec5181f..a9ed74dba8 100644 --- a/OpenTelemetryPlan/OpenTelemetryPlan.md +++ b/OpenTelemetryPlan/OpenTelemetryPlan.md @@ -138,7 +138,7 @@ The OpenTelemetry C++ SDK is selected for its CNCF backing, active development, Span naming follows a hierarchical `.` convention (e.g., `rpc.command.server_info`, `tx.process`, `consensus.round`). Context propagation uses W3C Trace Context headers for HTTP and embedded Protocol Buffer fields for P2P messages. The implementation coexists with existing PerfLog and Insight observability systems through correlation IDs. -**Data Collection & Privacy**: Telemetry collects only operational metadata (timing, counts, hashes) — never sensitive content (private keys, balances, amounts, raw payloads). Account addresses are hashed **unconditionally** by the SDK helper and hashed again at the collector; there is no redaction config key and therefore no insecure-by-default state. Trace volume is _not_ reduced on the node (head sampling is fixed at 100%); reduction, where wanted, is a collector-side tail-sampling decision. Node operators control which subsystems are traced via the `[telemetry]` per-component toggles. +**Data Collection & Privacy**: Telemetry collects only operational metadata (timing, counts, hashes, public identifiers) — never sensitive content (private keys, balances, amounts, raw payloads). Account addresses are public ledger identifiers and are emitted raw; there is no redaction setting and no collector-side hashing. Trace volume is _not_ reduced on the node (head sampling is fixed at 100%); reduction, where wanted, is a collector-side tail-sampling decision. Node operators control which subsystems are traced via the `[telemetry]` per-component toggles. ➡️ **[Read full Design Decisions](./02-design-decisions.md)** diff --git a/OpenTelemetryPlan/Phase3_taskList.md b/OpenTelemetryPlan/Phase3_taskList.md index 96d54870a5..017910930b 100644 --- a/OpenTelemetryPlan/Phase3_taskList.md +++ b/OpenTelemetryPlan/Phase3_taskList.md @@ -500,24 +500,25 @@ This gives the best of both worlds: guaranteed cross-node correlation via determ **Attributes added**: -| Span | Attribute | Type | Source | -| ----------------- | -------------------- | ------ | ------------------------------------------------------------------- | -| `tx.process` | `tx_type` | string | `TxFormats::getInstance().findByType(stx->getTxnType())->getName()` | -| `tx.process` | `fee` | int64 | `stx->getFieldAmount(sfFee).xrp().drops()` | -| `tx.process` | `sequence` | int64 | `stx->getSeqProxy().value()` | -| `tx.process` | `ter_result` | string | `transToken(e.result)` (set after batch application) | -| `tx.process` | `applied` | bool | `e.applied` (set after batch application) | -| `tx.receive` | `tx_type` | string | `TxFormats::getInstance().findByType(stx->getTxnType())->getName()` | -| `txq.enqueue` | `tx_type` | string | same pattern as above | -| `txq.enqueue` | `txq_status` | string | `queued` / `applied_direct` / `applied` / `failed` / `rejected` | -| `txq.enqueue` | `ter_code` | string | `transToken(directApplied->ter)` (set on the direct-apply path) | -| `txq.enqueue` | `fee_level_paid` | int64 | `getFeeLevelPaid(view, *tx).value()` | -| `txq.enqueue` | `required_fee_level` | int64 | `getRequiredFeeLevel(...).value()` | -| `txq.batch_clear` | `num_cleared` | int64 | queued txs cleared ahead of the applying tx | -| `txq.cleanup` | `expired_count` | int64 | entries dropped for passed `LastLedgerSequence` | -| `txq.accept_tx` | `txq_status` | string | `applied` / `failed` / `retried` | -| `txq.accept_tx` | `ter_code` | string | `transToken(txnResult)` (set before branching on the outcome) | -| `txq.accept` | `ledger_changed` | bool | set at end of accept loop | +| Span | Attribute | Type | Source | +| ----------------- | -------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------- | +| `tx.process` | `tx_type` | string | `TxFormats::getInstance().findByType(stx->getTxnType())->getName()` | +| `tx.process` | `fee` | int64 | `stx->getFieldAmount(sfFee).xrp().drops()` | +| `tx.process` | `sequence` | int64 | `stx->getSeqProxy().value()` | +| `tx.process` | `tx_` | string | one per top-level `STI_ACCOUNT` field, raw r-address (`tx_account`, `tx_destination`, ...); keys in `TxAccountSpanNames.h` | +| `tx.process` | `ter_result` | string | `transToken(e.result)` (set after batch application) | +| `tx.process` | `applied` | bool | `e.applied` (set after batch application) | +| `tx.receive` | `tx_type` | string | `TxFormats::getInstance().findByType(stx->getTxnType())->getName()` | +| `txq.enqueue` | `tx_type` | string | same pattern as above | +| `txq.enqueue` | `txq_status` | string | `queued` / `applied_direct` / `applied` / `failed` / `rejected` | +| `txq.enqueue` | `ter_code` | string | `transToken(directApplied->ter)` (set on the direct-apply path) | +| `txq.enqueue` | `fee_level_paid` | int64 | `getFeeLevelPaid(view, *tx).value()` | +| `txq.enqueue` | `required_fee_level` | int64 | `getRequiredFeeLevel(...).value()` | +| `txq.batch_clear` | `num_cleared` | int64 | queued txs cleared ahead of the applying tx | +| `txq.cleanup` | `expired_count` | int64 | entries dropped for passed `LastLedgerSequence` | +| `txq.accept_tx` | `txq_status` | string | `applied` / `failed` / `retried` | +| `txq.accept_tx` | `ter_code` | string | `transToken(txnResult)` (set before branching on the outcome) | +| `txq.accept` | `ledger_changed` | bool | set at end of accept loop | **New attr keys**: `TxSpanNames.h` (`txType`, `fee`, `sequence`, `terResult`, `applied`), `TxQSpanNames.h` (`txType`). diff --git a/bin/pre-commit/fix_gtest_names.py b/bin/pre-commit/fix_gtest_names.py new file mode 100755 index 0000000000..7dbbdf63ba --- /dev/null +++ b/bin/pre-commit/fix_gtest_names.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 + +""" +Rewrites gtest names to the required style in this project: the suite name is +CamelCase, the test-case name is snake_case. + + TEST(SuiteName, test_case_name) + +The gtest `DISABLED_` prefix is kept verbatim on either name. + +Both conversions fold acronyms the way a reader expects: +`SetAndResetAccountTxnID` -> `set_and_reset_account_txn_id`, not +`set_and_reset_account_txn_i_d`. + +The first argument of `TEST_F`, `TEST_P`, `TYPED_TEST` and `TYPED_TEST_P` is a +fixture class rather than a free identifier, so rewriting it here would leave +the class it names behind. Those are reported for a human to rename (clang-tidy +checks the class declaration itself, via readability-identifier-naming). + +Usage: ./bin/pre-commit/fix_gtest_names.py ... +""" + +import re +import sys +from collections import Counter +from pathlib import Path + +# A test-case definition, `MACRO(SuiteOrFixture, TestName)`, anchored at the +# start of a line so that commented-out definitions and project macros that +# merely look similar (`TEST_EXPECT(...)`) are left alone. The `\s*` between +# arguments allows for a definition clang-format wrapped over several lines. +PATTERN = re.compile( + r"(?P^[ \t]*(?PTYPED_TEST_P|TYPED_TEST|TEST_F|TEST_P|TEST)\s*\(\s*)" + r"(?P\w+)(?P\s*,\s*)(?P\w+)(?P\s*\))", + re.MULTILINE, +) + +# The macros whose first argument names a fixture class, not a free identifier. +FIXTURE_MACROS = ("TEST_F", "TEST_P", "TYPED_TEST", "TYPED_TEST_P") + +DISABLED = "DISABLED_" + +ACRONYM_BOUNDARY = re.compile(r"([A-Z]+)([A-Z][a-z])") +WORD_BOUNDARY = re.compile(r"([a-z\d])([A-Z])") + + +def _split_disabled(name: str) -> tuple[str, str]: + """Splits off gtest's `DISABLED_` prefix, which is kept verbatim.""" + if name.startswith(DISABLED): + return DISABLED, name[len(DISABLED) :] + return "", name + + +def snake_case(name: str) -> str: + """Returns the name in snake_case, leaving acronyms whole. + + `SetAndResetAccountTxnID` -> `set_and_reset_account_txn_id`, + `parseStatRSSkB` -> `parse_stat_rs_sk_b`. + """ + prefix, core = _split_disabled(name) + core = ACRONYM_BOUNDARY.sub(r"\1_\2", core) + return prefix + WORD_BOUNDARY.sub(r"\1_\2", core).lower() + + +def camel_case(name: str) -> str: + """Returns the name in CamelCase, capitalizing each underscored word. + + Only the letters that have to change are touched, so acronyms survive: a + conversion that went via snake_case would turn `SHAMapTest` into + `ShaMapTest`, whereas here it is already CamelCase and stays put. + `json_value` -> `JsonValue`, `parseStatRSSkB` -> `ParseStatRSSkB`. + """ + prefix, core = _split_disabled(name) + return prefix + "".join(w[:1].upper() + w[1:] for w in core.split("_") if w) + + +def _corrected(match: re.Match) -> tuple[str, str]: + """Returns the suite and test-case names this definition should end up with.""" + suite = match["suite"] + return ( + suite if match["macro"] in FIXTURE_MACROS else camel_case(suite), + snake_case(match["name"]), + ) + + +def fix_source(text: str) -> tuple[str, list[str]]: + """Returns the corrected text and one `line: message` report per bad name.""" + # gtest joins the suite and test names into one class name, so two test + # cases whose joined names agree cannot coexist: `TEST(a, b_c)` and + # `TEST(a_b, c)` both define `a_b_c_Test`. A rename that would introduce + # such a clash is reported for a human instead of applied. + joined = Counter("_".join(_corrected(m)) for m in PATTERN.finditer(text)) + reports = [] + + def rewrite(match: re.Match) -> str: + suite, name = match["suite"], match["name"] + new_suite, new_name = _corrected(match) + line = text.count("\n", 0, match.start()) + 1 + + if match["macro"] in FIXTURE_MACROS and camel_case(suite) != suite: + reports.append( + f"{line}: fixture '{suite}' is not CamelCase: rename the class " + f"to '{camel_case(suite)}' by hand" + ) + if (new_suite, new_name) == (suite, name): + return match[0] + if joined[f"{new_suite}_{new_name}"] > 1: + reports.append( + f"{line}: cannot rename '{suite}, {name}' to '{new_suite}, " + f"{new_name}': another test case already generates that name" + ) + return match[0] + if new_suite != suite: + reports.append(f"{line}: renamed suite '{suite}' to '{new_suite}'") + if new_name != name: + reports.append(f"{line}: renamed test case '{name}' to '{new_name}'") + return match["head"] + new_suite + match["mid"] + new_name + match["tail"] + + return PATTERN.sub(rewrite, text), reports + + +def fix_names(path: Path) -> bool: + """Corrects one file's gtest names, reporting each on stdout.""" + original = path.read_text(encoding="utf-8") + fixed, reports = fix_source(original) + for report in reports: + print(f"{path}:{report}") + if fixed != original: + path.write_text(fixed, encoding="utf-8") + return not reports + + +def main() -> int: + files = [Path(f) for f in sys.argv[1:]] + success = True + + for path in files: + success &= fix_names(path) + + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/pre-commit/test_fix_gtest_names.py b/bin/pre-commit/test_fix_gtest_names.py new file mode 100755 index 0000000000..aa0481f795 --- /dev/null +++ b/bin/pre-commit/test_fix_gtest_names.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +Tests for fix_gtest_names.py. + +Run directly (no test framework needed): + ./bin/pre-commit/test_fix_gtest_names.py +or under pytest: + pytest bin/pre-commit/test_fix_gtest_names.py +""" + +import sys +import textwrap + +from fix_gtest_names import camel_case, fix_source, snake_case + + +def dedent(text: str) -> str: + """Removes a fixture's common indentation and its leading newline. + + Lets fixtures be written as indented triple-quoted here-docs while keeping + honest 1-based line numbers. + """ + return textwrap.dedent(text).lstrip("\n") + + +def fixed(text: str) -> str: + return fix_source(dedent(text))[0] + + +def reports(text: str) -> list[str]: + return fix_source(dedent(text))[1] + + +# --- conversion -------------------------------------------------------------- + + +def test_snake_case_conversion() -> None: + assert snake_case("BadInputs") == "bad_inputs" + assert snake_case("mulDiv") == "mul_div" + assert snake_case("already_snake") == "already_snake" + assert snake_case("base64") == "base64" + + +def test_snake_case_keeps_acronyms_whole() -> None: + assert snake_case("SetAndResetAccountTxnID") == "set_and_reset_account_txn_id" + assert snake_case("XRPToIOU") == "xrp_to_iou" + assert snake_case("STAmountMath") == "st_amount_math" + + +def test_camel_case_conversion() -> None: + assert camel_case("json_value") == "JsonValue" + assert camel_case("mulDiv") == "MulDiv" + assert camel_case("scope") == "Scope" + assert camel_case("base64") == "Base64" + + +def test_camel_case_leaves_acronyms_alone() -> None: + # A snake_case round-trip would give `ShaMapTest` / `ParseStatmRsSkB` here. + assert camel_case("SHAMapTest") == "SHAMapTest" + assert camel_case("parseStatmRSSkB") == "ParseStatmRSSkB" + assert camel_case("XRPAmount") == "XRPAmount" + assert camel_case("CSPRNG") == "CSPRNG" + + +def test_disabled_prefix_preserved() -> None: + assert snake_case("DISABLED_FooBar") == "DISABLED_foo_bar" + assert snake_case("DISABLED_foo_bar") == "DISABLED_foo_bar" + assert snake_case("DISABLED_") == "DISABLED_" + assert camel_case("DISABLED_foo_bar") == "DISABLED_FooBar" + assert camel_case("DISABLED_") == "DISABLED_" + + +# --- what counts as a test definition --------------------------------------- + + +def test_all_macros_recognized() -> None: + code = """ + TEST(Suite, oneName) + TEST_F(Fixture, twoName) + TEST_P(Fixture, threeName) + TYPED_TEST(Fixture, fourName) + TYPED_TEST_P(Fixture, fiveName) + """ + assert fixed(code) == dedent(""" + TEST(Suite, one_name) + TEST_F(Fixture, two_name) + TEST_P(Fixture, three_name) + TYPED_TEST(Fixture, four_name) + TYPED_TEST_P(Fixture, five_name) + """) + + +def test_conforming_definitions_untouched() -> None: + code = """ + TEST(AccountSet, bad_inputs) + TEST_F(MutexMakeTest, default_constructor) + TEST(SHAMap, DISABLED_slow_path) + """ + assert reports(code) == [] + assert fixed(code) == dedent(code) + + +def test_lookalikes_ignored() -> None: + code = """ + // TEST(Suite, notATest) + TEST_EXPECT(someCall()) + TEST_EXPECTS(amount == value, amount.getText()) + INSTANTIATE_TEST_SUITE_P(Prefix, Fixture, testValues()); + auto x = TEST(Suite, notATest); + TYPED_TEST_SUITE(Fixture, MyTypes); + """ + assert reports(code) == [] + assert fixed(code) == dedent(code) + + +def test_indented_and_wrapped_definitions() -> None: + code = """ + namespace ripple { + TEST(Suite, indentedName) + } + TEST_F( + SomeVeryLongFixtureName, + wrappedName) + """ + assert fixed(code) == dedent(""" + namespace ripple { + TEST(Suite, indented_name) + } + TEST_F( + SomeVeryLongFixtureName, + wrapped_name) + """) + + +# --- rewriting -------------------------------------------------------------- + + +def test_only_the_two_names_are_rewritten() -> None: + code = """ + TEST(mulDiv, mulDiv) + { + auto const mulDiv = 1; // mulDiv stays + } + """ + assert fixed(code) == dedent(""" + TEST(MulDiv, mul_div) + { + auto const mulDiv = 1; // mulDiv stays + } + """) + + +def test_suite_name_camel_cased() -> None: + code = """ + TEST(json_value, limits) + TEST(scope, ScopeExit) + """ + assert reports(code) == [ + "1: renamed suite 'json_value' to 'JsonValue'", + "2: renamed suite 'scope' to 'Scope'", + "2: renamed test case 'ScopeExit' to 'scope_exit'", + ] + assert fixed(code) == dedent(""" + TEST(JsonValue, limits) + TEST(Scope, scope_exit) + """) + + +def test_fixture_reported_but_not_renamed() -> None: + # The first argument names a class, so only a human (or clang-tidy) can + # rename it; the test-case name is still fixed. + code = """ + TEST_F(my_fixture, someTest) + """ + assert reports(code) == [ + "1: fixture 'my_fixture' is not CamelCase: rename the class to " + "'MyFixture' by hand", + "1: renamed test case 'someTest' to 'some_test'", + ] + assert fixed(code) == dedent(""" + TEST_F(my_fixture, some_test) + """) + + +def test_reports_carry_line_numbers() -> None: + code = """ + #include + + TEST(Suite, firstName) + + TEST(Suite, secondName) + """ + assert reports(code) == [ + "3: renamed test case 'firstName' to 'first_name'", + "5: renamed test case 'secondName' to 'second_name'", + ] + + +# --- collisions ------------------------------------------------------------- + + +def test_collision_reported_and_not_applied() -> None: + # Both would define `Suite_mul_div_Test`. + code = """ + TEST(Suite, mulDiv) + TEST(Suite, mul_div) + """ + assert reports(code) == [ + "1: cannot rename 'Suite, mulDiv' to 'Suite, mul_div': another test " + "case already generates that name" + ] + assert fixed(code) == dedent(code) + + +def test_collision_between_converging_suites() -> None: + # Both suites camel-case to `SuiteA`, so both would define + # `SuiteA_one_test_Test`. + code = """ + TEST(SuiteA, oneTest) + TEST(Suite_a, one_test) + """ + assert [r.split(":")[1].strip() for r in reports(code)] == [ + "cannot rename 'SuiteA, oneTest' to 'SuiteA, one_test'", + "cannot rename 'Suite_a, one_test' to 'SuiteA, one_test'", + ] + assert fixed(code) == dedent(code) + + +def test_same_name_in_different_suites_is_not_a_collision() -> None: + code = """ + TEST(SuiteOne, mulDiv) + TEST(SuiteTwo, mulDiv) + """ + assert fixed(code) == dedent(""" + TEST(SuiteOne, mul_div) + TEST(SuiteTwo, mul_div) + """) + + +def main() -> int: + tests = sorted( + (name, fn) + for name, fn in globals().items() + if name.startswith("test_") and callable(fn) + ) + failed = 0 + for name, fn in tests: + try: + fn() + print(f"PASS {name}") + except AssertionError as exc: + failed += 1 + print(f"FAIL {name}: {exc!r}") + print(f"\n{len(tests) - failed}/{len(tests)} passed") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cmake/XrplCore.cmake b/cmake/XrplCore.cmake index aeb815d160..5dcb4930f8 100644 --- a/cmake/XrplCore.cmake +++ b/cmake/XrplCore.cmake @@ -231,6 +231,7 @@ target_link_libraries( # # Links xrpl.libxrpl.protocol and xrpl.libxrpl.core PUBLICLY: ValidationTracker.h # takes LedgerIndex and MetricMacros.h takes ServiceRegistry, both in interfaces. +# TxAccountSpanNames.cpp also reads the SField table from xrpl.libxrpl.protocol. add_module(xrpl telemetry) target_link_libraries( xrpl.libxrpl.telemetry diff --git a/conanfile.py b/conanfile.py index 1fd1ff842e..5a8c2318d0 100644 --- a/conanfile.py +++ b/conanfile.py @@ -239,3 +239,7 @@ class Xrpl(ConanFile): libxrpl.requires.append("rocksdb::librocksdb") if self.options.telemetry: libxrpl.requires.append("opentelemetry-cpp::opentelemetry-cpp") + # The public telemetry headers pick their class layout on this + # define, so a consumer that does not see it compiles a different + # SpanGuard than the one inside the library it links. + libxrpl.defines.append("XRPL_ENABLE_TELEMETRY") diff --git a/docker/telemetry/TESTING.md b/docker/telemetry/TESTING.md index 8c4568bb0e..cb9387db17 100644 --- a/docker/telemetry/TESTING.md +++ b/docker/telemetry/TESTING.md @@ -745,7 +745,6 @@ Differences that change what you will see: | Pipelines | 3: `traces`, `metrics`, `logs` | 5: `traces/metrics`, `traces/store`, `metrics/local`, `metrics/cloud`, `logs` | | Trace sampling | none — 100% of spans reach Tempo | `tail_sampling` keeps **0.5%** (one `probabilistic` policy, `decision_wait: 10s`) on `traces/store` | | `debug` exporter | present on `traces` | dropped | -| `attributes/hash` | present on `traces` | **omitted** | | Cloud metric labels | n/a | `transform/cloudlabels` on `metrics/cloud` only | Consequences worth knowing before you debug against the cloud stack: @@ -756,17 +755,10 @@ Consequences worth knowing before you debug against the cloud stack: pipeline, so `span_*` rates stay exact while only ~1 trace in 200 is retrievable by trace ID. A trace you can see in a metric may not exist in Tempo. -- **The same account carries a different token on each config.** No raw account - address leaves the node: the path-finding handlers under - `src/xrpld/rpc/handlers/orderbook/` pass both accounts through - `redactAccount()` first, which is a prefix of the address's SHA-512Half digest - (contract in `include/xrpl/telemetry/Redaction.h`). The base config's - `attributes/hash` processor then hashes that token a second time; no cloud - pipeline has it. The token is deterministic, so one account stays correlatable - across nodes and restarts — but only within one config. A trace stored while - the collector ran the base config must not be joined against a trace stored - under the cloud config, because the same account appears under two different - tokens. +- **Account addresses read the same on both configs.** Neither config hashes + or drops the `pathfind_*_account` or `tx_*` account attributes: an address is + a public ledger identifier and is stored as the node emitted it, so a trace + from the base stack joins a trace from the cloud stack by account. ### Step 4: Verify data reaches Grafana Cloud diff --git a/docker/telemetry/grafana/provisioning/alerting/contactpoints.yaml b/docker/telemetry/grafana/provisioning/alerting/contactpoints.yaml index 06407f904e..b905c889f4 100644 --- a/docker/telemetry/grafana/provisioning/alerting/contactpoints.yaml +++ b/docker/telemetry/grafana/provisioning/alerting/contactpoints.yaml @@ -41,11 +41,14 @@ # GF_SMTP_ENABLED=true and the relay settings point somewhere real. # # Grafana Cloud does NOT use this file — Cloud has no provisioning filesystem. -# Cloud delivery is created over the REST API instead: a single email-only -# contact point, attached to each rule via per-rule notification_settings. +# Cloud delivery is created over the REST API instead: one contact point named +# `xrpld-alerts` holding a Slack receiver AND an email receiver, attached to +# each rule via per-rule notification_settings. Keep this file's templates and +# the Cloud receivers' templates in step by hand; nothing syncs them. # The notification policy tree must never be pushed to a shared Cloud # instance -- there is exactly one tree per org and the PUT endpoint -# replaces it wholesale. +# replaces it wholesale. That Cloud instance is shared with other teams, so a +# change there must be scoped to the `xrpld-` uids and the xrpld folder. apiVersion: 1 @@ -61,12 +64,12 @@ contactPoints: # selects Slack webhook mode (no recipient/token required) and keeps # provisioning valid. Replace with a real webhook to enable delivery. url: https://hooks.slack.invalid/disabled - # `rulename` is used rather than CommonLabels.service_instance_id - # because on NoData/Error evaluations Grafana replaces the query's - # label set with only {datasource_uid, ref_id}, so the node label is - # absent and the title would render blank — and several rules are - # deliberately configured to fire that way. - title: "{{ .CommonLabels.rulename }}" + # Title and body both come from templates.yaml. `alertname` is the + # label Grafana sets on every alert; `service_instance_id` is a query + # label and is absent on NoData/Error evaluations, so the templates + # guard it rather than referencing it bare. + title: '{{ template "xrpld.title" . }}' + text: '{{ template "xrpld.slack.body" . }}' disableResolveMessage: false # --- Critical tier: Slack + email --- @@ -78,7 +81,10 @@ contactPoints: settings: # Disabled placeholder — see xrpld-slack-default above. url: https://hooks.slack.invalid/disabled - title: "[CRITICAL] {{ .CommonLabels.rulename }}" + # The title template already carries the severity label, so this tier + # needs no separate "[CRITICAL]" prefix. + title: '{{ template "xrpld.title" . }}' + text: '{{ template "xrpld.slack.body" . }}' disableResolveMessage: false - uid: xrpld-email-critical type: email @@ -90,6 +96,14 @@ contactPoints: addresses: alerts-disabled@xrpld.invalid # One message listing all recipients, rather than one message each. singleEmail: true + # DELIBERATELY no `subject`/`message` override here, unlike the Slack + # receivers above. Grafana's default email body is the good one: bold + # section headings, a styled label table, annotations rendered as real + # clickable links, and the View/Silence buttons. Overriding `message` + # replaces all of that with plain text, because email escapes HTML, so + # a custom body cannot reproduce any of it. The value line the default + # body carries is made readable by NAMING each rule's query steps (see + # rules.yaml), not by replacing the body. disableResolveMessage: false # To retire a receiver that a running Grafana has already stored, uncomment diff --git a/docker/telemetry/grafana/provisioning/alerting/rules.yaml b/docker/telemetry/grafana/provisioning/alerting/rules.yaml index 0f0f8223a3..1d88132564 100644 --- a/docker/telemetry/grafana/provisioning/alerting/rules.yaml +++ b/docker/telemetry/grafana/provisioning/alerting/rules.yaml @@ -56,7 +56,7 @@ groups: # hence increase() over a wide window rather than a decaying rate(). - uid: xrpld-ledger-history-mismatch title: LedgerHistoryMismatch - condition: C + condition: is_mismatching for: 2m isPaused: true noDataState: NoData @@ -67,44 +67,46 @@ groups: annotations: summary: "Ledger history mismatch on {{ $labels.service_instance_id }}" description: >- - Node {{ $labels.service_instance_id }} recorded - {{ $values.B.Value }} ledger history mismatch(es) in the last 15m. - The node's built ledger diverges from the validated network chain. + Ledger history mismatches on {{ $labels.service_instance_id }} in the last + 15m: {{ printf "%.0f" $values.mismatches_now.Value }} (threshold: more than 0). + action: >- + A built ledger diverged from the validated network chain, which can mean + corrupt local history. Check byzantine ledger jumps and the node-store. data: - - refId: A + - refId: mismatches_15m relativeTimeRange: from: 1200 to: 0 datasourceUid: prometheus model: - refId: A + refId: mismatches_15m expr: sum by (service_instance_id) (increase(ledger_history_mismatch_total{service_name="xrpld"}[15m])) instant: true range: false intervalMs: 1000 maxDataPoints: 43200 - - refId: B + - refId: mismatches_now relativeTimeRange: from: 1200 to: 0 datasourceUid: __expr__ model: - refId: B + refId: mismatches_now type: reduce reducer: last - expression: A + expression: mismatches_15m datasource: type: __expr__ uid: __expr__ - - refId: C + - refId: is_mismatching relativeTimeRange: from: 1200 to: 0 datasourceUid: __expr__ model: - refId: C + refId: is_mismatching type: threshold - expression: B + expression: mismatches_now conditions: - evaluator: type: gt @@ -120,7 +122,7 @@ groups: # dead node trips the threshold instead of vanishing from the result. - uid: xrpld-ledger-close-stalled title: LedgerCloseStalled - condition: C + condition: is_stalled for: 3m isPaused: true noDataState: Alerting @@ -131,17 +133,19 @@ groups: annotations: summary: "Ledger closing stalled on {{ $labels.service_instance_id }}" description: >- - Node {{ $labels.service_instance_id }} has closed no ledgers for - several minutes (5m rate has decayed to zero). Consensus or ledger - advancement is stuck, or the process is gone. + Ledger close rate on {{ $labels.service_instance_id }} over 5m: {{ printf + "%.4f" $values.close_rate_now.Value }} ledgers/s (threshold: below 0.001). + action: >- + Consensus or ledger advancement is stuck, or the process is gone. Check + consensus round duration, worker pool saturation and peer supply. data: - - refId: A + - refId: close_rate_5m relativeTimeRange: from: 600 to: 0 datasourceUid: prometheus model: - refId: A + refId: close_rate_5m expr: |- sum by (service_instance_id) (rate(ledgers_closed_total{service_name="xrpld"}[5m])) or (0 * max by (service_instance_id) (max_over_time(ledgers_closed_total{service_name="xrpld"}[1h]))) @@ -149,28 +153,28 @@ groups: range: false intervalMs: 1000 maxDataPoints: 43200 - - refId: B + - refId: close_rate_now relativeTimeRange: from: 600 to: 0 datasourceUid: __expr__ model: - refId: B + refId: close_rate_now type: reduce reducer: last - expression: A + expression: close_rate_5m datasource: type: __expr__ uid: __expr__ - - refId: C + - refId: is_stalled relativeTimeRange: from: 600 to: 0 datasourceUid: __expr__ model: - refId: C + refId: is_stalled type: threshold - expression: B + expression: close_rate_now conditions: - evaluator: type: lt @@ -195,7 +199,7 @@ groups: # validated ledger is caught by LedgerCloseStalled and NodeNotFull. - uid: xrpld-validated-ledger-stale title: ValidatedLedgerStale - condition: C + condition: is_stale for: 5m isPaused: true noDataState: NoData @@ -206,44 +210,46 @@ groups: annotations: summary: "Validated ledger stale on {{ $labels.service_instance_id }}" description: >- - Node {{ $labels.service_instance_id }} has a validated ledger age of - {{ $values.B.Value }}s (>60s). The node is not keeping up with the - validated network chain. + Validated ledger age on {{ $labels.service_instance_id }}: {{ printf "%.0f" + $values.ledger_age_now.Value }}s (threshold: over 60s). + action: >- + The node is no longer tracking the network. Check peer connectivity, node- + store IO latency and consensus rounds. data: - - refId: A + - refId: ledger_age_s relativeTimeRange: from: 600 to: 0 datasourceUid: prometheus model: - refId: A + refId: ledger_age_s expr: max by (service_instance_id) (ledgermaster_validated_ledger_age{service_name="xrpld"} < 1209600) instant: true range: false intervalMs: 1000 maxDataPoints: 43200 - - refId: B + - refId: ledger_age_now relativeTimeRange: from: 600 to: 0 datasourceUid: __expr__ model: - refId: B + refId: ledger_age_now type: reduce reducer: last - expression: A + expression: ledger_age_s datasource: type: __expr__ uid: __expr__ - - refId: C + - refId: is_stale relativeTimeRange: from: 600 to: 0 datasourceUid: __expr__ model: - refId: C + refId: is_stale type: threshold - expression: B + expression: ledger_age_now conditions: - evaluator: type: gt @@ -274,7 +280,7 @@ groups: # disagreement among nodes that do validate. - uid: xrpld-validations-missed title: ValidationsMissed - condition: C + condition: is_missing_validations for: 15m isPaused: true noDataState: NoData @@ -285,18 +291,19 @@ groups: annotations: summary: "Validations missed on {{ $labels.service_instance_id }}" description: >- - Validator {{ $labels.service_instance_id }} is missing - {{ $values.B.Value }} (fraction) of its validations over 15m. Its - validations are not agreeing with the validated ledger, which risks + Missed-validation fraction on {{ $labels.service_instance_id }} over 15m: {{ + printf "%.3f" $values.missed_ratio_now.Value }} (threshold: over 0.1). + action: >- + Its validations are not agreeing with the validated ledger, which risks removal from UNLs. data: - - refId: A + - refId: missed_ratio_15m relativeTimeRange: from: 1200 to: 0 datasourceUid: prometheus model: - refId: A + refId: missed_ratio_15m expr: |- ( sum by (service_instance_id) (rate(validation_missed_total{service_name="xrpld"}[15m])) @@ -311,28 +318,28 @@ groups: range: false intervalMs: 1000 maxDataPoints: 43200 - - refId: B + - refId: missed_ratio_now relativeTimeRange: from: 1200 to: 0 datasourceUid: __expr__ model: - refId: B + refId: missed_ratio_now type: reduce reducer: last - expression: A + expression: missed_ratio_15m datasource: type: __expr__ uid: __expr__ - - refId: C + - refId: is_missing_validations relativeTimeRange: from: 1200 to: 0 datasourceUid: __expr__ model: - refId: C + refId: is_missing_validations type: threshold - expression: B + expression: missed_ratio_now conditions: - evaluator: type: gt @@ -347,7 +354,7 @@ groups: # (see the LedgerCloseStalled comment). - uid: xrpld-validations-not-checked title: ValidationsNotChecked - condition: C + condition: is_not_checking for: 5m isPaused: true noDataState: Alerting @@ -358,17 +365,19 @@ groups: annotations: summary: "No validations checked on {{ $labels.service_instance_id }}" description: >- - Node {{ $labels.service_instance_id }} has checked no incoming - validations for several minutes (5m rate has decayed to zero). The - validation stream from peers may have stopped. + Validations-checked rate on {{ $labels.service_instance_id }} over 5m: {{ + printf "%.4f" $values.check_rate_now.Value }} per s (threshold: below 0.001). + action: >- + The validation stream from peers may have stopped. Check peer count and the + overlay. data: - - refId: A + - refId: check_rate_5m relativeTimeRange: from: 600 to: 0 datasourceUid: prometheus model: - refId: A + refId: check_rate_5m expr: |- sum by (service_instance_id) (rate(validations_checked_total{service_name="xrpld"}[5m])) or (0 * max by (service_instance_id) (max_over_time(validations_checked_total{service_name="xrpld"}[1h]))) @@ -376,28 +385,28 @@ groups: range: false intervalMs: 1000 maxDataPoints: 43200 - - refId: B + - refId: check_rate_now relativeTimeRange: from: 600 to: 0 datasourceUid: __expr__ model: - refId: B + refId: check_rate_now type: reduce reducer: last - expression: A + expression: check_rate_5m datasource: type: __expr__ uid: __expr__ - - refId: C + - refId: is_not_checking relativeTimeRange: from: 600 to: 0 datasourceUid: __expr__ model: - refId: C + refId: is_not_checking type: threshold - expression: B + expression: check_rate_now conditions: - evaluator: type: lt @@ -420,7 +429,7 @@ groups: # sparse counters). - uid: xrpld-jobqueue-tx-overflow title: JobQueueTxOverflow - condition: C + condition: is_overflowing for: 2m isPaused: true noDataState: NoData @@ -431,44 +440,46 @@ groups: annotations: summary: "Job queue transaction overflow on {{ $labels.service_instance_id }}" description: >- - Node {{ $labels.service_instance_id }} overflowed its transaction - job queue {{ $values.B.Value }} time(s) in the last 15m. - Transactions are being dropped under load. + Transaction job-queue overflows on {{ $labels.service_instance_id }} in the + last 15m: {{ printf "%.0f" $values.overflows_now.Value }} (threshold: more than 0). + action: >- + Transactions are being dropped under load. Check job-queue depth and worker + saturation. data: - - refId: A + - refId: overflows_15m relativeTimeRange: from: 1200 to: 0 datasourceUid: prometheus model: - refId: A + refId: overflows_15m expr: sum by (service_instance_id) (increase(jq_trans_overflow_total{service_name="xrpld"}[15m])) instant: true range: false intervalMs: 1000 maxDataPoints: 43200 - - refId: B + - refId: overflows_now relativeTimeRange: from: 1200 to: 0 datasourceUid: __expr__ model: - refId: B + refId: overflows_now type: reduce reducer: last - expression: A + expression: overflows_15m datasource: type: __expr__ uid: __expr__ - - refId: C + - refId: is_overflowing relativeTimeRange: from: 1200 to: 0 datasourceUid: __expr__ model: - refId: C + refId: is_overflowing type: threshold - expression: B + expression: overflows_now conditions: - evaluator: type: gt @@ -491,7 +502,7 @@ groups: # healthy p99 and fires only on genuine saturation, which is the intent. - uid: xrpld-jobqueue-latency-high title: JobQueueLatencyHigh - condition: C + condition: is_queue_slow for: 5m isPaused: true noDataState: NoData @@ -502,44 +513,45 @@ groups: annotations: summary: "Job queue latency high on {{ $labels.service_instance_id }}" description: >- - Node {{ $labels.service_instance_id }} has a p99 job-queue wait of - {{ $values.B.Value }}µs (>1s) over 5m. The node is saturated and jobs are - backing up. + p99 job-queue wait on {{ $labels.service_instance_id }} over 5m: {{ printf + "%.0f" $values.queue_p99_us_now.Value }}us (threshold: over 1000000us, i.e. 1s). + action: >- + The node is saturated and jobs are backing up. Check worker pool saturation. data: - - refId: A + - refId: queue_p99_us_5m relativeTimeRange: from: 600 to: 0 datasourceUid: prometheus model: - refId: A + refId: queue_p99_us_5m expr: histogram_quantile(0.99, sum by (le, service_instance_id) (rate(job_queued_us_bucket{service_name="xrpld"}[5m]))) instant: true range: false intervalMs: 1000 maxDataPoints: 43200 - - refId: B + - refId: queue_p99_us_now relativeTimeRange: from: 600 to: 0 datasourceUid: __expr__ model: - refId: B + refId: queue_p99_us_now type: reduce reducer: last - expression: A + expression: queue_p99_us_5m datasource: type: __expr__ uid: __expr__ - - refId: C + - refId: is_queue_slow relativeTimeRange: from: 600 to: 0 datasourceUid: __expr__ model: - refId: C + refId: is_queue_slow type: threshold - expression: B + expression: queue_p99_us_now conditions: - evaluator: type: gt @@ -561,7 +573,7 @@ groups: # is 12.9ms, far below the threshold. - uid: xrpld-nodestore-io-latency-high title: NodeStoreIOLatencyHigh - condition: C + condition: is_io_slow for: 10m isPaused: true noDataState: NoData @@ -572,44 +584,45 @@ groups: annotations: summary: "Node store IO latency high on {{ $labels.service_instance_id }}" description: >- - Node {{ $labels.service_instance_id }} has a p95 node-store IO - latency of {{ $values.B.Value }}ms (>1s) over 10m. Check disk - utilisation and whether the store is on a slow volume. + p95 node-store IO latency on {{ $labels.service_instance_id }} over 10m: {{ + printf "%.0f" $values.io_p95_ms_now.Value }}ms (threshold: over 1000ms). + action: >- + Check disk utilisation and whether the store is on a slow volume. data: - - refId: A + - refId: io_p95_ms_10m relativeTimeRange: from: 900 to: 0 datasourceUid: prometheus model: - refId: A + refId: io_p95_ms_10m expr: histogram_quantile(0.95, sum by (le, service_instance_id) (rate(ios_latency_milliseconds_bucket{service_name="xrpld"}[10m]))) instant: true range: false intervalMs: 1000 maxDataPoints: 43200 - - refId: B + - refId: io_p95_ms_now relativeTimeRange: from: 900 to: 0 datasourceUid: __expr__ model: - refId: B + refId: io_p95_ms_now type: reduce reducer: last - expression: A + expression: io_p95_ms_10m datasource: type: __expr__ uid: __expr__ - - refId: C + - refId: is_io_slow relativeTimeRange: from: 900 to: 0 datasourceUid: __expr__ model: - refId: C + refId: is_io_slow type: threshold - expression: B + expression: io_p95_ms_now conditions: - evaluator: type: gt @@ -644,7 +657,7 @@ groups: # the gate suppresses that first hour so a restart does not page. - uid: xrpld-node-state-flapping title: NodeStateFlapping - condition: C + condition: is_flapping for: 15m isPaused: true noDataState: OK @@ -655,19 +668,21 @@ groups: annotations: summary: "Node state flapping on {{ $labels.service_instance_id }}" description: >- - Node {{ $labels.service_instance_id }} re-entered the FULL state - {{ $values.B.Value }} time(s) in the last hour past its first hour - of uptime. It is flapping out of sync rather than holding FULL. - Likely the online-delete rotation cache-freshen; check the rotation - spans and cache lock-hold peak. + FULL-state re-entries on {{ $labels.service_instance_id }} in the last hour: + {{ printf "%.0f" $values.full_transitions_now.Value }} (threshold: more than 0, past the first + hour of uptime). + action: >- + The node is flapping out of sync rather than holding FULL. A likely cause is + the online-delete rotation cache-freshen; check the rotation spans and the + cache lock-hold peak. data: - - refId: A + - refId: full_transitions_1h relativeTimeRange: from: 3900 to: 0 datasourceUid: prometheus model: - refId: A + refId: full_transitions_1h expr: |- sum by (service_instance_id) (increase(state_accounting_full_transitions{service_name="xrpld"}[1h])) and on (service_instance_id) @@ -676,28 +691,28 @@ groups: range: false intervalMs: 1000 maxDataPoints: 43200 - - refId: B + - refId: full_transitions_now relativeTimeRange: from: 3900 to: 0 datasourceUid: __expr__ model: - refId: B + refId: full_transitions_now type: reduce reducer: last - expression: A + expression: full_transitions_1h datasource: type: __expr__ uid: __expr__ - - refId: C + - refId: is_flapping relativeTimeRange: from: 3900 to: 0 datasourceUid: __expr__ model: - refId: C + refId: is_flapping type: threshold - expression: B + expression: full_transitions_now conditions: - evaluator: type: gt @@ -713,7 +728,7 @@ groups: # SYNCING=2, TRACKING=3, FULL=4. - uid: xrpld-node-not-full title: NodeNotFull - condition: C + condition: is_not_full for: 15m isPaused: true noDataState: NoData @@ -724,17 +739,20 @@ groups: annotations: summary: "Node not in FULL state on {{ $labels.service_instance_id }}" description: >- - Node {{ $labels.service_instance_id }} has been below FULL - (state={{ $values.B.Value }}; 0=disconnected 1=connected 2=syncing - 3=tracking 4=full) for 15m. It is not fully synced with the network. + Server state on {{ $labels.service_instance_id }} for the last 15m: {{ + printf "%.0f" $values.server_state_now.Value }} (threshold: below 4; 0=disconnected + 1=connected 2=syncing 3=tracking 4=full). + action: >- + The node is not fully synced with the network. Check peer supply and sync + progress. data: - - refId: A + - refId: server_state_code relativeTimeRange: from: 1200 to: 0 datasourceUid: prometheus model: - refId: A + refId: server_state_code expr: |- max by (service_instance_id) (server_info{service_name="xrpld", metric="server_state"}) and on (service_instance_id) @@ -743,28 +761,28 @@ groups: range: false intervalMs: 1000 maxDataPoints: 43200 - - refId: B + - refId: server_state_now relativeTimeRange: from: 1200 to: 0 datasourceUid: __expr__ model: - refId: B + refId: server_state_now type: reduce reducer: last - expression: A + expression: server_state_code datasource: type: __expr__ uid: __expr__ - - refId: C + - refId: is_not_full relativeTimeRange: from: 1200 to: 0 datasourceUid: __expr__ model: - refId: C + refId: is_not_full type: threshold - expression: B + expression: server_state_now conditions: - evaluator: type: lt @@ -796,7 +814,7 @@ groups: # burst (peaks of 5 and 11, lasting well under 10m) from paging. - uid: xrpld-manifest-job-convoy title: ManifestJobQueueConvoy - condition: C + condition: is_convoying for: 10m isPaused: true noDataState: NoData @@ -807,44 +825,46 @@ groups: annotations: summary: "Manifest job convoy on {{ $labels.service_instance_id }}" description: >- - Node {{ $labels.service_instance_id }} has {{ $values.B.Value }} - manifest jobs waiting (>3) for 10m. Peer manifest dumps are - saturating the job pool and convoying on the manifest cache lock. + Manifest jobs waiting on {{ $labels.service_instance_id }} over 10m: {{ + printf "%.0f" $values.manifest_waiting_now.Value }} (threshold: more than 3). + action: >- + Peer manifest dumps are saturating the job pool and convoying on the + manifest cache lock. data: - - refId: A + - refId: manifest_waiting_10m relativeTimeRange: from: 900 to: 0 datasourceUid: prometheus model: - refId: A + refId: manifest_waiting_10m expr: sum by (service_instance_id) (jobq_manifest_waiting{service_name="xrpld"}) instant: true range: false intervalMs: 1000 maxDataPoints: 43200 - - refId: B + - refId: manifest_waiting_now relativeTimeRange: from: 900 to: 0 datasourceUid: __expr__ model: - refId: B + refId: manifest_waiting_now type: reduce reducer: last - expression: A + expression: manifest_waiting_10m datasource: type: __expr__ uid: __expr__ - - refId: C + - refId: is_convoying relativeTimeRange: from: 900 to: 0 datasourceUid: __expr__ model: - refId: C + refId: is_convoying type: threshold - expression: B + expression: manifest_waiting_now conditions: - evaluator: type: gt @@ -873,7 +893,7 @@ groups: # alerted. ManifestJobQueueConvoy covers that window via the job pool. - uid: xrpld-manifest-flood-inbound title: ManifestFloodInbound - condition: C + condition: is_flooding for: 10m isPaused: true noDataState: NoData @@ -884,18 +904,19 @@ groups: annotations: summary: "Inbound manifest flood on {{ $labels.service_instance_id }}" description: >- - Node {{ $labels.service_instance_id }} is receiving - {{ $values.B.Value }} B/s of manifest traffic over 10m, above the - 512 KiB/s (524288 B/s) threshold. + Inbound manifest traffic on {{ $labels.service_instance_id }} over 10m: {{ + printf "%.0f" $values.manifest_bytes_s_now.Value }} B/s (threshold: over 524288 B/s, i.e. 512 + KiB/s). + action: >- A peer is flooding oversized TMManifests dumps. data: - - refId: A + - refId: manifest_bytes_s_10m relativeTimeRange: from: 900 to: 0 datasourceUid: prometheus model: - refId: A + refId: manifest_bytes_s_10m expr: |- sum by (service_instance_id) (rate(overhead_manifest_bytes_in{service_name="xrpld"}[10m])) and on (service_instance_id) @@ -904,28 +925,28 @@ groups: range: false intervalMs: 1000 maxDataPoints: 43200 - - refId: B + - refId: manifest_bytes_s_now relativeTimeRange: from: 900 to: 0 datasourceUid: __expr__ model: - refId: B + refId: manifest_bytes_s_now type: reduce reducer: last - expression: A + expression: manifest_bytes_s_10m datasource: type: __expr__ uid: __expr__ - - refId: C + - refId: is_flooding relativeTimeRange: from: 900 to: 0 datasourceUid: __expr__ model: - refId: C + refId: is_flooding type: threshold - expression: B + expression: manifest_bytes_s_now conditions: - evaluator: type: gt @@ -944,7 +965,7 @@ groups: # trips on a node already in trouble. - uid: xrpld-peer-resource-disconnects title: PeerResourceDisconnects - condition: C + condition: is_disconnecting for: 5m isPaused: true noDataState: NoData @@ -955,44 +976,45 @@ groups: annotations: summary: "Resource-driven peer disconnects on {{ $labels.service_instance_id }}" description: >- - Node {{ $labels.service_instance_id }} disconnected - {{ $values.B.Value }} peer(s) for resource-budget violations in the - last 30m. Sustained disconnects can starve the node of peers. + Resource-budget peer disconnects on {{ $labels.service_instance_id }} in the + last 30m: {{ printf "%.0f" $values.disconnects_now.Value }} (threshold: more than 5). + action: >- + Sustained disconnects can starve the node of peers. data: - - refId: A + - refId: disconnects_30m relativeTimeRange: from: 2100 to: 0 datasourceUid: prometheus model: - refId: A + refId: disconnects_30m expr: sum by (service_instance_id) (increase(server_info{service_name="xrpld", metric="peer_disconnects_resources"}[30m])) instant: true range: false intervalMs: 1000 maxDataPoints: 43200 - - refId: B + - refId: disconnects_now relativeTimeRange: from: 2100 to: 0 datasourceUid: __expr__ model: - refId: B + refId: disconnects_now type: reduce reducer: last - expression: A + expression: disconnects_30m datasource: type: __expr__ uid: __expr__ - - refId: C + - refId: is_disconnecting relativeTimeRange: from: 2100 to: 0 datasourceUid: __expr__ model: - refId: C + refId: is_disconnecting type: threshold - expression: B + expression: disconnects_now conditions: - evaluator: type: gt diff --git a/docker/telemetry/grafana/provisioning/alerting/templates.yaml b/docker/telemetry/grafana/provisioning/alerting/templates.yaml new file mode 100644 index 0000000000..8d158318f3 --- /dev/null +++ b/docker/telemetry/grafana/provisioning/alerting/templates.yaml @@ -0,0 +1,96 @@ +# Notification templates for the xrpld OTel alerts. +# +# Why these exist: Grafana's built-in message body appends a raw dump of every +# expression node's value and every label, so a notification reads +# `Value: A=0, B=0, C=1` followed by five `key = value` lines. The refIds carry +# no meaning to a reader, and the label dump repeats what the title already +# says. These templates replace that body with the rule's own prose. +# +# --------------------------------------------------------------------------- +# SLACK ONLY. Email deliberately has no template here. +# --------------------------------------------------------------------------- +# Measured, and the reason this file covers one channel rather than two: +# +# * Email ESCAPES any HTML in the message body, so a literal `
` arrives +# as `<br>` and no tag or anchor is possible. A custom email body is +# therefore plain text and cannot reproduce Grafana's default layout, which +# already gives section headings, a label table, clickable annotation links +# and the View/Silence buttons. So email keeps the default body. +# * Slack markup is meaningless in email anyway: bold asterisks, backticks +# and `:rotating_light:` arrive as literal characters, and a `` +# link cannot become a link, so it dumps the whole URL inline. Four panel +# links bury the prose in a wall of text. That is what a shared body did. +# * Real emoji CHARACTERS render in both channels; the `:shortcode:` form +# only works in Slack. +# +# The value line Grafana's default email body ends with is made readable by +# NAMING each rule's query steps (see rules.yaml), not by replacing the body. +# +# --------------------------------------------------------------------------- +# What a template may and may not use +# --------------------------------------------------------------------------- +# The function set is much smaller than Go's text/template plus sprig. +# Available and used below: `match` (regexp), `reReplaceAll`, `title`, `printf`, +# `len`, `index`, `eq`. NOT available — each fails the whole template with +# `function "X" not defined`: `hasPrefix`, `hasSuffix`, `contains`, `humanize`, +# `humanizeDuration`, `humanizePercentage`. +# +# A template failure is nearly silent, which is the trap. Measured: a missing +# define logs one `failed to template email message` warning and then delivers +# Grafana's DEFAULT body — the value dump and label list this file exists to +# remove — while the rule still reports `health=ok`. So a change here is only +# verified by reading a real delivered notification, never by rule state, and +# never by the absence of an obvious error. +# +# --------------------------------------------------------------------------- +# Two label traps +# --------------------------------------------------------------------------- +# * `service_instance_id` is a QUERY label, so it is absent whenever Grafana +# evaluates NoData or Error — those evaluations carry only +# {datasource_uid, ref_id}. Two rules set `noDataState: Alerting` and so +# fire that way deliberately. `xrpld.node` below falls back rather than +# rendering an empty string. +# * `alertname` is set by Grafana on every alert. There is no `rulename` +# label, so referencing one renders blank. +# +# Values are deliberately NOT printed. Each rule's `description` already states +# its own measurement, formatted and with its threshold, which is the readable +# form of what the raw value dump was trying to say. + +apiVersion: 1 + +templates: + - orgId: 1 + name: xrpld.notifications + template: | + {{- /* Node identity, or a marker when the query label is absent. */ -}} + {{ define "xrpld.node" -}} + {{ if .Labels.service_instance_id }}{{ .Labels.service_instance_id }}{{ else }}unknown node (NoData/Error evaluation){{ end }} + {{- end }} + + {{- /* One line. Slack title and email subject share this. */ -}} + {{ define "xrpld.title" -}} + {{ if .Alerts.Firing }}🔥 FIRING{{ else }}✅ RESOLVED{{ end }} + {{- with .CommonLabels.alertname }} · {{ . }}{{ end }} + {{- with .CommonLabels.severity }} ({{ . }}){{ end }} + {{- with .CommonLabels.service_instance_id }} · {{ . }}{{ end }} + {{- end }} + + {{- /* SLACK body: mrkdwn, emoji shortcodes, links. */ -}} + {{ define "xrpld.slack.body" -}} + {{ with .Alerts.Firing }}{{ range . }} + :rotating_light: *FIRING* · *{{ .Labels.alertname }}* on `{{ template "xrpld.node" . }}` + {{ .Annotations.description }} + {{- with .Annotations.action }} + *Why it matters:* {{ . }} + {{- end }} + {{- range $key, $url := .Annotations }} + {{- if match "^panel_" $key }} + :chart_with_upwards_trend: <{{ $url }}|{{ title (reReplaceAll "_" " " (reReplaceAll "^panel_" "" $key)) }}> + {{- end }}{{ end }} + {{ end }}{{ end }} + {{- with .Alerts.Resolved }}{{ range . }} + :white_check_mark: *RESOLVED* · *{{ .Labels.alertname }}* on `{{ template "xrpld.node" . }}` + {{ .Annotations.description }} + {{ end }}{{ end }} + {{- end }} diff --git a/docker/telemetry/otel-collector-config.yaml b/docker/telemetry/otel-collector-config.yaml index d029e655fb..584109fdf9 100644 --- a/docker/telemetry/otel-collector-config.yaml +++ b/docker/telemetry/otel-collector-config.yaml @@ -137,15 +137,10 @@ processors: action: delete - key: telemetry.sdk.version action: delete - # Defense-in-depth: hash path-finding account attributes. The xrpld SDK - # already hashes these before export, but a node that emitted raw values - # is caught here so raw addresses never reach the backend. - attributes/hash: - actions: - - key: pathfind_source_account - action: hash - - key: pathfind_dest_account - action: hash + # No attribute hashing or redaction. Account addresses in span attributes + # (pathfind_source_account, pathfind_dest_account) are public ledger + # identifiers and are stored as emitted so they join against explorers and + # logs. Do not add an attributes/hash processor for them. connectors: span_metrics: @@ -342,7 +337,7 @@ service: pipelines: traces: receivers: [otlp] - processors: [resource/tier, resource/stripsdk, attributes/hash, batch] + processors: [resource/tier, resource/stripsdk, batch] exporters: [debug, otlp_grpc/tempo, span_metrics] metrics: receivers: [otlp, span_metrics] diff --git a/docker/telemetry/workload/README.md b/docker/telemetry/workload/README.md index 4777dd9cde..6d944bfa91 100644 --- a/docker/telemetry/workload/README.md +++ b/docker/telemetry/workload/README.md @@ -638,21 +638,34 @@ needs: a pattern that swallows unrelated names defeats the check. ### expected_spans.json Format -Each span entry defines its name, category, parent (for hierarchy validation), -required attributes, and the `config_flag` that must be enabled. A trailing `*` -in `name` is a wildcard. The optional `"optional": true` field marks a span whose -absence is a skip rather than a failure: +Each span entry defines its name, category, `allowed_parents`, required +attributes, and the `config_flag` that must be enabled. A trailing `*` in `name` +is a wildcard. The optional `"optional": true` field marks a span whose absence +is a skip rather than a failure: ```json { "name": "rpc.command.*", "category": "rpc", - "parent": "rpc.process", + "allowed_parents": ["rpc.ws_message", "rpc.process", "ROOT"], "required_attributes": ["command", "version", "rpc_role", "rpc_status"], "config_flag": "trace_rpc" } ``` +`allowed_parents` is asserted, by `validate_span_parents`: every emitted instance +of the span must be parented to one of the names listed, so a span declared a +root that is emitted as somebody's child fails a check instead of passing +silently. The list is derived from the span's creation factory plus every call +path that reaches it — `SpanGuard::span(...)` and a plain `ScopedSpanGuard` +inherit the ambient scope, so they take one entry per ambient scope their callers +can be under; `freshRoot`, `linkedSpan` and the standalone `hashSpan` are roots; +`childSpan(name, ctx)` names its parent exactly. `ROOT` means "no parent from +this node", which covers a genuine root and a span whose parent was created on a +different node, since a cross-node parent is the design for the receive spans and +is never a violation. An entry may itself be a glob, matched the same way span +names are. + ## Node Configuration Notes The orchestrator (`run-full-validation.sh`) generates node configs with: diff --git a/docker/telemetry/workload/expected_spans.json b/docker/telemetry/workload/expected_spans.json index 7ed1482688..c52b09b9ea 100644 --- a/docker/telemetry/workload/expected_spans.json +++ b/docker/telemetry/workload/expected_spans.json @@ -1,10 +1,10 @@ { - "description": "Expected span inventory for xrpld telemetry validation. Attribute keys follow the 2026-05-13 span-attr naming redesign (bare/underscore form; dotted xrpl.* reserved for resource attributes). Sourced from the *SpanNames.h headers and verified against the emitting call sites. Spans marked \"optional\": true are conditional \u2014 they only fire under traffic the harness may not produce (e.g. gRPC client, path-finding RPC, missing-ledger fetch, mode transitions) and are not failed when absent. \"parent\" is documentation only (validate_telemetry.py asserts hierarchy from parent_child_relationships, not from this field) and records the parent as the code actually produces it: null means the span is a root or an explicit freshRoot. required_attributes lists only attributes set on EVERY code path that creates the span \u2014 attributes set after an early return are described in the span's note instead, because _validate_span_attributes_otlp samples a single trace and would fail on a legitimate short-circuit path. total_unique_attributes is the size of the union of all required_attributes; total_span_types is len(spans). Span EVENTS (consensus.round phase.*/outcome.*, consensus.update_positions dispute.resolve, consensus.accept.apply tx.included) are NOT represented: validate_telemetry.py reads only span name, attributes and timestamps from Tempo, so an \"events\" key would be silently ignored. They are documented in the relevant span notes until the validator gains event support.", + "description": "Expected span inventory for xrpld telemetry validation. Attribute keys follow the 2026-05-13 span-attr naming redesign (bare/underscore form; dotted xrpl.* reserved for resource attributes). Sourced from the *SpanNames.h headers and verified against the emitting call sites. Spans marked \"optional\": true are conditional — they only fire under traffic the harness may not produce (e.g. gRPC client, path-finding RPC, missing-ledger fetch, mode transitions) and are not failed when absent. \"allowed_parents\" is asserted, by validate_span_parents in validate_telemetry.py: every emitted instance of the span must be parented to one of the names listed, so a span declared a root that is emitted as somebody's child now fails a check instead of passing silently. \"ROOT\" means \"no parent from this node\" — a genuine root, an explicit freshRoot, or a span whose parent was created on a different node, because a cross-node parent is the design for the receive spans and is never a violation. A span reachable by two call paths lists every parent either path can give it, and a listed parent may itself be a glob (pathfind.request is declared under rpc.command.*), matched the same way the span names in this file are. Each list is derived from the span's creation factory plus every call path that reaches it — README.md, section \"expected_spans.json Format\", has the rule — so a span note that lists fewer parents than the code can produce is a bug in this file, not a reason to widen the gate at the call site. Three spans list ROOT even though their factory inherits the ambient scope (ledger.acquire, pathfind.update_all, grpc.*): they are created at a boundary or outlive the creating scope, so an observed parent there is an ambient leak to fix in the C++, not an edge to add here. required_attributes lists only attributes set on EVERY code path that creates the span — attributes set after an early return are described in the span's note instead, because _validate_span_attributes_otlp samples a single trace and would fail on a legitimate short-circuit path. total_unique_attributes is the size of the union of all required_attributes; total_span_types is len(spans). Span EVENTS (consensus.round phase.*/outcome.*, consensus.update_positions dispute.resolve, consensus.accept.apply tx.included) are NOT represented: validate_telemetry.py reads only span name, attributes and timestamps from Tempo, so an \"events\" key would be silently ignored. They are documented in the relevant span notes until the validator gains event support.", "spans": [ { "name": "rpc.ws_message", "category": "rpc", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["command"], "config_flag": "trace_rpc", "note": "WebSocket RPC root span. The load generator uses WS, so this is the RPC entry span (not rpc.http_request, which needs an HTTP/JSON-RPC client)." @@ -12,33 +12,33 @@ { "name": "rpc.ws_upgrade", "category": "rpc", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": [], "config_flag": "trace_rpc", "optional": true, - "note": "WebSocket handshake span (ServerHandler::onHandoff, ServerHandler.cpp:272-273). A freshRoot with no attributes \u2014 only setOk() on success or recordException() on an upgrade failure. Fires once per WS connection, so the load generator produces only a handful of these at connect time; by the time validation runs after the propagation wait they may fall outside the Tempo search window. Optional for that reason, not because the code path is conditional." + "note": "WebSocket handshake span (ServerHandler::onHandoff, ServerHandler.cpp:272-273). A freshRoot with no attributes — only setOk() on success or recordException() on an upgrade failure. Fires once per WS connection, so the load generator produces only a handful of these at connect time; by the time validation runs after the propagation wait they may fall outside the Tempo search window. Optional for that reason, not because the code path is conditional." }, { "name": "rpc.process", "category": "rpc", - "parent": "rpc.http_request", + "allowed_parents": ["rpc.http_request"], "required_attributes": [], "config_flag": "trace_rpc", "optional": true, - "note": "HTTP-only. Created solely in ServerHandler::processRequest() (ServerHandler.cpp:718), which is reached only from processSession(Session, coro) (ServerHandler.cpp:646) \u2014 the HTTP/JSON-RPC path that roots rpc.http_request at ServerHandler.cpp:640-641. The WebSocket path (processSession(WSSession, coro, jv), ServerHandler.cpp:467) never calls processRequest, so this span never appears under a WebSocket request. It does still appear under this harness, 5 traces on a normal run, because run-full-validation.sh polls each node over HTTP with curl (:449, :502) and those requests take the HTTP path. Note that the harness does speak HTTP: a reader concluding this span is unreachable here would go looking for a way to add HTTP traffic that already exists." + "note": "HTTP-only. Created solely in ServerHandler::processRequest() (ServerHandler.cpp:718), which is reached only from processSession(Session, coro) (ServerHandler.cpp:646) — the HTTP/JSON-RPC path that roots rpc.http_request at ServerHandler.cpp:640-641. The WebSocket path (processSession(WSSession, coro, jv), ServerHandler.cpp:467) never calls processRequest, so this span never appears under a WebSocket request. It does still appear under this harness, 5 traces on a normal run, because run-full-validation.sh polls each node over HTTP with curl (:449, :502) and those requests take the HTTP path. Note that the harness does speak HTTP: a reader concluding this span is unreachable here would go looking for a way to add HTTP traffic that already exists." }, { "name": "rpc.command.*", "category": "rpc", - "parent": "rpc.ws_message", + "allowed_parents": ["rpc.ws_message", "rpc.process", "ROOT"], "required_attributes": ["command", "version", "rpc_role", "rpc_status"], "config_flag": "trace_rpc", - "note": "Wildcard \u2014 matches rpc.command.server_info, rpc.command.ledger, etc. Created as an ambient (scoped) child in rpc::doCommand / rpc::callMethod (RPCHandler.cpp:168, :271), so its parent is whichever transport span is active on the thread: rpc.ws_message on the WebSocket path (the harness workload) and rpc.process on the HTTP/JSON-RPC path." + "note": "Wildcard — matches rpc.command.server_info, rpc.command.ledger, etc. Created as a plain ScopedSpanGuard in rpc::callMethod (RPCHandler.cpp:204-205) and, on the fillHandler-rejection path, in rpc::doCommand (:340-341), so its parent is whichever transport span is ambient on the thread. Three call paths, all three exercised by this harness: rpc::doCommand from the WebSocket handler inside the rpc.ws_message scope (ServerHandler.cpp:589 under the freshRoot at :495); from ServerHandler::processRequest inside the rpc.process scope (:1037 under the guard at :734); and from ApplicationImp::setup's [rpc_startup] loop (Application.cpp:1786) on the main thread with no ambient scope, which makes it a ROOT. The third is why ROOT is listed: startTelemetry() runs at Application.cpp:1511, well before that loop, so the startup command really does emit a rooted rpc.command span — run-full-validation.sh writes a [rpc_startup] log_level command for every node, so there are five of them per run." }, { "name": "rpc.http_request", "category": "rpc", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["request_payload_size"], "config_flag": "trace_rpc", "optional": true, @@ -47,14 +47,15 @@ { "name": "tx.process", "category": "transaction", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["tx_hash", "local", "path"], - "config_flag": "trace_transactions" + "config_flag": "trace_transactions", + "note": "Also carries tx_type, fee, sequence, and one attribute per account-typed top-level field of the transaction: tx_account always, plus tx_destination, tx_owner, tx_issuer and so on when the transaction has them (keys in TxAccountSpanNames.h, raw r-addresses). These are set only when the STTx parses, so like tx_type they are not required attributes." }, { "name": "tx.receive", "category": "transaction", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["tx_hash", "peer_id"], "config_flag": "trace_transactions", "note": "Cross-node span: parent context propagated from the sender's tx.process via protobuf. Also carries tx_type and peer_version. The span is created only after the node decides to process the transaction, so a relayed duplicate produces no span at all; how many were dropped is the transactions_duplicate traffic category. tx_status is set only on the paths that drop a transaction after that point, so it is not a required attribute." @@ -62,15 +63,15 @@ { "name": "tx.apply", "category": "transaction", - "parent": "ledger.build", + "allowed_parents": ["ledger.build"], "required_attributes": ["tx_count", "tx_failed"], "config_flag": "trace_transactions", - "note": "Apply-step span inside BuildLedger. Carries tx_count/tx_failed (ledger_seq lives on the parent ledger.build span)." + "note": "Apply-step span inside BuildLedger. Sets its own ledger_seq next to tx_count and tx_failed (BuildLedger.cpp:197-199), so do not re-add the earlier claim that ledger_seq lives on the parent ledger.build span — it does not, and ledger.build sets it independently." }, { "name": "tx.preflight", "category": "transaction", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["stage", "tx_type", "ter_result"], "config_flag": "trace_transactions", "note": "Apply-pipeline stage span (stage=preflight). Shares a deterministic trace_id (txID[0:16]) with tx.preclaim/tx.transactor." @@ -78,7 +79,7 @@ { "name": "tx.preclaim", "category": "transaction", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["stage", "tx_type", "ter_result"], "config_flag": "trace_transactions", "note": "Apply-pipeline stage span (stage=preclaim)." @@ -86,7 +87,7 @@ { "name": "tx.transactor", "category": "transaction", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["stage", "tx_type"], "config_flag": "trace_transactions", "note": "Apply-pipeline stage span (stage=apply). Also carries applied." @@ -94,16 +95,21 @@ { "name": "txq.enqueue", "category": "transaction", - "parent": "tx.process", + "allowed_parents": [ + "tx.process", + "consensus.accept.apply", + "rpc.command.*", + "ROOT" + ], "required_attributes": ["tx_hash", "tx_type", "txq_status"], "config_flag": "trace_transactions", "optional": true, - "note": "Only fires when a tx is queued (fee below open-ledger level). Requires fee escalation \u2014 driven by the txq-burst workload phase. tx_hash/tx_type/txq_status are set on every code path; fee_level_paid/required_fee_level are conditional (TxQ.cpp ~895-898, after the rejected and applied_direct early exits), so they are NOT guaranteed on every txq.enqueue span and cannot be required." + "note": "Only fires when a tx is queued (fee below open-ledger level). Requires fee escalation — driven by the txq-burst workload phase. tx_hash/tx_type/txq_status are set on every code path; fee_level_paid/required_fee_level are conditional (TxQ.cpp ~895-898, after the rejected and applied_direct early exits), so they are NOT guaranteed on every txq.enqueue span and cannot be required. Four parents, because TxQ::apply (TxQ.cpp:770) takes an OPTIONAL parent context (:790-795): with a context it is an explicit childSpan, without one it is a plain ScopedSpanGuard that inherits whatever is ambient. tx.process comes from the submission path, which passes the captured context (NetworkOPs.cpp:1803-1810). consensus.accept.apply comes from the open-ledger rebuild, which passes none: OpenLedger.cpp:121 replays local transactions, and OpenLedger::accept is called from inside doAccept (RCLConsensus.cpp:871) where the consensus span is ambient. ROOT comes from that same rebuild reached by the switchLastClosedLedger jump path instead (NetworkOPs.cpp:2313), which has no ambient scope, and from the submission path when the tx.process context is invalid. rpc.command.* comes from the simulate RPC (Simulate.cpp:249), which passes no context and runs inside the command span's scope; the harness never issues simulate, so that parent is unreachable here but is still lawful." }, { "name": "txq.apply_direct", "category": "transaction", - "parent": "txq.enqueue", + "allowed_parents": ["txq.enqueue"], "required_attributes": [], "config_flag": "trace_transactions", "optional": true, @@ -112,24 +118,25 @@ { "name": "txq.batch_clear", "category": "transaction", - "parent": "txq.enqueue", - "required_attributes": ["num_cleared"], + "allowed_parents": ["txq.enqueue"], + "required_attributes": [], "config_flag": "trace_transactions", - "optional": true + "optional": true, + "note": "TxQ::tryClearAccountQueueUpThruTx (TxQ.cpp:571-572). num_cleared is NOT required: it is written only once the batch has actually cleared, inside the `if (txResult.applied)` branch at TxQ.cpp:646. Three earlier returns leave the span without it — a fee-level overflow (:589-590), a batch that did not pay enough (:598-599), and a queued transaction that failed to apply (:632-635) — as does the final doApply failing, which falls past the branch. The span is opened before any of those decisions, so a required num_cleared would fail the check on a legitimate fall-back-to-normal-processing path rather than on a defect." }, { "name": "txq.accept", "category": "transaction", - "parent": null, + "allowed_parents": ["consensus.accept.apply", "ROOT"], "required_attributes": ["queue_size", "ledger_changed"], "config_flag": "trace_transactions", "optional": true, - "note": "Ledger-close accept loop (TxQ::accept, TxQ.cpp:1499). Only meaningful when the queue is non-empty. Root on BOTH call paths, verified: the consensus path (RCLConsensus.cpp:823, inside doAccept) and the switchLastClosedLedger jump path (NetworkOPs.cpp:2150). The span is a ScopedSpanGuard, so it adopts whatever OTel context is ambient \u2014 but consensus.accept and consensus.accept.apply are unscoped thread-free SpanGuards and activate() is never called outside unit tests, so no consensus span is ever the ambient parent on the JtAccept worker. ledger.build's ScopedSpanGuard has already been destroyed by the time OpenLedger::accept runs." + "note": "Ledger-close accept loop (TxQ::accept, TxQ.cpp:1499). Only meaningful when the queue is non-empty. Two call paths, and they give it two different parents. On the consensus path (RCLConsensus.cpp:823, inside doAccept) consensus.accept.apply is the ambient scope for the whole of doAccept, so this span is its child. On the switchLastClosedLedger jump path (NetworkOPs.cpp:2150) no consensus span is open on that thread, so the span is a root. The span is a ScopedSpanGuard and adopts whatever OTel context is ambient, which is what makes the parent a property of the caller rather than of this span." }, { "name": "txq.accept_tx", "category": "transaction", - "parent": "txq.accept", + "allowed_parents": ["txq.accept"], "required_attributes": [ "tx_hash", "ter_code", @@ -142,16 +149,16 @@ { "name": "txq.cleanup", "category": "transaction", - "parent": null, + "allowed_parents": ["consensus.accept.apply", "ROOT"], "required_attributes": ["ledger_seq", "expired_count"], "config_flag": "trace_transactions", "optional": true, - "note": "TxQ::processClosedLedger (TxQ.cpp:1403). Root on BOTH call paths for the same reason as txq.accept: the consensus path (RCLConsensus.cpp:950) and the switchLastClosedLedger jump path (NetworkOPs.cpp:2121) both run with no consensus span activated as ambient context." + "note": "TxQ::processClosedLedger (TxQ.cpp:1403). The same two call paths as txq.accept, with the same split: the consensus path (RCLConsensus.cpp:950) runs inside the consensus.accept.apply scope and nests under it, while the switchLastClosedLedger jump path (NetworkOPs.cpp:2121) has no consensus span open and leaves it a root." }, { "name": "consensus.round", "category": "consensus", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": [ "consensus_ledger_id", "ledger_seq", @@ -160,19 +167,19 @@ "consensus_phase" ], "config_flag": "trace_consensus", - "note": "Root consensus span created per round. Also carries trace_strategy, previous_ledger_seq, previous_proposers, previous_round_time_ms. Emits seven span EVENTS that this manifest cannot assert: phase.open, phase.recovery, phase.establish, phase.accepted, outcome.yes, outcome.moved_on, outcome.expired (declared ConsensusSpanNames.h:265-277; emitted RCLConsensus.cpp:1344 and via onPhaseEvent/onOutcomeEvent from Consensus.h:764, 793, 1047, 1517-1525, 1530, 1566), plus a `view.change` event emitted from RCLConsensus::Adaptor::getPrevLedger when the network's preferred ledger differs from this node's (carries prev_ledger_prefix and net_ledger_prefix). validate_telemetry.py reads only span name, attributes and start/end timestamps from the Tempo OTLP payload \u2014 it has no event assertion support \u2014 so adding an \"events\" key here would be silently ignored. Recorded as a note instead; asserting events needs validator support first." + "note": "Root consensus span created per round. Also carries trace_strategy, previous_ledger_seq, previous_proposers, previous_round_time_ms. Emits seven span EVENTS that this manifest cannot assert: phase.open, phase.recovery, phase.establish, phase.accepted, outcome.yes, outcome.moved_on, outcome.expired (declared ConsensusSpanNames.h:265-277; emitted RCLConsensus.cpp:1344 and via onPhaseEvent/onOutcomeEvent from Consensus.h:764, 793, 1047, 1517-1525, 1530, 1566), plus a `view.change` event emitted from RCLConsensus::Adaptor::getPrevLedger when the network's preferred ledger differs from this node's (carries prev_ledger_prefix and net_ledger_prefix). validate_telemetry.py reads only span name, attributes and start/end timestamps from the Tempo OTLP payload — it has no event assertion support — so adding an \"events\" key here would be silently ignored. Recorded as a note instead; asserting events needs validator support first." }, { "name": "consensus.phase.open", "category": "consensus", - "parent": "consensus.round", + "allowed_parents": ["consensus.round"], "required_attributes": [], "config_flag": "trace_consensus" }, { "name": "consensus.proposal.send", "category": "consensus", - "parent": "consensus.round", + "allowed_parents": ["consensus.round"], "required_attributes": ["consensus_round"], "config_flag": "trace_consensus", "note": "Also carries is_bow_out." @@ -180,7 +187,7 @@ { "name": "consensus.ledger_close", "category": "consensus", - "parent": "consensus.round", + "allowed_parents": ["consensus.round"], "required_attributes": ["ledger_seq", "consensus_mode"], "config_flag": "trace_consensus", "note": "Also carries tx_count_open, close_time_resolution_ms." @@ -188,7 +195,7 @@ { "name": "consensus.establish", "category": "consensus", - "parent": "consensus.round", + "allowed_parents": ["consensus.round"], "required_attributes": [ "converge_percent", "establish_count", @@ -200,19 +207,19 @@ { "name": "consensus.update_positions", "category": "consensus", - "parent": "consensus.establish", + "allowed_parents": ["consensus.establish"], "required_attributes": [ "converge_percent", "proposers", "disputes_count" ], "config_flag": "trace_consensus", - "note": "childSpan of establishSpanContext_ (Consensus.h:1628), so the parent is consensus.establish \u2014 not consensus.round. Also emits a dispute.resolve span EVENT per resolved dispute (Consensus.h:1697-1698), which validate_telemetry.py cannot assert (no event support)." + "note": "childSpan of establishSpanContext_ (Consensus.h:1628), so the parent is consensus.establish — not consensus.round. Also emits a dispute.resolve span EVENT per resolved dispute (Consensus.h:1697-1698), which validate_telemetry.py cannot assert (no event support)." }, { "name": "consensus.check", "category": "consensus", - "parent": "consensus.establish", + "allowed_parents": ["consensus.establish"], "required_attributes": [ "agree_count", "disagree_count", @@ -220,19 +227,19 @@ "consensus_result" ], "config_flag": "trace_consensus", - "note": "childSpan of establishSpanContext_ (Consensus.h:1837), so the parent is consensus.establish \u2014 not consensus.round." + "note": "childSpan of establishSpanContext_ (Consensus.h:1837), so the parent is consensus.establish — not consensus.round." }, { "name": "consensus.accept", "category": "consensus", - "parent": "consensus.round", + "allowed_parents": ["consensus.round"], "required_attributes": ["proposers", "round_time_ms", "quorum"], "config_flag": "trace_consensus" }, { "name": "consensus.accept.apply", "category": "consensus", - "parent": "consensus.accept", + "allowed_parents": ["consensus.accept"], "required_attributes": [ "ledger_seq", "close_time_ripple_epoch_s", @@ -242,12 +249,12 @@ "resolution_direction" ], "config_flag": "trace_consensus", - "note": "Also carries close_time_correct, close_resolution_ms, consensus_state, proposing, round_time_ms, tx_count. Emits a tx.included span EVENT per transaction in the accepted set (RCLConsensus.cpp:720, with a tx_id attribute), which validate_telemetry.py cannot assert (no event support)." + "note": "Also carries close_time_correct, close_resolution_ms, consensus_state, proposing, round_time_ms, tx_count. Emits a tx.included span EVENT per transaction in the accepted set (RCLConsensus.cpp:720, with a tx_id attribute), which validate_telemetry.py cannot assert (no event support). One parent: a scoped childSpan of the captured accept context (RCLConsensus.cpp doAccept). The context is valid whenever the accept span is live; when it is not, the factory returns a null guard and no span is emitted at all. Scoped, so the txq spans doAccept creates after it are its children." }, { "name": "consensus.validation.send", "category": "consensus", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": [ "ledger_seq", "proposing", @@ -260,7 +267,7 @@ { "name": "consensus.proposal.receive", "category": "consensus", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": [], "config_flag": "trace_consensus", "note": "Context-propagated from the sending peer. No required local attributes." @@ -268,7 +275,7 @@ { "name": "consensus.validation.receive", "category": "consensus", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": [], "config_flag": "trace_consensus", "note": "Context-propagated from the sending peer. No required local attributes." @@ -276,7 +283,7 @@ { "name": "consensus.validation.accept", "category": "consensus", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": [ "ledger_hash", "ledger_seq", @@ -290,16 +297,16 @@ { "name": "consensus.mode_change", "category": "consensus", - "parent": "consensus.round", + "allowed_parents": ["consensus.round"], "required_attributes": ["mode_old", "mode_new"], "config_flag": "trace_consensus", "optional": true, - "note": "childSpan of roundSpanContext_ (RCLConsensus.cpp:1101), so the parent is consensus.round. Only fires on an operating-mode transition; a steady cluster rarely changes mode after warmup. A mode change outside a round leaves roundSpanContext_ invalid, which yields a null (no-op) guard rather than a root span." + "note": "childSpan of roundSpanContext_ (RCLConsensus.cpp:1101), so the parent is consensus.round. Only fires on an operating-mode transition; a steady cluster rarely changes mode after warmup. Note the handler itself is called at the start of EVERY round, with the before and after mode equal on almost all of them, so the guard on before != after is the whole reason this span is conditional rather than per-round — without it the span records no change and span.mode_change.records_a_real_change fails. A mode change outside a round leaves roundSpanContext_ invalid, which yields a null (no-op) guard rather than a root span." }, { "name": "ledger.build", "category": "ledger", - "parent": null, + "allowed_parents": ["consensus.accept.apply", "ROOT"], "required_attributes": [ "ledger_seq", "close_time_ripple_epoch_s", @@ -307,28 +314,28 @@ "close_resolution_ms" ], "config_flag": "trace_ledger", - "note": "tx_count/tx_failed live on the child tx.apply span, not here." + "note": "tx_count/tx_failed live on the child tx.apply span, not here. Two call paths give it two parents. The consensus path (RCLConsensus.cpp:983-985, inside doAccept) builds the ledger inside the consensus.accept.apply scope, so the span nests under it. The LedgerDeltaAcquire replay path (LedgerDeltaAcquire.cpp:208) rebuilds a historical ledger with no consensus span open on that thread, so the span is a root there." }, { "name": "ledger.validate", "category": "ledger", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["ledger_hash", "ledger_seq", "validations"], "config_flag": "trace_ledger", - "note": "ledger_hash is required because it is the trace-join key, not merely a descriptive attribute: this span is the anchor of the per_ledger group in trace_join_groups, and the join is computed by hashing that value. Both it and ledger_seq are stamped unconditionally by LedgerMaster::makeLedgerTraceSpan (LedgerMaster.cpp:186-190, called at :1089), so requiring it costs nothing on a healthy run. Without this the only symptom of a lost join key would be assert_trace_join_groups reporting that spans landed in separate traces, which names the consequence rather than the cause." + "note": "Created in LedgerMaster::checkAccept (LedgerMaster.cpp:1002-1003) with the ambient-inheriting span() factory, so its parent is whatever scope is open on the calling thread. The consensus path reaches it inside doAccept — consensusBuilt (RCLConsensus.cpp:803) calls checkAccept at LedgerMaster.cpp:1149, and the node's own validation takes the same route through handleNewValidation (RCLConsensus.cpp:1115) — so there it nests under the consensus span. ROOT covers the two paths with no ambient scope: an inbound peer validation, which PeerImp defers to a JtValidationT/JtValidationUt job (PeerImp.cpp:2751-2757) carrying only a never-activated SpanGuard handle, and the InboundLedger completion path (InboundLedger.cpp:168, :583). allowed_parents is ROOT alone on this branch: the span is a hashSpan (a true root on the ledger-hash trace), so it never inherits the consensus.accept.apply scope the upstream branches document." }, { "name": "ledger.store", "category": "ledger", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["ledger_hash", "ledger_seq"], "config_flag": "trace_ledger", - "note": "ledger_hash is required for the same reason as on ledger.validate: it is the per_ledger trace-join key, stamped unconditionally by LedgerMaster::makeLedgerTraceSpan (called at LedgerMaster.cpp:511). This span is the required_member of that join group, so a missing key here breaks the join from the other end." + "note": "Created in LedgerMaster::storeLedger (LedgerMaster.cpp:470) with the ambient-inheriting span() factory. The consensus path reaches it from RCLConsensus.cpp:1001, inside doAccept, so there it nests under the consensus span. ROOT covers every other caller, none of which has an ambient scope: InboundLedger.cpp:164 and :574, LedgerDeltaAcquire.cpp:243, and the genesis/startup stores at Application.cpp:2028 and :2037. allowed_parents is ROOT alone on this branch: the span is a hashSpan (a true root on the ledger-hash trace), so it never inherits the consensus.accept.apply scope the upstream branches document." }, { "name": "ledger.acquire", "category": "ledger", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": [ "ledger_seq", "acquire_reason", @@ -338,12 +345,12 @@ ], "config_flag": "trace_ledger", "optional": true, - "note": "Only fires when a node must fetch a missing ledger (InboundLedger). A healthy local cluster rarely back-fills history. outcome is one of complete|failed|abandoned and is stamped on every exit path, including the sweep/shutdown path where the fetch never finished (abandoned). ledger_hash identifies the target ledger from the first moment, since a by-hash acquire starts with ledger_seq 0. peer_count is not required: the destructor path deliberately skips the peer lookup to avoid taking the Overlay lock under the InboundLedgers collection lock." + "note": "Only fires when a node must fetch a missing ledger (InboundLedger). A healthy local cluster rarely back-fills history. outcome is one of complete|failed|abandoned and is stamped on every exit path, including the sweep/shutdown path where the fetch never finished (abandoned). ledger_hash identifies the target ledger from the first moment, since a by-hash acquire starts with ledger_seq 0. peer_count is not required: the destructor path deliberately skips the peer lookup to avoid taking the Overlay lock under the InboundLedgers collection lock. allowed_parents is ROOT alone on this branch: the span is a hashSpan (a true root on the ledger-hash trace), so it never inherits the consensus.accept.apply scope the upstream branches document." }, { "name": "ledger.acquire.header", "category": "ledger", - "parent": "ledger.acquire", + "allowed_parents": ["ledger.acquire"], "required_attributes": ["ledger_hash", "outcome", "timed_out"], "config_flag": "trace_ledger", "optional": true, @@ -352,7 +359,7 @@ { "name": "ledger.acquire.astree", "category": "ledger", - "parent": "ledger.acquire", + "allowed_parents": ["ledger.acquire"], "required_attributes": [ "ledger_hash", "outcome", @@ -366,7 +373,7 @@ { "name": "ledger.acquire.txtree", "category": "ledger", - "parent": "ledger.acquire", + "allowed_parents": ["ledger.acquire"], "required_attributes": [ "ledger_hash", "outcome", @@ -380,7 +387,7 @@ { "name": "ledger.serve", "category": "ledger", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": [ "object_type", "peer_id", @@ -394,7 +401,7 @@ { "name": "txset.acquire", "category": "ledger", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": [ "txset_hash", "outcome", @@ -409,7 +416,7 @@ { "name": "peer.dial", "category": "peer", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["remote_endpoint"], "config_flag": "trace_peer", "note": "One outbound connect attempt (ConnectAttempt), a fresh trace root because a dial is the first thing a starting node does and there is nothing to parent it to. Required: run-full-validation.sh lists the other four nodes in each node's [ips], so every node dials and the span always fires. Telemetry is live in time to catch it -- ApplicationImp::setup() calls startTelemetry() before start() calls overlay_->start(). outcome carries the same six values as the overlay_connect_total counter (connected|tcp_fail|tls_fail|self_connection|upgrade_fail|timeout) and is set from the same reportOutcome() funnel, so span and counter cannot disagree. remote_endpoint is the span-only dimension the counter cannot carry, since one series per peer address would be unbounded cardinality. Among its attributes only remote_endpoint is required. outcome and duration_ms are set on every terminal path, both inside reportOutcome(), but NOT on the teardown path: an attempt destroyed during overlay shutdown, or one whose connect was aborted, ends its span in ~ConnectAttempt with neither, deliberately -- the destructor's own comment records that a span ending with no outcome is the honest record of a dial that never concluded. Because peer.dial is a freshRoot, each dial is its own trace holding exactly one instance of the span, and the validator inspects only the most recent trace; a single newly-aborted dial would therefore fail CI on healthy behaviour. That single-trace sampling of a one-instance span is what makes requiring them unsafe -- not any doubt about the terminal paths setting them." @@ -417,44 +424,44 @@ { "name": "peer.proposal.receive", "category": "peer", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["peer_id"], "config_flag": "trace_peer", - "note": "peer_id is set immediately after the freshRoot (PeerImp.cpp:1925) and is the only unconditional attribute. proposal_trusted is set at PeerImp.cpp:1953, after several early returns (stale/duplicate/self-originated proposal checks), so a single rejected proposal in the sampled trace would fail the check \u2014 it is therefore not required." + "note": "peer_id is set immediately after the freshRoot (PeerImp.cpp:1925) and is the only unconditional attribute. proposal_trusted is set at PeerImp.cpp:1953, after several early returns (stale/duplicate/self-originated proposal checks), so a single rejected proposal in the sampled trace would fail the check — it is therefore not required." }, { "name": "peer.validation.receive", "category": "peer", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["peer_id", "ledger_hash", "full_validation"], "config_flag": "trace_peer", - "note": "ledger_hash and full_validation are shared with consensus.validation.send (same keys, told apart by span name). Both are set at PeerImp.cpp:2573-2574, BEFORE the isCurrent() gate, so only a too-small or unparseable validation skips them \u2014 they stay required (and validate_telemetry.py's PARITY_SPAN_ATTRS already asserts them independently). validation_trusted is set at PeerImp.cpp:2591, after the isCurrent() early return at :2576-2584, so a single not-current validation in the sampled trace would fail the check \u2014 it is therefore not required." + "note": "ledger_hash and full_validation are shared with consensus.validation.send (same keys, told apart by span name). Both are set at PeerImp.cpp:2573-2574, BEFORE the isCurrent() gate, so only a too-small or unparseable validation skips them — they stay required (and validate_telemetry.py's PARITY_SPAN_ATTRS already asserts them independently). validation_trusted is set at PeerImp.cpp:2591, after the isCurrent() early return at :2576-2584, so a single not-current validation in the sampled trace would fail the check — it is therefore not required." }, { "name": "pathfind.request", "category": "pathfind", - "parent": "rpc.command.*", + "allowed_parents": ["rpc.command.*"], "required_attributes": [ "pathfind_source_account", "pathfind_dest_account" ], "config_flag": "trace_rpc", "optional": true, - "note": "Fires on ripple_path_find / path_find RPC. Optional because the harness issues neither: rpc_load_generator.py's DEFAULT_WEIGHTS carries no ripple_path_find entry, and no workload-profiles.json phase names a path-finding command in a weights override, so no such RPC reaches a node at all. Created as an ambient (scoped) child inside the RPC command handler (RipplePathFind.cpp:35-36, PathFind.cpp:26-27), so its parent is the enclosing rpc.command.* span \u2014 RipplePathFind.cpp:30 states this explicitly. Note the span does NOT need pathfinding to be ENABLED, only the RPC to be issued: the ScopedSpanGuard is constructed at RipplePathFind.cpp:35, above the 'if (pathSearchMax == 0) return rpcError(RpcNotSupported)' guard at :48-49, so even a refused call opens and closes it. That positional accident means the load alone would satisfy this entry while pathfind.compute and pathfind.discover below stayed unasserted, and it is why such refusals would also drive a steady ~3% STATUS_CODE_ERROR floor in span_calls_total. Adding the load alone would make this span required and introduce that error floor; covering the whole family needs pathfinding actually enabled (a [path_search_max] override in run-full-validation.sh \u2014 see the pathfind.compute entry below). The workload README section 'Pathfinding is not exercised' carries the full recipe for enabling it." + "note": "Fires on ripple_path_find / path_find RPC. Optional because the harness issues neither: rpc_load_generator.py's DEFAULT_WEIGHTS carries no ripple_path_find entry, and no workload-profiles.json phase names a path-finding command in a weights override, so no such RPC reaches a node at all. Created as an ambient (scoped) child inside the RPC command handler (RipplePathFind.cpp:35-36, PathFind.cpp:26-27), so its parent is the enclosing rpc.command.* span — RipplePathFind.cpp:30 states this explicitly. Note the span does NOT need pathfinding to be ENABLED, only the RPC to be issued: the ScopedSpanGuard is constructed at RipplePathFind.cpp:35, above the 'if (pathSearchMax == 0) return rpcError(RpcNotSupported)' guard at :48-49, so even a refused call opens and closes it. That positional accident means the load alone would satisfy this entry while pathfind.compute and pathfind.discover below stayed unasserted, and it is why such refusals would also drive a steady ~3% STATUS_CODE_ERROR floor in span_calls_total. Adding the load alone would make this span required and introduce that error floor; covering the whole family needs pathfinding actually enabled (a [path_search_max] override in run-full-validation.sh — see the pathfind.compute entry below). The workload README section 'Pathfinding is not exercised' carries the full recipe for enabling it." }, { "name": "pathfind.compute", "category": "pathfind", - "parent": "pathfind.request", + "allowed_parents": ["pathfind.request", "pathfind.update_all"], "required_attributes": ["pathfind_fast"], "config_flag": "trace_rpc", "optional": true, - "note": "Created by PathRequest::doUpdate (PathRequest.cpp:749-750), which the harness never reaches: pathfinding is disabled on every harness node, so no PathRequest is ever constructed. Config.cpp:725-726 sets pathSearchMax to 0 when a [validation_seed] or [validator_token] section is present, run-full-validation.sh writes [validation_seed] for every node and has no [path_search*] override, and doRipplePathFind then returns RpcNotSupported at RipplePathFind.cpp:59-60 \u2014 above the request-construction branches and below the pathfind.request span guard at :35. Liquidity is not the reason and never was: the call is refused before any path search is attempted, so the outcome does not depend on what the ledger holds. There is a second, independent reason: the harness sends no path-finding RPC at all, since rpc_load_generator.py carries no ripple_path_find weight. Enabling this span therefore needs BOTH a [path_search_max] override (or a non-validator node) in run-full-validation.sh AND the load restored \u2014 see the workload README section 'Pathfinding is not exercised'." + "note": "Created by PathRequest::doUpdate (PathRequest.cpp:749-750), which the harness never reaches: pathfinding is disabled on every harness node, so no PathRequest is ever constructed. Config.cpp:725-726 sets pathSearchMax to 0 when a [validation_seed] or [validator_token] section is present, run-full-validation.sh writes [validation_seed] for every node and has no [path_search*] override, and doRipplePathFind then returns RpcNotSupported at RipplePathFind.cpp:59-60 — above the request-construction branches and below the pathfind.request span guard at :35. Liquidity is not the reason and never was: the call is refused before any path search is attempted, so the outcome does not depend on what the ledger holds. There is a second, independent reason: the harness sends no path-finding RPC at all, since rpc_load_generator.py carries no ripple_path_find weight. Enabling this span therefore needs BOTH a [path_search_max] override (or a non-validator node) in run-full-validation.sh AND the load restored — see the workload README section 'Pathfinding is not exercised'. Two parents: doUpdate's plain ScopedSpanGuard (PathRequest.cpp:788-789) inherits the ambient scope, and doUpdate has callers under both of them — under pathfind.request via PathRequest.cpp:272 and PathRequestManager.cpp:334, and under pathfind.update_all via PathRequestManager.cpp:161 and :176, where updateAll's own scoped guard is open." }, { "name": "pathfind.discover", "category": "pathfind", - "parent": "pathfind.compute", + "allowed_parents": ["pathfind.compute"], "required_attributes": ["pathfind_search_level", "pathfind_num_paths"], "config_flag": "trace_rpc", "optional": true, @@ -463,25 +470,25 @@ { "name": "pathfind.update_all", "category": "pathfind", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["pathfind_ledger_index", "pathfind_num_requests"], "config_flag": "trace_rpc", "optional": true, - "note": "Async recomputation at ledger close. PathRequestManager::updateAll emits the span only when requests_ is non-empty (PathRequestManager.cpp:88-95), so it needs a live path_find subscription. On the harness requests_ can never become non-empty at all: the only two insertPathRequest call sites are makePathRequest (:268) and makeLegacyPathRequest (:296), and both handlers return RpcNotSupported first because pathfinding is disabled on every node (PathFind.cpp:50-51, RipplePathFind.cpp:59; see the pathfind.compute entry above for the config chain)." + "note": "Async recomputation at ledger close. PathRequestManager::updateAll emits the span only when requests_ is non-empty (PathRequestManager.cpp:88-95), so it needs a live path_find subscription. On the harness requests_ can never become non-empty at all: the only two insertPathRequest call sites are makePathRequest (:268) and makeLegacyPathRequest (:296), and both handlers return RpcNotSupported first because pathfinding is disabled on every node (PathFind.cpp:50-51, RipplePathFind.cpp:59; see the pathfind.compute entry above for the config chain). ROOT despite the ambient-inheriting ScopedSpanGuard, because the one caller is LedgerMaster's advance thread (LedgerMaster.cpp:1491) with no scope open. An observed parent is an ambient leak into that thread, not a lawful edge." }, { "name": "grpc.*", "category": "grpc", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["method", "grpc_role", "grpc_status"], "config_flag": "trace_rpc", "optional": true, - "note": "Wildcard \u2014 grpc.. The harness has no gRPC client, so these do not fire. Tracked for completeness." + "note": "Wildcard — grpc.. The harness has no gRPC client, so these do not fire. Tracked for completeness. ROOT despite the ambient-inheriting span() factory (GRPCServer.cpp:174), because the span is created at the inbound boundary on a gRPC handler thread that runs no other instrumented work. An observed parent is an ambient leak onto that thread, not a lawful edge." }, { "name": "nodestore.rotate", "category": "ledger", - "parent": null, + "allowed_parents": ["ROOT"], "required_attributes": ["ledger_seq", "last_rotated", "outcome"], "config_flag": "trace_ledger", "optional": true, @@ -490,7 +497,7 @@ { "name": "nodestore.rotate.clear_prior", "category": "ledger", - "parent": "nodestore.rotate", + "allowed_parents": ["nodestore.rotate"], "required_attributes": [], "config_flag": "trace_ledger", "optional": true, @@ -499,7 +506,7 @@ { "name": "nodestore.rotate.copy", "category": "ledger", - "parent": "nodestore.rotate", + "allowed_parents": ["nodestore.rotate"], "required_attributes": ["node_count", "nodes_copied"], "config_flag": "trace_ledger", "optional": true, @@ -508,7 +515,7 @@ { "name": "nodestore.rotate.freshen.fetch", "category": "ledger", - "parent": "nodestore.rotate", + "allowed_parents": ["nodestore.rotate"], "required_attributes": ["key_count", "cache", "keys_copied"], "config_flag": "trace_ledger", "optional": true, @@ -517,7 +524,7 @@ { "name": "nodestore.rotate.new_backend", "category": "ledger", - "parent": "nodestore.rotate", + "allowed_parents": ["nodestore.rotate"], "required_attributes": [], "config_flag": "trace_ledger", "optional": true, @@ -526,7 +533,7 @@ { "name": "nodestore.rotate.clear_caches", "category": "ledger", - "parent": "nodestore.rotate", + "allowed_parents": ["nodestore.rotate"], "required_attributes": [], "config_flag": "trace_ledger", "optional": true, @@ -535,7 +542,7 @@ { "name": "nodestore.rotate.swap", "category": "ledger", - "parent": "nodestore.rotate", + "allowed_parents": ["nodestore.rotate"], "required_attributes": ["copy_forwards"], "config_flag": "trace_ledger", "optional": true, @@ -544,7 +551,15 @@ { "name": "nodestore.rotate.health_wait", "category": "ledger", - "parent": "nodestore.rotate", + "allowed_parents": [ + "nodestore.rotate", + "nodestore.rotate.clear_prior", + "nodestore.rotate.copy", + "nodestore.rotate.freshen.fetch", + "nodestore.rotate.new_backend", + "nodestore.rotate.clear_caches", + "nodestore.rotate.swap" + ], "required_attributes": ["server_mode", "missing_ledgers"], "config_flag": "trace_ledger", "optional": true, @@ -557,12 +572,12 @@ "child": "rpc.process", "description": "WebSocket message contains processing span", "skip": true, - "skip_reason": "This relationship does not exist in the code: rpc.process is created only in ServerHandler::processRequest() (ServerHandler.cpp:718), reached only from processSession(Session, coro) (ServerHandler.cpp:646) \u2014 the HTTP/JSON-RPC path. The WebSocket path (processSession(WSSession, coro, jv), ServerHandler.cpp:467) never calls processRequest, so rpc.process is never emitted at all under the WebSocket-only harness. The earlier diagnosis (cross-thread context loss needing a C++ fix) was wrong: rpc.ws_message is a deliberate freshRoot (ServerHandler.cpp:473-474) so each WS message is its own trace rather than nesting under a span leaked on a reused coroutine worker. Nothing to fix." + "skip_reason": "This relationship does not exist in the code: rpc.process is created only in ServerHandler::processRequest() (ServerHandler.cpp:718), reached only from processSession(Session, coro) (ServerHandler.cpp:646) — the HTTP/JSON-RPC path. The WebSocket path (processSession(WSSession, coro, jv), ServerHandler.cpp:467) never calls processRequest, so rpc.process is never emitted at all under the WebSocket-only harness. The earlier diagnosis (cross-thread context loss needing a C++ fix) was wrong: rpc.ws_message is a deliberate freshRoot (ServerHandler.cpp:473-474) so each WS message is its own trace rather than nesting under a span leaked on a reused coroutine worker. Nothing to fix." }, { "parent": "rpc.ws_message", "child": "rpc.command.*", - "description": "WebSocket message contains the per-command span \u2014 the real relationship on the harness WS path (rpc::doCommand at RPCHandler.cpp:271 creates an ambient child of the rpc.ws_message scope inside the same coroutine). Not skipped, because the validator globs the wildcard child: _span_name_matches() matches via fnmatch.fnmatchcase, so any rpc.command. under the parent satisfies the contract and the command mix does not matter. A validator that instead collapsed the wildcard to one literal name -- child_name.replace(\"*\", \"server_info\") -- would make this check depend on which command the sampled traces happened to carry: server_info is 25/100 of rpc_load_generator.py's DEFAULT_WEIGHTS, so a healthy run could sample three non-server_info traces and fail." + "description": "WebSocket message contains the per-command span — the real relationship on the harness WS path (rpc::doCommand at RPCHandler.cpp:271 creates an ambient child of the rpc.ws_message scope inside the same coroutine). Not skipped, because the validator globs the wildcard child: _span_name_matches() matches via fnmatch.fnmatchcase, so any rpc.command. under the parent satisfies the contract and the command mix does not matter. A validator that instead collapsed the wildcard to one literal name -- child_name.replace(\"*\", \"server_info\") -- would make this check depend on which command the sampled traces happened to carry: server_info is 25/100 of rpc_load_generator.py's DEFAULT_WEIGHTS, so a healthy run could sample three non-server_info traces and fail." }, { "parent": "rpc.process", @@ -589,7 +604,7 @@ "child": "pathfind.compute", "description": "Pathfind request contains the compute sub-span", "skip": true, - "skip_reason": "Real relationship (pathfind.compute is created inside PathRequest::doUpdate at PathRequest.cpp:749-750, under the pathfind.request scope), but the child never exists on the harness because pathfinding is disabled on every node: Config.cpp:725-726 zeroes pathSearchMax whenever a [validation_seed] or [validator_token] section is present, run-full-validation.sh writes [validation_seed] for all five nodes with no [path_search*] override, and doRipplePathFind returns RpcNotSupported at RipplePathFind.cpp:59-60 before constructing a PathRequest. The parent would appear anyway if the RPC were issued, because its ScopedSpanGuard is created at RipplePathFind.cpp:35, above that guard; but rpc_load_generator.py carries no ripple_path_find weight, so not even the parent appears and both ends of this relationship are absent. Liquidity has nothing to do with it \u2014 the earlier 'no liquidity, returns before computing' reason was wrong, because no path search is attempted at all. Asserting this relationship needs the load restored AND a [path_search_max] override (or a non-validator node) in run-full-validation.sh." + "skip_reason": "Real relationship (pathfind.compute is created inside PathRequest::doUpdate at PathRequest.cpp:749-750, under the pathfind.request scope), but the child never exists on the harness because pathfinding is disabled on every node: Config.cpp:725-726 zeroes pathSearchMax whenever a [validation_seed] or [validator_token] section is present, run-full-validation.sh writes [validation_seed] for all five nodes with no [path_search*] override, and doRipplePathFind returns RpcNotSupported at RipplePathFind.cpp:59-60 before constructing a PathRequest. The parent would appear anyway if the RPC were issued, because its ScopedSpanGuard is created at RipplePathFind.cpp:35, above that guard; but rpc_load_generator.py carries no ripple_path_find weight, so not even the parent appears and both ends of this relationship are absent. Liquidity has nothing to do with it — the earlier 'no liquidity, returns before computing' reason was wrong, because no path search is attempted at all. Asserting this relationship needs the load restored AND a [path_search_max] override (or a non-validator node) in run-full-validation.sh." }, { "parent": "ledger.acquire", @@ -764,5 +779,5 @@ }, "_conditional_attributes_note": "Five attributes documented in the 'Fresh-node sync diagnostics' table of docs/telemetry-glossary.md are deliberately absent from required_attributes above, because each is emitted only when its value is known and _validate_span_attributes_otlp() has no per-attribute optional flag -- listing one would fail CI red on a healthy run. ledger.acquire/peer_count is set only when finalizeAcquireSpan() is passed a peer count (InboundLedger.cpp), which the sweep and shutdown paths cannot supply. ledger_seq on the three ledger.acquire.header/.astree/.txtree phase spans is set only when seq_ != 0 (InboundLedger.cpp startPhaseSpan), and a by-hash acquire starts with seq_ == 0 and learns the sequence only when the header arrives -- so a phase that opens before the header legitimately carries no sequence. ledger_seq on ledger.serve is set only when the reply carries one (PeerImp.cpp), which an object-by-hash request does not. All five ARE indexed in the glossary table and rendered by the Ledger Sync Health board; the honest encoding is to document them here rather than assert a conditional attribute as required.", "total_span_types": 56, - "total_unique_attributes": 83 + "total_unique_attributes": 81 } diff --git a/docker/telemetry/workload/test_validate_telemetry.py b/docker/telemetry/workload/test_validate_telemetry.py index ac5f09a6ba..08c97e8b22 100644 --- a/docker/telemetry/workload/test_validate_telemetry.py +++ b/docker/telemetry/workload/test_validate_telemetry.py @@ -17,6 +17,7 @@ query itself and on the answer the check derives from a known corpus. import asyncio import json import sys +import tempfile from pathlib import Path from typing import Any @@ -24,6 +25,22 @@ sys.path.insert(0, str(Path(__file__).parent)) import validate_telemetry as vt # noqa: E402 +# The node a corpus entry belongs to unless it says otherwise. Named rather than +# repeated, because a test that writes it out for one span and relies on the +# default for another is asserting the two are the same node. +DEFAULT_INSTANCE = "node-1" + +# Arbitrary, and only the relative order matters. Non-zero so that a span whose +# start time went missing somewhere reads as earlier than every real one rather +# than tying with them. +START_TIME_BASE_NANOS = 1_000_000_000 + +# A corpus entry naming this as its parent gets the all-zero span id written out +# as its parentSpanId, instead of an id resolved from another span's name. That is +# OTLP's second spelling of "no parent": Tempo omits the field, but the field is +# optional rather than forbidden, so a root can arrive spelled this way. +ROOT_PARENT_SPAN_ID = "AAAAAAAAAAA=" + class FakeResponse: """Minimal stand-in for an aiohttp response used as an async context manager.""" @@ -51,15 +68,25 @@ class FakeTempo: Args: traces: Maps a trace id to the spans that trace contains, ordered newest first, which is the order /api/search returns. Each entry is - either a bare span name (a root span, no parent) or a - ``(name, parent_name)`` pair. A parent_name that no span in the - trace carries yields a parentSpanId pointing at a span the trace - does not hold, which is how a dangling chain is expressed. + either a bare span name (a root span, no parent) or a tuple + ``(name, parent_name[, instance[, attributes]])``. A parent_name + that no span in the trace carries yields a parentSpanId pointing + at a span the trace does not hold, which is how a dangling chain + is expressed, and the sentinel ROOT_PARENT_SPAN_ID writes OTLP's + all-zero "no parent" id verbatim. ``instance`` is the exporting + node's service.instance.id and defaults to DEFAULT_INSTANCE, so + a cross-node parent is written by giving the two spans different + ones. ``attributes`` is a plain str-to-str mapping, emitted in + OTLP stringValue form. Span ids are generated as ``-``. Their spelling does not matter: the code under test compares parentSpanId to spanId as opaque strings, exactly because Tempo's own encoding of those fields (hex or base64) is not something the validator should depend on. + + Start times follow the corpus order, one nanosecond apart, so listing spans + in the order they ran is how a test states that order. That is what lets the + round-shape check's phase-order assertion be exercised at all. """ def __init__(self, traces: dict[str, list[Any]]) -> None: @@ -76,10 +103,16 @@ class FakeTempo: span: dict[str, Any] = { "name": names[i], "spanId": ids[i], - "attributes": [], + "attributes": [ + {"key": k, "value": {"stringValue": v}} + for k, v in _entry_attributes(entry).items() + ], + "startTimeUnixNano": str(START_TIME_BASE_NANOS + i), } parent = entry[1] if isinstance(entry, tuple) else None - if parent is not None: + if parent == ROOT_PARENT_SPAN_ID: + span["parentSpanId"] = ROOT_PARENT_SPAN_ID + elif parent is not None: # An unknown parent name deliberately produces an id no span in # this trace owns, so the walk up the chain hits a gap. span["parentSpanId"] = ( @@ -90,6 +123,36 @@ class FakeTempo: spans.append(span) return spans + def _batches_for(self, tid: str) -> list[dict[str, Any]]: + """Group one trace's spans into one OTLP batch per exporting node. + + Tempo carries service.instance.id on the batch resource, not on the + span, so a trace that spans two nodes genuinely arrives as two batches. + The parent gate reads that field to tell a same-node parent from a + cross-node one, and a single batch could not express the difference -- + every span would claim the same node and a cross-node parent would read + as a mis-parenting. + """ + spans = self._spans_for(tid) + instances = [_entry_instance(e) for e in self.traces.get(tid, [])] + grouped: dict[str, list[dict[str, Any]]] = {} + for span, instance in zip(spans, instances): + grouped.setdefault(instance, []).append(span) + return [ + { + "resource": { + "attributes": [ + { + "key": "service.instance.id", + "value": {"stringValue": instance}, + } + ] + }, + "scopeSpans": [{"spans": batch}], + } + for instance, batch in grouped.items() + ] + def get(self, url: str, params: dict[str, str] | None = None) -> FakeResponse: params = params or {} if "/api/search" in url: @@ -103,17 +166,29 @@ class FakeTempo: return FakeResponse({"traces": [{"traceID": t} for t in matched[:limit]]}) if "/api/traces/" in url: tid = url.rsplit("/", 1)[-1] - return FakeResponse( - {"batches": [{"scopeSpans": [{"spans": self._spans_for(tid)}]}]} - ) + return FakeResponse({"batches": self._batches_for(tid)}) raise AssertionError(f"unexpected request: {url}") def _entry_name(entry: Any) -> str: - """The span name of a corpus entry, whether bare or a (name, parent) pair.""" + """The span name of a corpus entry, whether bare or a tuple.""" return entry[0] if isinstance(entry, tuple) else entry +def _entry_instance(entry: Any) -> str: + """The exporting node of a corpus entry, defaulting to DEFAULT_INSTANCE.""" + if isinstance(entry, tuple) and len(entry) > 2 and entry[2] is not None: + return str(entry[2]) + return DEFAULT_INSTANCE + + +def _entry_attributes(entry: Any) -> dict[str, str]: + """The span attributes of a corpus entry, defaulting to none.""" + if isinstance(entry, tuple) and len(entry) > 3 and entry[3] is not None: + return dict(entry[3]) + return {} + + def _query_matches_trace(query: str, names: list[str]) -> bool: """Evaluate the subset of TraceQL this suite uses against one trace. @@ -559,6 +634,514 @@ def test_literal_predicate_uses_equality() -> None: assert not vt._span_name_matches("txq.accept_tx_extra", "txq.accept_tx") +def test_span_declared_root_that_is_a_same_node_child_fails() -> None: + """The audit finding: ledger.build declared ROOT, emitted under accept.""" + tempo = FakeTempo( + {"t1": ["consensus.accept", ("ledger.build", "consensus.accept")]} + ) + report = Report() + run( + vt._validate_span_parents_for( + tempo, + "http://tempo", + {"name": "ledger.build", "allowed_parents": ["ROOT"]}, + report, + ) + ) + assert len(report.results) == 1, report.results + assert not report.results[0].passed, report.results[0].message + assert "consensus.accept" in report.results[0].message + + +def test_cross_node_parent_is_not_a_violation() -> None: + """Review Focus 1: tx.receive's parent is the sender's span, on another node.""" + tempo = FakeTempo( + {"t1": [("tx.process", None, "node-2"), ("tx.receive", "tx.process", "node-1")]} + ) + report = Report() + run( + vt._validate_span_parents_for( + tempo, + "http://tempo", + {"name": "tx.receive", "allowed_parents": ["ROOT"]}, + report, + ) + ) + assert len(report.results) == 1, report.results + assert report.results[0].passed, report.results[0].message + + +def test_same_node_parent_of_a_receive_span_is_a_violation() -> None: + """The control for the test above: the node id is what excuses the parent. + + Identical corpus except that both spans came from one node, which is the + shape a genuine mis-parenting of tx.receive would have. Without this, the + cross-node test passes just as happily against a gate that never reads + _instance at all and treats every in-trace parent as unprovable. + """ + tempo = FakeTempo( + {"t1": [("tx.process", None, "node-1"), ("tx.receive", "tx.process", "node-1")]} + ) + report = Report() + run( + vt._validate_span_parents_for( + tempo, + "http://tempo", + {"name": "tx.receive", "allowed_parents": ["ROOT"]}, + report, + ) + ) + assert len(report.results) == 1, report.results + assert not report.results[0].passed, report.results[0].message + assert "tx.process" in report.results[0].message + + +def test_second_call_path_parent_is_allowed_when_listed() -> None: + """Review Focus 2: txq.accept is a child on one path and a root on the other.""" + tempo = FakeTempo( + { + "t1": ["consensus.accept.apply", ("txq.accept", "consensus.accept.apply")], + "t2": ["txq.accept"], + } + ) + report = Report() + run( + vt._validate_span_parents_for( + tempo, + "http://tempo", + { + "name": "txq.accept", + "allowed_parents": ["consensus.accept.apply", "ROOT"], + }, + report, + ) + ) + assert len(report.results) == 1, report.results + assert report.results[0].passed, report.results[0].message + + +def test_absent_optional_span_skips_rather_than_fails() -> None: + """Review Focus 3: a span the harness never emits has nothing to judge.""" + tempo = FakeTempo({"t1": ["consensus.round"]}) + report = Report() + run( + vt._validate_span_parents_for( + tempo, + "http://tempo", + { + "name": "nodestore.rotate.swap", + "allowed_parents": ["nodestore.rotate"], + "optional": True, + }, + report, + ) + ) + assert len(report.results) == 1, report.results + assert report.results[0].passed, report.results[0].message + assert "not emitted" in report.results[0].message + + +def test_absent_required_span_fails_rather_than_skips() -> None: + """A span the contract does NOT mark optional must fail when absent. + + The control for the skip above. A gate that returned passed=True for every + absent span would report green on a node that stopped emitting consensus + spans entirely, which is the loudest failure the harness exists to catch. + """ + tempo = FakeTempo({"t1": ["consensus.round"]}) + report = Report() + run( + vt._validate_span_parents_for( + tempo, + "http://tempo", + {"name": "ledger.build", "allowed_parents": ["ROOT"]}, + report, + ) + ) + assert len(report.results) == 1, report.results + assert not report.results[0].passed, report.results[0].message + assert "not emitted" in report.results[0].message + + +def test_parent_id_absent_from_the_trace_is_inconclusive() -> None: + """Review Focus 4: a rotation still in flight has not exported its root.""" + tempo = FakeTempo({"t1": [("nodestore.rotate.copy", "nodestore.rotate")]}) + report = Report() + run( + vt._validate_span_parents_for( + tempo, + "http://tempo", + { + "name": "nodestore.rotate.copy", + "allowed_parents": ["nodestore.rotate"], + "optional": True, + }, + report, + ) + ) + assert len(report.results) == 1, report.results + assert report.results[0].passed, report.results[0].message + assert "nothing was provable" in report.results[0].message + + +def test_a_glob_in_allowed_parents_matches_the_family() -> None: + """pathfind.request's only lawful parent is written as rpc.command.*. + + The contract allows a glob on the parent side as well as on the span's own + name, and the concrete command varies per request, so plain set membership + would read every real parent as a violation. Asserted here because the + pathfinding family is never emitted on the harness, so a live run cannot + reach this path and would not notice it being wrong. + """ + tempo = FakeTempo( + {"t1": ["rpc.command.fee", ("pathfind.request", "rpc.command.fee")]} + ) + report = Report() + run( + vt._validate_span_parents_for( + tempo, + "http://tempo", + {"name": "pathfind.request", "allowed_parents": ["rpc.command.*"]}, + report, + ) + ) + assert len(report.results) == 1, report.results + assert report.results[0].passed, report.results[0].message + + +def test_round_missing_a_required_child_fails() -> None: + """A round whose phases are incomplete must fail, naming the phase. + + consensus.accept is present on purpose: it is the trace-selection predicate, + so a corpus without it exercises the "no trace holds both" path instead and + the test would be red for the wrong reason. The genuinely missing phase here + is consensus.ledger_close. + """ + tempo = FakeTempo( + { + "t1": [ + "consensus.round", + ("consensus.phase.open", "consensus.round"), + ("consensus.establish", "consensus.round"), + ("consensus.accept", "consensus.round"), + ] + } + ) + report = Report() + run(vt.validate_consensus_round_shape(tempo, "http://tempo", report)) + children = next(r for r in report.results if r.name == "span.round.children") + assert not children.passed, children.message + assert "consensus.ledger_close" in children.message + # The node id is the whole point of a red here: a five-node cluster gives no + # way to act on "1 of 5 rounds" without it. + assert DEFAULT_INSTANCE in children.message, children.message + + +def test_round_without_accept_in_the_newest_trace_is_not_a_missing_child() -> None: + """Item 3: a round exported before its accept span must not read as broken. + + The accept span always outlives the round span, and with a two-second export + batch delay the newest round can reach Tempo while its consensus.accept child + is still in the exporter. Selecting the newest rounds reports a missing child + on a healthy cluster; selecting traces that hold both does not. + + The production change that makes this fail: dropping the + `&& {name="consensus.accept"}` term from the search query. + """ + tempo = FakeTempo( + { + # Newest first, as /api/search returns. The newest round has not had + # its accept exported yet. + "t2": ["consensus.round", ("consensus.phase.open", "consensus.round")], + "t1": [ + "consensus.round", + ("consensus.phase.open", "consensus.round"), + ("consensus.ledger_close", "consensus.round"), + ("consensus.establish", "consensus.round"), + ("consensus.accept", "consensus.round"), + ], + } + ) + report = Report() + run(vt.validate_consensus_round_shape(tempo, "http://tempo", report)) + assert all(r.passed for r in report.results), [r.message for r in report.results] + + +def test_no_trace_holding_both_round_and_accept_is_its_own_message() -> None: + """Nothing to judge is a distinct failure from a badly shaped round. + + Reporting it as a missing child would send whoever reads it looking for a + consensus bug when the real state is that Tempo holds no usable trace. + """ + tempo = FakeTempo({"t1": ["ledger.build"]}) + report = Report() + run(vt.validate_consensus_round_shape(tempo, "http://tempo", report)) + assert len(report.results) == 1, report.results + assert not report.results[0].passed + assert "No trace holds both" in report.results[0].message + + +def test_a_round_is_not_shaped_from_another_nodes_phase_spans() -> None: + """A round's children are its OWN node's children, not the trace's. + + The deterministic trace strategy derives the trace_id from the previous + ledger hash, so all five validators' round spans arrive in one trace. Here + node-1's round span has no phases of its own and every phase span in the + trace was exported by node-2 while naming node-1's round as its parent -- + which is precisely the case a parentSpanId-only filter cannot tell apart from + a healthy round. node-1's round is missing all four phases, and that is what + the gate must report. + + The production change that makes this fail: dropping the _instance + comparison from the child filter, which makes node-1's round look complete. + """ + tempo = FakeTempo( + { + "t1": [ + "consensus.round", + ("consensus.phase.open", "consensus.round", "node-2"), + ("consensus.ledger_close", "consensus.round", "node-2"), + ("consensus.establish", "consensus.round", "node-2"), + ("consensus.accept", "consensus.round", "node-2"), + ] + } + ) + report = Report() + run(vt.validate_consensus_round_shape(tempo, "http://tempo", report)) + children = next(r for r in report.results if r.name == "span.round.children") + assert not children.passed, children.message + assert children.details["missing"] == { + "consensus.phase.open": 1, + "consensus.ledger_close": 1, + "consensus.establish": 1, + "consensus.accept": 1, + }, children.details + assert DEFAULT_INSTANCE in children.message, children.message + + +def test_round_with_all_children_in_order_passes() -> None: + tempo = FakeTempo( + { + "t1": [ + "consensus.round", + ("consensus.phase.open", "consensus.round"), + ("consensus.ledger_close", "consensus.round"), + ("consensus.establish", "consensus.round"), + ("consensus.accept", "consensus.round"), + ] + } + ) + report = Report() + run(vt.validate_consensus_round_shape(tempo, "http://tempo", report)) + assert all(r.passed for r in report.results), [r.message for r in report.results] + + +def test_round_with_phases_out_of_order_fails() -> None: + """The control for the pass above: the order has to be read, not assumed. + + Every required child is present, so span.round.children passes; only + span.round.phase_order can catch accept having started before open. Without + this test the positive case above passes against a gate that never compares + start times at all. + """ + tempo = FakeTempo( + { + "t1": [ + "consensus.round", + ("consensus.accept", "consensus.round"), + ("consensus.phase.open", "consensus.round"), + ("consensus.ledger_close", "consensus.round"), + ("consensus.establish", "consensus.round"), + ] + } + ) + report = Report() + run(vt.validate_consensus_round_shape(tempo, "http://tempo", report)) + children = next(r for r in report.results if r.name == "span.round.children") + assert children.passed, children.message + order = next(r for r in report.results if r.name == "span.round.phase_order") + assert not order.passed, order.message + assert "out of order" in order.message + + +def test_mode_change_with_equal_modes_fails() -> None: + """Finding 2: a mode_change span that records no change.""" + tempo = FakeTempo( + { + "t1": [ + "consensus.round", + ("consensus.phase.open", "consensus.round"), + ("consensus.ledger_close", "consensus.round"), + ("consensus.establish", "consensus.round"), + ("consensus.accept", "consensus.round"), + ( + "consensus.mode_change", + "consensus.round", + "node-1", + {"mode_old": "Observing", "mode_new": "Observing"}, + ), + ] + } + ) + report = Report() + run(vt.validate_consensus_round_shape(tempo, "http://tempo", report)) + mc = next( + r for r in report.results if r.name == "span.mode_change.records_a_real_change" + ) + assert not mc.passed, mc.message + + +def test_mode_change_recording_a_real_change_passes() -> None: + """The control: a transition must not be reported as a defect. + + Same corpus as the failing case with one mode differing, so the gate is + shown to be reading the two attributes rather than failing on the span's + mere presence -- which would make every real mode transition red. + """ + tempo = FakeTempo( + { + "t1": [ + "consensus.round", + ("consensus.phase.open", "consensus.round"), + ("consensus.ledger_close", "consensus.round"), + ("consensus.establish", "consensus.round"), + ("consensus.accept", "consensus.round"), + ( + "consensus.mode_change", + "consensus.round", + "node-1", + {"mode_old": "Observing", "mode_new": "Proposing"}, + ), + ] + } + ) + report = Report() + run(vt.validate_consensus_round_shape(tempo, "http://tempo", report)) + mc = next( + r for r in report.results if r.name == "span.mode_change.records_a_real_change" + ) + assert mc.passed, mc.message + assert "1 mode_change span(s)" in mc.message + + +def test_root_written_as_the_all_zero_span_id_still_counts_as_root() -> None: + """OTLP's other spelling of "no parent" must not read as unprovable. + + Tempo omits parentSpanId for a root, so this shape does not occur against it + today. An exporter or backend that writes the all-zero id instead would make + EVERY root unprovable, and an unprovable parent passes -- so the gate would + go quietly fail-open on exactly the spans it exists to judge. + + The message is asserted, not just the verdict: without the all-zero handling + this test still sees passed=True, because the id matches no span in the trace + and the span is counted as unprovable instead. + """ + tempo = FakeTempo({"t1": [("ledger.build", ROOT_PARENT_SPAN_ID)]}) + report = Report() + run( + vt._validate_span_parents_for( + tempo, + "http://tempo", + {"name": "ledger.build", "allowed_parents": ["ROOT"]}, + report, + ) + ) + assert len(report.results) == 1, report.results + assert report.results[0].passed, report.results[0].message + message = report.results[0].message + assert "every provable parent" in message, message + assert report.results[0].details["observed"] == {"ROOT": 1}, report.results[ + 0 + ].details + assert report.results[0].details["unprovable"] == 0, report.results[0].details + + +def test_validate_span_parents_checks_every_contract_entry() -> None: + """The sweep must visit the whole inventory, one result per entry. + + _validate_span_parents_for is well covered on its own, but nothing proved + that the caller iterates -- a loop that returned after the first entry, or + read a different key than 'spans', would leave 40 spans unchecked while the + report still looked healthy. Driven through a real file so the loader is + exercised too, rather than by stubbing _load_expected_spans. + """ + contract = { + "spans": [ + {"name": "consensus.round", "allowed_parents": ["ROOT"]}, + { + "name": "consensus.accept", + "allowed_parents": ["consensus.round"], + }, + ] + } + tempo = FakeTempo( + {"t1": ["consensus.round", ("consensus.accept", "consensus.round")]} + ) + report = Report() + original = vt.EXPECTED_SPANS_FILE + with tempfile.TemporaryDirectory() as tmp: + scratch = Path(tmp) / "expected_spans.json" + scratch.write_text(json.dumps(contract)) + vt.EXPECTED_SPANS_FILE = scratch + try: + run(vt.validate_span_parents(tempo, "http://tempo", report)) + finally: + vt.EXPECTED_SPANS_FILE = original + assert [r.name for r in report.results] == [ + "span.parent.consensus.round", + "span.parent.consensus.accept", + ], [r.name for r in report.results] + assert all(r.passed for r in report.results), [r.message for r in report.results] + + +def test_a_contract_entry_with_no_name_fails_only_itself() -> None: + """A malformed entry must not abort the sweep over the rest. + + Reading span_def["name"] outside the try raised KeyError out of the loop, so + one bad entry silently cost every later span its check. Now it is one failed + result and the sweep continues. + """ + contract = { + "spans": [ + {"allowed_parents": ["ROOT"]}, + {"name": "consensus.round", "allowed_parents": ["ROOT"]}, + ] + } + tempo = FakeTempo({"t1": ["consensus.round"]}) + report = Report() + original = vt.EXPECTED_SPANS_FILE + with tempfile.TemporaryDirectory() as tmp: + scratch = Path(tmp) / "expected_spans.json" + scratch.write_text(json.dumps(contract)) + vt.EXPECTED_SPANS_FILE = scratch + try: + run(vt.validate_span_parents(tempo, "http://tempo", report)) + finally: + vt.EXPECTED_SPANS_FILE = original + assert len(report.results) == 2, [r.name for r in report.results] + assert not report.results[0].passed, report.results[0].message + assert report.results[0].name == "span.parent." + assert report.results[1].passed, report.results[1].message + + +def test_every_contract_span_declares_allowed_parents() -> None: + """The contract itself: no entry may be left without the new key. + + validate_span_parents returns early on an entry with an empty or missing + allowed_parents, so a span that kept the old `parent` key would be silently + unchecked -- the exact failure mode this change exists to remove. Asserted + against the real file rather than a fixture, because the file is the thing + that can drift. + """ + contract = vt._load_expected_spans() + spans = contract["spans"] + assert spans, "expected_spans.json declares no spans" + missing = [s["name"] for s in spans if not s.get("allowed_parents")] + assert not missing, f"entries with no allowed_parents: {missing}" + stale = [s["name"] for s in spans if "parent" in s] + assert not stale, f"entries still carrying the old parent key: {stale}" + + def main() -> int: tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] # Collecting nothing is a failure, not a pass. A rename of the test_ prefix, diff --git a/docker/telemetry/workload/validate_telemetry.py b/docker/telemetry/workload/validate_telemetry.py index 6fcb60896a..76fcf72842 100644 --- a/docker/telemetry/workload/validate_telemetry.py +++ b/docker/telemetry/workload/validate_telemetry.py @@ -33,6 +33,7 @@ Usage: import argparse import asyncio +import collections import fnmatch import json import logging @@ -389,6 +390,9 @@ async def _tempo_get_trace( Returns: Flat list of span dicts as Tempo returned them, carrying at least 'name', 'attributes', 'spanId' and, for non-root spans, 'parentSpanId'. + Each span also gains '_instance', the service.instance.id of the batch + resource it arrived under, or '' when that resource carries none. The + leading underscore marks it as added here rather than sent by Tempo. Empty when Tempo has no trace with this id. Raises: @@ -416,8 +420,18 @@ async def _tempo_get_trace( data = await resp.json() spans: list[dict[str, Any]] = [] for batch in data.get("batches", []): + # The resource is per batch and flattening drops it, but which node + # a span came from is the difference between a mis-parented span and + # an ordinary cross-node parent. Stamp it onto each span. + instance = "" + for attr in batch.get("resource", {}).get("attributes", []): + if attr.get("key") == "service.instance.id": + instance = str(attr.get("value", {}).get("stringValue", "")) + break for scope_spans in batch.get("scopeSpans", []): - spans.extend(scope_spans.get("spans", [])) + for span in scope_spans.get("spans", []): + span["_instance"] = instance + spans.append(span) return spans @@ -519,6 +533,23 @@ def _unaccounted_span_names(emitted: list[str], expected: dict[str, Any]) -> lis # --------------------------------------------------------------------------- +def _load_expected_spans() -> dict[str, Any]: + """Parse expected_spans.json. + + Every span check reads the contract through this one function so that two + checks cannot end up disagreeing about it -- an inline open() in each would + let one of them be pointed at a different file or key during a refactor + while the other kept passing. + + Returns: + The parsed contract: a dict with 'spans' and + 'parent_child_relationships' keys. + """ + with open(EXPECTED_SPANS_FILE) as f: + contract: dict[str, Any] = json.load(f) + return contract + + async def validate_spans( session: aiohttp.ClientSession, tempo_url: str, @@ -538,8 +569,7 @@ async def validate_spans( logger.info("--- Span Validation (Tempo) ---") # Load expected spans. - with open(EXPECTED_SPANS_FILE) as f: - expected = json.load(f) + expected = _load_expected_spans() # Check service registration. try: @@ -1071,6 +1101,353 @@ async def _validate_parent_child( ) +_ALLOWED_PARENT_ROOT = "ROOT" + +# A parent span id of eight zero bytes is OTLP's "no parent". Tempo 2.9.4 omits +# the field entirely for a root instead -- measured on a 114-span trace, 80 spans +# with no parentSpanId, 34 with one, none empty and none all-zero -- but OTLP +# permits the all-zero id, so an exporter or backend that writes it must not turn +# every root into an unprovable parent. That would make this gate fail open on +# exactly the spans it exists to judge, and silently: "nothing was provable" +# passes. Both encodings a JSON OTLP payload can carry are listed, base64 (what +# Tempo emits for a real id) and lowercase hex. +_ROOT_PARENT_SPAN_IDS = frozenset({"AAAAAAAAAAA=", "0000000000000000"}) + + +def _observed_parent_label( + span: dict[str, Any], + by_id: dict[str, dict[str, Any]], +) -> str | None: + """Classify one span's parent as a label, or None when nothing is provable. + + Returns the parent span's name when the parent is in the trace and came from + the same node, _ALLOWED_PARENT_ROOT when the span has no parent at all + (either because the field is absent or because it holds the all-zero id), + and None when the parent is on another node or is not in the trace. + + None is deliberately not a verdict. A cross-node parent is the design for + the receive spans, and a parent id the trace does not hold means the parent + has not been exported yet, which a rotation in flight produces routinely. + + Args: + span: One OTLP span dict as _tempo_get_trace returned it, so carrying + the '_instance' key that function stamps on. + by_id: Every span in the same trace, keyed by its spanId. + + Returns: + The parent's name, _ALLOWED_PARENT_ROOT, or None. + """ + parent_id = span.get("parentSpanId", "") + if not parent_id or parent_id in _ROOT_PARENT_SPAN_IDS: + return _ALLOWED_PARENT_ROOT + parent = by_id.get(parent_id) + if parent is None: + return None + if parent.get("_instance", "") != span.get("_instance", ""): + return None + return str(parent.get("name", "")) + + +async def _validate_span_parents_for( + session: aiohttp.ClientSession, + tempo_url: str, + span_def: dict[str, Any], + report: ValidationReport, +) -> None: + """Check that every emitted instance of one span has an allowed parent. + + Driven by the span's own allowed_parents list rather than by the declared + relationship rows, so it covers every span in the inventory instead of the + pairs somebody remembered to declare -- and it is the only check that can + fail a span for being parented when it should be a root. + + Args: + session: aiohttp client session. + tempo_url: Base URL for Tempo API. + span_def: One entry from expected_spans.json's 'spans' array. + report: ValidationReport to accumulate results. + """ + # Read inside the try, so a contract entry missing its name is reported as + # one failed check rather than raised out of validate_span_parents' loop and + # taking every remaining span's check with it. + name = "" + check = "span.parent." + try: + name = str(span_def["name"]) + check = f"span.parent.{name}" + allowed = set(span_def.get("allowed_parents", [])) + if not allowed: + return + query = ( + '{resource.service.name="xrpld" && ' + _traceql_name_predicate(name) + "}" + ) + traces = await _tempo_search(session, tempo_url, query, limit=5) + if not traces: + optional = bool(span_def.get("optional", False)) + report.add( + CheckResult( + name=check, + category="span", + passed=optional, + message=f"{name}: not emitted under this workload, parent not checked", + details={"optional": optional}, + ) + ) + return + observed: collections.Counter[str] = collections.Counter() + unprovable = 0 + for summary in traces: + trace_id = summary.get("traceID", "") + if not trace_id: + continue + spans = await _tempo_get_trace(session, tempo_url, trace_id) + by_id = {s["spanId"]: s for s in spans if s.get("spanId")} + for span in spans: + if not _span_name_matches(span.get("name", ""), name): + continue + label = _observed_parent_label(span, by_id) + if label is None: + unprovable += 1 + else: + observed[label] += 1 + # An allowed_parents entry may itself be a glob -- pathfind.request is + # declared under rpc.command.* -- so membership goes through the same + # matcher the contract's span names use rather than a set lookup, which + # would read every concrete rpc.command. as a violation. + violations = { + lbl: n + for lbl, n in observed.items() + if not any(_span_name_matches(lbl, pattern) for pattern in allowed) + } + total = sum(observed.values()) + unprovable + if violations: + worst = max(violations.items(), key=lambda kv: kv[1]) + message = ( + f"{name}: parented to {worst[0]} on {worst[1]} of {total} " + f"instance(s); allowed: {sorted(allowed)}" + ) + elif observed: + message = f"{name}: every provable parent is one of {sorted(allowed)}" + else: + message = ( + f"{name}: {unprovable} instance(s), every parent on another node or " + "absent from the trace, so nothing was provable" + ) + report.add( + CheckResult( + name=check, + category="span", + passed=not violations, + message=message, + details={ + "observed": dict(observed), + "unprovable": unprovable, + "allowed": sorted(allowed), + }, + ) + ) + except Exception as exc: # noqa: BLE001 - a backend fault is a check failure + report.add( + CheckResult( + name=check, + category="span", + passed=False, + message=f"{name}: parent check failed ({exc})", + ) + ) + + +async def validate_span_parents( + session: aiohttp.ClientSession, + tempo_url: str, + report: ValidationReport, +) -> None: + """Run the parent gate over every span in the inventory. + + Args: + session: aiohttp client session. + tempo_url: Base URL for Tempo API. + report: ValidationReport to accumulate results. + """ + logger.info("--- Span Parent Validation (Tempo) ---") + for span_def in _load_expected_spans().get("spans", []): + await _validate_span_parents_for(session, tempo_url, span_def, report) + + +_ROUND_REQUIRED_CHILDREN = ( + "consensus.phase.open", + "consensus.ledger_close", + "consensus.establish", + "consensus.accept", +) + + +async def validate_consensus_round_shape( + session: aiohttp.ClientSession, + tempo_url: str, + report: ValidationReport, +) -> None: + """Check a consensus round's child set, their order, and mode_change. + + The presence and hierarchy checks judge one span at a time, so a round + missing a phase, or running its phases out of order, passes them both. The + shape of a round is what an operator reads a trace for, so it is asserted + directly: every required child under the same round span, on the same node, + and their start times in protocol order. Candidate traces are selected by + co-occurrence rather than recency -- see the comment on the query. + + mode_change rides along because it is a child of the same span. Its whole + purpose is to record a transition, so mode_old == mode_new is a defect + rather than a data point. + + Args: + session: aiohttp client session. + tempo_url: Base URL for Tempo API. + report: ValidationReport to accumulate results. + """ + logger.info("--- Consensus Round Shape (Tempo) ---") + try: + # Select traces holding the round AND its last phase, the way + # _validate_parent_child does, rather than the newest rounds. The accept + # span always outlives the round span -- the round guard is reset inside + # doAccept while the accept span's shared_ptr dies with the JtAccept + # lambda -- so with batch_delay_ms=2000 the two can leave in different + # export batches and the newest round becomes searchable before its + # consensus.accept child arrives. Sampling the newest rounds therefore + # reports a missing child on a perfectly shaped round, periodically. + query = '{resource.service.name="xrpld" && name="consensus.round"}' + traces = await _tempo_search( + session, + tempo_url, + query + ' && {name="consensus.accept"}', + limit=5, + ) + if not traces: + report.add( + CheckResult( + name="span.round.children", + category="span", + passed=False, + message=( + "No trace holds both consensus.round and consensus.accept, " + "so round shape could not be checked" + ), + ) + ) + return + missing: collections.Counter[str] = collections.Counter() + # Node ids are collected per failing check, not per round: a red here is + # only actionable if it says which node produced the bad shape, and on a + # five-node cluster "1 of 5 rounds" does not. + missing_nodes: set[str] = set() + disordered_nodes: set[str] = set() + equal_mode_nodes: set[str] = set() + rounds = out_of_order = mode_changes = equal_modes = 0 + for summary in traces: + trace_id = summary.get("traceID", "") + if not trace_id: + continue + spans = await _tempo_get_trace(session, tempo_url, trace_id) + for parent in [s for s in spans if s.get("name") == "consensus.round"]: + rounds += 1 + node = str(parent.get("_instance", "")) or "(unknown node)" + # Same node as well as same parent id: one trace carries every + # validator's view of the round, so a round span from node A and + # a phase span from node B must not be read as one round. + kids = [ + s + for s in spans + if s.get("parentSpanId") == parent.get("spanId") + and s.get("_instance", "") == parent.get("_instance", "") + ] + by_name = {s.get("name", ""): s for s in kids} + for required in _ROUND_REQUIRED_CHILDREN: + if required not in by_name: + missing[required] += 1 + missing_nodes.add(node) + starts = [ + int(by_name[n].get("startTimeUnixNano", "0")) + for n in _ROUND_REQUIRED_CHILDREN + if n in by_name + ] + if starts != sorted(starts): + out_of_order += 1 + disordered_nodes.add(node) + for mc in [s for s in kids if s.get("name") == "consensus.mode_change"]: + mode_changes += 1 + attrs = { + a["key"]: a.get("value", {}) for a in mc.get("attributes", []) + } + old = attrs.get("mode_old", {}).get("stringValue") + new = attrs.get("mode_new", {}).get("stringValue") + if old is not None and old == new: + equal_modes += 1 + equal_mode_nodes.add(node) + report.add( + CheckResult( + name="span.round.children", + category="span", + passed=not missing, + message=( + f"{rounds} round(s): every required child present" + if not missing + else f"{rounds} round(s) missing children: {dict(missing)} " + f"on {sorted(missing_nodes)}" + ), + details={ + "rounds": rounds, + "missing": dict(missing), + "nodes": sorted(missing_nodes), + }, + ) + ) + report.add( + CheckResult( + name="span.round.phase_order", + category="span", + passed=out_of_order == 0, + message=( + f"{rounds} round(s): phases start in protocol order" + if out_of_order == 0 + else f"{out_of_order} of {rounds} round(s) started their phases " + f"out of order on {sorted(disordered_nodes)}" + ), + details={ + "rounds": rounds, + "out_of_order": out_of_order, + "nodes": sorted(disordered_nodes), + }, + ) + ) + report.add( + CheckResult( + name="span.mode_change.records_a_real_change", + category="span", + passed=equal_modes == 0, + message=( + f"{mode_changes} mode_change span(s), none with mode_old == mode_new" + if equal_modes == 0 + else f"{equal_modes} of {mode_changes} mode_change span(s) recorded " + f"no change (mode_old == mode_new) on {sorted(equal_mode_nodes)}" + ), + details={ + "mode_changes": mode_changes, + "equal": equal_modes, + "nodes": sorted(equal_mode_nodes), + }, + ) + ) + except Exception as exc: # noqa: BLE001 - a backend fault is a check failure + report.add( + CheckResult( + name="span.round.children", + category="span", + passed=False, + message=f"Round shape check failed ({exc})", + ) + ) + + # --------------------------------------------------------------------------- # Trace-join Validation (Tempo API) # --------------------------------------------------------------------------- @@ -2733,6 +3110,8 @@ async def run_validation( async with aiohttp.ClientSession(timeout=REQUEST_TIMEOUT) as session: await validate_spans(session, tempo_url, report) + await validate_span_parents(session, tempo_url, report) + await validate_consensus_round_shape(session, tempo_url, report) await validate_span_durations(session, tempo_url, report) await assert_trace_join_groups(session, tempo_url, report) await validate_metrics(session, prometheus_url, report) diff --git a/docs/telemetry-runbook.md b/docs/telemetry-runbook.md index de2eed1196..8d7baf6664 100644 --- a/docs/telemetry-runbook.md +++ b/docs/telemetry-runbook.md @@ -281,14 +281,14 @@ this span: count successes as total minus error, or filter on `status_code`. ### Transaction Spans -| Span Name | Source File | Attributes | Description | -| --------------- | --------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| `tx.process` | NetworkOPs.cpp | `tx_hash`, `local`, `path`, `tx_type`, `fee`, `sequence`, `ter_result`, `applied`, `current_ledger_seq` | Transaction submission and processing | -| `tx.receive` | PeerImp.cpp | `peer_id`, `tx_hash`, `tx_type`, `peer_version`, `tx_status`, `current_ledger_seq` | Transaction this node will process, received from peer relay | -| `tx.apply` | BuildLedger.cpp | `tx_count`, `tx_failed` | Transaction set applied per ledger | -| `tx.preflight` | applySteps.cpp | `stage`, `tx_type`, `ter_result` | Stateless checks stage | -| `tx.preclaim` | applySteps.cpp | `stage`, `tx_type`, `ter_result`, `current_ledger_seq`, `current_ledger_hash` | Ledger-aware checks stage | -| `tx.transactor` | Transactor.cpp | `stage`, `tx_type`, `ter_result`, `applied`, `current_ledger_seq`, `current_ledger_hash` | Apply stage (transactor runs) | +| Span Name | Source File | Attributes | Description | +| --------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `tx.process` | NetworkOPs.cpp | `tx_hash`, `local`, `path`, `tx_type`, `fee`, `sequence`, `ter_result`, `applied`, `current_ledger_seq`, `tx_account` and one `tx_` per other account field the transaction carries (`tx_destination`, `tx_owner`, ...; keys in `TxAccountSpanNames.h`) | Transaction submission and processing | +| `tx.receive` | PeerImp.cpp | `peer_id`, `tx_hash`, `tx_type`, `peer_version`, `tx_status`, `current_ledger_seq` | Transaction this node will process, received from peer relay | +| `tx.apply` | BuildLedger.cpp | `tx_count`, `tx_failed` | Transaction set applied per ledger | +| `tx.preflight` | applySteps.cpp | `stage`, `tx_type`, `ter_result` | Stateless checks stage | +| `tx.preclaim` | applySteps.cpp | `stage`, `tx_type`, `ter_result`, `current_ledger_seq`, `current_ledger_hash` | Ledger-aware checks stage | +| `tx.transactor` | Transactor.cpp | `stage`, `tx_type`, `ter_result`, `applied`, `current_ledger_seq`, `current_ledger_hash` | Apply stage (transactor runs) | The three apply-pipeline spans (`tx.preflight`, `tx.preclaim`, `tx.transactor`) share a deterministic `trace_id` from `txID[0:16]`, so they group under one @@ -303,10 +303,11 @@ txID-keyed spans can be joined to the ledger trace it targeted `tx.transactor`) also carry `current_ledger_hash` (the current ledger's parent hash); `tx.preflight` is stateless and omits both. -`tx.apply` carries **no** `ledger_seq` of its own — the sequence is set on its -parent `ledger.build` +`tx.apply` carries its own `ledger_seq`, written beside `tx_count` and `tx_failed` +([BuildLedger.cpp:197](../src/xrpld/app/ledger/detail/BuildLedger.cpp#L197)). Its +parent `ledger.build` carries the same sequence ([BuildLedger.cpp:90](../src/xrpld/app/ledger/detail/BuildLedger.cpp#L90)), so -read it from the parent rather than filtering `tx.apply` on it. +either span can be filtered on it. ### Transaction Queue Spans @@ -321,12 +322,12 @@ read it from the parent rather than filtering `tx.apply` on it. ### PathFinding Spans -| Span Name | Source File | Attributes | Description | -| --------------------- | --------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------- | -| `pathfind.request` | PathFind.cpp / RipplePathFind.cpp | `pathfind_source_account`, `pathfind_dest_account` | Path-find RPC entry (accounts hashed; set when present) | -| `pathfind.compute` | PathRequest.cpp | `pathfind_fast`, `pathfind_dest_currency` | Path computation for one request (`doUpdate`) | -| `pathfind.discover` | PathRequest.cpp | `pathfind_search_level`, `pathfind_num_paths`, `pathfind_num_source_assets` | Graph exploration (one per RPC call in `findPaths`) | -| `pathfind.update_all` | PathRequestManager.cpp | `pathfind_ledger_index`, `pathfind_num_requests` | Async recomputation of active requests on ledger close | +| Span Name | Source File | Attributes | Description | +| --------------------- | --------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `pathfind.request` | PathFind.cpp / RipplePathFind.cpp | `pathfind_source_account`, `pathfind_dest_account` | Path-find RPC entry (raw r-addresses; set when the request field parses as one) | +| `pathfind.compute` | PathRequest.cpp | `pathfind_fast`, `pathfind_dest_currency` | Path computation for one request (`doUpdate`) | +| `pathfind.discover` | PathRequest.cpp | `pathfind_search_level`, `pathfind_num_paths`, `pathfind_num_source_assets` | Graph exploration (one per RPC call in `findPaths`) | +| `pathfind.update_all` | PathRequestManager.cpp | `pathfind_ledger_index`, `pathfind_num_requests` | Async recomputation of active requests on ledger close | ### Consensus Spans @@ -408,12 +409,12 @@ from `result_->state` at ### Ledger Spans -| Span Name | Source File | Attributes | Description | -| ----------------- | ----------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `ledger.build` | BuildLedger.cpp | `ledger_seq`, `close_time_ripple_epoch_s`, `close_time_correct`, `close_resolution_ms` | Ledger build during consensus | -| `ledger.validate` | LedgerMaster.cpp | `ledger_hash`, `ledger_seq`, `validations` | Ledger promoted to validated | -| `ledger.store` | LedgerMaster.cpp | `ledger_hash`, `ledger_seq` | Ledger stored in history | -| `ledger.acquire` | InboundLedger.cpp | `ledger_hash`, `ledger_seq`, `acquire_reason`, `timeouts`, `peer_count`, `outcome` | Fetch a missing ledger from peers (parent varies — see [known issues](#where-telemetry-parenting-differs-from-protocol-flow)) | +| Span Name | Source File | Attributes | Description | +| ----------------- | ----------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `ledger.build` | BuildLedger.cpp | `ledger_seq`, `close_time_ripple_epoch_s`, `close_time_correct`, `close_resolution_ms` | Ledger build during consensus | +| `ledger.validate` | LedgerMaster.cpp | `ledger_hash`, `ledger_seq`, `validations` | Ledger promoted to validated | +| `ledger.store` | LedgerMaster.cpp | `ledger_hash`, `ledger_seq` | Ledger stored in history | +| `ledger.acquire` | InboundLedger.cpp | `ledger_hash`, `ledger_seq`, `acquire_reason`, `timeouts`, `peer_count`, `outcome` | Fetch a missing ledger from peers (always a root on the ledger-hash trace) | `ledger.acquire` sets only `ledger_hash`, `ledger_seq` and `acquire_reason` when the span opens in `init()`. `outcome` has three values, written on two different paths: @@ -1137,9 +1138,9 @@ call edge. Read a trace with these in mind: | `tx.process` is a `hashSpan` root from `txID` — an independent trace root ([TxTracing.h:63](../src/xrpld/telemetry/TxTracing.h#L63)). | The real edge is the synchronous `doSubmit → processTransaction` call; it is **not** a child of `rpc.command.submit`. | | `tx.preflight` / `tx.preclaim` / `tx.transactor` share one `txID`-derived trace ID. | That shared ID is a correlation trick, not a call edge. The real order is the composed `apply()` at [apply.cpp:118](../src/libxrpl/tx/apply.cpp#L118). They are **not** children of `tx.process` or `tx.apply`. Because nothing else nests under it either, `tx.apply` is **always a leaf** — the stage spans for the transactions it applied sit in the txID-keyed trace, not beneath it. | | `consensus.round` uses a deterministic trace ID from the previous ledger hash. | This makes **all validators share one trace ID** (a cross-node shared root), not a per-node parent. The real round-to-round edge is `endConsensus → beginConsensus`. | -| `consensus.accept` (main thread) and `consensus.accept.apply` (JtAccept worker) are wired via a captured context. | The real edge is the queued `JtAccept` job, a thread hand-off ([RCLConsensus.cpp:483](../src/xrpld/app/consensus/RCLConsensus.cpp#L483)). | +| `consensus.accept` (main thread) and `consensus.accept.apply` (JtAccept worker) are wired via a captured context. | The real edge is the queued `JtAccept` job, a thread hand-off ([RCLConsensus.cpp:483](../src/xrpld/app/consensus/RCLConsensus.cpp#L483)). `consensus.accept.apply` is a scoped guard, so the spans `doAccept` creates after it (`ledger.build`, `txq.cleanup`, `txq.accept`, `ledger.store`, `ledger.validate`) nest under it; those are real containment edges. | | `pathfind.update_all` parents nothing from the original `pathfind.request`. | The causal link is the ledger-close job on `JtUpdatePf`, not span nesting. | -| `ledger.acquire` and its downstream `ledger.store` / `ledger.validate`. | Reached via the `AcqDone` job, not parent inheritance. All three are non-scoped `SpanGuard::span` spans, so none of them parents the others; each takes whatever ambient span its own caller happens to have active. See the `ledger.*` known issue below. | +| `ledger.acquire` and its downstream `ledger.store` / `ledger.validate`. | Reached via the `AcqDone` job, not parent inheritance. All three are `hashSpan` roots keyed on the ledger hash, so none of them parents the others and none inherits its caller's span; they share one trace instead. | | `peer.*.receive` (fresh `kConsumer` root) and `consensus.*.receive` on the same message. | Two **sequential stages of one synchronous handler**, not parent/child; on a duplicate/untrusted drop the `consensus.*.receive` is never created. | | Receive spans adopt the sender's `trace_id` + `span_id` as a genuine cross-node parent. | Deliberate: the receive span becomes a child of a **different node's** span (a cross-node context marker, not an in-process edge). `tx.receive` is asymmetric — it borrows only the sender's `span_id` and re-derives its own `trace_id` from `txID`. | @@ -1166,47 +1167,26 @@ are pending a code fix: happened to be active on the worker that picked the job up. A gRPC call appearing beneath an unrelated transaction's trace is this bug, not a real call edge. -- **`ledger.acquire` / `ledger.store` / `ledger.validate` are not reliably roots - either.** All three use `SpanGuard::span` - ([InboundLedger.cpp:113](../src/xrpld/app/ledger/detail/InboundLedger.cpp#L113), - [LedgerMaster.cpp:470](../src/xrpld/app/ledger/detail/LedgerMaster.cpp#L470), - [1003](../src/xrpld/app/ledger/detail/LedgerMaster.cpp#L1003)), which inherits the - ambient span ([SpanGuard.cpp:233](../src/libxrpl/telemetry/SpanGuard.cpp#L233)) - rather than `freshRoot` - ([245](../src/libxrpl/telemetry/SpanGuard.cpp#L245)) — the same defect as - `grpc.*` above. Whether they come out as roots depends purely on the caller: - - **Root, as documented.** On the `JtAdvance` / `AcqDone` job path - (`LedgerMaster::doAdvance`, `RCLConsensus::Adaptor::acquireLedger` → - [RCLConsensus.cpp:171](../src/xrpld/app/consensus/RCLConsensus.cpp#L171)) no - span is active on the worker, so nothing is inherited. `acquireSpan_` itself is - a non-scoped `SpanGuard`, so it never becomes the ambient parent of the - `ledger.store` / `ledger.validate` that follow it. - - **Mis-parented.** `InboundLedgers::acquire` is also called **synchronously from - an RPC handler** — `ledger_request` → `rpc::getOrAcquireLedger` - ([RPCLedgerHelpers.cpp:483](../src/xrpld/rpc/detail/RPCLedgerHelpers.cpp#L483)) - — which runs inside the scoped `rpc.command.` span - ([RPCHandler.cpp:168](../src/xrpld/rpc/detail/RPCHandler.cpp#L168)). There - `ledger.acquire` becomes a child of that RPC command, and when `init()` is - satisfied from the local store the `ledger.store` / `ledger.validate` it calls - ([InboundLedger.cpp:164](../src/xrpld/app/ledger/detail/InboundLedger.cpp#L164), - [168](../src/xrpld/app/ledger/detail/InboundLedger.cpp#L168)) land there as - siblings. A ledger acquisition nested under an `rpc.command.*` trace is this - bug, not a real call edge. +- **`ledger.acquire` / `ledger.store` / `ledger.validate` are true roots on the + ledger-hash trace.** All three use `SpanGuard::hashSpan` + ([InboundLedger.cpp:128](../src/xrpld/app/ledger/detail/InboundLedger.cpp#L128), + [LedgerMaster.cpp:181](../src/xrpld/app/ledger/detail/LedgerMaster.cpp#L181)), which + derives the trace id from the ledger hash and never inherits the ambient span. So + the caller does not matter: an acquire started from the `ledger_request` RPC, a + store reached from `buildLCL` inside `doAccept`, and a validate reached from + `checkAccept` all come out as roots of the same per-ledger trace. Two `ledger.store` + roots for one ledger is the normal shape when a node both fetches and builds it. - **`ledger.build` and `tx.apply` use the same ambient-parent construct but are - safe.** `ledger.build` is a plain `ScopedSpanGuard` - ([BuildLedger.cpp:55](../src/xrpld/app/ledger/detail/BuildLedger.cpp#L55)): its - only callers are `RCLConsensus::doAccept` - ([RCLConsensus.cpp:935-937](../src/xrpld/app/consensus/RCLConsensus.cpp#L935)) - on the `JtAccept` worker and the replay path - ([LedgerDeltaAcquire.cpp:208](../src/xrpld/app/ledger/detail/LedgerDeltaAcquire.cpp#L208)), - and every consensus accept span is a non-scoped `SpanGuard` - ([RCLConsensus.cpp:598-599](../src/xrpld/app/consensus/RCLConsensus.cpp#L598)), - so no ambient span exists to be inherited there. `tx.apply` + **`ledger.build` and `tx.apply` use the same ambient-parent construct and land + on the intended edges.** `ledger.build` is a plain `ScopedSpanGuard` + ([BuildLedger.cpp:55](../src/xrpld/app/ledger/detail/BuildLedger.cpp#L55)): on the + consensus path it is created inside `doAccept` after `consensus.accept.apply` + opens, so it nests under that span; on the replay path + ([LedgerDeltaAcquire.cpp:208](../src/xrpld/app/ledger/detail/LedgerDeltaAcquire.cpp#L208)) + nothing is ambient and it is a root. `tx.apply` ([BuildLedger.cpp:123](../src/xrpld/app/ledger/detail/BuildLedger.cpp#L123)) is reached only synchronously from `buildLedgerImpl` while `ledger.build`'s scope is - live, so its ambient parent is always `ledger.build` — which is exactly the - intended edge. + live, so its ambient parent is always `ledger.build`. - **`consensus.round` is not always a root.** The `consensus_trace_strategy=attribute` path has two creation branches; the fallback branch — taken on the first traced @@ -1328,8 +1308,9 @@ sum by (stage) (rate(span_calls_total{span_name=~"tx.preflight|tx.preclaim|tx.tr # Per-stage p95 latency histogram_quantile(0.95, sum by (le, stage) (rate(span_duration_milliseconds_bucket{span_name=~"tx.preflight|tx.preclaim|tx.transactor"}[5m]))) -# Per-stage failure rate (ter_result != tesSUCCESS; a failing ter completes the -# span normally, so filter on the attribute, not status_code which only flags exceptions) +# Per-stage failure rate (ter_result != tesSUCCESS). All three stage spans also set +# status_code="ERROR" on a failing ter, so status_code counts failures too; the +# attribute is used here because it names which failure. sum by (stage) (rate(span_calls_total{span_name=~"tx.preflight|tx.preclaim|tx.transactor", ter_result!~"tesSUCCESS|"}[5m])) ``` @@ -2873,7 +2854,7 @@ A plain `SpanGuard` that is **never activated** makes no span current — it "ne Severity does not affect injection, but `JLOG` filters on severity **before** `format()` runs, so the configured log level decides whether a qualifying line is emitted at all. -**The dependably correlated line at `info`** is the consensus accept pair at [RCLConsensus.cpp:736/740](../src/xrpld/app/consensus/RCLConsensus.cpp#L736) — an `if`/`else`, so exactly one of the two fires on every accepted round. `doAccept` activates the accept span as ambient over its whole body at [:565](../src/xrpld/app/consensus/RCLConsensus.cpp#L565) (`activateIfLive(acceptSpan)`, commented "Make the accept span ambient for the whole accept so doAccept's log lines ... correlate to it"), and the activation lives to the end of the function, so both branches are inside it. At roughly one round every 4 s this yields dozens of correlated lines per run. +**The dependably correlated line at `info`** is the consensus accept pair at [RCLConsensus.cpp:736/740](../src/xrpld/app/consensus/RCLConsensus.cpp#L736) — an `if`/`else`, so exactly one of the two fires on every accepted round. `doAccept` activates the accept span as ambient (`activateIfLive(acceptSpan)`) and then opens `consensus.accept.apply` as a scoped guard ([RCLConsensus.cpp:634](../src/xrpld/app/consensus/RCLConsensus.cpp#L634)), which stays ambient to the end of the function. Both branches of the pair sit inside it, so their lines carry the round's `trace_id` and `consensus.accept.apply`'s `span_id`. At roughly one round every 4 s this yields dozens of correlated lines per run. That is a dependable pair rather than an unconditional one: `info` severity is necessary but not sufficient. Four preconditions must all hold, and each has its own bail-out that silently yields an uncorrelated line rather than an error: diff --git a/include/xrpl/beast/insight/Collector.h b/include/xrpl/beast/insight/Collector.h index e95d805cc2..3cb14bfde1 100644 --- a/include/xrpl/beast/insight/Collector.h +++ b/include/xrpl/beast/insight/Collector.h @@ -21,6 +21,10 @@ namespace beast::insight { * as desired (counters, events, gauges, meters, and an optional hook) * using the interface. * + * Create them there, before the application calls onCollectionReady(). + * That call is when a collector starts polling and arms its observable + * instruments, and it runs once. + * * @see Counter, Event, Gauge, Hook, Meter * @see NullCollector, StatsDCollector */ @@ -140,6 +144,9 @@ public: /** * Create a gauge with the specified name. + * + * Create it before onCollectionReady(); a gauge made after that is + * never armed, so it is never exported. * @see Gauge */ /** @{ */ diff --git a/include/xrpl/consensus/ConsensusSpanNames.h b/include/xrpl/consensus/ConsensusSpanNames.h index d14c1dd5cd..3ad285d276 100644 --- a/include/xrpl/consensus/ConsensusSpanNames.h +++ b/include/xrpl/consensus/ConsensusSpanNames.h @@ -59,7 +59,9 @@ * | Attrs: proposers, round_time_ms, quorum * | | * | +-- consensus.accept.apply [jtACCEPT thread, child of accept] - * | Created: Adaptor::doAccept() + * | Created: Adaptor::doAccept(), scoped: the txq spans doAccept + * | goes on to create nest under it; the tx apply-stage + * | spans are hash-derived roots and do not * | Attrs: ledger_seq, close_time_ripple_epoch_s, close_time_correct, * | close_resolution_ms, consensus_state, proposing, round_time_ms, * | parent_close_time_ripple_epoch_s, close_time_self_ripple_epoch_s, @@ -71,7 +73,7 @@ * | Attrs: ledger_seq, proposing * | * +-- consensus.mode_change [main thread] - * Created: Adaptor::onModeChange() + * Created: Adaptor::onModeChange(), only when the mode moves * Attrs: mode_old, mode_new * * Standalone spans (no parent, created per-message in overlay): diff --git a/include/xrpl/protocol/ConfidentialTransfer.h b/include/xrpl/protocol/ConfidentialTransfer.h index 3706ef94e3..5c52fb0ba3 100644 --- a/include/xrpl/protocol/ConfidentialTransfer.h +++ b/include/xrpl/protocol/ConfidentialTransfer.h @@ -347,6 +347,30 @@ isAuditorMirrorCurrent(SLE const& issuance, SLE const& mptoken); [[nodiscard]] bool areMirrorsCurrent(SLE const& issuance, SLE const& mptoken); +/** + * @brief Set the holder's issuer mirror epoch to match the issuance's current issuer key epoch. + * + * Call this after writing the issuer mirror ciphertext under the issuance's + * currently registered issuer key, so that the mirror reads as current afterwards. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger entry to update. + */ +void +setIssuerMirrorEpoch(SLE const& issuance, SLE& mptoken); + +/** + * @brief Set the holder's auditor mirror epoch to match the issuance's current auditor key epoch. + * + * Call this after writing the auditor mirror ciphertext under the issuance's + * currently registered auditor key. Does nothing when the holder has no auditor mirror. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger entry to update. + */ +void +setAuditorMirrorEpoch(SLE const& issuance, SLE& mptoken); + /** * @brief Set the holder's MPToken mirror epochs to match the issuance's current key epochs. * diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 0170cbb88a..61f246c752 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -540,6 +540,11 @@ constexpr std::size_t kEcConvertBackProofLength = */ constexpr std::size_t kEcClawbackProofLength = SECP256K1_COMPACT_CLAWBACK_PROOF_SIZE; +/** + * Length of compact equality proof. + */ +constexpr std::size_t kEcEqualityProofLength = 128; + /** * Extra base fee multiplier charged to confidential MPT transactions. */ diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 454aa85ffd..cb3d5fd2b1 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1134,6 +1134,19 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, {sfRemainingOwnerCountDelta, SoeOptional}, })) +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_MIRROR_UPDATE, 92, ConfidentialMPTMirrorUpdate, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialMPTKeyRotation}), + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfHolder, SoeOptional}, + {sfIssuerEncryptedAmount, SoeOptional}, + {sfAuditorEncryptedAmount, SoeOptional}, + {sfZKProof, SoeRequired}, +})) + /** This system-generated transaction type is used to update the status of the various amendments. For details, see: https://xrpl.org/amendments.html diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h new file mode 100644 index 0000000000..1fc25bca99 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h @@ -0,0 +1,266 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTMirrorUpdateBuilder; + +/** + * @brief Transaction: ConfidentialMPTMirrorUpdate + * + * Type: ttCONFIDENTIAL_MPT_MIRROR_UPDATE (92) + * Delegable: Delegation::Delegable + * Amendment: featureConfidentialMPTKeyRotation + * Privileges: Privilege::NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTMirrorUpdateBuilder to construct new transactions. + */ +class ConfidentialMPTMirrorUpdate : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_MIRROR_UPDATE; + + /** + * @brief Construct a ConfidentialMPTMirrorUpdate transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTMirrorUpdate(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTMirrorUpdate"); + } + } + + // Transaction-specific field getters + + /** + * @brief Get sfMPTokenIssuanceID (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT192::type::value_type + getMPTokenIssuanceID() const + { + return this->tx_->at(sfMPTokenIssuanceID); + } + + /** + * @brief Get sfHolder (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getHolder() const + { + if (hasHolder()) + { + return this->tx_->at(sfHolder); + } + return std::nullopt; + } + + /** + * @brief Check if sfHolder is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasHolder() const + { + return this->tx_->isFieldPresent(sfHolder); + } + + /** + * @brief Get sfIssuerEncryptedAmount (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getIssuerEncryptedAmount() const + { + if (hasIssuerEncryptedAmount()) + { + return this->tx_->at(sfIssuerEncryptedAmount); + } + return std::nullopt; + } + + /** + * @brief Check if sfIssuerEncryptedAmount is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasIssuerEncryptedAmount() const + { + return this->tx_->isFieldPresent(sfIssuerEncryptedAmount); + } + + /** + * @brief Get sfAuditorEncryptedAmount (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorEncryptedAmount() const + { + if (hasAuditorEncryptedAmount()) + { + return this->tx_->at(sfAuditorEncryptedAmount); + } + return std::nullopt; + } + + /** + * @brief Check if sfAuditorEncryptedAmount is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorEncryptedAmount() const + { + return this->tx_->isFieldPresent(sfAuditorEncryptedAmount); + } + + /** + * @brief Get sfZKProof (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getZKProof() const + { + return this->tx_->at(sfZKProof); + } +}; + +/** + * @brief Builder for ConfidentialMPTMirrorUpdate transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class ConfidentialMPTMirrorUpdateBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTMirrorUpdateBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param zKProof The sfZKProof field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTMirrorUpdateBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::decay_t const& zKProof, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_MIRROR_UPDATE, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + setZKProof(zKProof); + } + + /** + * @brief Construct a ConfidentialMPTMirrorUpdateBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTMirrorUpdateBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_MIRROR_UPDATE) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTMirrorUpdateBuilder"); + } + object_ = *tx; + } + + /** + * @brief Transaction-specific field setters + */ + + /** + * @brief Set sfMPTokenIssuanceID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Set sfHolder (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setHolder(std::decay_t const& value) + { + object_[sfHolder] = value; + return *this; + } + + /** + * @brief Set sfIssuerEncryptedAmount (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setIssuerEncryptedAmount(std::decay_t const& value) + { + object_[sfIssuerEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptedAmount (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setAuditorEncryptedAmount(std::decay_t const& value) + { + object_[sfAuditorEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfZKProof (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setZKProof(std::decay_t const& value) + { + object_[sfZKProof] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTMirrorUpdate wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTMirrorUpdate + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTMirrorUpdate{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/telemetry/Recording.h b/include/xrpl/telemetry/Recording.h index 12a8d8203c..951f99ebc4 100644 --- a/include/xrpl/telemetry/Recording.h +++ b/include/xrpl/telemetry/Recording.h @@ -56,8 +56,7 @@ * // Edge case -- an expensive value still needs a block guard, * // because arguments are evaluated even when the method is a no-op. * if constexpr (telemetry::kEnabled) - * span.setAttribute( - * pathfind_span::attr::sourceAccount, redactAccount(account)); + * span.setAttribute(tx_span::attr::txHash, to_string(txId)); * @endcode */ diff --git a/include/xrpl/telemetry/Redaction.h b/include/xrpl/telemetry/Redaction.h index 3b1e8f67ef..c3738ddbb7 100644 --- a/include/xrpl/telemetry/Redaction.h +++ b/include/xrpl/telemetry/Redaction.h @@ -1,34 +1,30 @@ #pragma once /** - * Account-address redaction for telemetry span attributes. + * Account-address redaction helper for telemetry span attributes. * - * Path-finding RPC handlers would otherwise emit the caller's raw - * account addresses as span attributes. To keep plaintext addresses out - * of the telemetry backend, they are hashed at the point of emission. - * This header exposes a single pure helper that turns an address into a - * short, stable, obfuscated token. + * A single pure helper that turns a string into a short, stable, + * obfuscated token, for a span attribute whose value should not be stored + * in the clear and is hard to guess. * - * Data flow: - * - * handler -> redactAccount(addr) -> span attribute -> OTLP export + * Not applied to any span today. Account addresses are public ledger + * identifiers, so the path-finding spans emit them raw (see + * PathFindSpanNames.h) and no collector processor hashes them. Use this + * helper only for a value that is genuinely private, and document the + * reason at the attribute constant. * * The returned token is the first 16 hex characters (lowercase) of the - * SHA-512Half digest of the address. It is deterministic (same address - * always maps to the same token) so operators can still correlate spans - * for a given account across nodes and restarts. + * SHA-512Half digest of the input. It is deterministic (same input + * always maps to the same token) so spans for one value still correlate + * across nodes and restarts. * - * The hash is unsalted, so it is obfuscation, not a secrecy guarantee: - * XRP account addresses are a public, enumerable set, so a determined - * observer with the telemetry stream could rebuild the address->token - * mapping. The goal here is to keep plaintext addresses out of traces - * and dashboards, not to defend against a precomputation attack. A salt - * is intentionally omitted because it would break cross-node/restart - * correlation, which is the reason for hashing rather than dropping. - * - * A second, independent hashing layer runs in the OpenTelemetry - * Collector (an `attributes/hash` processor) as defense-in-depth for - * any node that emits a raw value. + * The hash is unsalted, so it is obfuscation, not a secrecy guarantee. + * It hides a value only when that value is hard to guess: for an input + * drawn from a small or enumerable set, such as an account address, an + * observer can rebuild the value->token mapping by lookup, which is why + * account addresses are emitted raw instead. A salt is intentionally + * omitted because it would break cross-node/restart correlation, which is + * the reason for hashing rather than dropping. * * @note This function is pure and reentrant: it holds no global state, * performs no I/O, and is safe to call concurrently from any thread. @@ -38,8 +34,7 @@ * #include * using namespace xrpl::telemetry; * - * span.setAttribute( - * pathfind_span::attr::sourceAccount, redactAccount(src.asString())); + * auto const token = redactAccount(value); // 16 lowercase hex chars * @endcode * * Edge case (empty input yields empty output): @@ -54,9 +49,10 @@ namespace xrpl::telemetry { /** - * Hash an account address into a short, stable, obfuscated token. + * Hash a value into a short, stable, obfuscated token. * - * @param addr The account address to redact (e.g. an r-address). + * @param addr The value to redact. Named for its original use on account + * addresses; any string can be passed. * @return The first 16 lowercase hex characters of sha512Half(addr), * or an empty string when @p addr is empty. */ diff --git a/include/xrpl/telemetry/SpanGuard.h b/include/xrpl/telemetry/SpanGuard.h index 10a4a80b4a..a11a4e1408 100644 --- a/include/xrpl/telemetry/SpanGuard.h +++ b/include/xrpl/telemetry/SpanGuard.h @@ -574,7 +574,14 @@ public: setAttribute(std::string_view key, std::string_view value) noexcept; /** - * Set a string attribute (C-string overload). No-op on a null guard. + * Set a string attribute from a C string. No-op on a null guard. + * + * @param key Attribute key. + * @param value Null-terminated text. A null pointer records nothing, since + * an empty value is already a meaningful value here. + * @note This overload is required, not a convenience. Without it a string + * literal binds to the bool overload, because pointer-to-bool is a standard + * conversion and beats the std::string_view one. */ void setAttribute(std::string_view key, char const* value) noexcept; @@ -875,7 +882,14 @@ public: setAttribute(std::string_view key, std::string_view value) noexcept; /** - * Set a string attribute (C-string overload). No-op on a null guard. + * Set a string attribute from a C string. No-op on a null guard. + * + * @param key Attribute key. + * @param value Null-terminated text. A null pointer records nothing, since + * an empty value is already a meaningful value here. + * @note This overload is required, not a convenience. Without it a string + * literal binds to the bool overload, because pointer-to-bool is a standard + * conversion and beats the std::string_view one. */ void setAttribute(std::string_view key, char const* value) noexcept; @@ -918,6 +932,15 @@ public: void addEvent(std::string_view name) noexcept; + /** + * Add a named event with key-value attributes to the span's timeline. + * No-op on a null guard. + * @param name Event name. + * @param attrs Attribute pairs (all string_view for simplicity). + */ + void + addEvent(std::string_view name, std::initializer_list attrs) noexcept; + /** * Record an exception as a span event and mark status as error. * No-op on a null guard. @@ -1355,6 +1378,10 @@ public: { } void + addEvent(std::string_view, std::initializer_list) noexcept + { + } + void recordException(std::exception const&) noexcept { } diff --git a/include/xrpl/telemetry/Telemetry.h b/include/xrpl/telemetry/Telemetry.h index f297af0223..e7d81d8f2e 100644 --- a/include/xrpl/telemetry/Telemetry.h +++ b/include/xrpl/telemetry/Telemetry.h @@ -168,6 +168,16 @@ inline constexpr auto kDefaultMetricExportInterval = std::chrono::milliseconds{1 */ inline constexpr auto kDefaultMetricExportTimeout = std::chrono::milliseconds{500}; +/** + * Default OTLP/HTTP URL for metrics, signal path included. + * + * The collector's standard port on the same host. Declared here so the Setup + * member, the config parser's default and the collector's startup log all name + * one string. Outside the telemetry #ifdef, because the config parser reads it + * in every build. + */ +inline constexpr char const* kDefaultMetricsEndpoint = "http://localhost:4318/v1/metrics"; + /** * How a consensus round span picks its trace id. * @@ -316,7 +326,7 @@ public: * point the two signals at different collectors, or at one whose OTLP * paths are not the defaults. */ - std::string metricsEndpoint = "http://localhost:4318/v1/metrics"; + std::string metricsEndpoint = kDefaultMetricsEndpoint; /** * Whether to use TLS for the exporter connection. diff --git a/include/xrpl/telemetry/TxAccountSpanNames.h b/include/xrpl/telemetry/TxAccountSpanNames.h new file mode 100644 index 0000000000..fd6560a9f7 --- /dev/null +++ b/include/xrpl/telemetry/TxAccountSpanNames.h @@ -0,0 +1,172 @@ +#pragma once + +/** + * Span attribute keys for the account-typed fields of a transaction. + * + * A transaction names one or more accounts: the sender in `Account`, and + * depending on the type a `Destination`, `Owner`, `Issuer`, `Holder` and so + * on. The tx.process span emits every one it finds as its own attribute, so + * an account can be searched for in traces whatever role it played. An + * account address is a public ledger identifier, so each is emitted as the + * raw r-address and never hashed. + * + * One key per protocol field: `tx_` followed by the field's JSON name in + * lower snake case. The full table is the initializer in + * src/libxrpl/telemetry/TxAccountSpanNames.cpp; the common ones are + * + * STTx field span attribute key + * ----------------- ------------------ + * Account tx_account + * Destination tx_destination + * Owner tx_owner + * Issuer tx_issuer + * RegularKey tx_regular_key + * NFTokenMinter tx_nftoken_minter + * + * Only fields that some transaction format carries at top level have a key. + * Account-typed fields that appear only in ledger entries or inner objects + * (LowSponsor, LockingChainDoor, ...) map to nullopt. A library test walks + * TxFormats and fails when a format gains an account field with no key. + * + * Why this header lives in libxrpl rather than beside TxSpanNames.h: the + * mapping is keyed by protocol fields and its completeness is checked from + * TxFormats, which a library test can reach and a daemon header cannot. + * + * Data flow: + * + * NetworkOPs::processTransaction (src/xrpld) + * │ for each top-level field with getSType() == STI_ACCOUNT + * ▼ + * accountFieldAttributeKey(field.getFName()) (this header) + * │ the key, or nullopt for a field with no key + * ▼ + * span->setAttribute(key, field.getText()) + * + * @code + * // Primary use: emit every account the transaction names. An empty + * // account field is skipped so it is not rendered as the zero address. + * for (auto const& field : stx) + * { + * if (field.getSType() != STI_ACCOUNT || field.isDefault()) + * continue; + * if (auto const key = telemetry::accountFieldAttributeKey(field.getFName())) + * span.setAttribute(*key, toBase58(stx.getAccountID(field.getFName()))); + * } + * @endcode + * + * @code + * // Edge case: a field that is not a top-level transaction account has + * // no key, so a caller must test the optional before using it. + * accountFieldAttributeKey(sfFee); // == std::nullopt + * accountFieldAttributeKey(sfLowSponsor); // == std::nullopt + * @endcode + * + * @note Only top-level fields are covered. Accounts nested in Signers, in a + * Batch's inner transactions, or as the issuer inside an Amount are not + * emitted. + * @note accountFieldAttributeKey() is thread-safe. Its table is built once + * on first use and is read-only afterwards. + */ + +#include + +#include +#include + +namespace xrpl { +class SField; +} // namespace xrpl + +namespace xrpl::telemetry { + +namespace tx_account_span::attr { +/** + * "tx_account" — the sending account (`Account`). Every transaction has one. + */ +inline constexpr auto account = makeStr("tx_account"); +/** + * "tx_destination" — the receiving account (`Destination`). + */ +inline constexpr auto destination = makeStr("tx_destination"); +/** + * "tx_owner" — the owner of the object acted on (`Owner`). + */ +inline constexpr auto owner = makeStr("tx_owner"); +/** + * "tx_issuer" — the issuer named by the transaction (`Issuer`). + */ +inline constexpr auto issuer = makeStr("tx_issuer"); +/** + * "tx_authorize" — the account being authorised (`Authorize`). + */ +inline constexpr auto authorize = makeStr("tx_authorize"); +/** + * "tx_unauthorize" — the account whose authorisation is removed (`Unauthorize`). + */ +inline constexpr auto unauthorize = makeStr("tx_unauthorize"); +/** + * "tx_regular_key" — the regular key being set (`RegularKey`). + */ +inline constexpr auto regularKey = makeStr("tx_regular_key"); +/** + * "tx_nftoken_minter" — the authorised NFToken minter (`NFTokenMinter`). + */ +inline constexpr auto nftokenMinter = makeStr("tx_nftoken_minter"); +/** + * "tx_holder" — the token holder acted on (`Holder`). + */ +inline constexpr auto holder = makeStr("tx_holder"); +/** + * "tx_delegate" — the delegate signing on the sender's behalf (`Delegate`). + */ +inline constexpr auto delegate = makeStr("tx_delegate"); +/** + * "tx_sponsor" — the account paying the fee or reserve (`Sponsor`). + */ +inline constexpr auto sponsor = makeStr("tx_sponsor"); +/** + * "tx_sponsee" — the account being sponsored (`Sponsee`). + */ +inline constexpr auto sponsee = makeStr("tx_sponsee"); +/** + * "tx_counterparty" — the other party to a loan (`Counterparty`). + */ +inline constexpr auto counterparty = makeStr("tx_counterparty"); +/** + * "tx_counterparty_sponsor" — the counterparty's sponsor (`CounterpartySponsor`). + */ +inline constexpr auto counterpartySponsor = makeStr("tx_counterparty_sponsor"); +/** + * "tx_subject" — the subject of a credential (`Subject`). + */ +inline constexpr auto subject = makeStr("tx_subject"); +/** + * "tx_other_chain_source" — the source account on the other chain (`OtherChainSource`). + */ +inline constexpr auto otherChainSource = makeStr("tx_other_chain_source"); +/** + * "tx_other_chain_destination" — destination on the other chain (`OtherChainDestination`). + */ +inline constexpr auto otherChainDestination = makeStr("tx_other_chain_destination"); +/** + * "tx_attestation_signer_account" — the attestation signer (`AttestationSignerAccount`). + */ +inline constexpr auto attestationSignerAccount = makeStr("tx_attestation_signer_account"); +/** + * "tx_attestation_reward_account" — attestation reward account (`AttestationRewardAccount`). + */ +inline constexpr auto attestationRewardAccount = makeStr("tx_attestation_reward_account"); +} // namespace tx_account_span::attr + +/** + * Look up the span attribute key for an account-typed transaction field. + * + * @param field The protocol field, as returned by STBase::getFName(). + * @return The `tx_*` key for a top-level transaction account field, or + * nullopt when the field is not account-typed or is carried only by ledger + * entries and inner objects. + */ +[[nodiscard]] std::optional +accountFieldAttributeKey(SField const& field); + +} // namespace xrpl::telemetry diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h b/include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h new file mode 100644 index 0000000000..12ad5c8f28 --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +/** + * @brief Updates the encrypted mirror balances of a Confidential MPToken. + * + * @details + * This transaction updates a single holder's mirrored confidential balances + * (`sfIssuerEncryptedBalance` and/or `sfAuditorEncryptedBalance`) with the latest + * ElGamal public keys defined on the `MPTokenIssuance`. + * + * It supports both issuer and holder self-migration modes, each mode supports multiple flows: + * - Issuer mode: Submitted by the issuer. + * 1. Issuer Key Rotation Migration: Re-encrypts the + * holder's `sfIssuerEncryptedBalance` under the issuer's new ElGamal public key. + * + * 2. Auditor Key Rotation Migration: Re-encrypts the + * holder's `sfAuditorEncryptedBalance` under the auditor's new ElGamal public key. + * + * 3. Simultaneous Rotation Migration: Updates both the issuer + * and auditor encrypted balances in a single transaction to optimize network throughput. + * + * 4. Auditor Late-Registration Migration: When the issuer ElGamal + * public key is already registered on the `MPTokenIssuance` object, the issuer can + * register an auditor key at a later time through `MPTokenIssuanceSet`. Then the issuer uses this + * flow to set the holder's initial `sfAuditorEncryptedBalance` on `MPToken` object. + * + * - Holder self-migration mode: Submitted by the holder. The holder decrypts their own + * `sfConfidentialBalanceSpending` with holder's private key to recover the balance and + * re-encrypts it under the relevant new ElGamal public key(s). This mode is always + * available to the holder and is not conditioned on the issuer being unable to migrate + * them: the ledger cannot verify whether an issuer has really lost its private key. That + * loss is only the expected motivation, since an issuer that still holds its key can + * migrate holders itself in issuer mode. + * @note All holder migration flows strictly require the holder's + * `sfConfidentialBalanceInbox` to be canonically zero; the holder must run + * `ConfidentialMPTMergeInbox` first so the spending balance reflects the + * full balance. + * + * 5. Holder Issuer-Mirror Migration: Re-encrypts the holder's + * `sfIssuerEncryptedBalance` under the issuer's new ElGamal public key. + * + * 6. Holder Auditor-Mirror Migration: Re-encrypts the holder's + * `sfAuditorEncryptedBalance` under the auditor's new ElGamal public key, or + * sets it for the first time when the auditor key was late-registered. This is the + * holder-driven counterpart to flows 2 and 4, for when the issuer does not migrate + * the holder itself. + * + * 7. Simultaneous Holder Self-Migration: Updates both the issuer and auditor + * encrypted balances in a single transaction (both keys have rotated). + */ +class ConfidentialMPTMirrorUpdate : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit ConfidentialMPTMirrorUpdate(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/src/libxrpl/protocol/ConfidentialTransfer.cpp b/src/libxrpl/protocol/ConfidentialTransfer.cpp index 99ae066475..a3e48b5f31 100644 --- a/src/libxrpl/protocol/ConfidentialTransfer.cpp +++ b/src/libxrpl/protocol/ConfidentialTransfer.cpp @@ -435,21 +435,41 @@ areMirrorsCurrent(SLE const& issuance, SLE const& mptoken) } void -setMirrorEpochs(SLE const& issuance, SLE& mptoken) +setIssuerMirrorEpoch(SLE const& issuance, SLE& mptoken) { XRPL_ASSERT( issuance.getType() == ltMPTOKEN_ISSUANCE, - "xrpl::setMirrorEpochs : issuance MPTokenIssuance object"); - XRPL_ASSERT(mptoken.getType() == ltMPTOKEN, "xrpl::setMirrorEpochs : mptoken MPToken object"); + "xrpl::setIssuerMirrorEpoch : issuance MPTokenIssuance object"); + XRPL_ASSERT( + mptoken.getType() == ltMPTOKEN, "xrpl::setIssuerMirrorEpoch : mptoken MPToken object"); + // Unlike the auditor mirror, the issuer mirror is not optional: every + // confidential MPToken carries one, so there is no existence check here. if (auto const epoch = issuance[~sfIssuerKeyEpoch].value_or(0); epoch != 0) mptoken[sfIssuerKeyMirrorEpoch] = epoch; +} - if (mptoken.isFieldPresent(sfAuditorEncryptedBalance)) - { - if (auto const epoch = issuance[~sfAuditorKeyEpoch].value_or(0); epoch != 0) - mptoken[sfAuditorKeyMirrorEpoch] = epoch; - } +void +setAuditorMirrorEpoch(SLE const& issuance, SLE& mptoken) +{ + XRPL_ASSERT( + issuance.getType() == ltMPTOKEN_ISSUANCE, + "xrpl::setAuditorMirrorEpoch : issuance MPTokenIssuance object"); + XRPL_ASSERT( + mptoken.getType() == ltMPTOKEN, "xrpl::setAuditorMirrorEpoch : mptoken MPToken object"); + + if (!mptoken.isFieldPresent(sfAuditorEncryptedBalance)) + return; + + if (auto const epoch = issuance[~sfAuditorKeyEpoch].value_or(0); epoch != 0) + mptoken[sfAuditorKeyMirrorEpoch] = epoch; +} + +void +setMirrorEpochs(SLE const& issuance, SLE& mptoken) +{ + setIssuerMirrorEpoch(issuance, mptoken); + setAuditorMirrorEpoch(issuance, mptoken); } TER diff --git a/src/libxrpl/telemetry/NullTelemetry.cpp b/src/libxrpl/telemetry/NullTelemetry.cpp index e6bac72d65..a8b9e35d6b 100644 --- a/src/libxrpl/telemetry/NullTelemetry.cpp +++ b/src/libxrpl/telemetry/NullTelemetry.cpp @@ -68,7 +68,13 @@ public: void stop() override { - Telemetry::setInstance(nullptr); + // Clear the global instance only if this object is the one that + // published it. A process with two of these, as the test binary has, + // would otherwise let one unregister the other. + if (Telemetry::getInstance() == this) + { + Telemetry::setInstance(nullptr); + } } [[nodiscard]] bool diff --git a/src/libxrpl/telemetry/SpanGuard.cpp b/src/libxrpl/telemetry/SpanGuard.cpp index 9c2cdc0a6e..8d5d89e51f 100644 --- a/src/libxrpl/telemetry/SpanGuard.cpp +++ b/src/libxrpl/telemetry/SpanGuard.cpp @@ -558,7 +558,11 @@ SpanGuard::setAttribute(std::string_view key, std::string_view value) noexcept void SpanGuard::setAttribute(std::string_view key, char const* value) noexcept { - setAttribute(key, std::string_view(value)); + // A std::string_view built from a pointer reads that pointer to find its + // length, so a null one is undefined behaviour. A null pointer carries no + // text, and an empty value already means something here, so record nothing. + if (value != nullptr) + setAttribute(key, std::string_view(value)); } void @@ -864,6 +868,14 @@ ScopedSpanGuard::addEvent(std::string_view name) noexcept impl_->guard.addEvent(name); } +void +ScopedSpanGuard::addEvent( + std::string_view name, + std::initializer_list attrs) noexcept +{ + impl_->guard.addEvent(name, attrs); +} + void ScopedSpanGuard::recordException(std::exception const& e) noexcept { diff --git a/src/libxrpl/telemetry/Telemetry.cpp b/src/libxrpl/telemetry/Telemetry.cpp index edae2ae6ed..91691acb45 100644 --- a/src/libxrpl/telemetry/Telemetry.cpp +++ b/src/libxrpl/telemetry/Telemetry.cpp @@ -203,7 +203,13 @@ public: void stop() override { - Telemetry::setInstance(nullptr); + // Clear the global instance only if this object is the one that + // published it. A process with two of these, as the test binary has, + // would otherwise let one unregister the other. + if (Telemetry::getInstance() == this) + { + Telemetry::setInstance(nullptr); + } } [[nodiscard]] bool @@ -596,7 +602,9 @@ public: // Unregister global instance before tearing down the pipeline, but only // if this object is the one that published it. if (Telemetry::getInstance() == this) + { Telemetry::setInstance(nullptr); + } if (sdkProvider_) { diff --git a/src/libxrpl/telemetry/TelemetryConfig.cpp b/src/libxrpl/telemetry/TelemetryConfig.cpp index 8c985fbb17..bde0620d42 100644 --- a/src/libxrpl/telemetry/TelemetryConfig.cpp +++ b/src/libxrpl/telemetry/TelemetryConfig.cpp @@ -72,7 +72,7 @@ constexpr char const* consensusTraceStrategy = "consensus_trace_strategy"; namespace dflt { constexpr char const* serviceName = "xrpld"; constexpr char const* tracesEndpoint = "http://localhost:4318/v1/traces"; -constexpr char const* metricsEndpoint = "http://localhost:4318/v1/metrics"; +constexpr char const* metricsEndpoint = kDefaultMetricsEndpoint; constexpr std::uint32_t batchSize = 512u; constexpr std::uint32_t batchDelayMs = 5000u; constexpr std::uint32_t maxQueueSize = 2048u; @@ -250,14 +250,14 @@ requirePositive(std::chrono::milliseconds value, char const* configKey) } /** - * Throw unless an endpoint URL is one the client certificate can be used on. + * Throw unless an endpoint URL is one TLS can actually be used on. * * The OTLP/HTTP exporter turns TLS on from the URL scheme alone, and matches - * "https:" exactly and case-sensitively. So a client certificate only reaches - * the collector on an https endpoint, and this check is what holds that - * invariant: with a client certificate configured, the endpoint is an https URL. - * "https://" is required in full, which is stricter than the exporter's own - * test, so anything this accepts the exporter also treats as TLS. + * "https:" exactly and case-sensitively. So the certificate settings only mean + * anything on an https endpoint, and this check is what holds that invariant: + * with TLS asked for, the endpoint is an https URL. "https://" is required in + * full, which is stricter than the exporter's own test, so anything this + * accepts the exporter also treats as TLS. * * @param endpoint Endpoint URL from the config, or the built-in default. * @param configKey Config key the URL came from, named in the message. @@ -273,8 +273,8 @@ requireHttpsEndpoint(std::string const& endpoint, char const* configKey) Throw( std::string("Invalid value '") + configKey + "' in " + kSectionLabel + - ": must start with '" + std::string{kHttpsPrefix} + "' when " + key::tlsClientCert + - " is set, but is '" + endpoint + "'."); + ": must start with '" + std::string{kHttpsPrefix} + "' when " + key::useTls + + "=1, but is '" + endpoint + "'."); } /** @@ -394,13 +394,12 @@ makeTelemetrySetup( // Still inside the enabled branch, and checked before the files are // opened so a scheme problem is not hidden behind a path problem. Each - // exporter reads TLS off its own endpoint scheme, and both are handed - // the client certificate, so both endpoints have to be https. Checking - // only one leaves the other signal exporting in the clear without this - // node's identity. tls_ca_cert is left out of this check: it only names - // a trust store, while a client certificate is this node's own identity - // and has to reach the collector to mean anything. - if (!setup.tlsClientCertPath.empty()) + // exporter reads TLS off its own endpoint scheme alone, so use_tls=1 on + // an http endpoint would validate the certificate files and then still + // export in the clear. Both endpoints are checked, because checking only + // one leaves the other signal in plaintext. An operator who asks for TLS + // gets TLS on both signals, or a startup error. + if (setup.useTls) { requireHttpsEndpoint(setup.tracesEndpoint, key::tracesEndpoint); requireHttpsEndpoint(setup.metricsEndpoint, key::metricsEndpoint); diff --git a/src/libxrpl/telemetry/TxAccountSpanNames.cpp b/src/libxrpl/telemetry/TxAccountSpanNames.cpp new file mode 100644 index 0000000000..71318f4195 --- /dev/null +++ b/src/libxrpl/telemetry/TxAccountSpanNames.cpp @@ -0,0 +1,45 @@ +#include + +#include + +#include +#include +#include + +namespace xrpl::telemetry { + +std::optional +accountFieldAttributeKey(SField const& field) +{ + // Built on first call, not at static initialisation: the sf* objects are + // globals in another translation unit, so their codes are only safe to + // read once main() has started. + static std::unordered_map const kTable = { + {sfAccount.getCode(), tx_account_span::attr::account}, + {sfDestination.getCode(), tx_account_span::attr::destination}, + {sfOwner.getCode(), tx_account_span::attr::owner}, + {sfIssuer.getCode(), tx_account_span::attr::issuer}, + {sfAuthorize.getCode(), tx_account_span::attr::authorize}, + {sfUnauthorize.getCode(), tx_account_span::attr::unauthorize}, + {sfRegularKey.getCode(), tx_account_span::attr::regularKey}, + {sfNFTokenMinter.getCode(), tx_account_span::attr::nftokenMinter}, + {sfHolder.getCode(), tx_account_span::attr::holder}, + {sfDelegate.getCode(), tx_account_span::attr::delegate}, + {sfSponsor.getCode(), tx_account_span::attr::sponsor}, + {sfSponsee.getCode(), tx_account_span::attr::sponsee}, + {sfCounterparty.getCode(), tx_account_span::attr::counterparty}, + {sfCounterpartySponsor.getCode(), tx_account_span::attr::counterpartySponsor}, + {sfSubject.getCode(), tx_account_span::attr::subject}, + {sfOtherChainSource.getCode(), tx_account_span::attr::otherChainSource}, + {sfOtherChainDestination.getCode(), tx_account_span::attr::otherChainDestination}, + {sfAttestationSignerAccount.getCode(), tx_account_span::attr::attestationSignerAccount}, + {sfAttestationRewardAccount.getCode(), tx_account_span::attr::attestationRewardAccount}, + }; + + auto const it = kTable.find(field.getCode()); + if (it == kTable.end()) + return std::nullopt; + return it->second; +} + +} // namespace xrpl::telemetry diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 46d1037acf..060673c6e1 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -40,6 +40,7 @@ constexpr auto kConfidentialMptTxTypes = std::to_array({ ttCONFIDENTIAL_MPT_CONVERT_BACK, ttCONFIDENTIAL_MPT_MERGE_INBOX, ttCONFIDENTIAL_MPT_CLAWBACK, + ttCONFIDENTIAL_MPT_MIRROR_UPDATE, }); // Clamp to the cap (== INT64_MAX) before the signed conversion. Invariant diff --git a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp index c3131714f8..642a415be7 100644 --- a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp +++ b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -206,6 +207,13 @@ SponsorshipTransfer::preflight(PreflightContext const& ctx) return temMALFORMED; } + if (auto const objectID = ctx.tx[~sfObjectID]; + ctx.rules.enabled(fixCleanup3_5_0) && objectID && *objectID == beast::kZero) + { + JLOG(ctx.j.debug()) << "preflight: sfObjectID must not be zero"; + return temMALFORMED; + } + return tesSUCCESS; } diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp new file mode 100644 index 0000000000..c00486e5e6 --- /dev/null +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp @@ -0,0 +1,273 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +bool +ConfidentialMPTMirrorUpdate::checkExtraFeatures(PreflightContext const& ctx) +{ + // Key rotation makes sense only when featureConfidentialTransfer is enabled. + return ctx.rules.enabled(featureConfidentialTransfer); +} + +NotTEC +ConfidentialMPTMirrorUpdate::preflight(PreflightContext const& ctx) +{ + auto const account = ctx.tx[sfAccount]; + auto const issuer = MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer(); + auto const holder = ctx.tx[~sfHolder]; + bool const hasHolder = holder.has_value(); + + // The rotation mode is determined by the presence of the + // Holder field: Holder present is issuer mode, Holder absent is + // holder self-migration. + if (hasHolder) + { + // Issuer mode: account must be the issuer + if (account != issuer) + return temMALFORMED; + + if (account == *holder) + return temMALFORMED; + } + else + { + // Holder self-migration: the submitter is the holder, account must not be the issuer. + if (account == issuer) + return temMALFORMED; + } + + // At least one ciphertext will be updated. + bool const hasIssuerAmount = ctx.tx.isFieldPresent(sfIssuerEncryptedAmount); + bool const hasAuditorAmount = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); + if (!hasIssuerAmount && !hasAuditorAmount) + return temMALFORMED; + + // Check the length of the encrypted amounts. Length check is cheaper than format check so put + // it before the format check. + if (hasIssuerAmount && ctx.tx[sfIssuerEncryptedAmount].length() != kEcGamalEncryptedTotalLength) + return temBAD_CIPHERTEXT; + + if (hasAuditorAmount && + ctx.tx[sfAuditorEncryptedAmount].length() != kEcGamalEncryptedTotalLength) + return temBAD_CIPHERTEXT; + + // Check proof length. + if (ctx.tx[sfZKProof].length() != kEcEqualityProofLength) + return temMALFORMED; + + // Check the encrypted amount formats. It is more expensive so put it at the end of preflight. + if (hasIssuerAmount && !isValidCiphertext(ctx.tx[sfIssuerEncryptedAmount])) + return temBAD_CIPHERTEXT; + + if (hasAuditorAmount && !isValidCiphertext(ctx.tx[sfAuditorEncryptedAmount])) + return temBAD_CIPHERTEXT; + + return tesSUCCESS; +} + +XRPAmount +ConfidentialMPTMirrorUpdate::calculateBaseFee(ReadView const& view, STTx const& tx) +{ + return Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier); +} + +TER +ConfidentialMPTMirrorUpdate::preclaim(PreclaimContext const& ctx) +{ + // Check if account exists + auto const account = ctx.tx[sfAccount]; + if (!ctx.view.exists(keylet::account(account))) + return terNO_ACCOUNT; // LCOV_EXCL_LINE + + // The issuance must exist and have confidential balances enabled with a + // registered issuer encryption key; otherwise there is no mirror to update. + auto const mptIssuanceID = ctx.tx[sfMPTokenIssuanceID]; + auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(mptIssuanceID)); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; + + // The issuance must have confidential balances enabled with a registered issuer encryption key. + if (!sleIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) || + !sleIssuance->isFieldPresent(sfIssuerEncryptionKey)) + return tecNO_PERMISSION; + + // Sanity check: preflight already enforced the issuer holder combination + // under different rotation modes. + auto const holder = ctx.tx[~sfHolder]; + bool const hasHolder = holder.has_value(); + auto const issuer = sleIssuance->getAccountID(sfIssuer); + if (hasHolder ? (issuer != account) : (issuer == account)) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::preclaim : invalid issuer holder combination"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + // The holder is sfHolder in issuer mode and is sfAccount in holder mode. + auto const holderID = hasHolder ? *holder : account; + + // In issuer mode, the holder must exist. In holder mode, the account existence was checked + // already. + if (hasHolder && !ctx.view.exists(keylet::account(holderID))) + return tecNO_TARGET; + + // In either issuer or holder mode, check the existence of the MPToken object. + auto const sleMptoken = ctx.view.read(keylet::mptoken(mptIssuanceID, holderID)); + if (!sleMptoken) + return tecOBJECT_NOT_FOUND; + + // The holder must already hold an issuer confidential balance. + if (!sleMptoken->isFieldPresent(sfIssuerEncryptedBalance)) + return tecNO_PERMISSION; + + bool const hasIssuerAmount = ctx.tx.isFieldPresent(sfIssuerEncryptedAmount); + bool const hasAuditorAmount = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); + + // Migrating the auditor mirror requires the issuance to have a registered + // auditor encryption key. + if (hasAuditorAmount && !sleIssuance->isFieldPresent(sfAuditorEncryptionKey)) + return tecNO_PERMISSION; + + // An issuer mirror may only be re-encrypted while it is stale, reject if it is already current. + if (hasIssuerAmount && isIssuerMirrorCurrent(*sleIssuance, *sleMptoken)) + return tecNO_PERMISSION; + + if (hasAuditorAmount) + { + // An issuer-mode auditor-only migration: the issuer mirror must already be up to date. + if (hasHolder && !hasIssuerAmount && !isIssuerMirrorCurrent(*sleIssuance, *sleMptoken)) + return tecNO_PERMISSION; + + // An auditor mirror may only be re-encrypted while it is stale, reject if it is already + // current. isAuditorMirrorCurrent reports an absent auditor mirror as stale, which is what + // allows an auditor-only migration to create one for the first time. + if (isAuditorMirrorCurrent(*sleIssuance, *sleMptoken)) + return tecNO_PERMISSION; + } + + // Holder self-migration re-encrypts the mirror from the holder's own + // spending balance, which reflects the holder's full balance only once the + // inbox has been merged into it. Require the inbox to be canonical zero, + // i.e. ConfidentialMPTMergeInbox has already been applied. + if (!hasHolder) + { + // Sanity check: a holder that already carries an issuer mirror + // necessarily has a holder encryption key and a spending balance + if (!sleMptoken->isFieldPresent(sfHolderEncryptionKey) || + !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending)) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::preclaim : an issuer mirror implies a holder " + "key and spending balance"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + auto const expectedZeroInbox = encryptCanonicalZeroAmount( + (*sleMptoken)[sfHolderEncryptionKey], holderID, mptIssuanceID); + if (!expectedZeroInbox) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::preclaim : canonical zero encryption cannot " + "fail for an already-valid holder public key"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + bool const inboxIsCanonicalZero = sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) && + Slice((*sleMptoken)[sfConfidentialBalanceInbox]) == Slice(*expectedZeroInbox); + if (!inboxIsCanonicalZero) + return tecNO_PERMISSION; + } + + return tesSUCCESS; +} + +TER +ConfidentialMPTMirrorUpdate::doApply() +{ + auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID]; + + auto const sleIssuance = view().read(keylet::mptokenIssuance(mptIssuanceID)); + if (!sleIssuance) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::doApply : preclaim already validated the " + "issuance exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + // The holderID is sfHolder in issuer mode and sfAccount in holder mode. + auto const holder = ctx_.tx[~sfHolder]; + auto const holderID = holder.value_or(accountID_); + + auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, holderID)); + if (!sleMptoken) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::doApply : preclaim already validated the " + "MPToken exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + // Re-encrypt the requested mirror(s) and advance the corresponding mirror + // epoch to match the issuance key epoch. Each mirror is stamped separately + // because this transaction may migrate either one or both. + if (ctx_.tx.isFieldPresent(sfIssuerEncryptedAmount)) + { + (*sleMptoken)[sfIssuerEncryptedBalance] = ctx_.tx[sfIssuerEncryptedAmount]; + setIssuerMirrorEpoch(*sleIssuance, *sleMptoken); + } + + if (ctx_.tx.isFieldPresent(sfAuditorEncryptedAmount)) + { + (*sleMptoken)[sfAuditorEncryptedBalance] = ctx_.tx[sfAuditorEncryptedAmount]; + setAuditorMirrorEpoch(*sleIssuance, *sleMptoken); + } + + view().update(sleMptoken); + return tesSUCCESS; +} + +void +ConfidentialMPTMirrorUpdate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ +} + +bool +ConfidentialMPTMirrorUpdate::finalizeInvariants( + STTx const&, + TER, + XRPAmount, + ReadView const&, + beast::Journal const&) +{ + return true; +} + +} // namespace xrpl diff --git a/src/test/app/ConfidentialMPTKeyRotation_test.cpp b/src/test/app/ConfidentialMPTKeyRotation_test.cpp index 3f0f64d89d..0db9e29d27 100644 --- a/src/test/app/ConfidentialMPTKeyRotation_test.cpp +++ b/src/test/app/ConfidentialMPTKeyRotation_test.cpp @@ -3,9 +3,12 @@ #include #include +#include +#include #include #include #include +#include #include #include #include @@ -1259,6 +1262,1198 @@ class ConfidentialMPTKeyRotation_test : public ConfidentialTransferTestBase } } + void + testConfidentialMPTMirrorUpdatePreflight(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate preflight"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + // A well-formed 66-byte ElGamal ciphertext + Buffer const& validCipher = getTrivialCiphertext(); + + // Both amendments are required: ConfidentialMPTKeyRotation and ConfidentialTransfer. + if (!features[featureConfidentialMPTKeyRotation] || !features[featureConfidentialTransfer]) + { + mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer}); + mptAlice.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = temDISABLED, + }); + return; + } + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, + }); + // Issuer mode but account is not the issuer. + mptAlice.mirrorUpdate({ + .account = bob, + .holder = carol, + .issuerEncryptedAmount = validCipher, + .err = temMALFORMED, + }); + + // Issuer mode but the holder is the same as the issuer. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = alice, + .issuerEncryptedAmount = validCipher, + .err = temMALFORMED, + }); + + // Issuer mode but holder is not provided. + mptAlice.mirrorUpdate({ + .account = alice, + .issuerEncryptedAmount = validCipher, + .err = temMALFORMED, + }); + + // At least one of issuer or auditor amount must be present. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .err = temMALFORMED, + }); + + // Issuer amount has the wrong length. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = gMakeZeroBuffer(10), + .err = temBAD_CIPHERTEXT, + }); + + // Auditor amount has the wrong length. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = gMakeZeroBuffer(10), + .err = temBAD_CIPHERTEXT, + }); + + // The proof has the wrong length. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .zkProof = gMakeZeroBuffer(kEcEqualityProofLength - 1), + .err = temMALFORMED, + }); + + // Issuer amount is the right length but not a valid ciphertext. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = getBadCiphertext(), + .err = temBAD_CIPHERTEXT, + }); + + // Auditor amount is the right length but not a valid ciphertext. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = getBadCiphertext(), + .err = temBAD_CIPHERTEXT, + }); + } + + void + testConfidentialMPTMirrorUpdatePreclaim(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate preclaim"); + using namespace test::jtx; + + Buffer const& validCipher = getTrivialCiphertext(); + + // The issuance does not exist. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, + }); + // Destroy the issuance to test issuance not found. + mptAlice.destroy(); + + mptAlice.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = tecOBJECT_NOT_FOUND, + }); + } + + // The issuance has not enabled confidential balances. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer}); + + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // The issuer encryption key was not already registered. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create( + {.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); + mptAlice.authorize({.account = bob}); + + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // In issuer mode, the specified holder account does not exist. + { + Env env{*this, features}; + Account const alice("alice"); + Account const carol("carol"); + MPTTester mptAlice(env, alice); + mptAlice.create( + {.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // Carol never got funded so it does not exist. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = carol, + .issuerEncryptedAmount = validCipher, + .err = tecNO_TARGET, + }); + } + + // The holder's MPToken does not exist (holder never authorized). + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create( + {.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tecOBJECT_NOT_FOUND, + }); + } + + // The holder has an MPToken but no confidential issuer balance (sfIssuerEncryptedBalance). + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create( + {.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); + mptAlice.authorize({.account = bob}); + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Auditor mirror migration on an issuance with no auditor key. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + + // This setup has issuer key but no auditor key. + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Issuer mirror is already most up-to-date so + // there is nothing to migrate. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Issuer-mode auditor-only migration while the issuer mirror is stale: + // the issuer mirror must be brought up to date before the auditor + // mirror can be migrated. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + + // Issuance has both an issuer key and an auditor key, and bob holds + // both mirrors at epoch 0. + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate the issuer key: issuer key epoch 0 -> 1, while bob's + // issuer-mirror epoch stays 0 (stale). + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Auditor mirror is already current (the auditor key has not rotated), + // so there is nothing to migrate. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + + // Issuance has both keys and bob holds both mirrors at epoch 0. + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // No key has rotated, so the auditor mirror is up to date + // so there is nothing to migrate. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // In an issuer-mode simultaneous migration, both mirrors must be stale. Here + // only the issuer key has rotated so its mirror is stale but the auditor mirror is not. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the issuer key: issuer key epoch 0 -> 1, auditor key + // epoch stays 0. The issuer mirror is now stale but the auditor + // mirror is still current. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // In an issuer-mode simultaneous migration, both mirrors must be stale. + // Here only the auditor key has rotated so its mirror is stale but the + // issuer mirror is not. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newAuditorKey("newAuditorKey"); + + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the auditor key: auditor key epoch 0 -> 1, issuer key + // epoch stays 0. The auditor mirror is now stale but the issuer + // mirror is still current. + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)}); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Holder self-migration mode runs the same staleness checks. + // No key has rotated, so the holder's own issuer mirror is current and + // there is nothing to migrate. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Holder self-migration mode, simultaneously migrating both keys: only the issuer key + // has rotated, so the holder's issuer mirror is stale but the auditor + // mirror is still current. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the issuer key: issuer key epoch 0 -> 1, auditor key + // epoch stays 0. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + // Holder mode (no Holder field) needs no previous issuer key. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Holder self-migration mode, simultaneously migrating both keys: + // only the auditor key has rotated, so the holder's auditor mirror is stale but the issuer + // mirror is still current. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newAuditorKey("newAuditorKey"); + + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the auditor key: auditor key epoch 0 -> 1, issuer key + // epoch stays 0. + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)}); + + // Auditor mirror is stale but issuer mirror is current so this is rejected. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Holder self-migration requires the holder's inbox to be canonical + // zero, because the cross-key equality proof anchors on the spending + // balance, which only reflects the full balance after the inbox is + // merged. A holder with a non-zero inbox is rejected. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const newIssuerKey("newIssuerKey"); + + ConfidentialEnv ct{env, alice, {{.account = bob}, {.account = carol}}}; + + // Carol sends Bob a confidential amount; Bob does NOT merge it, so + // his inbox is no longer canonical zero. + ct.mpt.send({.account = carol, .dest = bob, .amt = 10}); + + // Rotate the issuer key so the issuer mirror is stale and the + // migration gets past the epoch check to reach the inbox check. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + + // Merging the inbox makes the migration succeed. + ct.mpt.mergeInbox({.account = bob}); + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = tesSUCCESS, + }); + } + + // A lock does not block a migration. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const newIssuerKey("newIssuerKey"); + Account const newerIssuerKey("newerIssuerKey"); + + ConfidentialEnv ct{env, alice, {{.account = bob}, {.account = carol}}}; + ct.mpt.set({.account = alice, .holder = bob, .flags = tfMPTLock}); + ct.mpt.set({.account = alice, .holder = carol, .flags = tfMPTLock}); + + // Rotate the issuer key so both holders' mirrors are stale. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + // The issuer migrates an individually locked holder. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tesSUCCESS, + }); + + // An individually locked holder migrates itself. + ct.mpt.mirrorUpdate({ + .account = carol, + .issuerEncryptedAmount = validCipher, + .err = tesSUCCESS, + }); + + // Release the individual locks and lock the whole issuance instead. Rotate again so + // both mirrors are stale once more. + ct.mpt.set({.account = alice, .holder = bob, .flags = tfMPTUnlock}); + ct.mpt.set({.account = alice, .holder = carol, .flags = tfMPTUnlock}); + ct.mpt.set({.account = alice, .flags = tfMPTLock}); + ct.mpt.generateKeyPair(newerIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newerIssuerKey)}); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tesSUCCESS, + }); + + ct.mpt.mirrorUpdate({ + .account = carol, + .issuerEncryptedAmount = validCipher, + .err = tesSUCCESS, + }); + } + } + + void + testConfidentialMPTMirrorUpdateDoApply(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate doApply"); + using namespace test::jtx; + + // The holder's confidential balance, matching the ConfidentialEnv default + // convertAmount. The migration re-encrypts this amount under the new key. + std::uint64_t const amount = 100; + + // Issuer mode issuer-mirror migration. The new issuer mirror is written + // and the auditor mirror epoch advances to the issuer key epoch. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const newIssuerKey("newIssuerKey"); + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + // Rotate the issuer key: issuer key epoch 0 -> 1. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + // Re-encrypt Bob's balance under the new issuer key. + Buffer const newIssuerCipher = + ct.mpt.encryptAmount(newIssuerKey, amount, generateBlindingFactor()); + + // The previous issuer key is the pre-rotation issuer key (alice's), + // no longer on-ledger after the rotation, provide it in the transaction. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u); + + // The issuer mirror is now current, so re-migrating it is rejected. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + .err = tecNO_PERMISSION, + }); + } + + // Issuer mode auditor-mirror migration. The new auditor mirror is written + // and the auditor mirror epoch advances to the auditor key epoch. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newAuditorKey("newAuditorKey"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the auditor key: auditor key epoch 0 -> 1. + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)}); + + // Re-encrypt Bob's balance under the new auditor key. + Buffer const newAuditorCipher = + ct.mpt.encryptAmount(newAuditorKey, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = newAuditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u); + } + + // Issuer mode simultaneous migration: both mirrors are written in one transaction and + // both epochs advance. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + Account const newAuditorKey("newAuditorKey"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate both keys: both key epochs 0 -> 1. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(newIssuerKey), + .auditorPubKey = ct.mpt.getPubKey(newAuditorKey), + }); + + // Re-encrypt Bob's balance under each new key. + Buffer const bf = generateBlindingFactor(); + Buffer const newIssuerCipher = ct.mpt.encryptAmount(newIssuerKey, amount, bf); + Buffer const newAuditorCipher = ct.mpt.encryptAmount(newAuditorKey, amount, bf); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + .auditorEncryptedAmount = newAuditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u); + } + + // Issuer mode auditor late-registration: the auditor key is registered for the first + // time (key epoch absent), so setting the initial auditor mirror leaves + // the auditor mirror epoch absent as well. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + // No auditor in the confidential setup, so bob has no auditor mirror. + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + // Register an auditor key for the first time (auditor key epoch stays + // absent). + ct.mpt.generateKeyPair(auditor); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditor)}); + + // Encrypt Bob's balance under the newly registered auditor key. + Buffer const auditorCipher = + ct.mpt.encryptAmount(auditor, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = auditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(auditorCipher)); + // First-time registration leaves the mirror epoch absent (== 0). + BEAST_EXPECT(!sle->isFieldPresent(sfAuditorKeyMirrorEpoch)); + } + + // Holder self-migration migrates from the holder's own spending balance + // (Holder being Account field, no Holder field, and no previous issuer key in any flow + // because the anchor is the spending balance, not the old issuer mirror). ConfidentialEnv + // already merged the inbox so the holder's inbox is canonical zero. + + // Holder issuer-mirror migration. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const newIssuerKey("newIssuerKey"); + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + // The holder re-encrypts their own balance under the new issuer key. + Buffer const newIssuerCipher = + ct.mpt.encryptAmount(newIssuerKey, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = newIssuerCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u); + } + + // Holder auditor-mirror migration. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newAuditorKey("newAuditorKey"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)}); + + // The holder re-encrypts their own balance under the new auditor key. + Buffer const newAuditorCipher = + ct.mpt.encryptAmount(newAuditorKey, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .auditorEncryptedAmount = newAuditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u); + } + + // Holder simultaneous migration of both mirrors. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + Account const newAuditorKey("newAuditorKey"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(newIssuerKey), + .auditorPubKey = ct.mpt.getPubKey(newAuditorKey), + }); + + Buffer const bf = generateBlindingFactor(); + Buffer const newIssuerCipher = ct.mpt.encryptAmount(newIssuerKey, amount, bf); + Buffer const newAuditorCipher = ct.mpt.encryptAmount(newAuditorKey, amount, bf); + + // Holder mode needs no previous issuer key even for the issuer mirror. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = newIssuerCipher, + .auditorEncryptedAmount = newAuditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u); + } + + // Holder auditor late-registration: the auditor key is registered for the first time (key + // epoch absent), so the holder setting their initial auditor mirror leaves the auditor + // mirror epoch absent as well. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + // No auditor in the confidential setup, so bob has no auditor mirror. + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + // Register an auditor key for the first time (auditor key epoch stays + // absent). + ct.mpt.generateKeyPair(auditor); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditor)}); + + // The holder encrypts their own balance under the newly registered auditor key. + Buffer const auditorCipher = + ct.mpt.encryptAmount(auditor, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .auditorEncryptedAmount = auditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(auditorCipher)); + // First-time registration leaves the mirror epoch absent. + BEAST_EXPECT(!sle->isFieldPresent(sfAuditorKeyMirrorEpoch)); + } + } + + void + testConfidentialMPTMirrorUpdateMultipleRotationsIssuerMode(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate issuer migrates after several rotations"); + using namespace test::jtx; + + std::uint64_t const amount = 100; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const issuerKey1("issuerKey1"); + Account const issuerKey2("issuerKey2"); + Account const issuerKey3("issuerKey3"); + Account const issuerKey4("issuerKey4"); + Account const issuerKey5("issuerKey5"); + Account const auditorKey1("auditorKey1"); + Account const auditorKey2("auditorKey2"); + Account const auditorKey3("auditorKey3"); + Account const auditorKey4("auditorKey4"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate the issuer key three times: issuer key epoch 0 -> 3. Bob never + // migrates in between, so his issuer mirror stays at mirror epoch 0 and + // is still encrypted under the original issuer key (alice's). + ct.mpt.generateKeyPair(issuerKey1); + ct.mpt.generateKeyPair(issuerKey2); + ct.mpt.generateKeyPair(issuerKey3); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey1)}); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey2)}); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey3)}); + + { + auto const sleIssuance = env.le(keylet::mptokenIssuance(ct.mpt.issuanceID())); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 3u); + } + + // A single migration re-encrypts the mirror under the newest key and + // jumps the mirror epoch straight to the current key epoch (3), rather + // than advancing one rotation at a time. The previous issuer key is the + // original key (alice's) that the stale mirror is still encrypted under, + // not any intermediate rotation. + Buffer const newIssuerCipher = + ct.mpt.encryptAmount(issuerKey3, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u); + } + + // The issuer mirror is now current (epoch 3 == key epoch 3), so a second + // issuer migration is rejected. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + .err = tecNO_PERMISSION, + }); + + // Now rotate the auditor key twice: auditor key epoch 0 -> 2. Bob's + // auditor mirror is still at mirror epoch 0, under the original auditor + // key. The issuer key and its epoch are untouched. + ct.mpt.generateKeyPair(auditorKey1); + ct.mpt.generateKeyPair(auditorKey2); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey1)}); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey2)}); + + { + auto const sleIssuance = env.le(keylet::mptokenIssuance(ct.mpt.issuanceID())); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfAuditorKeyEpoch] == 2u); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 3u); + } + + // A single auditor-only migration jumps the auditor mirror epoch straight + // to the current auditor key epoch (2). This is an issuer-mode + // auditor-only migration, which is allowed because the issuer mirror is + // already current; no previous issuer key is needed for an auditor + // migration. + Buffer const newAuditorCipher = + ct.mpt.encryptAmount(auditorKey2, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = newAuditorCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 2u); + // The issuer mirror and its epoch are unaffected by the auditor + // migration. + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u); + } + + // The auditor mirror is now current (epoch 2 == key epoch 2), so a second + // auditor migration is rejected. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = newAuditorCipher, + .err = tecNO_PERMISSION, + }); + + // Now rotate BOTH keys together twice: issuer key epoch 3 -> 5, auditor + // key epoch 2 -> 4. Bob's mirrors stay at epoch 3 / 2 (stale again). + ct.mpt.generateKeyPair(issuerKey4); + ct.mpt.generateKeyPair(issuerKey5); + ct.mpt.generateKeyPair(auditorKey3); + ct.mpt.generateKeyPair(auditorKey4); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(issuerKey4), + .auditorPubKey = ct.mpt.getPubKey(auditorKey3), + }); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(issuerKey5), + .auditorPubKey = ct.mpt.getPubKey(auditorKey4), + }); + + { + auto const sleIssuance = env.le(keylet::mptokenIssuance(ct.mpt.issuanceID())); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 5u); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfAuditorKeyEpoch] == 4u); + } + + // A single simultaneous migration brings both mirrors current in one + // transaction: issuer mirror epoch 3 -> 5, auditor mirror epoch 2 -> 4. + // The previous issuer key is issuerKey3, which is the key Bob's current + // (stale) issuer mirror is encrypted under after the earlier issuer + // migration, not alice's original key nor any intermediate rotation. + Buffer const bothIssuerCipher = + ct.mpt.encryptAmount(issuerKey5, amount, generateBlindingFactor()); + Buffer const bothAuditorCipher = + ct.mpt.encryptAmount(auditorKey4, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = bothIssuerCipher, + .auditorEncryptedAmount = bothAuditorCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(bothIssuerCipher)); + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(bothAuditorCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 5u); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 4u); + } + + // Both mirrors are current now, so a second simultaneous migration is + // rejected. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = bothIssuerCipher, + .auditorEncryptedAmount = bothAuditorCipher, + .err = tecNO_PERMISSION, + }); + } + + void + testConfidentialMPTMirrorUpdateMultipleRotationsHolderMode(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate holder migrates after several rotations"); + using namespace test::jtx; + + std::uint64_t const amount = 100; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const issuerKey1("issuerKey1"); + Account const issuerKey2("issuerKey2"); + Account const issuerKey3("issuerKey3"); + Account const issuerKey4("issuerKey4"); + Account const issuerKey5("issuerKey5"); + Account const auditorKey1("auditorKey1"); + Account const auditorKey2("auditorKey2"); + Account const auditorKey3("auditorKey3"); + Account const auditorKey4("auditorKey4"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // In holder self-migration mode the holder submits (account = bob, no + // Holder field) and never provides a previous issuer key. + // Bob's inbox is canonical zero after the ConfidentialEnv merge. + + // Rotate the issuer key three times: issuer key epoch 0 -> 3. + ct.mpt.generateKeyPair(issuerKey1); + ct.mpt.generateKeyPair(issuerKey2); + ct.mpt.generateKeyPair(issuerKey3); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey1)}); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey2)}); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey3)}); + + // A single holder migration jumps the issuer mirror epoch straight to 3. + Buffer const newIssuerCipher = + ct.mpt.encryptAmount(issuerKey3, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = newIssuerCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u); + } + + // The issuer mirror is current, so a second holder issuer migration is + // rejected. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = newIssuerCipher, + .err = tecNO_PERMISSION, + }); + + // Rotate the auditor key twice: auditor key epoch 0 -> 2. + ct.mpt.generateKeyPair(auditorKey1); + ct.mpt.generateKeyPair(auditorKey2); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey1)}); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey2)}); + + // A single holder auditor migration jumps the auditor mirror epoch to 2. + Buffer const newAuditorCipher = + ct.mpt.encryptAmount(auditorKey2, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .auditorEncryptedAmount = newAuditorCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 2u); + // The issuer mirror is unaffected. + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u); + } + + // The auditor mirror is current, so a second holder auditor migration is + // rejected. + ct.mpt.mirrorUpdate({ + .account = bob, + .auditorEncryptedAmount = newAuditorCipher, + .err = tecNO_PERMISSION, + }); + + // Rotate both keys together twice: issuer key epoch 3 -> 5, auditor key + // epoch 2 -> 4. + ct.mpt.generateKeyPair(issuerKey4); + ct.mpt.generateKeyPair(issuerKey5); + ct.mpt.generateKeyPair(auditorKey3); + ct.mpt.generateKeyPair(auditorKey4); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(issuerKey4), + .auditorPubKey = ct.mpt.getPubKey(auditorKey3), + }); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(issuerKey5), + .auditorPubKey = ct.mpt.getPubKey(auditorKey4), + }); + + // A single holder migration brings both mirrors current: issuer mirror + // epoch 3 -> 5, auditor mirror epoch 2 -> 4. Still no previous issuer key. + Buffer const bothIssuerCipher = + ct.mpt.encryptAmount(issuerKey5, amount, generateBlindingFactor()); + Buffer const bothAuditorCipher = + ct.mpt.encryptAmount(auditorKey4, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = bothIssuerCipher, + .auditorEncryptedAmount = bothAuditorCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(bothIssuerCipher)); + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(bothAuditorCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 5u); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 4u); + } + + // Both mirrors are current, so a second holder migration is rejected. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = bothIssuerCipher, + .auditorEncryptedAmount = bothAuditorCipher, + .err = tecNO_PERMISSION, + }); + } + public: void testMPTokenIssuanceSetWithFeats(FeatureBitset features) @@ -1273,7 +2468,6 @@ public: testMPTokenIssuanceSetKeyEpochAtMax(features); } -public: void run() override { @@ -1288,6 +2482,14 @@ public: testConfidentialMPTSendEpoch(all); testConfidentialMPTConvertBackEpoch(all); testConfidentialMPTClawbackEpoch(all); + + testConfidentialMPTMirrorUpdatePreflight(all); + testConfidentialMPTMirrorUpdatePreflight(all - featureConfidentialMPTKeyRotation); + testConfidentialMPTMirrorUpdatePreflight(all - featureConfidentialTransfer); + testConfidentialMPTMirrorUpdatePreclaim(all); + testConfidentialMPTMirrorUpdateDoApply(all); + testConfidentialMPTMirrorUpdateMultipleRotationsIssuerMode(all); + testConfidentialMPTMirrorUpdateMultipleRotationsHolderMode(all); } }; diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index 9a0bf08660..9ff9270a7d 100644 --- a/src/test/app/ConfidentialTransfer_test.cpp +++ b/src/test/app/ConfidentialTransfer_test.cpp @@ -7131,6 +7131,18 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.confidentialClaw( {.account = alice, .holder = carol, .amt = 15, .fee = expectedFee}); }); + + // Check fee for the mirror update transaction. + Account const newIssuerKey("newIssuerKey"); + mptAlice.generateKeyPair(newIssuerKey); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(newIssuerKey)}); + checkFee(alice, [&]() { + mptAlice.mirrorUpdate( + {.account = alice, + .holder = bob, + .issuerEncryptedAmount = getTrivialCiphertext(), + .fee = expectedFee}); + }); } // test insufficient fee for confidential transactions @@ -7162,6 +7174,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .amt = 1, .fee = baseFee, .err = telINSUF_FEE_P}); + mptAlice.mirrorUpdate( + {.account = alice, + .holder = bob, + .issuerEncryptedAmount = getTrivialCiphertext(), + .fee = baseFee, + .err = telINSUF_FEE_P}); } // test excessive fee for confidential transactions diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp index 5414269333..5f57cfa21b 100644 --- a/src/test/app/Delegate_test.cpp +++ b/src/test/app/Delegate_test.cpp @@ -2838,7 +2838,7 @@ class Delegate_test : public beast::unit_test::Suite // DO NOT modify expectedDelegableCount unless all scenarios, including // edge cases, have been fully tested and verified. // ==================================================================== - std::size_t const expectedDelegableCount = 56; + std::size_t const expectedDelegableCount = 57; BEAST_EXPECTS( delegableCount == expectedDelegableCount, diff --git a/src/test/app/LedgerReplay_test.cpp b/src/test/app/LedgerReplay_test.cpp index 6639b7c70f..f89c245f69 100644 --- a/src/test/app/LedgerReplay_test.cpp +++ b/src/test/app/LedgerReplay_test.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -21,7 +22,6 @@ #include #include #include -#include #include #include #include @@ -29,19 +29,14 @@ #include #include #include -#include #include #include #include #include #include -#include -#include #include -#include #include #include -#include #include #include @@ -276,136 +271,33 @@ enum class PeerFeature { * Simulate a network peer. * Depending on the configured PeerFeature, * it either supports the ProtocolFeature::LedgerReplay or not + * + * `PeerStub` supplies the rest of the `Peer` interface as no-ops. */ -class TestPeer : public Peer +class TestPeer : public PeerStub { public: - TestPeer(bool enableLedgerReplay) - : ledgerReplayEnabled_(enableLedgerReplay) - , nodePublicKey_(derivePublicKey(KeyType::Ed25519, randomSecretKey())) + // Arbitrary but fixed: the replay code only compares ids. + explicit TestPeer(bool enableLedgerReplay) + : PeerStub(1234), ledgerReplayEnabled_(enableLedgerReplay) { } - void - send(std::shared_ptr const& m) override - { - } - [[nodiscard]] beast::ip::Endpoint - getRemoteAddress() const override - { - return {}; - } - void - charge(resource::Charge const& fee, std::string const& context = {}) override - { - } - [[nodiscard]] id_t - id() const override - { - return 1234; - } - [[nodiscard]] bool - cluster() const override - { - return false; - } - [[nodiscard]] bool - isHighLatency() const override - { - return false; - } - [[nodiscard]] int - getScore(bool) const override - { - return 0; - } - [[nodiscard]] PublicKey const& - getNodePublic() const override - { - return nodePublicKey_; - } - json::Value - json() override - { - return {}; - } [[nodiscard]] bool supportsFeature(ProtocolFeature f) const override { return f == ProtocolFeature::LedgerReplay && ledgerReplayEnabled_; } - [[nodiscard]] std::optional - publisherListSequence(PublicKey const&) const override - { - return {}; - } - void - setPublisherListSequence(PublicKey const&, std::size_t const) override - { - } - [[nodiscard]] uint256 - getClosedLedgerHash() const override - { - static uint256 const kHash{}; - return kHash; - } + + // The replay code only asks peers that already have the ledger. [[nodiscard]] bool - hasLedger(uint256 const& hash, std::uint32_t seq) const override + hasLedger(uint256 const&, std::uint32_t) const override { return true; } - void - ledgerRange(std::uint32_t& minSeq, std::uint32_t& maxSeq) const override - { - } - [[nodiscard]] bool - hasTxSet(uint256 const& hash) const override - { - return false; - } - void - cycleStatus() override - { - } - bool - hasRange(std::uint32_t uMin, std::uint32_t uMax) override - { - return false; - } - [[nodiscard]] bool - compressionEnabled() const override - { - return false; - } - void - sendTxQueue() override - { - } - void - addTxQueue(uint256 const&) override - { - } - void - removeTxQueue(uint256 const&) override - { - } - [[nodiscard]] bool - txReduceRelayEnabled() const override - { - return false; - } - [[nodiscard]] std::string const& - fingerprint() const override - { - return fingerprint_; - } - - // NOLINTBEGIN(readability-identifier-naming) - std::string fingerprint_; +private: bool ledgerReplayEnabled_; - PublicKey nodePublicKey_; - // NOLINTEND(readability-identifier-naming) }; enum class PeerSetBehavior { diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index 71d968014f..22b5d0bcdc 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -1163,6 +1163,25 @@ public: sponsor::SponseeAcc(alice), Ter(temMALFORMED)); } + + // Post-fixCleanup3_5_0, a zero ObjectID is malformed. + // Pre-fixCleanup3_5_0 path is unreachable so it is not testable. + if (features[fixCleanup3_5_0]) + { + uint256 const zeroObjectID{}; + + env(sponsor::transfer(alice, tfSponsorshipEnd, zeroObjectID), Ter(temMALFORMED)); + + env(sponsor::transfer(alice, tfSponsorshipCreate, zeroObjectID), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(temMALFORMED)); + + env(sponsor::transfer(alice, tfSponsorshipReassign, zeroObjectID), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(temMALFORMED)); + } } { diff --git a/src/test/jtx/ConfidentialTransfer.h b/src/test/jtx/ConfidentialTransfer.h index 5c1b90328b..21e1929902 100644 --- a/src/test/jtx/ConfidentialTransfer.h +++ b/src/test/jtx/ConfidentialTransfer.h @@ -3,28 +3,19 @@ #include #include #include -#include #include -#include #include #include -#include #include #include #include #include #include -#include - -#include -#include - #include #include #include -#include #include #include #include @@ -54,237 +45,66 @@ protected: return *value; } - // Offset where the bulletproof begins in a send proof blob. - // Proof layout: [compact_sigma | bulletproof] - static constexpr size_t kBulletproofOffset = kEcSendProofLength - kEcDoubleBulletproofLength; - - // Generate a forged aggregated bulletproof (double bulletproof) for - // the given values and blinding factors. Used to test that splicing - // a bulletproof claiming a different remaining balance is rejected. - // secp256k1 convention: returns 1 on success, 0 on failure. - static Buffer - getForgedBulletproof( - std::array const& values, - std::array const& blindingFactors, - uint256 const& contextHash) + // Creates the MPT issuance on the given Env, authorizes and funds each + // holder, generates keys for the issuer, holders and optional auditor, + // registers the issuer/auditor keys, and converts part of each holder's + // balance to a confidential balance. + struct ConfidentialEnv { - auto* const ctx = mpt_secp256k1_context(); + // Per-holder configuration: the account, how much MPT to fund it + // with, and how much of that to convert to a confidential balance. + struct HolderInit + { + test::jtx::Account account; + std::uint64_t payAmount = 1000; + std::uint64_t convertAmount = 100; + }; - secp256k1_pubkey h; - secp256k1_mpt_get_h_generator(ctx, &h); + test::jtx::MPTTester mpt; - Buffer proof(kEcDoubleBulletproofLength); - size_t proofLen = kEcDoubleBulletproofLength; + ConfidentialEnv( + test::jtx::Env& env, + test::jtx::Account const& issuer, + std::vector const& holders, + std::uint32_t flags = tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + std::optional auditor = std::nullopt); - unsigned char blindings[64]; - std::memcpy(blindings, blindingFactors[0].data(), 32); - std::memcpy(blindings + 32, blindingFactors[1].data(), 32); + private: + static std::vector + extractAccounts(std::vector const& holders); + }; - if (secp256k1_bulletproof_prove_agg( - ctx, - proof.data(), - &proofLen, - values.data(), - blindings, - 2, - &h, - contextHash.data()) == 0) - Throw("Failed to generate forged bulletproof"); - - return proof; - } - - // Generate a forged single bulletproof for a single value and blinding factor. - // Used to test ConvertBack overdraft prevention via bulletproof verification. - static Buffer - getForgedSingleBulletproof( - uint64_t value, - Buffer const& blindingFactor, - uint256 const& contextHash) - { - auto* const ctx = mpt_secp256k1_context(); - - secp256k1_pubkey h; - secp256k1_mpt_get_h_generator(ctx, &h); - - Buffer proof(kEcSingleBulletproofLength); - size_t proofLen = kEcSingleBulletproofLength; - - if (secp256k1_bulletproof_prove_agg( - ctx, - proof.data(), - &proofLen, - &value, - blindingFactor.data(), - 1, // m = 1 (single bulletproof) - &h, - contextHash.data()) == 0) - Throw("Failed to generate forged single bulletproof"); - - return proof; - } - - // Forges a ConvertBack proof (compact sigma + single bulletproof) whose - // sigma component claims claimedBalance (which may be wrong) while binding - // to the real pedersen commitment and encrypted spending balance - // ciphertext already on the ledger. The bulletproof component is built - // from realBalance so it stays honest. - // mpt_get_convert_back_proof does not allow to build a proof whose amount - // exceeds the holder's claimed balance. - static Buffer - getForgedConvertBackProof( + // Create an issuance that can hold confidential balances, with the listed + // holders funded and authorized, and a key pair generated for the issuer, + // every holder, and every extra key owner. The keys are + // generated but not registered. + static void + setupConfidentialIssuance( test::jtx::MPTTester& mpt, - test::jtx::Account const& holder, - uint64_t claimedBalance, - uint64_t realBalance, - uint64_t amt, - Buffer const& pedersenCommitment, - Buffer const& encryptedSpendingBalance, - Buffer const& pcBlindingFactor, - uint256 const& contextHash) - { - if (pedersenCommitment.size() != kCompressedEcPointLength) - Throw("getForgedConvertBackProof: bad pedersenCommitment length"); - if (encryptedSpendingBalance.size() != kEcGamalEncryptedTotalLength) - { - Throw( - "getForgedConvertBackProof: bad encryptedSpendingBalance length"); - } - if (amt > realBalance) - Throw("getForgedConvertBackProof: amt exceeds realBalance"); + test::jtx::Account const& issuer, + std::vector const& holders, + std::vector const& keyOwners = {}, + std::uint32_t flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance); - auto* const ctx = mpt_secp256k1_context(); - auto const holderPubKey = requireOptional(mpt.getPubKey(holder), "Missing holder pubkey"); - auto const holderPrivKey = - requireOptional(mpt.getPrivKey(holder), "Missing holder privkey"); - - secp256k1_pubkey pkHolder; - if (secp256k1_ec_pubkey_parse( - ctx, &pkHolder, holderPubKey.data(), kCompressedEcPointLength) != 1) - Throw("Failed to parse holder's public key"); - - secp256k1_pubkey pcB; - if (secp256k1_ec_pubkey_parse( - ctx, &pcB, pedersenCommitment.data(), kCompressedEcPointLength) != 1) - Throw("Failed to parse pedersen commitment"); - - secp256k1_pubkey b1, b2; - if (secp256k1_ec_pubkey_parse( - ctx, &b1, encryptedSpendingBalance.data(), kCompressedEcPointLength) != 1 || - secp256k1_ec_pubkey_parse( - ctx, - &b2, - encryptedSpendingBalance.data() + kCompressedEcPointLength, - kCompressedEcPointLength) != 1) - Throw("Failed to parse balance ciphertext"); - - Buffer sigmaProof(SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE); - if (secp256k1_compact_convertback_prove( - ctx, - sigmaProof.data(), - claimedBalance, - holderPrivKey.data(), - pcBlindingFactor.data(), - &pkHolder, - &b1, - &b2, - &pcB, - contextHash.data()) != 1) - Throw("Failed to generate convertback sigma proof"); - - auto const forgedBulletproof = - getForgedSingleBulletproof(realBalance - amt, pcBlindingFactor, contextHash); - - Buffer proof(kEcConvertBackProofLength); - std::memcpy(proof.data(), sigmaProof.data(), SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE); - std::memcpy( - proof.data() + SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE, - forgedBulletproof.data(), - kEcSingleBulletproofLength); - - return proof; - } - - // Get a bad ciphertext with valid structure but cryptographic invalid for - // testing purposes. For preflight test purposes. - static Buffer const& - getBadCiphertext() - { - static Buffer const kBadCiphertext = []() { - Buffer buf(kEcGamalEncryptedTotalLength); - std::memset(buf.data(), 0xFF, kEcGamalEncryptedTotalLength); - - buf.data()[0] = kEcCompressedPrefixEvenY; - buf.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY; - return buf; - }(); - - return kBadCiphertext; - } - - // Get a trivial buffer that is structurally and mathematically valid, but - // contains invalid data that does not match the ledger state. For preclaim - // test purposes. - static Buffer const& - getTrivialCiphertext() - { - static Buffer const kTrivialCiphertext = []() { - Buffer buf(kEcGamalEncryptedTotalLength); - std::memset(buf.data(), 0, kEcGamalEncryptedTotalLength); - - buf.data()[0] = kEcCompressedPrefixEvenY; - buf.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY; - - buf.data()[kEcCiphertextComponentLength - 1] = 0x01; - buf.data()[kEcGamalEncryptedTotalLength - 1] = 0x01; - - return buf; - }(); - - return kTrivialCiphertext; - } - - // Returns a valid compressed EC point (33 bytes) that can pass preflight - // validation but contains invalid data for preclaim test purposes. - static Buffer const& - getTrivialCommitment() - { - static Buffer const kTrivialCommitment = []() { - Buffer buf(kEcPedersenCommitmentLength); - std::memset(buf.data(), 0, kEcPedersenCommitmentLength); - - buf.data()[0] = kEcCompressedPrefixEvenY; - // Set last byte to make it a valid x-coordinate on the curve - buf.data()[kEcPedersenCommitmentLength - 1] = 0x01; - - return buf; - }(); - - return kTrivialCommitment; - } - - static std::string - getTrivialSendProofHex() - { - Buffer buf(kEcSendProofLength); - std::memset(buf.data(), 0, kEcSendProofLength); - - for (std::size_t i = 0; i < kEcSendProofLength; i += kEcCiphertextComponentLength) - { - buf.data()[i] = kEcCompressedPrefixEvenY; - if (i + kEcCiphertextComponentLength - 1 < kEcSendProofLength) - buf.data()[i + kEcCiphertextComponentLength - 1] = 0x01; - } - - return strHex(buf); - } + // Set up an MPT environment suitable for batch testing. + // alice is issuer; bob has 'bobAmt' in confidential spending; carol has + // 'carolAmt' in confidential spending; dave is initialised with pubkey but + // zero spending/inbox. + static void + setupBatchEnv( + test::jtx::MPTTester& mpt, + test::jtx::Account const& alice, + test::jtx::Account const& bob, + test::jtx::Account const& carol, + test::jtx::Account const& dave, + std::uint64_t bobAmt, + std::uint64_t carolAmt); // Helper struct to encapsulate common setup for integration tests. struct ConfidentialSendSetup { // Constants uint64_t sendAmount; - size_t nRecipients; uint32_t version; // Blinding factors @@ -324,55 +144,7 @@ protected: test::jtx::Account const& dest, test::jtx::Account const& issuer, uint64_t amount, - std::optional> auditor = std::nullopt) - : sendAmount(amount) - , nRecipients(auditor ? 4 : 3) - , version(mpt.getMPTokenVersion(sender)) - , blindingFactor(generateBlindingFactor()) - , amountBlindingFactor(blindingFactor) - , balanceBlindingFactor(generateBlindingFactor()) - , senderAmt(mpt.encryptAmount(sender, amount, blindingFactor)) - , destAmt(mpt.encryptAmount(dest, amount, blindingFactor)) - , issuerAmt(mpt.encryptAmount(issuer, amount, blindingFactor)) - , auditorAmt( - auditor ? std::optional( - mpt.encryptAmount(auditor->get(), amount, blindingFactor)) - : std::nullopt) - , amountCommitment(mpt.getPedersenCommitment(amount, amountBlindingFactor)) - , senderPubKey(requireOptional(mpt.getPubKey(sender), "Missing sender public key")) - , destPubKey(requireOptional(mpt.getPubKey(dest), "Missing destination public key")) - , issuerPubKey(requireOptional(mpt.getPubKey(issuer), "Missing issuer public key")) - , auditorPubKey(auditor ? mpt.getPubKey(auditor->get()) : std::nullopt) - , prevSpending(requireOptional( - mpt.getDecryptedBalance(sender, test::jtx::MPTTester::holderEncryptedSpending), - "Missing sender spending balance")) - , prevEncryptedSpending(requireOptional( - mpt.getEncryptedBalance(sender, test::jtx::MPTTester::holderEncryptedSpending), - "Missing sender encrypted spending balance")) - , balanceCommitment(mpt.getPedersenCommitment(prevSpending, balanceBlindingFactor)) - { - recipients.push_back({ - .publicKey = Slice(senderPubKey), - .encryptedAmount = senderAmt, - }); - recipients.push_back({ - .publicKey = Slice(destPubKey), - .encryptedAmount = destAmt, - }); - recipients.push_back({ - .publicKey = Slice(issuerPubKey), - .encryptedAmount = issuerAmt, - }); - if (auditor) - { - recipients.push_back({ - .publicKey = - Slice(requireOptionalRef(auditorPubKey, "Missing auditor public key")), - .encryptedAmount = - requireOptionalRef(auditorAmt, "Missing auditor encrypted amount"), - }); - } - } + std::optional> auditor = std::nullopt); // Generate proof with current account sequence std::optional @@ -380,54 +152,78 @@ protected: test::jtx::MPTTester& mpt, test::jtx::Env& env, test::jtx::Account const& sender, - test::jtx::Account const& dest) const - { - auto const ctxHash = getSendContextHash( - sender.id(), mpt.issuanceID(), env.seq(sender), dest.id(), version); - - return mpt.getConfidentialSendProof( - sender, - sendAmount, - recipients, - blindingFactor, - ctxHash, - { - .pedersenCommitment = amountCommitment, - .amt = sendAmount, - .encryptedAmt = senderAmt, - .blindingFactor = amountBlindingFactor, - }, - { - .pedersenCommitment = balanceCommitment, - .amt = prevSpending, - .encryptedAmt = prevEncryptedSpending, - .blindingFactor = balanceBlindingFactor, - }); - } + test::jtx::Account const& dest) const; [[nodiscard]] test::jtx::MPTConfidentialSend sendArgs( test::jtx::Account const& sender, test::jtx::Account const& dest, Buffer const& proof, - std::optional err = std::nullopt) const - { - return { - .account = sender, - .dest = dest, - .amt = sendAmount, - .proof = strHex(proof), - .senderEncryptedAmt = senderAmt, - .destEncryptedAmt = destAmt, - .issuerEncryptedAmt = issuerAmt, - .auditorEncryptedAmt = auditorAmt, - .amountCommitment = amountCommitment, - .balanceCommitment = balanceCommitment, - .err = err, - }; - } + std::optional err = std::nullopt) const; }; + // Get a bad ciphertext with valid structure but cryptographic invalid for + // testing purposes. For preflight test purposes. + static Buffer const& + getBadCiphertext(); + + // Get a trivial buffer that is structurally and mathematically valid, but + // contains invalid data that does not match the ledger state. For preclaim + // test purposes. + static Buffer const& + getTrivialCiphertext(); + + // Returns a valid compressed EC point (33 bytes) that can pass preflight + // validation but contains invalid data for preclaim test purposes. + static Buffer const& + getTrivialCommitment(); + + // Returns a hex-encoded send proof of the correct length filled with + // placeholder data. It passes the proof length check in preflight but + // fails proof verification. + static std::string + getTrivialSendProofHex(); + + // Offset where the bulletproof begins in a send proof blob. + // Proof layout: [compact_sigma | bulletproof] + static constexpr size_t kBulletproofOffset = kEcSendProofLength - kEcDoubleBulletproofLength; + + // Generate a forged aggregated bulletproof (double bulletproof) for + // the given values and blinding factors. Used to test that splicing + // a bulletproof claiming a different remaining balance is rejected. + static Buffer + getForgedBulletproof( + std::array const& values, + std::array const& blindingFactors, + uint256 const& contextHash); + + // Generate a forged single bulletproof for a single value and blinding factor. + // Used to test ConvertBack overdraft prevention via bulletproof verification. + static Buffer + getForgedSingleBulletproof( + uint64_t value, + Buffer const& blindingFactor, + uint256 const& contextHash); + + // Forges a ConvertBack proof (compact sigma + single bulletproof) whose + // sigma component claims claimedBalance (which may be wrong) while binding + // to the real pedersen commitment and to the encrypted spending balance + // already on the ledger. The bulletproof component is built from the real + // remaining balance (realBalance - amt) so it stays honest. + // mpt_get_convert_back_proof validates its inputs before proving, so it + // cannot be used to build such an inconsistent proof. + static Buffer + getForgedConvertBackProof( + test::jtx::MPTTester& mpt, + test::jtx::Account const& holder, + uint64_t claimedBalance, + uint64_t realBalance, + uint64_t amt, + Buffer const& pedersenCommitment, + Buffer const& encryptedSpendingBalance, + Buffer const& pcBlindingFactor, + uint256 const& contextHash); + // Forges a ConfidentialMPTSend proof (compact sigma + double bulletproof) // for setup.sendAmount against setup's real balance commitment/ciphertext. // mpt_get_confidential_send_proof does not allow to build a proof whose amount @@ -438,265 +234,7 @@ protected: test::jtx::Env& env, test::jtx::Account const& sender, test::jtx::Account const& dest, - ConfidentialSendSetup const& setup) - { - auto* const ctx = mpt_secp256k1_context(); - - secp256k1_pubkey c1; - std::vector c2Vec(setup.recipients.size()); - std::vector pkVec(setup.recipients.size()); - for (std::size_t i = 0; i < setup.recipients.size(); ++i) - { - auto const& r = setup.recipients[i]; - if (i == 0 && - secp256k1_ec_pubkey_parse( - ctx, &c1, r.encryptedAmount.data(), kCompressedEcPointLength) != 1) - Throw("Failed to parse C1"); - if (secp256k1_ec_pubkey_parse( - ctx, - &c2Vec[i], - r.encryptedAmount.data() + kCompressedEcPointLength, - kCompressedEcPointLength) != 1) - Throw("Failed to parse C2"); - if (secp256k1_ec_pubkey_parse( - ctx, &pkVec[i], r.publicKey.data(), kCompressedEcPointLength) != 1) - Throw("Failed to parse recipient pubkey"); - } - - secp256k1_pubkey pkSender, pcAmount, pcBalance, b1, b2; - if (secp256k1_ec_pubkey_parse( - ctx, &pkSender, setup.senderPubKey.data(), kCompressedEcPointLength) != 1 || - secp256k1_ec_pubkey_parse( - ctx, &pcAmount, setup.amountCommitment.data(), kCompressedEcPointLength) != 1 || - secp256k1_ec_pubkey_parse( - ctx, &pcBalance, setup.balanceCommitment.data(), kCompressedEcPointLength) != 1 || - secp256k1_ec_pubkey_parse( - ctx, &b1, setup.prevEncryptedSpending.data(), kCompressedEcPointLength) != 1 || - secp256k1_ec_pubkey_parse( - ctx, - &b2, - setup.prevEncryptedSpending.data() + kCompressedEcPointLength, - kCompressedEcPointLength) != 1) - Throw("Failed to parse commitments/ciphertext"); - - Buffer const senderPrivKey = - requireOptional(mpt.getPrivKey(sender), "Missing sender privkey"); - auto const ctxHash = getSendContextHash( - sender.id(), mpt.issuanceID(), env.seq(sender), dest.id(), setup.version); - - Buffer sigmaProof(SECP256K1_COMPACT_STANDARD_PROOF_SIZE); - if (secp256k1_compact_standard_prove( - ctx, - sigmaProof.data(), - setup.sendAmount, - setup.prevSpending, - setup.blindingFactor.data(), - senderPrivKey.data(), - setup.balanceBlindingFactor.data(), - setup.recipients.size(), - &c1, - c2Vec.data(), - pkVec.data(), - &pcAmount, - &pkSender, - &pcBalance, - &b1, - &b2, - ctxHash.data()) != 1) - Throw("Failed to generate sigma proof"); - - // Wraps (mod 2^64) for overdrafts, unlike the ledger's own homomorphic - // commitment subtraction (mod the curve order) — that mismatch is - // exactly what makes the forged proof fail verification. - // Computed without a wrapping `uint64` subtract: Clang UBSan treats - // unsigned overflow as fatal (see incrementConfidentialVersion). - std::uint64_t const remaining = setup.sendAmount <= setup.prevSpending - ? setup.prevSpending - setup.sendAmount - : ~setup.sendAmount + setup.prevSpending + 1; - - Buffer negAmountBf(kEcBlindingFactorLength); - Buffer remainingBf(kEcBlindingFactorLength); - secp256k1_mpt_scalar_negate(negAmountBf.data(), setup.amountBlindingFactor.data()); - secp256k1_mpt_scalar_add( - remainingBf.data(), setup.balanceBlindingFactor.data(), negAmountBf.data()); - - auto const forgedBulletproof = getForgedBulletproof( - {setup.sendAmount, remaining}, {setup.amountBlindingFactor, remainingBf}, ctxHash); - - Buffer combinedProof(kEcSendProofLength); - std::memcpy(combinedProof.data(), sigmaProof.data(), SECP256K1_COMPACT_STANDARD_PROOF_SIZE); - std::memcpy( - combinedProof.data() + SECP256K1_COMPACT_STANDARD_PROOF_SIZE, - forgedBulletproof.data(), - kEcDoubleBulletproofLength); - - return combinedProof; - } - - // Helper that wraps the boilerplate setup: Env + MPT creation, funding, key - // generation, and seeding each holder with a confidential balance. - // The caller supplies the issuer and any number of holders. - struct ConfidentialEnv - { - // Per-holder configuration: the account, how much MPT to fund it - // with, and how much of that to convert to a confidential balance. - struct HolderInit - { - test::jtx::Account account; - std::uint64_t payAmount = 1000; - std::uint64_t convertAmount = 100; - }; - - test::jtx::MPTTester mpt; - - ConfidentialEnv( - test::jtx::Env& env, - test::jtx::Account const& issuer, - std::vector const& holders, - std::uint32_t flags = tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, - std::optional auditor = std::nullopt) - : mpt{env, issuer, {.holders = extractAccounts(holders), .auditor = auditor}} - { - mpt.create({.ownerCount = 1, .flags = flags}); - - for (auto const& h : holders) - { - mpt.authorize({.account = h.account}); - if ((flags & tfMPTRequireAuth) != 0) - mpt.authorize({.account = issuer, .holder = h.account}); - mpt.pay(issuer, h.account, h.payAmount); - } - - mpt.generateKeyPair(issuer); - for (auto const& h : holders) - mpt.generateKeyPair(h.account); - if (auditor) - mpt.generateKeyPair(requireOptionalRef(auditor, "Missing auditor")); - - mpt.set({ - .account = issuer, - .issuerPubKey = mpt.getPubKey(issuer), - .auditorPubKey = auditor - ? mpt.getPubKey(requireOptionalRef(auditor, "Missing auditor")) - : std::optional{}, - }); - - for (auto const& h : holders) - { - mpt.convert({ - .account = h.account, - .amt = h.convertAmount, - .holderPubKey = mpt.getPubKey(h.account), - }); - mpt.mergeInbox({.account = h.account}); - } - } - - private: - static std::vector - extractAccounts(std::vector const& holders) - { - std::vector accounts; - accounts.reserve(holders.size()); - for (auto const& h : holders) - accounts.push_back(h.account); - return accounts; - } - }; - - // Create an issuance that can hold confidential balances, with the listed - // holders funded and authorized, and a key pair generated for the issuer, - // every holder, and every extra key owner. The keys are - // generated but not registered. - static void - setupConfidentialIssuance( - test::jtx::MPTTester& mpt, - test::jtx::Account const& issuer, - std::vector const& holders, - std::vector const& keyOwners = {}, - std::uint32_t flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance); - - // Set up an MPT environment suitable for batch testing. - // alice is issuer; bob has 'bobAmt' in confidential spending; carol has - // 'carolAmt' in confidential spending; dave is initialised with pubkey but - // zero spending/inbox. - static void - setupBatchEnv( - test::jtx::MPTTester& mpt, - test::jtx::Account const& alice, - test::jtx::Account const& bob, - test::jtx::Account const& carol, - test::jtx::Account const& dave, - std::uint64_t bobAmt, - std::uint64_t carolAmt) - { - using namespace test::jtx; - mpt.create({ - .ownerCount = 1, - .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, - }); - mpt.authorize({.account = bob}); - mpt.authorize({.account = carol}); - mpt.authorize({.account = dave}); - - if (bobAmt > 0) - mpt.pay(alice, bob, bobAmt); - if (carolAmt > 0) - mpt.pay(alice, carol, carolAmt); - - mpt.generateKeyPair(alice); - mpt.generateKeyPair(bob); - mpt.generateKeyPair(carol); - mpt.generateKeyPair(dave); - - mpt.set({ - .account = alice, - .issuerPubKey = mpt.getPubKey(alice), - }); - - if (bobAmt > 0) - { - mpt.convert({ - .account = bob, - .amt = bobAmt, - .holderPubKey = mpt.getPubKey(bob), - }); - mpt.mergeInbox({.account = bob}); - } - else - { - mpt.convert({ - .account = bob, - .amt = 0, - .holderPubKey = mpt.getPubKey(bob), - }); - } - - if (carolAmt > 0) - { - mpt.convert({ - .account = carol, - .amt = carolAmt, - .holderPubKey = mpt.getPubKey(carol), - }); - mpt.mergeInbox({.account = carol}); - } - else - { - mpt.convert({ - .account = carol, - .amt = 0, - .holderPubKey = mpt.getPubKey(carol), - }); - } - - // dave: register pubkey only (0 spending/inbox) - mpt.convert({ - .account = dave, - .amt = 0, - .holderPubKey = mpt.getPubKey(dave), - }); - } + ConfidentialSendSetup const& setup); }; } // namespace xrpl diff --git a/src/test/jtx/PeerStub.h b/src/test/jtx/PeerStub.h new file mode 100644 index 0000000000..cabe94f351 --- /dev/null +++ b/src/test/jtx/PeerStub.h @@ -0,0 +1,185 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl::test { + +/** + * A `Peer` whose every method is a no-op returning a default. + * + * Derive from this and override only the methods a test cares about. Adding a + * method to `Peer` then costs one stub here, not one per double. + * + * The id and the node public key are real, because the code under test routes + * and deduplicates on both. + */ +class PeerStub : public Peer +{ +public: + /** + * @param id The connection id reported by `id()`. + */ + explicit PeerStub(id_t id = 0) + : id_(id), nodePublicKey_(derivePublicKey(KeyType::Ed25519, randomSecretKey())) + { + } + + ~PeerStub() override = default; + + void + send(std::shared_ptr const&) override + { + } + + [[nodiscard]] beast::ip::Endpoint + getRemoteAddress() const override + { + return {}; + } + + void + sendTxQueue() override + { + } + + void + addTxQueue(uint256 const&) override + { + } + + void + removeTxQueue(uint256 const&) override + { + } + + void + charge(resource::Charge const&, std::string const&) override + { + } + + [[nodiscard]] id_t + id() const override + { + return id_; + } + + [[nodiscard]] bool + cluster() const override + { + return false; + } + + [[nodiscard]] bool + isHighLatency() const override + { + return false; + } + + [[nodiscard]] int + getScore(bool) const override + { + return 0; + } + + [[nodiscard]] PublicKey const& + getNodePublic() const override + { + return nodePublicKey_; + } + + json::Value + json() override + { + return {}; + } + + [[nodiscard]] bool + supportsFeature(ProtocolFeature) const override + { + return false; + } + + [[nodiscard]] std::optional + publisherListSequence(PublicKey const&) const override + { + return {}; + } + + void + setPublisherListSequence(PublicKey const&, std::size_t const) override + { + } + + [[nodiscard]] std::string const& + fingerprint() const override + { + return fingerprint_; + } + + [[nodiscard]] uint256 + getClosedLedgerHash() const override + { + return {}; + } + + [[nodiscard]] bool + hasLedger(uint256 const&, std::uint32_t) const override + { + return false; + } + + void + ledgerRange(std::uint32_t&, std::uint32_t&) const override + { + } + + [[nodiscard]] bool + hasTxSet(uint256 const&) const override + { + return false; + } + + void + cycleStatus() override + { + } + + bool + hasRange(std::uint32_t, std::uint32_t) override + { + return false; + } + + [[nodiscard]] bool + compressionEnabled() const override + { + return false; + } + + [[nodiscard]] bool + txReduceRelayEnabled() const override + { + return false; + } + +private: + id_t const id_; + PublicKey const nodePublicKey_; + std::string const fingerprint_; +}; + +} // namespace xrpl::test diff --git a/src/test/jtx/impl/ConfidentialTransfer.cpp b/src/test/jtx/impl/ConfidentialTransfer.cpp index 6c1538316c..0bc98778f6 100644 --- a/src/test/jtx/impl/ConfidentialTransfer.cpp +++ b/src/test/jtx/impl/ConfidentialTransfer.cpp @@ -1,13 +1,89 @@ #include #include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include #include +#include +#include +#include +#include +#include #include namespace xrpl { +ConfidentialTransferTestBase::ConfidentialEnv::ConfidentialEnv( + test::jtx::Env& env, + test::jtx::Account const& issuer, + std::vector const& holders, + std::uint32_t flags, + std::optional auditor) + : mpt{env, issuer, {.holders = extractAccounts(holders), .auditor = auditor}} +{ + mpt.create({.ownerCount = 1, .flags = flags}); + + for (auto const& h : holders) + { + mpt.authorize({.account = h.account}); + if ((flags & tfMPTRequireAuth) != 0) + mpt.authorize({.account = issuer, .holder = h.account}); + mpt.pay(issuer, h.account, h.payAmount); + } + + mpt.generateKeyPair(issuer); + for (auto const& h : holders) + mpt.generateKeyPair(h.account); + if (auditor) + mpt.generateKeyPair(requireOptionalRef(auditor, "Missing auditor")); + + mpt.set({ + .account = issuer, + .issuerPubKey = mpt.getPubKey(issuer), + .auditorPubKey = auditor ? mpt.getPubKey(requireOptionalRef(auditor, "Missing auditor")) + : std::optional{}, + }); + + for (auto const& h : holders) + { + mpt.convert({ + .account = h.account, + .amt = h.convertAmount, + .holderPubKey = mpt.getPubKey(h.account), + }); + mpt.mergeInbox({.account = h.account}); + } +} + +std::vector +ConfidentialTransferTestBase::ConfidentialEnv::extractAccounts( + std::vector const& holders) +{ + std::vector accounts; + accounts.reserve(holders.size()); + for (auto const& h : holders) + accounts.push_back(h.account); + return accounts; +} + void ConfidentialTransferTestBase::setupConfidentialIssuance( test::jtx::MPTTester& mpt, @@ -34,4 +110,478 @@ ConfidentialTransferTestBase::setupConfidentialIssuance( mpt.generateKeyPair(keyOwner); } +void +ConfidentialTransferTestBase::setupBatchEnv( + test::jtx::MPTTester& mpt, + test::jtx::Account const& alice, + test::jtx::Account const& bob, + test::jtx::Account const& carol, + test::jtx::Account const& dave, + std::uint64_t bobAmt, + std::uint64_t carolAmt) +{ + using namespace test::jtx; + mpt.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, + }); + mpt.authorize({.account = bob}); + mpt.authorize({.account = carol}); + mpt.authorize({.account = dave}); + + if (bobAmt > 0) + mpt.pay(alice, bob, bobAmt); + if (carolAmt > 0) + mpt.pay(alice, carol, carolAmt); + + mpt.generateKeyPair(alice); + mpt.generateKeyPair(bob); + mpt.generateKeyPair(carol); + mpt.generateKeyPair(dave); + + mpt.set({ + .account = alice, + .issuerPubKey = mpt.getPubKey(alice), + }); + + if (bobAmt > 0) + { + mpt.convert({ + .account = bob, + .amt = bobAmt, + .holderPubKey = mpt.getPubKey(bob), + }); + mpt.mergeInbox({.account = bob}); + } + else + { + mpt.convert({ + .account = bob, + .amt = 0, + .holderPubKey = mpt.getPubKey(bob), + }); + } + + if (carolAmt > 0) + { + mpt.convert({ + .account = carol, + .amt = carolAmt, + .holderPubKey = mpt.getPubKey(carol), + }); + mpt.mergeInbox({.account = carol}); + } + else + { + mpt.convert({ + .account = carol, + .amt = 0, + .holderPubKey = mpt.getPubKey(carol), + }); + } + + // dave: register pubkey only (0 spending/inbox) + mpt.convert({ + .account = dave, + .amt = 0, + .holderPubKey = mpt.getPubKey(dave), + }); +} + +ConfidentialTransferTestBase::ConfidentialSendSetup::ConfidentialSendSetup( + test::jtx::MPTTester& mpt, + test::jtx::Account const& sender, + test::jtx::Account const& dest, + test::jtx::Account const& issuer, + uint64_t amount, + std::optional> auditor) + : sendAmount(amount) + , version(mpt.getMPTokenVersion(sender)) + , blindingFactor(generateBlindingFactor()) + , amountBlindingFactor(blindingFactor) + , balanceBlindingFactor(generateBlindingFactor()) + , senderAmt(mpt.encryptAmount(sender, amount, blindingFactor)) + , destAmt(mpt.encryptAmount(dest, amount, blindingFactor)) + , issuerAmt(mpt.encryptAmount(issuer, amount, blindingFactor)) + , auditorAmt( + auditor ? std::optional(mpt.encryptAmount(auditor->get(), amount, blindingFactor)) + : std::nullopt) + , amountCommitment(mpt.getPedersenCommitment(amount, amountBlindingFactor)) + , senderPubKey(requireOptional(mpt.getPubKey(sender), "Missing sender public key")) + , destPubKey(requireOptional(mpt.getPubKey(dest), "Missing destination public key")) + , issuerPubKey(requireOptional(mpt.getPubKey(issuer), "Missing issuer public key")) + , auditorPubKey(auditor ? mpt.getPubKey(auditor->get()) : std::nullopt) + , prevSpending(requireOptional( + mpt.getDecryptedBalance(sender, test::jtx::MPTTester::holderEncryptedSpending), + "Missing sender spending balance")) + , prevEncryptedSpending(requireOptional( + mpt.getEncryptedBalance(sender, test::jtx::MPTTester::holderEncryptedSpending), + "Missing sender encrypted spending balance")) + , balanceCommitment(mpt.getPedersenCommitment(prevSpending, balanceBlindingFactor)) +{ + recipients.push_back({ + .publicKey = Slice(senderPubKey), + .encryptedAmount = senderAmt, + }); + recipients.push_back({ + .publicKey = Slice(destPubKey), + .encryptedAmount = destAmt, + }); + recipients.push_back({ + .publicKey = Slice(issuerPubKey), + .encryptedAmount = issuerAmt, + }); + if (auditor) + { + recipients.push_back({ + .publicKey = Slice(requireOptionalRef(auditorPubKey, "Missing auditor public key")), + .encryptedAmount = requireOptionalRef(auditorAmt, "Missing auditor encrypted amount"), + }); + } +} + +std::optional +ConfidentialTransferTestBase::ConfidentialSendSetup::generateProof( + test::jtx::MPTTester& mpt, + test::jtx::Env& env, + test::jtx::Account const& sender, + test::jtx::Account const& dest) const +{ + auto const ctxHash = + getSendContextHash(sender.id(), mpt.issuanceID(), env.seq(sender), dest.id(), version); + + return mpt.getConfidentialSendProof( + sender, + sendAmount, + recipients, + blindingFactor, + ctxHash, + { + .pedersenCommitment = amountCommitment, + .amt = sendAmount, + .encryptedAmt = senderAmt, + .blindingFactor = amountBlindingFactor, + }, + { + .pedersenCommitment = balanceCommitment, + .amt = prevSpending, + .encryptedAmt = prevEncryptedSpending, + .blindingFactor = balanceBlindingFactor, + }); +} + +test::jtx::MPTConfidentialSend +ConfidentialTransferTestBase::ConfidentialSendSetup::sendArgs( + test::jtx::Account const& sender, + test::jtx::Account const& dest, + Buffer const& proof, + std::optional err) const +{ + return { + .account = sender, + .dest = dest, + .amt = sendAmount, + .proof = strHex(proof), + .senderEncryptedAmt = senderAmt, + .destEncryptedAmt = destAmt, + .issuerEncryptedAmt = issuerAmt, + .auditorEncryptedAmt = auditorAmt, + .amountCommitment = amountCommitment, + .balanceCommitment = balanceCommitment, + .err = err, + }; +} + +Buffer const& +ConfidentialTransferTestBase::getBadCiphertext() +{ + static Buffer const kBadCiphertext = []() { + Buffer buf(kEcGamalEncryptedTotalLength); + std::memset(buf.data(), 0xFF, kEcGamalEncryptedTotalLength); + + buf.data()[0] = kEcCompressedPrefixEvenY; + buf.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY; + return buf; + }(); + + return kBadCiphertext; +} + +Buffer const& +ConfidentialTransferTestBase::getTrivialCiphertext() +{ + static Buffer const kTrivialCiphertext = []() { + Buffer buf(kEcGamalEncryptedTotalLength); + std::memset(buf.data(), 0, kEcGamalEncryptedTotalLength); + + buf.data()[0] = kEcCompressedPrefixEvenY; + buf.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY; + + buf.data()[kEcCiphertextComponentLength - 1] = 0x01; + buf.data()[kEcGamalEncryptedTotalLength - 1] = 0x01; + + return buf; + }(); + + return kTrivialCiphertext; +} + +Buffer const& +ConfidentialTransferTestBase::getTrivialCommitment() +{ + static Buffer const kTrivialCommitment = []() { + Buffer buf(kEcPedersenCommitmentLength); + std::memset(buf.data(), 0, kEcPedersenCommitmentLength); + + buf.data()[0] = kEcCompressedPrefixEvenY; + // Set last byte to make it a valid x-coordinate on the curve + buf.data()[kEcPedersenCommitmentLength - 1] = 0x01; + + return buf; + }(); + + return kTrivialCommitment; +} + +std::string +ConfidentialTransferTestBase::getTrivialSendProofHex() +{ + Buffer buf(kEcSendProofLength); + std::memset(buf.data(), 0, kEcSendProofLength); + + for (std::size_t i = 0; i < kEcSendProofLength; i += kEcCiphertextComponentLength) + { + buf.data()[i] = kEcCompressedPrefixEvenY; + if (i + kEcCiphertextComponentLength - 1 < kEcSendProofLength) + buf.data()[i + kEcCiphertextComponentLength - 1] = 0x01; + } + + return strHex(buf); +} + +Buffer +ConfidentialTransferTestBase::getForgedBulletproof( + std::array const& values, + std::array const& blindingFactors, + uint256 const& contextHash) +{ + auto* const ctx = mpt_secp256k1_context(); + + secp256k1_pubkey h; + secp256k1_mpt_get_h_generator(ctx, &h); + + Buffer proof(kEcDoubleBulletproofLength); + size_t proofLen = kEcDoubleBulletproofLength; + + unsigned char blindings[64]; + std::memcpy(blindings, blindingFactors[0].data(), 32); + std::memcpy(blindings + 32, blindingFactors[1].data(), 32); + + if (secp256k1_bulletproof_prove_agg( + ctx, proof.data(), &proofLen, values.data(), blindings, 2, &h, contextHash.data()) == 0) + Throw("Failed to generate forged bulletproof"); + + return proof; +} + +Buffer +ConfidentialTransferTestBase::getForgedSingleBulletproof( + uint64_t value, + Buffer const& blindingFactor, + uint256 const& contextHash) +{ + auto* const ctx = mpt_secp256k1_context(); + + secp256k1_pubkey h; + secp256k1_mpt_get_h_generator(ctx, &h); + + Buffer proof(kEcSingleBulletproofLength); + size_t proofLen = kEcSingleBulletproofLength; + + if (secp256k1_bulletproof_prove_agg( + ctx, + proof.data(), + &proofLen, + &value, + blindingFactor.data(), + 1, // m = 1 (single bulletproof) + &h, + contextHash.data()) == 0) + Throw("Failed to generate forged single bulletproof"); + + return proof; +} + +Buffer +ConfidentialTransferTestBase::getForgedConvertBackProof( + test::jtx::MPTTester& mpt, + test::jtx::Account const& holder, + uint64_t claimedBalance, + uint64_t realBalance, + uint64_t amt, + Buffer const& pedersenCommitment, + Buffer const& encryptedSpendingBalance, + Buffer const& pcBlindingFactor, + uint256 const& contextHash) +{ + if (pedersenCommitment.size() != kCompressedEcPointLength) + Throw("getForgedConvertBackProof: bad pedersenCommitment length"); + if (encryptedSpendingBalance.size() != kEcGamalEncryptedTotalLength) + { + Throw("getForgedConvertBackProof: bad encryptedSpendingBalance length"); + } + if (amt > realBalance) + Throw("getForgedConvertBackProof: amt exceeds realBalance"); + + auto* const ctx = mpt_secp256k1_context(); + auto const holderPubKey = requireOptional(mpt.getPubKey(holder), "Missing holder pubkey"); + auto const holderPrivKey = requireOptional(mpt.getPrivKey(holder), "Missing holder privkey"); + + secp256k1_pubkey pkHolder; + if (secp256k1_ec_pubkey_parse(ctx, &pkHolder, holderPubKey.data(), kCompressedEcPointLength) != + 1) + Throw("Failed to parse holder's public key"); + + secp256k1_pubkey pcB; + if (secp256k1_ec_pubkey_parse(ctx, &pcB, pedersenCommitment.data(), kCompressedEcPointLength) != + 1) + Throw("Failed to parse pedersen commitment"); + + secp256k1_pubkey b1, b2; + if (secp256k1_ec_pubkey_parse( + ctx, &b1, encryptedSpendingBalance.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, + &b2, + encryptedSpendingBalance.data() + kCompressedEcPointLength, + kCompressedEcPointLength) != 1) + Throw("Failed to parse balance ciphertext"); + + Buffer sigmaProof(SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE); + if (secp256k1_compact_convertback_prove( + ctx, + sigmaProof.data(), + claimedBalance, + holderPrivKey.data(), + pcBlindingFactor.data(), + &pkHolder, + &b1, + &b2, + &pcB, + contextHash.data()) != 1) + Throw("Failed to generate convertback sigma proof"); + + auto const forgedBulletproof = + getForgedSingleBulletproof(realBalance - amt, pcBlindingFactor, contextHash); + + Buffer proof(kEcConvertBackProofLength); + std::memcpy(proof.data(), sigmaProof.data(), SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE); + std::memcpy( + proof.data() + SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE, + forgedBulletproof.data(), + kEcSingleBulletproofLength); + + return proof; +} + +Buffer +ConfidentialTransferTestBase::getForgedSendProof( + test::jtx::MPTTester& mpt, + test::jtx::Env& env, + test::jtx::Account const& sender, + test::jtx::Account const& dest, + ConfidentialSendSetup const& setup) +{ + auto* const ctx = mpt_secp256k1_context(); + + secp256k1_pubkey c1; + std::vector c2Vec(setup.recipients.size()); + std::vector pkVec(setup.recipients.size()); + for (std::size_t i = 0; i < setup.recipients.size(); ++i) + { + auto const& r = setup.recipients[i]; + if (i == 0 && + secp256k1_ec_pubkey_parse( + ctx, &c1, r.encryptedAmount.data(), kCompressedEcPointLength) != 1) + Throw("Failed to parse C1"); + if (secp256k1_ec_pubkey_parse( + ctx, + &c2Vec[i], + r.encryptedAmount.data() + kCompressedEcPointLength, + kCompressedEcPointLength) != 1) + Throw("Failed to parse C2"); + if (secp256k1_ec_pubkey_parse( + ctx, &pkVec[i], r.publicKey.data(), kCompressedEcPointLength) != 1) + Throw("Failed to parse recipient pubkey"); + } + + secp256k1_pubkey pkSender, pcAmount, pcBalance, b1, b2; + if (secp256k1_ec_pubkey_parse( + ctx, &pkSender, setup.senderPubKey.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, &pcAmount, setup.amountCommitment.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, &pcBalance, setup.balanceCommitment.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, &b1, setup.prevEncryptedSpending.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, + &b2, + setup.prevEncryptedSpending.data() + kCompressedEcPointLength, + kCompressedEcPointLength) != 1) + Throw("Failed to parse commitments/ciphertext"); + + Buffer const senderPrivKey = requireOptional(mpt.getPrivKey(sender), "Missing sender privkey"); + auto const ctxHash = getSendContextHash( + sender.id(), mpt.issuanceID(), env.seq(sender), dest.id(), setup.version); + + Buffer sigmaProof(SECP256K1_COMPACT_STANDARD_PROOF_SIZE); + if (secp256k1_compact_standard_prove( + ctx, + sigmaProof.data(), + setup.sendAmount, + setup.prevSpending, + setup.blindingFactor.data(), + senderPrivKey.data(), + setup.balanceBlindingFactor.data(), + setup.recipients.size(), + &c1, + c2Vec.data(), + pkVec.data(), + &pcAmount, + &pkSender, + &pcBalance, + &b1, + &b2, + ctxHash.data()) != 1) + Throw("Failed to generate sigma proof"); + + // Wraps (mod 2^64) for overdrafts, unlike the ledger's own homomorphic + // commitment subtraction (mod the curve order) — that mismatch is + // exactly what makes the forged proof fail verification. + // Computed without a wrapping `uint64` subtract: Clang UBSan treats + // unsigned overflow as fatal (see incrementConfidentialVersion). + std::uint64_t const remaining = setup.sendAmount <= setup.prevSpending + ? setup.prevSpending - setup.sendAmount + : ~setup.sendAmount + setup.prevSpending + 1; + + Buffer negAmountBf(kEcBlindingFactorLength); + Buffer remainingBf(kEcBlindingFactorLength); + secp256k1_mpt_scalar_negate(negAmountBf.data(), setup.amountBlindingFactor.data()); + secp256k1_mpt_scalar_add( + remainingBf.data(), setup.balanceBlindingFactor.data(), negAmountBf.data()); + + auto const forgedBulletproof = getForgedBulletproof( + {setup.sendAmount, remaining}, {setup.amountBlindingFactor, remainingBf}, ctxHash); + + Buffer combinedProof(kEcSendProofLength); + std::memcpy(combinedProof.data(), sigmaProof.data(), SECP256K1_COMPACT_STANDARD_PROOF_SIZE); + std::memcpy( + combinedProof.data() + SECP256K1_COMPACT_STANDARD_PROOF_SIZE, + forgedBulletproof.data(), + kEcDoubleBulletproofLength); + + return combinedProof; +} + } // namespace xrpl diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index 512257cdc5..542043015b 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -2171,4 +2171,33 @@ MPTTester::convertBackJV(MPTConvertBack const& arg, std::uint32_t seq) return jv; } +void +MPTTester::mirrorUpdate(MPTMirrorUpdate const& arg) +{ + json::Value jv; + jv[jss::TransactionType] = jss::ConfidentialMPTMirrorUpdate; + + setAccountField(jv, arg.account); + setIssuanceIdField(jv, arg.id); + + if (arg.holder) + jv[sfHolder] = arg.holder->human(); + if (arg.issuerEncryptedAmount) + jv[sfIssuerEncryptedAmount] = strHex(*arg.issuerEncryptedAmount); + if (arg.auditorEncryptedAmount) + jv[sfAuditorEncryptedAmount] = strHex(*arg.auditorEncryptedAmount); + + // Placeholder for proof, the logic will be added in the future + if (arg.zkProof) + { + jv[sfZKProof] = strHex(*arg.zkProof); + } + else + { + jv[sfZKProof] = strHex(gMakeZeroBuffer(kEcEqualityProofLength)); + } + + submit(arg, jv); +} + } // namespace xrpl::test::jtx diff --git a/src/test/jtx/impl/utility.cpp b/src/test/jtx/impl/utility.cpp index f83cb7772c..6b2c9b69b9 100644 --- a/src/test/jtx/impl/utility.cpp +++ b/src/test/jtx/impl/utility.cpp @@ -78,7 +78,7 @@ fillFee(json::Value& jv, ReadView const& view) auto const txType = jv[jss::TransactionType].asString(); if (txType == jss::ConfidentialMPTConvert || txType == jss::ConfidentialMPTConvertBack || txType == jss::ConfidentialMPTSend || txType == jss::ConfidentialMPTMergeInbox || - txType == jss::ConfidentialMPTClawback) + txType == jss::ConfidentialMPTClawback || txType == jss::ConfidentialMPTMirrorUpdate) { jv[jss::Fee] = to_string(base * (kConfidentialFeeMultiplier + 1)); } diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index cefdd2cdca..a737e76320 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -362,6 +362,24 @@ struct MPTConfidentialClawback std::optional err = std::nullopt; }; +/** + * @brief Arguments for building a ConfidentialMPTMirrorUpdate test transaction. + */ +struct MPTMirrorUpdate +{ + std::optional account = std::nullopt; + std::optional holder = std::nullopt; + std::optional id = std::nullopt; + std::optional issuerEncryptedAmount = std::nullopt; + std::optional auditorEncryptedAmount = std::nullopt; + std::optional zkProof = std::nullopt; + std::optional fee = std::nullopt; + std::optional flags = std::nullopt; + std::optional ownerCount = std::nullopt; + std::optional holderCount = std::nullopt; + std::optional err = std::nullopt; +}; + /** * @brief Stores the parameters that are exclusively used to generate a * Pedersen linkage proof. @@ -584,6 +602,9 @@ public: void confidentialClaw(MPTConfidentialClawback const& arg = MPTConfidentialClawback{}); + void + mirrorUpdate(MPTMirrorUpdate const& arg = MPTMirrorUpdate{}); + [[nodiscard]] bool checkDomainID(std::optional expected) const; diff --git a/src/test/overlay/CapturePeer.h b/src/test/overlay/CapturePeer.h new file mode 100644 index 0000000000..28a26d01e7 --- /dev/null +++ b/src/test/overlay/CapturePeer.h @@ -0,0 +1,252 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +/** + * A real `PeerImp` that captures the messages it would have sent. + * + * Only `send` and `run` are overridden, so `onMessage` runs production code. + * Derive from this to reach a `protected` `PeerImp` member. + */ +class CapturePeer : public PeerImp +{ +public: + using MiddleType = boost::beast::tcp_stream; + using StreamType = boost::beast::ssl_stream; + using SocketType = boost::asio::ip::tcp::socket; + + /** + * Takes `PeerImp`'s two rvalue-reference parameters by value instead, so a + * derived double can inherit this constructor without a never-moved-from + * warning. + * + * @param app The application owning the peer. + * @param id The connection id, unique among the overlay's peers. + * @param slot The peer finder slot; must be seated. + * @param request The handshake request. + * @param publicKey The peer's node public key. + * @param protocol The negotiated protocol version. + * @param consumer The resource manager endpoint for the peer. + * @param streamPtr The connection's ssl stream. + * @param overlay The overlay to register with. + */ + CapturePeer( + Application& app, + Peer::id_t id, + std::shared_ptr const& slot, + http_request_type request, + PublicKey const& publicKey, + ProtocolVersion protocol, + resource::Consumer consumer, + std::unique_ptr streamPtr, + OverlayImpl& overlay) + : PeerImp( + app, + id, + slot, + std::move(request), + publicKey, + protocol, + consumer, // copy-only, so `std::move` would be a copy anyway + std::move(streamPtr), + overlay) + { + } + + ~CapturePeer() override = default; + + /** + * Does nothing, so the peer stays registered. The real `run()` reaches + * `PeerImp::doAccept`, which fails on an unconnected socket and detaches. + */ + void + run() override + { + } + + /** + * Captures the message instead of writing it, so replies are observable. + */ + void + send(std::shared_ptr const& m) override + { + sent_.push_back(m); + } + + /** + * @return Every message sent to this peer, in order. + */ + std::vector> const& + sent() const + { + return sent_; + } + + /** + * @return The most recent message sent, or null if there was none. + */ + std::shared_ptr + lastSent() const + { + return sent_.empty() ? nullptr : sent_.back(); + } + + /** + * Reads the accumulated charge without draining it through `charge()`. + * + * @return The charge accumulated on the peer so far. + */ + resource::Charge + feeCharge() const + { + return currentFeeCharge(); + } + +private: + std::vector> sent_; +}; + +namespace detail { + +// `inline` so the functions below name one entity across translation units. +inline constexpr std::uint16_t kCapturePeerPort = 51235; + +/** + * Non-template, so all `makeCapturePeer` instantiations share one counter. A + * per-instantiation counter would give two peer types the same id, and + * `addActive` would silently drop the second from `ids_`. + * + * @return The next unused connection id. + */ +inline Peer::id_t +nextCapturePeerId() +{ + static Peer::id_t id{0}; + return ++id; +} + +/** + * Non-template for the same reason as `nextCapturePeerId`. Each peer needs its + * own address, not just its own port: the peer finder caps inbound connections + * per address at `ipLimit`, which is at most 2 unless configured. + * + * @return The next unused remote endpoint. + */ +inline beast::ip::Endpoint +nextCapturePeerRemote() +{ + // From 172.2.0.1 upward, so ~900k fit before reaching 172.16/12, where the + // peer finder would treat them as private rather than as real inbound. + static std::uint32_t next{0xAC020001}; + return beast::ip::Endpoint(boost::asio::ip::address_v4(next++), kCapturePeerPort); +} + +/** + * Outlives every peer, whose stream keeps a reference to it. `PeerImp::charge` + * posts a handler holding the peer, so a peer can outlive its caller's scope. + * + * @return The ssl context every test peer's stream is built on. + */ +inline boost::asio::ssl::context& +capturePeerSslContext() +{ + static std::shared_ptr const kContext{makeSslContext("")}; + return *kContext; +} + +} // namespace detail + +/** + * Build an active `CapturePeer` and register it with the overlay. + * + * @tparam PeerType The peer class to build; must derive from `CapturePeer` and + * inherit its constructor. + * @param env The environment owning the overlay. + * @param key The peer's node public key, or unseated for a fresh random + * one. + * @param request The handshake request. `PeerImp` reads its `X-Protocol-Ctl` + * header in the constructor to negotiate features. + * @return The peer, already registered with the overlay. Throws if the peer + * finder refused a slot. + */ +template +std::shared_ptr +makeCapturePeer( + jtx::Env& env, + std::optional key = std::nullopt, + http_request_type request = {}) +{ + auto& overlay = dynamic_cast(env.app().getOverlay()); + auto streamPtr = std::make_unique( + CapturePeer::SocketType(env.app().getIOContext()), detail::capturePeerSslContext()); + + beast::ip::Endpoint const local( + boost::asio::ip::make_address("172.1.1.1"), detail::kCapturePeerPort); + auto const remote = detail::nextCapturePeerRemote(); + + auto consumer = overlay.resourceManager().newInboundEndpoint(remote); + auto [slot, _] = overlay.peerFinder().newInboundSlot(local, remote); + + // Unseated when the endpoint is already connected or at the per-address + // limit. `PeerImp` dereferences the slot, so fail here, not there. + if (!slot) + { + Throw("makeCapturePeer: no slot for " + to_string(remote)); + } + + if (!key) + key = PublicKey(std::get<0>(randomKeyPair(KeyType::Ed25519))); + + auto peer = std::make_shared( + env.app(), + detail::nextCapturePeerId(), + slot, + std::move(request), + *key, + // An unsupported version fails every `supportsFeature` test, so a + // version-gated reply would only ever take its legacy branch. + newestSupportedProtocolVersion(), + consumer, + std::move(streamPtr), + overlay); + + overlay.addActive(peer); + return peer; +} + +} // namespace xrpl::test diff --git a/src/test/overlay/PeerTest.cpp b/src/test/overlay/PeerTest.cpp deleted file mode 100644 index 341febb25b..0000000000 --- a/src/test/overlay/PeerTest.cpp +++ /dev/null @@ -1,166 +0,0 @@ -#include - -#include - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include - -#include -#include -#include -#include - -namespace xrpl::test { - -PeerTest::PeerTest( - Application& app, - std::shared_ptr const& slot, - http_request_type&& request, - PublicKey const& publicKey, - ProtocolVersion protocol, - resource::Consumer consumer, - std::unique_ptr&& streamPtr, - OverlayImpl& overlay) - : PeerImp{ - app, - id++, - slot, - std::move(request), - publicKey, - protocol, - consumer, - std::move(streamPtr), - overlay} -{ -} - -void -PeerTest::run() -{ -} - -void -PeerTest::send(std::shared_ptr const& message) -{ - lastSentMessage_ = message; -} - -std::shared_ptr -PeerTest::getLastSentMessage() const -{ - return lastSentMessage_; -} - -void -PeerTest::runProcessGetObjectByHash(std::shared_ptr const& message) -{ - PeerImp::processGetObjectByHash(message); -} - -void -PeerTest::runProcessLedgerRequest( - std::shared_ptr const& message, - std::vector nodeIDs) -{ - PeerImp::processLedgerRequest(message, std::move(nodeIDs)); -} - -resource::Charge -PeerTest::getCurrentFeeCharge() const -{ - return PeerImp::currentFeeCharge(); -} - -void -PeerTest::resetId() -{ - id = 0; -} - -bool -PeerTest::compressionEnabled() const -{ - if (compressionEnabled_.has_value()) - { - return *compressionEnabled_; - } - return PeerImp::compressionEnabled(); -} - -void -PeerTest::compressionEnabled(std::optional enabled) -{ - compressionEnabled_ = enabled; -} - -bool -PeerTest::txReduceRelayEnabled() const -{ - if (reduceRelayEnabled_.has_value()) - { - return *reduceRelayEnabled_; - } - return PeerImp::txReduceRelayEnabled(); -} - -void -PeerTest::txReduceRelayEnabled(std::optional enabled) -{ - reduceRelayEnabled_ = enabled; -} - -std::shared_ptr -makePeerTest(jtx::Env& env, PeerTest::SharedContext const& context, ProtocolVersion protocolVersion) -{ - using SocketType = boost::asio::ip::tcp::socket; - - auto& overlay = dynamic_cast(env.app().getOverlay()); - boost::beast::http::request request; - auto streamPtr = - std::make_unique(SocketType(env.app().getIOContext()), *context); - - beast::ip::Endpoint const local(boost::asio::ip::make_address("172.1.1.1"), 51235); - beast::ip::Endpoint const remote(boost::asio::ip::make_address("172.1.1.2"), 51235); - - PublicKey const key{std::get<0>(randomKeyPair(KeyType::Ed25519))}; - auto consumer = overlay.resourceManager().newInboundEndpoint(remote); - auto [slot, _] = overlay.peerFinder().newInboundSlot(local, remote); - - auto peer = std::make_shared( - env.app(), - slot, - std::move(request), - key, - protocolVersion, - consumer, - std::move(streamPtr), - overlay); - - overlay.addActive(peer); - return peer; -} - -} // namespace xrpl::test diff --git a/src/test/overlay/PeerTest.h b/src/test/overlay/PeerTest.h deleted file mode 100644 index f7b2815da3..0000000000 --- a/src/test/overlay/PeerTest.h +++ /dev/null @@ -1,108 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include - -#include -#include -#include - -namespace xrpl::test { - -/** - * Test peer that captures sent messages for verification. - */ -class PeerTest : public PeerImp -{ - inline static Peer::id_t id{}; - std::shared_ptr lastSentMessage_; - std::optional compressionEnabled_; - std::optional reduceRelayEnabled_; - -public: - using MiddleType = boost::beast::tcp_stream; - using SharedContext = std::shared_ptr; - using StreamType = boost::beast::ssl_stream; - - PeerTest( - Application& app, - std::shared_ptr const& slot, - http_request_type&& request, - PublicKey const& publicKey, - ProtocolVersion protocol, - resource::Consumer consumer, - std::unique_ptr&& streamPtr, - OverlayImpl& overlay); - - ~PeerTest() override = default; - - void - run() override; - - void - send(std::shared_ptr const& m) override; - - std::shared_ptr - getLastSentMessage() const; - - // Synchronous test access to the JobQueue-dispatched processor. - // The production path runs this on JtLedgerReq; tests need a - // synchronous entry point to inspect the reply via send(). - // PeerImp::processGetObjectByHash is `protected` so the derived - // test subclass can call it directly. - void - runProcessGetObjectByHash(std::shared_ptr const& m); - - void - runProcessLedgerRequest( - std::shared_ptr const& m, - std::vector nodeIDs); - - resource::Charge - getCurrentFeeCharge() const; - - static void - resetId(); - - bool - compressionEnabled() const override; - - void - compressionEnabled(std::optional enabled); - - bool - txReduceRelayEnabled() const override; - - void - txReduceRelayEnabled(std::optional enabled); -}; - -std::shared_ptr -makePeerTest( - jtx::Env& env, - PeerTest::SharedContext const& context, - ProtocolVersion protocolVersion); - -} // namespace xrpl::test diff --git a/src/test/overlay/TMGetLedger_test.cpp b/src/test/overlay/TMGetLedger_test.cpp index 9088e6fa65..eac1a24e24 100644 --- a/src/test/overlay/TMGetLedger_test.cpp +++ b/src/test/overlay/TMGetLedger_test.cpp @@ -1,30 +1,19 @@ #include -#include +#include #include -#include -#include -#include -#include #include #include -#include #include -#include #include #include -#include -#include -#include -#include -#include - #include #include #include +#include #include namespace xrpl::test { @@ -33,8 +22,23 @@ using namespace jtx; class TMGetLedger_test : public beast::unit_test::Suite { - PeerTest::SharedContext context_{makeSslContext("")}; - ProtocolVersion protocolVersion_{1, 7}; + /** + * Calls the JtLedgerReq-dispatched processor synchronously, so the reply is + * visible through `lastSent()`. + */ + class GetLedgerPeer : public CapturePeer + { + public: + using CapturePeer::CapturePeer; + + void + runProcessLedgerRequest( + std::shared_ptr const& m, + std::vector nodeIDs) + { + processLedgerRequest(m, std::move(nodeIDs)); + } + }; // Build a well-formed TMGetLedger node request carrying `numNodeIds` node // IDs. @@ -64,17 +68,16 @@ class TMGetLedger_test : public beast::unit_test::Suite testcase("Node ID Count Accepted"); Env env{*this}; - PeerTest::resetId(); - auto peer = makePeerTest(env, context_, protocolVersion_); + auto peer = makeCapturePeer(env); peer->onMessage(createRequest(numNodeIds)); // A request outside the accepted node-ID count is charged kFeeInvalidData; one inside // it is not. The JobQueue handler may run concurrently and update the fee in the // accepted case. BEAST_EXPECT( - expectRejected ? (peer->getCurrentFeeCharge() == resource::kFeeInvalidData) - : !(peer->getCurrentFeeCharge() == resource::kFeeInvalidData)); + expectRejected ? (peer->feeCharge() == resource::kFeeInvalidData) + : !(peer->feeCharge() == resource::kFeeInvalidData)); } void @@ -84,9 +87,8 @@ class TMGetLedger_test : public beast::unit_test::Suite Env env{*this}; env.close(); - PeerTest::resetId(); - auto peer = makePeerTest(env, context_, protocolVersion_); + auto peer = makeCapturePeer(env); // Ask for the account-state root node of the closed ledger. auto request = createRequest(numNodeIds); @@ -96,7 +98,7 @@ class TMGetLedger_test : public beast::unit_test::Suite peer->runProcessLedgerRequest(request, std::vector(numNodeIds)); - auto sentMessage = peer->getLastSentMessage(); + auto sentMessage = peer->lastSent(); BEAST_EXPECT(sentMessage != nullptr); if (!sentMessage) { diff --git a/src/test/overlay/TMTransaction_test.cpp b/src/test/overlay/TMTransaction_test.cpp index b208b3d81b..5a23e25005 100644 --- a/src/test/overlay/TMTransaction_test.cpp +++ b/src/test/overlay/TMTransaction_test.cpp @@ -1,21 +1,10 @@ #include #include -#include +#include -#include -#include -#include - -#include #include #include -#include -#include -#include -#include -#include - #include #include @@ -26,18 +15,14 @@ using namespace jtx; class TMTransaction_test : public beast::unit_test::Suite { - PeerTest::SharedContext context_{makeSslContext("")}; - ProtocolVersion protocolVersion_{1, 7}; - void testFailureDeserializingTransactionIsCharged() { testcase("Undeserializable Transaction Is Charged"); Env env{*this, envconfig()}; - PeerTest::resetId(); - auto peer = makePeerTest(env, context_, protocolVersion_); + auto peer = makeCapturePeer(env); auto tx = std::make_shared(); tx->set_status(protocol::tsNEW); @@ -45,7 +30,7 @@ class TMTransaction_test : public beast::unit_test::Suite tx->set_rawtransaction("\x01\x02\x03", 3); peer->onMessage(tx); - BEAST_EXPECT(peer->getCurrentFeeCharge() == resource::kFeeInvalidData); + BEAST_EXPECT(peer->feeCharge() == resource::kFeeInvalidData); } void diff --git a/src/test/overlay/TMTransactions_test.cpp b/src/test/overlay/TMTransactions_test.cpp index 67d36cc04b..87c09498f2 100644 --- a/src/test/overlay/TMTransactions_test.cpp +++ b/src/test/overlay/TMTransactions_test.cpp @@ -1,28 +1,21 @@ #include #include #include -#include +#include #include -#include -#include -#include -#include +#include -#include #include #include - -#include -#include -#include -#include -#include +#include #include #include #include +#include +#include namespace xrpl::test { @@ -30,9 +23,6 @@ using namespace jtx; class TMTransactions_test : public beast::unit_test::Suite { - PeerTest::SharedContext context_{makeSslContext("")}; - ProtocolVersion protocolVersion_{1, 7}; - static std::shared_ptr createRequest(std::size_t const numTransactions) { @@ -55,13 +45,17 @@ class TMTransactions_test : public beast::unit_test::Suite *this, envconfig(), std::make_unique(kLimitExceededMessage, &foundExpectedLog)}; - PeerTest::resetId(); - auto peer = makePeerTest(env, context_, protocolVersion_); - peer->txReduceRelayEnabled(true); + // `PeerImp` decides `txReduceRelayEnabled()` in its constructor, from + // the config and the handshake header, so set this first. + env.app().config().txReduceRelayEnable = true; + http_request_type request; + request.insert("X-Protocol-Ctl", makeFeaturesRequestHeader(false, false, true, false)); + + auto peer = makeCapturePeer(env, std::nullopt, std::move(request)); peer->onMessage(createRequest(numTransactions)); - auto fee = peer->getCurrentFeeCharge(); + auto fee = peer->feeCharge(); if (expectRejected) { BEAST_EXPECT(fee == resource::kFeeMalformedRequest); diff --git a/src/test/overlay/reduce_relay_test.cpp b/src/test/overlay/reduce_relay_test.cpp index 4091efa0ca..ff3eb51a4d 100644 --- a/src/test/overlay/reduce_relay_test.cpp +++ b/src/test/overlay/reduce_relay_test.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -12,10 +13,8 @@ #include #include #include -#include #include #include -#include #include #include #include @@ -27,7 +26,6 @@ #include #include #include -#include #include #include #include @@ -67,16 +65,16 @@ static constexpr std::uint32_t kMaxMessages = 200000; /** * Simulate two entities - peer directly connected to the server * (via squelch in PeerSim) and PeerImp (via Overlay) + * + * `PeerStub` supplies the rest of the `Peer` interface as no-ops. */ -class PeerPartial : public Peer +class PeerPartial : public PeerStub { public: - PeerPartial() : nodePublicKey(derivePublicKey(KeyType::Ed25519, randomSecretKey())) - { - } + using PeerStub::PeerStub; + // Keep the base overload visible; the one below would otherwise hide it. + using PeerStub::send; - PublicKey nodePublicKey; - ~PeerPartial() override = default; virtual void onMessage(MessageSPtr const& m, SquelchCB f) = 0; virtual void @@ -86,111 +84,6 @@ public: { onMessage(squelch); } - - // dummy implementation - void - send(std::shared_ptr const& m) override - { - } - [[nodiscard]] beast::ip::Endpoint - getRemoteAddress() const override - { - return {}; - } - void - charge(resource::Charge const& fee, std::string const& context = {}) override - { - } - [[nodiscard]] bool - cluster() const override - { - return false; - } - [[nodiscard]] bool - isHighLatency() const override - { - return false; - } - [[nodiscard]] int - getScore(bool) const override - { - return 0; - } - [[nodiscard]] PublicKey const& - getNodePublic() const override - { - return nodePublicKey; - } - json::Value - json() override - { - return {}; - } - [[nodiscard]] bool - supportsFeature(ProtocolFeature f) const override - { - return false; - } - [[nodiscard]] std::optional - publisherListSequence(PublicKey const&) const override - { - return {}; - } - void - setPublisherListSequence(PublicKey const&, std::size_t const) override - { - } - [[nodiscard]] uint256 - getClosedLedgerHash() const override - { - static uint256 const kHash{}; - return kHash; - } - [[nodiscard]] bool - hasLedger(uint256 const& hash, std::uint32_t seq) const override - { - return false; - } - void - ledgerRange(std::uint32_t& minSeq, std::uint32_t& maxSeq) const override - { - } - [[nodiscard]] bool - hasTxSet(uint256 const& hash) const override - { - return false; - } - void - cycleStatus() override - { - } - bool - hasRange(std::uint32_t uMin, std::uint32_t uMax) override - { - return false; - } - [[nodiscard]] bool - compressionEnabled() const override - { - return false; - } - [[nodiscard]] bool - txReduceRelayEnabled() const override - { - return false; - } - void - sendTxQueue() override - { - } - void - addTxQueue(uint256 const&) override - { - } - void - removeTxQueue(uint256 const&) override - { - } }; /** @@ -466,24 +359,13 @@ class PeerSim : public PeerPartial, public std::enable_shared_from_this { public: using id_t = Peer::id_t; - PeerSim(Overlay& overlay, beast::Journal journal) : overlay_(overlay), squelch_(journal) + PeerSim(Overlay& overlay, beast::Journal journal) + : PeerPartial(sid++), overlay_(overlay), squelch_(journal) { } ~PeerSim() override = default; - id_t - id() const override - { - return id_; - } - - std::string const& - fingerprint() const override - { - return fingerprint_; - } - static void resetId() { @@ -525,8 +407,6 @@ public: private: inline static id_t sid = 0; - std::string fingerprint_; - id_t id_{sid++}; Overlay& overlay_; reduce_relay::Squelch squelch_; }; diff --git a/src/test/overlay/tx_reduce_relay_test.cpp b/src/test/overlay/tx_reduce_relay_test.cpp index 8626d3e19c..e97fba88e5 100644 --- a/src/test/overlay/tx_reduce_relay_test.cpp +++ b/src/test/overlay/tx_reduce_relay_test.cpp @@ -1,38 +1,24 @@ #include #include +#include #include #include -#include #include #include #include #include -#include #include -#include -#include #include -#include #include #include #include #include -#include #include -#include -#include -#include -#include -#include -#include -#include - #include -#include #include #include #include @@ -47,13 +33,6 @@ namespace xrpl::test { class tx_reduce_relay_test : public beast::unit_test::Suite { -public: - using socket_type = boost::asio::ip::tcp::socket; - using middle_type = boost::beast::tcp_stream; - using stream_type = boost::beast::ssl_stream; - using shared_context = std::shared_ptr; - -private: void doTest(std::string const& msg, bool log, std::function f) { @@ -116,107 +95,83 @@ private: }); } - class PeerTest : public PeerImp + /** + * Counts queued transaction hashes. Relayed messages are counted through + * the inherited `sent()`. + */ + class TxReducePeer : public CapturePeer { public: - PeerTest( - Application& app, - std::shared_ptr const& slot, - http_request_type&& request, - PublicKey const& publicKey, - ProtocolVersion protocol, - resource::Consumer consumer, - std::unique_ptr&& streamPtr, - OverlayImpl& overlay) - : PeerImp( - app, - sid, - slot, - std::move(request), - publicKey, - protocol, - consumer, - std::move(streamPtr), - overlay) - { - sid++; - } - ~PeerTest() override = default; + using CapturePeer::CapturePeer; void - run() override + addTxQueue(uint256 const&) override { + ++queued_; } - void - send(std::shared_ptr const&) override + + /** + * @return The number of transaction hashes queued for this peer. + */ + std::size_t + queued() const { - sendTx++; + return queued_; } - void - addTxQueue(uint256 const& hash) override - { - queueTx++; - } - static void - init() - { - queueTx = 0; - sendTx = 0; - sid = 0; - } - inline static std::size_t sid = 0; - inline static std::uint16_t queueTx = 0; - inline static std::uint16_t sendTx = 0; + + private: + std::size_t queued_{0}; }; - std::uint16_t lid_{0}; - std::uint16_t rid_{1}; - shared_context context_; - ProtocolVersion protocolVersion_; - boost::beast::multi_buffer readBuf_; - -public: - tx_reduce_relay_test() : context_(makeSslContext("")), protocolVersion_{1, 7} - { - } - -private: + /** + * Build one peer and register it with the overlay. + * + * The first `nDisabled` peers get no `X-Protocol-Ctl` header, which leaves + * tx reduce-relay disabled on them. Built first, they sit at the front of + * `peers`, where `testRelay`'s skip set expects them. + * + * @param env The environment owning the overlay. + * @param peers Receives the peer; the overlay holds only a weak + * pointer, so the caller keeps it alive. + * @param nDisabled How many more peers to leave disabled; decremented + * per peer built. + */ void - addPeer(jtx::Env& env, std::vector>& peers, std::uint16_t& nDisabled) + addPeer( + jtx::Env& env, + std::vector>& peers, + std::uint16_t& nDisabled) { auto& overlay = dynamic_cast(env.app().getOverlay()); - boost::beast::http::request request; - (nDisabled == 0) - ? request.insert("X-Protocol-Ctl", makeFeaturesRequestHeader(false, false, true, false)) - : (void)nDisabled--; - auto streamPtr = std::make_unique( - socket_type(std::forward(env.app().getIOContext())), - *context_); - beast::ip::Endpoint const local( - boost::asio::ip::make_address("172.1.1." + std::to_string(lid_))); - beast::ip::Endpoint const remote( - boost::asio::ip::make_address("172.1.1." + std::to_string(rid_))); PublicKey const key(std::get<0>(randomKeyPair(KeyType::Ed25519))); - auto consumer = overlay.resourceManager().newInboundEndpoint(remote); - auto [slot, _] = overlay.peerFinder().newInboundSlot(local, remote); - auto const peer = std::make_shared( - env.app(), - slot, - std::move(request), - key, - protocolVersion_, - consumer, - std::move(streamPtr), - overlay); + + bool const disabled = nDisabled > 0; + if (disabled) + --nDisabled; + + http_request_type request; + if (!disabled) + request.insert("X-Protocol-Ctl", makeFeaturesRequestHeader(false, false, true, false)); + BEAST_EXPECT(overlay.findPeerByPublicKey(key) == std::shared_ptr{}); - overlay.addActive(peer); + auto const peer = makeCapturePeer(env, key, std::move(request)); BEAST_EXPECT(overlay.findPeerByPublicKey(key) == peer); - peers.emplace_back(peer); // overlay stores week ptr to PeerImp - lid_ += 2; - rid_ += 2; - assert(lid_ <= 254); + peers.emplace_back(peer); } + /** + * Relay one transaction to `nPeers` peers and check the split. + * + * @param test The testcase name. + * @param txRREnabled The `tx_enable` config value. + * @param nPeers How many peers to attach to the overlay. + * @param nDisabled How many of those peers have reduce-relay disabled. + * @param minPeers The `tx_min_peers` config value. + * @param relayPercentage The `tx_relay_percentage` config value. + * @param expectRelay The expected number of peers relayed to. + * @param expectQueue The expected number of peers queued for. + * @param nSkip How many of the first-built peers to skip. + */ void testRelay( std::string const& test, @@ -227,20 +182,30 @@ private: std::uint16_t relayPercentage, std::uint16_t expectRelay, std::uint16_t expectQueue, - std::set const& toSkip = {}) + std::size_t nSkip = 0) { testcase(test); jtx::Env env(*this); - std::vector> peers; + std::vector> peers; + // `PeerImp` decides `txReduceRelayEnabled()` in its constructor, from + // the config and the handshake header, so set these first. env.app().config().txReduceRelayEnable = txRREnabled; env.app().config().txReduceRelayMinPeers = minPeers; env.app().config().txRelayPercentage = relayPercentage; - PeerTest::init(); - lid_ = 0; - rid_ = 0; for (int i = 0; i < nPeers; i++) addPeer(env, peers, nDisabled); + // An under-filled skip set would also fail the relay counts below, for + // a reason that looks unrelated. + if (!BEAST_EXPECT(nSkip <= peers.size())) + return; + + // Skip the peers built first, so the skip set overlaps the disabled + // peers as the expected counts assume. + std::set toSkip; + for (std::size_t i = 0; i < nSkip; ++i) + toSkip.insert(peers[i]->id()); + auto const jtx = env.jt(noop(env.master)); if (BEAST_EXPECT(jtx.stx)) { @@ -251,7 +216,15 @@ private: m.set_deferred(false); m.set_status(protocol::TransactionStatus::tsNEW); env.app().getOverlay().relay(uint256{0}, m, toSkip); - BEAST_EXPECT(PeerTest::sendTx == expectRelay && PeerTest::queueTx == expectQueue); + + std::size_t sendTx = 0; + std::size_t queueTx = 0; + for (auto const& peer : peers) + { + sendTx += peer->sent().size(); + queueTx += peer->queued(); + } + BEAST_EXPECT(sendTx == expectRelay && queueTx == expectQueue); } } @@ -259,12 +232,11 @@ private: run() override { bool const log = false; - std::set skip = {0, 1, 2, 3, 4}; testConfig(log); // relay to all peers, no hash queue testRelay("feature disabled", false, 10, 0, 10, 25, 10, 0); // relay to nPeers - skip (10-5=5) - testRelay("feature disabled & skip", false, 10, 0, 10, 25, 5, 0, skip); + testRelay("feature disabled & skip", false, 10, 0, 10, 25, 5, 0, 5); // relay to all peers because min is greater than nPeers testRelay("relay all 1", true, 10, 0, 20, 25, 10, 0); // relay to all peers because min + disabled is greater thant nPeers @@ -275,24 +247,22 @@ private: // relay to minPeers + 25% of (nPeers - nPeers) - skip // (20+0.25*(60-20)-5=25), queue the rest, skip counts towards relayed // (60-25-5=30) - testRelay("skip", true, 60, 0, 20, 25, 25, 30, skip); + testRelay("skip", true, 60, 0, 20, 25, 25, 30, 5); // relay to minPeers + disabled + 25% of (nPeers - minPeers - disabled) // (20+10+0.25*(70-20-10)=40), queue the rest (30) testRelay("disabled", true, 70, 10, 20, 25, 40, 30); // relay to minPeers + disabled-not-in-skip + 25% of (nPeers - minPeers // - disabled) (20+5+0.25*(70-20-10)=35), queue the rest, skip counts // towards relayed (70-35-5=30)) - testRelay("disabled & skip", true, 70, 10, 20, 25, 35, 30, skip); + testRelay("disabled & skip", true, 70, 10, 20, 25, 35, 30, 5); // relay to minPeers + disabled + 25% of (nPeers - minPeers - disabled) // - skip (10+5+0.25*(15-10-5)-10=5), queue the rest, skip counts // towards relayed (15-5-10=0) - skip = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; - testRelay("disabled & skip, no queue", true, 15, 5, 10, 25, 5, 0, skip); + testRelay("disabled & skip, no queue", true, 15, 5, 10, 25, 5, 0, 10); // relay to minPeers + disabled + 25% of (nPeers - minPeers - disabled) // - skip (10+2+0.25*(20-10-2)-14=0), queue the rest, skip counts // towards relayed (20-14=6) - skip = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}; - testRelay("disabled & skip, no relay", true, 20, 2, 10, 25, 0, 6, skip); + testRelay("disabled & skip, no relay", true, 20, 2, 10, 25, 0, 6, 14); } }; diff --git a/src/test/rpc/LedgerRPC_test.cpp b/src/test/rpc/LedgerRPC_test.cpp index e7c5dd4a80..deb19e3a3f 100644 --- a/src/test/rpc/LedgerRPC_test.cpp +++ b/src/test/rpc/LedgerRPC_test.cpp @@ -13,17 +13,21 @@ #include #include +#include #include #include #include +#include #include #include #include +#include #include #include #include +#include #include #include #include @@ -807,6 +811,95 @@ class LedgerRPC_test : public beast::unit_test::Suite } } + void + testLedgerExpandedTransactionsCTID() + { + testcase("Expanded Transactions CTID"); + using namespace test::jtx; + + Env env{*this}; + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + env.close(); + + uint32_t const netID = env.app().getNetworkIDService().getNetworkID(); + + // API v2 non-binary: CTID present + { + json::Value jvParams; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::transactions] = true; + jvParams[jss::expand] = true; + jvParams[jss::api_version] = 2; + auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::status] == "success"); + auto const& txns = jrr[jss::ledger][jss::transactions]; + BEAST_EXPECT(txns.isArray() && txns.size() > 0); + for (auto const& txn : txns) + { + BEAST_EXPECT(txn.isMember(jss::ctid)); + auto const expectedCtid = rpc::encodeCTID( + jrr[jss::ledger][jss::ledger_index].asUInt(), + txn[jss::meta][sfTransactionIndex.jsonName].asUInt(), + netID); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + if (BEAST_EXPECT(expectedCtid.has_value())) + BEAST_EXPECT(txn[jss::ctid] == expectedCtid.value()); + // NOLINTEND(bugprone-unchecked-optional-access) + } + } + + // API v1 non-binary: CTID present + { + json::Value jvParams; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::transactions] = true; + jvParams[jss::expand] = true; + auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::status] == "success"); + auto const& txns = jrr[jss::ledger][jss::transactions]; + BEAST_EXPECT(txns.isArray() && txns.size() > 0); + for (auto const& txn : txns) + { + BEAST_EXPECT(txn.isMember(jss::ctid)); + } + } + + // Binary expanded: CTID present + { + json::Value jvParams; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::transactions] = true; + jvParams[jss::expand] = true; + jvParams[jss::binary] = true; + jvParams[jss::api_version] = 2; + auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::status] == "success"); + auto const& txns = jrr[jss::ledger][jss::transactions]; + BEAST_EXPECT(txns.isArray() && txns.size() > 0); + for (auto const& txn : txns) + { + BEAST_EXPECT(txn.isMember(jss::ctid)); + } + } + + // Non-expanded: transactions are plain hash strings, no CTID + { + json::Value jvParams; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::transactions] = true; + jvParams[jss::api_version] = 2; + auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::status] == "success"); + auto const& txns = jrr[jss::ledger][jss::transactions]; + BEAST_EXPECT(txns.isArray() && txns.size() > 0); + for (auto const& txn : txns) + { + BEAST_EXPECT(txn.isString()); + } + } + } + public: void run() override @@ -822,6 +915,7 @@ public: testNoQueue(); testQueue(); testLedgerAccountsOption(); + testLedgerExpandedTransactionsCTID(); } }; diff --git a/src/tests/libxrpl/basics/MallocTrim.cpp b/src/tests/libxrpl/basics/MallocTrim.cpp index 08bfe78e47..1b988f5b94 100644 --- a/src/tests/libxrpl/basics/MallocTrim.cpp +++ b/src/tests/libxrpl/basics/MallocTrim.cpp @@ -49,7 +49,7 @@ TEST(MallocTrimReport, structure) } #if defined(__GLIBC__) && BOOST_OS_LINUX -TEST(parseStatmRSSkB, standard_format) +TEST(ParseStatmRSSkB, standard_format) { using xrpl::detail::parseStatmRSSkB; @@ -126,7 +126,7 @@ TEST(parseStatmRSSkB, standard_format) // leave the caller with no duration to record on the one kind of node where // the trim cost matters. This is the guard for that: with a null sink // (nothing is even loggable) every field must still be populated. -TEST(mallocTrim, measures_without_debug_logging) +TEST(MallocTrim, measures_without_debug_logging) { beast::Journal const journal{beast::Journal::getNullSink()}; @@ -169,7 +169,7 @@ TEST(mallocTrim, measures_without_debug_logging) #endif } -TEST(mallocTrim, empty_tag) +TEST(MallocTrim, empty_tag) { beast::Journal const journal{beast::Journal::getNullSink()}; MallocTrimReport const report = mallocTrim("", journal); @@ -182,7 +182,7 @@ TEST(mallocTrim, empty_tag) #endif } -TEST(mallocTrim, with_debug_logging) +TEST(MallocTrim, with_debug_logging) { struct DebugSink : public beast::Journal::Sink { @@ -225,7 +225,7 @@ TEST(mallocTrim, with_debug_logging) #endif } -TEST(mallocTrim, repeated_calls) +TEST(MallocTrim, repeated_calls) { beast::Journal const journal{beast::Journal::getNullSink()}; diff --git a/src/tests/libxrpl/basics/RangeSet.cpp b/src/tests/libxrpl/basics/RangeSet.cpp index 44b13ad581..2e224c79f7 100644 --- a/src/tests/libxrpl/basics/RangeSet.cpp +++ b/src/tests/libxrpl/basics/RangeSet.cpp @@ -10,7 +10,7 @@ using namespace xrpl; -TEST(RangeSet, prevMissing) +TEST(RangeSet, prev_missing) { // Set will include: // [ 0, 5] @@ -36,7 +36,7 @@ TEST(RangeSet, prevMissing) } } -TEST(RangeSet, toString) +TEST(RangeSet, to_string) { RangeSet set; EXPECT_EQ(to_string(set), "empty"); @@ -54,7 +54,7 @@ TEST(RangeSet, toString) EXPECT_EQ(to_string(set), "1-2,6"); } -TEST(RangeSet, fromString) +TEST(RangeSet, from_string) { RangeSet set; diff --git a/src/tests/libxrpl/basics/StringUtilities.cpp b/src/tests/libxrpl/basics/StringUtilities.cpp index 0180e25db0..1859de2cc1 100644 --- a/src/tests/libxrpl/basics/StringUtilities.cpp +++ b/src/tests/libxrpl/basics/StringUtilities.cpp @@ -290,7 +290,7 @@ TEST_F(StringUtilitiesTest, to_string) EXPECT_EQ(result, "hello"); } -TEST_F(StringUtilitiesTest, trimWhitespace) +TEST_F(StringUtilitiesTest, trim_whitespace) { EXPECT_EQ(trimWhitespace(""), ""); EXPECT_EQ(trimWhitespace(" "), ""); @@ -303,7 +303,7 @@ TEST_F(StringUtilitiesTest, trimWhitespace) EXPECT_EQ(trimWhitespace(" a b\tc "), "a b\tc"); } -TEST_F(StringUtilitiesTest, toLower) +TEST_F(StringUtilitiesTest, to_lower) { EXPECT_EQ(toLower(""), ""); EXPECT_EQ(toLower("ABC"), "abc"); @@ -318,7 +318,7 @@ TEST_F(StringUtilitiesTest, toLower) // Both helpers are documented as depending only on their input. Guard that by // checking the bytes just outside ASCII, which a locale-aware isspace/tolower // could classify differently. -TEST_F(StringUtilitiesTest, trimAndLowerIgnoreLocale) +TEST_F(StringUtilitiesTest, trim_and_lower_ignore_locale) { // 0xA0 is NO-BREAK SPACE in Latin-1 and is whitespace to some locales. std::string const nbsp("\xA0", 1); diff --git a/src/tests/libxrpl/basics/base64.cpp b/src/tests/libxrpl/basics/base64.cpp index d26d23700a..c9f8331c98 100644 --- a/src/tests/libxrpl/basics/base64.cpp +++ b/src/tests/libxrpl/basics/base64.cpp @@ -14,7 +14,7 @@ check(std::string const& in, std::string const& out) EXPECT_EQ(base64Decode(encoded), in); } -TEST(base64, base64) +TEST(Base64, base64) { // cspell: disable check("", ""); diff --git a/src/tests/libxrpl/basics/base_uint.cpp b/src/tests/libxrpl/basics/base_uint.cpp index 969705b5b7..4783820205 100644 --- a/src/tests/libxrpl/basics/base_uint.cpp +++ b/src/tests/libxrpl/basics/base_uint.cpp @@ -128,7 +128,7 @@ struct BaseUintTest : public ::testing::Test using BaseUintDeathTest = BaseUintTest; -TEST_F(BaseUintDeathTest, fromRaw_size_mismatch) +TEST_F(BaseUintDeathTest, from_raw_size_mismatch) { // ENABLE_VOIDSTAR is a debug build, but does not crash on failed asserts. Rather than twist // these tests into knots to make them work, just skip them. diff --git a/src/tests/libxrpl/basics/contract.cpp b/src/tests/libxrpl/basics/contract.cpp index 0c6b32a7ad..e9404da78b 100644 --- a/src/tests/libxrpl/basics/contract.cpp +++ b/src/tests/libxrpl/basics/contract.cpp @@ -6,7 +6,7 @@ using namespace xrpl; -TEST(contract, contract) +TEST(Contract, contract) { try { diff --git a/src/tests/libxrpl/basics/mulDiv.cpp b/src/tests/libxrpl/basics/mulDiv.cpp index 725bef399e..cdc672a444 100644 --- a/src/tests/libxrpl/basics/mulDiv.cpp +++ b/src/tests/libxrpl/basics/mulDiv.cpp @@ -7,7 +7,7 @@ using namespace xrpl; -TEST(mulDiv, mulDiv) +TEST(MulDiv, mul_div) { auto const max = std::numeric_limits::max(); std::uint64_t const max32 = std::numeric_limits::max(); diff --git a/src/tests/libxrpl/basics/scope.cpp b/src/tests/libxrpl/basics/scope.cpp index dc5623e967..71186d3d90 100644 --- a/src/tests/libxrpl/basics/scope.cpp +++ b/src/tests/libxrpl/basics/scope.cpp @@ -6,7 +6,7 @@ using namespace xrpl; -TEST(scope, ScopeExit) +TEST(Scope, scope_exit) { // ScopeExit always executes the functor on destruction, // unless release() is called @@ -56,7 +56,7 @@ TEST(scope, ScopeExit) EXPECT_EQ(i, 5); } -TEST(scope, ScopeFail) +TEST(Scope, scope_fail) { // ScopeFail executes the functor on destruction only // if an exception is unwinding, unless release() is called @@ -106,7 +106,7 @@ TEST(scope, ScopeFail) EXPECT_EQ(i, 5); } -TEST(scope, ScopeSuccess) +TEST(Scope, scope_success) { // ScopeSuccess executes the functor on destruction only // if an exception is not unwinding, unless release() is called diff --git a/src/tests/libxrpl/basics/tagged_integer.cpp b/src/tests/libxrpl/basics/tagged_integer.cpp index 7382527d4d..dc553d4c0f 100644 --- a/src/tests/libxrpl/basics/tagged_integer.cpp +++ b/src/tests/libxrpl/basics/tagged_integer.cpp @@ -105,7 +105,7 @@ static_assert( using TagInt = TaggedInteger; -TEST(tagged_integer, comparison_operators) +TEST(TaggedInteger, comparison_operators) { TagInt const zero(0); TagInt const one(1); @@ -131,7 +131,7 @@ TEST(tagged_integer, comparison_operators) EXPECT_FALSE(one <= zero); } -TEST(tagged_integer, increment_decrement_operators) +TEST(TaggedInteger, increment_decrement_operators) { TagInt const zero(0); TagInt const one(1); @@ -146,7 +146,7 @@ TEST(tagged_integer, increment_decrement_operators) EXPECT_EQ(a, zero); } -TEST(tagged_integer, arithmetic_operators) +TEST(TaggedInteger, arithmetic_operators) { TagInt const a{-2}; EXPECT_EQ(+a, TagInt{-2}); @@ -166,7 +166,7 @@ TEST(tagged_integer, arithmetic_operators) EXPECT_EQ((TagInt{16} >> TagInt{2}), TagInt{4}); } -TEST(tagged_integer, assignment_operators) +TEST(TaggedInteger, assignment_operators) { TagInt a{-2}; TagInt b{0}; diff --git a/src/tests/libxrpl/crypto/csprng.cpp b/src/tests/libxrpl/crypto/csprng.cpp index 957f7f5c56..71e20f2ddc 100644 --- a/src/tests/libxrpl/crypto/csprng.cpp +++ b/src/tests/libxrpl/crypto/csprng.cpp @@ -6,7 +6,7 @@ using namespace xrpl; -TEST(csprng, get_values) +TEST(Csprng, get_values) { auto& engine = cryptoPrng(); auto randVal = engine(); diff --git a/src/tests/libxrpl/json/Value.cpp b/src/tests/libxrpl/json/Value.cpp index a58a5df9fd..1aa0756419 100644 --- a/src/tests/libxrpl/json/Value.cpp +++ b/src/tests/libxrpl/json/Value.cpp @@ -21,7 +21,7 @@ namespace xrpl { -TEST(json_value, limits) +TEST(JsonValue, limits) { using namespace json; static_assert(Value::kMinInt == Int(~(UInt(-1) / 2))); @@ -29,7 +29,7 @@ TEST(json_value, limits) static_assert(Value::kMaxUInt == UInt(-1)); } -TEST(json_value, construct_and_compare_Json_StaticString) +TEST(JsonValue, construct_and_compare_json_static_string) { static constexpr char kSample[]{"Contents of a json::StaticString"}; @@ -52,7 +52,7 @@ TEST(json_value, construct_and_compare_Json_StaticString) EXPECT_NE(kTest3, str); } -TEST(json_value, different_types) +TEST(JsonValue, different_types) { // Exercise ValueType constructor static constexpr json::StaticString kStaticStr{"staticStr"}; @@ -206,7 +206,7 @@ TEST(json_value, different_types) } } -TEST(json_value, compare_strings) +TEST(JsonValue, compare_strings) { auto doCompare = [&](json::Value const& lhs, json::Value const& rhs, @@ -560,7 +560,7 @@ TEST(json_value, compare_strings) #pragma pop_macro("DO_COMPARE") } -TEST(json_value, bool) +TEST(JsonValue, bool) { EXPECT_FALSE(json::Value()); @@ -583,7 +583,7 @@ TEST(json_value, bool) EXPECT_TRUE(bool(object)); } -TEST(json_value, bad_json) +TEST(JsonValue, bad_json) { char const* s(R"({"method":"ledger","params":[{"ledger_index":1e300}]})"); @@ -607,7 +607,7 @@ parseValue(std::string const& doc) } // namespace -TEST(json_value, parse_double_valid) +TEST(JsonValue, parse_double_valid) { // 1e300 is large but still representable, so it parses (unlike the out-of-range cases below). for (auto const& [text, expected] : @@ -627,14 +627,14 @@ TEST(json_value, parse_double_valid) } } -TEST(json_value, parse_double_out_of_range) +TEST(JsonValue, parse_double_out_of_range) { // Magnitudes with no finite double representation are rejected. for (char const* oor : {"1e400", "-1e400", "0.001e500", "1e-400", "-1e-400", "123e-500"}) EXPECT_FALSE(parseValue(oor).has_value()) << oor; } -TEST(json_value, parse_double_malformed) +TEST(JsonValue, parse_double_malformed) { // readNumber() collects any run of digits and '.eE+-' into a single Double // token, so these malformed tokens reach decodeDouble. Each has a valid @@ -644,7 +644,7 @@ TEST(json_value, parse_double_malformed) EXPECT_FALSE(parseValue(bad).has_value()) << bad; } -TEST(json_value, edge_cases) +TEST(JsonValue, edge_cases) { std::uint32_t const maxUInt = std::numeric_limits::max(); std::int32_t const maxInt = std::numeric_limits::max(); @@ -791,7 +791,7 @@ TEST(json_value, edge_cases) } } -TEST(json_value, copy) +TEST(JsonValue, copy) { json::Value v1{2.5}; EXPECT_TRUE(v1.isDouble()); @@ -812,7 +812,7 @@ TEST(json_value, copy) EXPECT_EQ(v1, v2); } -TEST(json_value, move) +TEST(JsonValue, move) { json::Value v1{2.5}; EXPECT_TRUE(v1.isDouble()); @@ -831,7 +831,7 @@ TEST(json_value, move) EXPECT_NE(v1, v2); // NOLINT(bugprone-use-after-move) } -TEST(json_value, comparisons) +TEST(JsonValue, comparisons) { json::Value a, b; auto testEquals = [&](std::string const& name) { @@ -886,7 +886,7 @@ TEST(json_value, comparisons) testGreaterThan("big"); } -TEST(json_value, compact) +TEST(JsonValue, compact) { json::Value j; json::Reader r; @@ -909,7 +909,7 @@ TEST(json_value, compact) } } -TEST(json_value, conversions) +TEST(JsonValue, conversions) { // We have json::ValueType::Real but json::Value::asDouble. // TODO: What's the thinking here? @@ -1125,7 +1125,7 @@ TEST(json_value, conversions) } } -TEST(json_value, access_members) +TEST(JsonValue, access_members) { json::Value val; EXPECT_EQ(val.type(), json::ValueType::Null); @@ -1218,7 +1218,7 @@ TEST(json_value, access_members) } } -TEST(json_value, remove_members) +TEST(JsonValue, remove_members) { json::Value val; EXPECT_EQ(val.removeMember(std::string("member")).type(), json::ValueType::Null); @@ -1245,7 +1245,7 @@ TEST(json_value, remove_members) EXPECT_EQ(val.size(), 0); } -TEST(json_value, iterator) +TEST(JsonValue, iterator) { { // Iterating an array. @@ -1331,7 +1331,7 @@ TEST(json_value, iterator) } } -TEST(json_value, nest_limits) +TEST(JsonValue, nest_limits) { json::Reader r; { @@ -1377,7 +1377,7 @@ TEST(json_value, nest_limits) } } -TEST(json_value, memory_leak) +TEST(JsonValue, memory_leak) { // When run with the address sanitizer, this test confirms there is no // memory leak with the scenarios below. diff --git a/src/tests/libxrpl/ledger/AMMEntry.cpp b/src/tests/libxrpl/ledger/AMMEntry.cpp index 6189d2b3e6..6013d0f38f 100644 --- a/src/tests/libxrpl/ledger/AMMEntry.cpp +++ b/src/tests/libxrpl/ledger/AMMEntry.cpp @@ -10,7 +10,7 @@ namespace xrpl::test { -TEST(AMMEntryTests, Constructors) +TEST(AMMEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/AccountRootEntry.cpp b/src/tests/libxrpl/ledger/AccountRootEntry.cpp index 964d0f2f43..4c8f18337e 100644 --- a/src/tests/libxrpl/ledger/AccountRootEntry.cpp +++ b/src/tests/libxrpl/ledger/AccountRootEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(AccountRootEntryTests, Constructors) +TEST(AccountRootEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/AmendmentsEntry.cpp b/src/tests/libxrpl/ledger/AmendmentsEntry.cpp index ec45b291ec..3f296759f3 100644 --- a/src/tests/libxrpl/ledger/AmendmentsEntry.cpp +++ b/src/tests/libxrpl/ledger/AmendmentsEntry.cpp @@ -7,7 +7,7 @@ namespace xrpl::test { -TEST(AmendmentsEntryTests, Constructors) +TEST(AmendmentsEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/BridgeEntry.cpp b/src/tests/libxrpl/ledger/BridgeEntry.cpp index bae65ea8b5..6e6a4394f8 100644 --- a/src/tests/libxrpl/ledger/BridgeEntry.cpp +++ b/src/tests/libxrpl/ledger/BridgeEntry.cpp @@ -11,7 +11,7 @@ namespace xrpl::test { -TEST(BridgeEntryTests, Constructors) +TEST(BridgeEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/CheckEntry.cpp b/src/tests/libxrpl/ledger/CheckEntry.cpp index da51da047e..58f6250b20 100644 --- a/src/tests/libxrpl/ledger/CheckEntry.cpp +++ b/src/tests/libxrpl/ledger/CheckEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(CheckEntryTests, Constructors) +TEST(CheckEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/CredentialEntry.cpp b/src/tests/libxrpl/ledger/CredentialEntry.cpp index ce3f80dca0..16b877858b 100644 --- a/src/tests/libxrpl/ledger/CredentialEntry.cpp +++ b/src/tests/libxrpl/ledger/CredentialEntry.cpp @@ -11,7 +11,7 @@ namespace xrpl::test { -TEST(CredentialEntryTests, Constructors) +TEST(CredentialEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/DIDEntry.cpp b/src/tests/libxrpl/ledger/DIDEntry.cpp index 41b27a486c..ff0017caad 100644 --- a/src/tests/libxrpl/ledger/DIDEntry.cpp +++ b/src/tests/libxrpl/ledger/DIDEntry.cpp @@ -7,7 +7,7 @@ namespace xrpl::test { -TEST(DIDEntryTests, Constructors) +TEST(DIDEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/DelegateEntry.cpp b/src/tests/libxrpl/ledger/DelegateEntry.cpp index a27299df46..3ffc3c73ed 100644 --- a/src/tests/libxrpl/ledger/DelegateEntry.cpp +++ b/src/tests/libxrpl/ledger/DelegateEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(DelegateEntryTests, Constructors) +TEST(DelegateEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/DepositPreauthEntry.cpp b/src/tests/libxrpl/ledger/DepositPreauthEntry.cpp index bba2a58c8a..115baa741d 100644 --- a/src/tests/libxrpl/ledger/DepositPreauthEntry.cpp +++ b/src/tests/libxrpl/ledger/DepositPreauthEntry.cpp @@ -14,7 +14,7 @@ namespace xrpl::test { -TEST(DepositPreauthEntryTests, Constructors) +TEST(DepositPreauthEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp b/src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp index 0efcb365f6..41349ea427 100644 --- a/src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp +++ b/src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp @@ -10,7 +10,7 @@ namespace xrpl::test { -TEST(DirectoryNodeEntryTests, Constructors) +TEST(DirectoryNodeEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/EscrowEntry.cpp b/src/tests/libxrpl/ledger/EscrowEntry.cpp index 35ee0d4ad5..2430589641 100644 --- a/src/tests/libxrpl/ledger/EscrowEntry.cpp +++ b/src/tests/libxrpl/ledger/EscrowEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(EscrowEntryTests, Constructors) +TEST(EscrowEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/FeeSettingsEntry.cpp b/src/tests/libxrpl/ledger/FeeSettingsEntry.cpp index 2528cdbca5..b7b8325736 100644 --- a/src/tests/libxrpl/ledger/FeeSettingsEntry.cpp +++ b/src/tests/libxrpl/ledger/FeeSettingsEntry.cpp @@ -7,7 +7,7 @@ namespace xrpl::test { -TEST(FeeSettingsEntryTests, Constructors) +TEST(FeeSettingsEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/LedgerHashesEntry.cpp b/src/tests/libxrpl/ledger/LedgerHashesEntry.cpp index e5c635c92c..94cb6b1db6 100644 --- a/src/tests/libxrpl/ledger/LedgerHashesEntry.cpp +++ b/src/tests/libxrpl/ledger/LedgerHashesEntry.cpp @@ -7,7 +7,7 @@ namespace xrpl::test { -TEST(LedgerHashesEntryTests, Constructors) +TEST(LedgerHashesEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/LoanBrokerEntry.cpp b/src/tests/libxrpl/ledger/LoanBrokerEntry.cpp index e3a180c21b..ab0ced00c0 100644 --- a/src/tests/libxrpl/ledger/LoanBrokerEntry.cpp +++ b/src/tests/libxrpl/ledger/LoanBrokerEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(LoanBrokerEntryTests, Constructors) +TEST(LoanBrokerEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/LoanEntry.cpp b/src/tests/libxrpl/ledger/LoanEntry.cpp index c91063c73f..23bc03bc0c 100644 --- a/src/tests/libxrpl/ledger/LoanEntry.cpp +++ b/src/tests/libxrpl/ledger/LoanEntry.cpp @@ -9,7 +9,7 @@ namespace xrpl::test { -TEST(LoanEntryTests, Constructors) +TEST(LoanEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/MPTokenEntry.cpp b/src/tests/libxrpl/ledger/MPTokenEntry.cpp index f93b210c87..fe89555c7e 100644 --- a/src/tests/libxrpl/ledger/MPTokenEntry.cpp +++ b/src/tests/libxrpl/ledger/MPTokenEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(MPTokenEntryTests, Constructors) +TEST(MPTokenEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/MPTokenIssuanceEntry.cpp b/src/tests/libxrpl/ledger/MPTokenIssuanceEntry.cpp index 2553f29435..825f75debe 100644 --- a/src/tests/libxrpl/ledger/MPTokenIssuanceEntry.cpp +++ b/src/tests/libxrpl/ledger/MPTokenIssuanceEntry.cpp @@ -10,7 +10,7 @@ namespace xrpl::test { -TEST(MPTokenIssuanceEntryTests, Constructors) +TEST(MPTokenIssuanceEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/NFTokenOfferEntry.cpp b/src/tests/libxrpl/ledger/NFTokenOfferEntry.cpp index 728977cab4..2ad676e220 100644 --- a/src/tests/libxrpl/ledger/NFTokenOfferEntry.cpp +++ b/src/tests/libxrpl/ledger/NFTokenOfferEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(NFTokenOfferEntryTests, Constructors) +TEST(NFTokenOfferEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/NFTokenPageEntry.cpp b/src/tests/libxrpl/ledger/NFTokenPageEntry.cpp index e5a5a1d7fd..e392cc52af 100644 --- a/src/tests/libxrpl/ledger/NFTokenPageEntry.cpp +++ b/src/tests/libxrpl/ledger/NFTokenPageEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(NFTokenPageEntryTests, Constructors) +TEST(NFTokenPageEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/NegativeUNLEntry.cpp b/src/tests/libxrpl/ledger/NegativeUNLEntry.cpp index 2a4bc0b59b..6a15578b3e 100644 --- a/src/tests/libxrpl/ledger/NegativeUNLEntry.cpp +++ b/src/tests/libxrpl/ledger/NegativeUNLEntry.cpp @@ -7,7 +7,7 @@ namespace xrpl::test { -TEST(NegativeUNLEntryTests, Constructors) +TEST(NegativeUNLEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/OfferEntry.cpp b/src/tests/libxrpl/ledger/OfferEntry.cpp index dc32679138..17f98b69c2 100644 --- a/src/tests/libxrpl/ledger/OfferEntry.cpp +++ b/src/tests/libxrpl/ledger/OfferEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(OfferEntryTests, Constructors) +TEST(OfferEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/OracleEntry.cpp b/src/tests/libxrpl/ledger/OracleEntry.cpp index a505017e1f..f467056c1e 100644 --- a/src/tests/libxrpl/ledger/OracleEntry.cpp +++ b/src/tests/libxrpl/ledger/OracleEntry.cpp @@ -9,7 +9,7 @@ namespace xrpl::test { -TEST(OracleEntryTests, Constructors) +TEST(OracleEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/PayChannelEntry.cpp b/src/tests/libxrpl/ledger/PayChannelEntry.cpp index 7866f34be0..82dcdb3be8 100644 --- a/src/tests/libxrpl/ledger/PayChannelEntry.cpp +++ b/src/tests/libxrpl/ledger/PayChannelEntry.cpp @@ -9,7 +9,7 @@ namespace xrpl::test { -TEST(PayChannelEntryTests, Constructors) +TEST(PayChannelEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/PermissionedDomainEntry.cpp b/src/tests/libxrpl/ledger/PermissionedDomainEntry.cpp index cbaa2f0300..feb3002c4e 100644 --- a/src/tests/libxrpl/ledger/PermissionedDomainEntry.cpp +++ b/src/tests/libxrpl/ledger/PermissionedDomainEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(PermissionedDomainEntryTests, Constructors) +TEST(PermissionedDomainEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/RippleStateEntry.cpp b/src/tests/libxrpl/ledger/RippleStateEntry.cpp index c46921bced..721dc09157 100644 --- a/src/tests/libxrpl/ledger/RippleStateEntry.cpp +++ b/src/tests/libxrpl/ledger/RippleStateEntry.cpp @@ -10,7 +10,7 @@ namespace xrpl::test { -TEST(RippleStateEntryTests, Constructors) +TEST(RippleStateEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/SLEBase.cpp b/src/tests/libxrpl/ledger/SLEBase.cpp index 910cb27f09..f721ea760d 100644 --- a/src/tests/libxrpl/ledger/SLEBase.cpp +++ b/src/tests/libxrpl/ledger/SLEBase.cpp @@ -149,7 +149,7 @@ protected: } }; -TEST_F(SLEBaseTests, ReadOnly) +TEST_F(SLEBaseTests, read_only) { AccountRootEntryR const absent(bob_.id(), env_.getClosedLedger()); EXPECT_FALSE(absent.exists()); @@ -168,7 +168,7 @@ TEST_F(SLEBaseTests, ReadOnly) EXPECT_EQ(&present.readView(), &env_.getClosedLedger()); } -TEST_F(SLEBaseTests, AdoptSLE) +TEST_F(SLEBaseTests, adopt_sle) { auto const sle = env_.getClosedLedger().read(keylet::account(alice_.id())); ASSERT_NE(sle, nullptr); @@ -201,7 +201,7 @@ TEST_F(SLEBaseTests, AdoptSLE) "writable entries must not be constructible from a bare SLE"); } -TEST_F(SLEBaseTests, WritableAccessors) +TEST_F(SLEBaseTests, writable_accessors) { ApplyViewImpl av(&env_.getClosedLedger(), TapNone); beast::Journal const j{beast::Journal::getNullSink()}; @@ -236,7 +236,7 @@ TEST_F(SLEBaseTests, WritableAccessors) !HasApplyView, "applyView() must not exist on a read-only entry"); } -TEST_F(SLEBaseTests, ApplyViewContextCtor) +TEST_F(SLEBaseTests, apply_view_context_ctor) { ApplyViewImpl av(&env_.getClosedLedger(), TapNone); beast::Journal const j{beast::Journal::getNullSink()}; @@ -260,7 +260,7 @@ TEST_F(SLEBaseTests, ApplyViewContextCtor) EXPECT_EQ(fromCtx.rawSle(), fromView.rawSle()); } -TEST_F(SLEBaseTests, WritableLifecycle) +TEST_F(SLEBaseTests, writable_lifecycle) { // A view we never apply, so nothing here reaches the ledger. ApplyViewImpl av(&env_.getClosedLedger(), TapNone); @@ -318,7 +318,7 @@ TEST_F(SLEBaseTests, WritableLifecycle) } } -TEST_F(SLEBaseTests, Conversion) +TEST_F(SLEBaseTests, conversion) { ApplyViewImpl av(&env_.getClosedLedger(), TapNone); @@ -336,7 +336,7 @@ TEST_F(SLEBaseTests, Conversion) EXPECT_EQ(generic.type(), ltACCOUNT_ROOT); } -TEST_F(SLEBaseTests, ResolveEntryPeeks) +TEST_F(SLEBaseTests, resolve_entry_peeks) { // getOpenLedger() is an OpenView, which derives from ReadView but not // from ApplyView, so resolveEntry's dynamic_cast fails and this takes @@ -368,7 +368,7 @@ TEST_F(SLEBaseTests, ResolveEntryPeeks) EXPECT_EQ(readOnly->getFieldU32(sfSequence), bumped); } -TEST_F(SLEBaseTests, ThrowsOnMissingEntry) +TEST_F(SLEBaseTests, throws_on_missing_entry) { // A generic read-only entry has no static type to fall back on, so // type() must read it off the (absent) SLE and throw. @@ -389,7 +389,7 @@ TEST_F(SLEBaseTests, ThrowsOnMissingEntry) EXPECT_THROW(std::ignore = (*missing).getType(), std::logic_error); } -TEST_F(SLEBaseTests, ThrowsOnMissingWritableEntry) +TEST_F(SLEBaseTests, throws_on_missing_writable_entry) { // A view we never apply, so nothing here reaches the ledger. ApplyViewImpl av(&env_.getClosedLedger(), TapNone); diff --git a/src/tests/libxrpl/ledger/SignerListEntry.cpp b/src/tests/libxrpl/ledger/SignerListEntry.cpp index 8f0e8f5d2d..db65af2f02 100644 --- a/src/tests/libxrpl/ledger/SignerListEntry.cpp +++ b/src/tests/libxrpl/ledger/SignerListEntry.cpp @@ -7,7 +7,7 @@ namespace xrpl::test { -TEST(SignerListEntryTests, Constructors) +TEST(SignerListEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/SponsorshipEntry.cpp b/src/tests/libxrpl/ledger/SponsorshipEntry.cpp index c5004b8d32..735c3e202d 100644 --- a/src/tests/libxrpl/ledger/SponsorshipEntry.cpp +++ b/src/tests/libxrpl/ledger/SponsorshipEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(SponsorshipEntryTests, Constructors) +TEST(SponsorshipEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/TicketEntry.cpp b/src/tests/libxrpl/ledger/TicketEntry.cpp index e09af46cbf..fa26b1a937 100644 --- a/src/tests/libxrpl/ledger/TicketEntry.cpp +++ b/src/tests/libxrpl/ledger/TicketEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(TicketEntryTests, Constructors) +TEST(TicketEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/VaultEntry.cpp b/src/tests/libxrpl/ledger/VaultEntry.cpp index 0cff87bbe9..61f24db410 100644 --- a/src/tests/libxrpl/ledger/VaultEntry.cpp +++ b/src/tests/libxrpl/ledger/VaultEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(VaultEntryTests, Constructors) +TEST(VaultEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/XChainOwnedClaimIDEntry.cpp b/src/tests/libxrpl/ledger/XChainOwnedClaimIDEntry.cpp index 1833c68f79..4145471a27 100644 --- a/src/tests/libxrpl/ledger/XChainOwnedClaimIDEntry.cpp +++ b/src/tests/libxrpl/ledger/XChainOwnedClaimIDEntry.cpp @@ -12,7 +12,7 @@ namespace xrpl::test { -TEST(XChainOwnedClaimIDEntryTests, Constructors) +TEST(XChainOwnedClaimIDEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/XChainOwnedCreateAccountClaimIDEntry.cpp b/src/tests/libxrpl/ledger/XChainOwnedCreateAccountClaimIDEntry.cpp index d109000f77..93851deedf 100644 --- a/src/tests/libxrpl/ledger/XChainOwnedCreateAccountClaimIDEntry.cpp +++ b/src/tests/libxrpl/ledger/XChainOwnedCreateAccountClaimIDEntry.cpp @@ -13,7 +13,7 @@ namespace xrpl::test { -TEST(XChainOwnedCreateAccountClaimIDEntryTests, Constructors) +TEST(XChainOwnedCreateAccountClaimIDEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/protocol/STXChainBridge.cpp b/src/tests/libxrpl/protocol/STXChainBridge.cpp index f4e6e60cc9..22f26d7ea0 100644 --- a/src/tests/libxrpl/protocol/STXChainBridge.cpp +++ b/src/tests/libxrpl/protocol/STXChainBridge.cpp @@ -28,7 +28,7 @@ account(std::string_view hex) // getText() builds its string from eight substitutions of the same type, so a // transposed pair would still compile and still type check. Pin the output so // the field/value pairing is actually verified. -TEST(STXChainBridge, getTextPairsEachFieldWithItsValue) +TEST(STXChainBridge, get_text_pairs_each_field_with_its_value) { auto const lockingDoor = account("0102030405060708090A0B0C0D0E0F1011121314"); auto const issuingDoor = account("14131211100F0E0D0C0B0A090807060504030201"); @@ -46,7 +46,7 @@ TEST(STXChainBridge, getTextPairsEachFieldWithItsValue) EXPECT_EQ(bridge.getText(), expected); } -TEST(STXChainBridge, getTextOnADefaultBridge) +TEST(STXChainBridge, get_text_on_a_default_bridge) { STXChainBridge const bridge; auto const text = bridge.getText(); diff --git a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdateTests.cpp new file mode 100644 index 0000000000..6cbeb008bd --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdateTests.cpp @@ -0,0 +1,255 @@ +// Auto-generated unit tests for transaction ConfidentialMPTMirrorUpdate + + +#include + +#include + +#include +#include +#include +#include +#include + +#include + +namespace xrpl::transactions { + +// 1 & 4) Set fields via builder setters, build, then read them back via +// wrapper getters. After build(), validate() should succeed. +TEST(TransactionsConfidentialMPTMirrorUpdateTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTMirrorUpdate")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 1; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const mPTokenIssuanceIDValue = canonical_UINT192(); + auto const holderValue = canonical_ACCOUNT(); + auto const issuerEncryptedAmountValue = canonical_VL(); + auto const auditorEncryptedAmountValue = canonical_VL(); + auto const zKProofValue = canonical_VL(); + + ConfidentialMPTMirrorUpdateBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + zKProofValue, + sequenceValue, + feeValue + }; + + // Set optional fields + builder.setHolder(holderValue); + builder.setIssuerEncryptedAmount(issuerEncryptedAmountValue); + builder.setAuditorEncryptedAmount(auditorEncryptedAmountValue); + + auto tx = builder.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(tx.validate(reason)) << reason; + + // Verify signing was applied + EXPECT_FALSE(tx.getSigningPubKey().empty()); + EXPECT_TRUE(tx.hasTxnSignature()); + + // Verify common fields + EXPECT_EQ(tx.getAccount(), accountValue); + EXPECT_EQ(tx.getSequence(), sequenceValue); + EXPECT_EQ(tx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = mPTokenIssuanceIDValue; + auto const actual = tx.getMPTokenIssuanceID(); + expectEqualField(expected, actual, "sfMPTokenIssuanceID"); + } + + { + auto const& expected = zKProofValue; + auto const actual = tx.getZKProof(); + expectEqualField(expected, actual, "sfZKProof"); + } + + // Verify optional fields + { + auto const& expected = holderValue; + auto const actualOpt = tx.getHolder(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfHolder should be present"; + expectEqualField(expected, *actualOpt, "sfHolder"); + EXPECT_TRUE(tx.hasHolder()); + } + + { + auto const& expected = issuerEncryptedAmountValue; + auto const actualOpt = tx.getIssuerEncryptedAmount(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfIssuerEncryptedAmount should be present"; + expectEqualField(expected, *actualOpt, "sfIssuerEncryptedAmount"); + EXPECT_TRUE(tx.hasIssuerEncryptedAmount()); + } + + { + auto const& expected = auditorEncryptedAmountValue; + auto const actualOpt = tx.getAuditorEncryptedAmount(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptedAmount should be present"; + expectEqualField(expected, *actualOpt, "sfAuditorEncryptedAmount"); + EXPECT_TRUE(tx.hasAuditorEncryptedAmount()); + } + +} + +// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, +// and verify all fields match. +TEST(TransactionsConfidentialMPTMirrorUpdateTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTMirrorUpdateFromTx")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 2; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const mPTokenIssuanceIDValue = canonical_UINT192(); + auto const holderValue = canonical_ACCOUNT(); + auto const issuerEncryptedAmountValue = canonical_VL(); + auto const auditorEncryptedAmountValue = canonical_VL(); + auto const zKProofValue = canonical_VL(); + + // Build an initial transaction + ConfidentialMPTMirrorUpdateBuilder initialBuilder{ + accountValue, + mPTokenIssuanceIDValue, + zKProofValue, + sequenceValue, + feeValue + }; + + initialBuilder.setHolder(holderValue); + initialBuilder.setIssuerEncryptedAmount(issuerEncryptedAmountValue); + initialBuilder.setAuditorEncryptedAmount(auditorEncryptedAmountValue); + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + ConfidentialMPTMirrorUpdateBuilder builderFromTx{initialTx.getSTTx()}; + + auto rebuiltTx = builderFromTx.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(rebuiltTx.validate(reason)) << reason; + + // Verify common fields + EXPECT_EQ(rebuiltTx.getAccount(), accountValue); + EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue); + EXPECT_EQ(rebuiltTx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = mPTokenIssuanceIDValue; + auto const actual = rebuiltTx.getMPTokenIssuanceID(); + expectEqualField(expected, actual, "sfMPTokenIssuanceID"); + } + + { + auto const& expected = zKProofValue; + auto const actual = rebuiltTx.getZKProof(); + expectEqualField(expected, actual, "sfZKProof"); + } + + // Verify optional fields + { + auto const& expected = holderValue; + auto const actualOpt = rebuiltTx.getHolder(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfHolder should be present"; + expectEqualField(expected, *actualOpt, "sfHolder"); + } + + { + auto const& expected = issuerEncryptedAmountValue; + auto const actualOpt = rebuiltTx.getIssuerEncryptedAmount(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfIssuerEncryptedAmount should be present"; + expectEqualField(expected, *actualOpt, "sfIssuerEncryptedAmount"); + } + + { + auto const& expected = auditorEncryptedAmountValue; + auto const actualOpt = rebuiltTx.getAuditorEncryptedAmount(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptedAmount should be present"; + expectEqualField(expected, *actualOpt, "sfAuditorEncryptedAmount"); + } + +} + +// 3) Verify wrapper throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTMirrorUpdateTests, WrapperThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(ConfidentialMPTMirrorUpdate{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTMirrorUpdateTests, BuilderThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(ConfidentialMPTMirrorUpdateBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + +// 5) Build with only required fields and verify optional fields return nullopt. +TEST(TransactionsConfidentialMPTMirrorUpdateTests, OptionalFieldsReturnNullopt) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTMirrorUpdateNullopt")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 3; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific required field values + auto const mPTokenIssuanceIDValue = canonical_UINT192(); + auto const zKProofValue = canonical_VL(); + + ConfidentialMPTMirrorUpdateBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + zKProofValue, + sequenceValue, + feeValue + }; + + // Do NOT set optional fields + + auto tx = builder.build(publicKey, secretKey); + + // Verify optional fields are not present + EXPECT_FALSE(tx.hasHolder()); + EXPECT_FALSE(tx.getHolder().has_value()); + EXPECT_FALSE(tx.hasIssuerEncryptedAmount()); + EXPECT_FALSE(tx.getIssuerEncryptedAmount().has_value()); + EXPECT_FALSE(tx.hasAuditorEncryptedAmount()); + EXPECT_FALSE(tx.getAuditorEncryptedAmount().has_value()); +} + +} diff --git a/src/tests/libxrpl/server/InfoSub.cpp b/src/tests/libxrpl/server/InfoSub.cpp index 6913812a92..08698ae3ac 100644 --- a/src/tests/libxrpl/server/InfoSub.cpp +++ b/src/tests/libxrpl/server/InfoSub.cpp @@ -12,7 +12,7 @@ using namespace xrpl; // by subscribing the real cap through a WebSocket, which would exceed the frame // limit and drop the connection before the check runs) lets the boundary be // asserted exactly. -TEST(InfoSubSubscriptionCap, Boundary) +TEST(InfoSubSubscriptionCap, boundary) { constexpr std::size_t cap = kMaxSubscriptionsPerConnection; @@ -30,7 +30,7 @@ TEST(InfoSubSubscriptionCap, Boundary) EXPECT_TRUE(exceedsSubscriptionCap(cap - 1, 2)); } -TEST(InfoSubSubscriptionCap, NoOverflow) +TEST(InfoSubSubscriptionCap, no_overflow) { constexpr std::size_t cap = kMaxSubscriptionsPerConnection; constexpr std::size_t max = std::numeric_limits::max(); @@ -41,7 +41,7 @@ TEST(InfoSubSubscriptionCap, NoOverflow) EXPECT_TRUE(exceedsSubscriptionCap(cap, max)); } -TEST(InfoSubSubscriptionCap, ExplicitCap) +TEST(InfoSubSubscriptionCap, explicit_cap) { // A configured override is honored: the boundary tracks the passed cap, not // the built-in default. This is the seam doSubscribe uses to enforce a diff --git a/src/tests/libxrpl/telemetry/SpanGuardScope.cpp b/src/tests/libxrpl/telemetry/SpanGuardScope.cpp index 3da54f8bee..647cf59013 100644 --- a/src/tests/libxrpl/telemetry/SpanGuardScope.cpp +++ b/src/tests/libxrpl/telemetry/SpanGuardScope.cpp @@ -59,6 +59,7 @@ #include #include +#include #include #include #include @@ -673,6 +674,85 @@ TEST_F(SpanGuardScopeTest, spanGuard_addEvent_without_attributes_records_bare_ev EXPECT_EQ(events.front().GetAttributes().size(), 0u); } +// The scoped guard records event attributes too. consensus.accept.apply relies +// on it for one tx.included event per transaction of the accepted set. +TEST_F(SpanGuardScopeTest, scopedGuard_addEvent_records_name_and_attribute_values) +{ + namespace cs = consensus::span; + + static constexpr std::string_view kEventName{cs::event::txIncluded}; + static constexpr std::string_view kTxIdKey{cs::attr::txId}; + static constexpr std::string_view kTxId{"6B5F1A2C3D4E5F60718293A4B5C6D7E8"}; + + { + ScopedSpanGuard guard(TraceCategory::Consensus, seg::consensus, cs::op::acceptApply); + ASSERT_TRUE(static_cast(guard)); + guard.addEvent(kEventName, {{kTxIdKey, kTxId}}); + } + + auto spans = spanData()->GetSpans(); + auto* applySpan = findSpan(spans, cs::acceptApply); + ASSERT_NE(applySpan, nullptr); + + auto const& events = applySpan->GetEvents(); + ASSERT_EQ(events.size(), 1u); + EXPECT_EQ(events.front().GetName(), std::string(kEventName)); + EXPECT_EQ(events.front().GetAttributes().size(), 1u); + EXPECT_EQ(eventAttribute(events.front(), kTxIdKey), std::string(kTxId)); +} + +// A scoped child of a captured context is the ambient parent of the spans +// created after it on the same thread. A hash-derived root created inside that +// scope stays a root. consensus.accept.apply relies on both. +TEST_F(SpanGuardScopeTest, scopedChildOfCapturedContextIsAmbientForLaterSpans) +{ + namespace cs = consensus::span; + + auto const h = makeTraceIdBytes(); + { + // consensus.accept: unscoped, thread-free, context captured. + auto accept = + SpanGuard::freshRoot(TraceCategory::Consensus, seg::consensus, cs::op::accept); + ASSERT_TRUE(static_cast(accept)); + auto const acceptCtx = accept.spanContext(); + + // consensus.accept.apply: scoped child of that context. + ScopedSpanGuard const apply = ScopedSpanGuard::childSpan(cs::acceptApply, acceptCtx); + ASSERT_TRUE(static_cast(apply)); + + // ledger.build: a plain ambient scoped guard. + { + ScopedSpanGuard const build(TraceCategory::Ledger, seg::ledger, "build"); + ASSERT_TRUE(static_cast(build)); + } + + // ledger.store: hash-derived, so a deterministic root. + { + auto store = + SpanGuard::hashSpan(TraceCategory::Ledger, "ledger.store", h.data(), h.size()); + ASSERT_TRUE(static_cast(store)); + } + } + + auto spans = spanData()->GetSpans(); + auto* accept = findSpan(spans, cs::accept); + auto* apply = findSpan(spans, cs::acceptApply); + auto* build = findSpan(spans, "ledger.build"); + auto* store = findSpan(spans, "ledger.store"); + ASSERT_NE(accept, nullptr); + ASSERT_NE(apply, nullptr); + ASSERT_NE(build, nullptr); + ASSERT_NE(store, nullptr); + + EXPECT_EQ(apply->GetParentSpanId(), accept->GetSpanId()); + // build nests under apply, not beside it. + EXPECT_EQ(build->GetParentSpanId(), apply->GetSpanId()); + EXPECT_EQ(build->GetTraceId(), apply->GetTraceId()); + // The hash-derived span is a root on its own pinned trace id. + EXPECT_FALSE(store->GetParentSpanId().IsValid()); + EXPECT_TRUE(std::ranges::equal(store->GetTraceId().Id(), h)); +} + // A forced-root span started while a PendingTraceId is active adopts that // pinned 16-byte trace_id and remains a true root (no parent). TEST_F(SpanGuardScopeTest, deterministicIdGenerator_forced_root_gets_pending_trace_id) diff --git a/src/tests/libxrpl/telemetry/TelemetryConfig.cpp b/src/tests/libxrpl/telemetry/TelemetryConfig.cpp index 64cf521e1c..ca817cdfa4 100644 --- a/src/tests/libxrpl/telemetry/TelemetryConfig.cpp +++ b/src/tests/libxrpl/telemetry/TelemetryConfig.cpp @@ -344,8 +344,8 @@ TEST(TelemetryConfig, parse_full_section) section.set("enabled", "1"); section.set("service_name", "my-rippled"); section.set("service_instance_id", "custom-id"); - section.set("traces_endpoint", "http://collector:4318/v1/traces"); - section.set("metrics_endpoint", "http://collector:4318/v1/metrics"); + section.set("traces_endpoint", "https://collector:4318/v1/traces"); + section.set("metrics_endpoint", "https://collector:4318/v1/metrics"); section.set("use_tls", "1"); section.set("tls_ca_cert", caCert); section.set("batch_size", "256"); @@ -364,8 +364,8 @@ TEST(TelemetryConfig, parse_full_section) EXPECT_TRUE(setup.enabled); EXPECT_EQ(setup.serviceName, "my-rippled"); EXPECT_EQ(setup.serviceInstanceId, "custom-id"); - EXPECT_EQ(setup.tracesEndpoint, "http://collector:4318/v1/traces"); - EXPECT_EQ(setup.metricsEndpoint, "http://collector:4318/v1/metrics"); + EXPECT_EQ(setup.tracesEndpoint, "https://collector:4318/v1/traces"); + EXPECT_EQ(setup.metricsEndpoint, "https://collector:4318/v1/metrics"); EXPECT_TRUE(setup.useTls); EXPECT_EQ(setup.tlsCertPath, caCert); EXPECT_EQ(setup.batchSize, 256u); @@ -487,16 +487,20 @@ TEST(TelemetryConfig, mtls_neither_set_is_one_way_tls) { // Telemetry is on so the checks run, and this config must pass all of // them: one-way TLS with a CA bundle and no client certificate. The CA - // path has to name a real file, because the parser opens it here. + // path has to name a real file, because the parser opens it here. Both + // endpoints are https because use_tls=1 now requires it. TempDir const dir; auto const caCert = mtls::writeCertFile(dir.file("ca.pem")); Section section = mtls::makeSection(true); section.set("use_tls", "1"); + section.set(mtls::keyEndpoint, mtls::httpsEndpoint); + section.set(mtls::keyMetricsEndpoint, mtls::metricsHttpsEndpoint); section.set("tls_ca_cert", caCert); auto const setup = mtls::parseSection(section); EXPECT_TRUE(setup.enabled); EXPECT_TRUE(setup.useTls); + EXPECT_EQ(setup.tracesEndpoint, mtls::httpsEndpoint); EXPECT_EQ(setup.tlsCertPath, caCert); EXPECT_TRUE(setup.tlsClientCertPath.empty()); EXPECT_TRUE(setup.tlsClientKeyPath.empty()); @@ -548,11 +552,14 @@ TEST(TelemetryConfig, tls_missing_client_key_file_throws) TEST(TelemetryConfig, tls_missing_ca_cert_file_throws) { // One-way TLS with no client certificate, so the CA bundle is the only - // path checked. + // path checked. Both endpoints are https so the scheme guard, which runs + // first, cannot be what throws. TempDir const dir; auto const absentCa = dir.file("absent-ca.pem"); Section section = mtls::makeSection(true); section.set("use_tls", "1"); + section.set(mtls::keyEndpoint, mtls::httpsEndpoint); + section.set(mtls::keyMetricsEndpoint, mtls::metricsHttpsEndpoint); section.set("tls_ca_cert", absentCa); EXPECT_THAT( @@ -758,22 +765,26 @@ TEST(TelemetryConfig, mtls_client_cert_with_the_default_metrics_endpoint_throws) HasSubstr(mtls::defaultMetricsEndpoint)))); } -TEST(TelemetryConfig, one_way_tls_on_a_plain_http_metrics_endpoint_is_accepted) +TEST(TelemetryConfig, one_way_tls_on_a_plain_http_metrics_endpoint_throws) { - // The control for the metric guard's scope, matching the trace one below: - // same plain http metrics endpoint and use_tls=1, but no client identity to - // lose. Widen the guard to every use_tls=1 node and this case starts failing. + // use_tls=1 with no client certificate, and only the metrics endpoint left + // on plain http. traces_endpoint is https so the trace guard, which runs + // first, cannot be what throws -- the message must name metrics_endpoint. + // Narrow the guard back to tls_client_cert and this case passes. TempDir const dir; auto const ca = mtls::writeCertFile(dir.file("ca.pem")); Section section = mtls::makeSection(true); section.set("use_tls", "1"); + section.set(mtls::keyEndpoint, mtls::httpsEndpoint); section.set(mtls::keyMetricsEndpoint, mtls::metricsHttpEndpoint); section.set("tls_ca_cert", ca); - telemetry::Telemetry::Setup setup; - ASSERT_NO_THROW(setup = mtls::parseSection(section)); - EXPECT_EQ(setup.metricsEndpoint, mtls::metricsHttpEndpoint); - EXPECT_TRUE(setup.tlsClientCertPath.empty()); + EXPECT_THAT( + [§ion] { mtls::parseSection(section); }, + ThrowsMessage(AllOf( + HasSubstr(mtls::schemeError), + HasSubstr(mtls::keyMetricsEndpoint), + HasSubstr(mtls::metricsHttpEndpoint)))); } TEST(TelemetryConfig, mtls_scheme_check_is_case_sensitive_like_the_exporter) @@ -793,12 +804,13 @@ TEST(TelemetryConfig, mtls_scheme_check_is_case_sensitive_like_the_exporter) ThrowsMessage(HasSubstr(mtls::schemeError))); } -TEST(TelemetryConfig, one_way_tls_on_a_plain_http_endpoint_is_accepted) +TEST(TelemetryConfig, one_way_tls_on_a_plain_http_endpoint_throws) { - // The control for the guard's scope: same http:// endpoint and use_tls=1, - // but no client certificate. Only a client identity can be silently - // dropped, so this configuration is left alone. Widen the guard to every - // use_tls=1 node and this case starts failing. + // use_tls=1 with no client certificate, on an http:// endpoint. The + // exporter reads TLS off the scheme, so this config would check the CA + // file and then export in the clear. The guard covers every use_tls=1 + // node, not just the ones presenting a client identity, so it fires here + // too. Narrow the guard back to tls_client_cert and this case passes. TempDir const dir; auto const ca = mtls::writeCertFile(dir.file("ca.pem")); Section section = mtls::makeSection(true); @@ -806,11 +818,12 @@ TEST(TelemetryConfig, one_way_tls_on_a_plain_http_endpoint_is_accepted) section.set(mtls::keyEndpoint, mtls::httpEndpoint); section.set("tls_ca_cert", ca); - telemetry::Telemetry::Setup setup; - ASSERT_NO_THROW(setup = mtls::parseSection(section)); - EXPECT_EQ(setup.tracesEndpoint, mtls::httpEndpoint); - EXPECT_EQ(setup.tlsCertPath, ca); - EXPECT_TRUE(setup.tlsClientCertPath.empty()); + EXPECT_THAT( + [§ion] { mtls::parseSection(section); }, + ThrowsMessage(AllOf( + HasSubstr(mtls::schemeError), + HasSubstr(mtls::keyEndpoint), + HasSubstr(mtls::httpEndpoint)))); } TEST(TelemetryConfig, mtls_scheme_not_checked_when_telemetry_disabled) diff --git a/src/tests/libxrpl/telemetry/TxAccountSpanNames.cpp b/src/tests/libxrpl/telemetry/TxAccountSpanNames.cpp new file mode 100644 index 0000000000..7bfc6bab81 --- /dev/null +++ b/src/tests/libxrpl/telemetry/TxAccountSpanNames.cpp @@ -0,0 +1,167 @@ +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * Contract tests for the account-field attribute keys of the tx.process span. + * + * The key set is a cross-component contract: Tempo span-filter tags, the + * naming CI check and dashboards read these exact strings. A transaction type + * that gains an account-typed field without a key would silently emit nothing + * for it, so the completeness tests derive the required set from TxFormats + * and from the SField registry rather than from a copied list. + */ + +using namespace xrpl; +using namespace xrpl::telemetry; + +namespace { + +// Every account-typed field that any transaction format carries at top level, +// including the common fields shared by all formats. +std::set +accountFieldsInTransactionFormats() +{ + std::set fields; + for (auto const& format : TxFormats::getInstance()) + { + for (auto const& element : format.getSOTemplate()) + { + if (element.sField().fieldType == STI_ACCOUNT) + fields.insert(&element.sField()); + } + } + return fields; +} + +// Every account-typed field the protocol defines, whether or not a transaction +// carries it. +std::set +allAccountFields() +{ + std::set fields; + for (auto const& [code, field] : SField::getKnownCodeToField()) + { + if (field->fieldType == STI_ACCOUNT) + fields.insert(field); + } + return fields; +} + +} // namespace + +TEST(TxAccountSpanNames, every_account_field_a_transaction_can_carry_has_a_key) +{ + auto const fields = accountFieldsInTransactionFormats(); + // Setup: the walk over TxFormats found the one field every transaction has. + ASSERT_TRUE(fields.contains(&sfAccount)); + + for (auto const* field : fields) + { + EXPECT_TRUE(accountFieldAttributeKey(*field).has_value()) + << "no attribute key for " << field->getName(); + } +} + +TEST(TxAccountSpanNames, account_fields_no_transaction_carries_have_no_key) +{ + auto const carried = accountFieldsInTransactionFormats(); + auto const all = allAccountFields(); + // Setup: the registry holds more account fields than transactions carry. + ASSERT_TRUE(all.contains(&sfLowSponsor)); + ASSERT_FALSE(carried.contains(&sfLowSponsor)); + + for (auto const* field : all) + { + // Braced on purpose: EXPECT_EQ expands to an if/else, so an unbraced + // if around it is a dangling-else error with warnings as errors. + if (!carried.contains(field)) + { + EXPECT_EQ(accountFieldAttributeKey(*field), std::nullopt) << field->getName(); + } + } +} + +TEST(TxAccountSpanNames, every_key_is_tx_plus_the_field_name_in_lower_snake_case) +{ + for (auto const* field : accountFieldsInTransactionFormats()) + { + auto const maybeKey = accountFieldAttributeKey(*field); + ASSERT_TRUE(maybeKey.has_value()) << field->getName(); + auto const key = maybeKey.value_or(std::string_view{}); + + // Shape: tx_ prefix, then lower_snake_case with no empty segment. + EXPECT_TRUE(key.starts_with("tx_")) << key; + EXPECT_FALSE(key.ends_with('_')) << key; + EXPECT_EQ(key.find("__"), std::string_view::npos) << key; + EXPECT_TRUE(std::ranges::all_of(key, [](unsigned char c) { + return std::islower(c) != 0 || std::isdigit(c) != 0 || c == '_'; + })) << key; + + // Content: the key with prefix and underscores removed is the field's + // JSON name lowercased. Catches a misspelt or swapped key. + std::string flattened(key.substr(3)); + std::erase(flattened, '_'); + std::string lowered = field->getName(); + std::ranges::transform(lowered, lowered.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + EXPECT_EQ(flattened, lowered) << key; + } +} + +// The published contract: every carried field and its exact key. Literals are +// deliberate here; the point is to pin underscore placement, which the shape +// test above cannot see. +TEST(TxAccountSpanNames, exact_key_for_every_carried_field) +{ + std::vector> const expected = { + {&sfAccount, "tx_account"}, + {&sfDestination, "tx_destination"}, + {&sfOwner, "tx_owner"}, + {&sfIssuer, "tx_issuer"}, + {&sfAuthorize, "tx_authorize"}, + {&sfUnauthorize, "tx_unauthorize"}, + {&sfRegularKey, "tx_regular_key"}, + {&sfNFTokenMinter, "tx_nftoken_minter"}, + {&sfHolder, "tx_holder"}, + {&sfDelegate, "tx_delegate"}, + {&sfSponsor, "tx_sponsor"}, + {&sfSponsee, "tx_sponsee"}, + {&sfCounterparty, "tx_counterparty"}, + {&sfCounterpartySponsor, "tx_counterparty_sponsor"}, + {&sfSubject, "tx_subject"}, + {&sfOtherChainSource, "tx_other_chain_source"}, + {&sfOtherChainDestination, "tx_other_chain_destination"}, + {&sfAttestationSignerAccount, "tx_attestation_signer_account"}, + {&sfAttestationRewardAccount, "tx_attestation_reward_account"}, + }; + // Setup: this list and the TxFormats walk must name the same fields, or a + // row is missing here. + std::set listed; + for (auto const& [field, key] : expected) + listed.insert(field); + ASSERT_EQ(listed, accountFieldsInTransactionFormats()); + + for (auto const& [field, key] : expected) + EXPECT_EQ(accountFieldAttributeKey(*field).value_or(""), key) << field->getName(); +} + +TEST(TxAccountSpanNames, non_account_fields_have_no_key) +{ + EXPECT_EQ(accountFieldAttributeKey(sfFee), std::nullopt); + EXPECT_EQ(accountFieldAttributeKey(sfSequence), std::nullopt); + EXPECT_EQ(accountFieldAttributeKey(sfInvalid), std::nullopt); +} diff --git a/src/tests/libxrpl/tx/AccountSet.cpp b/src/tests/libxrpl/tx/AccountSet.cpp index ae291791d4..fe6818670e 100644 --- a/src/tests/libxrpl/tx/AccountSet.cpp +++ b/src/tests/libxrpl/tx/AccountSet.cpp @@ -43,7 +43,7 @@ namespace xrpl::test { -TEST(AccountSet, NullAccountSet) +TEST(AccountSet, null_account_set) { TxTest env; @@ -60,7 +60,7 @@ TEST(AccountSet, NullAccountSet) EXPECT_EQ(accountRoot.getFlags(), 0); } -TEST(AccountSet, MostFlags) +TEST(AccountSet, most_flags) { Account const alice("alice"); @@ -175,7 +175,7 @@ TEST(AccountSet, MostFlags) }); } -TEST(AccountSet, SetAndResetAccountTxnID) +TEST(AccountSet, set_and_reset_account_txn_id) { TxTest env; Account const alice("alice"); @@ -206,7 +206,7 @@ TEST(AccountSet, SetAndResetAccountTxnID) EXPECT_EQ(nowFlags, origFlags); } -TEST(AccountSet, SetNoFreeze) +TEST(AccountSet, set_no_freeze) { TxTest env; Account const alice("alice"); @@ -249,7 +249,7 @@ TEST(AccountSet, SetNoFreeze) EXPECT_TRUE(env.getAccountRoot(alice).isFlag(lsfNoFreeze)); } -TEST(AccountSet, Domain) +TEST(AccountSet, domain) { TxTest env; Account const alice("alice"); @@ -317,7 +317,7 @@ TEST(AccountSet, Domain) } } -TEST(AccountSet, MessageKey) +TEST(AccountSet, message_key) { TxTest env; Account const alice("alice"); @@ -358,7 +358,7 @@ TEST(AccountSet, MessageKey) telBAD_PUBLIC_KEY); } -TEST(AccountSet, WalletID) +TEST(AccountSet, wallet_id) { TxTest env; Account const alice("alice"); @@ -391,7 +391,7 @@ TEST(AccountSet, WalletID) EXPECT_FALSE(env.getAccountRoot(alice).hasWalletLocator()); } -TEST(AccountSet, EmailHash) +TEST(AccountSet, email_hash) { TxTest env; Account const alice("alice"); @@ -422,7 +422,7 @@ TEST(AccountSet, EmailHash) EXPECT_FALSE(env.getAccountRoot(alice).hasEmailHash()); } -TEST(AccountSet, TransferRate) +TEST(AccountSet, transfer_rate) { struct TestCase { @@ -473,7 +473,7 @@ TEST(AccountSet, TransferRate) } } -TEST(AccountSet, BadInputs) +TEST(AccountSet, bad_inputs) { TxTest env; Account const alice("alice"); @@ -553,7 +553,7 @@ TEST(AccountSet, BadInputs) tecNO_ALTERNATIVE_KEY); } -TEST(AccountSet, RequireAuthWithDir) +TEST(AccountSet, require_auth_with_dir) { TxTest env; Account const alice("alice"); @@ -601,7 +601,7 @@ TEST(AccountSet, RequireAuthWithDir) tesSUCCESS); } -TEST(AccountSet, Ticket) +TEST(AccountSet, ticket) { TxTest env; Account const alice("alice"); @@ -660,7 +660,7 @@ TEST(AccountSet, Ticket) tefNO_TICKET); } -TEST(AccountSet, BadSigningKey) +TEST(AccountSet, bad_signing_key) { TxTest env; Account const alice("alice"); @@ -684,7 +684,7 @@ TEST(AccountSet, BadSigningKey) EXPECT_FALSE(result.applied); } -TEST(AccountSet, Gateway) +TEST(AccountSet, gateway) { Account const alice("alice"); Account const bob("bob"); diff --git a/src/xrpld/app/consensus/RCLConsensus.cpp b/src/xrpld/app/consensus/RCLConsensus.cpp index 96bd174bd7..5a7f89db45 100644 --- a/src/xrpld/app/consensus/RCLConsensus.cpp +++ b/src/xrpld/app/consensus/RCLConsensus.cpp @@ -643,10 +643,9 @@ RCLConsensus::Adaptor::doAccept( { namespace cs = telemetry::consensus::span; - // Make the accept span ambient for the whole accept so doAccept's log lines - // (and any spans created here) correlate to it. Non-owning: acceptSpan still - // owns/ends the span. doAccept runs to completion on the JtAccept worker - // (no coroutine yield), so this scope is thread-local and safe. + // Make the accept span ambient until accept.apply opens below. Non-owning: + // acceptSpan still owns and ends the span. doAccept runs to completion on + // one thread, so the scope pops on the thread that pushed it. auto acceptActivation = telemetry::activateIfLive(acceptSpan); prevProposers_ = result.proposers; @@ -675,13 +674,10 @@ RCLConsensus::Adaptor::doAccept( closeTimeCorrect = true; } - // Parent accept.apply via the captured accept context (acceptSpanContext_): - // the accept span is a thread-free SpanGuard, so an explicit context is - // used for both the sync (onForceAccept) and async (onAccept) paths. Falls - // back to the round context if the accept span was null. - auto doAcceptSpan = acceptSpanContext_.isValid() - ? telemetry::SpanGuard::childSpan(cs::acceptApply, acceptSpanContext_) - : telemetry::SpanGuard::childSpan(cs::acceptApply, roundSpanContext_); + // Scoped: accept.apply is the ambient parent of every span doAccept creates + // from here on. Parented through acceptSpanContext_ because the accept span + // is a thread-free SpanGuard; the context is valid whenever that span is live. + auto doAcceptSpan = telemetry::ScopedSpanGuard::childSpan(cs::acceptApply, acceptSpanContext_); doAcceptSpan.setAttribute(cs::attr::ledgerSeq, static_cast(prevLedger.seq()) + 1); doAcceptSpan.setAttribute( cs::attr::closeTimeRippleEpochS, @@ -1208,9 +1204,16 @@ RCLConsensus::Adaptor::onModeChange(ConsensusMode before, ConsensusMode after) // thread-free SpanGuard, so parent explicitly via its context). A mode // change outside a round leaves roundSpanContext_ invalid, yielding a null // guard (no-op). - auto span = telemetry::SpanGuard::childSpan(cs::modeChange, roundSpanContext_); - span.setAttribute(cs::attr::modeOld, toDisplayString(before).c_str()); - span.setAttribute(cs::attr::modeNew, toDisplayString(after).c_str()); + // + // Only a real transition gets a span. MonitoredMode::set also calls this + // on every round start; the round's mode attribute below still needs that + // call, the span does not. + if (before != after) + { + auto span = telemetry::SpanGuard::childSpan(cs::modeChange, roundSpanContext_); + span.setAttribute(cs::attr::modeOld, toDisplayString(before).c_str()); + span.setAttribute(cs::attr::modeNew, toDisplayString(after).c_str()); + } JLOG(j_.info()) << "Consensus mode change before=" << to_string(before) << ", after=" << to_string(after); diff --git a/src/xrpld/app/ledger/detail/LedgerToJson.cpp b/src/xrpld/app/ledger/detail/LedgerToJson.cpp index 1d789aa604..419da2a995 100644 --- a/src/xrpld/app/ledger/detail/LedgerToJson.cpp +++ b/src/xrpld/app/ledger/detail/LedgerToJson.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -11,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +29,7 @@ #include #include +#include #include #include #include @@ -179,6 +182,19 @@ fillJsonTx( } } + // compute outgoing CTID + if (stMeta && stMeta->isFieldPresent(sfTransactionIndex)) + { + uint32_t const lgrSeq = fill.ledger.seq(); + uint32_t const txnIdx = stMeta->getFieldU32(sfTransactionIndex); + uint32_t netID = fill.context->app.getNetworkIDService().getNetworkID(); + if (txn->isFieldPresent(sfNetworkID)) + netID = txn->getFieldU32(sfNetworkID); + + if (auto ctid = rpc::encodeCTID(lgrSeq, txnIdx, netID)) + txJson[jss::ctid] = *ctid; + } + if (((fill.options & static_cast(LedgerFill::Options::OwnerFunds)) != 0) && txn->getTxnType() == ttOFFER_CREATE) { diff --git a/src/xrpld/app/main/Application.cpp b/src/xrpld/app/main/Application.cpp index d984840e64..a416cf3259 100644 --- a/src/xrpld/app/main/Application.cpp +++ b/src/xrpld/app/main/Application.cpp @@ -478,6 +478,11 @@ public: // helper, keeping metrics and traces on one network label. config_->section("telemetry").valueOr("service_name", ""), telemetry::networkTypeFromId(config_->networkId), + // telemetry_ is declared before this member, so it is already + // built. An OTel collector needs the meter provider that only + // the telemetry module installs, and this lets the collector + // warn instead of silently dropping every metric. + telemetry_->isEnabled(), logs_->journal("Collector"))) , jobQueue_( std::make_unique( diff --git a/src/xrpld/app/main/CollectorManager.cpp b/src/xrpld/app/main/CollectorManager.cpp index df40c4e1df..4182085a90 100644 --- a/src/xrpld/app/main/CollectorManager.cpp +++ b/src/xrpld/app/main/CollectorManager.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -29,6 +30,7 @@ public: Section const& params, std::string const& serviceName, std::string const& networkType, + bool telemetryEnabled, beast::Journal journal) : journal_(journal) { @@ -45,11 +47,24 @@ public: // LCOV_EXCL_START -- OTel collector path is not exercised in unit tests else if (server == "otel") { - // Read OTLP metrics endpoint from [insight] section. - // Default to the standard OTLP/HTTP metrics path on localhost. + // The collector records through the global meter provider, and only + // the telemetry module installs one. With telemetry off it attaches + // to the SDK's noop provider and every metric is dropped, which + // otherwise looks like a clean start with empty dashboards. Say so + // rather than change what is built. + if (!telemetryEnabled && journal_.warn()) + { + journal_.warn() << "[insight] server=otel needs [telemetry] enabled=1. " + "Telemetry is off, so no metric will be exported."; + } + + // Read OTLP metrics endpoint from [insight] section, falling back + // to the same default the [telemetry] parser uses. std::string endpoint = get(params, "endpoint"); if (endpoint.empty()) - endpoint = "http://localhost:4318/v1/metrics"; + { + endpoint = telemetry::kDefaultMetricsEndpoint; + } std::string const& prefix(get(params, "prefix")); // Read for signature uniformity only. OTelCollector ignores it: @@ -100,9 +115,11 @@ makeCollectorManager( Section const& params, std::string const& serviceName, std::string const& networkType, + bool telemetryEnabled, beast::Journal journal) { - return std::make_unique(params, serviceName, networkType, journal); + return std::make_unique( + params, serviceName, networkType, telemetryEnabled, journal); } } // namespace xrpl diff --git a/src/xrpld/app/main/CollectorManager.h b/src/xrpld/app/main/CollectorManager.h index 2539b7ecc8..19157e3a78 100644 --- a/src/xrpld/app/main/CollectorManager.h +++ b/src/xrpld/app/main/CollectorManager.h @@ -33,6 +33,10 @@ public: * (empty -> the collector defaults it to "xrpld"). * @param networkType xrpl.network.type resource attribute for OTel * metrics (e.g. "mainnet"), derived from [network_id]. + * @param telemetryEnabled Whether the telemetry module is on. The OTel + * collector records through the global meter provider, which only the + * telemetry module installs, so with this false an OTel collector would + * discard every metric. Used to warn, not to change what is built. * @param journal Journal for logging. */ std::unique_ptr @@ -40,6 +44,7 @@ makeCollectorManager( Section const& params, std::string const& serviceName, std::string const& networkType, + bool telemetryEnabled, beast::Journal journal); } // namespace xrpl diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 260452f0af..76bee5caa4 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -125,6 +125,7 @@ #include #endif #include +#include #include #include @@ -1649,10 +1650,28 @@ NetworkOPsImp::processTransaction( { if (auto const* fmt = TxFormats::getInstance().findByType(stx->getTxnType())) span->setAttribute(tx_span::attr::txType, fmt->getName().c_str()); + // xrp() throws on a non-XRP fee. preflight rejects such a + // transaction with temBAD_FEE, so leave the attribute out rather + // than let tracing turn that into an internal error. + if (auto const& fee = stx->getFieldAmount(sfFee); fee.native()) + { + span->setAttribute( + tx_span::attr::fee, static_cast(fee.xrp().drops())); + } span->setAttribute( - tx_span::attr::fee, static_cast(stx->getFieldAmount(sfFee).xrp().drops())); - span->setAttribute( - tx_span::attr::sequence, static_cast(stx->getSeqProxy().value())); + tx_span::attr::sequence, static_cast(stx->getSeqProxy().value())); + // Every account the transaction names, keyed by its role + // (tx_account, tx_destination, ...). Addresses are public ledger + // identifiers and go out raw; see TxAccountSpanNames.h. A present + // but empty account field is skipped rather than rendered as the + // all-zero address. + for (auto const& field : *stx) + { + if (field.getSType() != STI_ACCOUNT || field.isDefault()) + continue; + if (auto const key = accountFieldAttributeKey(field.getFName())) + span->setAttribute(*key, field.getText()); + } } } diff --git a/src/xrpld/overlay/detail/ProtocolVersion.cpp b/src/xrpld/overlay/detail/ProtocolVersion.cpp index 74dad61828..d24928a4ec 100644 --- a/src/xrpld/overlay/detail/ProtocolVersion.cpp +++ b/src/xrpld/overlay/detail/ProtocolVersion.cpp @@ -151,4 +151,12 @@ isProtocolSupported(ProtocolVersion const& v) return std::end(kSupportedProtocolList) != std::ranges::find(kSupportedProtocolList, v); } +ProtocolVersion +newestSupportedProtocolVersion() +{ + // Scans rather than reading the sorted list's last entry, so it does not + // depend on an invariant kept elsewhere. + return *std::ranges::max_element(kSupportedProtocolList); +} + } // namespace xrpl diff --git a/src/xrpld/overlay/detail/ProtocolVersion.h b/src/xrpld/overlay/detail/ProtocolVersion.h index 5c05f63e2a..a4342c2453 100644 --- a/src/xrpld/overlay/detail/ProtocolVersion.h +++ b/src/xrpld/overlay/detail/ProtocolVersion.h @@ -68,4 +68,13 @@ supportedProtocolVersions(); bool isProtocolSupported(ProtocolVersion const& v); +/** + * The version negotiated with a peer that speaks everything we speak, so also + * the one that enables every version-gated feature. + * + * @return The largest version in the list of supported protocol versions. + */ +ProtocolVersion +newestSupportedProtocolVersion(); + } // namespace xrpl diff --git a/src/xrpld/rpc/detail/PathFindSpanAttributes.h b/src/xrpld/rpc/detail/PathFindSpanAttributes.h new file mode 100644 index 0000000000..52d1996439 --- /dev/null +++ b/src/xrpld/rpc/detail/PathFindSpanAttributes.h @@ -0,0 +1,66 @@ +#pragma once + +/** + * Helpers that set path-finding span attributes from RPC request fields. + * + * The path_find and ripple_path_find handlers both record the request's + * source and destination accounts on the pathfind.request span. The value + * comes from client input, so it is emitted only when it parses as an + * r-address; a malformed or mistaken value never reaches the span. The + * address itself is a public ledger identifier and is emitted raw (see the + * attribute docs in PathFindSpanNames.h). + * + * doPathFind() / doRipplePathFind() + * │ params[jss::source_account], read through a const json::Value + * ▼ + * setAccountAttribute(span, key, field) (this header) + * │ parseBase58: nullopt -> nothing emitted + * ▼ + * span.setAttribute(key, toBase58(account)) + * + * @code + * // Primary use, inside the span-live guard of a handler: + * auto const& params = std::as_const(context.params); + * pathfind_span::setAccountAttribute( + * span, pathfind_span::attr::sourceAccount, params[jss::source_account]); + * @endcode + * + * @code + * // Edge cases: a missing field (null), a non-string, or a string that is + * // not an r-address all leave the span untouched. + * pathfind_span::setAccountAttribute(span, key, json::Value{}); + * pathfind_span::setAccountAttribute(span, key, json::Value{"not an address"}); + * @endcode + * + * @note Not a hot path: one Base58 decode per account per RPC call, and only + * while the span is live. Thread-safe; it holds no state. + */ + +#include +#include +#include + +#include + +namespace xrpl::telemetry::pathfind_span { + +/** + * Set an account attribute on a path-finding span when the request field + * holds an r-address. + * + * @param span The live pathfind.request span. + * @param key The attribute key, pathfind_source_account or + * pathfind_dest_account. + * @param field The request parameter. Read it through a const json::Value so + * a missing key is not inserted into the request. + */ +inline void +setAccountAttribute(ScopedSpanGuard& span, std::string_view key, json::Value const& field) +{ + if (!field.isString()) + return; + if (auto const account = parseBase58(field.asString())) + span.setAttribute(key, toBase58(*account)); +} + +} // namespace xrpl::telemetry::pathfind_span diff --git a/src/xrpld/rpc/detail/PathFindSpanNames.h b/src/xrpld/rpc/detail/PathFindSpanNames.h index 47376f7e8b..5e5a71f811 100644 --- a/src/xrpld/rpc/detail/PathFindSpanNames.h +++ b/src/xrpld/rpc/detail/PathFindSpanNames.h @@ -80,10 +80,20 @@ inline constexpr auto discover = makeStr("discover"); namespace attr { /** * "pathfind_source_account" — originating account for path search. + * + * Emitted as the raw r-address, not hashed. An account address is a public + * ledger identifier drawn from an enumerable set, so an unsalted hash of it + * is reversible by lookup and protects nothing; it only breaks the join + * against explorers, RPC responses and logs that show the same address. + * Only a value that parses as an r-address is emitted, so a malformed or + * mistaken request value never reaches the span. Do not add redaction here + * or in a collector processor. */ inline constexpr auto sourceAccount = makeStr("pathfind_source_account"); /** * "pathfind_dest_account" — destination account. + * + * Raw r-address, for the same reason as pathfind_source_account. */ inline constexpr auto destAccount = makeStr("pathfind_dest_account"); /** @@ -109,7 +119,10 @@ inline constexpr auto numRequests = makeStr("pathfind_num_requests"); */ inline constexpr auto ledgerIndex = makeStr("pathfind_ledger_index"); /** - * "pathfind_dest_currency" — destination currency code. + * "pathfind_dest_currency" — destination asset as rendered by to_string(Asset): + * "XRP", "/" for an IOU, or the 48-hex-char + * issuance id for an MPT (its last 20 bytes are the issuer's account id). + * The issuer is a public identifier and is not hashed. */ inline constexpr auto destCurrency = makeStr("pathfind_dest_currency"); /** diff --git a/src/xrpld/rpc/detail/PathRequest.cpp b/src/xrpld/rpc/detail/PathRequest.cpp index 888546ad0a..cbcd3d5a7c 100644 --- a/src/xrpld/rpc/detail/PathRequest.cpp +++ b/src/xrpld/rpc/detail/PathRequest.cpp @@ -40,7 +40,6 @@ #include #include #include // IWYU pragma: keep -#include #include #include @@ -799,21 +798,11 @@ PathRequest::doUpdate( if (span) { span.setAttribute(pathfind_span::attr::fast, fast); - // to_string(Issue) renders a non-XRP asset as "/" with - // the issuer as a plaintext Base58 address, so it cannot be emitted - // as-is: every account reaching a span is hashed first. Redact just the - // issuer and keep the currency, which is what this attribute is for. An - // MPT asset renders as its issuance ID and carries no address, so it - // needs no redaction. - span.setAttribute( - pathfind_span::attr::destCurrency, - saDstAmount_.asset().visit( - [](Issue const& issue) { - return isXRP(issue.account) - ? to_string(issue.currency) - : redactAccount(toBase58(issue.account)) + "/" + to_string(issue.currency); - }, - [](MPTIssue const& mpt) { return to_string(mpt.getMptID()); })); + // to_string(Asset) renders XRP as "XRP", an IOU as "/" + // with the issuer's Base58 address, and an MPT as its issuance id. The + // issuer is a public ledger identifier, so the asset is emitted as + // rendered (see the attribute docs in PathFindSpanNames.h). + span.setAttribute(pathfind_span::attr::destCurrency, to_string(saDstAmount_.asset())); } JLOG(journal_.debug()) << iIdentifier_ << " update " << (fast ? "fast" : "normal"); diff --git a/src/xrpld/rpc/handlers/orderbook/PathFind.cpp b/src/xrpld/rpc/handlers/orderbook/PathFind.cpp index af15403030..a4323b9e00 100644 --- a/src/xrpld/rpc/handlers/orderbook/PathFind.cpp +++ b/src/xrpld/rpc/handlers/orderbook/PathFind.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include @@ -10,7 +11,6 @@ #include #include #include -#include #include #include @@ -25,26 +25,23 @@ doPathFind(rpc::JsonContext& context) // thread) nest under it. doPathFind does not yield, so scoping is safe. auto span = ScopedSpanGuard( TraceCategory::Rpc, pathfind_span::prefix::pathfind, pathfind_span::op::request); - // Guarded on the span being live because setAttribute's arguments are - // evaluated whatever the build, and neither is free: asString() copies the - // address out of the JSON and redactAccount() takes a SHA-512Half over it. - // That is two copies and two hashes on every path_find call. The - // compiled-out guard's operator bool() is a literal false, so the block - // disappears entirely in that build; with telemetry compiled in it is - // skipped when telemetry is disabled at runtime or the category is off. + // Guarded on the span being live because the account parse below is not + // free and runs on every path_find call otherwise. The compiled-out + // guard's operator bool() is a literal false, so the block disappears + // entirely in that build; with telemetry compiled in it is skipped when + // telemetry is disabled at runtime or the category is off. if (span) { - // Addresses are hashed before emission for privacy. Read through a - // const reference: the non-const json::Value::operator[] inserts a null - // for a missing key, which would make PathRequest::parseJson's - // isMember() checks see an absent field as present and return Malformed - // instead of Missing. Reading for telemetry must not alter what the - // request looks like. + // Read through a const reference: the non-const json::Value::operator[] + // inserts a null for a missing key, which would make + // PathRequest::parseJson's isMember() checks see an absent field as + // present and return Malformed instead of Missing. Reading for + // telemetry must not alter what the request looks like. auto const& params = std::as_const(context.params); - if (auto const& src = params[jss::source_account]; src.isString()) - span.setAttribute(pathfind_span::attr::sourceAccount, redactAccount(src.asString())); - if (auto const& dst = params[jss::destination_account]; dst.isString()) - span.setAttribute(pathfind_span::attr::destAccount, redactAccount(dst.asString())); + pathfind_span::setAccountAttribute( + span, pathfind_span::attr::sourceAccount, params[jss::source_account]); + pathfind_span::setAccountAttribute( + span, pathfind_span::attr::destAccount, params[jss::destination_account]); } // A failed reply carries the rpc error token, so reading the status off the diff --git a/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp b/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp index 9abe40cdcc..944b442299 100644 --- a/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp +++ b/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -14,7 +15,6 @@ #include #include #include -#include #include #include @@ -34,26 +34,23 @@ doRipplePathFind(rpc::JsonContext& context) // span's log lines stay trace-correlated. auto span = ScopedSpanGuard( TraceCategory::Rpc, pathfind_span::prefix::pathfind, pathfind_span::op::request); - // Guarded on the span being live because setAttribute's arguments are - // evaluated whatever the build, and neither is free: asString() copies the - // address out of the JSON and redactAccount() takes a SHA-512Half over it. - // That is two copies and two hashes on every ripple_path_find call. The - // compiled-out guard's operator bool() is a literal false, so the block - // disappears entirely in that build; with telemetry compiled in it is - // skipped when telemetry is disabled at runtime or the category is off. + // Guarded on the span being live because the account parse below is not + // free and runs on every ripple_path_find call otherwise. The compiled-out + // guard's operator bool() is a literal false, so the block disappears + // entirely in that build; with telemetry compiled in it is skipped when + // telemetry is disabled at runtime or the category is off. if (span) { - // Addresses are hashed before emission for privacy. Read through a - // const reference: the non-const json::Value::operator[] inserts a null - // for a missing key, which would make PathRequest::parseJson's - // isMember() checks see an absent field as present and return Malformed - // instead of Missing. Reading for telemetry must not alter what the - // request looks like. + // Read through a const reference: the non-const json::Value::operator[] + // inserts a null for a missing key, which would make + // PathRequest::parseJson's isMember() checks see an absent field as + // present and return Malformed instead of Missing. Reading for + // telemetry must not alter what the request looks like. auto const& params = std::as_const(context.params); - if (auto const& src = params[jss::source_account]; src.isString()) - span.setAttribute(pathfind_span::attr::sourceAccount, redactAccount(src.asString())); - if (auto const& dst = params[jss::destination_account]; dst.isString()) - span.setAttribute(pathfind_span::attr::destAccount, redactAccount(dst.asString())); + pathfind_span::setAccountAttribute( + span, pathfind_span::attr::sourceAccount, params[jss::source_account]); + pathfind_span::setAccountAttribute( + span, pathfind_span::attr::destAccount, params[jss::destination_account]); } // A failed reply carries the rpc error token, so reading the status off the diff --git a/src/xrpld/telemetry/TxSpanNames.h b/src/xrpld/telemetry/TxSpanNames.h index 1b54f3371b..64049da6c9 100644 --- a/src/xrpld/telemetry/TxSpanNames.h +++ b/src/xrpld/telemetry/TxSpanNames.h @@ -85,6 +85,9 @@ inline constexpr auto fee = makeStr("fee"); * "sequence" — transaction sequence number. */ inline constexpr auto sequence = makeStr("sequence"); +// The per-role account keys (tx_account, tx_destination, ...) that tx.process +// also carries live in , in libxrpl, so a +// library test can check them against TxFormats. /** * "ter_result" — engine result code after application. */