Skip to content

Upgrade Rust toolchain to nightly-2026-01-22 - #4645

Merged
tautschnig merged 1 commit into
model-checking:mainfrom
tautschnig:toolchain-2026-01-22-manual
Jul 16, 2026
Merged

Upgrade Rust toolchain to nightly-2026-01-22#4645
tautschnig merged 1 commit into
model-checking:mainfrom
tautschnig:toolchain-2026-01-22-manual

Conversation

@tautschnig

@tautschnig tautschnig commented Jul 15, 2026

Copy link
Copy Markdown
Member

Advance from nightly-2026-01-14 to nightly-2026-01-22. Two source changes are required (both first needed at nightly-2026-01-15); every intermediate nightly up to 01-22 then builds and passes the regression with no further changes.

  • Remove Deref/DerefMut impl for Providers. rust-lang/rust#151096 removed the Deref/DerefMut impl for rustc_middle::util::Providers (which forwarded to its inner queries field). Access the query providers explicitly in provide.rs: providers.queries.optimized_mir and DEFAULT_QUERY_PROVIDERS.queries.optimized_mir.
  • rustc now renders unstable traits in E0277 as "the nightly-only, unstable trait" rather than "the trait"; update the float-to-int-in-range expected files (FloatToInt is unstable).

Resolves #4646
Resolves #4603

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 and MIT licenses.

Advance from nightly-2026-01-14 to nightly-2026-01-22. Two source changes are
required (both first needed at nightly-2026-01-15); every intermediate nightly
up to 01-22 then builds and passes the regression with no further changes.

- rust-lang/rust#151096 removed the `Deref`/`DerefMut` impl for
  `rustc_middle::util::Providers` (which forwarded to its inner `queries`
  field). Access the query providers explicitly in provide.rs:
  `providers.queries.optimized_mir` and
  `DEFAULT_QUERY_PROVIDERS.queries.optimized_mir`.
- rustc now renders unstable traits in `E0277` as "the nightly-only, unstable
  trait" rather than "the trait"; update the float-to-int-in-range expected
  files (`FloatToInt` is unstable).

Co-authored-by: Kiro <kiro-agent@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 15, 2026 15:03
@tautschnig
tautschnig requested a review from a team as a code owner July 15, 2026 15:03
@github-actions github-actions Bot added Z-EndToEndBenchCI Tag a PR to run benchmark CI Z-CompilerBenchCI Tag a PR to run benchmark CI labels Jul 15, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates Kani to build and pass regression tests on a newer Rust nightly toolchain by accommodating upstream rustc API and diagnostic output changes introduced after nightly-2026-01-14.

Changes:

  • Bump rust-toolchain.toml from nightly-2026-01-14 to nightly-2026-01-22.
  • Adjust query provider wiring to explicitly access Providers.queries.* after upstream removal of Deref forwarding.
  • Update .expected UI test outputs for E0277 wording that now labels unstable traits as “nightly-only”.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
rust-toolchain.toml Advances the pinned Rust nightly used to build and test Kani.
kani-compiler/src/kani_middle/provide.rs Updates rustc query provider access to match the new Providers API shape.
tests/expected/float-to-int-in-range/invalid_int.expected Refreshes expected E0277 text to match new rustc wording for unstable traits.
tests/expected/float-to-int-in-range/invalid_float.expected Refreshes expected E0277 text to match new rustc wording for unstable traits.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@tautschnig

Copy link
Copy Markdown
Member Author

Seems like we are impacted by a Ubuntu outage: https://status.canonical.com/#/incident/KNms6QK9ewuzz-7xUsPsNylV20jEt5kyKsd8A-3ptQHQlVHKXwTShtGeZgLBjkPGsYE34Yqq6Qn0e1aU5TaseA%3D%3D - though finding further information proving pretty tricky.

@tautschnig
tautschnig enabled auto-merge July 16, 2026 17:12
@tautschnig
tautschnig added this pull request to the merge queue Jul 16, 2026
Merged via the queue into model-checking:main with commit e7fa2e8 Jul 16, 2026
60 of 77 checks passed
@tautschnig
tautschnig deleted the toolchain-2026-01-22-manual branch July 16, 2026 18:16
lubosruler added a commit to budlum-xyz/budlum that referenced this pull request Jul 30, 2026
The first attempt failed CI for a real reason, and the failure is worth
recording rather than papering over.

Kani ships its own pinned nightly. 0.67.0 — the newest published release —
bundles rustc 1.93.0-nightly, and `budlum-core` declares
`rust-version = "1.94.0"`, so cargo refused before a single harness ran:

    error: rustc 1.93.0-nightly is not supported by the following packages:
      budlum-core@0.1.0 requires rustc 1.94.0

The upstream fix is merged but unreleased: model-checking/kani#4645 moved the
bundled toolchain to a nightly new enough for 1.94, and Kani's `main` is now on
nightly-2026-02-15, but 0.67.0 is still the latest tag.

Three ways out, and only one of them is honest. Lowering the root crate's MSRV
would weaken a promise made to operators so that a tool could run. Pinning an
unreleased Kani would mean building it from a moving `main`. So the harnesses
move into a standalone `kani/` package with no `rust-version` and no dependency
on `budlum-core` — the same shape `fuzz/` already uses.

That makes the arithmetic a mirror rather than a call, which needs its own
guard. `bond_arithmetic_matches_the_kani_mirror` recomputes both expressions
across the boundary values the proofs care about — zero, one, `u64::MAX`, and
every ratio endpoint — and fails if they ever disagree. It also asserts
`FIXED_POINT_SCALE` is still 1_000_000, since the mirror hardcodes it. Calling
`slash_role_only` directly was never on the table: it needs a populated
`BTreeMap` of registrations that a bit-precise model checker would have to
unroll, and the map is not what is under proof.

The gate now reads `kani/src/lib.rs` for the declared harness count, and the
workflow runs `cargo kani` with `working-directory: kani`. The canary still runs
before Kani is installed, so the "is this gate vacuous" question is answered
even when the toolchain setup is what breaks.

SECURITY.md records the packaging constraint alongside what is proved, so the
next reader does not have to rediscover why the harnesses sit outside the crate
they describe.
lubosruler added a commit to budlum-xyz/budlum that referenced this pull request Jul 31, 2026
* feat: model checking for bond arithmetic, and a gate that can fail

SECURITY.md listed Kani as open work, with the reason recorded: a
`scripts/check-kani.sh` existed that printed a stub message and pointed at a
`src/crypto/kani.rs` that was not in the tree. No workflow ran it and there were
no `#[kani::proof]` harnesses anywhere, so it counted as a gate while proving
nothing. It was deleted rather than left implying coverage.

This is the replacement, against the target SECURITY.md itself nominated: bond
arithmetic is bounded, self-contained, and decides how much stake a validator
loses.

Five harnesses over the penalty computation in `slash_role_only`:

  - a penalty never exceeds the bond it is taken from
  - `remaining + penalty == stake` exactly, so the `saturating_sub` is not
    masking an underflow
  - the 0% and 100% ratios are exact, leaving no dust and taking nothing
  - the penalty is monotonic in the ratio, so raising a slash ratio through
    governance can never reduce the actual penalty
  - dropping the `ratio <= FIXED_POINT_SCALE` precondition breaks the bound,
    which records `RegistryParams::validate` as load-bearing rather than
    incidental

`kani::any()` is every value of the type, so these eliminate the whole input
space rather than the points a unit test samples. The existing proptests stay.

The harnesses mirror the registry's arithmetic rather than calling the method,
because the method needs a populated `BTreeMap` of registrations that model
checking would have to unroll. The mirror is one expression and it is the
subject of the second gate condition below, so it cannot rot unnoticed.

The gate checks two things. A failing proof fails it, and so does a run whose
harness count is lower than the number declared in the source. The second is
the specific way the deleted script was hollow: a stray `cfg`, a renamed
module, or a stale `--harness` filter would otherwise leave the gate green with
nothing behind it.

The canary runs before Kani is installed and proves the gate rejects a FAILED
verification, empty output, and a short run, while accepting a full one. Kani is
pinned to 0.67.0.

`kani_proofs` is behind `cfg(kani)`, so ordinary builds, tests and clippy never
compile it. SECURITY.md is updated to describe what now runs; signature
verification and Merkle paths are left recorded as still open, since both reach
into third-party crypto that needs bounded harnesses written first.

* fix: put the Kani harnesses where Kani can actually build them

The first attempt failed CI for a real reason, and the failure is worth
recording rather than papering over.

Kani ships its own pinned nightly. 0.67.0 — the newest published release —
bundles rustc 1.93.0-nightly, and `budlum-core` declares
`rust-version = "1.94.0"`, so cargo refused before a single harness ran:

    error: rustc 1.93.0-nightly is not supported by the following packages:
      budlum-core@0.1.0 requires rustc 1.94.0

The upstream fix is merged but unreleased: model-checking/kani#4645 moved the
bundled toolchain to a nightly new enough for 1.94, and Kani's `main` is now on
nightly-2026-02-15, but 0.67.0 is still the latest tag.

Three ways out, and only one of them is honest. Lowering the root crate's MSRV
would weaken a promise made to operators so that a tool could run. Pinning an
unreleased Kani would mean building it from a moving `main`. So the harnesses
move into a standalone `kani/` package with no `rust-version` and no dependency
on `budlum-core` — the same shape `fuzz/` already uses.

That makes the arithmetic a mirror rather than a call, which needs its own
guard. `bond_arithmetic_matches_the_kani_mirror` recomputes both expressions
across the boundary values the proofs care about — zero, one, `u64::MAX`, and
every ratio endpoint — and fails if they ever disagree. It also asserts
`FIXED_POINT_SCALE` is still 1_000_000, since the mirror hardcodes it. Calling
`slash_role_only` directly was never on the table: it needs a populated
`BTreeMap` of registrations that a bit-precise model checker would have to
unroll, and the map is not what is under proof.

The gate now reads `kani/src/lib.rs` for the declared harness count, and the
workflow runs `cargo kani` with `working-directory: kani`. The canary still runs
before Kani is installed, so the "is this gate vacuous" question is answered
even when the toolchain setup is what breaks.

SECURITY.md records the packaging constraint alongside what is proved, so the
next reader does not have to rediscover why the harnesses sit outside the crate
they describe.

* style: rustfmt the mirror assertion

`cargo fmt --all -- --check` fails on one call in
`bond_arithmetic_matches_the_kani_mirror`: rustfmt puts the two short
`assert_eq!` operands on one line when they fit, and the hand-written version
split them across three.

* fix: the mirror tripped the pedantic ratchet

`clippy-extra: 7114 | baseline: 7108`. The six new warnings are all `as` casts
in the code this branch added — `cast_lossless` on the widening `u64 -> u128`
and `cast_possible_truncation` on the narrowing `u128 -> u64`.

The ratchet is doing its job, so the fix is the casts, not the baseline.
`u128::from` for the widenings and `u64::try_from(...).expect(...)` for the
narrowing. The `expect` is not a new assumption: `penalty_never_exceeds_stake`
is the harness that proves the quotient fits in a `u64`.

`kani/src/lib.rs` is rewritten the same way so the mirror and the test that
guards it stay literally comparable.

`an_unbounded_ratio_would_overshoot_the_bond` now computes the quotient
directly instead of calling `penalty_for`. That harness deliberately violates
the precondition, and `penalty_for` would panic on the `try_from` before the
assertion could be reached — the harness would have been proving that a panic
happens rather than that the bound is load-bearing.

* style: rustfmt the mirror helper

The `u128::from` rewrite left `kani_mirror` split across four lines where
rustfmt fits it in two.

* fix: Kani rejected one of the claims, and it was right

First real run of the harnesses, and it found a bug in a harness rather than in
the code under proof. Worth keeping the record.

`an_unbounded_ratio_would_overshoot_the_bond` asserted that a ratio above
`FIXED_POINT_SCALE` makes the penalty strictly exceed the bond. Kani produced a
counterexample in 2.9 seconds:

    stake = 1, ratio = 1_000_001
    (1 * 1_000_001) / 1_000_000 = 1        // equal to the stake, not above it

The truncating divide swallows the excess whenever `stake * (ratio - SCALE)` is
smaller than `SCALE`. The property that actually holds is `>=`: past the bound
the penalty stops being capped by the bond. That is the thing `validate()` is
buying, so that is what the harness now says. A second harness pins a concrete
overshoot at a 200% ratio, so `>=` cannot be read as "it only ever reaches the
bond".

The run was then cancelled at the 45-minute timeout, inside
`penalty_is_monotonic_in_the_ratio`. Three unconstrained `u64`s turn that into
two 128-bit multiplications compared against each other, which CBMC does not
close in a CI budget. Split in two: the stake is bounded to 32 bits while both
ratios stay free, and a companion harness lifts the stake to the full `u64`
range while fixing the ratio step at one unit — the case where truncation is
most likely to swallow an increase. Between them the property is covered
without asking the solver for the full cross product.

Seven harnesses now. The gate counts `#[kani::proof]` in the source, so the
expected count follows automatically.

* ci: give Kani a budget that matches what it measured

The first real run was cancelled by the 45-minute job timeout while still
inside one harness. The harness has been split since, but the timeout was a
guess and the run turned it into a measurement, so it should move too.

90 minutes on the job, and `--harness-timeout 15m` so a single harness that
blows up fails on its own instead of consuming the whole budget and reporting
as a cancellation — a cancelled job says nothing about whether the proofs hold,
which is the least useful way for this gate to fail.

Both numbers are provisional and should come down once a green run gives a real
duration to size against.

* ci: drop --harness-timeout, it is an unstable option

    error: The `--harness-timeout` option is unstable and requires
           `-Z unstable-options` to be used.

Adding `-Z unstable-options` would make it work today and make the gate depend
on an interface Kani is explicitly not committing to. A gate that breaks on a
routine tool upgrade is worse than a gate with a coarser failure mode, so the
flag is removed rather than force-enabled.

The 90-minute job timeout stays as the backstop. It cannot say which harness
ran long, which is the thing `--harness-timeout` would have bought — noted in
the workflow so the trade is visible.

* perf: stop asking CBMC for two symbolic operands at once

Second run, cancelled again at the timeout, and this time the log names the
cost precisely:

    an_unbounded_ratio_can_strictly_exceed_the_bond  ...  0.043s
    an_unbounded_ratio_would_overshoot_the_bond      ...  cancelled at 90m

The two harnesses assert nearly the same thing. The difference is that the fast
one fixes the ratio and the slow one leaves it free. `stake * ratio` is a
128-bit multiply; with one side concrete CBMC closes it in milliseconds, with
both symbolic it does not close at all. That is the whole story, and it applies
to three harnesses here, not one.

So each now leaves exactly one side symbolic:

  - `an_unbounded_ratio_would_overshoot_the_bond` keeps the stake fully
    symbolic and iterates two concrete ratios — the smallest possible violation
    and a large one. Truncation is what the claim turns on, and truncation is a
    property of the stake, so that is the side worth quantifying over.
  - `penalty_is_monotonic_in_the_ratio` keeps both ratios symbolic but as
    `u16`s scaled across the range, so the pair still spans every ratio
    relationship at two 16-bit symbols instead of two 64-bit ones.
  - `penalty_is_monotonic_for_full_stakes` fixes the ratio at the 50% default
    and leaves the stake symbolic over the whole `u64`.

Together they cover what the unbounded versions claimed, without asking the
solver for a cross product it cannot finish. The properties are unchanged; only
the quantification is arranged to be provable inside a CI budget.

* perf: the cost was the division, not the multiplication

Third cancellation, same harness, and this time the log settles what the
previous two guesses got wrong. Two harnesses in this file assert almost the
same thing about almost the same expression:

    an_unbounded_ratio_can_strictly_exceed_the_bond   ratio = 2 * SCALE    0.04s
    an_unbounded_ratio_would_overshoot_the_bond       ratio = SCALE + 1    never

Both have a symbolic `u32` stake. Both do a 128-bit multiply. The one that
returns is the one whose divisor folds: `x * 2_000_000 / 1_000_000` becomes
`x * 2` before CBMC sees a division at all, while `x * 1_000_001 / 1_000_000`
is a genuine 128-bit divide with a symbolic dividend, which CBMC models bit by
bit.

The earlier commit blamed "two symbolic operands in a multiply" and constrained
the ratio to fixed steps. That was the wrong diagnosis — one of those fixed
steps was `SCALE + 1`, so the division survived and the harness still hung. The
first three harnesses in this file divide too, with a fully symbolic `u64`
stake *and* a symbolic ratio, and they were never the ones that stalled.

The fix is to drop the division rather than the quantification. For a positive
divisor, `(stake * ratio) / SCALE >= stake` iff `stake * ratio >= stake * SCALE`,
and the multiplication form is cheap. Both operands go back to being symbolic:
the stake across `u32`, and the ratio across every value above the bound rather
than the two the previous commit hand-picked. The harness now proves strictly
more than it did, and should return in the same time as its neighbour.

* perf: the symbolic ratio was the cost all along

Fourth cancellation on the same harness. Putting the four attempts side by side
finally isolates the variable, and it is not the one the last commit blamed:

    attempt  ratio                  division  result
    1        symbolic u64           yes       cancelled
    2        {SCALE+1, 2*SCALE}     yes       cancelled
    3        symbolic u32           no        cancelled
    -        2*SCALE (fixed)        yes       0.026s

Attempt 3 removed the division and still hung, which clears the divide of
blame. The neighbouring harness keeps its division and returns in 26
milliseconds. The only thing that tracks the outcome is whether `ratio` is
symbolic: a 128-bit multiply with both operands symbolic is what CBMC cannot
close, and dropping the division does not make it cheaper.

Attempt 2 looked like it contradicted that — it used fixed ratios and still
hung — but it fixed them by iterating a two-element array while the values
stayed `u64` expressions built from `FIXED_POINT_SCALE`; the multiply still had
two non-constant sides after monomorphisation.

So the ratio is now enumerated as concrete `u128` literals: the two smallest
values above the bound, one and a half times, and double. Truncation is most
likely to hide an overshoot just above the boundary, so that is where the
values cluster. The stake stays fully symbolic across `u32`, which is the side
the claim actually turns on.

I was wrong twice about this in the commit log above, and the record of the
wrong guesses is worth more than a clean history: the next person to hit a CBMC
timeout here has the table.

* fix: the test badge has been wrong since branch protection landed

README advertised 1542 tests. The suite has 1775. The badge was 233 behind, on
the front page of the repository.

The badge is written by a CI step that pushes to `main`. Every run since branch
protection was enabled has ended the same way:

    remote: error: GH006: Protected branch update failed for refs/heads/main.
    ! [remote rejected] HEAD -> main (protected branch hook declined)
    UYARI: BADGE_PUSH_TOKEN yok - GITHUB_TOKEN denecek
    ##[warning]Rozet 1773 olmali ama push 3/3 denemede reddedildi.

Then, deliberately:

    echo "UYARI (soft-fail): ... rozet kozmetiktir, is kirmizi yapilmaz."
    exit 0

The step reports success. Checked the last three `main` runs — the same warning
in all three, and the step green in all three. A warning nobody reads is not a
signal, and a badge that silently stops updating is worse than no badge: it
keeps making a specific, checkable, false claim.

The push channel cannot be repaired in the direction it was built. It needs an
admin PAT to bypass the protection that exists for good reasons, and it makes
the badge depend on one secret's lifetime — `BADGE_PUSH_TOKEN` is not set, and
the fallback cannot bypass protection by design.

So the direction is inverted. `scripts/check-badges-are-current.sh` compares
the README badge against the count the run measured and *fails the pull
request* when they disagree, printing the exact line to paste. The number then
arrives through review like every other change, and the 137-line push step with
its retry loop, PAT handling and extraheader workaround is deleted.

The canary covers the ways this gate could be hollow: a badge one test behind
must fail, a comparison against a red run must fail, empty output must fail,
and an unparseable log must fail rather than pass by default. A matching pair
must pass, or the gate would reject every pull request.

The coverage badge had the same shape of problem and is fixed the same way. It
read `nextest+llvm-cov` — a tool name, not a measurement, unchanged since it was
first committed, while `.github/coverage-baseline.txt` says 64.30%. It now shows
the number. That one is not yet gated; `check-coverage.sh` already ratchets the
underlying value, so the badge drifting from it is the remaining gap and is
worth its own change rather than being bundled here.

* fix: bridging under 100 units was free for the relayer

Someone bridging into Budlum does not need a $BUD balance: the relayer's cut is
taken from the asset arriving, not from an account the user has not funded yet.
That is the right design and it already worked. The rate is the problem.

Three call sites in `blockchain.rs` each wrote the same literal:

    let fee = transfer.amount.saturating_mul(1) / 100;

Integer division rounds down, so a pure percentage charges nothing below
`100 / rate` units. Measured at the 1% those sites used:

    amount   1 -> fee 0
    amount  50 -> fee 0
    amount  99 -> fee 0
    amount 100 -> fee 1

Every transfer of 99 base units or less was relayed for free. The relayer still
pays external gas for each message, so an attacker splits a large bridge into
99-unit pieces, moves the value across at no cost, and bills the relayers for
the gas. Nothing in the code notices; each individual transfer looks fine.

Three copies of an economic constant is the second half of the problem. Change
one and the other two keep the old price silently.

`split_bridge_fee` in `cross_domain/bridge.rs` is now the single place the split
is computed, and it takes a floor. `RegistryParams` carries both numbers so they
are governance-tunable rather than compiled in:

  - `bridge_relayer_fee_ppm` defaults to 10_000 — exactly the 1% the call sites
    used, so this is not a silent repricing
  - `bridge_relayer_min_fee` defaults to 10, matching `slashing_report_fee` and
    `proof_submission_fee`

An amount that cannot cover the floor is refused rather than relayed at a loss.
`validate()` refuses a rate at or above 100%, which would credit the recipient
nothing — a bridge fee that takes everything is theft by parameter, and the
existing gate that keeps ratios in range is the right place to say so.

Tests state the property rather than the arithmetic: splitting a transfer must
never cost less in total than sending it whole. Paired with a test that large
transfers still pay the plain percentage, so the floor cannot quietly become
the whole fee.

* fix: route all three bridge fee sites through split_bridge_fee

The previous commit added the shared helper and the parameters. This is the
half that matters: the three call sites now use them, and
`saturating_mul(1) / 100` appears nowhere in the tree.

  - `mint_bridge_asset` (inbound mint)
  - the relay-driven mint path
  - the unlock path

Each reads the rate and floor from `RegistryParams` instead of carrying its own
literal, and each propagates the refusal when an amount cannot cover the floor
rather than relaying it at a loss.

* style: unparseable -> unparsable

The typos gate rejects `unparseable`; `unparsable` is the spelling it accepts.

* fix: two more copies of the bridge fee, in the executor

The first pass found three copies in `blockchain.rs` and called that the set. It
was not: `executor.rs` carries two more, written as `checked_mul(1) ... / 100`
instead of `saturating_mul(1) / 100`, which is why the first grep missed them.

Same arithmetic, same missing floor, same free bridge under 100 units — reached
through the relay-proof and unlock paths in `apply_transaction_checked` rather
than the `Blockchain` methods.

Both now call `split_bridge_fee` with the governance parameters, and an amount
that cannot cover the floor surfaces as `bridge_fee_below_minimum` rather than
being relayed at a loss.

The lesson is the grep, not the fix. Counting copies of a constant is worth
doing as its own pass, with more than one spelling in mind, before claiming a
set is complete. `checked_mul(1)` and `saturating_mul(1)` are the same
economic decision written two ways.

* feat: debit and refund the opener bond

`opener_bond` is described in five places as the anti-spam mechanism that makes
a permissionless challenge endpoint safe:

  storage_deal.rs:41   "`opener_bond` already debited from the caller's stake"
  storage_deal.rs:48   without it "the gate is economically meaningless"
  storage_deal.rs:168  "Bond is returned on success, burned on false positive"
  storage_deal.rs:286  "Opener bond returned, deal stays Active"
  rpc/api.rs:335       "the anti-spam mechanism is `opener_bond`"

No balance was ever touched. `open_challenge` checked the number against
`required_opener_bond(range_len)` and stored it in the struct; grepping the tree
for `opener_bond` alongside any balance operation returns nothing.

`storage_open_challenge` is a public RPC method. A caller with an empty account
could pass `opener_bond: 999_999`, clear the validation, and open a challenge
that costs them nothing. Each challenge costs the operator a read plus a hash
over the range, up to 16 MiB. `MAX_OPEN_CHALLENGES_PER_DEAL` and the
per-(operator, manifest) cooldown bound the rate, but a bound is not a price —
the doc comment for `OPENER_BOND_PER_KIB` says exactly this: the point of the
bond is to make griefing *capital-bound*, requiring locked stake proportional to
the damage. With nothing locked, that property was absent.

This commit adds the two halves the accounting needs. `debit_opener_bond` takes
the bond up front and refuses on insufficient balance; `refund_opener_bond`
returns it when the challenge resolves. Both mirror
`submit_registry_slashing_report`, which does the same debit-and-refund forty
lines further up in this file.

Wiring the call sites and the tests follow in the next commit, so the mechanism
and its use are separately reviewable.

* fix: charge the opener bond on the RPC challenge path

`ChainCommand::OpenStorageChallenge` is where a `storage_open_challenge` RPC
call reaches the chain. It verified the opener's signature, built the entropy,
and opened the challenge — without touching the opener's balance.

The bond is now debited before the challenge is opened, and refunded if
`open_challenge_with_entropy` refuses it. A challenge that is never created must
not leave the caller charged.

The refund on the failure path uses `let _ =` deliberately: the debit succeeded,
so the only way the refund fails is a balance overflow, and there is no better
outcome available inside an error path that is already returning the real
reason. The alternative — replacing the caller's error with an overflow message
— would hide why the challenge was refused.

* test: pin the opener bond accounting

Four tests, paired so the fix cannot be a gate that refuses everything:

  - an empty account cannot afford a bond, and a refused debit moves nothing
  - a funded opener pays exactly the bond and gets exactly it back
  - a zero bond is refused, agreeing with `open_challenge`'s `ZeroOpenerBond`
  - repeated challenges exhaust the *opener* — the property the bond exists for

The last one states the attack as an invariant. The rate limit bounds how fast
challenges can be opened; the bond is what makes sustaining them cost the
attacker rather than the operator.

* fix: put the bridge fee parameters on the governance whitelist

The parameters were added to `RegistryParams` and described as
governance-tunable. They were not: `GOVERNANCE_PARAM_NAMES` is a whitelist, and
anything absent from it is refused with "governance parameter is not
whitelisted".

So the previous commits moved the rate out of three hardcoded literals and into
a struct field that no proposal could reach — better, because there is now one
value instead of three, but still not what the commit message claimed.

Both names are on the list now, with the parse arms that go with them.
`RegistryParams::validate` already refuses a rate at or above 100%, and the
governance path runs `validate()` before applying, so a proposal cannot set a
fee that takes the whole transfer.

* fix: the governance whitelist and the apply match were two separate lists

Adding the bridge parameters to `GOVERNANCE_PARAMETER_WHITELIST` was half the
job. The whitelist in `governance.rs` decides whether a proposal is *accepted*;
`AccountState::apply_registry_parameter_update` in `account.rs` decides what
happens when it *executes*, and it carries its own `match` with its own list of
names, ending in:

    other => return Err(format!("unknown registry parameter: {other}")),

So a proposal naming `bridge_relayer_fee_ppm` would have passed validation,
collected votes, waited out its ten-epoch activation delay, and then failed at
execution. Nothing in the type system connects the two lists, and nothing
tested that they agree.

Both bridge parameters are now in the apply match, and
`every_whitelisted_governance_parameter_can_be_applied` walks the whitelist and
asserts each name is handled. It checks specifically for the "unknown registry
parameter" error rather than for success, so a parameter that is applicable but
rejected on its value (out of range, unparseable for its type) still counts as
wired — the test is about the two lists agreeing, not about what a valid value
looks like.

This is the same defect shape as the three copies of the bridge fee: one
decision written in two places, with nothing keeping them in step.

* docs: adding a RegistryParams field breaks snapshot compatibility

Two fields were added to this struct without saying what that costs.
`PermissionlessRegistry::root()` bincode-serializes `params` and hashes the
result into the state root. bincode writes fields positionally, with no names
and no per-struct length, so:

  - a snapshot written before the new fields has fewer bytes than the new
    binary expects and fails to deserialize
  - the state root changes, because the serialized params are hashed

The `#[serde(default)]` on `PermissionlessRegistry::params` does not help. It
covers the field being *absent* from the parent, not being *short*.

Both consequences are acceptable here — the chain is reset between pre-mainnet
releases — but they were unstated, which is the actual defect. A future
contributor adding a field to a governance-parameter struct should not have to
rediscover that it is a state-format change.

`registry_params_serialized_shape_is_pinned` asserts the encoded length, so the
next addition fails a test with a message pointing at the docs rather than
failing on an operator's node at load time. Post-launch the right shape is a
versioned params struct, which the note records.

* fix: a relayer restart silently skipped every request it was down for

`relayed_through` was a local variable initialised from
`get_finalized_height()` at boot and never written anywhere:

    let mut relayed_through = self.chain.get_finalized_height().await;

So a relayer that restarts resumes at the current tip. Every `UniversalRelay`
request that finalized while it was down falls between the old cursor and the
new one and is never looked at again.

The user has already paid. Their request sits on chain, correctly recorded,
with nothing acting on it — and nothing reports the gap, because from the
worker's perspective there is no gap: it started, read a height, and processed
everything after it.

This is not a double-spend. Restarting *ahead* of the work cannot re-submit
anything, which is why the defect is quiet: it loses service, not money, and
the loss is invisible from both ends.

The cursor is now persisted to a file, in the same shape `banned-peers.json`
uses. `with_cursor_path(None)` keeps the old in-memory behaviour for tests and
embedded use.

Ordering matters and is deliberate: the cursor is written *after* a batch of
heights is relayed, never before. A cursor ahead of the work is exactly how
requests get skipped. The opposite ordering risks a repeated relay attempt
after a crash, which the chain-side replay protection already refuses — a
retried relay is recoverable, a skipped one is not.

A corrupt or unreadable cursor file logs and falls back to the chain tip rather
than refusing to start. Turning a bad cursor into an outage would be a worse
failure than the one being fixed.

* test: pin the relay cursor round-trip

Four tests over the persistence helpers:

  - a cursor written by one run is read by the next — the property whose
    absence caused paid requests to be skipped
  - a later cursor replaces an earlier one
  - a corrupt cursor reads as absent rather than panicking, so a bad file
    cannot become an outage
  - no configured path keeps the old in-memory behaviour

The helpers only touch `cursor_path`, so these run against a worker built on a
channel with no chain actor behind it.

* fix: vote history did not survive a restart

Two high-water marks stop this node signing twice at one checkpoint height:

    let mut last_voted_height: u64 = 0;   // prevote — a local, inside the loop
    pub last_precommit_height: u64,       // precommit — a field, never written

Neither reached disk. A restart set both back to zero, and the node was willing
to sign every height it had already voted on.

On a chain that has not moved this is harmless. The block hash is the same,
`detect_prevote_equivocation` compares hashes and finds no conflict, and
`add_prevote` refuses the duplicate. Nothing bad happens, which is why the gap
survived.

The case that matters is a restart across a reorg. The same height now carries
a different hash, the node signs it, and that is precisely the pair equivocation
detection exists to catch: same voter, same height, two hashes. The penalty is
`double_sign_slash_ratio_fixed` — 50% of the bond by default — for a crash and
a restart rather than any dishonesty.

`last_voted_height` becomes `self.last_prevote_height`, so both marks are
fields, and both are written to `vote_history_db` immediately after a vote is
published. Not batched: the window between signing and persisting is exactly
the window a crash loses.

`vote_history_db: None` keeps the current behaviour for tests and ephemeral
devnet nodes. A corrupt file logs and leaves the marks at zero rather than
refusing to boot — the marks are a safety margin, and turning a bad file into
downtime would be the worse trade.

* fix: certificates from old epochs were checked against today's validator set

`validator_snapshot_for_epoch` fell back to building a snapshot from current
state and stamping the requested epoch on it:

    .unwrap_or_else(|| self.build_validator_snapshot(epoch))

`build_validator_snapshot_from_state` uses `epoch` only as a label. The members
come from `state.get_active_validators()` — today's set. A certificate from
epoch 5 was therefore verified against the validators of epoch 200: different
members, different stakes, different quorum threshold, and no error anywhere.

This is reachable in ordinary operation. `validator_snapshots` retains 100
epochs and is never persisted, so after a restart *every* historical epoch takes
the fallback.

The consequences run in both directions, which is why guessing is worse than
refusing:

  - a genuine old certificate is rejected, because its signers have left
  - a forged old certificate is accepted, if today's members happen to meet
    the quorum
  - `handle_qc_fault_proof` judges an old fault against the wrong set, so a
    validator can be slashed for an epoch it was not in, or escape one it was

The lookup now returns `Option`, and the three paths that verify someone else's
claim about a past epoch — `import_qc_blob`, `handle_qc_fault_proof` and the
finality-certificate path — go through `require_validator_snapshot`, which
errors with the epoch and the retained count.

`start_prevote_task` keeps building from state. It only ever runs for the
current epoch, where the current set is the correct answer rather than a guess.

Persisting the snapshots is the follow-on that would stop the refusal from
firing in the first place; refusing is what makes the gap visible instead of
silent.

* fix: governance could vote the unbonding window and nothing moved

`unbonding_epochs` is in `GOVERNANCE_PARAMETER_WHITELIST` and
`RegistryParams::validate` bounds it to 1..=100_000, so lengthening the
window is a legitimate, accepted governance action. Two of the three paths
that queue a release ignored the vote and read the compile-time
`core::account::UNBONDING_EPOCHS` (7) instead:

* `executor.rs` `TransactionType::Unstake` — the only path that queues
  validator stake into `unbonding_queue`.
* `account.rs` `begin_lubot_operator_unbonding` — passed the constant to
  `begin_unbonding_with_delay` while every other role reaches the registry
  through `begin_unbonding`, which does read the parameter.

The result: a passed proposal updated `RegistryParams`, the registry
reported the new window, and stake still came back after 7 epochs. Both now
read `registry.params().unbonding_epochs`.

Separately, the `Unstake` arm never mirrored the reduced stake into the
permissionless registry. `Stake` calls `sync_validator_registration`;
`Unstake` did not, so the registry kept the pre-unstake stake indefinitely.
That is consensus state, not bookkeeping:

* `registry.root()` is folded into the state root (`account.rs`
  `calculate_state_root`), so the reduced stake was invisible to it.
* `registry.is_active(addr, VALIDATOR)` gates the liveness and
  invalid-vote slashing paths in `blockchain.rs`.
* `active_members(VALIDATOR)` backs the RPC validator views.

An account could unstake to zero and stay an `Active` registry validator
carrying its original stake. `Unstake` now calls
`sync_validator_registration`, and `upsert_stake` removes the entry when
the stake reaches zero.

`UNBONDING_EPOCHS` stays as the documented default behind
`RegistryParams::default` and `PoSConfig::default`; it is no longer read
as a live protocol rule.

Five tests, each of which fails if the corresponding line is reverted:
release epoch follows the parameter (window 40, not 7), the RoleId(8) bond
follows the same parameter (window 33), the registry stake tracks the
validator stake, a full unstake deactivates the membership, and the registry
root moves.

* fix: the Kani harness was hanging on an unbounded loop, not the arithmetic

`an_unbounded_ratio_would_overshoot_the_bond` has been cancelled at the CI
timeout five times. Every diagnosis so far blamed the operands — a symbolic
`u64` ratio, then the division, then a symbolic `u32`, then a concrete
`u128` list. All four were wrong, and the evidence was in the log the whole
time:

    Checking harness proofs::an_unbounded_ratio_can_strictly_exceed_the_bond...
    VERIFICATION:- SUCCESSFUL
    Verification Time: 0.040798683s
    Checking harness proofs::an_unbounded_ratio_would_overshoot_the_bond...
    [90 minutes, cancelled]

The harness that finishes in 0.04s does strictly more arithmetic: a 128-bit
multiply AND a 128-bit divide, on a symbolic stake. The one that hangs had
already had its division removed. The only structural difference was a `for`
loop over a four-element array.

CBMC unwinds loops. With no `--unwind` bound and no `#[kani::unwind(n)]` it
has no reason to stop at four iterations, so it keeps unwinding and never
reaches a decision. Every earlier attempt changed the operands and left the
loop in place, which is why each one reproduced the same cancellation.

The fix is to write the four asserts out. `#[kani::unwind(5)]` would also
work but couples a magic number to the length of a literal array: add a fifth
ratio, forget the attribute, and the harness silently stops covering it.

Coverage is unchanged — the same four ratios, the same symbolic `u32` stake.
The attempt table in the doc-comment is corrected to record what actually
distinguished the runs.

* test: fund the watcher that now really pays the opener bond

CI caught the one caller this change breaks, which is the point of the
change:

    rpc::tests::rpc_tests::test_storage_rpc_full_lifecycle_register_deal_challenge_answer
    called `Result::unwrap()` on an `Err` value: ErrorObject {
      code: InvalidParams,
      message: "Invalid challenge: insufficient balance for opener bond: have 0, need 50"
    }
    test result: FAILED. 1778 passed; 1 failed; 1 ignored

The test generates a fresh keypair for the watcher, never funds it, and opens
a challenge declaring `opener_bond: 50`. That passed for as long as the bond
was documented as debited and never was. It now fails, correctly.

Funds the watcher. The assertion the test exists for — open, answer,
settle — is unchanged.

* fix: bud_estimateGas answered with Ethereum's number

    async fn estimate_gas(&self, tx: Transaction) -> Result<String, _> {
        ...
        Ok(Self::to_hex(21000))
    }

Every transaction type, every network, always `21000`. That is Ethereum's
transfer intrinsic. This chain has no gas metering: the live protocol is a
flat fee. `AccountState::validate_transaction` rejects `fee < base_fee`,
rejects a `max_fee` that diverges from `fee` and rejects any
`priority_fee`; `total_cost` is `amount + fee`. `SPECIFICATION.md` states
it plainly: "the producer is credited exactly once with
`tx.fee - metabolic_burn`".

So the RPC was not estimating anything. The number a wallet needs is
`base_fee` — 10 on mainnet, 1 on testnet and devnet — and it moves every
block through `adjust_base_fee`. A caller sizing a transaction off `21000`
overpays by three orders of magnitude on mainnet, and gets no signal at all
when the floor rises.

Worse, `bud_gasPrice` right next to it *does* read the live chain
(`get_base_fee`). The two numbers a wallet combines came from different
places, one live and one hard-coded.

`estimate_gas` now returns `max(tx.fee, base_fee)`: the floor the caller
must clear, or the caller's own fee when it already clears it, since that is
what will actually be charged.

`Transaction::estimate_gas_with_schedule` is left in place — `GasSchedule`
is part of the genesis document and pinned per network, so its shape has to
stay round-trippable — but it now says in its doc-comment that it is not what
the chain charges and that no settlement path reads it. It has zero callers,
which is how the RPC came to hard-code a constant instead.

Four tests, where there were none:

* the estimate equals the live base fee, and is explicitly not 21000
* a fee already above the floor comes back unchanged
* Transfer, Stake and ContractCall all get the same answer, because the
  charge does not vary by type
* `bud_gasPrice` and `bud_estimateGas` agree

* fix: storage bonds were only ever taken, never returned

`open_storage_deal_with_escrow` debits `economics.operator_bond` from the
operator's balance. `StorageRegistry::expire_deal` was written to hand it
back — its doc-comment says it "returns the operator bond amount to be
refunded by the blockchain accounting layer".

No production path ever called it. Grepping `expire_deal`: five call sites,
all in `#[cfg(test)]` modules. The only other writer of
`DealStatus::Expired` is `prune_content`, reached when an NFT is burned, and
that one does not return the bond either.

The asymmetry is exact:

| outcome | registry call | accounting call | wired from maintenance |
| :-- | :-- | :-- | :-- |
| missed challenge | `finalize_missed_challenge` | `apply_storage_bond_slash` | yes |
| served to term | `expire_deal` | *(nothing)* | **no** |

So an honest operator that served a deal for its whole term never got its
bond back. The deal stayed `Active` forever, the bond stayed debited, and
the only recorded end-of-life for a bond was losing it —
`StorageEconomicsEventKind` had `OperatorBondSlashed` and no counterpart.

Adds `Blockchain::finalize_expired_storage_deals`, mirroring
`finalize_missed_storage_challenges`: it finds `Active` deals past
`deal_end_epoch`, calls the `expire_deal` that was already there, credits
the bond with `try_add_balance` (checked, because the debit side was a plain
`saturating_sub`), records an `OperatorBondReturned` event and persists.

Wired into `run_storage_maintenance` next to the slash pass, so it runs on
the same epoch hook. It cannot pay twice: `expire_deal` re-checks the epoch
and the status and returns `Ok(0)` for a deal that is not `Active`, and the
scan only picks up `Active` deals.

A slashed deal is not `Active`, so the two outcomes stay mutually exclusive
— a slashed operator is not repaid the bond it lost.

Five tests: the round trip, an unmatured deal keeping its bond, no double
payout on a second pass, a slashed deal getting nothing, and the audit
event.

* merge #58: three role bonds were debited with no way back

Re-applied onto the integration branch rather than merged, because #57
rewrote `begin_lubot_operator_unbonding` — the function immediately above the
insertion point — and git could not reconcile the two hunks.

Both changes are wanted and they are independent:

* #57 makes `begin_lubot_operator_unbonding` call `begin_unbonding`, so the
  RoleId(8) window follows the `unbonding_epochs` governance parameter.
* #58 adds `begin_role_bond_unbonding` / `withdraw_role_bond` /
  `ensure_withdrawable_role` next to it, giving RELAYER, PROVER and
  STORAGE_OPERATOR the exit they never had.

Both are present here, verified after the resolution: the Lubot fix still
reads `begin_unbonding(*address, roles::LUBOT_OPERATOR, self.epoch_index)`,
and `ensure_withdrawable_role` still refuses LUBOT_OPERATOR so the two paths
cannot be crossed.

The tests from both PRs are in `src/tests/permissionless.rs` side by side —
#57's five unbonding-window and registry-mirror tests, #58's nine role-bond
exit tests.

* test: two relay tests asserted the fee bug #52 removes

    full_internal_relay_cycle_lock_mint   left: 90  right: 99
    full_internal_relay_cycle_burn_unlock left: 990 right: 999
    test result: FAILED. 1814 passed; 2 failed; 1 ignored

Both are asserting the old behaviour, not a regression.

On a 100-unit transfer the fee used to be `amount.saturating_mul(1) / 100`
= 1, so the recipient got 99. #52 replaced that with
`split_bridge_fee(amount, fee_ppm, min_fee)`:

    max(100 * 10_000 / 1_000_000, 10) = max(1, 10) = 10

so the recipient gets 90. The floor is the entire point of #52 — a bare
percentage rounds to zero below 100 units, which is what let an attacker
split a large bridge into 99-unit pieces and move it for free while relayers
paid the external gas. 90 is the correct number.

These two tests never ran against the change: #52 sat in the queue behind
ten other pipelines and its full suite had not completed once.

Rather than swapping 99 for 90, both assertions now call
`split_bridge_fee` with the live `RegistryParams` and assert the split it
returns, plus an explicit `assert_eq!(fee, 10)` naming why the floor beats
the percentage at this size. A future governance change to
`bridge_relayer_fee_ppm` or `bridge_relayer_min_fee` moves the test with the
protocol instead of freezing another literal that has to be chased later.

* style: cargo fmt over the integrated tree

`cargo fmt --all -- --check` was the only failing gate on the integration
branch: 59 green, 2 red, and both reds traced to this one step (Budlum Core
runs fmt before the tests, so Economy Invariants never got its log).

Ten files, all touched by the merged fixes. rustfmt only rewraps: the diff
with all whitespace stripped is empty except for trailing commas rustfmt
adds when it splits a call across lines. No expression changed.

* fix: a merge left #[cfg(test)] on one item, and three fixes inert

Five clippy errors under `-D warnings`, four of them merge damage that the
integration branch surfaced and no individual PR could have:

* `blockchain.rs` — `#[cfg(test)]` was attached to a single item rather than
  a block, so every test after the first compiled into the production
  library. `build_divergent_pow_chains` then tripped `-D dead-code` because
  nothing outside the tests calls it. The region is now a module.
* `account.rs` — a `#[test]` attribute landed on the wrong function, which
  silently disabled `total_bud_committed_counts_stake_and_unbonding`. That
  test is a *required* name in `scripts/check-economy-invariants.sh`, so the
  economy gate would have been reporting on a test that never ran.
* `storage_economics_tests.rs` — an unused binding.
* `params.rs` — `field_reassign_with_default`.
* `bridge.rs` — `identity_op` on the `* 1` the old call sites carried. Kept
  as an explicit comparison against what the chain really charged.

Also closes the two follow-on risks the merged PRs left open, both the same
shape: the mechanism landed, nothing called it.

* `main.rs` never set `vote_history_db`, so `load_vote_history` and
  `save_vote_history` both returned at their first line. A validator that
  restarts re-signs heights it has already voted at; across a reorg that is
  equivocation and costs 50% of the bond. `apply_network_security` now
  defaults it on **every** network — unlike the ban list, there is no network
  where the unsafe default is right — and `--vote-history-db` overrides it.
* `main.rs` never set the relayer cursor path, so `with_cursor_path` was
  dead and a restart resumed from the chain tip, silently skipping every
  request finalized while the worker was down. It now persists next to the
  chain database.

Two tests pin the vote-history default, including that it is not gated on
`persist_banned_peers` the way the ban list is.

* feat: a gate whose required test stopped being a test

Eight gates name the tests they require by hand and then assert each name
appears as passing in a `cargo test` log. Good gates, with one hole: nothing
checks that the name is attached to a `#[test]` in the first place.

That hole opened during this integration. A merge moved a `#[test]` onto the
wrong function in `src/core/account.rs`:

    #[test]
    fn every_whitelisted_governance_parameter_can_be_applied() { ... }

    fn total_bud_committed_counts_stake_and_unbonding() { ... }   <-- bare

`total_bud_committed_counts_stake_and_unbonding` is a *required* name in
`check-economy-invariants.sh`. It stopped being a test, so the supply
accounting invariant it pins stopped being checked. Only `-D dead-code`
noticed, and only because nothing else calls that function — a required test
that happened to be called from elsewhere would have gone straight through.

The new gate reads every `required_tests=(...)` list and fails when a name
carries no `#[test]` or `#[tokio::test]` anywhere in the workspace. Today:
120 names across 8 scripts, all attached.

Two details that are easy to get wrong and are pinned by the canary:

* Scope is the workspace, not `src/`. `check-wallet-core-gate.sh` names 36
  tests that live in `wallet-core/`, a separate crate. A scan rooted at
  `src/` reports all 36 as missing.
* The list parser matches only a line that is exactly `required_tests=(`.
  The loose version matched the string inside this script's own header
  comment and then collected shell keywords as test names. The self-test
  fixture is built with `printf` for the same reason: a heredoc would leave a
  literal declaration in a file the gate scans.

The canary rejects a stripped attribute, a tree with no tests, and a tree
where no gate declares a list, and passes a two-crate tree. Verified against
the real bug: reproducing the account.rs attribute loss makes the gate fail
by name, and restoring it makes it pass.

Wired into ci.yml next to check-gates-are-wired.sh, which now counts 25/25.

* fix: the one storage accounting path that dropped its persist failure

Four paths write the storage economics snapshot. Three end the same way:

    self.persist_storage_economics_state()?;

`accrue_storage_operator_rewards` ended with:

    let _ = self.persist_storage_economics_state();

It is the one where dropping the failure costs the most.
`storage_last_reward_epoch` is the only thing between an operator and being
paid twice for the same epoch. The balance credit is in-memory state that
commits with the block; the cursor that says "already paid through epoch N"
lives in the economics snapshot. If that write fails and the node restarts,
the cursor reloads at its old value and every epoch since is paid again —
out of an escrow the payer funded once.

The slash path had already been reasoned about, in its own doc-comment: "the
in-memory totals and the event have already been applied at that point, so
the caller must treat this as a failed block rather than retrying the burn —
replaying it would slash twice." The reward path has the identical hazard in
the opposite direction and was the only one not saying so.

`accrue_storage_operator_rewards` now returns `Result<(u32, u64), String>`.
`run_storage_maintenance` handles it with the same `match` shape as the other
three steps, so a failed write is logged rather than silent.

A source-level test asserts all four paths propagate, because producing the
failure needs an unwritable store the in-memory harness cannot create. It
fails if the `let _ =` comes back.

* fix: a parameter documented as governance-tunable that no vote could reach

`max_invalid_votes_per_epoch` carries this comment in the `Default` impl:

    // 20 invalid votes in a single epoch. ... Governance-tunable per network.
    max_invalid_votes_per_epoch: 20,

It was not in `GOVERNANCE_PARAMETER_WHITELIST`. A proposal naming it is
refused by `validate_governance_parameter_update` with "governance parameter
is not whitelisted" — after the vote, after the timelock. The comment
described an intention, not the code.

The parameter is the threshold for the `InvalidSignatureSpam` fault, so the
only way to retune spam tolerance on a live network was a code change and a
coordinated restart.

This is the third variant of one bug. #52 found `bridge_relayer_fee_ppm`
whitelisted but missing from `apply_registry_parameter_update` — vote passes,
nothing happens. #57 found `unbonding_epochs` whitelisted, applied, and then
ignored by the two paths that queue a release. This one is the entry side:
documented, never votable.

Wires it through all four places a parameter needs: the whitelist, both
`apply_registry_parameter_update` arms, and a bound in `validate`. The bound
keeps zero — a documented off-switch — and caps at 100_000, because a
threshold above any reachable vote count (`max_votes_per_msg` is 128)
disables the fault while looking configured.

Two tests. The first reads the doc comment attached to each field and fails
when a field claims to be governance-settable without being whitelisted, so
the next one is caught here rather than on a live chain. Only the unbroken
run of `///` directly above the field counts — a wider window bleeds into the
neighbouring field's docs and produces three false positives. Verified by
removing the name from the whitelist: the test fails, and passes again when
restored. The second pins the new bound at 0, 100_000, 100_001 and u64::MAX.

* docs: an authorization gate that fails open, and the action that would close it

`mark_verified_by_governance` checks its caller like this:

    if !self.authorized_governors.is_empty()
        && !self.authorized_governors.contains(caller) {
        return Err(BudlumxyzError::NotAuthorized);
    }

An empty set accepts everyone. The field doc says production populates it
"via governance action (e.g. `GovernanceAction::AddBudlumxyzGovernor`)".

There is no such variant. `GovernanceAction` has `WhitelistVerifier`,
`DewhitelistVerifier`, `SetEncryptionPolicy`, `SetConstitutionParameter` and
`UnfreezeConsensusDomain`. The only writers of `authorized_governors` are
tests. So on every real network the set is empty and the gate is open.

Not exploitable today, for exactly one reason: nothing in production calls
the function. Its single caller is `src/tests/hardening_h2_locks.rs`. That is
why this is a comment and a test rather than a new governance action —
inventing a proposal type for a code path nobody can reach is the wrong shape
of fix, and would ship an unexercised governance surface.

What ships instead is the reachability pin.
`governance_verification_stays_unreachable_while_it_fails_open` asserts both
halves: that an arbitrary caller really does succeed against an empty set
(the hazard is real, not theoretical), and that `AddBudlumxyzGovernor` still
does not exist. Wire a transaction type, an RPC method or a chain command to
this function and the test fails, forcing the governance action and the
empty-set inversion — from "empty means everyone" to "empty means no one" —
to land in the same change.

A permission check that defaults to allow is the wrong shape regardless of
how the set gets populated; the doc now says so at the definition.

* fix: validator sets were kept in memory and refused after every restart

`validator_snapshot_for_epoch` said it in its own doc-comment:

    /// `validator_snapshots` keeps 100 epochs and is never written to disk, so
    /// every historical epoch falls into the fallback after a restart.

While the fallback rebuilt from today's state, that was a correctness bug
(#56 fixed it: a certificate from epoch 5 was checked against the validators
of epoch 200). Making it fail closed was right, and it turned the missing
persistence into an availability bug instead. A restarted node now refuses
every past-epoch certificate and fault proof — `import_qc_blob`,
`handle_qc_fault_proof`, `handle_finality_cert` — until it has observed a
fresh window of epochs.

Replay does not cover the gap. It reconstructs snapshots from `start_index`
onward, and `start_index` jumps to the state snapshot's height whenever one
exists, so a node restoring from a snapshot reconstructs almost nothing.

Adds `save_validator_snapshot` / `load_validator_snapshots` /
`delete_validator_snapshot`, keyed by epoch and flushed on write — the same
shape as `save_qc_blob` next to it. `record_validator_snapshot` writes
through, and eviction deletes from disk as well, so a bounded map does not
become an unbounded table. Boot loads the stored sets *before* replay, so
replay stays authoritative for the range it covers, and trims to the same
retention bound the live path enforces (the stored set can outnumber it if an
older build ran with a larger window).

A failed write is logged, not fatal: losing one snapshot degrades to the
behaviour that exists today — refuse to verify that epoch — while aborting
block application would turn a disk hiccup into a halt.

Two tests: a set recorded before a restart is found after it with the same
`set_hash` (a rebuild would differ), and evicted epochs do not linger on
disk.

* docs: 768 lines of relayer economics with no edge into consensus

`UserIntent`, `SolverBid` and `IntentSettlement` have zero callers outside
`relayer/policy.rs` and the `pub use` in `relayer/mod.rs`. No transaction type
carries an intent, no `ChainCommand` accepts a bid, no RPC settles one.

What the chain actually does with a relay request:

    TransactionType::UniversalRelay(ext_tx) => {
        let sender = state.get_or_create(&tx.from);
        sender.balance = sender.balance.checked_sub(tx.fee)?;
        sender.nonce = sender.nonce.saturating_add(1);
    }

The sender pays `tx.fee`, and that fee is credited to the **block producer**
— `Executor::apply_block_checked` is the single authority for fee settlement.
The relayer that will spend gas on Ethereum executing the request receives
nothing. Relaying outbound is unpaid work performed by an account that had to
bond RoleId(3) first.

`max_fee` cannot express a ceiling either: the flat-fee protocol rejects any
`max_fee` that diverges from `fee`. So there is no price commitment from the
relayer and no cap from the user — the two things the bid/settlement types
below exist to provide.

The inbound direction is wired: `split_bridge_fee` pays the relayer out of the
arriving asset (#52). Outbound, where this module applies, is not.

No code change. Closing this means a transaction type, a settlement path
moving `paid_fee` to the winning solver, and a slash path for a committed
bid that is not honoured — three consensus changes, none of which belong in
a hardening pass.

`the_policy_layer_is_still_unwired_and_relaying_is_still_unpaid` pins the gap
so it cannot be half-closed: it fails if any of the three types reaches the
executor, or if the relay arm starts crediting anyone. Whoever wires it has
to delete the test deliberately, having done the settlement and the slash.

* docs: outbound relay is not Ethereum-only, it is off

`ExternalChain` lists eight variants and `EvmChainAdapter` implements one of
them, which reads as "Ethereum works, the other seven do not". Neither half
is true on a deployed node.

`main.rs` builds the worker as:

    RelayerWorker::new(chain.clone(), relayer_addr)
        .with_cursor_path(Some(cursor_path))

`with_adapters` is never called, and nothing outside its own tests constructs
an `EvmChainAdapter`. The registry is empty everywhere, so
`build_verified_result` returns `AdapterError::UnsupportedChain` for all
eight variants — Ethereum included.

This is the safe direction to be wrong in: the failure is a refusal, not a
forged result, and `build_verified_result` cannot be made to return
`success: true` for a chain it cannot observe. But it means the outbound path
has never run against a live chain, and the only coverage of a populated
registry is `chain_adapter.rs`'s stub.

Not wiring it here, because the node cannot carry the configuration yet:
`EvmChainAdapter::new` needs the bridge contract address and the `Deposit`
topic0, and `RelayerConfig` has a field for neither. `test_default()` would
supply a zero address, which lets a node advertise Ethereum support while
pointing at nothing — strictly worse than refusing.

`an_unconfigured_worker_refuses_every_external_chain` asserts both halves:
an empty registry refuses all eight variants with `UnsupportedChain`, and
`main.rs` still does not call `with_adapters`. Wiring the adapter fails the
second half, forcing whoever does it to confirm the address and topic0 became
configurable in the same change.

* test: nothing asserted that VerifyInference always answers "not verified"

The opcode once accepted any non-zero commitment as proof of an AI inference
— no cryptography, just a non-zero check. It was reduced to a no-op that
always writes 0 until a real STARK verification AIR exists.

Two tests cover it: one that it executes off-mainnet, one that the mainnet
gate rejects it. Neither asserts the result.

That is the assertion carrying the security property. A gate can be lifted by
configuration; "the answer is always 0" is what makes lifting it safe. Put
the non-zero-commitment shortcut back and both existing tests still pass —
the opcode runs, the gate still gates — and nothing fails.

`verify_inference_never_reports_success` runs the operand shapes the old
shortcut would have accepted, including a non-zero commitment pair, and reads
the destination register directly. `rd = 4` rather than `0`, because the
opcode discards writes to `r0` through its own `dst_idx > 0` guard — asserting
on `r0` would pass no matter what the opcode computed.

* fix: an unconsumed callback queue grew into the state root forever

`AiRegistry::callback_queue` is a `BTreeMap<Address, Vec<AiCallbackEvent>>`
with a drain that nothing calls. `consume_callback_events` exists; its only
caller is a test. The production path is read-only:

    bud_aiCallbackQueue -> get_ai_callback_queue -> get_callback_queue

`get_callback_queue` clones. So for any callback address whose owner polls
but never consumes — which is every address, since no consuming endpoint is
exposed — the vector grew once per finalized inference, for the life of the
chain.

That is not just memory. The queue is hashed into `AiRegistry::root` under
`BDLM_AI_CALLBACK_QUEUE`, iterating every event of every address, and that
root feeds `calculate_state_root`. Every validator rehashed the entire
accumulated backlog on every block, forever.

Reaching it costs a finalized inference each time, so this is a slow leak
rather than a cheap flood — closer to the storage-DoS shape than to Circle
Arc's unbounded per-height vote bucket, where the buffer capped distinct keys
and left each bucket unbounded. Worth noting that `pending_finality_certs`,
the structure here most like Arc's, already counts total entries rather than
buckets and is not affected.

Bounds the backlog at 256 events per address, evicting oldest-first the way
`record_slash` does. Both finalization paths now route through
`enqueue_callback_event`, so the bound cannot be applied to one and forgotten
on the other — which is how the two call sites drifted in the first place.

Per-address rather than global, so one noisy consumer cannot evict another's
events. Oldest-first because a consumer coming back online wants the newest
result; a callback uncollected across 256 finalized inferences is not going
to be collected.

* fix: an orphaned #[test] left above the doc block

`-D duplicate-macro-attributes`, one line, six red jobs — Budlum Core plus
the five gates that read its test log.

My own doing. When I restored the `#[test]` that a merge had moved onto the
wrong function, I added it directly above `fn`, where it belongs, and did not
notice the original was still sitting above the doc comment:

    #[test]                                  <- orphan
    /// Every whitelisted governance parameter must be applicable.
    ...
    #[test]
    fn every_whitelisted_governance_parameter_can_be_applied() {

The scan I wrote at the time only compared adjacent lines, so eleven lines of
doc comment between the two hid it. Rerun with the gap allowed: this was the
only occurrence in the workspace, and it is now zero.

* fix: a source-scanning test that failed on its own documentation, and RUSTSEC-2026-0221

Two unrelated reds, both real.

**The test.** `every_storage_accounting_path_propagates_a_failed_persist` ran
`str::matches` over the whole of `blockchain.rs` looking for
`let _ = self.persist_storage_economics_state()`. It found exactly one — the
doc-comment I wrote on `accrue_storage_operator_rewards`, which quotes the old
line to explain what changed. The fix and the test that guards it were in the
same commit, so the test failed on the prose describing the fix.

Now line-based and doc-aware: lines whose first non-space characters are `//`
do not count, and the failure names the offending line numbers instead of a
bare count. Exactly the mistake `check-required-tests-are-tests.sh` hit when
its own header comment contained `required_tests=(`; I did not carry the
lesson across. Rechecked the four other source-scanning tests added in this
branch — none matches its own text.

**The advisory.** OSV flagged RUSTSEC-2026-0221 (event-listener 5.4.1),
issued today: `StackSlot<'_, T>` implements `Send`/`Sync` unconditionally, so
a `!Send` tag set via `Event::with_tag` can cross a thread boundary and race
in safe code. Unsound rather than exploited, and transitive through
`async-channel`, but the fix is a patch bump. `cargo update -p event-listener
--precise 5.4.2` — the lockfile diff is that one package.

* style: clear the pedantic warnings this branch introduced

The ratchet caught +48 pedantic/nursery warnings against the 7108 baseline.
Raising the baseline is CI-softening, so this fixes them instead.

Measured by diffing a full `clippy --all-targets -W pedantic -W nursery` run
on this branch against the same run on main, per (file, lint) pair, rather
than trusting a single total — the sandbox cannot compile every target, so
the absolute number is not comparable to CI's, but the delta is.

* `items_after_statements` ×2 — `MAX_VALIDATOR_SNAPSHOTS` was a `const`
  declared mid-function in `new_with_genesis`, and `struct VoteHistory` was
  declared inside `load_vote_history`. Both are now at module level, which
  the constant needed anyway: `new_with_genesis` and
  `record_validator_snapshot` both trim to it, and two literals would let a
  reload keep more than the live path is willing to hold.
* `missing_errors_doc` ×8 — the three `validator_snapshot` storage functions,
  the two `role_bond` methods on `AccountState`, and their two `ChainHandle`
  wrappers. Each `# Errors` section says what actually fails, not "returns an
  error".
* `doc_markdown` ×8 — `RELAYER`, `PROVER`, `STORAGE_OPERATOR` and `RoleId`
  written as prose in doc comments.
* `uninlined_format_args` ×1 and `return_self_not_must_use` ×1 — both on the
  vote-history wiring added this round.

Nothing suppressed: no `#[allow]`, no baseline change. Re-measured after the
fix, the per-pair delta against main is zero.

* style: three more pedantic warnings from this branch

The ratchet went 7156 -> 7122 after the first pass (+48 -> +14). The sandbox
cannot compile `budlum-core`'s lib-test target — clippy-driver is SIGKILLed
on it every time, so the local total is never comparable to CI's. Diffing
per (file, lint) against the same partial run on main works regardless, and
surfaced three:

* `doc_markdown` — `LUBOT_OPERATOR` in `ensure_withdrawable_role`'s doc and
  `RELAYER, PROVER, STORAGE_OPERATOR` on the `BeginRoleBondUnbonding` command.
* `missing_errors_doc` — `finalize_expired_storage_deals`, the one function
  added this round that returns `Result` and had no `# Errors` section.

Still nothing suppressed. If CI reports a remainder after this, it is in the
lib-test target the sandbox cannot reach, and I will read it off the log
rather than guess.

* style: the last pedantic warnings, exposed by moving tests into a mod…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Z-CompilerBenchCI Tag a PR to run benchmark CI Z-EndToEndBenchCI Tag a PR to run benchmark CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Toolchain upgrade to nightly-2026-01-15 failed Update nightly version to support rustc >= 1.94.0

3 participants