Skip to content

fix: refuse a zero relative lock however it is spelled - #612

Merged
Zyrtnin merged 3 commits into
mainfrom
fix/btc-refund-floor-covers-every-unit
Sep 4, 2026
Merged

fix: refuse a zero relative lock however it is spelled#612
Zyrtnin merged 3 commits into
mainfrom
fix/btc-refund-floor-covers-every-unit

Conversation

@Zyrtnin

@Zyrtnin Zyrtnin commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

refund_leaf_script's floor was unit is BLOCKS and value < 1, under a comment saying "every caller gets the floor by construction". That was false for a SECONDS-tagged Timelock. BIP68 quantises time locks to 512 s, so Timelock(0..511, SECONDS) all encode to nSequence = 0x00400000 — zero time units, the same no-op lock, emitted without complaint:

Timelock(  0, seconds) -> leaf 03000040b275   accepted
Timelock(511, seconds) -> leaf 03000040b275   accepted   (identical bytes)
Timelock(  0, blocks ) -> refused

Reachable end to end, not latent

This was first reported as latent because nothing in pyrxd or scripts/ constructs a SECONDS t_btc. But the unit tag comes off the wire:

  1. NegotiatedTerms.from_dict_timelock_from_dict accepts {"value": 0, "unit": "seconds"}
  2. swap_state's own t_btc floor is scoped to BLOCKS in exactly the same way → skipped
  3. its ordering guard only fires when both units match → skipped
  4. assert_timelock_margin normalises to 0 blocks and passes

So a counterparty-authored envelope produced a refund leaf spendable in its own funding block — the #482-shaped free option, one unit tag away from the floor meant to stop it.

"No builder constructs one" is not the same claim as "no path produces one", when a deserialiser reads the tag from an untrusted envelope.

Fixed by deriving the check from the encoding

csv_script_operand() & SEQUENCE_LOCKTIME_MASK == 0 — whatever the unit, whatever units are added later, a relative lock that encodes to zero is refused. That is the difference between a floor and a floor for the one spelling someone thought of, which is the defect class this repo keeps meeting.

It refuses nothing honest

The only newly-rejected inputs are SECONDS 0..511, every one of which is already a zero lock — a caller asking for 511 seconds is silently getting none today. Both directions planted: reverting to the BLOCKS-only floor fails 6; over-tightening to also refuse an honest 512 s fails 3.

Accepted leaves are byte-identical, pinned by a test — this leaf is committed to the taptree, so changing it would move the HTLC address and strand every existing swap.

Full suite: 11,013 passed, 192 skipped, 1 xfailed.

🤖 Generated with Claude Code

Mudwood Labs and others added 2 commits September 3, 2026 23:32
Assertive text is a claim, and no test evaluates claims — tests pin that a
sentence is emitted, never that it is correct. Five docstrings/comments in the
swap wallet layer had drifted from the code beside them. Each correction says
what is true and what the text used to say, so anyone who relied on the old
wording knows to re-check. NO BEHAVIOUR CHANGES.

taproot.refund_leaf_script — "Any caller that builds a leaf ... gets the floor by
construction" was false: the refusal is scoped to TimeUnit.BLOCKS. BIP68 quantises
time locks to 512 s, so Timelock(0..511, SECONDS) all encode nSequence 0x00400000
(zero time units) — the same no-op relative lock a 0-block CSV is — and are emitted
without complaint. swap_state.NegotiatedTerms carried the identical over-broad claim
("unrepresentable for every caller") over an identically BLOCKS-scoped condition;
corrected too, since leaving one would have contradicted the other.

btc_wallet.htlc_leg — "txid_of resolves ... VIA THE NODE — never a local segwit
parse" had the mechanism exactly backwards, in the BtcFundingReader Protocol, in
its txid_of stub, and in confirmations_of_claim's own docstring, which contradicted
the comment three lines below it. All three shipped readers (mempool.space,
Bitcoin Core, MultiSource) call taproot.btc_txid_from_raw. The safety property is
unchanged and is better served by the local derivation: the gated txid must be that
of the exact bytes p was scraped from, and serialising them asks nobody.

eth_wallet.erc20_leg.claim — "checked alongside both parties" describes round 4's
address list; round 5 removed the refundee on purpose (a claim never touches it, so
refusing there refuses valid work and hands the counterparty a free unilateral veto)
and the call site passes claimant only.

eth_wallet.htlc_leg module docstring — "funded balance == negotiated amount" where
the check is `bal < expected_amount_wei`, a deliberate LOWER bound (an == check is
griefable by a 1-wei force-send). verify_funded documents it correctly at the check;
EthHtlcLocator.amount_wei repeated the same == claim and is corrected with it.

eth_wallet.locator.EthHtlcLocator.chain_id — "the leg refuses a chain_id mismatch
up front" is false; nothing in src/ or scripts/ compares locator.chain_id to
anything. EthRpc.assert_chain compares the NODE to the LEG's expected_chain_id, a
different check. The field is now documented as a record, not a gate.

Two gaps are REPORTED rather than fixed, because both are behaviour changes on
fund-moving code and need their own review: the BLOCKS-only scoping of the zero-
timelock floors, and the absent locator/leg chain-id comparison.

Verified: ruff check + ruff format --check clean; full suite 10985 passed,
197 skipped, 4 xfailed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`refund_leaf_script`'s floor was `unit is BLOCKS and value < 1`, under a comment
saying "every caller gets the floor by construction". That was false for a
SECONDS-tagged Timelock. BIP68 quantises time locks to 512 s, so
Timelock(0..511, SECONDS) all encode to nSequence 0x00400000 - ZERO time units,
the same no-op relative lock, emitted without complaint:

    Timelock(  0, seconds) -> leaf 03000040b275   accepted
    Timelock(511, seconds) -> leaf 03000040b275   accepted   (identical bytes)
    Timelock(  0, blocks ) -> refused

REACHABLE END TO END. Nothing in `pyrxd` or `scripts/` constructs a SECONDS
`t_btc`, which is why this was first reported as latent - but the unit tag comes
off the WIRE. `NegotiatedTerms.from_dict` -> `_timelock_from_dict` accepts
`{"value": 0, "unit": "seconds"}`; `swap_state`'s own `t_btc` floor is scoped to
BLOCKS in exactly the same way so it is skipped; its ordering guard only fires
when both units match, so that is skipped too; and `assert_timelock_margin`
normalises to 0 blocks and passes. A counterparty-authored envelope therefore
produced a refund leaf spendable in its own funding block - the #482-shaped free
option, one unit tag away from the floor meant to stop it.

FIXED BY DERIVING THE CHECK FROM THE ENCODING rather than the tag. Whatever the
unit, whatever units are added later, a relative lock that encodes to zero is
refused. That is the difference between a floor and a floor for the one spelling
someone thought of, which is the defect class this repo keeps meeting.

IT REFUSES NOTHING HONEST. The only newly-rejected inputs are SECONDS 0..511,
every one of which is ALREADY a zero lock - a caller asking for 511 seconds is
silently getting none today. Both directions are planted: reverting to the
BLOCKS-only floor fails 6, and over-tightening to also refuse an honest 512 s
fails 3.

The accepted leaves are byte-identical, pinned by a test, because this leaf is
committed to the taptree: changing it would move the HTLC address and strand
every existing swap.

Found as a secondary assertion in a lead that called this latent. It is not
latent, and "no builder constructs one" is not the same claim as "no path
produces one" when a deserialiser reads the tag from an untrusted envelope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zyrtnin added a commit that referenced this pull request Sep 4, 2026
…ed (#613)

Five prose claims in fund-moving wallet code, flagged by a reviewer that
**explicitly marked them unverified**. All five held on checking — but
one's *secondary* assertion was wrong, in the direction of understating
the problem, and that turned into #612.

| lead | verdict |
|---|---|
| `taproot.py` "any caller gets the floor" | held — and the "latent"
half is **wrong**, see #612 |
| `htlc_leg.py` "`txid_of` resolves via the node" | held; **mechanism
backwards, property fine** — all three shipped readers derive locally,
which serves the property *better* |
| `erc20_leg.py` "checked alongside both parties" | held — the call
passes claimant only, deliberately, per the reasoning directly above it
|
| `htlc_leg.py` "funded balance == negotiated amount" | held — the check
is `<`, a deliberate lower bound, correctly documented elsewhere |
| `locator.py` "pins which network" | held, **and there is a real gap**
|

## Reported, not fixed

**Nothing compares `locator.chain_id` to anything.**
`EthRpc.assert_chain` compares the *node* to the *leg's* expected chain
id, so a leg pointed at the wrong network is caught — but a locator
*from* a different network driven by a correctly-configured leg is not,
and `claim()` against an address with no code on the target chain does
not revert.

Adding that check is a behaviour change on fund-moving code and wants
its own review, so it is reported rather than slipped into a prose pass.

## Bonus, unprompted

`EthHtlcLocator.amount_wei` carried the identical false `==` claim, and
`swap_state.py` carried the twin over-broad "unrepresentable for every
caller" — which would have contradicted the taproot correction sitting
one call away.

Comments and docstrings only; no behaviour changed. Verified
independently: full suite green at baseline skip/xfail counts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Mudwood Labs <opensource@mudwoodlabs.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#613 documented the BLOCKS-only floor and said 'left as-is deliberately -
widening the refusal is a behaviour change that needs its own review'. This
branch IS that review, and the fix: the refusal now keys on the encoded
magnitude, so it is unit-agnostic by construction. Kept the fix.
@Zyrtnin
Zyrtnin merged commit c415d5d into main Sep 4, 2026
14 checks passed
@Zyrtnin
Zyrtnin deleted the fix/btc-refund-floor-covers-every-unit branch September 4, 2026 08:24
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.

1 participant