From 30770d160fcc70c2477484891b7830f61c1d192a Mon Sep 17 00:00:00 2001 From: Hisham Fahmy Date: Tue, 24 Mar 2026 22:22:22 +0100 Subject: [PATCH 1/6] feat(via_btc_sender): harden spendability, fee floors, and anti-dust policy --- core/lib/config/src/configs/via_btc_sender.rs | 56 ++++++++++++ core/lib/via_btc_client/src/inscriber/mod.rs | 90 +++++++++++++++++-- .../layers/via_btc_sender/aggregator.rs | 13 ++- .../layers/via_btc_sender/manager.rs | 13 ++- .../layers/via_btc_sender/vote_manager.rs | 13 ++- .../src/btc_inscription_manager.rs | 22 +++++ core/node/via_btc_sender/src/metrics.rs | 3 + etc/env/base/via_btc_sender.toml | 16 +++- .../src/btc_inscription_manager.rs | 22 +++++ .../node/via_btc_sender/src/metrics.rs | 3 + 10 files changed, 239 insertions(+), 12 deletions(-) diff --git a/core/lib/config/src/configs/via_btc_sender.rs b/core/lib/config/src/configs/via_btc_sender.rs index 5b63c7e02..e6734889e 100644 --- a/core/lib/config/src/configs/via_btc_sender.rs +++ b/core/lib/config/src/configs/via_btc_sender.rs @@ -35,6 +35,27 @@ pub struct ViaBtcSenderConfig { /// The required time (seconds) to wait before create a proof inscription. pub block_time_to_proof: Option, + + /// Hard floor for taproot inscription output to avoid dust / borderline outputs. + pub min_inscription_output_sats: Option, + + /// Hard floor for change output to keep spendable UTXOs healthy. + pub min_change_output_sats: Option, + + /// Minimum fee-rate for non-chained transactions (sat/vB). + pub min_feerate_sat_vb: Option, + + /// Minimum fee-rate when pending inscriptions exist in context (sat/vB). + pub min_chained_feerate_sat_vb: Option, + + /// Maximum fee-rate safety cap (sat/vB). + pub max_feerate_sat_vb: Option, + + /// Max number of pending inscriptions in context before pausing new sends. + pub max_pending_chain_depth: Option, + + /// Do not send new inscriptions when spendable balance goes below this threshold. + pub min_spendable_balance_sats: Option, } impl ViaBtcSenderConfig { @@ -60,6 +81,34 @@ impl ViaBtcSenderConfig { pub fn stuck_inscription_block_number(&self) -> u32 { self.stuck_inscription_block_number.unwrap_or(6) } + + pub fn min_inscription_output_sats(&self) -> u64 { + self.min_inscription_output_sats.unwrap_or(600) + } + + pub fn min_change_output_sats(&self) -> u64 { + self.min_change_output_sats.unwrap_or(1_000) + } + + pub fn min_feerate_sat_vb(&self) -> u64 { + self.min_feerate_sat_vb.unwrap_or(8) + } + + pub fn min_chained_feerate_sat_vb(&self) -> u64 { + self.min_chained_feerate_sat_vb.unwrap_or(20) + } + + pub fn max_feerate_sat_vb(&self) -> u64 { + self.max_feerate_sat_vb.unwrap_or(80) + } + + pub fn max_pending_chain_depth(&self) -> u32 { + self.max_pending_chain_depth.unwrap_or(3) + } + + pub fn min_spendable_balance_sats(&self) -> u64 { + self.min_spendable_balance_sats.unwrap_or(2_000) + } } impl ViaBtcSenderConfig { @@ -76,6 +125,13 @@ impl ViaBtcSenderConfig { block_time_to_commit: None, block_time_to_proof: None, stuck_inscription_block_number: None, + min_inscription_output_sats: None, + min_change_output_sats: None, + min_feerate_sat_vb: None, + min_chained_feerate_sat_vb: None, + max_feerate_sat_vb: None, + max_pending_chain_depth: None, + min_spendable_balance_sats: None, } } } diff --git a/core/lib/via_btc_client/src/inscriber/mod.rs b/core/lib/via_btc_client/src/inscriber/mod.rs index dcab76987..351675356 100644 --- a/core/lib/via_btc_client/src/inscriber/mod.rs +++ b/core/lib/via_btc_client/src/inscriber/mod.rs @@ -64,12 +64,39 @@ const BROADCAST_RETRY_COUNT: u32 = 3; // https://bitcoin.stackexchange.com/questions/10986/what-is-meant-by-bitcoin-dust // https://bitcointalk.org/index.php?topic=5453107.msg62262343#msg62262343 const P2TR_DUST_LIMIT: Amount = Amount::from_sat(330); +const DEFAULT_MIN_INSCRIPTION_OUTPUT_SATS: u64 = 600; +const DEFAULT_MIN_CHANGE_OUTPUT_SATS: u64 = 1_000; +const DEFAULT_MIN_FEERATE_SAT_VB: u64 = 8; +const DEFAULT_MIN_CHAINED_FEERATE_SAT_VB: u64 = 20; +const DEFAULT_MAX_FEERATE_SAT_VB: u64 = 80; + +#[derive(Debug, Clone)] +pub struct InscriberPolicy { + pub min_inscription_output_sats: u64, + pub min_change_output_sats: u64, + pub min_feerate_sat_vb: u64, + pub min_chained_feerate_sat_vb: u64, + pub max_feerate_sat_vb: u64, +} + +impl Default for InscriberPolicy { + fn default() -> Self { + Self { + min_inscription_output_sats: DEFAULT_MIN_INSCRIPTION_OUTPUT_SATS, + min_change_output_sats: DEFAULT_MIN_CHANGE_OUTPUT_SATS, + min_feerate_sat_vb: DEFAULT_MIN_FEERATE_SAT_VB, + min_chained_feerate_sat_vb: DEFAULT_MIN_CHAINED_FEERATE_SAT_VB, + max_feerate_sat_vb: DEFAULT_MAX_FEERATE_SAT_VB, + } + } +} #[derive(Debug)] pub struct Inscriber { client: Arc, signer: Arc, context: InscriberContext, + policy: InscriberPolicy, } impl Inscriber { @@ -90,6 +117,7 @@ impl Inscriber { client, signer, context, + policy: InscriberPolicy::default(), }) } @@ -114,6 +142,19 @@ impl Inscriber { Ok(balance) } + pub fn with_policy(mut self, policy: InscriberPolicy) -> Self { + self.policy = policy; + self + } + + pub fn set_policy(&mut self, policy: InscriberPolicy) { + self.policy = policy; + } + + pub fn pending_chain_depth(&self) -> usize { + self.context.fifo_queue.len() + } + #[instrument(skip(self, input), target = "bitcoin_inscriber")] pub async fn prepare_inscribe( &mut self, @@ -355,8 +396,15 @@ impl Inscriber { inscription_pubkey: ScriptBuf, ) -> Result { debug!("Preparing commit transaction output"); + let min_inscription_output = Amount::from_sat( + std::cmp::max( + self.policy.min_inscription_output_sats, + P2TR_DUST_LIMIT.to_sat(), + ), + ); + let inscription_commitment_output = TxOut { - value: P2TR_DUST_LIMIT, + value: min_inscription_output, script_pubkey: inscription_pubkey, }; @@ -373,17 +421,29 @@ impl Inscriber { let fee_amount_before_decrease = fee_amount; fee_amount -= (fee_amount * FEE_RATE_DECREASE_COMMIT_TX) / 100; + let required_amount = fee_amount + min_inscription_output; + let commit_tx_change_output_value = tx_input_data .unlocked_value - .checked_sub(fee_amount + P2TR_DUST_LIMIT) + .checked_sub(required_amount) .ok_or_else(|| { anyhow::anyhow!( "Required Amount: {:?}, Spendable Amount: {:?} ", - fee_amount + P2TR_DUST_LIMIT, + required_amount, tx_input_data.unlocked_value ) })?; + if commit_tx_change_output_value < Amount::from_sat(self.policy.min_change_output_sats) { + anyhow::bail!( + "Required Amount: {:?}, Spendable Amount: {:?}. change output {:?} is below minimum {:?}", + required_amount + Amount::from_sat(self.policy.min_change_output_sats), + tx_input_data.unlocked_value, + commit_tx_change_output_value, + Amount::from_sat(self.policy.min_change_output_sats) + ); + } + let commit_tx_change_output = TxOut { value: commit_tx_change_output_value, script_pubkey: self.signer.get_p2wpkh_script_pubkey().clone(), @@ -405,8 +465,17 @@ impl Inscriber { async fn get_fee_rate(&self) -> Result { debug!("Getting fee rate"); let res = self.client.get_fee_rate(FEE_RATE_CONF_TARGET).await?; - debug!("Fee rate obtained: {}", res); - Ok(std::cmp::max(res, 1)) + let min_floor = if self.context.fifo_queue.is_empty() { + self.policy.min_feerate_sat_vb + } else { + self.policy.min_chained_feerate_sat_vb + }; + + let mut effective = std::cmp::max(res, min_floor); + effective = std::cmp::min(effective, self.policy.max_feerate_sat_vb); + + debug!("Fee rate obtained: {}, effective: {}", res, effective); + Ok(std::cmp::max(effective, 1)) } #[instrument(skip(self, input, output), target = "bitcoin_inscriber")] @@ -615,6 +684,16 @@ impl Inscriber { ) })?; + if reveal_change_amount < Amount::from_sat(self.policy.min_change_output_sats) { + anyhow::bail!( + "Required Amount:{:?} Spendable Amount: {:?}. reveal change output {:?} is below minimum {:?}", + fee_amount + recipient_amount + Amount::from_sat(self.policy.min_change_output_sats), + tx_input_data.unlock_value, + reveal_change_amount, + Amount::from_sat(self.policy.min_change_output_sats) + ); + } + // Change output goes back to the inscriber let reveal_tx_change_output = TxOut { value: reveal_change_amount, @@ -968,6 +1047,7 @@ mod tests { client: Arc::new(client), signer: Arc::new(signer), context, + policy: InscriberPolicy::default(), } } diff --git a/core/node/node_framework/src/implementations/layers/via_btc_sender/aggregator.rs b/core/node/node_framework/src/implementations/layers/via_btc_sender/aggregator.rs index 31ab80872..f8b0153ad 100644 --- a/core/node/node_framework/src/implementations/layers/via_btc_sender/aggregator.rs +++ b/core/node/node_framework/src/implementations/layers/via_btc_sender/aggregator.rs @@ -1,4 +1,4 @@ -use via_btc_client::inscriber::Inscriber; +use via_btc_client::inscriber::{Inscriber, InscriberPolicy}; use via_btc_sender::btc_inscription_aggregator::ViaBtcInscriptionAggregator; use zksync_config::{configs::via_wallets::ViaWallet, ViaBtcSenderConfig}; @@ -66,9 +66,18 @@ impl WiringLayer for ViaBtcInscriptionAggregatorLayer { let master_pool = input.master_pool.get().await.unwrap(); let client = input.btc_client_resource.btc_sender.unwrap(); + let inscriber_policy = InscriberPolicy { + min_inscription_output_sats: self.config.min_inscription_output_sats(), + min_change_output_sats: self.config.min_change_output_sats(), + min_feerate_sat_vb: self.config.min_feerate_sat_vb(), + min_chained_feerate_sat_vb: self.config.min_chained_feerate_sat_vb(), + max_feerate_sat_vb: self.config.max_feerate_sat_vb(), + }; + let inscriber = Inscriber::new(client, &self.wallet.private_key, None) .await - .unwrap(); + .unwrap() + .with_policy(inscriber_policy); let via_btc_inscription_aggregator = ViaBtcInscriptionAggregator::new(inscriber, master_pool, self.config).await?; diff --git a/core/node/node_framework/src/implementations/layers/via_btc_sender/manager.rs b/core/node/node_framework/src/implementations/layers/via_btc_sender/manager.rs index 71c16a891..48a1e8b42 100644 --- a/core/node/node_framework/src/implementations/layers/via_btc_sender/manager.rs +++ b/core/node/node_framework/src/implementations/layers/via_btc_sender/manager.rs @@ -1,5 +1,5 @@ use anyhow::Context; -use via_btc_client::inscriber::Inscriber; +use via_btc_client::inscriber::{Inscriber, InscriberPolicy}; use via_btc_sender::btc_inscription_manager::ViaBtcInscriptionManager; use zksync_config::{configs::via_wallets::ViaWallet, ViaBtcSenderConfig}; @@ -66,9 +66,18 @@ impl WiringLayer for ViaInscriptionManagerLayer { let master_pool = input.master_pool.get().await.unwrap(); let client = input.btc_client_resource.btc_sender.unwrap(); + let inscriber_policy = InscriberPolicy { + min_inscription_output_sats: self.config.min_inscription_output_sats(), + min_change_output_sats: self.config.min_change_output_sats(), + min_feerate_sat_vb: self.config.min_feerate_sat_vb(), + min_chained_feerate_sat_vb: self.config.min_chained_feerate_sat_vb(), + max_feerate_sat_vb: self.config.max_feerate_sat_vb(), + }; + let inscriber = Inscriber::new(client, &self.wallet.private_key, None) .await - .with_context(|| "Error init inscriber")?; + .with_context(|| "Error init inscriber")? + .with_policy(inscriber_policy); let via_btc_inscription_manager = ViaBtcInscriptionManager::new(inscriber, master_pool, self.config) diff --git a/core/node/node_framework/src/implementations/layers/via_btc_sender/vote_manager.rs b/core/node/node_framework/src/implementations/layers/via_btc_sender/vote_manager.rs index 639318e47..dc72fcaae 100644 --- a/core/node/node_framework/src/implementations/layers/via_btc_sender/vote_manager.rs +++ b/core/node/node_framework/src/implementations/layers/via_btc_sender/vote_manager.rs @@ -1,5 +1,5 @@ use anyhow::Context; -use via_btc_client::inscriber::Inscriber; +use via_btc_client::inscriber::{Inscriber, InscriberPolicy}; use via_verifier_btc_sender::btc_inscription_manager::ViaBtcInscriptionManager; use zksync_config::{configs::via_wallets::ViaWallet, ViaBtcSenderConfig}; @@ -66,9 +66,18 @@ impl WiringLayer for ViaInscriptionManagerLayer { let master_pool = input.master_pool.get().await.unwrap(); let client = input.btc_client_resource.btc_sender.unwrap(); + let inscriber_policy = InscriberPolicy { + min_inscription_output_sats: self.config.min_inscription_output_sats(), + min_change_output_sats: self.config.min_change_output_sats(), + min_feerate_sat_vb: self.config.min_feerate_sat_vb(), + min_chained_feerate_sat_vb: self.config.min_chained_feerate_sat_vb(), + max_feerate_sat_vb: self.config.max_feerate_sat_vb(), + }; + let inscriber = Inscriber::new(client, &self.wallet.private_key, None) .await - .with_context(|| "Error init inscriber")?; + .with_context(|| "Error init inscriber")? + .with_policy(inscriber_policy); let via_btc_inscription_manager = ViaBtcInscriptionManager::new(inscriber, master_pool, self.config) diff --git a/core/node/via_btc_sender/src/btc_inscription_manager.rs b/core/node/via_btc_sender/src/btc_inscription_manager.rs index 107f967cf..194e602a8 100644 --- a/core/node/via_btc_sender/src/btc_inscription_manager.rs +++ b/core/node/via_btc_sender/src/btc_inscription_manager.rs @@ -200,6 +200,28 @@ impl ViaBtcInscriptionManager { &mut self, storage: &mut Connection<'_, Core>, ) -> anyhow::Result<()> { + let pending_chain_depth = self.inscriber.pending_chain_depth() as u32; + if pending_chain_depth >= self.config.max_pending_chain_depth() { + METRICS.chain_guard_blocks.inc(); + tracing::warn!( + "Skipping new inscription broadcast due to pending chain depth guard. depth={} max={}.", + pending_chain_depth, + self.config.max_pending_chain_depth() + ); + return Ok(()); + } + + let spendable_balance = self.inscriber.get_balance().await?; + if spendable_balance < self.config.min_spendable_balance_sats() as u128 { + METRICS.chain_guard_blocks.inc(); + tracing::warn!( + "Skipping new inscription broadcast due to low spendable balance guard. spendable={} min={}", + spendable_balance, + self.config.min_spendable_balance_sats() + ); + return Ok(()); + } + let number_inflight_txs = storage .btc_sender_dal() .list_inflight_inscription_ids() diff --git a/core/node/via_btc_sender/src/metrics.rs b/core/node/via_btc_sender/src/metrics.rs index fea4ce756..98aa5844d 100644 --- a/core/node/via_btc_sender/src/metrics.rs +++ b/core/node/via_btc_sender/src/metrics.rs @@ -57,6 +57,9 @@ pub struct ViaBtcSenderMetrics { /// Manager errors. pub manager_errors: Counter, + /// Number of times new inscriptions were intentionally paused by policy guards. + pub chain_guard_blocks: Counter, + /// Last L1 block observed by the Ethereum sender. pub last_known_l1_block: Family>, diff --git a/etc/env/base/via_btc_sender.toml b/etc/env/base/via_btc_sender.toml index def6d84a0..8c8b73af0 100644 --- a/etc/env/base/via_btc_sender.toml +++ b/etc/env/base/via_btc_sender.toml @@ -16,4 +16,18 @@ stuck_inscription_block_number = 6 # The required time (seconds) to wait before create a commit inscription. block_time_to_commit = 0 # The required time (seconds) to wait before create a proof inscription. -block_time_to_proof = 0 \ No newline at end of file +block_time_to_proof = 0 +# Hard floor for taproot inscription output (avoid dust / borderline outputs). +min_inscription_output_sats = 600 +# Hard floor for change output to preserve a reusable UTXO. +min_change_output_sats = 1000 +# Baseline fee floor in sat/vB. +min_feerate_sat_vb = 8 +# Higher floor applied when inscription chain is already pending. +min_chained_feerate_sat_vb = 20 +# Safety cap to avoid accidental overpaying. +max_feerate_sat_vb = 80 +# Pause new sends when context chain gets too deep. +max_pending_chain_depth = 3 +# Pause new sends if spendable balance falls below this threshold. +min_spendable_balance_sats = 2000 \ No newline at end of file diff --git a/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs b/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs index cbc01cf90..23ad97996 100644 --- a/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs +++ b/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs @@ -166,6 +166,28 @@ impl ViaBtcInscriptionManager { &mut self, storage: &mut Connection<'_, Verifier>, ) -> anyhow::Result<()> { + let pending_chain_depth = self.inscriber.pending_chain_depth() as u32; + if pending_chain_depth >= self.config.max_pending_chain_depth() { + METRICS.chain_guard_blocks.inc(); + tracing::warn!( + "Skipping new verifier inscription broadcast due to pending chain depth guard. depth={} max={}.", + pending_chain_depth, + self.config.max_pending_chain_depth() + ); + return Ok(()); + } + + let spendable_balance = self.inscriber.get_balance().await?; + if spendable_balance < self.config.min_spendable_balance_sats() as u128 { + METRICS.chain_guard_blocks.inc(); + tracing::warn!( + "Skipping new verifier inscription broadcast due to low spendable balance guard. spendable={} min={}", + spendable_balance, + self.config.min_spendable_balance_sats() + ); + return Ok(()); + } + let number_inflight_txs = storage .via_btc_sender_dal() .get_inflight_inscriptions() diff --git a/via_verifier/node/via_btc_sender/src/metrics.rs b/via_verifier/node/via_btc_sender/src/metrics.rs index fd14ed9f0..31411c49d 100644 --- a/via_verifier/node/via_btc_sender/src/metrics.rs +++ b/via_verifier/node/via_btc_sender/src/metrics.rs @@ -50,6 +50,9 @@ pub struct ViaBtcSenderMetrics { /// Errors pub errors: Counter, + + /// Number of times new inscriptions were intentionally paused by policy guards. + pub chain_guard_blocks: Counter, } impl ViaBtcSenderMetrics { From 355b15fb8cc0dfd2a500d63bc9e64443dba6e211 Mon Sep 17 00:00:00 2001 From: Hisham Fahmy Date: Tue, 24 Mar 2026 22:54:18 +0100 Subject: [PATCH 2/6] fix(via_btc_sender): address PR review feedback on policy wiring and fee guards --- core/lib/via_btc_client/src/inscriber/mod.rs | 76 +++++++++++++++++-- .../layers/via_btc_sender/aggregator.rs | 22 +++--- .../layers/via_btc_sender/manager.rs | 19 ++--- .../layers/via_btc_sender/mod.rs | 1 + .../layers/via_btc_sender/policy.rs | 12 +++ .../layers/via_btc_sender/vote_manager.rs | 19 ++--- .../src/btc_inscription_manager.rs | 10 +-- .../src/btc_inscription_manager.rs | 10 +-- 8 files changed, 120 insertions(+), 49 deletions(-) create mode 100644 core/node/node_framework/src/implementations/layers/via_btc_sender/policy.rs diff --git a/core/lib/via_btc_client/src/inscriber/mod.rs b/core/lib/via_btc_client/src/inscriber/mod.rs index 351675356..8fa729fb7 100644 --- a/core/lib/via_btc_client/src/inscriber/mod.rs +++ b/core/lib/via_btc_client/src/inscriber/mod.rs @@ -471,8 +471,17 @@ impl Inscriber { self.policy.min_chained_feerate_sat_vb }; - let mut effective = std::cmp::max(res, min_floor); - effective = std::cmp::min(effective, self.policy.max_feerate_sat_vb); + let max_cap = self.policy.max_feerate_sat_vb; + let effective = if max_cap < min_floor { + warn!( + "Inconsistent fee policy: max_feerate_sat_vb ({}) < min_floor ({}); ignoring max cap", + max_cap, + min_floor + ); + std::cmp::max(res, min_floor) + } else { + std::cmp::min(std::cmp::max(res, min_floor), max_cap) + }; debug!("Fee rate obtained: {}, effective: {}", res, effective); Ok(std::cmp::max(effective, 1)) @@ -678,7 +687,7 @@ impl Inscriber { .checked_sub(fee_amount + recipient_amount) .ok_or_else(|| { anyhow::anyhow!( - "Required Amount:{:?} Spendable Amount: {:?} ", + "Required Amount: {:?} Spendable Amount: {:?} ", fee_amount + recipient_amount, tx_input_data.unlock_value ) @@ -928,8 +937,8 @@ mod tests { use super::*; use crate::types::{ - BitcoinClientResult, BitcoinNetwork, BitcoinSignerResult, InscriptionMessage, - L1BatchDAReferenceInput, + BitcoinClientResult, BitcoinNetwork, BitcoinSignerResult, CommitTxInput, FeePayerCtx, + InscriberOutput, InscriptionMessage, InscriptionRequest, L1BatchDAReferenceInput, }; mock! { @@ -1070,4 +1079,61 @@ mod tests { assert_ne!(res.final_commit_tx.txid, Txid::all_zeros()); assert_ne!(res.final_reveal_tx.txid, Txid::all_zeros()); } + + #[tokio::test] + async fn test_get_fee_rate_applies_floor_when_context_empty() { + let mut inscriber = get_mock_inscriber_and_conditions(); + inscriber.set_policy(InscriberPolicy { + min_inscription_output_sats: 600, + min_change_output_sats: 1_000, + min_feerate_sat_vb: 12, + min_chained_feerate_sat_vb: 20, + max_feerate_sat_vb: 50, + }); + + let fee_rate = inscriber.get_fee_rate().await.unwrap(); + assert_eq!(fee_rate, 12); + } + + #[tokio::test] + async fn test_get_fee_rate_handles_inconsistent_cap_when_context_non_empty() { + let mut inscriber = get_mock_inscriber_and_conditions(); + inscriber.context.fifo_queue.push_back(InscriptionRequest { + message: InscriptionMessage::L1BatchDAReference(L1BatchDAReferenceInput { + l1_batch_hash: zksync_basic_types::H256([0; 32]), + l1_batch_index: zksync_basic_types::L1BatchNumber(0_u32), + da_identifier: "da_identifier_celestia".to_string(), + blob_id: "batch_temp_blob_id".to_string(), + prev_l1_batch_hash: zksync_basic_types::H256([0; 32]), + }), + inscriber_output: InscriberOutput { + commit_txid: Txid::all_zeros(), + commit_raw_tx: String::new(), + commit_tx_fee_rate: 0, + reveal_txid: Txid::all_zeros(), + reveal_raw_tx: String::new(), + reveal_tx_fee_rate: 0, + is_broadcasted: false, + }, + fee_payer_ctx: FeePayerCtx { + fee_payer_utxo_txid: Txid::all_zeros(), + fee_payer_utxo_vout: 0, + fee_payer_utxo_value: Amount::from_sat(0), + }, + commit_tx_input: CommitTxInput { + spent_utxo: vec![TxIn::default()], + }, + }); + + inscriber.set_policy(InscriberPolicy { + min_inscription_output_sats: 600, + min_change_output_sats: 1_000, + min_feerate_sat_vb: 8, + min_chained_feerate_sat_vb: 20, + max_feerate_sat_vb: 10, + }); + + let fee_rate = inscriber.get_fee_rate().await.unwrap(); + assert_eq!(fee_rate, 20); + } } diff --git a/core/node/node_framework/src/implementations/layers/via_btc_sender/aggregator.rs b/core/node/node_framework/src/implementations/layers/via_btc_sender/aggregator.rs index f8b0153ad..ee3d55f4e 100644 --- a/core/node/node_framework/src/implementations/layers/via_btc_sender/aggregator.rs +++ b/core/node/node_framework/src/implementations/layers/via_btc_sender/aggregator.rs @@ -1,11 +1,15 @@ -use via_btc_client::inscriber::{Inscriber, InscriberPolicy}; +use anyhow::Context; +use via_btc_client::inscriber::Inscriber; use via_btc_sender::btc_inscription_aggregator::ViaBtcInscriptionAggregator; use zksync_config::{configs::via_wallets::ViaWallet, ViaBtcSenderConfig}; use crate::{ - implementations::resources::{ - pools::{MasterPool, PoolResource}, - via_btc_client::BtcClientResource, + implementations::{ + layers::via_btc_sender::policy::build_inscriber_policy, + resources::{ + pools::{MasterPool, PoolResource}, + via_btc_client::BtcClientResource, + }, }, service::StopReceiver, task::{Task, TaskId}, @@ -66,17 +70,11 @@ impl WiringLayer for ViaBtcInscriptionAggregatorLayer { let master_pool = input.master_pool.get().await.unwrap(); let client = input.btc_client_resource.btc_sender.unwrap(); - let inscriber_policy = InscriberPolicy { - min_inscription_output_sats: self.config.min_inscription_output_sats(), - min_change_output_sats: self.config.min_change_output_sats(), - min_feerate_sat_vb: self.config.min_feerate_sat_vb(), - min_chained_feerate_sat_vb: self.config.min_chained_feerate_sat_vb(), - max_feerate_sat_vb: self.config.max_feerate_sat_vb(), - }; + let inscriber_policy = build_inscriber_policy(&self.config); let inscriber = Inscriber::new(client, &self.wallet.private_key, None) .await - .unwrap() + .with_context(|| "Error init inscriber")? .with_policy(inscriber_policy); let via_btc_inscription_aggregator = diff --git a/core/node/node_framework/src/implementations/layers/via_btc_sender/manager.rs b/core/node/node_framework/src/implementations/layers/via_btc_sender/manager.rs index 48a1e8b42..17a680f0b 100644 --- a/core/node/node_framework/src/implementations/layers/via_btc_sender/manager.rs +++ b/core/node/node_framework/src/implementations/layers/via_btc_sender/manager.rs @@ -1,12 +1,15 @@ use anyhow::Context; -use via_btc_client::inscriber::{Inscriber, InscriberPolicy}; +use via_btc_client::inscriber::Inscriber; use via_btc_sender::btc_inscription_manager::ViaBtcInscriptionManager; use zksync_config::{configs::via_wallets::ViaWallet, ViaBtcSenderConfig}; use crate::{ - implementations::resources::{ - pools::{MasterPool, PoolResource}, - via_btc_client::BtcClientResource, + implementations::{ + layers::via_btc_sender::policy::build_inscriber_policy, + resources::{ + pools::{MasterPool, PoolResource}, + via_btc_client::BtcClientResource, + }, }, service::StopReceiver, task::{Task, TaskId}, @@ -66,13 +69,7 @@ impl WiringLayer for ViaInscriptionManagerLayer { let master_pool = input.master_pool.get().await.unwrap(); let client = input.btc_client_resource.btc_sender.unwrap(); - let inscriber_policy = InscriberPolicy { - min_inscription_output_sats: self.config.min_inscription_output_sats(), - min_change_output_sats: self.config.min_change_output_sats(), - min_feerate_sat_vb: self.config.min_feerate_sat_vb(), - min_chained_feerate_sat_vb: self.config.min_chained_feerate_sat_vb(), - max_feerate_sat_vb: self.config.max_feerate_sat_vb(), - }; + let inscriber_policy = build_inscriber_policy(&self.config); let inscriber = Inscriber::new(client, &self.wallet.private_key, None) .await diff --git a/core/node/node_framework/src/implementations/layers/via_btc_sender/mod.rs b/core/node/node_framework/src/implementations/layers/via_btc_sender/mod.rs index 3ca69bc78..937485c52 100644 --- a/core/node/node_framework/src/implementations/layers/via_btc_sender/mod.rs +++ b/core/node/node_framework/src/implementations/layers/via_btc_sender/mod.rs @@ -1,4 +1,5 @@ pub mod aggregator; pub mod manager; +pub mod policy; pub mod vote; pub mod vote_manager; diff --git a/core/node/node_framework/src/implementations/layers/via_btc_sender/policy.rs b/core/node/node_framework/src/implementations/layers/via_btc_sender/policy.rs new file mode 100644 index 000000000..01b8c8510 --- /dev/null +++ b/core/node/node_framework/src/implementations/layers/via_btc_sender/policy.rs @@ -0,0 +1,12 @@ +use via_btc_client::inscriber::InscriberPolicy; +use zksync_config::ViaBtcSenderConfig; + +pub(super) fn build_inscriber_policy(config: &ViaBtcSenderConfig) -> InscriberPolicy { + InscriberPolicy { + min_inscription_output_sats: config.min_inscription_output_sats(), + min_change_output_sats: config.min_change_output_sats(), + min_feerate_sat_vb: config.min_feerate_sat_vb(), + min_chained_feerate_sat_vb: config.min_chained_feerate_sat_vb(), + max_feerate_sat_vb: config.max_feerate_sat_vb(), + } +} diff --git a/core/node/node_framework/src/implementations/layers/via_btc_sender/vote_manager.rs b/core/node/node_framework/src/implementations/layers/via_btc_sender/vote_manager.rs index dc72fcaae..a53ca6063 100644 --- a/core/node/node_framework/src/implementations/layers/via_btc_sender/vote_manager.rs +++ b/core/node/node_framework/src/implementations/layers/via_btc_sender/vote_manager.rs @@ -1,12 +1,15 @@ use anyhow::Context; -use via_btc_client::inscriber::{Inscriber, InscriberPolicy}; +use via_btc_client::inscriber::Inscriber; use via_verifier_btc_sender::btc_inscription_manager::ViaBtcInscriptionManager; use zksync_config::{configs::via_wallets::ViaWallet, ViaBtcSenderConfig}; use crate::{ - implementations::resources::{ - pools::{PoolResource, VerifierPool}, - via_btc_client::BtcClientResource, + implementations::{ + layers::via_btc_sender::policy::build_inscriber_policy, + resources::{ + pools::{PoolResource, VerifierPool}, + via_btc_client::BtcClientResource, + }, }, service::StopReceiver, task::{Task, TaskId}, @@ -66,13 +69,7 @@ impl WiringLayer for ViaInscriptionManagerLayer { let master_pool = input.master_pool.get().await.unwrap(); let client = input.btc_client_resource.btc_sender.unwrap(); - let inscriber_policy = InscriberPolicy { - min_inscription_output_sats: self.config.min_inscription_output_sats(), - min_change_output_sats: self.config.min_change_output_sats(), - min_feerate_sat_vb: self.config.min_feerate_sat_vb(), - min_chained_feerate_sat_vb: self.config.min_chained_feerate_sat_vb(), - max_feerate_sat_vb: self.config.max_feerate_sat_vb(), - }; + let inscriber_policy = build_inscriber_policy(&self.config); let inscriber = Inscriber::new(client, &self.wallet.private_key, None) .await diff --git a/core/node/via_btc_sender/src/btc_inscription_manager.rs b/core/node/via_btc_sender/src/btc_inscription_manager.rs index 194e602a8..6e265d00c 100644 --- a/core/node/via_btc_sender/src/btc_inscription_manager.rs +++ b/core/node/via_btc_sender/src/btc_inscription_manager.rs @@ -73,15 +73,15 @@ impl ViaBtcInscriptionManager { return Ok(()); } - self.update_inscription_status(storage).await?; - self.send_new_inscription_txs(storage).await?; + let spendable_balance = self.update_inscription_status(storage).await?; + self.send_new_inscription_txs(storage, spendable_balance).await?; Ok(()) } async fn update_inscription_status( &mut self, storage: &mut Connection<'_, Core>, - ) -> anyhow::Result<()> { + ) -> anyhow::Result { self.inscriber.sync_context_with_blockchain().await?; let inflight_inscriptions_ids = storage @@ -193,12 +193,13 @@ impl ViaBtcInscriptionManager { METRICS.btc_sender_account_balance[&self.config.wallet_address.clone()] .set(balance as usize); - Ok(()) + Ok(balance) } async fn send_new_inscription_txs( &mut self, storage: &mut Connection<'_, Core>, + spendable_balance: u128, ) -> anyhow::Result<()> { let pending_chain_depth = self.inscriber.pending_chain_depth() as u32; if pending_chain_depth >= self.config.max_pending_chain_depth() { @@ -211,7 +212,6 @@ impl ViaBtcInscriptionManager { return Ok(()); } - let spendable_balance = self.inscriber.get_balance().await?; if spendable_balance < self.config.min_spendable_balance_sats() as u128 { METRICS.chain_guard_blocks.inc(); tracing::warn!( diff --git a/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs b/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs index 23ad97996..96e6d1095 100644 --- a/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs +++ b/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs @@ -65,15 +65,15 @@ impl ViaBtcInscriptionManager { return Ok(()); } - self.update_inscription_status_or_resend(storage).await?; - self.send_new_inscription_txs(storage).await?; + let spendable_balance = self.update_inscription_status_or_resend(storage).await?; + self.send_new_inscription_txs(storage, spendable_balance).await?; Ok(()) } async fn update_inscription_status_or_resend( &mut self, storage: &mut Connection<'_, Verifier>, - ) -> anyhow::Result<()> { + ) -> anyhow::Result { self.inscriber.sync_context_with_blockchain().await?; let inflight_inscriptions = storage @@ -159,12 +159,13 @@ impl ViaBtcInscriptionManager { METRICS.btc_sender_account_balance[&self.config.wallet_address.clone()] .set(balance as usize); - Ok(()) + Ok(balance) } async fn send_new_inscription_txs( &mut self, storage: &mut Connection<'_, Verifier>, + spendable_balance: u128, ) -> anyhow::Result<()> { let pending_chain_depth = self.inscriber.pending_chain_depth() as u32; if pending_chain_depth >= self.config.max_pending_chain_depth() { @@ -177,7 +178,6 @@ impl ViaBtcInscriptionManager { return Ok(()); } - let spendable_balance = self.inscriber.get_balance().await?; if spendable_balance < self.config.min_spendable_balance_sats() as u128 { METRICS.chain_guard_blocks.inc(); tracing::warn!( From 814fa31d54d1f42d3f8e4dc66ec080fc4b71993b Mon Sep 17 00:00:00 2001 From: Hisham Fahmy Date: Tue, 24 Mar 2026 23:25:41 +0100 Subject: [PATCH 3/6] fix(via_btc_sender): address follow-up review findings on guards and tests --- core/lib/config/src/configs/via_btc_sender.rs | 2 +- core/lib/via_btc_client/src/inscriber/mod.rs | 26 ++++++++++++++++--- .../src/btc_inscription_manager.rs | 18 ++++++++----- etc/env/base/via_btc_sender.toml | 2 +- .../src/btc_inscription_manager.rs | 18 ++++++++----- 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/core/lib/config/src/configs/via_btc_sender.rs b/core/lib/config/src/configs/via_btc_sender.rs index e6734889e..7720505a4 100644 --- a/core/lib/config/src/configs/via_btc_sender.rs +++ b/core/lib/config/src/configs/via_btc_sender.rs @@ -54,7 +54,7 @@ pub struct ViaBtcSenderConfig { /// Max number of pending inscriptions in context before pausing new sends. pub max_pending_chain_depth: Option, - /// Do not send new inscriptions when spendable balance goes below this threshold. + /// Do not send new inscriptions when trusted (confirmed) balance goes below this threshold. pub min_spendable_balance_sats: Option, } diff --git a/core/lib/via_btc_client/src/inscriber/mod.rs b/core/lib/via_btc_client/src/inscriber/mod.rs index 8fa729fb7..e5a0d6998 100644 --- a/core/lib/via_btc_client/src/inscriber/mod.rs +++ b/core/lib/via_btc_client/src/inscriber/mod.rs @@ -142,6 +142,12 @@ impl Inscriber { Ok(balance) } + #[instrument(skip(self), target = "bitcoin_inscriber")] + pub async fn get_trusted_balance(&self) -> Result { + let address_ref = &self.signer.get_p2wpkh_address()?; + Ok(self.client.get_balance(address_ref).await?) + } + pub fn with_policy(mut self, policy: InscriberPolicy) -> Self { self.policy = policy; self @@ -695,7 +701,7 @@ impl Inscriber { if reveal_change_amount < Amount::from_sat(self.policy.min_change_output_sats) { anyhow::bail!( - "Required Amount:{:?} Spendable Amount: {:?}. reveal change output {:?} is below minimum {:?}", + "Required Amount: {:?} Spendable Amount: {:?}. reveal change output {:?} is below minimum {:?}", fee_amount + recipient_amount + Amount::from_sat(self.policy.min_change_output_sats), tx_input_data.unlock_value, reveal_change_amount, @@ -1060,6 +1066,20 @@ mod tests { } } + fn get_mock_inscriber_for_fee_rate_tests() -> Inscriber { + let mut client = MockBitcoinOps::new(); + let signer = MockBitcoinSigner::new(); + + client.expect_get_fee_rate().returning(|_| Ok(1)); + + Inscriber { + client: Arc::new(client), + signer: Arc::new(signer), + context: InscriberContext::default(), + policy: InscriberPolicy::default(), + } + } + #[tokio::test] async fn test_inscriber_inscribe() { let mut inscriber = get_mock_inscriber_and_conditions(); @@ -1082,7 +1102,7 @@ mod tests { #[tokio::test] async fn test_get_fee_rate_applies_floor_when_context_empty() { - let mut inscriber = get_mock_inscriber_and_conditions(); + let mut inscriber = get_mock_inscriber_for_fee_rate_tests(); inscriber.set_policy(InscriberPolicy { min_inscription_output_sats: 600, min_change_output_sats: 1_000, @@ -1097,7 +1117,7 @@ mod tests { #[tokio::test] async fn test_get_fee_rate_handles_inconsistent_cap_when_context_non_empty() { - let mut inscriber = get_mock_inscriber_and_conditions(); + let mut inscriber = get_mock_inscriber_for_fee_rate_tests(); inscriber.context.fifo_queue.push_back(InscriptionRequest { message: InscriptionMessage::L1BatchDAReference(L1BatchDAReferenceInput { l1_batch_hash: zksync_basic_types::H256([0; 32]), diff --git a/core/node/via_btc_sender/src/btc_inscription_manager.rs b/core/node/via_btc_sender/src/btc_inscription_manager.rs index 6e265d00c..40d361d2c 100644 --- a/core/node/via_btc_sender/src/btc_inscription_manager.rs +++ b/core/node/via_btc_sender/src/btc_inscription_manager.rs @@ -73,8 +73,10 @@ impl ViaBtcInscriptionManager { return Ok(()); } - let spendable_balance = self.update_inscription_status(storage).await?; - self.send_new_inscription_txs(storage, spendable_balance).await?; + let balance_with_pending_context = self.update_inscription_status(storage).await?; + let trusted_balance = self.inscriber.get_trusted_balance().await?; + self.send_new_inscription_txs(storage, balance_with_pending_context, trusted_balance) + .await?; Ok(()) } @@ -199,10 +201,11 @@ impl ViaBtcInscriptionManager { async fn send_new_inscription_txs( &mut self, storage: &mut Connection<'_, Core>, - spendable_balance: u128, + balance_with_pending_context: u128, + trusted_balance: u128, ) -> anyhow::Result<()> { let pending_chain_depth = self.inscriber.pending_chain_depth() as u32; - if pending_chain_depth >= self.config.max_pending_chain_depth() { + if pending_chain_depth > self.config.max_pending_chain_depth() { METRICS.chain_guard_blocks.inc(); tracing::warn!( "Skipping new inscription broadcast due to pending chain depth guard. depth={} max={}.", @@ -212,11 +215,12 @@ impl ViaBtcInscriptionManager { return Ok(()); } - if spendable_balance < self.config.min_spendable_balance_sats() as u128 { + if trusted_balance < self.config.min_spendable_balance_sats() as u128 { METRICS.chain_guard_blocks.inc(); tracing::warn!( - "Skipping new inscription broadcast due to low spendable balance guard. spendable={} min={}", - spendable_balance, + "Skipping new inscription broadcast due to low trusted balance guard. trusted={} (with_pending={}) min={}", + trusted_balance, + balance_with_pending_context, self.config.min_spendable_balance_sats() ); return Ok(()); diff --git a/etc/env/base/via_btc_sender.toml b/etc/env/base/via_btc_sender.toml index 8c8b73af0..26c652fe9 100644 --- a/etc/env/base/via_btc_sender.toml +++ b/etc/env/base/via_btc_sender.toml @@ -29,5 +29,5 @@ min_chained_feerate_sat_vb = 20 max_feerate_sat_vb = 80 # Pause new sends when context chain gets too deep. max_pending_chain_depth = 3 -# Pause new sends if spendable balance falls below this threshold. +# Pause new sends if trusted (confirmed) balance falls below this threshold. min_spendable_balance_sats = 2000 \ No newline at end of file diff --git a/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs b/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs index 96e6d1095..8f104786b 100644 --- a/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs +++ b/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs @@ -65,8 +65,10 @@ impl ViaBtcInscriptionManager { return Ok(()); } - let spendable_balance = self.update_inscription_status_or_resend(storage).await?; - self.send_new_inscription_txs(storage, spendable_balance).await?; + let balance_with_pending_context = self.update_inscription_status_or_resend(storage).await?; + let trusted_balance = self.inscriber.get_trusted_balance().await?; + self.send_new_inscription_txs(storage, balance_with_pending_context, trusted_balance) + .await?; Ok(()) } @@ -165,10 +167,11 @@ impl ViaBtcInscriptionManager { async fn send_new_inscription_txs( &mut self, storage: &mut Connection<'_, Verifier>, - spendable_balance: u128, + balance_with_pending_context: u128, + trusted_balance: u128, ) -> anyhow::Result<()> { let pending_chain_depth = self.inscriber.pending_chain_depth() as u32; - if pending_chain_depth >= self.config.max_pending_chain_depth() { + if pending_chain_depth > self.config.max_pending_chain_depth() { METRICS.chain_guard_blocks.inc(); tracing::warn!( "Skipping new verifier inscription broadcast due to pending chain depth guard. depth={} max={}.", @@ -178,11 +181,12 @@ impl ViaBtcInscriptionManager { return Ok(()); } - if spendable_balance < self.config.min_spendable_balance_sats() as u128 { + if trusted_balance < self.config.min_spendable_balance_sats() as u128 { METRICS.chain_guard_blocks.inc(); tracing::warn!( - "Skipping new verifier inscription broadcast due to low spendable balance guard. spendable={} min={}", - spendable_balance, + "Skipping new verifier inscription broadcast due to low trusted balance guard. trusted={} (with_pending={}) min={}", + trusted_balance, + balance_with_pending_context, self.config.min_spendable_balance_sats() ); return Ok(()); From 01e3cf7fb0e403c0d6c6514000194d71c8420afe Mon Sep 17 00:00:00 2001 From: Hisham Fahmy Date: Tue, 24 Mar 2026 23:54:57 +0100 Subject: [PATCH 4/6] fix(via_btc_sender): resolve remaining review comments and add guardrail tests --- core/lib/via_btc_client/src/inscriber/mod.rs | 128 ++++++++++++++---- .../src/inscriber/test_utils.rs | 3 +- .../layers/via_btc_sender/manager.rs | 2 +- .../layers/via_btc_sender/vote_manager.rs | 2 +- 4 files changed, 105 insertions(+), 30 deletions(-) diff --git a/core/lib/via_btc_client/src/inscriber/mod.rs b/core/lib/via_btc_client/src/inscriber/mod.rs index e5a0d6998..4d6ff23f2 100644 --- a/core/lib/via_btc_client/src/inscriber/mod.rs +++ b/core/lib/via_btc_client/src/inscriber/mod.rs @@ -701,7 +701,7 @@ impl Inscriber { if reveal_change_amount < Amount::from_sat(self.policy.min_change_output_sats) { anyhow::bail!( - "Required Amount: {:?} Spendable Amount: {:?}. reveal change output {:?} is below minimum {:?}", + "Required Amount: {:?}, Spendable Amount: {:?}. reveal change output {:?} is below minimum {:?}", fee_amount + recipient_amount + Amount::from_sat(self.policy.min_change_output_sats), tx_input_data.unlock_value, reveal_change_amount, @@ -1080,6 +1080,35 @@ mod tests { } } + fn dummy_inscription_request() -> InscriptionRequest { + InscriptionRequest { + message: InscriptionMessage::L1BatchDAReference(L1BatchDAReferenceInput { + l1_batch_hash: zksync_basic_types::H256([0; 32]), + l1_batch_index: zksync_basic_types::L1BatchNumber(0_u32), + da_identifier: "da_identifier_celestia".to_string(), + blob_id: "batch_temp_blob_id".to_string(), + prev_l1_batch_hash: zksync_basic_types::H256([0; 32]), + }), + inscriber_output: InscriberOutput { + commit_txid: Txid::all_zeros(), + commit_raw_tx: String::new(), + commit_tx_fee_rate: 0, + reveal_txid: Txid::all_zeros(), + reveal_raw_tx: String::new(), + reveal_tx_fee_rate: 0, + is_broadcasted: false, + }, + fee_payer_ctx: FeePayerCtx { + fee_payer_utxo_txid: Txid::all_zeros(), + fee_payer_utxo_vout: 0, + fee_payer_utxo_value: Amount::from_sat(0), + }, + commit_tx_input: CommitTxInput { + spent_utxo: vec![TxIn::default()], + }, + } + } + #[tokio::test] async fn test_inscriber_inscribe() { let mut inscriber = get_mock_inscriber_and_conditions(); @@ -1118,32 +1147,7 @@ mod tests { #[tokio::test] async fn test_get_fee_rate_handles_inconsistent_cap_when_context_non_empty() { let mut inscriber = get_mock_inscriber_for_fee_rate_tests(); - inscriber.context.fifo_queue.push_back(InscriptionRequest { - message: InscriptionMessage::L1BatchDAReference(L1BatchDAReferenceInput { - l1_batch_hash: zksync_basic_types::H256([0; 32]), - l1_batch_index: zksync_basic_types::L1BatchNumber(0_u32), - da_identifier: "da_identifier_celestia".to_string(), - blob_id: "batch_temp_blob_id".to_string(), - prev_l1_batch_hash: zksync_basic_types::H256([0; 32]), - }), - inscriber_output: InscriberOutput { - commit_txid: Txid::all_zeros(), - commit_raw_tx: String::new(), - commit_tx_fee_rate: 0, - reveal_txid: Txid::all_zeros(), - reveal_raw_tx: String::new(), - reveal_tx_fee_rate: 0, - is_broadcasted: false, - }, - fee_payer_ctx: FeePayerCtx { - fee_payer_utxo_txid: Txid::all_zeros(), - fee_payer_utxo_vout: 0, - fee_payer_utxo_value: Amount::from_sat(0), - }, - commit_tx_input: CommitTxInput { - spent_utxo: vec![TxIn::default()], - }, - }); + inscriber.context.fifo_queue.push_back(dummy_inscription_request()); inscriber.set_policy(InscriberPolicy { min_inscription_output_sats: 600, @@ -1156,4 +1160,74 @@ mod tests { let fee_rate = inscriber.get_fee_rate().await.unwrap(); assert_eq!(fee_rate, 20); } + + #[tokio::test] + async fn test_prepare_inscribe_fails_when_commit_change_below_minimum() { + let mut inscriber = get_mock_inscriber_and_conditions(); + inscriber.set_policy(InscriberPolicy { + min_inscription_output_sats: 600, + min_change_output_sats: Amount::from_btc(3.0).unwrap().to_sat(), + min_feerate_sat_vb: 1, + min_chained_feerate_sat_vb: 1, + max_feerate_sat_vb: 100, + }); + + let l1_da_batch_ref = L1BatchDAReferenceInput { + l1_batch_hash: zksync_basic_types::H256([0; 32]), + l1_batch_index: zksync_basic_types::L1BatchNumber(0_u32), + da_identifier: "da_identifier_celestia".to_string(), + blob_id: "batch_temp_blob_id".to_string(), + prev_l1_batch_hash: zksync_basic_types::H256([0; 32]), + }; + + let res = inscriber + .prepare_inscribe(&InscriptionMessage::L1BatchDAReference(l1_da_batch_ref), None) + .await; + + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("change output") + ); + } + + #[tokio::test] + async fn test_prepare_inscribe_fails_when_reveal_change_below_minimum() { + let mut inscriber = get_mock_inscriber_and_conditions(); + inscriber.set_policy(InscriberPolicy { + min_inscription_output_sats: 600, + min_change_output_sats: 1_000, + min_feerate_sat_vb: 1, + min_chained_feerate_sat_vb: 1, + max_feerate_sat_vb: 100, + }); + + let l1_da_batch_ref = L1BatchDAReferenceInput { + l1_batch_hash: zksync_basic_types::H256([0; 32]), + l1_batch_index: zksync_basic_types::L1BatchNumber(0_u32), + da_identifier: "da_identifier_celestia".to_string(), + blob_id: "batch_temp_blob_id".to_string(), + prev_l1_batch_hash: zksync_basic_types::H256([0; 32]), + }; + + let recipient = Recipient { + amount: Amount::from_btc(1.99999).unwrap(), + script_pubkey: inscriber.signer.get_p2wpkh_script_pubkey().clone(), + }; + + let res = inscriber + .prepare_inscribe( + &InscriptionMessage::L1BatchDAReference(l1_da_batch_ref), + Some(recipient), + ) + .await; + + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("reveal change output") + ); + } } diff --git a/core/lib/via_btc_client/src/inscriber/test_utils.rs b/core/lib/via_btc_client/src/inscriber/test_utils.rs index ab48ec52e..474b56699 100644 --- a/core/lib/via_btc_client/src/inscriber/test_utils.rs +++ b/core/lib/via_btc_client/src/inscriber/test_utils.rs @@ -13,7 +13,7 @@ use bitcoin::{ }; use bitcoincore_rpc::json::{FeeRatePercentiles, GetBlockStatsResult}; -use super::Inscriber; +use super::{Inscriber, InscriberPolicy}; use crate::{ traits::{BitcoinOps, BitcoinSigner}, types::{self, BitcoinClientResult, InscriberContext}, @@ -280,5 +280,6 @@ pub fn get_mock_inscriber_and_conditions(config: MockBitcoinOpsConfig) -> Inscri client: Arc::new(client), signer: Arc::new(signer), context, + policy: InscriberPolicy::default(), } } diff --git a/core/node/node_framework/src/implementations/layers/via_btc_sender/manager.rs b/core/node/node_framework/src/implementations/layers/via_btc_sender/manager.rs index 17a680f0b..74f1c36f4 100644 --- a/core/node/node_framework/src/implementations/layers/via_btc_sender/manager.rs +++ b/core/node/node_framework/src/implementations/layers/via_btc_sender/manager.rs @@ -79,7 +79,7 @@ impl WiringLayer for ViaInscriptionManagerLayer { let via_btc_inscription_manager = ViaBtcInscriptionManager::new(inscriber, master_pool, self.config) .await - .unwrap(); + .with_context(|| "Error init via btc inscription manager")?; Ok(Output { via_btc_inscription_manager, diff --git a/core/node/node_framework/src/implementations/layers/via_btc_sender/vote_manager.rs b/core/node/node_framework/src/implementations/layers/via_btc_sender/vote_manager.rs index a53ca6063..e78b34539 100644 --- a/core/node/node_framework/src/implementations/layers/via_btc_sender/vote_manager.rs +++ b/core/node/node_framework/src/implementations/layers/via_btc_sender/vote_manager.rs @@ -79,7 +79,7 @@ impl WiringLayer for ViaInscriptionManagerLayer { let via_btc_inscription_manager = ViaBtcInscriptionManager::new(inscriber, master_pool, self.config) .await - .unwrap(); + .with_context(|| "Error init via verifier btc inscription manager")?; Ok(Output { via_btc_inscription_manager, From 0eec8bd79487b7ee3dbaa45f656343ab40061c63 Mon Sep 17 00:00:00 2001 From: Hisham Fahmy Date: Wed, 25 Mar 2026 00:20:51 +0100 Subject: [PATCH 5/6] fix(via_btc_sender): address latest active review threads on balances and typing --- core/lib/via_btc_client/src/inscriber/mod.rs | 28 ++++++++++++------- .../src/btc_inscription_manager.rs | 19 +++++++------ .../src/btc_inscription_manager.rs | 19 +++++++------ 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/core/lib/via_btc_client/src/inscriber/mod.rs b/core/lib/via_btc_client/src/inscriber/mod.rs index 4d6ff23f2..0cfe06a2d 100644 --- a/core/lib/via_btc_client/src/inscriber/mod.rs +++ b/core/lib/via_btc_client/src/inscriber/mod.rs @@ -122,11 +122,13 @@ impl Inscriber { } #[instrument(skip(self), target = "bitcoin_inscriber")] - pub async fn get_balance(&self) -> Result { - debug!("Getting balance"); + pub async fn get_balances(&self) -> Result<(u128, u128)> { + debug!("Getting balances"); let address_ref = &self.signer.get_p2wpkh_address()?; - let mut balance = self.client.get_balance(address_ref).await?; - debug!("Balance obtained: {}", balance); + let trusted_balance = self.client.get_balance(address_ref).await?; + debug!("Trusted balance obtained: {}", trusted_balance); + + let mut balance_with_pending_context = trusted_balance; // Include the transactions in mempool when calculate the balance for inscription in &self.context.fifo_queue { @@ -134,18 +136,24 @@ impl Inscriber { tx.output.iter().for_each(|output| { if output.script_pubkey == address_ref.script_pubkey() { - balance += output.value.to_sat() as u128; + balance_with_pending_context += output.value.to_sat() as u128; } }); } - Ok(balance) + Ok((trusted_balance, balance_with_pending_context)) + } + + #[instrument(skip(self), target = "bitcoin_inscriber")] + pub async fn get_balance(&self) -> Result { + let (_, balance_with_pending_context) = self.get_balances().await?; + Ok(balance_with_pending_context) } #[instrument(skip(self), target = "bitcoin_inscriber")] pub async fn get_trusted_balance(&self) -> Result { - let address_ref = &self.signer.get_p2wpkh_address()?; - Ok(self.client.get_balance(address_ref).await?) + let (trusted_balance, _) = self.get_balances().await?; + Ok(trusted_balance) } pub fn with_policy(mut self, policy: InscriberPolicy) -> Self { @@ -434,7 +442,7 @@ impl Inscriber { .checked_sub(required_amount) .ok_or_else(|| { anyhow::anyhow!( - "Required Amount: {:?}, Spendable Amount: {:?} ", + "Required Amount: {:?}, Spendable Amount: {:?}", required_amount, tx_input_data.unlocked_value ) @@ -693,7 +701,7 @@ impl Inscriber { .checked_sub(fee_amount + recipient_amount) .ok_or_else(|| { anyhow::anyhow!( - "Required Amount: {:?} Spendable Amount: {:?} ", + "Required Amount: {:?}, Spendable Amount: {:?}", fee_amount + recipient_amount, tx_input_data.unlock_value ) diff --git a/core/node/via_btc_sender/src/btc_inscription_manager.rs b/core/node/via_btc_sender/src/btc_inscription_manager.rs index 40d361d2c..4b05b0e6b 100644 --- a/core/node/via_btc_sender/src/btc_inscription_manager.rs +++ b/core/node/via_btc_sender/src/btc_inscription_manager.rs @@ -73,8 +73,8 @@ impl ViaBtcInscriptionManager { return Ok(()); } - let balance_with_pending_context = self.update_inscription_status(storage).await?; - let trusted_balance = self.inscriber.get_trusted_balance().await?; + let (trusted_balance, balance_with_pending_context) = + self.update_inscription_status(storage).await?; self.send_new_inscription_txs(storage, balance_with_pending_context, trusted_balance) .await?; Ok(()) @@ -83,7 +83,7 @@ impl ViaBtcInscriptionManager { async fn update_inscription_status( &mut self, storage: &mut Connection<'_, Core>, - ) -> anyhow::Result { + ) -> anyhow::Result<(u128, u128)> { self.inscriber.sync_context_with_blockchain().await?; let inflight_inscriptions_ids = storage @@ -191,11 +191,11 @@ impl ViaBtcInscriptionManager { } } - let balance = self.inscriber.get_balance().await?; + let (trusted_balance, balance_with_pending_context) = self.inscriber.get_balances().await?; METRICS.btc_sender_account_balance[&self.config.wallet_address.clone()] - .set(balance as usize); + .set(balance_with_pending_context as usize); - Ok(balance) + Ok((trusted_balance, balance_with_pending_context)) } async fn send_new_inscription_txs( @@ -204,13 +204,14 @@ impl ViaBtcInscriptionManager { balance_with_pending_context: u128, trusted_balance: u128, ) -> anyhow::Result<()> { - let pending_chain_depth = self.inscriber.pending_chain_depth() as u32; - if pending_chain_depth > self.config.max_pending_chain_depth() { + let pending_chain_depth = self.inscriber.pending_chain_depth(); + let max_pending_chain_depth = self.config.max_pending_chain_depth() as usize; + if pending_chain_depth > max_pending_chain_depth { METRICS.chain_guard_blocks.inc(); tracing::warn!( "Skipping new inscription broadcast due to pending chain depth guard. depth={} max={}.", pending_chain_depth, - self.config.max_pending_chain_depth() + max_pending_chain_depth ); return Ok(()); } diff --git a/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs b/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs index 8f104786b..1c5b3e917 100644 --- a/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs +++ b/via_verifier/node/via_btc_sender/src/btc_inscription_manager.rs @@ -65,8 +65,8 @@ impl ViaBtcInscriptionManager { return Ok(()); } - let balance_with_pending_context = self.update_inscription_status_or_resend(storage).await?; - let trusted_balance = self.inscriber.get_trusted_balance().await?; + let (trusted_balance, balance_with_pending_context) = + self.update_inscription_status_or_resend(storage).await?; self.send_new_inscription_txs(storage, balance_with_pending_context, trusted_balance) .await?; Ok(()) @@ -75,7 +75,7 @@ impl ViaBtcInscriptionManager { async fn update_inscription_status_or_resend( &mut self, storage: &mut Connection<'_, Verifier>, - ) -> anyhow::Result { + ) -> anyhow::Result<(u128, u128)> { self.inscriber.sync_context_with_blockchain().await?; let inflight_inscriptions = storage @@ -157,11 +157,11 @@ impl ViaBtcInscriptionManager { } } - let balance = self.inscriber.get_balance().await?; + let (trusted_balance, balance_with_pending_context) = self.inscriber.get_balances().await?; METRICS.btc_sender_account_balance[&self.config.wallet_address.clone()] - .set(balance as usize); + .set(balance_with_pending_context as usize); - Ok(balance) + Ok((trusted_balance, balance_with_pending_context)) } async fn send_new_inscription_txs( @@ -170,13 +170,14 @@ impl ViaBtcInscriptionManager { balance_with_pending_context: u128, trusted_balance: u128, ) -> anyhow::Result<()> { - let pending_chain_depth = self.inscriber.pending_chain_depth() as u32; - if pending_chain_depth > self.config.max_pending_chain_depth() { + let pending_chain_depth = self.inscriber.pending_chain_depth(); + let max_pending_chain_depth = self.config.max_pending_chain_depth() as usize; + if pending_chain_depth > max_pending_chain_depth { METRICS.chain_guard_blocks.inc(); tracing::warn!( "Skipping new verifier inscription broadcast due to pending chain depth guard. depth={} max={}.", pending_chain_depth, - self.config.max_pending_chain_depth() + max_pending_chain_depth ); return Ok(()); } From e65e12a1451a8423ead317d2d1ec301ed73fc96f Mon Sep 17 00:00:00 2001 From: Hisham Fahmy Date: Wed, 25 Mar 2026 00:33:23 +0100 Subject: [PATCH 6/6] fix(via_btc_client): correct pending-context balance computation --- core/lib/via_btc_client/src/inscriber/mod.rs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/core/lib/via_btc_client/src/inscriber/mod.rs b/core/lib/via_btc_client/src/inscriber/mod.rs index 0cfe06a2d..0c6b7f0e1 100644 --- a/core/lib/via_btc_client/src/inscriber/mod.rs +++ b/core/lib/via_btc_client/src/inscriber/mod.rs @@ -128,18 +128,9 @@ impl Inscriber { let trusted_balance = self.client.get_balance(address_ref).await?; debug!("Trusted balance obtained: {}", trusted_balance); - let mut balance_with_pending_context = trusted_balance; - - // Include the transactions in mempool when calculate the balance - for inscription in &self.context.fifo_queue { - let tx: Transaction = deserialize_hex(&inscription.inscriber_output.reveal_raw_tx)?; - - tx.output.iter().for_each(|output| { - if output.script_pubkey == address_ref.script_pubkey() { - balance_with_pending_context += output.value.to_sat() as u128; - } - }); - } + // Reuse UTXO-selection logic to avoid overcounting chained pending outputs. + let commit_tx_input_info = self.prepare_commit_tx_input().await?; + let balance_with_pending_context = commit_tx_input_info.unlocked_value.to_sat() as u128; Ok((trusted_balance, balance_with_pending_context)) }