Skip to content

The signature-free refund makes an UNCONFIRMED claim sitting through CSV maturity unsafe (eviction/reorg, not a lost mempool race) #511

Description

@Zyrtnin

Target-rotated round, the consensus-enforced covenant scripts. MEDIUM by CVSS, but it changes the deadline math every other timelock issue depends on. On main. Verified against the covenant source and the Radiant Core interpreter.

The property

GravityHtlcCovenantFt.rxd:54-57 (and the NFT/RXD siblings):

refund() {
    require(tx.age >= refundCsv);
    require(hash256(tx.outputs[0].lockingBytecode) == expectedMakerFtHash);
}

No signature check. After maturity the refund branch is spendable by anyone who supplies a fee input — the covenant only constrains where the value goes, not who moves it.

That is a deliberate and reasonable design (it makes the refund recoverable without the maker's key). But it has a consequence the surrounding code does not account for.

The attack, and it is simply the rational play

The maker already holds p and has already taken the counter leg. At the instant CSV matures, the maker broadcasts a minimum-fee refund. Every node that sees it first rejects the taker's later claim — even a much higher-fee one — as txn-mempool-conflict.

Radiant has no RBF. There is nothing to outbid with. The taker cannot pay its way past a conflicting transaction that arrived first.

So the taker's real deadline is not CSV maturity. It is:

maturity − propagation − one confirmation

and nothing in the code enforces that margin. blocks_to_deadline plumbing already exists in htlc_spend.py:211-237 and is not used for this.

Why this matters beyond itself

Three other issues filed this round concern t_rxd being unvalidated — #507 (nothing couples it to the value-scaled burial), #508 (the sizer has no caller and the script gate is a tautology), #484 (the coordinator gate measures punctuality, not slow-chain risk). All of them reason about the deadline as maturity. If the effective deadline is earlier by propagation plus a confirmation — and on a chain whose measured inter-block time ranges 9s to 330s, one confirmation is a wide and highly variable quantity — then every one of those margins is computed against the wrong target.

This cannot be fixed in script: there is no opcode that bounds age from above.

Fix

Enforce a hard claim-by height in the leg, at maturity − propagation_margin − confirmation_margin, and refuse to build a claim past it (the same shape as the ETH leg's CLAIM_INCLUSION_BUDGET_S, which exists for the identical reason on the other chain). Size the confirmation margin from a slow-tail inter-block measurement, not the mean — and pair the refusal with an honest-path test, because a margin sized carelessly here refuses valid claims, which on this chain forfeits the asset.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions