Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
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 trusted (confirmed) 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,
}
}
}
182 changes: 174 additions & 8 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,25 @@ impl Inscriber {
Ok(balance)
}

#[instrument(skip(self), target = "bitcoin_inscriber")]
pub async fn get_trusted_balance(&self) -> Result<u128> {
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
}

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 +402,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 +427,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 +471,26 @@ 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 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)
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

The expression std::cmp::min(std::cmp::max(res, min_floor), max_cap) can be simplified by using the clamp method, which is more idiomatic and readable.

Suggested change
std::cmp::min(std::cmp::max(res, min_floor), max_cap)
res.clamp(min_floor, max_cap)

};

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 @@ -609,12 +693,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
)
})?;
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 inconsistency in the error message format. This message is missing a comma between 'Required Amount' and 'Spendable Amount', while a similar error message in prepare_commit_tx_output (line 445) includes it. For consistency in logging and error reporting, it would be good to make them uniform.

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 @@ -849,8 +943,8 @@ mod tests {

use super::*;
use crate::types::{
BitcoinClientResult, BitcoinNetwork, BitcoinSignerResult, InscriptionMessage,
L1BatchDAReferenceInput,
BitcoinClientResult, BitcoinNetwork, BitcoinSignerResult, CommitTxInput, FeePayerCtx,
InscriberOutput, InscriptionMessage, InscriptionRequest, L1BatchDAReferenceInput,
};

mock! {
Expand Down Expand Up @@ -968,6 +1062,21 @@ 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(),
}
}

Expand All @@ -990,4 +1099,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_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,
});
Comment on lines +1132 to +1140
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 new get_fee_rate tests call get_mock_inscriber_and_conditions(), but that helper sets strict mockall expectations for methods like get_network, sign_ecdsa, and sign_schnorr. Since get_fee_rate() doesn't invoke those, the test will likely fail due to unmet expectations when the mock is dropped. Consider relaxing/removing those times(...) constraints in the shared helper or creating a minimal helper specifically for fee-rate tests.

Copilot uses AI. Check for mistakes.

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(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()],
},
});
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

The setup for this test involves creating a large, detailed InscriptionRequest object inline. This makes the test harder to read and maintain. To improve readability and reusability, consider extracting the creation of this test object into a helper function, for example fn dummy_inscription_request() -> InscriptionRequest. You could then call this function to get a test object, making the test logic clearer.


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