Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions core/lib/config/src/configs/via_btc_sender.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ pub struct ViaBtcSenderConfig {

/// The required time (seconds) to wait before create a proof inscription.
pub block_time_to_proof: Option<u32>,

/// Hard floor for taproot inscription output to avoid dust / borderline outputs.
pub min_inscription_output_sats: Option<u64>,

/// Hard floor for change output to keep spendable UTXOs healthy.
pub min_change_output_sats: Option<u64>,

/// Minimum fee-rate for non-chained transactions (sat/vB).
pub min_feerate_sat_vb: Option<u64>,

/// Minimum fee-rate when pending inscriptions exist in context (sat/vB).
pub min_chained_feerate_sat_vb: Option<u64>,

/// Maximum fee-rate safety cap (sat/vB).
pub max_feerate_sat_vb: Option<u64>,

/// Max number of pending inscriptions in context before pausing new sends.
pub max_pending_chain_depth: Option<u32>,

/// Do not send new inscriptions when spendable balance goes below this threshold.
pub min_spendable_balance_sats: Option<u64>,
}

impl ViaBtcSenderConfig {
Expand All @@ -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)
}
Comment on lines +85 to +111
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These default policy values are now duplicated across config getters, env defaults, and InscriberPolicy::default(). To reduce drift risk, consider defining the defaults in a single place (e.g., shared constants) and referencing them from both the config getters and the inscriber policy defaults.

Copilot uses AI. Check for mistakes.
}

impl ViaBtcSenderConfig {
Expand All @@ -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,
}
}
}
90 changes: 85 additions & 5 deletions core/lib/via_btc_client/src/inscriber/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +67 to +71
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These default values are also hardcoded in core/lib/config/src/configs/via_btc_sender.rs. Duplicating these constants can lead to inconsistencies if they are updated in one place but not the other.

Consider defining these constants in a single location, for example in the zksync_config crate, and reusing them in both ViaBtcSenderConfig and InscriberPolicy::default(). This would improve maintainability.


#[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<dyn BitcoinOps>,
signer: Arc<dyn BitcoinSigner>,
context: InscriberContext,
policy: InscriberPolicy,
Comment on lines 95 to +99
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the policy field to Inscriber requires updating all Inscriber { ... } struct literals. core/lib/via_btc_client/src/inscriber/test_utils.rs::get_mock_inscriber_and_conditions currently initializes Inscriber without policy, which will not compile. Initialize policy there (e.g., InscriberPolicy::default()), or refactor helpers to construct via Inscriber::new() / with_policy() to avoid future breakage when fields change.

Copilot uses AI. Check for mistakes.
}

impl Inscriber {
Expand All @@ -90,6 +117,7 @@ impl Inscriber {
client,
signer,
context,
policy: InscriberPolicy::default(),
})
}

Expand All @@ -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,
Expand Down Expand Up @@ -355,8 +396,15 @@ impl Inscriber {
inscription_pubkey: ScriptBuf,
) -> Result<CommitTxOutputRes> {
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,
};

Expand All @@ -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)
);
}
Comment on lines +442 to +450
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minimum change-output enforcement for commit/reveal is new behavior that can block sending under low balances. Add unit tests that exercise the below-minimum paths (both commit and reveal) to ensure the failure mode and error details stay stable as fee estimation logic evolves.

Copilot uses AI. Check for mistakes.
Comment on lines 404 to +450
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change introduces new policy behavior (inscription output floor + minimum change output enforcement) but there are no unit tests covering the new boundary conditions (e.g., configured min below dust, insufficient funds for min_change_output_sats, etc.). Since this module already has tests, please add coverage for both the happy-path and the failure cases introduced by these checks.

Copilot uses AI. Check for mistakes.
Comment on lines +442 to +450
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit-tx change-output minimum (min_change_output_sats) introduces a new failure path, but there is no unit test asserting that prepare_commit_tx_output fails when the computed change output is below the policy floor. Adding a targeted test would prevent regressions in the anti-dust guardrail logic.

Copilot uses AI. Check for mistakes.

let commit_tx_change_output = TxOut {
value: commit_tx_change_output_value,
script_pubkey: self.signer.get_p2wpkh_script_pubkey().clone(),
Expand All @@ -405,8 +465,17 @@ impl Inscriber {
async fn get_fee_rate(&self) -> Result<u64> {
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);
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_fee_rate() clamps effective by applying min_floor first and then max_feerate_sat_vb. If max_feerate_sat_vb is configured lower than min_feerate_sat_vb / min_chained_feerate_sat_vb, this logic will silently violate the intended minimum fee floor (and still return a value below the floor). Consider validating the policy invariants (e.g., ensure max_feerate_sat_vb >= min_*) when setting the policy, or adjust the clamping logic to preserve the floor and surface a clear error when the cap is inconsistent.

Suggested change
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 {
// Misconfigured policy: max fee rate is below the minimum floor.
// Preserve the minimum floor and log the inconsistency instead of
// silently returning a value below the 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 {
// Clamp to the configured [min_floor, max_cap] range.
std::cmp::min(std::cmp::max(res, min_floor), max_cap)
};

Copilot uses AI. Check for mistakes.

debug!("Fee rate obtained: {}, effective: {}", res, effective);
Ok(std::cmp::max(effective, 1))
Comment on lines 470 to +492
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New policy behavior is introduced here (fee floor/cap selection), but the unit tests in this module still only cover the happy-path inscribe() flow. Since this file already has a test harness, consider adding targeted tests asserting get_fee_rate() applies the configured floors/caps for empty vs non-empty context (and respects the cap).

Copilot uses AI. Check for mistakes.
}

#[instrument(skip(self, input, output), target = "bitcoin_inscriber")]
Expand Down Expand Up @@ -615,6 +684,16 @@ impl Inscriber {
)
})?;
Comment on lines 694 to 699
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new error string has inconsistent punctuation/spacing (missing comma between fields and trailing space), which makes log/error parsing harder. Please standardize the formatting of the "Required Amount" / "Spendable Amount" messages (e.g., consistent separators and no trailing whitespace).

Copilot uses AI. Check for mistakes.

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 {:?}",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There's a minor typo in the error message format string. A space is missing after Required Amount:, which could affect log parsing and readability.

Suggested change
"Required Amount:{:?} Spendable Amount: {:?}. reveal change output {:?} is below minimum {:?}",
"Required Amount: {:?} Spendable Amount: {:?}. reveal change output {:?} is below minimum {:?}",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There's a minor typo in the error message string. A space is missing after Amount:. For consistency with other error messages in this file, it should be Required Amount: {:?}.

Suggested change
"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,
Amount::from_sat(self.policy.min_change_output_sats)
);
Comment on lines +702 to +708
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new reveal change-output minimum enforcement introduces an error message with inconsistent formatting (missing spaces after Amount: / between clauses), which makes logs harder to scan during incidents. Consider normalizing this message format (e.g., consistent spacing/punctuation and casing) with the other sender errors.

Copilot uses AI. Check for mistakes.
Comment on lines +702 to +708
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo/formatting in this error message: Required Amount:{:?} is missing a space after the colon, and the required/spendable fields are formatted differently than the commit-tx errors. Please standardize the message formatting for consistency and readability.

Copilot uses AI. Check for mistakes.
}
Comment on lines +701 to +709
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

min_change_output_sats enforcement for the reveal tx is new behavior and should have a dedicated unit test to ensure it fails fast when the computed change is below the configured floor (and that it does not regress when fee calculation changes).

Copilot uses AI. Check for mistakes.
Comment on lines +701 to +709
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new reveal-tx change-output minimum (min_change_output_sats) introduces a new failure path, but there is no unit test asserting that prepare_reveal_tx_output fails when the computed change output is below the policy floor. Adding a targeted test would prevent regressions in the anti-dust guardrail logic.

Copilot uses AI. Check for mistakes.

// Change output goes back to the inscriber
let reveal_tx_change_output = TxOut {
value: reveal_change_amount,
Expand Down Expand Up @@ -968,6 +1047,7 @@ mod tests {
client: Arc::new(client),
signer: Arc::new(signer),
context,
policy: InscriberPolicy::default(),
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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(),
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This block of code for creating InscriberPolicy from ViaBtcSenderConfig is duplicated across three files:

  • core/node/node_framework/src/implementations/layers/via_btc_sender/aggregator.rs
  • core/node/node_framework/src/implementations/layers/via_btc_sender/manager.rs
  • core/node/node_framework/src/implementations/layers/via_btc_sender/vote_manager.rs

To improve maintainability and reduce duplication, consider adding a helper method to ViaBtcSenderConfig to create the InscriberPolicy.

For example, in core/lib/config/src/configs/via_btc_sender.rs:

use via_btc_client::inscriber::InscriberPolicy;

// ... inside impl ViaBtcSenderConfig
pub fn to_inscriber_policy(&self) -> InscriberPolicy {
    InscriberPolicy {
        min_inscription_output_sats: self.min_inscription_output_sats(),
        min_change_output_sats: self.min_change_output_sats(),
        min_feerate_sat_vb: self.min_feerate_sat_vb(),
        min_chained_feerate_sat_vb: self.min_chained_feerate_sat_vb(),
        max_feerate_sat_vb: self.max_feerate_sat_vb(),
    }
}

Then you can simplify the wiring layers to:

let inscriber_policy = self.config.to_inscriber_policy();


let inscriber = Inscriber::new(client, &self.wallet.private_key, None)
.await
.unwrap();
.unwrap()
.with_policy(inscriber_policy);
Comment on lines 75 to +78
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wiring layer still uses unwrap() on Inscriber::new(...). If inscriber initialization fails (e.g., invalid key/network config), the node will panic during wiring rather than returning a structured WiringError with context. Prefer propagating the error (?) and adding context (similar to the other via_btc_sender layers) so startup failures are diagnosable and don't crash the process abruptly.

Copilot uses AI. Check for mistakes.

let via_btc_inscription_aggregator =
ViaBtcInscriptionAggregator::new(inscriber, master_pool, self.config).await?;
Expand Down
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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)
Comment on lines 79 to 80
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ViaBtcInscriptionManager::new(...) is awaited and then unwrapped a couple of lines below, which can panic the node during wiring (e.g., if config/env issues make initialization fail). Since this wire() returns Result<_, WiringError>, propagate the error instead of unwrapping so startup failures are reported gracefully.

Copilot uses AI. Check for mistakes.
Expand Down
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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)
Comment on lines 79 to 80
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ViaBtcInscriptionManager::new(...) is awaited and then unwrapped a couple of lines below, which can panic the node during wiring (e.g., if config/env issues make initialization fail). Since this wire() returns Result<_, WiringError>, propagate the error instead of unwrapping so startup failures are reported gracefully.

Copilot uses AI. Check for mistakes.
Expand Down
22 changes: 22 additions & 0 deletions core/node/via_btc_sender/src/btc_inscription_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says the pending-chain guard should trigger when depth exceeds the configured max, but the code blocks when pending_chain_depth >= max_pending_chain_depth(). Either update the comparison to > or adjust the config docs/description to clarify that reaching the max is considered blocked.

Copilot uses AI. Check for mistakes.
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();
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spendable_balance is sourced from Inscriber::get_balance(), which explicitly adds outputs from the in-memory pending context (i.e., unconfirmed / not-yet-spendable change). Using this value for the min_spendable_balance_sats guard can let the sender proceed even when the actually spendable UTXO set is empty (the failure mode this guard is meant to prevent). Consider either computing a true spendable balance from the filtered UTXO set (similar to prepare_commit_tx_input), or rename the config/variable to reflect that it is not strictly spendable.

Copilot uses AI. Check for mistakes.
tracing::warn!(
"Skipping new inscription broadcast due to low spendable balance guard. spendable={} min={}",
spendable_balance,
self.config.min_spendable_balance_sats()
);
return Ok(());
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

send_new_inscription_txs() calls self.inscriber.get_balance() even though the same loop iteration already calls get_balance() in update_inscription_status() for the balance gauge. This adds an extra Bitcoin RPC + tx deserialization pass every poll interval and can become a noticeable overhead (and additional failure surface) under load. Consider computing the spendable balance once per loop iteration (e.g., in loop_iteration), passing it into both methods, or caching it on the manager for the current tick.

Copilot uses AI. Check for mistakes.
Comment on lines +219 to +228
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new low-trusted-balance guard is behavior-changing but currently isn’t covered by the existing manager tests. Please add a test that sets a low mock confirmed balance and a higher min_spendable_balance_sats and asserts the manager skips broadcasting and increments chain_guard_blocks.

Copilot uses AI. Check for mistakes.

let number_inflight_txs = storage
.btc_sender_dal()
.list_inflight_inscription_ids()
Expand Down
3 changes: 3 additions & 0 deletions core/node/via_btc_sender/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlockNumberVariant, Gauge<usize>>,

Expand Down
16 changes: 15 additions & 1 deletion etc/env/base/via_btc_sender.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
Loading
Loading