Radiant HTLC covenant amount-pin must match (be ≤) the funded carrier¶
A build-time covenant constant (the amount pin baked into the script’s
OP_OUTPUTVALUE >= <amount> OP_VERIFY guard) was decoupled from the runtime reality
(the actual photon value funded into the carrier output). Because the same value
guard sits on both the claim and refund dispatch branches, an over-large pin
doesn’t just block one path — it renders the entire UTXO permanently unspendable,
silently converting locked funds into burned funds, with only a generic OP_VERIFY
failure as the broadcast-time signal.
Symptom¶
A Radiant HTLC covenant (RXD variant, for the cross-chain atomic swap) was built and
funded, but every spend attempt failed — both the hashlock claim branch and the
tx.age CSV refund branch — with the same consensus rejection:
mandatory-script-verify-flag-failed (Script failed an OP_VERIFY operation) (code 16)
The covenant carrier (100,000 photons = 0.001 RXD) became permanently unspendable.
This was not a wrong-preimage or premature-CSV rejection (those have distinct
messages — OP_EQUALVERIFY and Locktime requirement not satisfied). It was an
unconditional OP_VERIFY failure that no spend witness could satisfy — the tell that
a guard is failing on every code path, not on the witness you supplied.
Root Cause¶
The covenant was compiled with an amount pin of 50,000,000 photons but the
covenant output was funded with only 100,000 photons.
Both branches of the covenant ASM contain an identical output-value guard that checks
the value routed into output 0 against the compile-time amount:
OP_TXOUTPUTCOUNT OP_1 OP_NUMEQUALVERIFY
OP_DUP OP_0 OP_NUMEQUAL
OP_IF # ---- claim branch (selector OP_0) ----
OP_SWAP OP_SHA256 <H> OP_EQUALVERIFY
OP_0 OP_OUTPUTVALUE <amount> OP_GREATERTHANOREQUAL OP_VERIFY # <-- the trap
OP_0 OP_OUTPUTBYTECODE OP_HASH256 <takerHash> OP_EQUAL OP_NIP
OP_ELSE # ---- refund branch (selector OP_1) ----
OP_1 OP_NUMEQUALVERIFY
<refundCsv> OP_CHECKSEQUENCEVERIFY OP_DROP
OP_0 OP_OUTPUTVALUE <amount> OP_GREATERTHANOREQUAL OP_VERIFY # <-- same trap
OP_0 OP_OUTPUTBYTECODE OP_HASH256 <makerHash> OP_EQUAL
OP_ENDIF
The fatal sequence on both paths is:
OP_0 OP_OUTPUTVALUE <amount> OP_GREATERTHANOREQUAL OP_VERIFY
This requires value(output[0]) >= amount. Output 0 is funded from the covenant
carrier, so its value can be at most the carrier. With amount pinned at 50,000,000
and the carrier at 100,000, the predicate 100,000 >= 50,000,000 is always false,
so OP_GREATERTHANOREQUAL pushes 0 and the following OP_VERIFY aborts. Because the
guard sits on both the OP_IF (claim) and OP_ELSE (refund) branches, there is no
escape route — the UTXO is bricked.
The deeper cause is a decoupling of two values that must be coupled:
amountis a compile-time parameter substituted into the script (see_build_rxdindocs/brainstorms/gravity-ref-spike/build_htlc_covenant_spk.py):amount = int(sys.argv[2]) ... subs = {..., "amount": push(scriptnum(amount)).hex(), ...} for name, val in subs.items(): spk_hex = spk_hex.replace(f"<{name}>", val)
the carrier value is chosen independently at funding time, when the covenant SPK is placed into a real UTXO.
Nothing in the build or funding path enforced amount <= carrier. The builder happily
compiled amount=50000000 into a script that was then funded with 100,000 photons,
producing a structurally valid but permanently unspendable covenant.
Solution¶
The invariant: amount <= funded_carrier. The spend routes the carrier (plus any
surplus from extra fee inputs) into output 0, and the covenant requires
output[0].value >= amount. So the pin must never exceed what you actually fund.
Choose the carrier you will fund first, then pin
amountat or below it. In the corrected run the carrier was 500,000 photons andamountwas pinned to 100,000 (100000 <= 500000, comfortably satisfying the guard).Rebuild the covenant SPK with the matching pin via the RXD variant builder:
build_htlc_covenant_spk.py rxd <amount> <taker_wif> <maker_wif> <hashlock_hex> <refund_csv>
with
<amount>= 100000._build_rxdsubstitutespush(scriptnum(amount))into the<amount>placeholder on both branches, producing the 141-byte RXD HTLC covenant. (The static guards still run: GUARD 1 asserts no bare0xbd, GUARD 2 asserts no input refs — native RXD has neither.)Fund a fresh covenant UTXO with the new SPK. Do not attempt to reuse or rescue the old bricked UTXO — it is permanently unspendable.
Claim, supplying the preimage and the
OP_0selector. The claim routed the full 5,500,000 photons (carrier plus surplus from a separate fee input) into the single taker output. This passesoutput[0].value (5.5M) >= amount (100k), satisfies theOP_SHA256 <H> OP_EQUALVERIFYhashlock and theOP_OUTPUTBYTECODE OP_HASH256 <takerHash> OP_EQUALdestination pin, and pays the fee from the separate input (not from output 0). Covenant scriptSig:20<p>00 # 32-byte preimage push, then OP_0 (claim selector)
Because the guard is >= (not ==), over-funding output 0 is always safe; only
under-pinning relative to the carrier is required. The failure mode is pinning amount
above the carrier.
Verification¶
The corrected covenant claimed cleanly on Radiant mainnet. The preimage p pushed in
the RXD claim scriptSig (20<p>00) is byte-identical to the preimage revealed in
the corresponding Bitcoin Taproot claim witness — confirming both the value-guard fix
and the cross-chain secret binding. (Reference txids are recorded in
docs/brainstorms/gravity-ref-spike/REAL_SWAP_RESULT.md,
Phase 4b.)
On-chain check (confirms acceptance under live consensus):
radiant-cli getrawtransaction <claim_txid> 1
A confirmed result (non-null blockhash / confirmations >= 1) verifies the spend
succeeded. The bricked v1 covenant remains unspendable on both branches; its 100,000
photons are unrecoverable. The coupling of the covenant amount pin to the real
carrier value is exactly the class of bug the external audit must cover — a working
demo is not an audit.
Prevention¶
1. Pre-funding assertion (parse the pin back out of the SPK)¶
Before broadcasting the funding tx, recover the amount literal from the compiled
covenant script and assert the carrier you are about to send is at least that large.
Don’t trust the in-memory builder value — parse what actually got compiled into the
SPK, because that is what consensus enforces.
def extract_value_pin(spk: bytes) -> int:
"""
Recover the <amount> push feeding the value-conservation check
OP_0 OP_OUTPUTVALUE <amount> OP_GREATERTHANOREQUAL OP_VERIFY.
"""
ops = list(parse_script(spk)) # [(opcode, pushdata_or_None, offset), ...]
for i, (op, data, _) in enumerate(ops):
if op == OP_GREATERTHANOREQUAL and i + 1 < len(ops) and ops[i + 1][0] == OP_VERIFY:
amount_push = ops[i - 1][1] # OP_OUTPUTVALUE <amount> OP_GTE
assert amount_push is not None, "expected a data push before OP_GREATERTHANOREQUAL"
return int.from_bytes(amount_push, "little", signed=True) # script-num decode
raise ValueError("no value-conservation check found in covenant SPK")
def assert_fundable(spk: bytes, carrier_photons: int) -> None:
pin = extract_value_pin(spk)
if carrier_photons < pin:
raise ValueError(
f"UNFUNDABLE COVENANT: carrier {carrier_photons} < amount pin {pin}; "
f"both claim and refund branches would fail OP_VERIFY and strand the carrier."
)
# call site, immediately before signing/broadcasting the funding tx:
assert_fundable(covenant_spk, funding_output.value)
broadcast(funding_tx)
Decode the push as a script-num (little-endian, sign-magnitude), not a plain int. If the check appears on both branches (as here), assert both occurrences match if the builder ever allows asymmetric pins.
2. Spendability self-test (dry-run before funding real value)¶
After building the SPK and before committing real value, simulate a spend against a synthetic UTXO whose value equals the intended carrier. This catches the stranding bug at build time instead of after broadcast.
def selftest_spendable(covenant_spk, intended_carrier, preimage, claim_key) -> None:
synthetic_utxo = make_utxo(spk=covenant_spk, value=intended_carrier)
claim_tx = build_htlc_claim(synthetic_utxo, preimage, claim_key, out0_value=intended_carrier)
ok, err = run_script_interpreter(claim_tx, input_index=0, prevout=synthetic_utxo,
flags=MANDATORY_VERIFY_FLAGS)
if not ok:
raise AssertionError(f"covenant not spendable with carrier {intended_carrier}: {err}")
# repeat for the refund branch
validate_htlc_covenant.py already exists as a static guard (it checks SPK shape).
Extend it to accept a planned-carrier value and additionally assert
amount_pin <= planned_carrier for every value-conservation check on every branch —
turning a build-time shape check into a build-time fundability check.
3. Design note: compile-time pins vs runtime funding are a coupling hazard¶
Covenant value pins (amount), time pins (deadline), and sequence pins (csv) are
baked into the script at compile time but are only meaningful relative to values the
runtime supplies — the funded carrier, the actual height/MTP, the spending tx’s
nSequence. Any parameter substituted into a script that is later compared against an
on-chain value is a coupling hazard: the script and the funding step can drift apart
silently, and the only feedback is mandatory-script-verify-flag-failed after the
money is locked.
Centralize the (amount, carrier) decision (and (deadline, anchor),
(csv, spend_nSequence)) in ONE place — a single params object both the SPK compiler
and the funding-tx builder read from, so a mismatch is impossible to express:
@dataclass(frozen=True)
class HtlcParams:
amount_pin: int # compiled into the SPK
carrier: int # funded into the covenant output
deadline: int
csv: int
def __post_init__(self):
if self.carrier < self.amount_pin:
raise ValueError(f"carrier {self.carrier} < amount_pin {self.amount_pin}: unfundable")
spk = compile_htlc_covenant(params) # uses params.amount_pin
fund = build_funding_tx(spk, params.carrier) # cannot diverge
4. Test case suggestion¶
def test_underfunded_covenant_rejected_before_broadcast():
spk = compile_htlc_covenant_raw(amount_pin=50_000_000) # the buggy combo
with pytest.raises(ValueError, match="UNFUNDABLE COVENANT"):
assert_fundable(spk, carrier_photons=100_000)
def test_parsed_pin_matches_builder():
spk = compile_htlc_covenant_raw(amount_pin=50_000_000)
assert extract_value_pin(spk) == 50_000_000
def test_exact_funding_is_accepted():
spk = compile_htlc_covenant_raw(amount_pin=100_000)
assert_fundable(spk, carrier_photons=100_000) # boundary: carrier == pin is OK