Skip to content
Open
Changes from 2 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e2a788c
feat(btc-client): implement Largest-First UTXO selection
romanornr Dec 28, 2025
b6a22d7
refactor(btc-client): optimize select_utxos function
romanornr Dec 30, 2025
91f0103
fix(via_btc_client): add btc inscription output safety floors
romanornr Mar 24, 2026
2bc96f7
fix(via_btc_client): stop reusing unconfirmed change outputs
romanornr Mar 24, 2026
7019c70
feat(via_btc_sender): add config-backed spendability guardrails
romanornr Mar 24, 2026
526fc71
fix(via_btc_client): repair config-backed inscriber build
romanornr Mar 24, 2026
403f38a
feat(via_btc_sender): add adaptive fee policy scaffolding
romanornr Mar 24, 2026
8311aee
feat(via_btc_sender): add stuck inscription rebroadcast path
romanornr Mar 24, 2026
10ed39a
fix(via_btc_sender): address review feedback on policy and retries
romanornr Mar 24, 2026
293e6bc
refactor(via_btc_sender): align policy plumbing with review feedback
romanornr Mar 24, 2026
a351a10
fix(via_btc_client): fall back to full utxo set when needed
romanornr Mar 24, 2026
93ed235
fix(via_btc_sender): address latest copilot feedback
romanornr Mar 24, 2026
459f28c
fix(via_btc_client): tighten policy validation and tests
romanornr Mar 24, 2026
13530a5
refactor(via_btc_client): simplify latest review fixes
romanornr Mar 25, 2026
73bf931
test(via_btc_client): fix truncated utxo fallback coverage
romanornr Mar 25, 2026
6327233
test(via_btc_client): make fallback selection test robust
romanornr Mar 25, 2026
372ea0a
feat(via_btc_sender): add sender chain guardrails
romanornr Mar 25, 2026
560c30f
fix(via_btc_client): tighten selection target and chain guards
romanornr Mar 25, 2026
d819d6e
refactor(via_btc_client): reuse policy constants in defaults
romanornr Mar 25, 2026
6a45737
refactor(via_btc_client): simplify fee candidate calculation
romanornr Mar 25, 2026
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
166 changes: 158 additions & 8 deletions core/lib/via_btc_client/src/inscriber/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,83 @@ const BROADCAST_RETRY_COUNT: u32 = 3;
// https://bitcointalk.org/index.php?topic=5453107.msg62262343#msg62262343
const P2TR_DUST_LIMIT: Amount = Amount::from_sat(330);

/// Minimum buffer for change output to ensure Reveal TX can be funded.
/// This accounts for Reveal TX fees plus safety margin.
const MIN_CHANGE_BUFFER: Amount = Amount::from_sat(10_000);

/// Maximum number of UTXOs to consider for selection (performance reasoning)
const MAX_UTXOS_TO_CONSIDER: usize = 100;

/// Calculates the minimum target amount needed for UTXO selection.
/// This includes: Commit TX fee (estimated), P2TR dust output, and minimum change buffer.
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 comment on MIN_CHANGE_BUFFER / calculate_selection_target says it “accounts for Reveal TX fees”, but the buffer is a fixed 10_000 sats and does not scale with fee rate, inscription script size, pending-tx incentive, or optional recipient output (all of which directly affect prepare_reveal_tx_output fee). This can still lead to Reveal TX funding failures at higher fee rates / larger inscriptions. Either (a) compute a buffer from an estimated Reveal TX fee (likely requires passing inscription_data.script_size and recipient info into selection), or (b) adjust the comment to avoid implying the Reveal TX fees are actually covered by this constant.

Suggested change
/// Minimum buffer for change output to ensure Reveal TX can be funded.
/// This accounts for Reveal TX fees plus safety margin.
const MIN_CHANGE_BUFFER: Amount = Amount::from_sat(10_000);
/// Maximum number of UTXOs to consider for selection (performance reasoning)
const MAX_UTXOS_TO_CONSIDER: usize = 100;
/// Calculates the minimum target amount needed for UTXO selection.
/// This includes: Commit TX fee (estimated), P2TR dust output, and minimum change buffer.
/// Minimum fixed buffer for change output used as a safety margin when funding
/// subsequent transactions (e.g. Reveal TX). Note: this does **not** dynamically
/// scale with fee rate or inscription size and therefore does not guarantee
/// Reveal TX fees are fully covered under all conditions.
const MIN_CHANGE_BUFFER: Amount = Amount::from_sat(10_000);
/// Maximum number of UTXOs to consider for selection (performance reasoning)
const MAX_UTXOS_TO_CONSIDER: usize = 100;
/// Calculates the minimum target amount needed for UTXO selection.
/// This includes: Commit TX fee (estimated), P2TR dust output, and a static
/// safety buffer (`MIN_CHANGE_BUFFER`). It does **not** explicitly model
/// Reveal TX fees; those must still be affordable from the remaining funds.

Copilot uses AI. Check for mistakes.
fn calculate_selection_target(input_count: u32, fee_rate: u64) -> Result<Amount> {
let commit_fee = InscriberFeeCalculator::estimate_fee(
input_count,
COMMIT_TX_P2TR_INPUT_COUNT,
COMMIT_TX_P2WPKH_OUTPUT_COUNT,
COMMIT_TX_P2TR_OUTPUT_COUNT,
vec![],
fee_rate,
)?;

let target = commit_fee
.checked_add(P2TR_DUST_LIMIT)
.and_then(|v| v.checked_add(MIN_CHANGE_BUFFER))
.ok_or_else(|| anyhow::anyhow!("Target amount overflow"))?;
Ok(target)
}

/// Selects UTXOs using Largest-First: sorts by value descending, picks until target met.
/// Dynamically recalculates fees as inputs are added. Ensures change for Reveal TX.
fn select_utxos(
mut utxos: Vec<(OutPoint, TxOut)>,
fee_rate: u64,
) -> Result<(Vec<(OutPoint, TxOut)>, Amount)> {
if utxos.is_empty() {
return Err(anyhow::anyhow!("No UTXOs available for selection"));
}

// Sort by value descending (largest first)
utxos.sort_by(|a, b| b.1.value.cmp(&a.1.value));

// Limit UTXOs to consider for performance
utxos.truncate(MAX_UTXOS_TO_CONSIDER);

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.

utxos.truncate(MAX_UTXOS_TO_CONSIDER) can produce a false "Insufficient funds" error when the wallet has >100 UTXOs: if the sum of the top 100 UTXOs is below the target but the overall balance is sufficient, selection will fail even though a valid solution exists. Consider removing the hard cap, or implementing a fallback that expands the candidate set when the target isn't met (e.g., progressively increase the limit / scan all UTXOs in worst case).

Suggested change
// Limit UTXOs to consider for performance
utxos.truncate(MAX_UTXOS_TO_CONSIDER);

Copilot uses AI. Check for mistakes.
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.

Truncating the candidate UTXO list to MAX_UTXOS_TO_CONSIDER can produce a false "Insufficient funds" error when the wallet has >100 UTXOs and the (discarded) smaller UTXOs are needed to reach the target. If you need a performance guard, consider only applying a limit after confirming the remaining UTXOs can still meet the target, or iterating all UTXOs but short-circuiting once the target is met.

Suggested change
// Limit UTXOs to consider for performance
utxos.truncate(MAX_UTXOS_TO_CONSIDER);

Copilot uses AI. Check for mistakes.
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.

select_utxos() truncates the UTXO set to MAX_UTXOS_TO_CONSIDER after sorting. If the wallet has >100 UTXOs and the largest 100 don’t cover the target, this will return Insufficient funds even when the remaining smaller UTXOs would make the balance sufficient. Consider either removing the truncation, or only applying it after first verifying that the truncated set still contains enough total value (and otherwise falling back to selecting from the full set).

Suggested change
// Limit UTXOs to consider for performance
utxos.truncate(MAX_UTXOS_TO_CONSIDER);

Copilot uses AI. Check for mistakes.
let mut selected: Vec<(OutPoint, TxOut)> = Vec::new();
let mut total_value = Amount::ZERO;

for (outpoint, txout) in utxos {
let value = txout.value;
selected.push((outpoint, txout));
total_value = total_value
.checked_add(value)
.ok_or_else(|| anyhow::anyhow!("Total value overflow"))?;

// Calculate target with current input count
let input_count = selected.len() as u32;
let target = calculate_selection_target(input_count, fee_rate)?;

// Check if we have enough
if total_value >= target {
debug!(
"UTXO selection complete: {} inputs, {} sats, target {} sats",
input_count,
total_value.to_sat(),
target.to_sat()
);
return Ok((selected, total_value));
}
}

// If we get here, we've used all UTXOs but still don't have enough.
let final_target = calculate_selection_target(selected.len() as u32, fee_rate)?;
Err(anyhow::anyhow!(
"Insufficient funds: have {} sats, need {} sats",
total_value.to_sat(),
final_target.to_sat()
))
}

#[derive(Debug)]
pub struct Inscriber {
client: Arc<dyn BitcoinOps>,
Expand Down Expand Up @@ -226,10 +303,6 @@ impl Inscriber {
#[instrument(skip(self), target = "bitcoin_inscriber")]
async fn prepare_commit_tx_input(&self) -> Result<CommitTxInputRes> {
debug!("Preparing commit transaction input");
let mut commit_tx_inputs: Vec<TxIn> = Vec::new();
let mut unlocked_value: Amount = Amount::ZERO;
let mut inputs_count: u32 = 0;
let mut utxo_amounts: Vec<Amount> = Vec::new();

let address_ref = &self.signer.get_p2wpkh_address()?;
let mut utxos = self.client.fetch_utxos(address_ref).await?;
Expand Down Expand Up @@ -312,7 +385,17 @@ impl Inscriber {
}
}

for (outpoint, txout) in utxos {
// Get fee rate for selection calculation
let fee_rate = self.get_fee_rate().await?;

// Select optimal UTXOs using the Largest-First selection algorithm
let (selected_utxos, unlocked_value) = select_utxos(utxos, fee_rate)?;
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.

prepare_commit_tx_input selects UTXOs using a fee rate fetched here, but prepare_commit_tx_output fetches the fee rate again later. If the fee rate increases between these calls, selection may succeed but output construction can still fail (or produce much smaller change than intended) because the actual commit fee used later is higher than the fee assumed during selection. Consider fetching the fee rate once and threading it through (e.g., store it in CommitTxInputRes or pass it into prepare_commit_tx_output) to keep selection and fee calculation consistent.

Copilot uses AI. Check for mistakes.

Comment on lines +636 to +647
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.

prepare_commit_tx_input() now fetches a fee rate for UTXO selection, but prepare_commit_tx_output() and prepare_reveal_tx_output() fetch again later. This adds extra RPC calls and can make selection inconsistent with fee estimation if the network fee changes between calls (selection may pick too few inputs and output construction can then fail). Consider computing the effective fee rate once per inscription flow and threading it through (e.g., store it in CommitTxInputRes / InscriberInfo) so selection + fee calculations use the same value.

Copilot uses AI. Check for mistakes.
// Build transaction inputs from selected UTXOs
let mut commit_tx_inputs: Vec<TxIn> = Vec::new();
let mut utxo_amounts: Vec<Amount> = Vec::new();

for (outpoint, txout) in selected_utxos {
let txin = TxIn {
previous_output: outpoint,
script_sig: ScriptBuf::default(), // For a p2wpkh script_sig is empty.
Expand All @@ -321,12 +404,15 @@ impl Inscriber {
};

commit_tx_inputs.push(txin);
unlocked_value += txout.value;
inputs_count += 1;
utxo_amounts.push(txout.value);
}

debug!("Commit transaction input prepared");
let inputs_count = commit_tx_inputs.len() as u32;
debug!(
"Commit transaction input prepared: {} inputs, {} sats",
inputs_count,
unlocked_value.to_sat()
);

let res = CommitTxInputRes {
commit_tx_inputs,
Expand Down Expand Up @@ -853,6 +939,70 @@ mod tests {
L1BatchDAReferenceInput,
};

#[test]
fn test_select_utxos_largest_first() {
let script_pubkey = ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::all_zeros());

let utxos = vec![
(
OutPoint { txid: Txid::all_zeros(), vout: 0 },
TxOut { value: Amount::from_sat(1_000), script_pubkey: script_pubkey.clone() },
),
(
OutPoint { txid: Txid::all_zeros(), vout: 1 },
TxOut { value: Amount::from_sat(50_000), script_pubkey: script_pubkey.clone() },
),
(
OutPoint { txid: Txid::all_zeros(), vout: 2 },
TxOut { value: Amount::from_sat(10_000), script_pubkey: script_pubkey.clone() },
),
(
OutPoint { txid: Txid::all_zeros(), vout: 3 },
TxOut { value: Amount::from_sat(100_000), script_pubkey: script_pubkey.clone() },
),
];

// Select with a low fee rate - should prefer largest UTXOs
let (selected, total) = select_utxos(utxos, 1).unwrap();

// Verify largest first ordering - first selected should be 100k sats
assert_eq!(selected[0].1.value, Amount::from_sat(100_000));
assert!(total >= Amount::from_sat(100_000));
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.

test_select_utxos_largest_first() only asserts that the first selected UTXO is the largest and that total >= 100_000, but it doesn't verify that selection stops as soon as the target is met (i.e., that it doesn't unnecessarily select extra inputs). Consider asserting selected.len() == 1 for this input set, or comparing total against calculate_selection_target(1, fee_rate) and asserting that adding the next-largest UTXO would be unnecessary.

Suggested change
assert!(total >= Amount::from_sat(100_000));
// Ensure selection stops as soon as the target is met (no unnecessary inputs)
assert_eq!(selected.len(), 1);
assert_eq!(total, Amount::from_sat(100_000));

Copilot uses AI. Check for mistakes.
}

#[test]
fn test_select_utxos_insufficient_funds() {
let script_pubkey = ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::all_zeros());

let utxos = vec![
(
OutPoint { txid: Txid::all_zeros(), vout: 0 },
TxOut { value: Amount::from_sat(100), script_pubkey: script_pubkey.clone() },
),
];

let result = select_utxos(utxos, 10);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Insufficient funds"));
}

#[test]
fn test_select_utxos_empty() {
let utxos: Vec<(OutPoint, TxOut)> = vec![];
let result = select_utxos(utxos, 10);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No UTXOs available"));
}

#[test]
fn test_calculate_selection_target() {
let target = calculate_selection_target(1, 10).unwrap();

// Target = commit fee + dust (330) + buffer (10000), should be > 10330 sats
assert!(target.to_sat() > 10_330);
assert!(target.to_sat() < 1_000_000);
}

mock! {
BitcoinOps {}
#[async_trait]
Expand Down
Loading