Summary
instance_answers_monitor can overwrite a live, progressing peg-in instance row with a blank stub. The write primitive it uses is a bare INSERT OR REPLACE of every instance column, while the P2P path refuses exactly this write through a compare-and-set status guard. A committee node that is behind on Gateway history and simultaneously live on P2P can have its instance row wiped: committees_answers emptied, parameters set to NULL, btc_txid cleared, status forced to UserDiscarded. The row cannot self-heal; afterwards the node stops contributing pegin_confirm signatures.
Security impact: denial of service on the committee consensus path (pegin_confirm is n of n on the Bitcoin path) plus incorrect state transition on the affected node's local store. No fund theft path identified.
Affected component
- Repository: GOATNetwork/bitvm-node
- Commit reviewed:
1ff9a4aeced45309a097eeba5955e26164302ab3 (current HEAD at time of review)
- Files:
node/src/scheduled_tasks/instance_maintenance_tasks.rs, crates/store/src/localdb.rs, node/src/utils.rs, node/src/scheduled_tasks/event_watch_task.rs, node/src/scheduled_tasks/mod.rs
- Component class: Implementation (Bridge State Machine node internals)
Description
instance_answers_monitor (node/src/scheduled_tasks/instance_maintenance_tasks.rs:118-231) scans goat_tx_record rows of type BridgeInRequest with status Pending. For a record that is outside the response window and whose input UTXOs are no longer available, the monitor builds a fresh instance via generate_instance_from_bridge_in_request_event (node/src/scheduled_tasks/event_watch_task.rs:997-1054) and stamps it UserDiscarded, then writes it with upsert_instance.
The stub is built by generate_instance (node/src/utils.rs:4371-4422) with committees_answers = empty, parameters = None, btc_txid = None. The write goes through upsert_instance (crates/store/src/localdb.rs:1124-1131), which is a bare INSERT OR REPLACE INTO instance (...) that replaces every column.
The P2P path deliberately refuses this write: store_pegin_request (node/src/utils.rs:4437-4465) routes through upsert_pegin_request_instance (crates/store/src/localdb.rs:1179-1217), which carries an explicit allow list of statuses (UserIniting, UserInited). The code comment there states the guard exists "keeps a replayed or forged request from rolling a live instance back to UserInited and clearing what later stages stored". The monitor bypasses that same guard by calling the unguarded sibling.
How it can be exploited (no attacker action needed, self race)
Maintenance is deferred while Gateway history catchup runs (is_processing_gateway_history_events check, node/src/scheduled_tasks/mod.rs:263-282), but P2P keeps running during that window. A node behind on history can therefore have a live instance created by P2P (committees answered, pegin_prepare broadcast, request UTXOs spent) while its BridgeInRequest goat_tx_record is still Pending, because that record is only ever written by the history watcher (event_watch_task.rs:674-687), never by P2P. When catchup finishes, the first monitor pass sees the record outside the response window with spent UTXOs and executes the INSERT OR REPLACE, wiping the live row.
Recovery is impossible from the outside: UserDiscarded is not in the CAS allow list, so replayed PeginRequest messages are ignored, and later BridgeIn mint events update only the status field, not committees_answers, parameters, or btc_txid.
Steps to reproduce
- Run a committee node with a watch contract whose
from_height + gap is behind the finalized tip, so history catchup is active and instance_answers_monitor is deferred (mod.rs:263-282).
- Over P2P, deliver a
PeginRequest for a bridge-in instance. store_pegin_request creates the live instance row (CAS guarded). Committees answer and the user broadcasts pegin_prepare, so the request UTXOs are spent.
- Let history catchup complete; it upserts the
BridgeInRequest goat_tx_record as Pending (event_watch_task.rs:674-687).
instance_answers_monitor runs, sees the record outside the response window with outpoint_available false, and calls upsert_instance with the blank UserDiscarded stub (instance_maintenance_tasks.rs:168-182).
- Inspect the instance row:
committees_answers emptied, parameters NULL, btc_txid NULL, status UserDiscarded. Replay a PeginRequest; it is ignored because UserDiscarded is outside the allow list.
Proof of concept (source level)
No PoC transaction was performed and no live system was contacted; this is a source-only review at the pinned commit. The relevant code, byte-exact at 1ff9a4a:
// instance_maintenance_tasks.rs:168-182, the destructive write
instance.status = InstanceBridgeInStatus::UserDiscarded.to_string();
Some(instance)
...
let mut tx = local_db.start_transaction().await?;
if let Some(event) = event {
if is_outside_response_window {
if let Some(instance) = discarded_instance {
tx.upsert_instance(&instance).await?;
// localdb.rs:1124-1131, unguarded write primitive
pub async fn upsert_instance(&mut self, instance: &Instance) -> anyhow::Result<bool> {
...
let res = sqlx::query!(
"INSERT OR
REPLACE INTO instance (instance_id, network, from_addr, to_addr, amount, fees, input_utxos, status, goat_tx_hash, goat_tx_height,
user_xonly_pubkey, user_change_addr, user_refund_addr, btc_txid, pegin_confirm_txid, pegin_cancel_txid, committees_answers,
pegin_data_tx_hash, btc_height, parameters, status_updated_at, post_pegin_txhash, created_at, updated_at)
// utils.rs:4410-4422, the blank stub fields
status: InstanceBridgeInStatus::UserInited.to_string(),
...
btc_txid: None,
pegin_confirm_txid: None,
pegin_cancel_txid: None,
committees_answers: IndexMap::new(),
pegin_data_tx_hash: "".to_string(),
btc_height: 0,
parameters: None,
// utils.rs:4437-4465, the CAS guard the P2P path uses and the monitor bypasses
// A PeginRequest only initializes an instance. Guarding the write on the
// pre-pegin statuses keeps a replayed or forged request from rolling a live
// instance back to UserInited and clearing what later stages stored.
let stored = storage_processor
.upsert_pegin_request_instance(
&instance,
&[
InstanceBridgeInStatus::UserIniting.to_string(),
InstanceBridgeInStatus::UserInited.to_string(),
],
)
Security impact
Denial of service on the committee consensus path: the affected node stops contributing pegin_confirm signatures, and pegin_confirm is n of n on the Bitcoin path, so a stalled committee delays in-flight peg-ins. Incorrect state transition on the node's local instance store, with no self-healing path. Not a fund theft path.
Source only review; no PoC transactions were performed; no live system was contacted.
Summary
instance_answers_monitorcan overwrite a live, progressing peg-in instance row with a blank stub. The write primitive it uses is a bareINSERT OR REPLACEof every instance column, while the P2P path refuses exactly this write through a compare-and-set status guard. A committee node that is behind on Gateway history and simultaneously live on P2P can have its instance row wiped:committees_answersemptied,parametersset to NULL,btc_txidcleared, status forced toUserDiscarded. The row cannot self-heal; afterwards the node stops contributingpegin_confirmsignatures.Security impact: denial of service on the committee consensus path (pegin_confirm is n of n on the Bitcoin path) plus incorrect state transition on the affected node's local store. No fund theft path identified.
Affected component
1ff9a4aeced45309a097eeba5955e26164302ab3(current HEAD at time of review)node/src/scheduled_tasks/instance_maintenance_tasks.rs,crates/store/src/localdb.rs,node/src/utils.rs,node/src/scheduled_tasks/event_watch_task.rs,node/src/scheduled_tasks/mod.rsDescription
instance_answers_monitor(node/src/scheduled_tasks/instance_maintenance_tasks.rs:118-231) scansgoat_tx_recordrows of typeBridgeInRequestwith statusPending. For a record that is outside the response window and whose input UTXOs are no longer available, the monitor builds a fresh instance viagenerate_instance_from_bridge_in_request_event(node/src/scheduled_tasks/event_watch_task.rs:997-1054) and stamps itUserDiscarded, then writes it withupsert_instance.The stub is built by
generate_instance(node/src/utils.rs:4371-4422) withcommittees_answers= empty,parameters= None,btc_txid= None. The write goes throughupsert_instance(crates/store/src/localdb.rs:1124-1131), which is a bareINSERT OR REPLACE INTO instance (...)that replaces every column.The P2P path deliberately refuses this write:
store_pegin_request(node/src/utils.rs:4437-4465) routes throughupsert_pegin_request_instance(crates/store/src/localdb.rs:1179-1217), which carries an explicit allow list of statuses (UserIniting,UserInited). The code comment there states the guard exists "keeps a replayed or forged request from rolling a live instance back to UserInited and clearing what later stages stored". The monitor bypasses that same guard by calling the unguarded sibling.How it can be exploited (no attacker action needed, self race)
Maintenance is deferred while Gateway history catchup runs (
is_processing_gateway_history_eventscheck,node/src/scheduled_tasks/mod.rs:263-282), but P2P keeps running during that window. A node behind on history can therefore have a live instance created by P2P (committees answered,pegin_preparebroadcast, request UTXOs spent) while itsBridgeInRequestgoat_tx_record is stillPending, because that record is only ever written by the history watcher (event_watch_task.rs:674-687), never by P2P. When catchup finishes, the first monitor pass sees the record outside the response window with spent UTXOs and executes theINSERT OR REPLACE, wiping the live row.Recovery is impossible from the outside:
UserDiscardedis not in the CAS allow list, so replayedPeginRequestmessages are ignored, and laterBridgeInmint events update only the status field, notcommittees_answers,parameters, orbtc_txid.Steps to reproduce
from_height + gapis behind the finalized tip, so history catchup is active andinstance_answers_monitoris deferred (mod.rs:263-282).PeginRequestfor a bridge-in instance.store_pegin_requestcreates the live instance row (CAS guarded). Committees answer and the user broadcastspegin_prepare, so the request UTXOs are spent.BridgeInRequestgoat_tx_record asPending(event_watch_task.rs:674-687).instance_answers_monitorruns, sees the record outside the response window withoutpoint_availablefalse, and callsupsert_instancewith the blankUserDiscardedstub (instance_maintenance_tasks.rs:168-182).committees_answersemptied,parametersNULL,btc_txidNULL, statusUserDiscarded. Replay aPeginRequest; it is ignored becauseUserDiscardedis outside the allow list.Proof of concept (source level)
No PoC transaction was performed and no live system was contacted; this is a source-only review at the pinned commit. The relevant code, byte-exact at
1ff9a4a:Security impact
Denial of service on the committee consensus path: the affected node stops contributing
pegin_confirmsignatures, andpegin_confirmis n of n on the Bitcoin path, so a stalled committee delays in-flight peg-ins. Incorrect state transition on the node's local instance store, with no self-healing path. Not a fund theft path.Source only review; no PoC transactions were performed; no live system was contacted.