Summary
Unauthorized kickoff (no L2 initWithdraw) is in the Gateway threat model. The automated defense is handle_kickoff_sent_verifier: if withdraw is None/Canceled, broadcast Challenge.
When kickoff_height >= btc_spv_latest_height() the handler logs and returns Ok(()) without push_local_unhandled_messages_with_reason. The local queue then marks the message Processed. detect_kickoff only scans OperatorDataPushed, so it never re-enqueues. After ConnectorA CSV the operator can Take1. PegBTC was never burned.
Committee handle_kickoff_sent_committee already retries the same SPV lag. Verifier does not. The verifier check is also strict > (skips when SPV == H), while committee proceeds at SPV >= H.
Not proven here: live kickoff broadcast, live Take1, BTC leaving the peg. This is a complete code path on stock gc-v2, not a live steal.
Affected component
GOATNetwork/bitvm-node gc-v2 — Bridge State Machine (verifier challenge path).
node/src/handle.rs handle_kickoff_sent_verifier — skip + no defer when kickoff_height >= goat_confirmed_btc_height
node/src/handle.rs handle_kickoff_sent_committee — does defer the same SPV lag
node/src/action.rs — Ok(()) → message Processed
node/src/scheduled_tasks/graph_maintenance_tasks.rs detect_kickoff — only OperatorDataPushed
node/src/utils.rs upsert_message(..., is_update: false) — will not recreate {graph_id}_KickoffSent
handle_take1_ready_operator — no L2 withdraw-status check
should_always_challenge() is defined and never called
- Watchtower / Operator /
Actor::All hit handle_kickoff_sent_default (status only)
Checked on gc-v2 @ f2f0285e. Not #416 (recursion bind), not #418 (Take1/Challenge CSV race — scripts are fine if Challenge is actually sent), not #428, not #349.
Mechanism
if [WithdrawStatus::None, WithdrawStatus::Canceled].contains(&withdraw_status) {
if kickoff_height >= goat_confirmed_btc_height {
tracing::warn!(/* not confirmed by goat spv yet */);
return Ok(()); // no push_local_unhandled_messages_with_reason
} else {
send_challenge_tx(...);
}
}
First detection is when Bitcoin confirms kickoff. GOAT SPV lags the Bitcoin tip, so kickoff_height >= goat_confirmed is the common first-confirm case. Every online honest verifier processes once, skips, and is done.
Steps (code path)
- Graph is committee-presigned and
OperatorDataPushed. L2 withdraw for graph_id is None.
- Operator broadcasts prekickoff+kickoff. PegBTC stays circulating.
detect_kickoff enqueues KickoffSent to Actor::All.
- Each Verifier:
refresh_graph_status → OperatorKickOff; withdraw None; kickoff_height >= SPV → log and Ok(()).
- Message →
Processed. No later handler re-checks this graph for an L2-invalid kickoff.
- Wait ConnectorA timelock.
- Operator signs Take1 (n-of-n already pre-signed on connector_0) and broadcasts.
proceedWithdraw / finishWithdrawHappyPath cannot run (status != Initialized).
A verifier that first comes online only after SPV > H and has an empty local DB could still challenge. Deployed verifiers keep persistent DBs. Restart does not recreate KickoffSent.
Impact (proven vs not)
Proven (source + local predicate)
Stock online verifier skips Challenge on first confirm (SPV lag or SPV == H) and never retries. Operator Take1 after CSV has no L2 withdraw check.
Not proven
Live kickoff / Take1 / BTC moved / undercollateralized peg on testnet or mainnet.
Suggested fix
On None/Canceled + SPV not strictly past kickoff: push_local_unhandled_messages_with_reason (same pattern as committee). Challenge when SPV >= kickoff_height (or keep > but retry). Periodically re-check OperatorKickOff + withdraw ∈ {None, Canceled} + connector_a unspent and send Challenge. Optionally have Watchtower call send_challenge_tx on the same predicate.
Summary
Unauthorized kickoff (no L2
initWithdraw) is in the Gateway threat model. The automated defense ishandle_kickoff_sent_verifier: if withdraw isNone/Canceled, broadcast Challenge.When
kickoff_height >= btc_spv_latest_height()the handler logs and returnsOk(())withoutpush_local_unhandled_messages_with_reason. The local queue then marks the messageProcessed.detect_kickoffonly scansOperatorDataPushed, so it never re-enqueues. After ConnectorA CSV the operator can Take1. PegBTC was never burned.Committee
handle_kickoff_sent_committeealready retries the same SPV lag. Verifier does not. The verifier check is also strict>(skips whenSPV == H), while committee proceeds atSPV >= H.Not proven here: live kickoff broadcast, live Take1, BTC leaving the peg. This is a complete code path on stock
gc-v2, not a live steal.Affected component
GOATNetwork/bitvm-nodegc-v2— Bridge State Machine (verifier challenge path).node/src/handle.rshandle_kickoff_sent_verifier— skip + no defer whenkickoff_height >= goat_confirmed_btc_heightnode/src/handle.rshandle_kickoff_sent_committee— does defer the same SPV lagnode/src/action.rs—Ok(())→ messageProcessednode/src/scheduled_tasks/graph_maintenance_tasks.rsdetect_kickoff— onlyOperatorDataPushednode/src/utils.rsupsert_message(..., is_update: false)— will not recreate{graph_id}_KickoffSenthandle_take1_ready_operator— no L2 withdraw-status checkshould_always_challenge()is defined and never calledActor::Allhithandle_kickoff_sent_default(status only)Checked on
gc-v2@f2f0285e. Not#416(recursion bind), not#418(Take1/Challenge CSV race — scripts are fine if Challenge is actually sent), not#428, not#349.Mechanism
First detection is when Bitcoin confirms kickoff. GOAT SPV lags the Bitcoin tip, so
kickoff_height >= goat_confirmedis the common first-confirm case. Every online honest verifier processes once, skips, and is done.Steps (code path)
OperatorDataPushed. L2 withdraw forgraph_idisNone.detect_kickoffenqueuesKickoffSenttoActor::All.refresh_graph_status→OperatorKickOff; withdrawNone;kickoff_height >= SPV→ log andOk(()).Processed. No later handler re-checks this graph for an L2-invalid kickoff.proceedWithdraw/finishWithdrawHappyPathcannot run (status != Initialized).A verifier that first comes online only after
SPV > Hand has an empty local DB could still challenge. Deployed verifiers keep persistent DBs. Restart does not recreateKickoffSent.Impact (proven vs not)
Proven (source + local predicate)
Stock online verifier skips Challenge on first confirm (SPV lag or
SPV == H) and never retries. Operator Take1 after CSV has no L2 withdraw check.Not proven
Live kickoff / Take1 / BTC moved / undercollateralized peg on testnet or mainnet.
Suggested fix
On
None/Canceled+ SPV not strictly past kickoff:push_local_unhandled_messages_with_reason(same pattern as committee). Challenge whenSPV >= kickoff_height(or keep>but retry). Periodically re-checkOperatorKickOff+withdraw ∈ {None, Canceled}+connector_aunspent and send Challenge. Optionally have Watchtower callsend_challenge_txon the same predicate.