Summary
Any unprivileged user can permanently wedge a Committee node's bridge-out (Swap) event watcher with one cheap on-chain claim.
BitcoinNoncedOutputClaimHandler.claim only checks keccak256(scriptPubKey) against the committed txoHash. It accepts OP_RETURN / bare / non-standard payout scripts. The node then does Address::from_script(...)? on that same script. A non-address script returns Err, the block-range DB transaction rolls back, and the Swap cursor from_height never advances. Every later tick re-fetches the same poison event.
Not proven here: live poison initialize+claim on testnet4 (withheld — that would wedge the deployed Committee node). This is a complete stock path on gc-v2, not a fund theft.
Affected component
GOATNetwork/bitvm-node gc-v2 — Bridge State Machine (Swap/EscrowManager event watcher).
node/src/scheduled_tasks/event_watch_task.rs handle_swap_claim_events — Address::from_script(...)?
monitor_events_item — fetch_and_handle_block_range_events(...).await? then from_height = to_height + 1 + commit
fetch_and_handle_bridge_out_events — Initializes then Claims in the same range
- On-chain:
BitcoinNoncedOutputClaimHandler.claim — keccak of script only, no script-type gate
Checked on gc-v2. Not #428 (unauth tag PUTs). Not #429 / #430 / #431. Not #21. Not #339 (style nits / hex parse). Not Pashov.
Mechanism
let to_addr = Address::from_script(
bitcoin::Script::from_bytes(&claim_data.output_script),
get_network(),
)?;
Inside the per-range transaction:
fetch_and_handle_block_range_events(...).await?; // Err returns before commit
watch_contract.from_height = to_height + 1;
tx.upsert_watch_contract(&watch_contract).await?;
tx.commit().await?;
find_claim_data swallows witness-decode errors (Ok(None)). The reachable wedge is a valid extract of a contract-accepted claim whose output_script is not P2PKH/P2SH/P2WPKH/P2WSH/P2TR.
Initialize in the same block range creates the instance first, so Claim does not skip from_script. initialize as both offerer and claimer with the reputation flag unset needs no claimer signature. amount has no > 0 require.
Only Actor::Committee watches Swap. Gateway is a separate monitor_events_item transaction. Swap Err does not roll back bridge-in.
WatchContractStatus::Failed is set on the history path (fetch_history_events), not the live tick. Live tick: log + retry the same range forever. Same wedge, no self-recovery without a code change + cursor surgery.
Impact
Remote, unauthenticated, cheap, permanent DoS of Committee bridge-out indexing (Claim/Refund/timeout tracking, bridge_out_global_stats). Escrow funds stay on-chain-authoritative (EscrowStorage._finalize is atomic). No pegin steal. No Gateway wedge.
Availability of a money-path indexer, not unauthorized withdrawal.
Suggested fix
Best-effort address decode (sibling already exists at utils.rs generate_instance: Address::from_script(...).map(|a| a.to_string()).unwrap_or_default()), and isolate per-event failures instead of aborting the block-range tx. Still advance from_height.
Summary
Any unprivileged user can permanently wedge a Committee node's bridge-out (Swap) event watcher with one cheap on-chain claim.
BitcoinNoncedOutputClaimHandler.claimonly checkskeccak256(scriptPubKey)against the committedtxoHash. It accepts OP_RETURN / bare / non-standard payout scripts. The node then doesAddress::from_script(...)?on that same script. A non-address script returnsErr, the block-range DB transaction rolls back, and the Swap cursorfrom_heightnever advances. Every later tick re-fetches the same poison event.Not proven here: live poison
initialize+claimon testnet4 (withheld — that would wedge the deployed Committee node). This is a complete stock path ongc-v2, not a fund theft.Affected component
GOATNetwork/bitvm-nodegc-v2— Bridge State Machine (Swap/EscrowManager event watcher).node/src/scheduled_tasks/event_watch_task.rshandle_swap_claim_events—Address::from_script(...)?monitor_events_item—fetch_and_handle_block_range_events(...).await?thenfrom_height = to_height + 1+ commitfetch_and_handle_bridge_out_events— Initializes then Claims in the same rangeBitcoinNoncedOutputClaimHandler.claim— keccak of script only, no script-type gateChecked on
gc-v2. Not#428(unauth tag PUTs). Not#429/#430/#431. Not#21. Not#339(style nits / hex parse). Not Pashov.Mechanism
Inside the per-range transaction:
find_claim_dataswallows witness-decode errors (Ok(None)). The reachable wedge is a valid extract of a contract-accepted claim whoseoutput_scriptis not P2PKH/P2SH/P2WPKH/P2WSH/P2TR.Initialize in the same block range creates the instance first, so Claim does not skip
from_script.initializeas both offerer and claimer with the reputation flag unset needs no claimer signature.amounthas no> 0require.Only
Actor::Committeewatches Swap. Gateway is a separatemonitor_events_itemtransaction. SwapErrdoes not roll back bridge-in.WatchContractStatus::Failedis set on the history path (fetch_history_events), not the live tick. Live tick: log + retry the same range forever. Same wedge, no self-recovery without a code change + cursor surgery.Impact
Remote, unauthenticated, cheap, permanent DoS of Committee bridge-out indexing (Claim/Refund/timeout tracking,
bridge_out_global_stats). Escrow funds stay on-chain-authoritative (EscrowStorage._finalizeis atomic). No pegin steal. No Gateway wedge.Availability of a money-path indexer, not unauthorized withdrawal.
Suggested fix
Best-effort address decode (sibling already exists at
utils.rsgenerate_instance:Address::from_script(...).map(|a| a.to_string()).unwrap_or_default()), and isolate per-event failures instead of aborting the block-range tx. Still advancefrom_height.