Summary
handle_assert_sent_verifier is the automated defense against a bad OperatorAssert. When connector_e is unspent it never runs validate_pubin_disprove. It then skips ChallengeAssert if Groth16 verifies on [static_input] only.
static_input is the global operator VK hash + Ziren version. x_d is whatever 32 bytes the operator put in this graph’s assert WOTS. Guest (blockhash, constant, included_watchtowers) is not decoded. Off-chain get_operator_proof already rejects outputs.constant != statement.constant. The on-chain skip does not.
PubinDisprove does bind this graph’s constant and x_d == blake3(guest pubin). That script only runs if connector_e is already spent when AssertSent is handled. Honest nodes commit pubin before Assert. A malicious operator can invert that:
- Broadcast OperatorAssert first (WOTS key is local;
operator_sign_assert spends connector_c, independent of connector_e).
- Stock verifier:
connector_e unspent → skip pubin check → Groth16 true on a replayed proof → return Ok(()).
- Local queue marks AssertSent
Processed. Later ticks still detect AssertSent, but upsert_message(..., is_update: false) will not recreate {graph_id}_AssertSent.
- Commit pubin before the commit-timeout CSV (Bitcoin 432 after WCI). That spends
connector_e, so handle_operator_commit_pubin_timeout_verifier returns without sending OperatorCommitTimeout (E+F).
- No handler re-runs
validate_pubin_disprove after a late pubin. handle_take2_ready_operator only checks Challenge status, unspent inputs, and CSV. Take2 after assert+432 and WCI+576.
Not proven here: live Assert / Take2 / BTC leaving the peg. This is a complete code path on stock gc-v2, not a live steal. First graph on a fresh VK cannot replay (needs a prior valid operator-proof for this VK).
A committee verifier that was offline at AssertSent and first comes online after the late pubin, with an empty local DB, would create AssertSent and slash via PubinDisprove. Deployed verifiers keep persistent DBs. Same caveat as #429.
Affected component
GOATNetwork/bitvm-node gc-v2 — Bridge State Machine (verifier challenge path).
node/src/handle.rs handle_assert_sent_verifier — pubin check gated on connector_e already spent; Groth16 skip on [derive_operator_static_input()] only
node/src/utils.rs derive_operator_static_input / operator_identity — VK + Ziren, not hash_operator_constant
bitvm-gc TxAssertWitness::verify_groth16_proof — Groth16 on [static_input, x_d]
node/src/action.rs — Ok(()) → message Processed
node/src/utils.rs upsert_message(..., is_update: false) — will not recreate {graph_id}_AssertSent
node/src/scheduled_tasks/graph_maintenance_tasks.rs detect_assert_sent_flow — re-detects every Challenge tick; upsert still no-ops
handle_operator_commit_pubin_timeout_verifier — returns if connector_e or connector_f already spent
handle_take2_ready_operator — no L2 withdraw / no statement check
- Honest
AssertReady is only detected after pubin_commit_completed; malicious operator does not use that path (operator_sign_assert is local)
Checked on gc-v2. Not #410 (witness format / recover-and-verify, not graph-constant bind), not #416 (circuit public-values bind), not #418, not #429 (KickoffSent / Take1).
Mechanism
// handle_assert_sent_verifier
if let Ok(connector_e_input) = graph.watchtower_challenge_init.connector_e_input() {
// only if connector_e is ALREADY spent:
if let Some(commit_pubin_txid) = outpoint_spent_txid(...) {
match validate_pubin_disprove(...) { /* slash or proceed */ }
}
}
// falls through when connector_e is unspent
if assert_witness.verify_groth16_proof(&vk, &[static_input]) {
tracing::info!("Skip ChallengeAssert ... operator assert proof is valid");
return Ok(()); // no defer
}
operator_sign_assert spends kickoff connector_c. operator_sign_commit_pubin spends WCI connector_e. Independent UTXOs. Invert-order is allowed on-chain.
Bitcoin pubin_disprove_scripts.rs: x_d is the guest-pubin commitment. Replay of graph A’s proof into graph B:
| Later pubin |
If PubinDisprove ran |
| This graph’s 96 bytes |
blake3 ≠ A’s x_d → slash |
| A’s 96 bytes |
A’s constant ≠ baked hash_operator_constant → slash |
It does not run after the skip.
Steps (code path)
- Graph is committee-presigned. Challenge flow complete.
connector_e unspent.
- Operator already produced a valid operator-proof on an earlier graph for this VK. Replays
(π, x_d) into this graph’s assert WOTS and broadcasts OperatorAssert.
detect_assert_sent_flow enqueues AssertSent.
- Each online verifier:
connector_e unspent; Groth16([static_input], x_d) true; skip ChallengeAssert; Ok(()).
- Message →
Processed. Restart / later ticks do not recreate it on that node.
- Operator commits some 96-byte pubin before WCI+432. Timeout input
connector_e is spent → timeout never broadcasts.
- Wait ConnectorD / ConnectorF CSV.
- Operator signs Take2 (n-of-n already pre-signed) and broadcasts.
Impact (proven vs not)
Proven (source)
Stock online verifier skips ChallengeAssert on a Groth16 that is not bound to this graph’s constant, when Assert is confirmed before pubin. That skip is one-shot. A later pubin disables OperatorCommitTimeout. Take2 has no statement check.
Not proven
Live Assert / Take2 / BTC moved / undercollateralized peg on testnet or mainnet.
Suggested fix
Do not skip ChallengeAssert unless pubin is on chain and validate_pubin_disprove returns None (consistent with this graph’s baked constant). If connector_e is unspent: defer (push_local_unhandled_messages_with_reason), do not Ok(())/Processed. After a late pubin, re-run the pubin check (or detect a PubinCommitted message). Optionally pass hash_operator_constant into the Groth16 public inputs so skip cannot succeed on a foreign x_d.
Summary
handle_assert_sent_verifieris the automated defense against a bad OperatorAssert. Whenconnector_eis unspent it never runsvalidate_pubin_disprove. It then skips ChallengeAssert if Groth16 verifies on[static_input]only.static_inputis the global operator VK hash + Ziren version.x_dis whatever 32 bytes the operator put in this graph’s assert WOTS. Guest(blockhash, constant, included_watchtowers)is not decoded. Off-chainget_operator_proofalready rejectsoutputs.constant != statement.constant. The on-chain skip does not.PubinDisprove does bind this graph’s
constantandx_d == blake3(guest pubin). That script only runs ifconnector_eis already spent when AssertSent is handled. Honest nodes commit pubin before Assert. A malicious operator can invert that:operator_sign_assertspendsconnector_c, independent ofconnector_e).connector_eunspent → skip pubin check → Groth16 true on a replayed proof →return Ok(()).Processed. Later ticks still detect AssertSent, butupsert_message(..., is_update: false)will not recreate{graph_id}_AssertSent.connector_e, sohandle_operator_commit_pubin_timeout_verifierreturns without sending OperatorCommitTimeout (E+F).validate_pubin_disproveafter a late pubin.handle_take2_ready_operatoronly checks Challenge status, unspent inputs, and CSV. Take2 after assert+432 and WCI+576.Not proven here: live Assert / Take2 / BTC leaving the peg. This is a complete code path on stock
gc-v2, not a live steal. First graph on a fresh VK cannot replay (needs a prior valid operator-proof for this VK).A committee verifier that was offline at AssertSent and first comes online after the late pubin, with an empty local DB, would create AssertSent and slash via PubinDisprove. Deployed verifiers keep persistent DBs. Same caveat as #429.
Affected component
GOATNetwork/bitvm-nodegc-v2— Bridge State Machine (verifier challenge path).node/src/handle.rshandle_assert_sent_verifier— pubin check gated onconnector_ealready spent; Groth16 skip on[derive_operator_static_input()]onlynode/src/utils.rsderive_operator_static_input/operator_identity— VK + Ziren, nothash_operator_constantbitvm-gcTxAssertWitness::verify_groth16_proof— Groth16 on[static_input, x_d]node/src/action.rs—Ok(())→ messageProcessednode/src/utils.rsupsert_message(..., is_update: false)— will not recreate{graph_id}_AssertSentnode/src/scheduled_tasks/graph_maintenance_tasks.rsdetect_assert_sent_flow— re-detects every Challenge tick; upsert still no-opshandle_operator_commit_pubin_timeout_verifier— returns ifconnector_eorconnector_falready spenthandle_take2_ready_operator— no L2 withdraw / no statement checkAssertReadyis only detected afterpubin_commit_completed; malicious operator does not use that path (operator_sign_assertis local)Checked on
gc-v2. Not#410(witness format / recover-and-verify, not graph-constant bind), not#416(circuit public-values bind), not#418, not#429(KickoffSent / Take1).Mechanism
operator_sign_assertspends kickoffconnector_c.operator_sign_commit_pubinspends WCIconnector_e. Independent UTXOs. Invert-order is allowed on-chain.Bitcoin
pubin_disprove_scripts.rs:x_dis the guest-pubin commitment. Replay of graph A’s proof into graph B:x_d→ slashhash_operator_constant→ slashIt does not run after the skip.
Steps (code path)
connector_eunspent.(π, x_d)into this graph’s assert WOTS and broadcasts OperatorAssert.detect_assert_sent_flowenqueuesAssertSent.connector_eunspent; Groth16([static_input], x_d)true; skip ChallengeAssert;Ok(()).Processed. Restart / later ticks do not recreate it on that node.connector_eis spent → timeout never broadcasts.Impact (proven vs not)
Proven (source)
Stock online verifier skips ChallengeAssert on a Groth16 that is not bound to this graph’s
constant, when Assert is confirmed before pubin. That skip is one-shot. A later pubin disables OperatorCommitTimeout. Take2 has no statement check.Not proven
Live Assert / Take2 / BTC moved / undercollateralized peg on testnet or mainnet.
Suggested fix
Do not skip ChallengeAssert unless pubin is on chain and
validate_pubin_disprovereturnsNone(consistent with this graph’s baked constant). Ifconnector_eis unspent: defer (push_local_unhandled_messages_with_reason), do notOk(())/Processed. After a late pubin, re-run the pubin check (or detect aPubinCommittedmessage). Optionally passhash_operator_constantinto the Groth16 public inputs so skip cannot succeed on a foreignx_d.