Repository navigation
Hybrid post-quantum v2 transport (#184) — integration - #233
Conversation
feature/pq-transport collects the #184 hybrid post-quantum transport work before it goes to 1.x.x. Add it to the branch lists of Full Validation, Core Checks and the Required Merge Gate, so pull requests into it and merges to it get the same validation as 1.x.x. Remove the entry when the branch merges into 1.x.x. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pull request build of the development RPC docs checked out refs/pull/<n>/merge and then required it to be the event's merge commit. That ref moves whenever the base branch or the pull request's stack changes, so a run that overlapped such a change checked out a different merge commit and failed "Validate version source checkout". Check out the event's merge commit by hash instead, as the release entries already do, and drop the now unused --development-checkout-ref plumbing. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…explicit rejection (#223) * net: support bounded interruptible SAM session creation Proxy::Connect() waits up to -timeout for a connection and does not watch any interrupt, so a caller that has to finish several dependent steps by one deadline, and stop promptly on shutdown, cannot use it. Add a Proxy::Connect(deadline, interrupt) overload for that. It waits for the connection in slices of at most MAX_CONNECT_POLL_INTERVAL (100 ms), rechecking the interrupt and the deadline between them, and creates no socket if either is already over. It does not use nConnectTimeout; callers cap the deadline as they need. Proxy::Connect() and ConnectDirectly() behave as before. The new netbase tests use a socket whose connect stays in progress to check that the overload connects, gives up at the deadline rather than at -timeout, notices an interrupt within one slice, never waits longer than a slice, and that the existing overload still waits once for -timeout. TCP and Unix socket proxies are both covered. Part of #185. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * i2p: prefer hybrid leasesets and retry explicit router rejection once Backport Bitcoin Core #35696: ask the SAM router for the post-quantum hybrid leaseset encryption type, i2cp.leaseSetEncType=6,4 (MLKEM768-X25519, then ECIES-X25519), instead of 4,0, for persistent and transient sessions. Java I2P 2.10.0 or newer and i2pd 2.58.0 or newer built with OpenSSL 3.5 or newer use it; other i2pd versions ignore it. Java I2P older than 2.10.0 fails the session instead. qbit-only, as upstream has no retry: on an explicit rejection of 6,4, a well-formed SESSION STATUS whose RESULT is present, not OK and not one of DUPLICATED_ID, DUPLICATED_DEST, INVALID_ID or INVALID_KEY, close that control socket and retry once on a fresh connection with a fresh HELLO and session id, keeping the persistent destination, with 4,0. A SAM reply with RESULT=TIMEOUT is such a rejection and is retried. No reply (a timeout waiting for one, EOF, a socket error), a malformed reply, a missing RESULT or a lost success reply is never retried. The original rejection is logged at debug level with RESULT and a sanitized MESSAGE capped at 200 characters. m_session_id and m_control_sock are set only on success. Only a rejection whose MESSAGE names the encryption type is evidence that the router lacks 6,4. Java I2P 2.9.0 replies "... Failed to build tunnels - ...: Unsupported crypto type: 6". The MESSAGE, parsed like the logged one but before it is sanitized or capped, names the type if it contains "crypto type", "encryption type", "enc type" or "enctype", ASCII case-insensitively. A process-lifetime, mutex-protected record per router (its TCP endpoint or Unix socket path), shared by all sessions, counts the sessions in a row in which the router rejected 6,4 naming the encryption type and accepted 4,0. After two, later sessions through it start with 4,0 and never retry, until qbit restarts. A session created with 6,4 starts the count anew, so one transient rejection does not turn 6,4 off, and a failed retry counts for nothing. The record keeps at most 64 routers and forgets the least recently updated one. At the first such session through a router, this is logged once at warning level, with RESULT shown only if it is one of the SAM protocol's RESULT values, otherwise "other": I2P: the router rejected leaseSetEncType=6,4 (RESULT=I2P_ERROR) and accepted 4,0, so this session has no post-quantum leaseset. Java I2P older than 2.10.0 does this; upgrade the router to get it. An info line says when later sessions through the router start using 4,0 until qbit restarts. A rejection that does not name the encryption type, such as a passing failure to build tunnels, RESULT=TIMEOUT or CANT_REACH_PEER, is retried the same way but is no evidence either way: it neither counts nor starts the count anew, and does not warn. One info line says that the session uses 4,0 because of that rejection, with RESULT shown as in the warning and the sanitized MESSAGE, and that the next session asks for 6,4 again. Session creation now has one deadline, SAM_CREATE_TIMEOUT (180 s), over connecting, HELLO, the optional DEST GENERATE and both SESSION CREATE attempts, through new Hello() and SendRequestAndGetReply() overloads that take it. Each connect uses Proxy::Connect(deadline, interrupt), capped by -timeout as before, so shutdown interrupts it promptly. Other SAM requests behave as before. Session constructors take an optional creation timeout for tests. An unexpected reply to SESSION CREATE is now reported with the sanitized request, "SESSION CREATE ...", instead of the raw one, which carried the private key. The new i2p tests use a recording mock SAM router to cover creation with 6,4, the retry, the two-sessions-in-a-row rule and its reset, rejections that name the encryption type (with the reply of Java I2P 2.9.0) and ones that do not, which are retried but neutral, the phrase matching, the bounded record, sessions through a remembered router including concurrent ones, rejections that are not retried, a failing retry, transport failures and lost replies, interruption during reconnect, HELLO and CREATE, an exhausted deadline across steps, during DEST GENERATE and while connecting, the -timeout cap on connecting, and that the persistent private key is never logged. Deadline checks use the timeouts the mock router saw rather than wall-clock margins. Tests and the i2p fuzz target forget the router record between runs. The existing tests are unchanged. Part of #185. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * doc: document I2P hybrid router requirements and compatibility retry State in i2p.md which routers use the post-quantum leaseset type that qbit now asks for, that Java I2P older than 2.10.0 rejects it naming the encryption type, and how qbit retries once with 4,0 and warns at the first such session through a router. After two such sessions in a row, qbit uses 4,0 with that router until qbit restarts; a session that gets the post-quantum type starts the count anew. Other rejections, a SAM RESULT=TIMEOUT reply among them, are retried but neither count nor warn. Tell users to restart qbit after upgrading the router. Note that the retry is qbit-only, that no reply from the router is not retried, and the single 3-minute limit on creating a session. Add a release-note fragment, to be renamed to the pull request number. Part of #185. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
StarvationDistance() cast nanobench's complexityN() to size_t. It is -1 unless -asymptote sets it, so a plain bench_qbit run (including ctest's bench_sanity_check) built a header chain of about 2^64 blocks and grew until the host ran out of memory: 27 GiB before it was killed. Use complexityN() only when -asymptote sets it to at least 1, and default to 10,000 legacy blocks since the last AuxPoW block otherwise. Both Cadence benchmarks now run in about 17 MB. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
Current state, merge order and remaining work: #238. |
This effectively reverts a3cb309 from PR #30194. That PR reduced the multi_index type signatures as recommended upstream, but this is no longer supported as of boost 1.91 because it is no longer necessary. 1.91 drops support for the pre-c++11 work-arounds that bloated the type signatures to begin with. The upstream `BOOST_MULTI_INDEX_ENABLE_MPL_SUPPORT` define is meant to provide compatibility with removed features, but it does not work for this case. Using `indexed_by` directly when defining the `multi_index` (as opposed to inheriting from it) works with all versions, and avoids the use of the back-compat define. This is a slight regression when building against boost < 1.91 because the bloated type signatures are reintroduced in that case, but it's not significant enough to go to the trouble of introducing version detection and ifdefs. Github-Pull: #35175 Rebased-From: 0bc9d354dfd8074d1c36a891a69b6585a8775c65 (cherry picked from commit 9632450ff65c1fee29c2564a6ad64dfaad4d663c) Backport of bitcoin/bitcoin#35175. Co-authored-by: Cory Fields <cory-nospam-@coryfields.com>
#231) * ci: wait for a newer run instead of failing on an earlier one's result The Required Merge Gate reads the newest check run of each required name on the commit. When CI runs again on the same commit (a re-run, a reopened pull request, a label), the gate starts within seconds, before the new workflows have created their own gate checks. It then found the previous run's result and, if that run had been cancelled or failed, failed at once, although the new run was still on its way. PR #229's gate failed this way three seconds after a reopen. Treat a check that concluded other than success before this gate run started as pending: it belongs to an earlier run, and a newer one may not have reported yet. The gate keeps polling and fails, with the reason named, only if no newer result arrives before its deadline. A failure after the gate run started still fails at once, and an earlier success still counts, as a label starts no new Core Checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: date the merge gate from its workflow run, not its step The gate step starts only after the jobs it needs, so a check from the current run can fail before the step's own start time and be taken for an earlier run's result, making the gate wait out its deadline instead of failing at once. Read this run attempt's run_started_at from the API instead, and only when checks are polled, so profiles that poll nothing still make no API calls. If it can't be read, fall back to the step's start with a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: never take a cancelled check as the merge gate's verdict A newer push, reopen or label cancels the running Full Validation (cancel-in-progress), and that cancellation can complete after this gate run started, so the run-start cutoff alone took it for a current failure. A cancelled check means superseded or never run, so treat it as pending and wait for the run that replaces it; the gate still fails at its deadline if none arrives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: let the merge gate read its run attempt's start time Getting a workflow run attempt needs Actions: read, and the workflow grants only contents and checks, so the start-time lookup always fell back to the step's own start. Grant actions: read, and pin it in the poller contract test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: start Core Checks on every event that starts the merge gate The gate now waits for a newer Core Checks Gate instead of trusting an earlier failure, but Core Checks ran only on opened, synchronize and reopened, so a label, unlabel or ready-for-review event left the gate waiting out its deadline for a run that could not start. Give Core Checks the same pull_request activity types as the gate and Full Validation, and pin that in the poller contract test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: tell the merge gate's checks apart by the event that started them The gate waited on a check that failed before its own run started, taking it for an earlier run's result. A time cutoff cannot tell the two apart: Core Checks, Full Validation and the gate are separate workflows, so this event's check can fail before the gate's run starts, an earlier event's can fail or be cancelled after it, and re-running the gate alone moves its start but starts nothing else. Date each failed check by its event instead. Every workflow run an event starts is created at that event, and a re-run keeps the run, its check suite and its created_at. The gate maps a failed check's check suite to its workflow run (GET /actions/runs?check_suite_id=) and compares that run's created_at with its own run's (GET /actions/runs/{run_id}), allowing 60 seconds between one event's runs. - An earlier event's failure is waited on until a newer run reports. - A failure from this event or a later one fails at once, including after a gate-only re-run. - A success counts whichever event produced it, and a cancellation is never a verdict; neither needs a lookup. - If either run can't be read after the usual retries, the gate fails closed under its own error title. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: let the newest event's check decide, not the newest-started one The gate judged only the newest check run of each required name by started_at. A manual re-run of an earlier event's workflow starts later than this event's check, so it hid this event's result: a failed old re-run was taken as an earlier event's failure and waited on, poll after poll, until the deadline, even with this event's success already on the commit. Read every check run of the name instead. When they come from more than one workflow run and not all of them succeeded, map each check suite to its workflow run (cached per suite) and let the newest workflow run's check decide, by created_at, then run_number for runs created in the same second, then started_at for its attempts. - An earlier event's re-run never outranks this or a later event's result, whether it failed, passed, was cancelled or is still running. - Attempts within one workflow run still rank by started_at alone, and a check whose runs all succeeded passes, both without a lookup. - If the runs to rank can't be read, or a check run names no check suite, the gate fails closed under its "could not correlate" title. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: tell close events apart by a newer run of the check's workflow The gate took any failure whose workflow run was created within 60 s of its own as this event's. Two events that close together (a label, then an unlabel or a reopen) made the second gate fail at once on the first event's result. That is the usual case, not an edge one: the newer event cancels the earlier event's Core Checks and Full Validation, and their if: always() gate jobs then conclude failure (7 of 8 cancelled Core Checks runs sampled here did), after the newer event has started. GitHub's REST API names no event on a workflow run, check suite or check run, so there is no marker to compare. It does give each workflow's runs a run_number that grows with every new run and is kept by re-runs. Before failing at once, the gate now lists the check's workflow's runs on the commit (GET /actions/workflows/{id}/runs ?head_sha=): if one has a higher run_number, an event since has started the run that replaces this one, and the failure is waited on. That tells events apart at any spacing, even within the same second. Tighten the allowance from 60 s to 10 s. Across 325 pull request events on this repository (June to October 2026), one event's runs were created within 4 s of each other, and the gate's up to 3 s after another's, so exact equality would misdate a current failure; the closest two events on one commit were 28 s apart. - A current failure with no newer run of its workflow still fails at once, including after a gate-only re-run. - A current failure whose workflow already has a newer run, from a later event, is waited on, and that run's result decides. - A newer run, once listed, is cached; a listing that can't be read, or lacks the check's own run, fails closed. - Left open: two events within 10 s where the earlier one's check fails and the later one's run is not yet listed when the gate judges it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…leases, v1.0.0 interop, PQ CI, canary tools) (#224) * test: add an independent ML-KEM-1024 Python reference Add test_framework/crypto/mlkem.py, a pure-Python FIPS 203 ML-KEM-1024 written from the standard and independent of the C library: KeyGen from a 64-byte d || z seed, Encaps from a 32-byte m, Decaps with implicit rejection, and the section 7.2 (length and modulus) and 7.3 (length and hash) input checks. Hashing uses hashlib's SHA3 and SHAKE only. TestMLKEM1024, registered in feature_framework_unit_tests.py, checks: - NIST ACVP-Server ML-KEM-1024 keyGen and encapsulation vectors (exact ek, dk, ct and shared secret); - ACVP decapsulation vectors, including modified ciphertexts whose expected secret is the implicit-rejection value J(z || ct); - ACVP encapsulationKeyCheck vectors: valid keys pass the section 7.2 check and encapsulate, the others are rejected; - C2SP CCTV modulus vectors (q and 4095 at the first and last coefficient of every polynomial, plus a sample of the full run), each rejected, and accepted once the bad coefficient is set to 3328; - round trips, implicit rejection on corrupted ciphertexts, and input size and hash checks. mlkem1024_test_vectors.txt holds the subset with its sources and pinned commits in a header comment. The new tests take about 0.07 s; the whole feature_framework_unit_tests.py run about 4 s. Part of #184 (part 2, #186, work item B2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: identify qbit releases independently of Core versions Let functional tests download and run qbit release binaries the way they run Bitcoin Core releases, starting with v1.0.0. get_previous_releases.py resolves each tag through resolve_download(). qbit tags come from explicit descriptors: the GitHub release URL, the archive per host (the "-unsigned" tarballs on macOS, whose -unsigned.zip holds only the GUI app, and the "-unsigned" zip on win64), the archive root and each archive's SHA256 copied from the release's SHA256SUMS. Unsigned arm64 macOS binaries are self-signed as old Core ones are. A host without a qbit archive fails with a message instead of a guessed download. Bitcoin Core URLs, archives, hashes and messages are unchanged, and qbit tags join the default download list, so the previous-releases and Windows jobs fetch v1.0.0. The test framework maps client versions through explicit ReleaseInfo metadata (test_node.py): 10000 is qbit v1.0.0, based on Core v30.2 (core_compat_version 300200), with qbitd/qbit-cli binary names. A version from 140000 (Core v0.14) up is the Core release it names, as before; any lower version not in QBIT_RELEASES is an unknown qbit release and raises, instead of being read as a Core version. version_is_at_least() compares core_compat_version, so v1.0.0 nodes get -v2transport and the logging options, and qbit_version_is_at_least() gates qbit-only options: -p2mronly=0 now reaches qbit releases that have it, and supports_v2_pq marks which nodes may get -v2pqtransport. tool_qbit_release_resolution.py (qbit_release_resolution) checks v1.0.0 on every published host triple including win64 against a verbatim copy of SHA256SUMS, the Core mappings, the rejection of unknown qbit versions, and the options a v1.0.0, Core and current node get. It needs no downloads, so it runs in every functional job, Linux and Windows. Part of #184 (part 2, #186, work item B1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: baseline v1.0.0 v2 transport interoperability Add p2p_v2_previous_release.py, which pins how the qbit v1.0.0 release binary treats non-empty BIP324 version-packet contents, where the hybrid post-quantum handshake (#184) will carry its ML-KEM-1024 key and ciphertext. It drives a BIP324 peer on a plain socket, so it controls every byte and pause, against v1.0.0 in both roles: - test_version_contents: with 0, 1,572 and 65,000 bytes of contents, and with record-shaped contents (CompactSize(len) || header || payload): one 0xF0 record exactly as the hybrid offer and accept carry it (fd 21 06 f0 || 1,568 bytes), a 0xF0 record followed by an unknown 0xF1 record, and a single 65,000-byte record, each sent after three decoy packets, both immediately and after a 10 s pause under the default 60 s -peertimeout, v1.0.0 completes the handshake, exchanges version/verack and ping/pong, sends empty contents itself, and reports the plain BIP324 session id. As initiator it sends its version packet and VERSION message before receiving ours. - test_limits: v1.0.0 keeps a connection whose packet length is exactly MAX_CONTENTS_LEN (4,000,013), drops one at 4,000,014 bytes at once ("packet too large"), and drops a handshake stalled after the keys at -peertimeout ("V2 handshake timeout"), within a bounded time. The test skips without previous releases and runs in the previous-releases job, which downloads v1.0.0 since the previous commit. It takes about 24 s. Part of #184 (part 2, #186, work item B3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: add reusable maintained-branch validation entry points Add .github/workflows/ci-pq.yml, one reusable workflow for the post-quantum transport jobs, and make ci-nightly-heavy.yml callable and job-selectable, so main's callers (C4), on a schedule and on demand, can run either against a recorded commit. GitHub dispatches only workflows whose file is on the default branch, so on-demand PQ runs go through C4's caller on main (workflow_dispatch with source_ref and jobs, calling ci-pq.yml@1.x.x), available after this part merges; ci-pq.yml keeps its own workflow_dispatch trigger for when it is on main. ci-nightly-heavy.yml is already on main, so it can also be dispatched on a branch directly. ci-pq.yml runs on workflow_call and workflow_dispatch with two inputs: - source_ref: resolved once by the resolve-source job to a full commit (empty: the triggering commit; a branch or tag, looked up on the remote with annotated tags peeled; or a full hash). A missing, ambiguous or malformed ref fails the run. Every job checks out that commit, verifies HEAD against it, and the summaries record it. - jobs: a comma-separated allowlist; empty runs all jobs, and an unknown, repeated or empty name fails the run. The resolve-source job emits the matrix, since a job-level if cannot see matrix values. Its first job, aarch64-unit, runs on GitHub's ubuntu-24.04-arm runner with the public-runner environment (docker.io/library images, CI_ENFORCE_INTERNAL_REGISTRY=0, public DNS, no tailnet search domains), builds only test_bitcoin and runs bip324_tests and net_tests (ci/test/00_setup_env_native_aarch64_pq.sh). Its summary shows the source commit, labels this the pre-library phase, and lists both suites with their results. ci/test/03_test_script.sh: - CI_BUILD_TARGET (default all) selects the target of the build and of its verbose retry; GOAL's install/deploy targets apply to full builds; - CTEST_REGEX selects tests by name and is rejected together with CTEST_INCLUDE_RANGE; - ctest runs with --no-tests=error, lists the selected tests first and writes JUnit results; when CTEST_EXPECTED_SUITES is set, ci/checks/ctest_evidence.py fails the job unless every expected suite was selected, executed and passed (ctest itself passes a skipped suite, for example one compiled out). ci-nightly-heavy.yml gains workflow_call inputs mirroring workflow_dispatch (choices become strings, which run-scanners.py validates), an optional LIBBITCOINPQC_READ_TOKEN secret, and a jobs input over arm32-unit-1, arm32-unit-2, previous-releases, fuzz and scanner. The matrix entries are unchanged and now come from resolve-source. A called run gets its caller's github context, so github.event_name, github.ref and github.sha are main's and cannot choose what a called run validates. Only workflow_call takes a source_ref input (default 1.x.x, must not be empty), and a non-empty source_ref marks a run as called: - a called run resolves source_ref once, with the same function as ci-pq.yml (which the contract test keeps identical), never reads github.ref or github.sha, and records source_mode maintained when source_ref names 1.x.x and requested otherwise; it honors run_test_matrix, run_scanners, the scanner settings and jobs whatever its caller's event; - a direct scheduled run still resolves 1.x.x and follows the CI_NIGHTLY_HEAVY_RUN_* variables and scheduled defaults, and a direct dispatch keeps its selected ref and event commit; - a first step fails the run when source_ref disagrees with GitHub's job.workflow_ref, for example a caller that passes an empty source_ref, and skips that check, saying so in its summary, where job.workflow_ref is unavailable; - a called run's concurrency group names its caller's event and source_ref, so callers for different sources, and an on-demand call beside the scheduled one, never cancel each other; - the summaries record the source_ref input and source_mode. ci/checks/test_ci_pq_contract.py executes the workflows' inline steps and the 03_test_script.sh blocks against git fixtures and stub cmake/ctest: job selection, rejection of unknown jobs, an immutable source commit, suites that really ran, and called and direct nightly runs with their concurrency groups. The shared expression evaluator follows GitHub's loose equality and supports format() and toJSON(). test_scheduled_validation_contract.py reads the nightly matrix from its new catalog. Both now run in required CI: the Required Merge Gate's classify-changes job (.github/workflows/required-merge-gate.yml) gains one step that runs them, beside the classifier contract. Part of #184 (part 2, #186, work item C1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * tools: add PQ canary sampling and evidence evaluation Scripted evidence for the mainnet canary of the hybrid post-quantum v2 transport (#184, R5), ready before the 14-day R0 baseline starts on v1.0.0 nodes. Everything lives in contrib/pq-canary/. pq-canary-sample.sh runs every 5 minutes from cron or a systemd timer on each canary node and the control node. Through qbit-cli it appends one row to samples.csv: time, host label, uptime, version; enabled, instance_id, backends, since, every per-direction handshake counter, load_shedding.active and each ring's last_sequence and dropped from getpqtransportinfo; connections_in and connections_pq; and, on the pool node (--pinned), the pinned archive node's getpeerinfo connection_type, transport_pq and transport_pq_status. sample_ok is 1 only when uptime, getnetworkinfo and, on the pool node, getpeerinfo answered. A failed call or a missing field is NA, never 0, and a real 0 stays 0, so a v1.0.0 node writes NA for the new fields with sample_ok=1. The raw recent_failures and fallback_set go to failures.jsonl, which keeps its addresses between the team's canary nodes. --pinned and --host are validated. pq_canary.py holds the format shared with the evaluator. pq-canary-eval.py prints each figure with its sample count, coverage and pass, fail or unknown: the pinned link manual and hybrid in at least 99% of samples excluding restarts (with the disconnectnode remedy when an automatic connection holds the address); no malformed_record, first_packet_failed or fallback entry between known-good endpoints, on every monitored canary node (one node's failures file cannot pass it); closed_after_switch entries between them, listed for triage; and the archive node's connections_in within +-20% of the control's, relative to their baseline ratio, per UTC day, excluding 48 hours after a restart. A jump in boot time is a restart gap. A ring that wrapped stays judged while the entries read continue from the last sequence processed; only a wrap that evicted unread entries, a changed instance_id or an inconsistent ring makes that ring's interval unknown. A figure, node or day below --min-coverage (default 90% of the expected 5-minute samples) is unknown, not judged, and every figure reports its coverage. Missing evidence is unknown, never clean. The report names known-good peers by label and never prints a peer address, so it can be posted. Unknown, malformed or missing arguments are rejected with a message and exit status 2. Tests: contrib/pq-canary/test/test_pq_canary.py (stdlib only, fixtures in the #184 getpqtransportinfo schema) covers, among others, a monitored outbound failure surviving 300 unrelated inbound failures, unrelated noise, a restart gap, an RPC outage, a wrapped ring with and without lost entries, per-node evidence, coverage thresholds, address redaction and argument validation. The Required Merge Gate's PQ contract step (.github/workflows/required-merge-gate.yml) now runs it as well, and test/functional/tool_pq_canary.py runs the real sampler against the node under test and, when previous releases are present, a v1.0.0 node. The README covers install, the cron lines and how to read the output. Part of #184 (part 2, #186, work item C5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * contrib: treat missing PQ canary fallback-set evidence as unknown The evaluator read a missing or malformed fallback_set as an empty list, so a sample without fallback evidence could pass Figure 2. Record it as an unknown interval for that host instead, as for malformed rings, and iterate fallback_set only when it is a list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * contrib: treat PQ canary records without an instance_id as unknown A missing or malformed instance_id was compared like a real process identity, so a restart between samples whose new ring did not go backwards went unnoticed and lost failures could pass Figure 2. Mark both rings unknown from the previous sample through the next record with a valid 64-hex-digit instance_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * contrib: treat PQ canary entries with malformed endpoints as unknown A ring entry whose endpoint was missing or malformed got no known-good label and was skipped like a valid unrelated peer, so a fallback, malformed_record or first_packet_failed entry without endpoint telemetry could still let the failure figure pass. Check that an endpoint has the getpqtransportinfo Endpoint shape: a kind and network that agree, an address of that network without a port, and a port from 0 to 65535. An entry that counts towards figure 2 or 3 and has neither a known-good nor a well-formed endpoint now makes that figure unknown. A malformed endpoint that still names a known-good peer remains that peer's failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * contrib: reject PQ canary samples that compare a node with itself Giving the same samples.csv for --archive and --control, or two files with the same host label, made figure 4 compare a node with itself: a perfect ratio that could pass at full coverage. The same applied to --pool and --archive, which also set the nodes monitored for figure 2. Require --pool, --archive and --control to be three different nodes. A file given for two roles, two roles sharing a host label or an instance_id, and a file whose rows carry no host label or more than one are bad input: the evaluator exits with status 2 and names the roles. The node test gave one node's samples as both pool and archive; give it the pool samples alone and check the rejection instead. The unit tests' simulated nodes now get distinct instance_ids, as real nodes do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: leave qbit releases without a host archive out of the default list On powerpc64le, a host set_host() recognizes, running get_previous_releases.py without tags now always included v1.0.0, which has no powerpc64le archive, so the default download stopped with "No qbit v1.0.0 archive". Without tags, download every Core tag as before but only the qbit releases that have an archive for the host, and say which were skipped. A tag given explicitly still fails when it has no archive for the host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * contrib: treat malformed PQ canary failure evidence as unknown Audit every failures.jsonl field the evaluator reads, so that a missing, malformed or contradictory value makes its stretch unknown rather than clean, and never crashes the evaluator into a "fail" exit status: - recent_failures that is not an object, and ring counters or sequences that are booleans, negative or zero, are a malformed ring (a non-object recent_failures raised AttributeError); - an entry outcome outside the getpqtransportinfo vocabulary may be a monitored or a triage one, so both figures are unknown (it was skipped as clean; an object outcome raised TypeError); - an entry time that is missing, malformed, before its process started or more than CLOCK_SLACK after the sample that read it is unknown (it was read as 1970 and skipped, or raised TypeError); - a fallback-set entry that is not an object or lacks a well-formed endpoint is unknown, once per distinct entry (it was skipped); - since that is missing, malformed, after the sample or changed within one instance_id makes the record's process identity unusable, like a missing instance_id (it fell back to the sample time); - a failures.jsonl line needs a time in Unix seconds up to year 9999 and a host label, else it is bad input (a boolean time was accepted, and a huge one crashed the report); - a known-good failure whose reason, cause, connection type, peer id, streak or times are malformed is still a failure, and those fields print as "malformed": a malformed field could put an address in the report, and malformed times or unhashable values raised. Labels are now matched in full, so a host label with a trailing newline is rejected. The unit tests' simulated nodes now report only failures recorded by the time of each sample, and boot before their first sample. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * contrib: treat malformed PQ canary sample values as unknown Audit every samples.csv field the evaluator reads, so that a missing, malformed or contradictory value is unknown rather than a pass, a miss or a crash: - time, uptime and connections_in must be plain non-negative decimal integers. int() also took "-30", " 30", "3_0" and non-ASCII digits, so a negative uptime looked like a fresh restart and excluded samples from figures 1 and 4; a time such as "soon" raised ValueError. A row without a usable time is now a missing sample. --canary-start and --end must lie between 1970 and 9999, and so must a sample time, which the report prints. - A row with more or fewer fields than the header, such as a line cut short by a crash and continued by the next sample, has values in the wrong columns, so it is not a sample. - A connected pinned peer needs a connection type, transport_pq 0 or 1 and a transport_pq_status from the getpeerinfo vocabulary, with transport_pq 1 exactly when the status is hybrid. A malformed or contradictory value is unknown: it counted as a miss, and transport_pq=1 with status "pending" counted as hybrid. - Figure 4 pairs only samples with sample_ok=1. - An uptime whose boot time contradicts the one already seen for the same instance_id is unknown: it was read as a restart and excluded samples. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * contrib: write only well-typed values to PQ canary samples The sampler wrote any integer, string or boolean it found: true became a connections_in or counter of 1, a negative uptime or since was kept, and an instance_id of any shape was recorded. Write each column only when the RPC value has its contract type (counts are non-negative integers, flags booleans, backends and statuses strings, instance_id 64 lowercase hex digits); anything else is NA, which the evaluator reads as unknown. Reject --host NA, which would read back as a missing host label. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * contrib: keep one stray PQ canary value from making whole stretches unknown Two of the new unknown rules reached further than the evidence: - An uptime was checked against the first boot time seen for its instance_id. A restart between the sampler's uptime and getpqtransportinfo calls pairs the old uptime with the new instance_id once, which made every later uptime of that process unknown. Check each uptime against the median boot time of its instance_id instead, so one racy or malformed value is the only unknown. - An entry with an implausible time made figure 2 or 3 unknown even when the sample that first read it was taken long before the window. Such an entry was recorded before that sample, so it cannot be in the window. Also rename the samples() helper to sample_rows(), which evaluate_failures shadowed with its sample count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: check the release default list without the real host tool_qbit_release_resolution's default-tags case set HOST to make get_previous_releases.py act as a powerpc64le host. set_host() ignores HOST on Windows and answers win64, so on the Windows CI job the case downloaded the v1.0.0 win64 archive and failed. Stand in for set_host() so the case checks main()'s use of the host on any platform. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: skip the v1.0.0 transport baseline unless v1.0.0 is downloaded p2p_v2_previous_release.py only checked that previous releases were enabled. A releases directory that holds other releases, such as Bitcoin Core's, but not qbit v1.0.0 enables them, so the test went on and setup_nodes() aborted on the missing qbitd and qbit-cli. Skip unless both v1.0.0 binaries are present, as tool_pq_canary.py leaves out its v1.0.0 sample. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * contrib: count each expected PQ canary sample once Figure 1 counted every samples.csv row in the window, so rows repeated for one sampling slot (overlapping files, a manual run beside cron) added to its total: 95 of 100 expected samples is unknown, but the same rows recorded six times read as 99.1% hybrid and passed. Expected samples are now --interval apart from --canary-start, a sample stands for the nearest one, and every figure counts an expected sample once. Several samples for one count as the least favorable of them, so an extra sample can never improve the evidence: for the pinned link a miss, then unknown, then excluded, then hybrid; for inbound connections unknown, then excluded, then a measurement, where measurements that disagree are unknown (figure 4 kept whichever row came last). Figure 2 counted a record nearest to an expected sample just outside the window as covering one inside it; it now uses the same expected samples. A row repeated exactly is one sample, so repeated rows cannot outvote the median boot time that decides whether an uptime is a restart: one racy uptime copied a hundred times made two misses look like a restart and excluded them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: find every host qbit publishes v1.0.0 archives for get_previous_releases.py registers the arm-linux-gnueabihf and powerpc64-linux-gnu v1.0.0 archives, but set_host() had no pattern for either host, so on 32-bit ARM or big-endian POWER it exited "Not sure which binary to download" before resolve_download() saw them. Map both hosts. Each pattern for a qbit host now accepts the depends triplet given in HOST, which the script's help documents, as well as config.guess output. This also fixes the upstream-derived aarch64, riscv64 and arm64 macOS patterns, which only matched config.guess output: HOST=aarch64-linux-gnu, riscv64-linux-gnu or arm64-apple-darwin exited the same way. The ARM patterns accept only little-endian ARMv7 or ARMv8 hosts with hard float, because the v1.0.0 ARM binaries are ARMv7 VFPv3 armhf builds; an ARMv6 host still gets "Not sure which binary". powerpc64le (Core archives only) and x86_64-w64-mingw32 (win64 comes from platform.system(), not HOST) are left as they were. tool_qbit_release_resolution now runs set_host() with a mocked platform, HOST and config.guess output for every published host, and checks that each answer resolves to that host's v1.0.0 archive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: skip the v1.0.0 canary sample unless both executables exist tool_pq_canary enabled its v1.0.0 node whenever releases/v1.0.0/bin existed. get_previous_releases.py creates the release directory before extracting into it and keeps it after a failed extraction, so a stale or partial download could leave bin/ without qbitd or qbit-cli, and add_nodes() then aborted the whole test. Check both resolved executables, as p2p_v2_previous_release does, and otherwise skip only the optional v1.0.0 sample. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * contrib: pass a PQ canary day exactly 20% off its baseline ratio Figure 4 passes a day whose archive/control connections_in ratio is within 20% of the baseline ratio, inclusive. The evaluator computed the relative ratio in floats with two divisions, so a day exactly on the bound could round past it: 24/10 against a baseline of 3 came out as 0.7999999999999999, and 108/10 against 9 as 1.2000000000000002, and both failed the figure and the release gate. Compute the ratios as exact fractions, and read --baseline-ratio exactly too, since the float nearest 1.2 is below it. float() still screens the argument first, so nan, infinity and exponents outside its range are rejected as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * contrib: match a known-good IP address however it is written The failure figures look endpoints up in the known-good list by string. The node reports an IPv6 address compressed, so a list entry spelled out, such as [2001:0db8:0:0:0:0:0:5]:8333, never matched 2001:db8::5: its first_packet_failed entries were counted as another endpoint's, and Figure 2 passed at full coverage despite a failure between known-good peers. Give every IP address one spelling on both sides, the list and the node's endpoints, address-only entries included: compressed, lower case, and an IPv4-mapped IPv6 address as IPv4, which is how the node reports it. Two spellings of one entry are now "listed twice". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…d selection (#225) * crypto: vendor pinned mlkem-native without source changes Vendor mlkem-native v2.0.0 (d1b2fe782888bdb761a50336012923180be7f502): the whole upstream mlkem/ directory and LICENSE, byte for byte, in src/mlkem-native. Nothing compiles it yet. contrib/devtools/mlkem-native.pin records the commit, tag, the tree id of mlkem/ and the LICENSE blob id. contrib/devtools/update-mlkem-native.sh reproduces the tree from upstream and rewrites the pin from the fetched object ids. test/lint/mlkem-native-check.sh, run by the subtree lint, proves offline that HEAD still holds exactly the pinned tree. Lints that scan src/ ignore the vendored tree, and doc/subtrees/mlkem-native.md is the runbook. Part of #184 (part 3, #190, D1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * crypto: share x86 feature primitives and detect usable AVX2 Move the XGETBV instruction out of sha256.cpp into compat/cpuid.h as XGetBV(), next to GetCPUID(). SHA256AutoDetect keeps its own selection logic unchanged; it calls the shared primitive only. Add compat/cpu_features.{h,cpp} with a standalone answer to "can this CPU and OS run AVX2 code": the supported leaves are queried first, XCR0 is read only once leaf 1 reports OSXSAVE and AVX, and AVX2 is usable only with XMM and YMM state enabled and the leaf 7 AVX2 bit set. Unlike the SHA256 logic it does not depend on SSE4 or SHA-NI. HasMlkemX86Native() adds the other extensions that mlkem-native's x86_64 assembly executes, found by auditing the assembled object: SSSE3, SSE4.1, POPCNT and BMI2. A virtual machine can show AVX2 while hiding those, and checking AVX2 alone would then end in SIGILL. Both checks detect once and cache the result, because the ML-KEM capability hook asks about a hundred times per operation. Non-x86 targets and compilers without <cpuid.h> report false. Part of #184 (part 3, #190, D2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * crypto: require SSSE3 for the SSE4 and SHA-NI SHA256 paths SHA256AutoDetect selects the SSE4 code whenever CPUID leaf 1 reports SSE4.1, and only under that check also the 4-way SSE4.1 code and the SHA-NI code. All three execute SSSE3 instructions. An audit of the built objects finds pshufb and palignr in the SSE4 and SHA-NI code and pshufb in the 4-way code, besides SSE4.1's pinsrd, pextrd and pblendw; no SSE4.2 instruction occurs. A virtual machine can report SSE4.1 while masking SSSE3, and the self-test then dies with SIGILL at startup. Require SSSE3 as well. Under qemu-x86_64, -cpu Haswell,-ssse3 and the SHA-NI model EPYC,-ssse3 die with SIGILL before this change and select the standard implementation after it. Unmasked, Haswell still selects sse4, sse41 and avx2, and EPYC and Denverton x86_shani. This is an upstream Bitcoin Core bug, so the change is kept minimal enough to go upstream. Part of #184 (part 3, #190). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * crypto: add ML-KEM-1024 wrapper and runtime backend dispatch Build the vendored mlkem-native as ML-KEM-1024 into bitcoin_node only, behind a small C++ wrapper. Nothing in qbitd calls it yet. - src/crypto/mlkem_config.h configures the library (MLK_CONFIG_FILE): parameter set 1024, the qbit_mlkem1024 prefix, deterministic APIs only, zeroization through memory_cleanse, and a capability hook. - Native backends are chosen by qbit-owned backend headers instead of upstream's compile-time defaults, which enable x86_64 only for AVX2 compilers. No source is compiled with an ISA flag: every native entry point asks the hook, which reads only cached CPU detection and one atomic portable override. On x86_64 that detection requires every extension the assembly executes (AVX2, SSSE3, SSE4.1, POPCNT, BMI2), not only the AVX2 that upstream's capability name says. - Upstream's AArch64 single-lane Keccak entry point skips the hook, so the override could not reach it. qbit mirrors that header's dispatch glue with the check added; every other entry point checks itself. - mlkem::KeyGen, CheckPublicKey, Encaps and Decaps take fixed-size spans, map upstream's invalid-key results separately from internal failures, and clear their outputs on every failure. GetBackendNames() reports the implementation that runs. ForcePortableForTesting and InjectResultForTesting are test-only guards. - cmake/mlkem-native.cmake adds WITH_MLKEM_NATIVE=AUTO|ON|OFF and the configure summary line. The library gets core_interface's hardening flags but not qbit's C++ warnings, then APPEND_CPPFLAGS and APPEND_CFLAGS, so the MSan jobs' -U_FORTIFY_SOURCE applies to it as to the other C libraries. MSVC builds without assembly, MSan builds portable C, and AArch64 ELF targets build portable C because the upstream assembly has no BTI landing pads or GNU property note and would strip BTI/PAC marking from the binary (doc/subtrees has the evidence and the revisit trigger). ON fails where native is impossible. mlkem_tests covers NIST ACVP keygen, encapsulation, decapsulation and key checks, all 1040 C2SP CCTV modulus vectors and the 3328/3329 boundary, implicit rejection against an independent SHAKE256, injected failures, moves and wiping, and backend selection, each on the native backend and with portable C forced. ci/checks/test_mlkem_build_policy.py checks the WITH_MLKEM_NATIVE matrix, builds the library with each compiler found (core-checks requires the AArch64 cross compiler), and counts assembly calls to show that the portable override reaches every x86_64 routine. Part of #184 (part 3, #190, D3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fuzz: exercise ML-KEM validation and backend equivalence Add two fuzz targets over the mlkem:: wrapper: - mlkem: honest key generation, encapsulation and decapsulation agree; an arbitrary encapsulation key is accepted exactly when an independent modulus check passes, and a rejected one leaves no output; any ciphertext of the right length decapsulates to a secret that differs from the honest one (implicit rejection is never proof of validity); an arbitrary decapsulation key is rejected exactly when an independent SHA3-256 hash check fails, and the secret is then cleared. - mlkem_backend_diff: the detected backend and forced portable C give equal statuses and outputs for the same inputs, and the override is restored afterwards. Both honor MLKEM_FORCE_PORTABLE=1. They join the required qbit corpus with nine generated seeds each (CCTV modulus key and 3328/3329/4095 coefficients, NIST ACVP decapsulation vectors, a ciphertext bit flip). CI replays them a second time with MLKEM_FORCE_PORTABLE=1 and includes them in the mutation phase; the PQ CI contract test checks that wiring. UBSan's -fsanitize=integer checks are suppressed for the vendored tree, as for the other external libraries. Part of #184 (part 3, #190, D5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * bench: measure ML-KEM operations by active backend Benchmark ML-KEM-1024 key generation, encapsulation and decapsulation twice each: on the detected backend and with portable C forced. Like the per-implementation SHA256 benchmarks, each is named after the backend that ran, so on an AVX2 host bench_qbit -filter='MLKEM.*' lists both, and the portable guard restores auto-detection afterwards. Part of #184 (part 3, #190, D5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: add s390x and macOS arm64 PQ unit jobs Add two jobs to the PQ workflow, selectable by name like aarch64-unit: - s390x-unit cross-compiles only test_bitcoin for big-endian s390x on the x64 runner, with Ubuntu's g++-s390x-linux-gnu and the depends tree, and runs the suites through ctest with qemu-s390x as the crosscompiling emulator, so only the tests run emulated. It stays out of the merge gate. Its timeout is 180 minutes until runs are measured. - macos-arm64-unit runs natively on a GitHub-hosted macos-14 runner without Docker, asserts that uname -m is arm64, and shares the unit job's source checks and evidence report through YAML anchors. It is the job that runs native AArch64 ML-KEM, and configures with WITH_MLKEM_NATIVE=ON so that losing native code fails it. All three jobs now expect mlkem_tests besides bip324_tests and net_tests (CTEST_REGEX ^(mlkem_tests|bip324_tests|net_tests)$), and report the suites through ctest_evidence.py. The aarch64 job's description says that ML-KEM runs portable C on AArch64 Linux. The PQ CI contract test covers selecting each job, the s390x cross and emulator settings, the macOS architecture check and report, and that every expression uses a context GitHub makes available there (no runner in job env, no matrix in a job without one), which GitHub enforces when loading the file. Part of #184 (part 3, #190, C2 and the CI part of D3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: gate ML-KEM and transport changes on the aarch64 PQ job The merge-profile classifier gains a pq_aarch64_required output, true when a change touches the ML-KEM library (src/mlkem-native, src/crypto/mlkem*, cmake/mlkem-native.cmake), the v2 transport (src/bip324*, src/net.h, src/net.cpp), the CPU feature checks, the PQ workflow or its env files, or the PQ test suites and vectors. A path it cannot read also requires the job. Full Validation calls ci-pq.yml with jobs: aarch64-unit on the change itself (a pull request's merge commit) when the output is true. The gate accepts a skipped job only when the classifier says it is not required; a required job that was skipped, failed or cancelled, or a missing or unreadable classifier output, fails it. The changed-file lists that feed the classifier, in Full Validation and in the Required Merge Gate, use --no-renames, so moving a file out of a gated path still names it. ci/checks/test_pq_merge_gate_contract.py runs the real classifier, job condition, ci-pq selection step and gate script: a pull request touching src/bip324.cpp requires and runs the job, while docs-only and other source pull requests skip it and pass, and a rename out of a gated path still requires it. The Required Merge Gate runs it with the other PQ CI contracts. Part of #184 (part 3, #190, C3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: run the PQ macOS arm64 job on macos-15 GitHub is retiring the macos-14 runner image: brownouts start on 2026-10-05 and the image is removed on 2026-11-02. Move the macos-arm64-unit job to macos-15, which is also Apple Silicon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build: probe the ML-KEM target with the appended flags APPEND_CPPFLAGS and APPEND_CFLAGS reach mlkem_native's C and assembly compiles, but the architecture probes ran without them. A build that appends a target-changing flag such as -m32 was detected as x86_64, enabled the native backend, and then failed in mlkem_config.h. Run the probes with the appended flags, and probe afresh on every configure, since those flags may change between configures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: require the macOS arm64 and x86_64 PQ jobs on PQ changes No pull request ever built or ran ML-KEM's native code on macOS: the macos-arm64-unit job could only be dispatched, and nothing ran the x86_64 native code on an Intel Mac at all. Apple Silicon and Intel macOS are release targets with native code enabled. ci-pq.yml gains a macos-x86_64-unit job on GitHub's Intel image (macos-15-intel), sharing every step with macos-arm64-unit. Both build with WITH_MLKEM_NATIVE=ON, check the runner's architecture, and after the suites pass require the native backend by name from mlkem_tests/backend_selection, so a runner whose CPU lacks an extension cannot pass on portable C. Full Validation's PQ job, renamed pq-unit, now calls ci-pq.yml with aarch64-unit, macos-arm64-unit and macos-x86_64-unit, so all three are required exactly when the classifier says the change needs them. The classifier output is renamed pq_unit_required to match. On-demand runs accept the new job name; an empty jobs input runs it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: check the portable override on AArch64 and per entry point The build-policy test counted assembly calls only for the x86_64 backend, so nothing in CI showed that the portable override reaches every AArch64 assembly routine. With upstream's x1 Keccak glue in place of qbit's mirror, 221 assembly calls ran with portable C forced, and every test passed: outputs agree, and the backend name comes from the hook. The counting harness now also runs on AArch64: a static binary built with aarch64-linux-gnu-g++, from a copy of mlkem_config.h without the AArch64 ELF refusal, and run under qemu-aarch64. A negative control builds upstream's x1 glue and requires the leaked calls to show. Each entry point also runs alone on a new thread while portable C is forced, and must make no assembly call: one that skipped BeginOperation() would run on the new thread's stale copy of the override. The earlier round trip could not show this, since KeyGen set the copy for the whole thread. The build smoke job installs g++-aarch64-linux-gnu and qemu-user and lists them as required, so the AArch64 case cannot skip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover each ML-KEM entry point's override and its per-thread copy Two wrapper mutations passed every test. An entry point without BeginOperation() went unnoticed because ForEachBackend calls GetBackendNames() on the same thread first, which takes the override for it. A shared (not thread_local) copy of the override went unnoticed because only one thread ran operations. every_entry_point_takes_the_override runs each entry point alone on a new thread with portable C forced, then asks the capability hooks what that thread's copy says. override_copy_is_per_thread lets one thread take the override set while another holds it unset, and checks that the first thread's copy did not change. override_changed_by_another_thread now runs operations on two threads at once, so a shared copy also gives wrong outputs on x86_64 and a data race under ThreadSanitizer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fuzz: check that the ML-KEM portable replay runs portable C CI replays the mlkem and mlkem_backend_diff targets a second time with MLKEM_FORCE_PORTABLE=1, but nothing checked that the binary honored it: any other value, such as "true", silently selected native code, and the second replay would have repeated the first. Initialize now refuses any value but unset, empty, 0 or 1, prints the active backend once, and aborts when MLKEM_FORCE_PORTABLE=1 but the active backend is not portable/portable. A host without native code also shows in the log, where mlkem_backend_diff compares portable C with itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover SHA256's SSSE3 requirement with synthetic CPUs SHA256AutoDetect requires SSSE3 besides SSE4.1 before it selects its SSE4, SSE4.1 or SHA-NI code, all of which execute SSSE3 instructions. Nothing tested that: dropping the SSSE3 term passed every test, because real SSE4.1 hardware always has SSSE3. An overload of SHA256AutoDetect takes the CPUID and XGETBV functions, as cpu_features::DetectX86Features does, and the existing one passes the real ones, so the queries and the selection are unchanged. A cpu_features_tests case runs it on synthetic CPUs derived from this one: unmasked it selects what this CPU gets, and with SSSE3 or SSE4.1 masked the standard code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * doc: say exactly where ML-KEM native code runs The wrapper's header said native code runs on "x86_64 with AVX2, AArch64", and the WITH_MLKEM_NATIVE help text spoke of "the x86_64 and AArch64 ML-KEM assembly". AArch64 runs native code only on macOS (AArch64 ELF builds stay portable to keep their BTI and PAC marking), and x86_64 also needs SSSE3, SSE4.1, POPCNT and BMI2, as cpu_features::HasMlkemX86Native checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * contrib: keep the clone unshallow when vendoring mlkem-native update-mlkem-native.sh fetched the upstream release with --depth=1 into the qbit repository itself. That writes .git/shallow, so a full clone then reported itself as shallow to git and every tool that asks. Fetch into a temporary bare repository instead, and read the commit, the object ids and the archive from there. Staging and the byte-for-byte check still run in the qbit repository. Re-vendoring v2.0.0 by tag and by commit leaves no diff, and the clone stays complete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * doc: record the local x86_64 GNU property evidence for mlkem-native #190 asks for ELF property evidence that linking the x86_64 assembly keeps the CET marking. Record what a local Ubuntu build shows: the assembly and C objects and the test_qbit and fuzz binaries that link the native backend all carry IBT and SHSTK, the binaries need only baseline x86-64, and the assembly loses its note only when built with -fcf-protection=none. The Guix release evidence is still outstanding and is required before the post-quantum feature branch merges into 1.x.x. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: name the ML-KEM entry point instead of capturing a binding every_entry_point_takes_the_override captured a structured binding in the thread's lambda, which compilers accept only from C++20 (P1091R3, P1381R1). Bind the label and the entry point to ordinary variables, so the test does not depend on that support. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: build the macOS PQ jobs' Boost and libevent through depends The first macOS arm64 run failed to compile txmempool.h: with NO_DEPENDS=1 the job used Homebrew's Boost, now 1.92, and Boost 1.91 stopped accepting a struct derived from multi_index::indexed_by as an index list. Bitcoin Core fixed the source in bitcoin/bitcoin#35175; that backport is handled on its own. Build Boost and libevent with depends, natively for the runner, so the jobs compile against the versions qbit releases with. Homebrew now installs GNU Make 4 for depends instead of the two libraries. A step sets HOST and reads depends' own build id (packages, recipes and compilers), which keys an actions/cache of depends/built. It is saved once the toolchain file exists, even if the suites fail. After the build, a step requires that depends built that id and that CMake took the toolchain, libevent and Boost at boost.mk's version from it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…t 4) (#230) * bip324: derive hybrid keys from an ordered transport transcript Add the hybrid ML-KEM-1024 key recipe of #184 to BIP324Cipher. Nothing calls it from the transport yet; E3 does. - Initialize(..., retain_for_hybrid) keeps the raw BIP324 ECDH secret, starts a running SHA256 transcript with ellswift_I || ellswift_R, and snapshots the network magic and cipher direction. The default (false) keeps today's secret lifetime and the existing vectors. - AddVersionContents hashes CompactSize(len) || contents, responder first, through a small stream adapter without copying the contents. - SwitchToHybrid derives HKDF-SHA256 with salt "qbit_v2_hybrid_mlkem1024" || magic over ss_ECDH || ss_MLKEM || TH, re-emplaces all four packet ciphers with counters at 0 and replaces the session id; garbage terminators stay. Misuse (not initialized, not retained, wrong contents count, a second switch) returns false and changes nothing. - The retained secret, IKM, HKDF state and derived keys are wiped on switch, in DiscardHybridSecret and in the destructor. Copying is deleted. The same recipe goes into test_framework/crypto/bip324_cipher.py, and contrib/devtools/generate-pq-transport-vectors.py uses it, the Python ML-KEM-1024 and the BIP324 ECDH helpers (imported by path, never the C++ code) to write src/test/data/pq_transport_vectors.json: inputs, every intermediate, both confirmations and first packets, and negative cases. Its --check mode runs in lint through test/lint/lint-pq-transport-vectors.py. bip324_tests gains hybrid_vectors (both roles and self-decrypt mode, with every intermediate recomputed from qbit's own primitives), hybrid_transcript_binding, hybrid_api_order and hybrid_rekey_boundaries. The bip324_cipher_roundtrip fuzz target gains an optional switch, transcript and secret mutations and discards. The existing BIP324 vectors are unchanged. Part of #184 (#199, E1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * doc: specify hybrid v2 transport (doc/design/pq-transport.md) Add the specification of the hybrid ML-KEM-1024 v2 transport from #184's design, written for interoperating implementations and outside review: the record grammar and header registry (0x00 reserved, 0xF0 for the ML-KEM-1024 offer and accept, qbit's values from 0xF0 upward), byte layouts, parse rules (unparseable contents are ignored, unlike the Transport Feature Negotiation draft), the key recipe byte by byte, the responder and initiator message rules and why progress still holds, the switch boundary, key confirmation MUSTs, failure handling, security notes, an interop walk-through of the vectors file, the convergence and retirement policy, and a contributor section. The spec quotes vector 0 in a marked block, and generate-pq-transport-vectors.py now fails (in --check mode too, so in lint) when a quoted value differs from the vectors. Part of #184 (#199, H2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * net: implement PQ feature parsing and per-connection KEM state Add src/bip324_pq.{h,cpp} with PQHandshake, the version-contents parser and one connection's ML-KEM-1024 state for the hybrid v2 transport of #184. Nothing calls it from V2Transport yet; E3 does, so behavior is unchanged. - ParseContents is a single noexcept pass over a SpanReader. The whole contents must parse as records CompactSize(len) || header || payload (len >= 1, canonical, within the contents) before anything else is looked at; any framing error means no features. Each length is checked against what is left before SpanReader::ignore(). The first 0xF0 record wins and is returned as a span into the contents. - CheckRecordLength applies the role's length rule to that record (EK_LENGTH or CT_LENGTH); AcceptOffer runs the modulus check (EK_MODULUS) and encapsulates; DecapsulateAccept decapsulates and wipes the single-use key. SerializeRecord writes fd 21 06 f0 || payload. - Key generation draws two independent 32-byte values and encapsulation one, from GetStrongRandBytes in production or the test constructor's PQRandomSource; the ML-KEM operations come through PQKemOps. Seeds and coins are wiped. - Internal ML-KEM errors return mlkem::Error::INTERNAL and record which operation failed; nothing asserts. The first recorded failure wins, and any failure, ClearSecrets() and the destructor wipe the decapsulation key. net_tests gains pq_records_grammar, pq_records_first_wins, pq_record_semantics, pq_records_max_contents and pq_handshake_negotiation (vector 0's ek, ct and shared secret from deterministic entropy, plus injected keygen, check, encaps and decaps failures). The new pq_records fuzz target checks the parser against an independent reading of the grammar; it joins the required qbit seed corpora with 15 generated seeds, including the nightly mutation list in ci/test/03_test_script.sh. Part of #184 (#199, E2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * net: negotiate hybrid v2 keys and require key confirmation Wire PQHandshake into V2Transport per #184 and #199 (E3). Production construction stays PQ-off (V2PQOptions{} in MakeTransport); part 6 adds the option, and with the switch off the wire bytes are today's. - New states: SendState::AWAITING_VERSION and RecvState::CONFIRM, with the transition checks and diagrams updated. - With PQMode::NEGOTIATE the ciphers retain the ECDH secret and start the transcript. A responder generates its key only once the initiator's whole public key has arrived, queues its terminator and the offer, and then sends nothing until the initiator's version packet is processed. An initiator queues only its terminator and holds its version packet. While holding, SetMessageToSend refuses and GetBytesToSend reports more=false. - ProcessReceivedPacketBytes takes the send lock around ProcessVersionContents, so the switch of all four ciphers and the session id is atomic under both locks; application packets take no extra lock. A legacy peer (no own record, or contents that do not parse) keeps the ECDH keys. A valid offer is answered with the accept under the ECDH keys, then both sides switch and append a 20-byte key confirmation. Packets are only ever appended to the send buffer. - In CONFIRM the peer's first hybrid packet must decrypt to length 0, checked as soon as its 3 length bytes arrive, then verify its tag with empty AAD and carry the ignore bit. It never becomes a message. - Off and fallback neither parse nor validate the peer's contents; a responder that could not offer never decapsulates. - Internal ML-KEM failures never assert. Key generation, key check and encapsulation failures continue as plain v2 with an empty version packet; a decapsulation failure after the initiator committed closes. The decapsulation key and the retained ECDH secret are wiped on switch, legacy outcome, every failure and in destructors. - Transport::Info gains transport_pq and transport_pq_status; until the confirmation the status is pending, the classification stays detecting and no session id is published. V2Transport::GetPQSnapshot exposes the negotiation's progress and first failure for part 5. - V2PQOptions::corrupt_shared_secret inverts byte 0 of the local ML-KEM secret before derivation, as the vectors' negative case models. net_tests gains v2_pq_pair, v2_pq_legacy_matrix (including two real transports in every mode pair), v2_pq_v1_detection, v2_pq_send_hold, v2_pq_switch_boundary, v2_pq_confirmation_length, _tag and _not_decoy, v2_pq_corrupt_ct, v2_pq_internal_errors and v2_pq_rekey, and the existing random-contents scenarios also run with the negotiation on against a legacy peer. The bidirectional v2 and v1v2 transport fuzz targets take a PQ mode and an ML-KEM entropy seed per side from the input and check that the keys are hybrid exactly when both sides negotiate; both become required qbit corpus targets with generated seeds, including in the nightly mutation list. Part of #184 (#199, E3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fuzz: add a stateful malicious-peer target for PQ negotiation Add the p2p_v2_pq_malicious_peer target (#199, G4): a peer that completes the ECDH part of BIP324 honestly, so every packet it sends authenticates, and drives the hybrid negotiation of a V2Transport from the attacker's side. The tested transport runs in either role and in each mode (a responder never falls back), built with E3's deterministic test constructor and counting ML-KEM operations. The fuzzer scripts the peer step by step: its key, terminator, decoys (also carrying own records), version packets (honest, duplicate and unknown records, trailing corruption, malformed lengths, a key that fails the modulus check, blind, damaged and unsolicited ciphertexts, an accept without the peer's own switch), confirmation variants, application packets, unauthenticated bytes, partial delivery and collection, messages for the transport to send, and a disconnect at any point. The peer reads everything the transport sends and switches to hybrid keys itself when it played its part honestly. The variant generator follows the E3 verifier's malpeer harness. Checked after every step: no assertion or hang; GetBytesToSend stays append-only and its 'more' predictions hold; refused messages stay untouched and a holding initiator takes none; nothing is delivered between the switch and the peer's confirmation; the transport sends no decoy but its confirmation, which decrypts to length 0 under the peer's keys when both switched; only the same keys confirm, with equal session ids, and an honest exchange delivered in full confirms; off and fallback make no ML-KEM call, and a responder decapsulates only an accept to its own offer; once the peer's version packet is processed, or the transport failed, no hybrid secret remains; a close under the hybrid keys before confirmation is a confirmation failure. The target joins the required qbit seed corpora with 16 generated seeds, each a script whose outcome is documented in the manifest: honest exchanges in both roles (with maximum garbage, extra records and fragmented delivery), every malformed record and confirmation failure, a damaged ciphertext, a legacy peer, a fallback initiator, an unsolicited accept and a disconnect while the offer is pending. Part of #184 (#199, G4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fuzz: drop a stray libFuzzer artifact and ignore new ones The G4 commit added crash-ef5ec86784262f7cd94f46933e5f6760bee654b5 at the repository root by accident. It is an input for p2p_v2_pq_malicious_peer left over from one of the harness false positives fixed during G4's development, not a transport defect: it replays cleanly on that target and on pq_records, bip324_cipher_roundtrip and both bidirectional transport targets. libFuzzer writes its crash, leak, out-of-memory, slow-unit and timeout artifacts to the working directory, so ignore those prefixes at the root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: check hybrid secret wipes, close paths and pending status Close the test gaps the #230 deep review's mutants exposed; each mutant below survived bip324_tests and net_tests before and is now caught. - The switch wipes the retained ECDH secret and the transcript (hybrid_api_order, v2_pq_pair, v2_pq_legacy_matrix), and a legacy outcome wipes them and the decapsulation key (v2_pq_legacy_matrix, in every arm and for every pair of modes). - v2_pq_close_wipes: a missing garbage terminator, an oversized version packet length and a damaged version packet tag close with both secrets wiped at once, in both roles. These closes come before the peer's version packet authenticated: no PQ failure, pending, and a responder's offer counts as sent. - The status is pending from the start of the handshake until the peer's version packet is processed, not legacy_peer (v2_pq_pair, v2_pq_legacy_matrix, v2_pq_close_wipes). - A decapsulation failure closes on the initiator's version packet itself, with no confirmation queued, instead of one packet later. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * net: wipe hybrid secrets whenever the peer's version is processed ProcessVersionContents returned early, without wiping, when this side was not awaiting the peer's version packet: the negotiation off, in fallback, or a responder that could not offer. Every such path in this PR wipes (or never retains) before it sends its version packet, so this is correct today. But a later path that declines the negotiation after Initialize(..., retain_for_hybrid=true) would keep the ECDH secret for the whole connection; part 5's load shedding first had exactly that bug. Wipe in that branch too, a no-op unless such a path kept a secret, so that no hybrid secret outlives the peer's version packet by construction. A test-only option, V2PQOptions::retain_without_negotiation, retains the secret with the negotiation off to stand in for such a path; production passes default options. v2_pq_version_wipes checks the wipe in both roles and that plain v2 continues. v2_pq_internal_errors now also checks that the keygen failure wipes at once, which the new wipe would otherwise mask until the initiator's version packet arrives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…184 part 5) (#235) * net: record a first-cause-wins close cause Add NodeCloseCause and CNode::RequestDisconnect(cause). It records the cause with a compare-exchange from NONE before setting fDisconnect, so the first cause wins and later or concurrent calls, including cleanup, cannot relabel it. CloseSocketDisconnect(cause) goes through it. The socket sites record PEER_EOF, PEER_RESET and SEND_ERROR, InactivityCheck() and the ping timeout record TIMEOUT, and every other writer in net.cpp and net_processing.cpp records LOCAL by calling RequestDisconnect() instead of setting fDisconnect directly. A node that reaches DisconnectNodes() with no cause is finalized there as LOCAL. fDisconnect stays for its readers, and no protocol behavior changes. The close_cause_first_wins test drives each site through CConnman and PeerManager: EOF, reset, send error, inactivity, receive-parse failure, operator disconnect, network off, eviction, shutdown and the ping timeout. It also checks that repeated and racing calls keep the first cause and that no node is deleted without one. Work item F1 of #206 (#184 part 5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * net: attribute PQ outcomes per direction with bounded failure rings Count every hybrid negotiation outcome exactly once, per direction, and keep the recent non-success outcomes as evidence. This is the data source for the fallback (F3), load shedding (F4) and the RPC (F6). CConnman::ObservePQ() snapshots the transport under its own lock, releases it, and only then takes m_pq_mutex; nothing calls into the transport, socket, peer manager or node enumeration while holding it. It runs after ReceiveMsgBytes() and once more, finalizing, in DisconnectNodes() before the disconnected pool keeps the node. Each CNode records the events already accounted, so an event seen after receive, at disconnect and in cleanup counts once. Finalization derives closed_after_switch (outbound, switched but unconfirmed, closed by EOF, reset or timeout) and abandoned (an offer queued, no authenticated version) from the F1 close cause, marks the node final and wipes the transport's pending secrets. With the network off it wipes but records nothing, since every close is then our own. - Transport gains GetPQSnapshot() and ClearPQSecrets(), with no-op defaults for V1Transport. The snapshot gains the legacy reason (no_features or parse_error) and the transport's own reason for an abandoned offer (garbage_terminator, version_length, version_tag), set at V2Transport's existing failure sites. - CNode keeps a typed PQEndpointKey captured at creation through CNodeOptions::pq_endpoint: the resolved CService with its port, the normalized hostname and port of a name-proxy destination (never the proxy's address), or an inbound peer's remote address and source port. CNodeOptions::pq carries the negotiation mode to the transport; CConnman's mode stays off until part 6 adds the option. - Counters count events: switched at key installation, even if the confirmation fails; each failure once; abandoned, closed_after_switch and the shed placeholder per direction. - Two rings of at most 256 entries, inbound and outbound (manual included), each with its own sequence and dropped count, so inbound noise can't evict outbound evidence. Reasons come from the fixed vocabulary only. - instance_id and since identify the process's counting period, and GetPQTransportStats() returns an owning copy. - One "v2 pq:" line per outcome, with fixed fields. Outbound failures log at info level, each from its own call site; inbound outcomes, switched, legacy_peer and abandoned are net debug, from separate call sites so inbound noise can't rate-limit outbound lines. net_pq_tests covers once-only observation (including racing observers), endpoint identity, the ring bounds, switched-then-failed, and every abandoned and closed_after_switch cause; a confirmed handshake that the peer later closes, or that times out, counts nothing. It checks the exact text and level of each outcome's log line in both directions (info for outbound failures, net debug otherwise), and the status each failure reports (off for a local fault before commit, pending while a failed connection closes). pq_cipher_state_closes checks that an inconsistent cipher state closes in both roles, the initiator before it commits included, and counts once as internal_error/cipher_state. pq_finalization_wipes checks, for every close cause (EOF, reset, timeout, local, send error, network off, transport failure) and both roles, that the pending secrets survive the socket close and are gone once the connection is finalized, before the node is deleted. pq_name_proxy_endpoint drives ConnectNode() through a name proxy (a mocked SOCKS5 server) and checks that the endpoint key is the normalized destination and port, never the proxy. ProxySettingsRestorerForTesting, a test-only netbase guard, saves and restores the proxy settings so that no later test sees them. Production construction stays PQ-off. Work item F2 of #206 (#184 part 5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * net: apply bounded per-endpoint fallback after repeated PQ failures Keep upgraded nodes connected when the hybrid negotiation with one endpoint keeps failing, without letting an outsider downgrade connections cheaply: a single failure is telemetry only. - Counted failures are outbound only, per endpoint key: malformed_record, first_packet_failed, and closed_after_switch (eof, reset or timeout). Our own closes, send errors, legacy outcomes and inbound failures are neutral: they neither advance nor reset a streak. - The third consecutive counted failure puts the endpoint in the fallback set for 1 h, then 4 h, then 24 h for each later entry (PQ_FALLBACK_WINDOWS). At expiry the streak restarts at 0 and the escalation stays. A failure from a connection already running during a window neither extends it nor enters again. - A confirmed hybrid handshake moves the endpoint into a separate success set, never evicted until restart, and drops its history. The check and the insert share m_pq_mutex with the counters and rings, so a late failure can't reinsert a protected endpoint. - The history holds at most 1,000 endpoints, evicting the oldest insertion first; insertion orders are unique, so equal times can't tie. It is memory only and uses the mockable NodeClock. - ConnectNode() asks IsPQFallback(endpoint, now): a fallback connection runs PQMode::FALLBACK, plain v2 that neither parses nor validates the offer. No retry loop is added: the next attempt the connection logic makes asks again, existing connections never renegotiate, and inbound connections never fall back. - Entering counts the outbound fallback counter once, adds a ring entry and logs one warning, "v2 pq: fallback cause=<trigger> until=<UTC time>" with the usual fields. - When 8 distinct endpoints have counted failures and none has succeeded since startup, it warns once that this node's own ML-KEM code may be at fault, naming the active backends. It names no remedy: this tree has no option that forces portable code yet. - GetPQTransportStats() gains the fallback set and the failure streaks for part 6. net_pq_tests covers the threshold for each counted variant with neutral closes in between, the escalating windows under mock time (including new connections getting FALLBACK and existing ones not), success protection against a late failure and 1,001 endpoints of churn, oldest-insertion eviction with tied times and insertion order opposite to key order, the local-fault warning (once, naming the backends, native or portable; never after a success), and a fallback connection completing as plain v2 against a malformed offer. pq_closed_after_switch now uses an endpoint per connection, so that its three counted failures don't enter fallback, and pq_cipher_state_closes checks that a local fault never counts toward the fallback. Work item F3 of #206 (#184 part 5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * net: shed inbound PQ offers under handshake floods Bound the extra CPU an inbound connection flood can cost. Each offer costs the responder one ML-KEM-1024 key generation; above a rate threshold, responders stop offering and cost exactly what plain v2 costs today. - V2PQOptions gains an offer gate (a function pointer and context, like PQRandomSource). A negotiating responder calls it at its key generation point, once the initiator's 64-byte key has arrived; when it declines, the responder wipes the retained secret and sends empty version contents, and its status is off. - CConnman's gate counts the attempted offers per second. More than threshold_per_s in the current second starts shedding; ten consecutive complete seconds with fewer than half the threshold's attempts stop it. The quiet seconds before a flood don't count toward its end, so shedding can't flap within a second. The seconds come from the steady clock (MockableSteadyClock), so a wall clock step can't merge seconds or stretch a period; only the reported start time is wall-clock time. The socket handler also checks the clock, so shedding stops on time when the flood stops entirely. - Each shed offer increments the inbound shed counter and adds no ring entry, so a flood can't wash out the evidence. Starting and stopping each log one info line: "v2 pq: load_shedding started threshold_per_s=<n>" and "v2 pq: load_shedding stopped". - The threshold defaults to DEFAULT_PQ_SHED_THRESHOLD_PER_S (1,000) and CConnman::Options::pq_shed_threshold_per_s overrides it for unit tests and the flood lab; there is no new user-facing option. - GetPQTransportStats() gains load_shedding {active, threshold_per_s, since} for part 6. - Outbound connections are never affected. The gate's mutex is taken under the transport's locks, and nothing holding it calls into a transport or takes m_pq_mutex. net_pq_tests drives inbound connections through the accept path under mock time: offers up to the threshold, the first one above it shed (empty contents, so the initiator sees a legacy responder; status off; shed +1), outbound connections still hybrid, a second at half the threshold restarting the quiet run, offers resuming only after ten quiet seconds, and each transition logged once at info level. A wall clock step back by an hour under a trickle of offers sheds nothing, and a step forward doesn't end a flood early. The real socket handler thread, run as Start() runs it, ends a flood that stopped. With the switch off the gate is never asked. Work item F4 of #206 (#184 part 5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: pin the shed wipe, inbound-success and outbound-abandoned guards Three guards had no test, and each survived a mutation in the deep review of PR #235: - pq_shed_wipes_secrets: a shed responder wipes the retained ECDH secret and transcript at once (ClearHybridSecrets() in the shed branch), not only at finalization, since it runs plain v2 for the whole connection. - pq_inbound_success_does_not_protect: only outbound successes enter the success set, which is never evicted. An inbound peer's key is its address and source port, so an inbound success must neither protect an endpoint nor silence the local-fault warning. - pq_outbound_never_abandoned: only responders offer, so an outbound connection closed before the peer's version, by either side, counts no abandoned and adds no outbound ring entry. Follow-up to work items F2-F4 of #206 (#184 part 5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: restore mocked globals on every path in net_pq_tests Three tests changed process-wide state and restored it only at their end: pq_endpoint_identity, pq_name_proxy_endpoint and pq_fallback_windows replaced CreateSock, pq_name_proxy_endpoint also cleared the SOCKS5 interrupt, and pq_log_lines set fLogIPs. A failed BOOST_REQUIRE in between throws past the restore. Every later suite in the same test_qbit process would then connect through a mock socket or log differently, and fail far from the cause. A GlobalOverride guard, a MockCreateSock() helper built on it, and a Socks5InterruptCleared guard now restore them on every path, as ProxySettingsRestorerForTesting already does for the proxy settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * doc: name load shedding in the off status of pq-transport.md A responder that sheds its offer under an inbound flood runs plain v2 and reports off (#206 F4), but the status list in doc/design/pq-transport.md named only the switch and local faults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: check the shed wipe before the initiator's version arrives Part 4 now wipes the hybrid secrets whenever the peer's version is processed (#230, L5). pq_shed_wipes_secrets already checks right after the initiator's key, before any version, so a shed branch that doesn't wipe is still caught. With the merged part 4, a variant of the test that checks after the version lets that mutant survive. Make the precondition explicit, so the check can't drift past the version. Follow-up to work item F4 of #206 (#184 part 5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: pin the local-fault set's cap The distinct failing endpoints that the local-fault warning counts are never evicted, so the cap at PQ_LOCAL_FAULT_THRESHOLD is their only bound. No test saw it: the warning fires once either way, and an uncapped set survived mutation in the deep review of PR #235 (finding 5). ConnmanTestMsg::PQLocalFaultEndpoints() reads the set's size, test only. pq_local_fault_warning checks that the set holds exactly the threshold once the warning fires, and still does after 12 distinct endpoints have failed. Follow-up to work item F3 of #206 (#184 part 5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: build PQ test peers without a conditional of prvalues MSVC requires an accessible copy constructor for `cond ? a : b` with prvalue operands, and V2Transport has none, so test_bitcoin failed to build on the Windows CI job (C2280). Return the transport from a lambda instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * net: finalize PQ state of the connections deleted at shutdown Finalization ran only in DisconnectNodes(). StopNodes() swaps the nodes out of m_nodes and deletes them itself, so a node still held at shutdown was never marked finalized, and its pending decapsulation key and retained ECDH secret stayed until the transport's destructor (PR #235 review). StopNodes() now runs the same finalization, ObservePQ(finalizing), before deleting each node: it marks the node final and wipes the pending secrets. A shutdown close is our own decision, so like the network-off path it records nothing: no abandoned offer, ring entry or log line. ObservePQ() gains a record_outcomes flag for that; the nodes in the disconnected pool were already finalized by DisconnectNodes(). pq_shutdown_finalizes stops a connection manager that holds an inbound connection with its offer pending, an outbound one awaiting the version, and a hybrid one. At deletion no node is unfinalized or holds a hybrid secret, and no counter or ring changes. Follow-up to work item F2 of #206 (#184 part 5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: report would-block from DynSock where Windows reads it net_pq_tests failed on the Windows CI job: no handshake completed, offers were abandoned with reason reset instead of eof, and the rings stayed empty. The log shows the cause: "socket recv error, disconnecting peer=0: The operation completed successfully. (0)". DynSock::Pipe::GetBytes() signals an empty pipe like a non-blocking recv(2) with no data: it returns -1 and sets errno to EAGAIN. The socket handler reads the error with WSAGetLastError(). That is errno on POSIX, but on Windows it is Winsock's own last error, which the mock never set. So Windows saw error 0, treated every idle read as a permanent receive error, and closed the connection as PEER_RESET before the peer's next bytes arrived. Production is right: real sockets set the Winsock error, an orderly close still reads 0 (EOF), and a peer that closes with unread data resets the connection on Windows and Linux alike. GetBytes() now also calls WSASetLastError(WSAEWOULDBLOCK) on Windows. mock_socket_would_block pins it: an empty pipe reads -1 with the would-block error from WSAGetLastError(), EOF reads 0, and a socket handler pass over a connection with nothing to read leaves it open. Making the mock report error 0 on Linux reproduces the Windows failures exactly, and the new test catches it. Four checks dereferenced Streak() or Fallback() without requiring a value; on Windows one read an indeterminate streak. They now BOOST_REQUIRE it first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * net: keep active fallback windows when the endpoint history is full When the history of outbound endpoints with counted failures reached PQ_MAX_ENDPOINT_HISTORY, a failure from a new endpoint evicted the oldest insertion, even one inside an active fallback window. Entries whose window ended stay in the map to remember escalation, so a later one-strike failure could drop an in-window fallback: IsPQFallback then returned false, the next outbound connection tried hybrid again, and the endpoint's window and escalation started over. Evict the oldest insertion outside a fallback window instead, and only when every endpoint is in one, the window that ends first (the oldest insertion of those that end together). Found by Cursor Bugbot on #235. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…184 part 6) (#236) * init: add -v2pqtransport and hybrid transport test options Make the hybrid post-quantum v2 transport configurable, off by default until the default-on change in part 9, through one configuration that the startup warnings, the startup line, every new connection and RPC share. - PQTransportConfig {v2_enabled, pq_requested, fail_first_packet} is computed once in AppInitParameterInteraction() from the effective -v2transport, -v2pqtransport and -test settings. NODE_P2P_V2 now comes from the same value. CConnman::Options::m_pq carries it, and CConnman::Init() derives m_pq_mode from it: NEGOTIATE only with both switches on. This is the only way the mode is set; the unit-test hook ConnmanTestMsg::SetPQMode is removed and net_pq_tests passes a configuration instead. - -v2pqtransport (DEFAULT_V2_PQ_TRANSPORT = false) has no effect with -v2transport=0. An explicit -v2pqtransport=1 then warns: "-v2pqtransport=1 has no effect because -v2transport=0. Set -v2transport=1 to use hybrid post-quantum v2 transport." An explicit 0 or the default never warns. - Booleans are read as integers, so -v2pqtransport=true is 0. A value that doesn't parse completely as an int, under InterpretBool's whitespace and sign rules, warns and names the value it is read as: true, 1abc and an overflowing number. An empty value and a negation are valid. InterpretBool itself is unchanged. - -test=pq_fail_first_packet (regtest) sets corrupt_shared_secret on inbound and outbound transports, so every hybrid handshake fails its key confirmation. -test=mlkem_portable (regtest) and the hidden -mlkemportable (debug category, all chains) force portable ML-KEM code through mlkem::InitializeRuntime(), called before the connection manager exists. - The startup line is always logged and names the active backends: "v2 pq: enabled arith=<a> keccak=<k>", "v2 pq: disabled (-v2pqtransport=0) ..." or "v2 pq: disabled (-v2transport=0) ..." when v2 is off, whatever the PQ switch says. - The local-fault warning names -mlkemportable as the remedy again while native code runs: " Restart with -mlkemportable to use the portable implementation." - MakeTransport() takes the CNodeOptions. rpc_pqtransport.py checks the switch from the command line, qbit.conf, -nov2pqtransport and across restarts on a live two-node connection ("v2 pq: switched"), the exact warnings and their absence, -help and -help-debug, all three startup lines, portable code from both options, the test options' regtest-only check, and that the test options print nothing to stderr. net_pq_tests' pq_config checks the mode of inbound and outbound connections for each configuration and the effect of fail_first_packet; the local-fault tests check the remedy appears only while native code runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 19268db) * rpc: expose hybrid transport state and diagnostics Give operators and monitoring scripts everything they need to see the hybrid post-quantum v2 transport, using the RPC contract in #184. - getpqtransportinfo renders the configuration and part 5's PQTransportStats field for field: enabled; status (enabled, disabled_v2pqtransport or disabled_v2transport); instance_id (64 lowercase hex); the active arith and keccak backends; since; per direction handshake counters, inbound shed and outbound closed_after_switch and fallback included; load_shedding (since is 0 when inactive); fallback_set with window_seconds; failure_streaks with next_window_seconds; and both failure rings, oldest first. Every field is always present and empty collections are []. An endpoint is {kind, network, address, port}: an address's own network (127.0.0.1 is ipv4), or name_proxy and the normalized hostname. The help text lists the fixed reason vocabulary and the counter semantics. - getpeerinfo gains transport_pq and transport_pq_status (pending, hybrid, legacy_peer, fallback, off or v1), carried by CNodeStats from Transport::Info. The help documents part 4's rule: there is no failed status, so a connection closing on any hybrid failure reports pending until it is removed. transport_protocol_type keeps its values, and session_id is the hybrid session id once transport_pq is true. - getnetworkinfo gains connections_pq. getpeerinfo's visibility filter (a peer without node state is skipped) moves into GetListedPeers(), and connections_pq counts transport_pq over exactly those peers, before cs_main is taken. - getpqtransportinfo joins the rpc fuzz target's safe list, and doc/rpc/annotations/v30.2-delta.yml describes the three methods. - qbit-cli -netinfo shows 2p in the v column for a hybrid peer, and its legend explains it. A server without transport_pq, such as v1.0.0, counts as not hybrid. - The GUI's peer details read "v2 (hybrid post-quantum)" for a hybrid peer, and the transport type tooltip explains it. rpc_pqtransport.py gains the RPC cases: every getpqtransportinfo field with the switch off, with v2 off, on, and with an endpoint in the fallback set, and a new instance_id per restart; each transport_pq_status on both sides of a connection; exact per-direction counts for hybrid, legacy and failing connections, with -test=pq_fail_first_packet failing handshakes in both directions; an abandoned offer after eof and after a handshake timeout, with no switch; 300 inbound failures leaving 256 entries with contiguous sequences, 44 dropped, and the outbound ring's entry kept; and connections_pq equal to getpeerinfo's transport_pq count, 0 with the switch off. rpc_net.py's exact getpeerinfo dict gains both fields, interface_bitcoin_cli.py checks 2p and 2 with one hybrid and one legacy peer and the legend, and p2p_v2_previous_release.py runs this release's qbit-cli -netinfo against v1.0.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit fc3eb7b) * init: warn when a PQ option's setting is read differently than written ArgsManager's boolean parsing stays as it is (it matches Bitcoin Core), but -v2pqtransport and -mlkemportable now warn whenever the text given doesn't say what the setting is read as, naming what it was read as and the effect. - A non-numeric value is read by InterpretBool's integer rules: true, yes and on as 0, 1abc or an overflowing number as 1. It warns with the value given: "-v2pqtransport=true is not a number and was read as -v2pqtransport=0: hybrid transport is OFF. Use -v2pqtransport=1 or -v2pqtransport=0." Surrounding whitespace and a sign are allowed, as before. - A double negative, -nov2pqtransport given 0 or any non-numeric value (on the command line or in qbit.conf), switches the option on. ArgsManager keeps only the result of a negation, a bool, so the text is gone; the warning says what it can: "-nov2pqtransport was given 0 or a non-numeric value, so it was read as -v2pqtransport=1: hybrid transport is ON. Use -v2pqtransport=0 to switch it off." -nov2pqtransport=1abc is read as off, like -nov2pqtransport, and can't be told apart, so it doesn't warn. - The effect is the real one: hybrid transport is ON only with -v2transport on too, and the contradiction warning still follows. - -mlkemportable gets the same two warnings, so -mlkemportable=true, which keeps native code, no longer passes silently. Its effect names the code that runs, -test=mlkem_portable included. The setting is now read once, in AppInitParameterInteraction(), and AppInitMain() passes the stored result to mlkem::InitializeRuntime(). - The -v2pqtransport and -mlkemportable help, and getpqtransportinfo's help, say that such values warn at startup. rpc_pqtransport.py checks the exact warnings and startup states for -v2pqtransport=true, yes, on, 1abc and an overflow, none for " 1", +1 and -1, both warnings with -v2transport=0; -nov2pqtransport=0, true and on (and nov2pqtransport=true in qbit.conf) warning and enabled; no warning for -nov2pqtransport, =1 and =1abc; and, for -mlkemportable, =true warning while the default code stays, =1abc, -nomlkemportable=0 and =true warning with portable code, no warning for -nomlkemportable, =1 and -mlkemportable=0, and the test option's effect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 8ae2a47) * test: cover getpqtransportinfo's rendering paths The review of #236 found nine plausible mutants of the RPC rendering and startup code that every test passed. Each now fails a test. net_pq_tests calls the RPCs (getpqtransportinfo with the fixture's own connection manager): - pq_rpc_status: status names v2 first, so v2 off reads disabled_v2transport whatever the hybrid switch says; enabled follows. - pq_rpc_load_shedding: with a threshold of 1, the second offer in one second is shed: load_shedding is {active: true, threshold_per_s: 1, since: now}, and inbound shed counts it. - pq_rpc_endpoints: IPv4, IPv4-mapped IPv6, IPv6, ::1, onion, I2P and name-proxy endpoints, each with its kind, its own network, its canonical address and its port, in the outbound ring and the failure streaks. - pq_rpc_windows: one endpoint's windows escalate, 3600, 14400, then 86400, in window_seconds (equal to expires - entered) and next_window_seconds, and an expired window is listed nowhere. - pq_rpc_listed_peers: a confirmed hybrid connection the peer manager doesn't know is in getnetworkinfo's connections but not in connections_pq or getpeerinfo; once known, both show it. rpc_pqtransport.py: - a default node runs native ML-KEM-1024 code where the build and CPU have it. The test config gains ENABLE_MLKEM_NATIVE, and the test reads the CPU's flags on Linux x86_64 (the features cpu_features::HasMlkemX86Native() requires), assumes native code on macOS arm64, and expects portable code without native support; it doesn't check where it can't tell; - getpqtransportinfo with -v2transport=0 alone: disabled_v2transport; - a name-proxy destination through a SOCKS5 proxy that resolves it to a failing node, end to end: the ring entry and the streak name {name_proxy, name_proxy, pqtest.example, 8333} for "PQTest.Example.:8333"; - with local IPv6, an IPv6 connection: {address, ipv6, ::1, port} on the outbound side, and ipv6 ::1 on the inbound side. The whitespace case (-v2pqtransport= 1 doesn't warn) is in the previous commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 7e0f826) * ci: require the PQ jobs for test/CMakeLists.txt changes test/CMakeLists.txt now passes ENABLE_MLKEM_NATIVE to the functional test config, so the contract test that every file mentioning ML-KEM is classified as PQ-relevant failed. Classify it with the other build wiring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 9e04cfb) * rpc: construct getpqtransportinfo's help entries in place clang-tidy's modernize-use-emplace failed the tidy CI job on the three direction-specific help entries. Build them with emplace_back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: expect the node's line ending between two startup warnings On Windows the node ends each stderr line with CRLF, so the two rpc_pqtransport.py cases that expect two warnings failed on both Windows jobs: they joined the warnings with "\n". Join them with os.linesep, as feature_config_args.py does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ing peers and fallback (#184 part 7) (#239) * test: add an opt-in hybrid v2 Python peer Give the functional test framework an independent implementation of the hybrid post-quantum v2 handshake (doc/design/pq-transport.md), built on crypto/mlkem.py and the key recipe in crypto/bip324_cipher.py, and use it for end-to-end tests against the node. - v2_p2p.py: EncryptedP2PState(supports_v2_pq=True) negotiates in both roles. A responder offers an ML-KEM-1024 key from two independent 32-byte draws; an initiator holds its version packet until the responder's arrives, accepts a valid offer and switches; each side's first hybrid packet is a key confirmation, and the peer's must verify. parse_version_contents() mirrors the node's record grammar. Subclasses can override the offer and accept contents, the shared secret and the confirmation, and a peer can withhold its confirmation. - Framework peers stay legacy by default (supports_v2_pq=False). The unconditional assertion that the node's version contents are empty is gone: a legacy peer accepts a non-empty offer and answers with empty contents. Received and sent contents are recorded, so tests of switched-off nodes assert empty contents explicitly. - p2p.py: authenticate_handshake() also returns the bytes it produced (the held version packet, a confirmation), which the driver sends. The handshake completes only after the peer's confirmation verified and ours was sent; send_pq_confirmation() releases a withheld one. - test_node.py: add_p2p_connection() and add_outbound_p2p_connection() take supports_v2_pq, and the outbound helper gains wait_for_v2_handshake. - v2_p2p.py unit tests (feature_framework_unit_tests.py) replay all four vectors of src/test/data/pq_transport_vectors.json through two states, run every role pairing across a rekey, and check withheld and damaged confirmations. p2p_v2_pq.py covers two nodes in both directions (hybrid, the same session id, more than 224 packets each way), the Python peer in both roles (the node draws fresh keys and encapsulation randomness per connection), legacy and v1 peers under each switch setting and each pair of node settings, and the pending status while a peer withholds its confirmation. Work item G1 of #212 (#184 part 7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: add test options for deterministic load shedding Load shedding counts inbound offer attempts per second of MockableSteadyClock, which only unit tests could set. A functional test therefore had to fit more than 1,000 offers into one real second and then wait out the real quiet period. Slow CI machines (ASan, MSan, TSan, i686) could do neither reliably. Add two regtest-only test options, listed in -help-debug next to pq_fail_first_packet: - -test=pq_low_shed_threshold sets the connection manager's shedding threshold to TEST_PQ_SHED_THRESHOLD_PER_S (5 attempts per second), so six connections reach it. - -test=mock_steady_clock makes setmocktime set MockableSteadyClock to the same number of seconds as well; setmocktime 0 clears both. Without the option setmocktime leaves the steady clock alone, so the headers presync rate limit in other tests is unaffected. rpc_pqtransport.py checks that both options appear in -help-debug, are rejected off regtest and start without printing to stderr, and that the first one reports threshold_per_s 5. The next commit's p2p_v2_pq_misbehaving.py drives load shedding with both. For #212 (#184 part 7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover malformed negotiation, confirmation and fallback end to end Drive every way a hybrid negotiation can go wrong through a real node, using scripted variants of the Python hybrid peer, and check the fallback that follows repeated failures. p2p_v2_pq_misbehaving.py: - test_records: offers (the node initiates) and accepts (the node responds) of every shape: EK and CT one byte short and long, a bad-modulus EK, two own records (the first wins either way), unknown and reserved headers, junk after a record, zero, non-canonical, past-the-end, above-MAX_SIZE and truncated lengths, contents padded to exactly 4,000,013 bytes and one byte more, a damaged version tag, and version packets cut short before EOF. Each case checks that the connection is kept (with its transport_pq_status) or dropped at once, the exact log lines, and the exact per-direction counter deltas. - test_confirmation: a nonzero length, a bad tag and a non-decoy confirmation in both roles, a corrupted CT and a responder that derives another secret: each disconnects at once, long before -peertimeout. - test_decoys_and_partial_writes: decoys before the version packet (the first one authenticating the garbage), decoys after the confirmation, coalesced bursts and a handshake fragmented into 1- to 7-byte writes, in both roles. - test_unsolicited_accept: a switched-off responder ignores own-record bytes it never asked for. - test_load_shedding: with -test=pq_low_shed_threshold (5 attempts per second) and -test=mock_steady_clock, setmocktime places every attempt in a chosen second, so nothing depends on the machine's speed. Five attempts in each of two seconds are all offered; the sixth in one second starts shedding, is the first one shed, and since is that second. While shed, a hybrid initiator sees a legacy responder, an accept is ignored and outbound connections still negotiate. With the clock held, shedding outlasts the quiet period in real time (the one real-time check, which waiting longer only makes stricter). An attempt after nine quiet seconds is still shed; after ten, with no connection at all, the socket handler alone ends shedding. Every step checks exact counter deltas, and the log holds exactly one started and one stopped line. p2p_v2_pq_fallback.py, with addnode onetry for explicit outbound attempts (addconnection can't open manual connections): - test_three_failures: -test=pq_fail_first_packet on one node; three failed attempts enter the fallback set (fallback = 1, one warning line, the set's entry and ring), and the fourth connection is plain v2 with status fallback. - test_expiry_and_restart: past the window the negotiation is tried again with the next window at 4 h; a restart clears everything under a new instance_id. - test_malformed_offer_fallback: against a responder that always offers a bad-modulus key, the fourth connection completes, because a fallback connection neither parses nor validates offers. - test_inbound_and_success_exclusion: inbound failures, and outbound failures after a hybrid success with the endpoint, never lead to a fallback. - test_endpoint_separation: through a SOCKS5 proxy, hostnames in any letter case with or without the root dot, one hostname on two ports and one proxied address on two ports keep separate histories. This drives ConnectNode's name-proxy endpoint key through the real path. Mutating the node, each of these is killed: dropping the socket handler's shedding update, starting shedding at the threshold rather than above it, counting attempts across seconds, ending shedding a second early or late, never declining an offer, setmocktime leaving the steady clock alone, ignoring -test=pq_low_shed_threshold, keying name-proxy endpoints by the proxy, reporting v2 before the confirmation, parsing only up to the first own record, dropping the early confirmation length check, skipping the fallback lookup, the success protection or the window escalation, fixed KEM randomness, counting inbound failures, and parsing contents with the switch off. Work item G2 of #212 (#184 part 7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: exercise hybrid transport across the functional suite Run every existing functional test with hybrid post-quantum v2 transport between all nodes as well, so the feature can't quietly break unrelated behavior. - Framework flag --v2pqtransport, which implies --v2transport: every node that knows the option (the build under test, or a release whose metadata says so) starts with -v2pqtransport=1. Plain --v2transport passes -v2pqtransport=0 explicitly, so the i686 job keeps covering plain v2 after the default changes (#222); --v1transport clears both. Only nodes that use v2 get the setting, because -v2pqtransport=1 with -v2transport=0 warns, and a test's own arguments come later and win. - Framework Python peers stay legacy BIP324 peers in this mode, so the suite pays no pure-Python KEM cost; links between nodes go hybrid. - Tests of the option itself set v2pqtransport_from_run_mode = False, so their nodes start with only their own arguments: rpc_pqtransport.py checks -v2pqtransport's defaults, qbit.conf and negation. - rpc_net.py: the exact getpeerinfo dictionary takes transport_pq and transport_pq_status from the run mode (v1, off, or legacy_peer for a framework peer when the node offers), and connections_pq counts both links between the two nodes when the run mode switches it on. - p2p_v2_misbehaving.py: random 5-byte version contents are redrawn only when they parse as an own record, and a deterministic 04 f0 00 00 00 case disconnects at once when the node offers (from getpqtransportinfo) and is ignored otherwise. The case dispatch no longer treats every unmatched type as SEND_NON_EMPTY_VERSION_PACKET. - CI: a pq-functional job in ci-pq.yml (a non-matrix job sharing the unit job's source guard, checkout and verification) runs the whole suite with --v2pqtransport from ci/test/00_setup_env_native_pq_functional.sh. It runs nightly through main's caller like every ci-pq job, and on a branch by named dispatch (jobs=pq-functional). It joins aarch64-unit, s390x-unit, macos-arm64-unit and macos-x86_64-unit in every job list, the outputs and NON_MATRIX_JOBS. Its timeout is twice a measured local run, rounded up: a cold build at -j 4 on 4 cores (475 s) plus the whole suite at -j 4 (727 s) is 1,202 s, so 45 minutes. The i686 env notes that it covers plain v2. ci/checks/test_ci_pq_contract.py covers the job, its selection, its source wiring and its environment. Work item G3 of #212 (#184 part 7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: damage the key confirmation itself in the v2 peer's unit test test_bad_confirmation released the initiator's held key confirmation, hybrid packet 0, and encrypted a damaged one after it as packet 1. The responder still expected packet 0, so all three cases failed on a length decrypted with the wrong cipher position, and the tag and ignore-bit checks were never reached: the test passed with either check removed. Damage the held confirmation through make_confirmation instead, so it is packet 0 as a real one is. Record which check failed, and require that each case fails its own: length, tag or not_decoy, the reasons the node logs. Dropping the tag or the ignore-bit check in receive_confirmation now fails the test. For #212 (#184 part 7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: find the hybrid vectors from a Windows build's copy of the framework On Windows the build links or copies test/functional into the build tree instead of symlinking it, so the v2 peer's unit test looked for src/test/data/pq_transport_vectors.json under the build directory and failed the Windows native job. Take the source tree from the build's config.ini when the file isn't beside the module's source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hybrid v2 transport (#240) * bench: add a V2Transport handshake benchmark, legacy and hybrid, native and portable Time one complete in-memory handshake between a V2Transport initiator and responder per iteration: fresh keys, production entropy and ML-KEM code, no garbage, and done only when both sides report v2 with the same session id and, for hybrid, the peer's verified key confirmation. Legacy v2 and hybrid each run on the detected ML-KEM-1024 backend and with portable C forced (mlkem::ForcePortableForTesting, as the ML-KEM benchmarks do). Each benchmark is named after the transport, the bytes on the wire per handshake (measured, and checked on every iteration) and the backend that runs; legacy v2 runs no ML-KEM code, so its two runs show that the backend selection costs it nothing. Part of #184 (part 8, #215, H1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * doc: add the hybrid v2 transport operator guide and release note doc/user/network/pq-transport.md, listed next to Tor and I2P, is the operator guide: a two-node regtest quick start that ends in a cleanup step, a two-line BIP324 primer, how to switch the feature on and off (numeric booleans, the warnings for a non-numeric value or a double negative, -v2transport=0), how to verify it (the startup line, getpqtransportinfo, getpeerinfo transport_pq and transport_pq_status, -netinfo's 2p and getnetworkinfo connections_pq), the fallback and load shedding in plain words, what it protects and what it doesn't, where the log is, a troubleshooting table keyed by log text and counter name, and upgrading and rolling back, with -mlkemportable as the escape hatch. Every string, default, threshold and window is the code's: the feature is off by default in this stack, inbound outcomes and successful switches are debug-only, nothing it records survives a restart, and v1.0.0 (checked with its release binary) ignores v2pqtransport=1 in qbit.conf but refuses to start with -v2pqtransport on its command line. doc/release-notes-240.md says what it protects and doesn't, the default and how to switch it on and off. The specification, the guide and the release note link to each other. bitcoin-core-differences.md, rpc-delta-reference.md (getpqtransportinfo, getpeerinfo and getnetworkinfo's fields) and fuzzing.md (the hybrid v2 transport targets) are updated. The interface names the three documents quote are marked for the token check that follows, and the guide's quick start blocks for its test. Part of #184 (part 8, #215, H3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lint: check the interface names the PQ transport docs quote test/lint/lint-pq-doc-tokens.py, which the lint runner runs with the other test/lint/lint-*.py checks, reads every Markdown file under doc/ that marks a name with <!--pq:KIND--> before an inline code span (not the archived release notes), and fails when a marked name doesn't exist: - opt: an option qbitd -help or -help-debug, or qbit-cli -help, lists, given those binaries; without them, an AddArg() call, which is what -help prints; - rpc: a method registered with RPCHelpMan; - field: an RPC result field set with pushKV(), or a dotted path; - value: exactly a C++ string literal; - log: part of a C++ string literal, with … for each printf conversion, at word boundaries. An empty, malformed or unterminated marker, a marker in a fenced code block and an unclosed code block fail with the file and line, so a typo can't drop a name, and the specification and the operator guide must mark some. Every run first tests the check: renaming -v2pqtransport, the transport_pq_status field and the load shedding log string in memory must make exactly those names fail, malformed markers must be reported where they are, and near misses must not pass. The lint job has no binaries; the quick start's functional test runs the check with the build's, so CI also checks against real -help output. Part of #184 (part 8, #215, H3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: run the operator guide's quick start feature_pq_quickstart.py runs the three quick-start steps of doc/user/network/pq-transport.md as written, in one bash shell, with the build's qbitd and qbit-cli first on PATH; only the data directory and the four ports are replaced, by the framework's. Each step must print what the guide shows, where numbers, dates and the ML-KEM-1024 backend names may differ, and nothing on stderr. The quick start's nodes are stopped and their data removed even when a step fails; their logs are kept in the test directory. It also checks that the qbit.conf line the guide gives switches the feature on, through the real startup path, as the startup line and getpqtransportinfo show, that -nov2pqtransport on the command line overrides it, that the startup warnings the guide shows are what qbitd prints, and that the interface names the docs quote exist, with options from this build's -help and -help-debug output. Writing the test found that the hybrid confirmation can verify before the version handshake ends, which is why the guide waits for the first ping, not for connections_pq, before it shows -netinfo. Part of #184 (part 8, #215, H3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: run the PQ doc-token check on docs-only pull requests A pull request that changes only public documentation is classified public-docs and skips the lint job, so lint-pq-doc-tokens.py never saw an edit to the operator guide, the specification or the release note alone: a docs-only PR could quote an option, RPC field or log string that doesn't exist. Run the check in the public-docs-lint job as well. It needs only the sources, so it reads options from the AddArg() calls there; feature_pq_quickstart.py still runs it against real -help output. Part of #184 (part 8, #215, H3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: substitute the quick start's ports in one pass The quick-start test replaced each of the guide's example ports in turn, so a port the framework allocated for one node could equal a guide port replaced later, and be rewritten. With --portseed=1456, node A's RPC port is 18512, which node B's substitution then turned into 18513: both nodes used one port and the test failed. Build the replacement map first and apply it in one pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* net: turn hybrid v2 transport on by default Release N ships hybrid v2 transport on by default (#222, R1). DEFAULT_V2_PQ_TRANSPORT is now true: a node with v2 transport, also on by default, offers and accepts hybrid keys unless -v2pqtransport=0, -nov2pqtransport or v2pqtransport=0 in qbit.conf switches it off. The startup warnings need no change: "has no effect because -v2transport=0" fires only for an ON value given explicitly (1, an empty value, a non-numeric value read as 1, or a double negative), never for the default or an explicit 0. rpc_pqtransport.py now checks each case, including a non-numeric value with -v2transport=0, which is read as 0 and must not report the contradiction, and the same settings in qbit.conf. Tests that assumed the old default: - rpc_pqtransport.py starts from the default, switches it off with -v2pqtransport=0 and in qbit.conf, checks precedence both ways, and checks that getpqtransportinfo, the startup line and transport_pq_status agree for each -v2transport (0, 1) and -v2pqtransport (default, 0, 1). - p2p_v2_previous_release.py: a node with the default and v1.0.0 connect both ways, as legacy_peer over v2 and over v1, and v1.0.0 ignores v2pqtransport in qbit.conf but refuses it on its command line, as the operator guide says. The functional suite's run modes keep their meaning, as #212's G3 set them up for this change: the default mode runs v1 between nodes, and tests that start nodes with v2 now get hybrid; plain --v2transport (the i686 job of ci.yml) passes -v2pqtransport=0, and --v2pqtransport (pq-functional) -v2pqtransport=1, explicitly, so both jobs keep their coverage whatever the default, also if the release-cut decision reverts this commit. ci/checks now checks each mode's node arguments, and the help text and comments say what each mode means. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * doc: hybrid v2 transport is on by default The operator guide, the release note and the Bitcoin Core differences page say the feature is on by default, and how to switch it off. The quick start starts its nodes without -v2pqtransport, so it shows the default, and Switch It On or Off gives v2pqtransport=0, with v2pqtransport=1 as the explicit form. Upgrading and Rolling Back say that an upgrade switches it on; that an operator who wants it off adds v2pqtransport=0 to qbit.conf before upgrading, since v1.0.0 ignores it there but refuses any form of it on its command line; and that a node that never set the option has nothing to remove before a rollback. feature_pq_quickstart.py checks the default, the guide's new qbit.conf line and the command-line overrides, and that the quick start sets no -v2pqtransport. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: run the hybrid functional suite on pull requests that touch the transport With hybrid v2 transport on by default, the only whole-suite v2 run on pull requests, the i686 job, is plain v2: a regression that only the hybrid suite catches would surface nightly, through ci-pq.yml's pq-functional job. The classifier gets a pq_functional_required output: true whenever pq_unit_required is, and for the hybrid transport paths the PQ unit jobs neither build nor run: src/init.cpp (the hybrid setting), src/rpc/net.cpp (the hybrid RPC fields), rpc_net.py (whose hybrid checks run only in the hybrid suite), the p2p_v2_*.py tests, the test framework (the run modes and the Python peers), and the job's own setup. The macOS and AArch64 unit jobs still start only for pq_unit_required. A new pq-functional job of Full Validation calls ci-pq.yml with only pq-functional on the change itself, on its GitHub-hosted ubuntu-24.04 runner, when that output is true and the pull request is not a draft. The Full Validation Gate needs it and treats it as it treats pq-unit, now through one function: it must succeed when required, may be skipped when not, and a missing or other classifier output fails the gate. The Required Merge Gate waits for the Full Validation Gate, so it needs no change. test_pq_merge_gate_contract.py checks both jobs: the call and what ci-pq.yml then runs, each job's condition, the gate's needs, env and outcomes, and changed files traced through classify-changes' outputs to each job's condition and the gate; and that every path the classifier names exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
run_externally_generated_address_test refilled node 0's keypool to 1,000 before copying its wallet to node 2. For P2MR descriptors each new key derives and persists post-quantum key material, so on a busy machine the call took longer than the 30 s RPC timeout and failed the test. After the copy the test hands out three addresses and one change address; refill 10. On a loaded host the test failed this way at 1,000 and passes in about 13 s at 10. Fixes #242. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The benchmarks include crypto/pqc.h, which includes libbitcoinpqc's headers, but bench_bitcoin got libbitcoinpqc's include directory only when the wallet was built. With -DBUILD_BENCH=ON -DENABLE_WALLET=OFF, ccoins_caching.cpp and checkinputs_p2mr_pqc.cpp failed to compile: 'libbitcoinpqc/slh_dsa.h' file not found. Link bitcoinpqc directly, as the benchmarks use it directly. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
755e02a added feature/pq-transport to the branch lists of Full Validation, Core Checks and the Required Merge Gate, so pull requests into it got the same validation as 1.x.x while it collected the #184 work. The branch now merges into 1.x.x; remove the entries, leaving the three branch lists as they are on 1.x.x. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
@codex review |
|
Codex Review: Didn't find any major issues. Already looking forward to the next diff. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
P2PConnection._write_unwritten() checked once that the transport was open, then wrote every queued message. A failed write, for example after the node disconnects the test peer, closes the transport. asyncio then drops each later write, and from the fifth on it also logs "socket.send() raised exception." on stderr. The test runner fails a test that writes to stderr, so feature_assumevalid.py, which sends blocks until the node disconnects it, failed although its checks passed. Check the transport before each write, as the per-message callback did before the queue was added. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
@codex review |
|
Codex Review: Didn't find any major issues. What shall we delve into next? Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
Reviewed at 529e19f: 0 findings shared privately with maintainers. This review is partial: the PR was too large to read in full. |
…253) Core Checks' build smoke job installs its build dependencies, and the cross compilers, qemu and Clang that the ML-KEM build-policy test needs, on every run: about 240 MB. Hosted runners fetch packages from the Azure mirror of their own region first. In westus3 that mirror served packages at about 30 kB/s, while jobs of the same run in other regions fetched the same packages at 40 MB/s. Build smoke ran there on every attempt and reached its 45-minute limit before the build. apt moves to the next mirror only when a download fails, not when it is slow. - Try the main Ubuntu archive first, and keep the regional mirror as the last fallback. - Cache the downloaded packages between runs. apt reuses a cached package of the expected size without checking its hash, so only push runs save the cache, as with the ccache caches, and a pull request cannot change what another run installs. - Raise the limit to 90 minutes, so that a run that misses the cache can still finish on a slower mirror. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
@codex review |
|
@qbit-reviewer review |
|
Codex Review: Didn't find any major issues. Breezy! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
Reviewed at 36868cc: 0 findings shared privately with maintainers. This review is partial: the PR was too large to read in full. |
When the Python v2 peer fails the node's key confirmation, it writes out what it has sent and closes the connection, so that the node sees the peer's own confirmation fail (#246). But a close with the node's later bytes still unread on the peer's socket resets the connection, and on Windows the reset discards what the node has not read yet: the node then records closed_after_switch (reset) instead of first_packet_failed, and p2p_v2_pq_misbehaving.py fails. The node sends its version packet right after its confirmation, so this depends on timing. End the peer's stream with write_eof() instead and let the node close the connection, discarding what the node still sends until then. Sends after that are dropped, since a write after write_eof() raises. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
@codex review |
|
Codex Review: Didn't find any major issues. Keep them coming! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
Reviewed at 543fc53: 0 findings shared privately with maintainers. This review is partial: the PR was too large to read in full. |
* ci: check mainnet publication posture for 1.1.0 The "mainnet publication posture" job checks that the source being released keeps the mainnet launch posture: the AuxPoW chain ID, the genesis and ASERT launch data, the mainnet bootstrap, and the default-mainnet build. The "Classify release target" step only set mainnet_publish_gate_required for target branch 1.0.0, so pull requests, merge-queue runs and pushes into 1.1.0, the next mainnet release line, skipped the job and the Core Checks Gate never asked for its result. Set the output for 1.1.0 as well, and keep 1.0.0. The job's `if:` and the Core Checks Gate both read this output, so for source and release-policy changes into 1.1.0 the job now runs and a failure blocks the gate. Pin both branches in the existing workflow contract test, and update the release process, which names the branches the job runs for. Refs #222 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build: bump version to v1.1.0-rc1 Set CLIENT_VERSION_MINOR to 1, CLIENT_VERSION_BUILD to 0 and CLIENT_VERSION_RC to 1 so the source identifies as v1.1.0-rc1, the first release candidate of v1.1.0, the release after v1.0.1 that adds the hybrid post-quantum v2 transport (#184). CLIENT_VERSION_IS_RELEASE stays true: 1.1.0 is a release line, so candidate binaries carry no commit suffix, as for v1.0.0 and the v1.0.1 candidates. CMake appends "-rc1" to CLIENT_VERSION_STRING, so every binary reports v1.1.0-rc1, the user agent becomes /qbit:1.1.0-rc1/, and the Windows installer metadata and the RPC docs manifest's project version derive from the same string. The Guix archive name comes from the v1.1.0-rc1 tag itself. Regenerate the seven manual pages with contrib/devtools/gen-manpages.py (help2man 1.49.3) from a build of this tree with the GUI, the wallet and every tool. Besides the version and date lines, qbitd.1 and qbit-qt.1 gain -v2pqtransport, which #184 added as a connection option. The other options #184 added (-mlkemportable and the regtest-only -test values) are debug options that only -help-debug lists, so the pages don't show them. util_tests/test_FormatSubVersion expected the "Test" client name to format as CLIENT_VERSION_STRING, which fails for every version with a release-candidate suffix: FormatSubVersion uses that string only for the qbit user agent and formats any other name numerically. Apply the same fix as the v1.0.1-rc1 preparation (#181), byte for byte, so that merging 1.x.x into 1.1.0 brings an identical change: expect the numeric form built from the version components. The qbit user-agent checks on the next lines still cover CLIENT_VERSION_STRING. Refs #222 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * doc: draft v1.1.0 release notes Add doc/release-notes-1.1.0.md in the structure of the v1.0.1 notes (#181). It covers every change merged to 1.1.0 since it branched from 1.x.x at 45e9e72: the hybrid post-quantum v2 transport, with the parts and fixes merged through #233 (#223 to #254), and #249. Behavior changes come first: the hybrid transport is on by default, how to switch it off, the numeric -v2pqtransport value, the single I2P session-creation limit and qbit-photon's OpenSSL configuration loading. A compatibility table states how v1.1.0 nodes connect with v1.0.0 and v1.0.1 nodes and over v1, in both directions, as p2p_v2_previous_release.py and rpc_pqtransport.py test it. Known limitations summarize the release fuzz campaign and the limits its report on #222 states, the basis of the load-shedding threshold, the portable ML-KEM code on AArch64 Linux, and the unsigned-platform waiver (#70). The only performance figures are the handshake benchmark results published in #240. Fold in and delete the 1.1.0 fragments doc/release-notes-223.md, -240.md and -249.md, and point the specification's and the operator guide's release-note links at the new notes' hybrid transport section. The v1.0.0 and v1.0.1 fragments are left alone. The notes say that v1.1.0 contains every change in v1.0.1. That holds once 1.x.x is merged into 1.1.0 after v1.0.1 ships; the short "Changes in v1.0.1" section then gains a link to the v1.0.1 notes. Refs #222 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * net: say how the load-shedding threshold was set The comment on DEFAULT_PQ_SHED_THRESHOLD_PER_S said that the release gate's flood measurement (#222, R3) would set its final value. R3 was settled with CPU benchmarks of the handshake instead, and the threshold stays at 1,000 offers per second, as the v1.1.0 release notes say. Say so, so the comment no longer points at a change that is not coming. Refs #222 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * doc: cite the new fuzz seeds in the v1.1.0 notes #255 merged into 1.1.0 after the notes were drafted. It adds generated seeds to the required fuzz corpus for the hybrid transport's targets, so the test section now cites it alongside the earlier seed changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Integration branch for #184, hybrid post-quantum v2 transport (ML-KEM-1024 + ECDH), plus the fixes made along the way. Each part lands here as its own reviewed PR, with full CI and agentic review. This PR is where the combined change is reviewed and merged into
1.1.0.Merged into
1.1.0as db8dbe5 (2026-10-26), with full CI and agentic review clean on its last head, 543fc53. Every part below merged here first, and #245 removed this branch from the CI branch lists. Parts 1 to 8 leave the hybrid transport off by default. Part 9 (#222 R1, #241) turns it on, so release 1.1.0 ships it on by default.Base:
1.1.0, the release line after 1.0.1. It branched from1.x.x, which becomes 1.0.1 and takes no more feature merges. Its first commit (7d2f240) adds it to the CI branch lists.1.1.0has no branch protection; this PR still merges only with full CI and agentic review clean. The Guix check (#190 D4) runs on the release candidate, as part of the release gate (#222).Parts
Fixes found along the way
wallet_listtransactions.pyrefills 10 keys instead of 1,000, which timed out on loaded runners (#242)bench_qbitbuilds without the wallet (missing libbitcoinpqc include directory)V2TransportTesterowns itsPQKemOpstruein settings.json; ML-KEM and ECDH secrets in locked memory; SHA-256 CPU detection checks the maximum CPUID leaf; ML-KEM test guard and fuzz backend checkfeature_assumevalid.pyon i686 and CentOS): the Python test peer stops writing a batch once the node has closed the connectionbuild smoketimed out on every attempt at 529e19f, downloading its toolchains from a slow regional package mirror (westus3): it tries the main Ubuntu archive first, caches the packages, and allows 90 minutesp2p_v2_pq_misbehaving.py): the Python peer ends a failed handshake with a half-close instead of a close, which reset the connection and dropped its last bytesThe review's 20 findings, their severity and the PR that fixed each are listed in this comment.
Before merging
feature/pq-transportfrom the branch lists inci.yml,core-checks.ymlandrequired-merge-gate.yml(added in 755e02a so PRs into this branch get full CI): ci: stop validating feature/pq-transport by name #245 (290228b).1.1.0, which depends: upgrade OpenSSL for photon from 3.0.15 to 3.5.9 #249 moved to 0893eee. Pull request CI tests this branch merged with the current base: the run on 543fc53 passed, and the merged tree is the one it tested.How the parts merge
Decisions made
feature/pq-transportfirst, because approvals on1.x.xweren't available.1.x.xbecomes 1.0.1 and takes no more feature merges, so this PR targets the new1.1.0branch, cut from1.x.x.-nov2pqtransportand-mlkemportablewith non-numeric or double-negative values warn whenever the result is ON. The argument parser itself is unchanged, to stay aligned with upstream.-mlkemportableis a hidden option (part 6), and the local-fault warning names it.-test=pq_low_shed_thresholdand-test=mock_steady_clock. It has no real-time or skip path.CI notes
pq-transport) starts Full Validation and the Required Merge Gate. Relinking a stack can cancel running CI, so link before CI starts or after it ends.Disk space is too low!; the code is fine. Run one PR's Full Validation at a time, then rerun the failed jobs. The host needs pruning or more disk.apt-get updatecan stall until the job's time limit (CentOS, Core Checks' build smoke). Rerun the job.pq-functional(nightly, or on a named dispatch) hasn't run on GitHub yet. Its 45-minute limit is twice the measured cold 4-core build plus the whole--v2pqtransportsuite.Target Branch
1.1.0.Part of #184
🤖 Generated with Claude Code