Skip to content

Hybrid post-quantum v2 transport (#184) — integration - #233

Merged
djh58 merged 24 commits into
1.1.0from
feature/pq-transport
Oct 26, 2026
Merged

djh58 merged 24 commits into
1.1.0from
feature/pq-transport

Conversation

@djh58

@djh58 djh58 commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

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.0 as 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 from 1.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.0 has 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

Part PR Status
1. I2P post-quantum leasesets (#185) #223 merged
2. Foundations: release tooling, Python ML-KEM, PQ CI, canary (#186) #224 merged
3. ML-KEM library and runtime backend selection (#190) #225 merged
4. Hybrid handshake with key confirmation (#199) #230 merged
5. Outcomes, close causes, fallback, load shedding (#206) #235 merged (with the fallback-eviction fix)
6. Option, startup line, RPC, GUI (#209) #236 merged (9e2e884), with a fix for Windows line endings in its test
7. End-to-end tests (#212) #239 merged (8586855), with a fix for finding the test vectors from a Windows build
8. Benchmark, operator guide, release note (#215; the flood harness stays open there) #240 merged (5da8c1f)
9. Hybrid transport on by default (#222 R1) #241 merged (4d4b11a)

Fixes found along the way

PR What
#228 (merged) RPC docs CI checks out the event commit, not the moving merge ref
#229 (merged) Cadence benchmark no longer builds an unbounded chain (OOM)
#231 (merged) Required Merge Gate waits for a newer run instead of failing on an earlier one's result
#237 (merged) Backport bitcoin#35175: build with Boost 1.91+ (multi_index index lists)
#243 (merged, 3c4bcb1) wallet_listtransactions.py refills 10 keys instead of 1,000, which timed out on loaded runners (#242)
#244 (merged, 507a171) bench_qbit builds without the wallet (missing libbitcoinpqc include directory)
#245 (merged, 290228b) Removes this branch from the CI branch lists
#246 (merged, 19e45c8) Review of this PR: I2P session creation keeps its 3-minute limit through clock steps; the Python v2 peer delivers its bytes before disconnecting; V2TransportTester owns its PQKemOps
#247 (merged, d58c327) Review of this PR: every gate lists both sides of a rename; docs-only edits to the spec and guide run their checks; the doc-token lint rejects misspelled markers and hidden options
#248 (merged, 56b26f7) Review of this PR: no false warning for true in 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 check
#250 (merged, f376093) Review of this PR: manual peers never fall back, plus the connection manager's fallback and accounting state
#251 (merged, 529e19f) Fixes a regression from #246 that failed this PR's Full Validation (feature_assumevalid.py on i686 and CentOS): the Python test peer stops writing a batch once the node has closed the connection
#253 (merged, 36868cc) Core Checks' build smoke timed 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 minutes
#254 (merged, 543fc53) Fixes a timing-dependent failure from #246 on Windows (p2p_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 bytes

The review's 20 findings, their severity and the PR that fixed each are listed in this comment.

Before merging

How the parts merge

Decisions made

CI notes

  • Stacked PRs need a label to get full CI. A PR opened before it joins a stack runs only Core Checks and RPC Docs. Adding a label (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.
  • The self-hosted runners' disk fills up. They share one 234 GB disk, and two PRs' Full Validation runs at once fill it. Sanitizer, CentOS and i686 jobs then fail together with 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.
  • Runner starvation. Many PRs pushed at once fill the hosted job limit. Jobs that wait about 15 minutes are cancelled, and the gates then fail.
  • Blacksmith Windows runners sometimes die, showing "failure" with no steps and no log. Rerun the job.
  • Windows failures hide later ones. On pull requests the functional suite stops at its first failure, so after a Windows fix, rerun the Windows jobs to reach the rest of the suite. They run on the Blacksmith runners, not the shared disk, so rerunning them alone never contends with another PR's run.
  • Mirror timeouts. A depends download or apt-get update can stall until the job's time limit (CentOS, Core Checks' build smoke). Rerun the job.
  • GitHub auto-rebases a stack when one of its PRs merges into the base. It doesn't when an outside PR merges, for a PR not yet linked into the stack, or when the stack is out of date. Keep stacked branches linear: a merge commit leaves the PR conflicting.
  • 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 --v2pqtransport suite.

Target Branch

  • This PR targets a maintainer-requested release branch: 1.1.0.

Part of #184

🤖 Generated with Claude Code

djh58 and others added 3 commits October 6, 2026 11:16
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>
@djh58

djh58 commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

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>
djh58 and others added 6 commits October 21, 2026 17:02
#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>
@kiwidream kiwidream mentioned this pull request Oct 22, 2026
9 of 18 tasks
@kiwidream

Copy link
Copy Markdown
Member

Heads-up: 1.x.x is frozen for the v1.0.1-rc1 release (#174, #181). Please don't merge this into 1.x.x until v1.0.1-rc1 is tagged. The rc1 release notes are cut at 45e9e72c2d (#182), and any further merge would make them stale. I'll post in #174 when the freeze lifts.

djh58 and others added 5 commits October 22, 2026 14:57
…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>
@djh58

djh58 commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: f3760931f1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

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>
@djh58

djh58 commented Oct 26, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 529e19f0bf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

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".

@qbit-reviewer

qbit-reviewer Bot commented Oct 26, 2026

Copy link
Copy Markdown

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>
@djh58

djh58 commented Oct 26, 2026

Copy link
Copy Markdown
Member Author

@codex review

@djh58

djh58 commented Oct 26, 2026

Copy link
Copy Markdown
Member Author

@qbit-reviewer review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 36868cce5b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

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".

@qbit-reviewer

qbit-reviewer Bot commented Oct 26, 2026

Copy link
Copy Markdown

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>
@djh58

djh58 commented Oct 26, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 543fc5373d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

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".

@qbit-reviewer

qbit-reviewer Bot commented Oct 26, 2026

Copy link
Copy Markdown

Reviewed at 543fc53: 0 findings shared privately with maintainers. This review is partial: the PR was too large to read in full.

@djh58
djh58 merged commit db8dbe5 into 1.1.0 Oct 26, 2026
54 checks passed
djh58 added a commit that referenced this pull request Oct 27, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants