diff --git a/core/lib/config/src/configs/via_btc_sender.rs b/core/lib/config/src/configs/via_btc_sender.rs index 5b63c7e02..7720505a4 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 trusted (confirmed) 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..0c6b7f0e1 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,28 +117,47 @@ impl Inscriber { client, signer, context, + policy: InscriberPolicy::default(), }) } #[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); - // 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)?; + // 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; - tx.output.iter().for_each(|output| { - if output.script_pubkey == address_ref.script_pubkey() { - balance += output.value.to_sat() as u128; - } - }); - } + 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 (trusted_balance, _) = self.get_balances().await?; + Ok(trusted_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; + } - Ok(balance) + pub fn pending_chain_depth(&self) -> usize { + self.context.fifo_queue.len() } #[instrument(skip(self, input), target = "bitcoin_inscriber")] @@ -355,8 +401,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 +426,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: {:?}, Spendable Amount: {:?}", + 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 +470,26 @@ 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 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)) } #[instrument(skip(self, input, output), target = "bitcoin_inscriber")] @@ -609,12 +692,22 @@ 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 ) })?; + 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, @@ -849,8 +942,8 @@ mod tests { use super::*; use crate::types::{ - BitcoinClientResult, BitcoinNetwork, BitcoinSignerResult, InscriptionMessage, - L1BatchDAReferenceInput, + BitcoinClientResult, BitcoinNetwork, BitcoinSignerResult, CommitTxInput, FeePayerCtx, + InscriberOutput, InscriptionMessage, InscriptionRequest, L1BatchDAReferenceInput, }; mock! { @@ -968,6 +1061,50 @@ mod tests { client: Arc::new(client), signer: Arc::new(signer), context, + policy: InscriberPolicy::default(), + } + } + + 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(), + } + } + + 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()], + }, } } @@ -990,4 +1127,106 @@ 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_for_fee_rate_tests(); + 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_for_fee_rate_tests(); + inscriber.context.fifo_queue.push_back(dummy_inscription_request()); + + 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); + } + + #[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/aggregator.rs b/core/node/node_framework/src/implementations/layers/via_btc_sender/aggregator.rs index 31ab80872..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 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,9 +70,12 @@ 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 = 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 = 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..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 @@ -4,9 +4,12 @@ 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,14 +69,17 @@ 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 = build_inscriber_policy(&self.config); + 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) .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/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 639318e47..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 @@ -4,9 +4,12 @@ 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,14 +69,17 @@ 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 = build_inscriber_policy(&self.config); + 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) .await - .unwrap(); + .with_context(|| "Error init via verifier btc inscription manager")?; Ok(Output { via_btc_inscription_manager, 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..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,15 +73,17 @@ impl ViaBtcInscriptionManager { return Ok(()); } - self.update_inscription_status(storage).await?; - self.send_new_inscription_txs(storage).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(()) } 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 @@ -189,17 +191,42 @@ 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(()) + Ok((trusted_balance, balance_with_pending_context)) } async fn send_new_inscription_txs( &mut self, storage: &mut Connection<'_, Core>, + balance_with_pending_context: u128, + trusted_balance: u128, ) -> anyhow::Result<()> { + 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, + max_pending_chain_depth + ); + return Ok(()); + } + + 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 trusted balance guard. trusted={} (with_pending={}) min={}", + trusted_balance, + balance_with_pending_context, + 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..26c652fe9 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 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 cbc01cf90..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,15 +65,17 @@ impl ViaBtcInscriptionManager { return Ok(()); } - self.update_inscription_status_or_resend(storage).await?; - self.send_new_inscription_txs(storage).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(()) } 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 @@ -155,17 +157,42 @@ 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(()) + Ok((trusted_balance, balance_with_pending_context)) } async fn send_new_inscription_txs( &mut self, storage: &mut Connection<'_, Verifier>, + balance_with_pending_context: u128, + trusted_balance: u128, ) -> anyhow::Result<()> { + 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, + max_pending_chain_depth + ); + return Ok(()); + } + + 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 trusted balance guard. trusted={} (with_pending={}) min={}", + trusted_balance, + balance_with_pending_context, + 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 {