Summary
Unauthorized kickoff (no L2 initWithdraw) is in the Gateway threat model. Automated Challenge starts at detect_kickoff, which is supposed to enqueue {graph_id}_KickoffSent for every posted graph whose kickoff is confirmed.
detect_kickoff does not scan every OperatorDataPushed graph. It uses fetch_on_turn_graph_by_status, which keeps one row per operator_pubkey. SQL order is operator_pubkey, kickoff_index ASC, so the watched graph is always the lowest nonce still at OperatorDataPushed.
An operator with two posted graphs leaves nonce 0 idle and broadcasts kickoff on nonce ≥ 1. Honest verifiers and the committee never create {graph_id}_KickoffSent for the kicked graph. After ConnectorA CSV the operator Take1s. PegBTC was never burned.
This is not #429. #429 is: KickoffSent exists, verifier Ok(()) on SPV lag, no retry. Here KickoffSent is never created for the kicked graph. Fixing #429 (defer on SPV lag) does not close this.
Not proven here: live kickoff / Take1 / BTC leaving the peg. Complete code path on stock gc-v2, not a live steal.
Affected component
GOATNetwork/bitvm-node gc-v2 @ f2f0285e — Bridge State Machine (kickoff detection). Live gc-v2 HEAD is still this commit (2026-08-17). No later patch on detect_kickoff / fetch_on_turn_graph_by_status.
node/src/scheduled_tasks/mod.rs fetch_on_turn_graph_by_status — “Only process one graph for each operator each time”
crates/store/src/localdb.rs find_graphs_by_status_group_by_operator — ORDER BY operator_pubkey, kickoff_index
node/src/scheduled_tasks/graph_maintenance_tasks.rs detect_kickoff — only that filtered set; only that row’s kickoff_txid
- Same helper also feeds
detect_take1_or_challenge (OperatorKickOff) and process_graph_challenge (Challenge)
check_pre_kickoff_sent — only reached from detect_take1_or_challenge (OperatorKickOff), so an idle decoy never walks the chain forward
refresh_and_compensate — only when a message already names that graph_id
handle_take1_ready_operator — no L2 withdraw-status check
operator_kickoff / operator_sign_take1 — sign this graph only; do not require the lower nonce to be skipped
Not #416, not #418, not #428, not #429, not #430.
Mechanism
// fetch_on_turn_graph_by_status
// graphs_ori already ORDER BY operator_pubkey, kickoff_index
for graph in graphs_ori {
if graph.operator_pubkey != pre_operator_pubkey {
pre_operator_pubkey = graph.operator_pubkey.clone();
graphs.push(graph); // first / lowest kickoff_index only
}
}
// detect_kickoff
let graphs = fetch_on_turn_graph_by_status(..., OperatorDataPushed)?;
// only graphs[0] for this operator is checked for kickoff-on-chain
Two OperatorDataPushed graphs for one operator is the normal multi-pegin state (graph_nonce / kickoff_index chain). The unused lower nonce is the decoy. It never changes status (no maintenance ticker calls refresh_graph on unused OperatorDataPushed rows), so later ticks keep watching it forever.
G1 prekickoff spends G0’s next-prekickoff connector, not G0’s kickoff connector (vout 1). G0 therefore stays OperatorDataPushed in the local DB. Honest handle_kickoff_ready_operator would skip/close lower nonces first; a malicious operator calls operator_kickoff locally and does not.
No other path fills the gap:
detect_take1_or_challenge / process_graph_challenge also use fetch_on_turn (and on OperatorKickOff / Challenge, not the idle decoy).
check_pre_kickoff_sent walks the prekickoff chain only from an already-OperatorKickOff graph.
- L2
InitWithdraw records a goat-tx hash; it does not move verifier graph status and does not enqueue KickoffSent.
upsert_message(..., is_update: false) cannot create {victim}_KickoffSent if detect_kickoff never names the victim.
Take1 does not need honest nodes to enqueue Take1Ready. operator_sign_take1 only needs committee presigs + unspent ConnectorA/B/guardian + CSV.
Steps (code path)
- Operator has G0 (
kickoff_index=0) and G1 (kickoff_index=1), both postGraphData’d. G0 withdraw is None. Normal multi-pegin state.
- Operator broadcasts G1 prekickoff + kickoff. No
initWithdraw on G1.
- Each honest node:
detect_kickoff watches G0; G0 kickoff is not confirmed; no message.
- G1 is never scanned. No Challenge. No
proceedWithdraw.
- Wait ConnectorA timelock.
- Operator signs Take1 and broadcasts.
A verifier that first comes online with only G1 in an empty DB would watch G1. Deployed nodes that presigned both pegins keep both rows. Same empty-DB caveat as #429. First graph on a brand-new operator is #429, not this.
Why the project's own TLA+ audit did not catch this
Repo ships audit/TLAPlus-20260710.md (commit 991faaa “Dev fix #418”). Scope note:
these specs model the local node's status bookkeeping and the timelock arithmetic of the transaction graph.
Coverage is every stateful enum (GraphStatus, instance/message races) plus shared-connector CSV margins. There is no model of the maintenance scheduler’s graph-selection (fetch_on_turn_graph_by_status): multiple graphs of the same operator competing for one per-tick slot.
TLA+ Finding 1 / 1b (Graph.status uncoordinated writers) is marked data-integrity, not fund-custody, and is FIXED in 991faaa via transition_graph_status + allowed_transition_from. That predecessor table correctly excludes PreKickoff/OperatorKickOff as sources of OperatorDataPushed. A graph that detect_kickoff never observes never reaches that guard. The status machine they verified is fine; the victim graph is never fed into it.
Recommendation 7 is a different gap (topology / value-conservation / leaf-script authorization). This ticket is the unmodeled scheduler gap, not Rec-7.
Impact (proven vs not)
Proven (source + local predicate)
Stock detect drops every OperatorDataPushed graph except the lowest kickoff_index per operator. Kickoff on a later graph does not create KickoffSent. Take1 has no L2 withdraw check.
Not proven
Live kickoff / Take1 / BTC moved / undercollateralized peg on testnet or mainnet.
Suggested fix
- Stop using
fetch_on_turn_graph_by_status for protocol detect. It also feeds detect_take1_or_challenge and process_graph_challenge. Fixing only detect_kickoff leaves Assert/NACK/Take2 detect blind on a second Challenge graph.
- Scan every graph in the relevant status (or every known kickoff/assert txid).
- Periodically
refresh_and_compensate unused OperatorDataPushed rows whose prekickoff or kickoff is on chain.
- Keep the
#429 SPV-lag retry once KickoffSent exists.
- Do not treat “one graph per operator per tick” as a substitute for watching every live kickoff txid.
Summary
Unauthorized kickoff (no L2
initWithdraw) is in the Gateway threat model. Automated Challenge starts atdetect_kickoff, which is supposed to enqueue{graph_id}_KickoffSentfor every posted graph whose kickoff is confirmed.detect_kickoffdoes not scan everyOperatorDataPushedgraph. It usesfetch_on_turn_graph_by_status, which keeps one row peroperator_pubkey. SQL order isoperator_pubkey, kickoff_indexASC, so the watched graph is always the lowest nonce still atOperatorDataPushed.An operator with two posted graphs leaves nonce 0 idle and broadcasts kickoff on nonce ≥ 1. Honest verifiers and the committee never create
{graph_id}_KickoffSentfor the kicked graph. After ConnectorA CSV the operator Take1s. PegBTC was never burned.This is not
#429.#429is: KickoffSent exists, verifierOk(())on SPV lag, no retry. Here KickoffSent is never created for the kicked graph. Fixing#429(defer on SPV lag) does not close this.Not proven here: live kickoff / Take1 / BTC leaving the peg. Complete code path on stock
gc-v2, not a live steal.Affected component
GOATNetwork/bitvm-nodegc-v2@f2f0285e— Bridge State Machine (kickoff detection). Livegc-v2HEAD is still this commit (2026-08-17). No later patch ondetect_kickoff/fetch_on_turn_graph_by_status.node/src/scheduled_tasks/mod.rsfetch_on_turn_graph_by_status— “Only process one graph for each operator each time”crates/store/src/localdb.rsfind_graphs_by_status_group_by_operator—ORDER BY operator_pubkey, kickoff_indexnode/src/scheduled_tasks/graph_maintenance_tasks.rsdetect_kickoff— only that filtered set; only that row’skickoff_txiddetect_take1_or_challenge(OperatorKickOff) andprocess_graph_challenge(Challenge)check_pre_kickoff_sent— only reached fromdetect_take1_or_challenge(OperatorKickOff), so an idle decoy never walks the chain forwardrefresh_and_compensate— only when a message already names thatgraph_idhandle_take1_ready_operator— no L2 withdraw-status checkoperator_kickoff/operator_sign_take1— sign this graph only; do not require the lower nonce to be skippedNot
#416, not#418, not#428, not#429, not#430.Mechanism
Two
OperatorDataPushedgraphs for one operator is the normal multi-pegin state (graph_nonce/kickoff_indexchain). The unused lower nonce is the decoy. It never changes status (no maintenance ticker callsrefresh_graphon unusedOperatorDataPushedrows), so later ticks keep watching it forever.G1 prekickoff spends G0’s next-prekickoff connector, not G0’s kickoff connector (vout 1). G0 therefore stays
OperatorDataPushedin the local DB. Honesthandle_kickoff_ready_operatorwould skip/close lower nonces first; a malicious operator callsoperator_kickofflocally and does not.No other path fills the gap:
detect_take1_or_challenge/process_graph_challengealso usefetch_on_turn(and onOperatorKickOff/Challenge, not the idle decoy).check_pre_kickoff_sentwalks the prekickoff chain only from an already-OperatorKickOffgraph.InitWithdrawrecords a goat-tx hash; it does not move verifier graph status and does not enqueueKickoffSent.upsert_message(..., is_update: false)cannot create{victim}_KickoffSentifdetect_kickoffnever names the victim.Take1 does not need honest nodes to enqueue
Take1Ready.operator_sign_take1only needs committee presigs + unspent ConnectorA/B/guardian + CSV.Steps (code path)
kickoff_index=0) and G1 (kickoff_index=1), bothpostGraphData’d. G0 withdraw isNone. Normal multi-pegin state.initWithdrawon G1.detect_kickoffwatches G0; G0 kickoff is not confirmed; no message.proceedWithdraw.A verifier that first comes online with only G1 in an empty DB would watch G1. Deployed nodes that presigned both pegins keep both rows. Same empty-DB caveat as
#429. First graph on a brand-new operator is#429, not this.Why the project's own TLA+ audit did not catch this
Repo ships
audit/TLAPlus-20260710.md(commit991faaa“Dev fix #418”). Scope note:Coverage is every stateful enum (
GraphStatus, instance/message races) plus shared-connector CSV margins. There is no model of the maintenance scheduler’s graph-selection (fetch_on_turn_graph_by_status): multiple graphs of the same operator competing for one per-tick slot.TLA+ Finding 1 / 1b (
Graph.statusuncoordinated writers) is marked data-integrity, not fund-custody, and is FIXED in991faaaviatransition_graph_status+allowed_transition_from. That predecessor table correctly excludesPreKickoff/OperatorKickOffas sources ofOperatorDataPushed. A graph thatdetect_kickoffnever observes never reaches that guard. The status machine they verified is fine; the victim graph is never fed into it.Recommendation 7 is a different gap (topology / value-conservation / leaf-script authorization). This ticket is the unmodeled scheduler gap, not Rec-7.
Impact (proven vs not)
Proven (source + local predicate)
Stock detect drops every
OperatorDataPushedgraph except the lowestkickoff_indexper operator. Kickoff on a later graph does not create KickoffSent. Take1 has no L2 withdraw check.Not proven
Live kickoff / Take1 / BTC moved / undercollateralized peg on testnet or mainnet.
Suggested fix
fetch_on_turn_graph_by_statusfor protocol detect. It also feedsdetect_take1_or_challengeandprocess_graph_challenge. Fixing onlydetect_kickoffleaves Assert/NACK/Take2 detect blind on a secondChallengegraph.refresh_and_compensateunusedOperatorDataPushedrows whose prekickoff or kickoff is on chain.#429SPV-lag retry once KickoffSent exists.