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.
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 whilepis 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 fromBOTH_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_finalityrequiresmax(flat, value_scaled)burial, but the construct-time N-floor uses only the flat burial, andt_rxdis sized from arxd_claim_burial_sthat is likewise uncoupled.So a value-bearing swap can construct cleanly and then, at reveal, find
blocks_left < rxd_burial— a permanentSQUEEZEDstate where the maker's CSV refund can beat the taker's claim. Fix: use the samemax(flat, value_scaled)in the N-floor and deriverxd_claim_burial_sfrom it.3. NFT value-at-risk is declared, never cross-checked — and USDC makes it checkable
The under-statement guard is
rxd-variant only. Fornft,radiant_amountis carrier dust, so a 50,000-USDC NFT sale can declarevalue_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 assertvalue_at_risk_photons >= value_amount * rate.4.
expected_locatordoes not bind the deadline to the negotiated termEthLeg.fundassertsterms.eth_timeout_unix_s == self._eth_timeout_unix_s;expected_locator/verify_counterparty_fundeddo not.verify_fundedthen 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 thefund()assertion insideexpected_locator.Also, minor: the Solidity constructor has no sanity floor
Erc20Htlcacceptstimeout == 0or in the past (claim reverts forever,refund()immediately callable) andhashlock == 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.