Skip to content

fix: Radiant reserves were converted with the Bitcoin interval (#579, #581) - #590

Merged
Zyrtnin merged 2 commits into
mainfrom
fix/radiant-reserves-per-chain-interval
Sep 3, 2026
Merged

fix: Radiant reserves were converted with the Bitcoin interval (#579, #581)#590
Zyrtnin merged 2 commits into
mainfrom
fix/radiant-reserves-per-chain-interval

Conversation

@Zyrtnin

@Zyrtnin Zyrtnin commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Closes #579. Closes the parity-sweep half of #581.

rxd_claim_burial and rxd_claim_inclusion count Radiant blocks. They were normalised with policy.block_interval_s — the Bitcoin interval — at seven sites, and policy.rxd_block_interval_s was used for them nowhere.

Why it survived everything

It is inert on every shipped path. Each constructor tags those fields BLOCKS, so the conversion is the identity and the interval argument is never read. A wrong argument that is never used looks exactly like a right one — which is why it passed review, the type system (security/units.py has one BlockSpan for all chains), and an "auditor-grade" parity sweep.

Verified inert before changing anything: no Timelock in src/ or scripts/ is constructed with TimeUnit.SECONDS, and there is no MarginPolicy deserializer. This PR is a behavioural no-op today and removes a trap for tomorrow.

Both directions were reachable the moment a SECONDS value existed, measured at 600/300:

value is was read as direction
1800 s burial 6 Radiant blocks 3 unsafe — half the intended depth
900 s burial 3 honest Radiant blocks refused at construction refuses valid work

The test that should have caught it (#581)

The parity sweep bills its reference as deriving the answer "from the rule rather than from the code" — and it normalised the Radiant reserves with the Bitcoin interval exactly as production did. The two agreed by sharing a defect. Every swept policy was also BLOCKS-tagged, so all conversion arithmetic was the identity and no unit error of any size could have failed it.

The reference now derives interval and rounding independently (a reserve rounds UP; it had used normalize_to, which floors), and the sweep includes a SECONDS-tagged policy.

Two guards, each verified to catch what the other cannot

  • The parity sweep catches a regression inside the helper — confirmed by switching it back to the Bitcoin interval, which fails it.
  • The scanner catches a new call site that bypasses the helper — confirmed by planting one, which fails it.

Neither covers both, and the scanner's docstring says so rather than implying completeness.

Its honest-path case matters more than usual: rxd_block_interval_s contains block_interval_s as a substring, so a naive pattern would flag every correct call site — a guard refusing valid work on the very line that fixes the bug. It also carries a stale-guard ratchet asserting the fields it names still exist, since a rename would silently end its coverage.

CI-equivalent locally: 10,990 passed, 192 skipped, 1 xfailed.

🤖 Generated with Claude Code

Mudwood Labs and others added 2 commits September 2, 2026 22:52
…581)

rxd_claim_burial and rxd_claim_inclusion count RADIANT blocks. They were
normalised with policy.block_interval_s — the BITCOIN interval — at seven sites,
and policy.rxd_block_interval_s was used for them nowhere.

INERT ON EVERY SHIPPED PATH, which is the whole reason it survived: each
constructor tags those fields BLOCKS, the conversion is then the identity, and
the interval argument is never read. A wrong argument that is never used looks
exactly like a right one. Verified inert before changing anything — no Timelock
in src/ or scripts/ is constructed with TimeUnit.SECONDS and there is no
MarginPolicy deserializer — so this commit is a behavioural no-op today and
removes a trap for tomorrow.

Both directions were reachable the moment a SECONDS value existed. Measured at
600/300: an 1800 s burial is 6 Radiant blocks and was read as 3, half the
intended depth (unsafe); a 900 s burial is 3 honest Radiant blocks and was
REFUSED at construction as "1 blk < safety floor 2" (refuses valid work).

ONE HELPER, NOT SEVEN FIXES. `_radiant_reserve_blocks(policy, reserve)` makes the
choice once. `MarginPolicy.__post_init__` now applies each field's own chain
interval — Bitcoin's for btc_claim_reorg_depth, Radiant's for the rest.

AND THE TEST THAT SHOULD HAVE CAUGHT IT (#581). The parity sweep bills its
reference as deriving the answer "from the rule rather than from the code", and
it normalised the Radiant reserves with the Bitcoin interval exactly as
production did — the two agreed by SHARING a defect. Every swept policy was also
BLOCKS-tagged, so all conversion arithmetic was the identity and no unit error of
any size could have failed it. The reference now derives interval AND rounding
independently (a reserve rounds UP; it had used normalize_to, which floors), and
the sweep includes a SECONDS-tagged policy.

Two guards, each verified to catch what the other cannot, and the split is stated
in the scanner's own docstring rather than implied:

- the parity sweep catches a regression INSIDE the helper — confirmed by
  switching it back to the Bitcoin interval, which fails it;
- tests/test_radiant_reserves_use_the_radiant_interval.py catches a NEW call site
  that bypasses the helper — confirmed by planting one, which fails it.

The scanner's honest-path case matters more than usual here: `rxd_block_interval_s`
contains `block_interval_s` as a substring, so a naive pattern would flag every
CORRECT call site — a guard refusing valid work on the very line that fixes the
bug. It also carries a stale-guard ratchet asserting the fields it names still
exist, since a rename would silently end its coverage.

CI-equivalent: 10,990 passed, 192 skipped, 1 xfailed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every broadcast test in this file ran at MAXIMUM slack, which the suite could not
see from the inside.

`_armed_executor` defaults confs=1, so now_rxd == funded_h and blocks_left is the
full t_rxd — while the BTC claim is 10 confirmations deep. Radiant does not reach
that state: 10 Bitcoin confirmations is roughly 100 minutes, which is 20-plus
Radiant blocks at the measured 222 s median, not zero. The fixture describes a
co-occurrence the chain cannot produce.

MEASURED through the production entry point, sweeping confs 1..199: the verdict
flips BROADCAST -> DECLINED between 137 and 138. The suite used confs 1 and 172
and nothing else, so no case sat within 136 blocks of the flip on one side or 34
on the other.

An off-by-one in `now_rxd = funded_h + max(cov_confs, 1) - 1` was therefore
invisible, and both directions are real defects: one block LATE is a spurious
squeeze after p is public, which is a forfeiture path; one block EARLY certifies
a claim that cannot bury in time.

Three cases: the last confs that still broadcasts (137), the first that declines
(138), and an honest path at a REACHABLE depth (confs=24 against btc_confs=10)
rather than at maximum slack.

Plant-verified: dropping the `- 1` fails the boundary case and nothing else in
the file — the 49 pre-existing tests stay green, which is the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Zyrtnin
Zyrtnin merged commit dc80c71 into main Sep 3, 2026
19 checks passed
@Zyrtnin
Zyrtnin deleted the fix/radiant-reserves-per-chain-interval branch September 3, 2026 06:16
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.

claim-side chain-unit conflation: Radiant reserves converted with the Bitcoin interval at seven sites, three converters with two rounding rules

1 participant