From 5fed577e92f0bf367280d2318104bce528380675 Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 22 Jul 2026 12:10:38 +0800 Subject: [PATCH 1/9] fix commit pubin --- node/src/utils.rs | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/node/src/utils.rs b/node/src/utils.rs index abb5a8de..b2706740 100644 --- a/node/src/utils.rs +++ b/node/src/utils.rs @@ -2512,21 +2512,36 @@ pub async fn get_watchtower_challenge_info( Ok(WatchtowerChallengeInfo { challenge_txids, included_watchtowers, resolved_branch_txids }) } -/// Returns `(btc_best_block_hash, included_watchtowers_bitmap)` using every confirmed +/// Returns `(btc_best_block_hash, included_watchtowers_bitmap)` using every finalized /// challenge-branch resolution for the block hash while only challenges set bitmap bits. +/// +/// The selected hash is signed into the operator's pubin and cannot be replaced after a +/// reorganization. Require every branch that determines the bitmap to be at least as deeply +/// buried as the header/SPV finality window before returning it. pub async fn compute_operator_pubin_blockhash_and_bitmap( btc_client: &BTCClient, resolved_branch_txids: &[Txid], included_watchtowers_bits: &[bool], ) -> Result<([u8; 32], [u8; 32])> { + let tip_height = btc_client.get_height().await?; + let required_burial_depth = get_btc_block_confirms(btc_client.network()); let btc_best_block_hash = { let mut largest: Option<(u32, BlockHash)> = None; for txid in resolved_branch_txids { let status = btc_client.get_tx_status(txid).await?; - let (height, hash) = match (status.block_height, status.block_hash) { - (Some(height), Some(hash)) => (height, hash), + let (height, hash) = match (status.confirmed, status.block_height, status.block_hash) { + (true, Some(height), Some(hash)) => (height, hash), _ => bail!("watchtower branch resolution tx {txid} is not confirmed yet"), }; + // `BTC_BLOCK_CONFIRMS` is the number of blocks that must follow a block before + // the SPV/header pipeline consumes it. Match the proof-builder's strict boundary: + // the anchor must have more than this many blocks after it. + let burial_depth = tip_height.saturating_sub(height); + if burial_depth <= required_burial_depth { + bail!( + "watchtower branch resolution tx {txid} is not final enough for operator pubin: height={height}, tip={tip_height}, burial_depth={burial_depth}, required_burial_depth>{required_burial_depth}" + ); + } if largest.is_none_or(|(h, _)| height > h) { largest = Some((height, hash)); } @@ -6195,8 +6210,10 @@ mod commit_pubin_tests { let init_txid = make_txid(0x00); let challenge_txid_wt0 = make_txid(0x01); + let timeout_txid_wt1 = make_txid(0x03); let challenge_txid_wt2 = make_txid(0x02); let block_hash_low = make_block_hash(0x10); + let block_hash_middle = make_block_hash(0x15); let block_hash_high = make_block_hash(0x20); let challenge_vout_0 = @@ -6204,7 +6221,9 @@ mod commit_pubin_tests { let challenge_vout_2 = output_topology::watchtower_challenge_init::watchtower_connector(2) as u32; - // wt0 confirmed at height 100, wt2 at height 200 (highest) + // All three branches are finalized; wt2's height 200 block is the anchor. + let required_burial_depth = get_btc_block_confirms(btc_client.network()); + mock_adaptor.set_height(200 + required_burial_depth + 1); mock_adaptor.set_tx( challenge_txid_wt0, create_confirmed_tx( @@ -6214,6 +6233,10 @@ mod commit_pubin_tests { block_hash_low, ), ); + mock_adaptor.set_tx( + timeout_txid_wt1, + create_confirmed_tx(timeout_txid_wt1, &[(init_txid, 1)], 150, block_hash_middle), + ); mock_adaptor.set_tx( challenge_txid_wt2, create_confirmed_tx( @@ -6224,7 +6247,7 @@ mod commit_pubin_tests { ), ); - let resolved_branch_txids = vec![challenge_txid_wt0, challenge_txid_wt2]; + let resolved_branch_txids = vec![challenge_txid_wt0, timeout_txid_wt1, challenge_txid_wt2]; let bits = vec![true, false, true]; let (best_hash, bitmap) = From 709c9f178d62925e1c8a6be3d698988280141c04 Mon Sep 17 00:00:00 2001 From: ethan Date: Wed, 22 Jul 2026 13:26:51 +0800 Subject: [PATCH 2/9] add debug logs --- node/src/handle.rs | 155 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 149 insertions(+), 6 deletions(-) diff --git a/node/src/handle.rs b/node/src/handle.rs index 3ed9c50b..3fb673fb 100644 --- a/node/src/handle.rs +++ b/node/src/handle.rs @@ -1940,6 +1940,16 @@ async fn handle_compact_soldering_proof_operator( .await .context("real BABE setup verification task failed")??; + tracing::info!( + event = "operator_graph_creation", + outcome = "soldering_verified", + verifier_index, + verifier_pubkey = %verifier_pubkey, + finalized_instances = finalized.len(), + payload_hash = %hex::encode(soldering_proof_ready.payload_hash), + "verified verifier soldering proof" + ); + if finalized.len() != BABE_M_CC { bail!("each verifier must contribute exactly {BABE_M_CC} finalized BABE instances"); } @@ -1956,10 +1966,50 @@ async fn handle_compact_soldering_proof_operator( soldering_proof_ready.clone(), )? else { - save_babe_setup_state(ctx.local_db, instance_id, graph_id, &state)?; + let completed_slots = operator_state + .candidates + .iter() + .filter(|candidate| candidate.gc_data.is_some()) + .count(); + let expected_slots = operator_state.candidates.len(); + if let Err(error) = save_babe_setup_state(ctx.local_db, instance_id, graph_id, &state) { + tracing::error!( + event = "operator_graph_creation", + outcome = "failed", + stage = "babe_state_persist", + verifier_index, + error = %error, + "failed to persist incomplete operator BABE setup state" + ); + return Err(error).context("persist incomplete operator BABE setup state"); + } + tracing::info!( + event = "operator_graph_creation", + outcome = "waiting_for_soldering", + verifier_index, + completed_slots, + expected_slots, + "persisted verifier soldering proof; waiting for remaining graph slots" + ); return Ok(()); }; - save_babe_setup_state(ctx.local_db, instance_id, graph_id, &state)?; + if let Err(error) = save_babe_setup_state(ctx.local_db, instance_id, graph_id, &state) { + tracing::error!( + event = "operator_graph_creation", + outcome = "failed", + stage = "babe_state_persist", + verifier_index, + error = %error, + "failed to persist complete operator BABE setup state" + ); + return Err(error).context("persist complete operator BABE setup state"); + } + tracing::info!( + event = "operator_graph_creation", + outcome = "all_soldering_collected", + verifier_slots = bitvm_gc_circuit_datas.len(), + "all verifier soldering proofs are ready to build the graph" + ); let instance_params = get_instance_parameters(ctx.local_db, instance_id) .await? @@ -1988,14 +2038,107 @@ async fn handle_compact_soldering_proof_operator( operator_pre_sign(operator_master_key.master_keypair(), &mut graph)?; let graph = graph.to_simplified()?; - store_operator_presigned_graph(ctx.local_db, &graph).await?; + let definition_hash = hex::encode(graph.parameters_hash()?); + tracing::info!( + event = "operator_graph_creation", + outcome = "started", + stage = "definition_store", + graph_nonce, + definition_hash = %definition_hash, + "storing operator-pre-signed graph definition" + ); + if let Err(error) = store_operator_presigned_graph(ctx.local_db, &graph).await { + tracing::error!( + event = "operator_graph_creation", + outcome = "failed", + stage = "definition_store", + graph_nonce, + definition_hash = %definition_hash, + error = %error, + "failed to store operator-pre-signed graph definition" + ); + return Err(error).context("store operator-pre-signed graph definition"); + } + tracing::info!( + event = "operator_graph_creation", + outcome = "committed", + stage = "definition_store", + graph_nonce, + definition_hash = %definition_hash, + "stored operator-pre-signed graph definition" + ); - let mut storage = ctx.local_db.acquire().await?; - storage.delete_pending_graph_init(&instance_id, &local_operator_pubkey.to_string()).await?; + let mut storage = match ctx.local_db.acquire().await { + Ok(storage) => storage, + Err(error) => { + tracing::error!( + event = "operator_graph_creation", + outcome = "failed", + stage = "pending_session_delete", + graph_nonce, + definition_hash = %definition_hash, + error = %error, + "failed to acquire database connection to delete pending graph session" + ); + return Err(error) + .context("acquire database connection to delete pending graph session"); + } + }; + let deleted_pending_sessions = match storage + .delete_pending_graph_init(&instance_id, &local_operator_pubkey.to_string()) + .await + { + Ok(rows) => rows, + Err(error) => { + tracing::error!( + event = "operator_graph_creation", + outcome = "failed", + stage = "pending_session_delete", + graph_nonce, + definition_hash = %definition_hash, + error = %error, + "failed to delete pending graph session after definition persistence" + ); + return Err(error).context("delete pending graph session after definition persistence"); + } + }; + tracing::info!( + event = "operator_graph_creation", + outcome = "completed", + stage = "pending_session_delete", + graph_nonce, + definition_hash = %definition_hash, + deleted_pending_sessions, + "deleted pending graph session after definition persistence" + ); let message_content = GOATMessageContent::CreateGraph(CreateGraph { instance_id, graph_id, graph_nonce, graph }); - send_to_peer(ctx.swarm, GOATMessage::new(Actor::All, message_content)).await?; + let message_id = + match send_to_peer(ctx.swarm, GOATMessage::new(Actor::All, message_content)).await { + Ok(message_id) => message_id, + Err(error) => { + tracing::error!( + event = "operator_graph_creation", + outcome = "failed", + stage = "create_graph_publish", + graph_nonce, + definition_hash = %definition_hash, + error = %error, + "failed to publish CreateGraph" + ); + return Err(error).context("publish CreateGraph"); + } + }; + tracing::info!( + event = "operator_graph_creation", + outcome = "published", + stage = "create_graph_publish", + graph_nonce, + definition_hash = %definition_hash, + message_id = ?message_id, + "published CreateGraph to the local gossipsub mesh" + ); Ok(()) } From 711dc0fcc8d5b913e990e49d0bc1d8077eb5c1cb Mon Sep 17 00:00:00 2001 From: ethan Date: Thu, 23 Jul 2026 01:00:29 +0800 Subject: [PATCH 3/9] add retry logic for pegin --- crates/store/src/localdb.rs | 10 +- node/src/action.rs | 99 ++++++++++++++++++- .../instance_maintenance_tasks.rs | 29 +++++- node/src/utils.rs | 8 +- 4 files changed, 134 insertions(+), 12 deletions(-) diff --git a/crates/store/src/localdb.rs b/crates/store/src/localdb.rs index 422dfe34..1954516b 100644 --- a/crates/store/src/localdb.rs +++ b/crates/store/src/localdb.rs @@ -10,7 +10,7 @@ use crate::{ use indexmap::IndexMap; use sqlx::migrate::Migrator; use sqlx::pool::PoolConnection; -use sqlx::sqlite::SqliteRow; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteRow}; use sqlx::types::Uuid; use sqlx::{Row, Sqlite, SqliteConnection, SqlitePool, Transaction, migrate::MigrateDatabase}; use std::str::FromStr; @@ -82,7 +82,13 @@ impl LocalDB { tracing::info!("Database already exists"); } - let conn = SqlitePool::connect(path).await.unwrap(); + let mut options = SqliteConnectOptions::from_str(path).unwrap().create_if_missing(true); + if !is_mem { + // File-backed nodes run event watchers, P2P handlers, and maintenance tasks + // concurrently. WAL allows their readers to proceed while a short write commits. + options = options.journal_mode(SqliteJournalMode::Wal); + } + let conn = SqlitePool::connect_with(options).await.unwrap(); Self { path: path.to_string(), is_mem, conn } } diff --git a/node/src/action.rs b/node/src/action.rs index bf0c13ee..bc67aa80 100644 --- a/node/src/action.rs +++ b/node/src/action.rs @@ -35,6 +35,7 @@ pub struct GOATMessage { } const GOAT_MESSAGE_BIN_PREFIX: &[u8] = b"GOATBIN1"; +const TRANSIENT_PEGIN_RETRY_DELAY_SECS: usize = 30; #[derive(Serialize, Deserialize, Clone)] pub enum GOATMessageContent { @@ -127,6 +128,58 @@ impl GOATMessageContent { Self::Tick => "Tick", } } + + fn pegin_retry_business_id(&self) -> Option { + match self { + Self::PeginRequest(message) => Some(message.instance_id), + Self::ConfirmInstance(message) => Some(message.instance_id), + Self::CreateGraph(message) => Some(message.graph_id), + Self::InitGraph(message) => Some(message.graph_id), + Self::GenCircuits(message) => Some(message.graph_id), + Self::CutCircuits(message) => Some(message.graph_id), + Self::SolderingProofReady(message) => Some(message.graph_id), + Self::VerifierGraphParamsEndorsement(message) => Some(message.graph_id), + Self::NonceGeneration(message) => Some(message.graph_id), + Self::CommitteePresign(message) => Some(message.graph_id), + Self::EndorseGraph(message) => Some(message.graph_id), + Self::GraphFinalize(message) => Some(message.graph_id), + Self::PeginConfirmNonce(message) => Some(message.instance_id), + Self::PeginConfirmPartialSig(message) => Some(message.instance_id), + Self::PostReady(message) => Some(message.instance_id), + _ => None, + } + } +} + +fn is_retryable_sqlite_error(error: &anyhow::Error) -> bool { + error.chain().any(|cause| { + let message = cause.to_string().to_ascii_lowercase(); + message.contains("database is locked") + || message.contains("database is busy") + || message.contains("sqlite_busy") + }) +} + +fn is_pegin_message_type(message_type: &str) -> bool { + matches!( + message_type, + "PeginRequest" + | "ConfirmInstance" + | "CreateGraph" + | "InitGraph" + | "GenCircuits" + | "CutCircuits" + | "SolderingProof" + | "SolderingProofReady" + | "VerifierGraphParamsEndorsement" + | "NonceGeneration" + | "CommitteePresign" + | "EndorseGraph" + | "GraphFinalize" + | "PeginConfirmNonce" + | "PeginConfirmPartialSig" + | "PostReady" + ) } /// Pegin @@ -519,7 +572,13 @@ pub async fn handle_self_p2p_msg( } } Err(err) => { - let lock_time = 600; + let lock_time: i64 = if is_retryable_sqlite_error(&err) + && is_pegin_message_type(&message.msg_type) + { + TRANSIENT_PEGIN_RETRY_DELAY_SECS as i64 + } else { + 600 + }; tracing::warn!( event = "local_message_queue", outcome = "deferred", @@ -564,7 +623,8 @@ pub async fn recv_and_dispatch( id: MessageId, message: &[u8], ) -> Result<()> { - if id != GOATMessage::default_message_id() { + let is_local_queue_message = id == GOATMessage::default_message_id(); + if !is_local_queue_message { update_node_timestamp(local_db, &from_peer_id.to_string()).await?; } // Determine whether the message comes from this node itself to optionally skip validations @@ -586,7 +646,40 @@ pub async fn recv_and_dispatch( id, is_self_peer, }; - let result = handle_dispatch(&mut handler_ctx, message.content()).await; + let result = match handle_dispatch(&mut handler_ctx, message.content()).await { + Err(error) if !is_local_queue_message && is_retryable_sqlite_error(&error) => { + if let Some(business_id) = message.content.pegin_retry_business_id() { + match push_local_unhandled_messages( + local_db, + business_id, + &message, + TRANSIENT_PEGIN_RETRY_DELAY_SECS, + ) + .await + { + Ok(()) => { + tracing::warn!( + event = "pegin_message_retry", + outcome = "deferred", + role = %role, + message_type, + business_id = %business_id, + retry_after_secs = TRANSIENT_PEGIN_RETRY_DELAY_SECS, + error = %error, + "deferred pegin message after a transient SQLite failure" + ); + Ok(()) + } + Err(queue_error) => Err(error.context(format!( + "failed to enqueue transient pegin message retry: {queue_error}" + ))), + } + } else { + Err(error) + } + } + result => result, + }; match &result { Ok(()) => tracing::info!( event = "message_dispatch_result", diff --git a/node/src/scheduled_tasks/instance_maintenance_tasks.rs b/node/src/scheduled_tasks/instance_maintenance_tasks.rs index cd4e1b15..55cd4466 100644 --- a/node/src/scheduled_tasks/instance_maintenance_tasks.rs +++ b/node/src/scheduled_tasks/instance_maintenance_tasks.rs @@ -141,10 +141,14 @@ pub async fn instance_answers_monitor( let current_height = goat_client.get_finalized_block_number().await?; let response_window_blocks = goat_client.gateway_get_response_window_blocks().await? as i64; for tx_record in tx_records { - let mut tx = local_db.start_transaction().await?; - if let Some(event) = tx_record.extra { - let event: BridgeInRequestEvent = serde_json::from_str(&event)?; - if tx_record.height + response_window_blocks < current_height { + let event = tx_record + .extra + .as_deref() + .map(serde_json::from_str::) + .transpose()?; + let is_outside_response_window = tx_record.height + response_window_blocks < current_height; + let discarded_instance = if is_outside_response_window { + if let Some(event) = event.as_ref() { info!( "instance_answers_monitor: instance_id:{} BridgeInRequest is outside the response window", tx_record.instance_id @@ -154,7 +158,7 @@ pub async fn instance_answers_monitor( generate_instance_from_bridge_in_request_event( btc_client, goat_client, - &event, + event, true, ) .await @@ -167,6 +171,21 @@ pub async fn instance_answers_monitor( // for the case: if bridgeIn confirm is broadcast,but L2 not minted, // instance status will been updated to L2Minted when normal finished instance.status = InstanceBridgeInStatus::UserDiscarded.to_string(); + Some(instance) + } else { + None + } + } else { + None + } + } else { + None + }; + + 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?; } } else { diff --git a/node/src/utils.rs b/node/src/utils.rs index b2706740..14dae501 100644 --- a/node/src/utils.rs +++ b/node/src/utils.rs @@ -4575,7 +4575,9 @@ pub(crate) async fn store_operator_presigned_graph( ))); } - let mut tx = local_db.start_transaction().await?; + // The definition path reads existing state before updating it. Reserve the + // writer up front so another task cannot invalidate that read snapshot. + let mut tx = local_db.start_immediate_transaction().await?; ingest_graph_definition(&mut tx, simple_graph, GraphDefinitionIngestKind::OperatorPresigned) .await?; tx.commit().await?; @@ -4593,7 +4595,9 @@ pub(crate) async fn store_finalized_graph_if_needed( bail!(SpecialError::InvalidGraph(format!("graph {graph_id} is not fully pre-signed"))); } - let mut tx = local_db.start_transaction().await?; + // The definition path reads existing state before updating it. Reserve the + // writer up front so another task cannot invalidate that read snapshot. + let mut tx = local_db.start_immediate_transaction().await?; let outcome = ingest_graph_definition(&mut tx, simple_graph, GraphDefinitionIngestKind::Finalized) .await?; From 776de3a18057cd09049a01ab797293d2d3222429 Mon Sep 17 00:00:00 2001 From: ethan Date: Thu, 23 Jul 2026 01:19:34 +0800 Subject: [PATCH 4/9] add more retry logic for pegin --- node/src/handle.rs | 66 +++++--- .../instance_maintenance_tasks.rs | 149 +++++++++++++++++- node/src/scheduled_tasks/mod.rs | 7 +- node/src/utils.rs | 19 ++- 4 files changed, 218 insertions(+), 23 deletions(-) diff --git a/node/src/handle.rs b/node/src/handle.rs index 3fb673fb..0822c32f 100644 --- a/node/src/handle.rs +++ b/node/src/handle.rs @@ -70,20 +70,6 @@ fn is_io_not_found_error(err: &anyhow::Error) -> bool { }) } -fn load_committee_instance_keypair( - committee_master_key: &CommitteeMasterKey, - instance_id: Uuid, -) -> Result { - let envelope_path = committee_instance_keys_envelope_path(instance_id); - committee_master_key.load_instance_keypair(instance_id, &envelope_path).with_context(|| { - format!( - "load committee instance keypair failed for {} at {}", - instance_id, - envelope_path.display() - ) - }) -} - fn load_or_create_committee_instance_keypair( committee_master_key: &CommitteeMasterKey, instance_id: Uuid, @@ -449,6 +435,7 @@ pub async fn dispatch(ctx: &mut HandlerContext<'_>, content: &GOATMessageContent received_committee_pubkey, pub_nonce, nonce_sig, + content, ) .await } @@ -3240,7 +3227,6 @@ async fn handle_graph_finalize_committee( pub_nonce: pub_nonce.clone(), nonce_sig, }); - send_to_peer(ctx.swarm, GOATMessage::new(Actor::Committee, message_content)).await?; store_committee_pub_nonce_for_instance( ctx.local_db, instance_id, @@ -3248,6 +3234,7 @@ async fn handle_graph_finalize_committee( pub_nonce, ) .await?; + send_to_peer(ctx.swarm, GOATMessage::new(Actor::Committee, message_content)).await?; } } // 4. (Relayer) try to call Gateway.postGraphData @@ -3341,6 +3328,7 @@ async fn handle_pegin_confirm_nonce_committee( received_committee_pubkey: &PublicKey, pub_nonce: &musig2::PubNonce, nonce_sig: &secp256k1::schnorr::Signature, + content: &GOATMessageContent, ) -> Result<()> { // received from Committee members if !ensure_self_or_valid_committee( @@ -3371,6 +3359,9 @@ async fn handle_pegin_confirm_nonce_committee( pub_nonce.clone(), ) .await?; + if ctx.id == GOATMessage::default_message_id() { + send_to_peer(ctx.swarm, GOATMessage::new(Actor::Committee, content.clone())).await?; + } // 3. if received enough pub_nonces, generate partial signature & broadcast PeginConfirmPartialSig let committee_pubkeys = ctx.goat_client.gateway_get_committee_pubkeys(&instance_id).await?; let pub_nonces = get_committee_pub_nonces_for_instance(ctx.local_db, instance_id).await?; @@ -3556,8 +3547,9 @@ async fn handle_pegin_confirm_partial_sig_committee( return Ok(()); } Err(e) => { + push_local_unhandled_messages(ctx.local_db, instance_id, &message, 30).await?; tracing::warn!( - "Ignore PeginConfirmPartialSig for {instance_id} from {}: failed to verify endorsement signature: {e}", + "Retry PeginConfirmPartialSig later for {instance_id} from {}: failed to verify endorsement signature: {e}", received_committee_pubkey ); return Ok(()); @@ -3578,6 +3570,9 @@ async fn handle_pegin_confirm_partial_sig_committee( endorse_sig.to_owned(), ) .await?; + if ctx.id == GOATMessage::default_message_id() { + send_to_peer(ctx.swarm, GOATMessage::new(Actor::Committee, content.clone())).await?; + } // 3. (Relayer) if received enough partial signatures, aggregate the sigs if is_relayer() { let pub_nonces = get_committee_pub_nonces_for_instance(ctx.local_db, instance_id).await?; @@ -3633,8 +3628,20 @@ async fn handle_post_ready(ctx: &mut HandlerContext<'_>, instance_id: Uuid) -> R let pegin_tx = match ctx.btc_client.get_tx(&pegin_txid).await? { Some(tx) => tx, None => { + let delay_secs = todo_funcs::avg_block_time_secs(ctx.btc_client.network()); + let message = GOATMessage::new( + ctx.actor.clone(), + GOATMessageContent::PostReady(PostReady { instance_id }), + ); + push_local_unhandled_messages( + ctx.local_db, + instance_id, + &message, + delay_secs as usize, + ) + .await?; tracing::warn!( - "Ignore PostReady for {instance_id}: Pegin-Confirm transaction not found on Bitcoin: {pegin_txid}" + "Retry postPeginData later for {instance_id}: Pegin-Confirm transaction not found on Bitcoin: {pegin_txid}" ); return Ok(()); } @@ -3645,8 +3652,15 @@ async fn handle_post_ready(ctx: &mut HandlerContext<'_>, instance_id: Uuid) -> R .map(|(_, es)| es) .collect::>(); if endorse_sigs.len() != committee_pubkeys.len() { + let delay_secs = todo_funcs::avg_block_time_secs(ctx.btc_client.network()); + let message = GOATMessage::new( + ctx.actor.clone(), + GOATMessageContent::PostReady(PostReady { instance_id }), + ); + push_local_unhandled_messages(ctx.local_db, instance_id, &message, delay_secs as usize) + .await?; tracing::warn!( - "Ignore PostReady for {instance_id}: not enough endorse sigs for pegin confirm tx: {}", + "Retry postPeginData later for {instance_id}: not enough endorse sigs for pegin confirm tx: {}", endorse_sigs.len() ); return Ok(()); @@ -3695,6 +3709,7 @@ async fn handle_post_ready(ctx: &mut HandlerContext<'_>, instance_id: Uuid) -> R } // 2. (Relayer)call Gateway.postGraphData on GoatChain let graph_ids = get_graph_ids_for_instance(ctx.local_db, instance_id).await?; + let mut missing_graph_endorsements = false; for graph_id in &graph_ids { let graph_data = ctx.goat_client.gateway_get_graph_data(graph_id).await?; if graph_data.operator_pubkey != [0u8; 32] { @@ -3708,8 +3723,9 @@ async fn handle_post_ready(ctx: &mut HandlerContext<'_>, instance_id: Uuid) -> R .map(|(_, _, sig)| sig) .collect::>(); if endorsement_sigs.len() != committee_pubkeys.len() { + missing_graph_endorsements = true; tracing::warn!( - "Ignore postGraphData for {instance_id}:{graph_id}: not enough endorse sigs for graph: {}", + "Defer postGraphData for {instance_id}:{graph_id}: not enough endorse sigs for graph: {}", endorsement_sigs.len() ); continue; @@ -3723,6 +3739,18 @@ async fn handle_post_ready(ctx: &mut HandlerContext<'_>, instance_id: Uuid) -> R .gateway_post_graph_data(&instance_id, graph_id, &graph_data, &endorsement_sigs) .await?; } + if missing_graph_endorsements { + let delay_secs = todo_funcs::avg_block_time_secs(ctx.btc_client.network()); + let message = GOATMessage::new( + ctx.actor.clone(), + GOATMessageContent::PostReady(PostReady { instance_id }), + ); + push_local_unhandled_messages(ctx.local_db, instance_id, &message, delay_secs as usize) + .await?; + tracing::info!( + "Retry postGraphData later for {instance_id}: waiting for committee graph endorsements" + ); + } Ok(()) } diff --git a/node/src/scheduled_tasks/instance_maintenance_tasks.rs b/node/src/scheduled_tasks/instance_maintenance_tasks.rs index 55cd4466..8d7bb0e0 100644 --- a/node/src/scheduled_tasks/instance_maintenance_tasks.rs +++ b/node/src/scheduled_tasks/instance_maintenance_tasks.rs @@ -1,4 +1,7 @@ -use crate::action::{ConfirmInstance, GOATMessageContent, PeginRequest, PostReady}; +use crate::action::{ + ConfirmInstance, GOATMessage, GOATMessageContent, PeginConfirmNonce, PeginConfirmPartialSig, + PeginRequest, PostReady, push_local_unhandled_messages, +}; use crate::env::{ COMMITTEE_INSTANCE_KEYS_DIR, get_bitvm_key, get_committee_instance_key_delete_timelock_blocks, get_instance_maintenance_batch_size, get_instance_presigned_time_expired_secs, @@ -10,7 +13,9 @@ use crate::scheduled_tasks::get_timestamp_from_contract_data; use crate::utils::evm_swap_utils::IEscrowManager::EscrowData; use crate::utils::{ SELF_SENDER, check_bridge_in_uxto_available_or_self_spent, gen_instance_parameters_local, - upsert_message, + get_committee_endorse_sigs_for_pegin, get_committee_partial_sig_for_instance, + get_committee_pub_nonce_for_instance, load_committee_instance_keypair, + store_committee_pub_nonce_for_instance, upsert_message, }; use alloy::sol_types::SolType; use bitvm_lib::actors::Actor; @@ -38,6 +43,7 @@ const TASK_KEY_INSTANCE_WINDOW_EXPIRATION: &str = "instance_window_expiration_mo const TASK_KEY_INSTANCE_EXPIRATION: &str = "instance_expiration_monitor"; const TASK_KEY_INSTANCE_BTC_TX: &str = "instance_btc_tx_monitor"; const TASK_KEY_INSTANCE_BRIDGE_OUT: &str = "instance_bridge_out_monitor"; +const TASK_KEY_PEGIN_CONFIRM_RECOVERY: &str = "pegin_confirm_recovery_monitor"; #[derive(Clone, Debug)] struct InstancePageState { @@ -507,6 +513,145 @@ pub async fn instance_btc_tx_monitor( Ok(()) } +/// Re-publish persisted PeginConfirm signing material until the transaction is visible on Bitcoin. +/// This never derives a new partial signature: it only reuses the deterministic nonce or stored signature. +pub async fn pegin_confirm_recovery_monitor( + local_db: &LocalDB, + btc_client: &BTCClient, + actor: &Actor, +) -> anyhow::Result<()> { + if actor != &Actor::Committee { + return Ok(()); + } + + let instances = find_one_instance_page( + local_db, + TASK_KEY_PEGIN_CONFIRM_RECOVERY, + InstanceQuery::default() + .with_is_bridge_in(true) + .with_status(InstanceBridgeInStatus::Presigned.to_string()), + get_instance_maintenance_batch_size(), + ) + .await?; + if instances.is_empty() { + return Ok(()); + } + + let committee_master_key = CommitteeMasterKey::new(get_bitvm_key()?); + for instance in instances { + let Some(pegin_confirm_txid) = instance.pegin_confirm_txid.clone() else { + warn!( + instance_id = %instance.instance_id, + "skip PeginConfirm recovery: instance has no expected PeginConfirm txid" + ); + continue; + }; + match btc_client.get_tx(&pegin_confirm_txid.0).await { + Ok(Some(_)) => continue, + Ok(None) => {} + Err(error) => { + warn!( + instance_id = %instance.instance_id, + error = %error, + "defer PeginConfirm recovery: unable to query Bitcoin transaction" + ); + continue; + } + } + + let instance_id = instance.instance_id; + let instance_parameters = gen_instance_parameters_local(&instance)?; + let instance_keypair = load_committee_instance_keypair(&committee_master_key, instance_id)?; + let local_committee_pubkey = instance_keypair.public_key().into(); + + if let Some(partial_sig) = + get_committee_partial_sig_for_instance(local_db, instance_id, &local_committee_pubkey) + .await? + { + let Some((_, endorse_sig)) = + get_committee_endorse_sigs_for_pegin(local_db, instance_id) + .await? + .into_iter() + .find(|(pubkey, _)| *pubkey == local_committee_pubkey) + else { + warn!( + instance_id = %instance_id, + "skip PeginConfirm partial signature recovery: local endorsement signature is missing" + ); + continue; + }; + let message = GOATMessage::new( + Actor::Committee, + GOATMessageContent::PeginConfirmPartialSig(PeginConfirmPartialSig { + instance_id, + committee_pubkey: local_committee_pubkey, + partial_sig, + endorse_sig, + }), + ); + push_local_unhandled_messages(local_db, instance_id, &message, 0).await?; + tracing::info!( + event = "pegin_confirm_recovery", + action = "republish_partial_signature", + instance_id = %instance_id, + "queued persisted PeginConfirm partial signature for re-publication" + ); + continue; + } + + let instance_parameters_hash = instance_parameters.parameters_hash()?; + let (_, derived_pub_nonce, nonce_sig) = committee_master_key + .nonce_for_instance_job_with_keypair( + instance_id, + instance_parameters_hash, + instance_keypair, + ); + let pub_nonce = match get_committee_pub_nonce_for_instance( + local_db, + instance_id, + &local_committee_pubkey, + ) + .await? + { + Some(stored_pub_nonce) => { + if stored_pub_nonce != derived_pub_nonce { + anyhow::bail!( + "stored PeginConfirm nonce differs from deterministic nonce for instance {instance_id}" + ); + } + stored_pub_nonce + } + None => { + store_committee_pub_nonce_for_instance( + local_db, + instance_id, + local_committee_pubkey, + derived_pub_nonce.clone(), + ) + .await?; + derived_pub_nonce + } + }; + let message = GOATMessage::new( + Actor::Committee, + GOATMessageContent::PeginConfirmNonce(PeginConfirmNonce { + instance_id, + committee_pubkey: local_committee_pubkey, + pub_nonce, + nonce_sig, + }), + ); + push_local_unhandled_messages(local_db, instance_id, &message, 0).await?; + tracing::info!( + event = "pegin_confirm_recovery", + action = "republish_nonce", + instance_id = %instance_id, + "queued persisted PeginConfirm nonce for re-publication" + ); + } + Ok(()) +} + pub async fn get_bridge_out_deadline<'a>( storage_processor: &mut StorageProcessor<'a>, instance_id: &Uuid, diff --git a/node/src/scheduled_tasks/mod.rs b/node/src/scheduled_tasks/mod.rs index 6a91e0a1..6db30a15 100644 --- a/node/src/scheduled_tasks/mod.rs +++ b/node/src/scheduled_tasks/mod.rs @@ -19,7 +19,7 @@ use crate::scheduled_tasks::graph_maintenance_tasks::{ use crate::scheduled_tasks::instance_maintenance_tasks::{ instance_answers_monitor, instance_bridge_out_monitor, instance_btc_tx_monitor, instance_committee_key_cleanup_monitor, instance_expiration_monitor, - instance_window_expiration_monitor, + instance_window_expiration_monitor, pegin_confirm_recovery_monitor, }; use crate::scheduled_tasks::node_maintenance_tasks::node_available_pbtc_update_monitor; use crate::scheduled_tasks::spv_maintenance_tasks::spv_header_hash_update; @@ -151,6 +151,11 @@ async fn run( instance_btc_tx_monitor(local_db, btc_client), ) .await; + run_maintenance_subtask( + "pegin_confirm_recovery_monitor", + pegin_confirm_recovery_monitor(local_db, btc_client, &actor), + ) + .await; run_maintenance_subtask( "instance_committee_key_cleanup_monitor", instance_committee_key_cleanup_monitor(local_db, btc_client), diff --git a/node/src/utils.rs b/node/src/utils.rs index 14dae501..b09d2c3f 100644 --- a/node/src/utils.rs +++ b/node/src/utils.rs @@ -29,7 +29,9 @@ use bitcoin_light_client_circuit::{ use bitvm::treepp::*; use bitvm_lib::actors::Actor; use bitvm_lib::committee::*; -use bitvm_lib::keys::{OperatorMasterKey, VerifierMasterKey, WatchtowerMasterKey}; +use bitvm_lib::keys::{ + CommitteeMasterKey, OperatorMasterKey, VerifierMasterKey, WatchtowerMasterKey, +}; use bitvm_lib::operator::*; use bitvm_lib::timelocks::{connector_f_timelock_blocks, default_timelock_config}; use bitvm_lib::types::{ @@ -112,6 +114,21 @@ use zkm_verifier::{ pub const SELF_SENDER: &str = "self"; const BRIDGE_OUT_INSTANCE_ID_PREFIX: [u8; 4] = *b"BOID"; +pub(crate) fn load_committee_instance_keypair( + committee_master_key: &CommitteeMasterKey, + instance_id: Uuid, +) -> Result { + let mut envelope_path = PathBuf::from(COMMITTEE_INSTANCE_KEYS_DIR); + envelope_path.push(format!("{instance_id}.json")); + committee_master_key.load_instance_keypair(instance_id, &envelope_path).with_context(|| { + format!( + "load committee instance keypair failed for {} at {}", + instance_id, + envelope_path.display() + ) + }) +} + /// Derive the shared bridge-out instance ID from its escrow hash. /// /// Both the RPC tag endpoint and the chain-event watcher must use this ID so From 184c4ea461592697f1983c2692865e0f3e6b6cee Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 24 Jul 2026 11:15:55 +0800 Subject: [PATCH 5/9] add regtest transaction flows tests --- Cargo.lock | 4 + crates/bitvm-gc/Cargo.toml | 4 + crates/bitvm-gc/tests/regtest.rs | 1242 ++++++++++++++++++++++++++++++ 3 files changed, 1250 insertions(+) create mode 100644 crates/bitvm-gc/tests/regtest.rs diff --git a/Cargo.lock b/Cargo.lock index fb7561fb..b3bc35e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3050,6 +3050,7 @@ dependencies = [ "bitvm 0.1.0 (git+https://github.com/GOATNetwork/BitVM.git?branch=gc-v2)", "chacha20poly1305", "clap", + "esplora-client", "garbled-snark-verifier", "goat", "hex", @@ -3058,6 +3059,8 @@ dependencies = [ "rand 0.8.5", "rand_chacha 0.3.1", "rayon", + "reqwest 0.11.27", + "reqwest 0.12.28", "secp256k1 0.29.1", "serde", "serde-big-array", @@ -3065,6 +3068,7 @@ dependencies = [ "sha2 0.10.9", "soldering-host", "strum 0.26.3", + "tokio", "tracing", "uuid 1.23.0", "verifiable-circuit-babe", diff --git a/crates/bitvm-gc/Cargo.toml b/crates/bitvm-gc/Cargo.toml index d6e9e379..50ae4e37 100644 --- a/crates/bitvm-gc/Cargo.toml +++ b/crates/bitvm-gc/Cargo.toml @@ -37,7 +37,11 @@ strum = { workspace = true, features = ["derive"] } [dev-dependencies] ark-crypto-primitives = { workspace = true } ark-ec = { workspace = true } +esplora-client = { workspace = true } rand_chacha = { workspace = true } +reqwest = { workspace = true } +reqwest-0-11 = { package = "reqwest", version = "0.11.27", default-features = false, features = ["json", "rustls-tls"] } +tokio = { workspace = true, features = ["macros", "time"] } [features] default = [] diff --git a/crates/bitvm-gc/tests/regtest.rs b/crates/bitvm-gc/tests/regtest.rs new file mode 100644 index 00000000..57b80415 --- /dev/null +++ b/crates/bitvm-gc/tests/regtest.rs @@ -0,0 +1,1242 @@ +use anyhow::{Context, Result, anyhow, bail, ensure}; +use ark_bn254::{Bn254, Fr, G1Affine, G2Affine}; +use ark_ec::AffineRepr; +use ark_groth16::Proof; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use bitcoin::hashes::{Hash, hash160}; +use bitcoin::key::Keypair; +use bitcoin::secp256k1::{SECP256K1, SecretKey}; +use bitcoin::{ + Address, Amount, EcdsaSighashType, Network, OutPoint, PublicKey, ScriptBuf, Sequence, + Transaction, TxIn, TxOut, Txid, XOnlyPublicKey, absolute, transaction, +}; +use bitcoin_script::script; +use bitvm_gc::babe_adapter::{ + BABE_M_CC, BabeProverState, CACSetupPackage, TxAssertWitness, assert_wots_message, + build_assert_witness, build_setup_package, derive_finalized_indices, extract_gc_circuit_data, + open_and_solder, recover_operator_proof_from_assert_witness, verify_setup, +}; +use bitvm_gc::committee::{ + agg_and_push_pegin_confirm_sigs, committee_pre_sign, generate_nonce_from_seed, key_aggregation, + nonce_aggregation, nonces_aggregation, push_committee_pre_signatures, sign_pegin_confirm, + signature_aggregation, verify_graph_committee_pre_signatures, verify_nonce_signatures, +}; +use bitvm_gc::keys::{CommitteeMasterKey, OperatorMasterKey}; +use bitvm_gc::operator::{ + generate_bitvm_graph, operator_pre_sign, operator_sign_assert, operator_sign_challenge_ack, + operator_sign_commit_pubin, operator_sign_kickoff, operator_sign_prekickoff_input_0, + operator_sign_take1, operator_sign_take2, operator_sign_watchtower_challenge_init, + operator_sign_watchtower_challenge_timeout, operator_sign_wrongly_challenged, + verify_graph_operator_pre_signatures, +}; +use bitvm_gc::timelocks::{ + connector_f_timelock_blocks, default_timelock_config, disprove_timelock_blocks, + operator_ack_timelock_blocks, operator_commit_timelock_blocks, take1_timelock_blocks, + take2_timelock_blocks, watchtower_challenge_timelock_blocks, +}; +use bitvm_gc::types::{ + BitvmGcCircuitData, BitvmGcGraph, BitvmGcGraphParameters, BitvmGcInstanceParameters, + PrekickoffParameters, UserInfo, +}; +use bitvm_gc::verifier::{ + build_disprove_tx, build_pubin_disprove_txin, build_verifier_assert_tx, export_challenge_tx, + validate_pubin_disprove, +}; +use bitvm_gc::watchtower::{build_watchtower_challenge_tx, estimate_watchtower_challenge_vbytes}; +use esplora_client::AsyncClient as EsploraClient; +use goat::assert_scripts::{INPUT_WIRE_NUM, Label, WireHash, label_hash}; +use goat::connectors::base::TaprootConnector; +use goat::connectors::kickoff_connectors::{ + ForceSkipConnector, KickoffConnector, PrekickoffConnector, +}; +use goat::scripts::{generate_opreturn_script, p2a_output}; +use goat::transactions::base::{DUST_AMOUNT, Input}; +use goat::transactions::pre_signed::PreSignedTransaction; +use goat::transactions::prekickoff::PrekickoffTransaction; +use goat::transactions::signing::populate_p2wsh_witness; +use reqwest::Client; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; +use tokio::time::sleep; +use uuid::Uuid; + +const REGTEST_ESPLORA_URL: &str = "http://127.0.0.1:3002"; +const REGTEST_RPC_URL: &str = "http://127.0.0.1:18443/wallet/alice"; +const FIXTURE_VERSION: u32 = 1; +const FIXTURE_FILE: &str = "bitvm-gc-regtest-proof-gc-v1.bin"; +const DEFAULT_FEE_SATS: u64 = 1_000; +const PREKICKOFF_AMOUNT_SATS: u64 = 500_000; +const PEGIN_AMOUNT_SATS: u64 = 100_000_000; +const PAYER_AMOUNT_SATS: u64 = 5_000_000; +const FEE_RATE_SAT_PER_VBYTE: u64 = 2; +const CONFIRM_TIMEOUT: Duration = Duration::from_secs(60); + +static MOCK_FIXTURE: OnceLock> = OnceLock::new(); + +#[derive(Serialize, Deserialize)] +struct MockProofGcFixture { + version: u32, + setup_package: CACSetupPackage, + opened: Vec<(usize, u64)>, + prover_state: BabeProverState, + gc_data: BitvmGcCircuitData, + proof_bytes: Vec, +} + +struct TestKeys { + user: Keypair, + operator: Keypair, + challenger: Keypair, + committee: Vec, + verifier: Keypair, + watchtowers: Vec, +} + +struct RegtestRpc { + client: Client, + url: String, + user: String, + password: String, +} + +struct RegtestGraph { + graph: BitvmGcGraph, + keys: TestKeys, + assert_witness: TxAssertWitness, + challenge_labels: Vec