Skip to content

Cross-chain sizing gaps a stable-value counter-leg makes sharper (timelock ordering, value-scaled burial, NFT value-at-risk) #482

Description

@Zyrtnin

Four protocol-level findings from the fourth security round on feat/erc20-usdc-leg. None is introduced by the ERC-20 work — but a stable-value counter-leg sharpens each one, because for the first time the swap states its own market value in a unit anyone can read.

Grouped because they share a root: sizing decisions that were made against a volatile-vs-volatile corridor.

1. The timelock ordering is inverted, and the option is now one-sided in a new way

The gate enforces rxd_refund_open + margin <= eth_timeout — so the leg claimed second carries the shorter deadline, the inverse of the canonical rule. In the window [rxd_open, eth_timeout] the maker can CSV-refund the Radiant covenant, wait for confirmation while p is still secret, and then claim the USDC. Both legs, deterministically.

This is a documented, accepted residual (ONE_SIDED_LOSS_TAKER), and the taker has no exit from BOTH_LOCKED. What the token leg changes is the economics: the maker now holds a costless put on the volatile asset struck in a stable unit, running the full window, with the option value unpriced. The BTC corridor's version of this option was two volatiles against each other.

Worth re-deciding rather than inheriting: invert to t_rxd_abs > eth_timeout + Δ, or price the option.

2. Value-scaled burial is enforced at claim time but not at setup

assess_claim_finality requires max(flat, value_scaled) burial, but the construct-time N-floor uses only the flat burial, and t_rxd is sized from a rxd_claim_burial_s that is likewise uncoupled.

So a value-bearing swap can construct cleanly and then, at reveal, find blocks_left < rxd_burial — a permanent SQUEEZED state where the maker's CSV refund can beat the taker's claim. Fix: use the same max(flat, value_scaled) in the N-floor and derive rxd_claim_burial_s from it.

3. NFT value-at-risk is declared, never cross-checked — and USDC makes it checkable

The under-statement guard is rxd-variant only. For nft, radiant_amount is carrier dust, so a 50,000-USDC NFT sale can declare value_at_risk_photons = 1000; value-scaled burial then collapses to flat, and a cheap Radiant reorg can reverse the taker's claim.

A stable counter-leg is precisely the case where the swap states its own value (terms.value_amount, 6-decimal USDC). Fix: for a token counter-leg require an explicit photons-per-base-unit rate and assert value_at_risk_photons >= value_amount * rate.

4. expected_locator does not bind the deadline to the negotiated term

EthLeg.fund asserts terms.eth_timeout_unix_s == self._eth_timeout_unix_s; expected_locator / verify_counterparty_funded do not. verify_funded then binds the on-chain immutable to that locator — so the cross-clock ordering gate can validate deadline A while the accepted contract carries deadline B.

The loss is liveness (a dead swap with the asset locked for a full t_rxd), not theft, but the invariant the whole ordering argument rests on is being checked against a number that is not the one on chain. Fix: replicate the fund() assertion inside expected_locator.

Also, minor: the Solidity constructor has no sanity floor

Erc20Htlc accepts timeout == 0 or in the past (claim reverts forever, refund() immediately callable) and hashlock == 0 (unclaimable). Off-chain this is caught only because each side compares against its own expected values — there is no absolute check anywhere. Two lines in the constructor would make the degenerate cases unrepresentable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions