Skip to content

smite: add funding transaction construction#74

Merged
morehouse merged 1 commit into
morehouse:masterfrom
NishantBansal2003:funding-tx
May 20, 2026
Merged

smite: add funding transaction construction#74
morehouse merged 1 commit into
morehouse:masterfrom
NishantBansal2003:funding-tx

Conversation

@NishantBansal2003
Copy link
Copy Markdown
Contributor

@NishantBansal2003 NishantBansal2003 commented May 11, 2026

Depends-on: #51 and #73

Added support for creating a funding tx. This requires a few prerequisite RPC calls: get_utxos to fetch spendable inputs for the tx, get_new_address_script_pubkey to generate a change output address script pubkey, and sign_and_broadcast_tx to sign and broadcast the tx. Signing must be handled by bitcoind since it controls the private keys. As part of this, I also implemented a very basic coin selection algorithm with a time complexity of O(N log N).

Since the available spendable inputs may be less than the required funding amount, I added a new InsufficientFunds error type to signal to the caller that there are not enough funds available.

Note that for our use case, the bitcoin-cli -generate, bitcoin-cli listunspent, bitcoin-cli getnewaddress, bitcoin-cli signrawtransactionwithwallet, and bitcoin-cli sendrawtransaction RPC commands should not fail. Because of this, I added panics and asserts for the error conditions to ensure that we catch and fix any edge cases we might have missed if any of these commands ever return an error.

These functions will later be wired into IR operations and the executor to support a v1 funding flow scenario.

For testing, I used this script. Please note that I added a bunch of logging to get the oracle for the unit tests. I removed those (since they were a bit messy), but let me know if they are needed for validation:

I/O
# Case of output ordering
brcli getnewaddress
## bcrt1qv04x6qc8fm29zmh0g6tgd6nf7ll5xy8sdmtwrg
brcli sendtoaddress bcrt1qv04x6qc8fm29zmh0g6tgd6nf7ll5xy8sdmtwrg 0.10010653
## 9f7ab35ebf8cbc57c1099b2ec8c2badb0e6fe636a0bbb1706cabf8d82cba4f80
cargo run
## UTXO: amount=0.10010653 BTC | txid=9f7ab35ebf8cbc57c1099b2ec8c2badb0e6fe636a0bbb1706cabf8d82cba4f80 | vout=1 | script_pubkey=001463ea6d03074ed4516eef469686ea69f7ff4310f0 | privkey=9865bded76c28e6fe38ed09c642a372bfe7869179b975e89bdd06eb3387f6d43
## change_spk=001433ddddc311bb4ac2b23f0880f2f4876443c52c33 | Fee 0.00007290 BTC
## serialize_hex=02000000000101804fba2cd8f8ab6c70b1bba036e66f0edbbac2c82e9b09c157bc8cbf5eb37a9f0100000000ffffffff02df0500000000000016001433ddddc311bb4ac2b23f0880f2f4876443c52c338096980000000000220020c015c4a6be010e21657068fc2e6a9d02b27ebe4d490a25846f7237f104d1a3cd0247304402203c826aa74c3e971cc6bea32210c30b02b8fd288fa4c480d74d6e6f48316074b702207fdc14adedc53f526c10fa4789b1e54a04c79ad8ccacd76c1de7284b6896c6f7012103596e8168073fc881f2022fac98ed600decb43fd141a42d863f21a7c511b3def900000000
## txid=393a79d1b8ac352464e151eeb903260f68e3331d00969c3d1b2d304fece32c73

# Case of No Change
brcli getnewaddress
## bcrt1qmqjyey7wv3csvqxuz0vkvumuff5yw3dn4sevh2
brcli sendtoaddress bcrt1qmqjyey7wv3csvqxuz0vkvumuff5yw3dn4sevh2 0.10008942
## 1261e35df559b1cfb28225f356ef6897496cdeb859aabba54d3e7dc257834937
cargo run
## UTXO: amount=0.10008942 BTC | txid=1261e35df559b1cfb28225f356ef6897496cdeb859aabba54d3e7dc257834937 | vout=0 | script_pubkey=0014d8244c93ce64710600dc13d966737c4a684745b3 | privkey=775b7c755fbdab9572385dac3e2a0a4fb5afda48689df10f4f5cf43e0197f4ce
## change_spk=00142e532c12351a5c81e23c8a76d19345ca7b6de57a | Fee 0.00007290 BTC
## serialize_hex=0200000000010137498357c27d3e4da5bbaa59b8de6c499768ef56f32582b2cfb159f55de361120000000000ffffffff018096980000000000220020c015c4a6be010e21657068fc2e6a9d02b27ebe4d490a25846f7237f104d1a3cd0247304402204b7b97df9655c5bb514f2b2f9e20d1bc90dfbeaaf35b0340832aa54350247cac0220589457afc5e148d2be2a984f8d3a14c58bb7b558c46d6b1f08ab6cff1ca14ca6012102b1b0bf21ad828f6f7ff3b899cd7500a59ef566e4a4c8e893748c37c85b59b7c900000000
## txid=a38288eacddcd549cbad9f000968aad38b24c5f56732e9c9778cf73b824ab9a9
Test script
use std::path::PathBuf;
use std::process::Command;
use std::str::FromStr;

use bitcoin::absolute::LockTime;
use bitcoin::consensus::encode::serialize_hex;
use bitcoin::opcodes::all as opcodes;
use bitcoin::script::Builder;
use bitcoin::secp256k1::PublicKey;
use bitcoin::transaction::predict_weight;
use bitcoin::transaction::InputWeightPrediction;
use bitcoin::transaction::Version;
use bitcoin::{
    Address, Amount, Network, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid,
    Witness,
};

use serde::Deserialize;

/// Connection info for invoking `bitcoin-cli` against the regtest `bitcoind`
/// started by a target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BitcoinCli {
    /// RPC port exposed by the regtest `bitcoind` instance.
    pub rpc_port: u16,
    /// Path passed to `bitcoin-cli -datadir`.
    pub bitcoind_dir: PathBuf,
}

impl BitcoinCli {
    /// Creates a `bitcoin-cli` command preconfigured with the connection
    /// arguments for this regtest node.
    #[must_use]
    pub fn run(&self) -> Command {
        let mut cmd = Command::new("bitcoin-cli");
        cmd.arg("-regtest")
            .arg(format!("-datadir={}", self.bitcoind_dir.display()))
            .arg(format!("-rpcport={}", self.rpc_port))
            .arg("-rpcuser=rpcuser")
            .arg("-rpcpassword=rpcpass");
        cmd
    }

    /// Mines a single block.
    #[allow(clippy::missing_panics_doc)]
    pub fn mine_block(&self) {
        let mine_out = self
            .run()
            .arg("-generate")
            .arg("1")
            .output()
            .expect("bitcoin-cli -generate should not fail");
        assert!(
            mine_out.status.success(),
            "bitcoin-cli -generate failed: {}",
            String::from_utf8_lossy(&mine_out.stderr)
        );
    }

    /// Returns all sorted spendable UTXOs in the wallet.
    #[must_use]
    #[allow(clippy::missing_panics_doc)]
    pub fn get_utxos(&self) -> Vec<(Amount, OutPoint, ScriptBuf)> {
        #[derive(Deserialize)]
        struct UnspentOutput {
            txid: String,
            vout: u32,
            amount: f64,
            #[serde(rename = "scriptPubKey")]
            script_pubkey: String,
            spendable: bool,
        }

        let utxo_out = self
            .run()
            .arg("listunspent")
            .output()
            .expect("bitcoin-cli listunspent should not fail");
        assert!(
            utxo_out.status.success(),
            "bitcoin-cli listunspent failed: {}",
            String::from_utf8_lossy(&utxo_out.stderr)
        );

        let utxos: Vec<UnspentOutput> =
            serde_json::from_slice(&utxo_out.stdout).expect("listunspent should return valid JSON");

        let mut spendable: Vec<(Amount, OutPoint, ScriptBuf)> = utxos
            .into_iter()
            .filter(|u| u.spendable)
            .map(|u| {
                (
                    Amount::from_btc(u.amount).expect("listunspent amount should be valid BTC"),
                    OutPoint::new(
                        Txid::from_str(&u.txid).expect("listunspent should return valid txid"),
                        u.vout,
                    ),
                    ScriptBuf::from(
                        hex::decode(&u.script_pubkey)
                            .expect("listunspent should return valid hex scriptPubKey"),
                    ),
                )
            })
            .collect();
        spendable.sort();

        spendable
    }

    /// Returns the scriptPubKey for a newly generated wallet address.
    #[must_use]
    #[allow(clippy::missing_panics_doc)]
    pub fn get_new_address_script_pubkey(&self) -> ScriptBuf {
        let addr_out = self
            .run()
            .arg("getnewaddress")
            .output()
            .expect("bitcoin-cli getnewaddress should not fail");
        assert!(
            addr_out.status.success(),
            "bitcoin-cli getnewaddress failed: {}",
            String::from_utf8_lossy(&addr_out.stderr)
        );

        let addr_str = String::from_utf8_lossy(&addr_out.stdout);
        Address::from_str(addr_str.trim())
            .and_then(|a| a.require_network(Network::Regtest))
            .expect("getnewaddress should return a valid address")
            .script_pubkey()
    }

    /// Signs and broadcasts a transaction.
    #[allow(clippy::missing_panics_doc)]
    pub fn sign_and_broadcast_tx(&self, tx: &Transaction) {
        #[derive(Deserialize)]
        struct SignRawTransactionResponse {
            hex: String,
            complete: bool,
        }

        let tx_hex = serialize_hex(tx);

        let signed_out = self
            .run()
            .arg("signrawtransactionwithwallet")
            .arg(&tx_hex)
            .output()
            .expect("bitcoin-cli signrawtransactionwithwallet should not fail");
        assert!(
            signed_out.status.success(),
            "bitcoin-cli signrawtransactionwithwallet failed: {}",
            String::from_utf8_lossy(&signed_out.stderr)
        );

        let signed_tx: SignRawTransactionResponse = serde_json::from_slice(&signed_out.stdout)
            .expect("signrawtransactionwithwallet should return valid JSON");
        assert!(
            signed_tx.complete,
            "signrawtransactionwithwallet returned complete=false"
        );

        let broadcast_out = self
            .run()
            .arg("sendrawtransaction")
            .arg(&signed_tx.hex)
            .output()
            .expect("bitcoin-cli sendrawtransaction should not fail");
        assert!(
            broadcast_out.status.success(),
            "bitcoin-cli sendrawtransaction failed: {}",
            String::from_utf8_lossy(&broadcast_out.stderr)
        );

        let broadcast_txid = String::from_utf8_lossy(&broadcast_out.stdout)
            .trim()
            .to_string();
        println!("serialize_hex={}", signed_tx.hex);
        println!("txid={}", broadcast_txid);
    }
}

/// Error returned when available UTXOs cannot cover the funding amount plus estimated miner fee.
#[derive(Debug, thiserror::Error)]
#[error("insufficient funds to cover funding amount and fee")]
pub struct InsufficientFunds;

/// Builds a funding transaction with a 2-of-2 P2WSH output between the opener and acceptor.
///
/// # Errors
///
/// Returns [`InsufficientFunds`] if the provided inputs do not contain enough
/// value to cover `funding_satoshis` and the required transaction fees.
#[allow(clippy::missing_panics_doc)]
pub fn build_funding_transaction(
    opener_funding_pubkey: &PublicKey,
    acceptor_funding_pubkey: &PublicKey,
    funding_satoshis: u64,
    feerate_per_kw: u32,
    utxos: Vec<(Amount, OutPoint, ScriptBuf)>,
    change_spk: ScriptBuf,
) -> Result<(Transaction, u32), InsufficientFunds> {
    let funding_amt = Amount::from_sat(funding_satoshis);
    let funding_script =
        build_funding_redeemscript(opener_funding_pubkey, acceptor_funding_pubkey).to_p2wsh();

    let mut inputs = Vec::new();
    let mut inputs_weight = Vec::new();
    let mut outputs = vec![TxOut {
        value: funding_amt,
        script_pubkey: funding_script.clone(),
    }];
    let mut total = Amount::ZERO;

    for (amount, outpoint, script) in utxos {
        total += amount;

        inputs.push(TxIn {
            previous_output: outpoint,
            script_sig: ScriptBuf::new(),
            sequence: Sequence::MAX,
            witness: Witness::new(),
        });

        // By default, the address we generate coins to is always a P2WPKH address,
        // but to support the BOLT 3 test vectors, we also include P2PKH.
        inputs_weight.push(if script.is_p2pkh() {
            InputWeightPrediction::P2PKH_COMPRESSED_MAX
        } else {
            InputWeightPrediction::P2WPKH_MAX
        });

        // Check whether the selected inputs can cover the funding amount and fees.
        let expected_fee =
            funding_tx_fee_sat(feerate_per_kw, &inputs_weight, &[funding_script.len()]);
        if total >= funding_amt + expected_fee {
            break;
        }
    }

    // Verify that the selected inputs can cover the funding amount and fees.
    let expected_fee = funding_tx_fee_sat(feerate_per_kw, &inputs_weight, &[funding_script.len()]);
    if total < funding_amt + expected_fee {
        return Err(InsufficientFunds);
    }

    // Add remaining funds after accounting for fees as a change output,
    // unless the resulting change would be dust.
    let expected_fee_with_change = funding_tx_fee_sat(
        feerate_per_kw,
        &inputs_weight,
        &[funding_script.len(), change_spk.len()],
    );
    let dust = change_spk.minimal_non_dust();
    if let Some(change) = total
        .checked_sub(funding_amt + expected_fee_with_change)
        .filter(|c| *c >= dust)
    {
        outputs.push(TxOut {
            value: change,
            script_pubkey: change_spk,
        });
    }

    // BIP 69 output ordering: sort by (value, script_pubkey).
    outputs.sort_by(|a, b| {
        a.value
            .cmp(&b.value)
            .then_with(|| a.script_pubkey.as_bytes().cmp(b.script_pubkey.as_bytes()))
    });

    let funding_vout = outputs
        .iter()
        .position(|o| o.script_pubkey == funding_script)
        .and_then(|vout| u32::try_from(vout).ok())
        .expect("funding output always present");

    Ok((
        Transaction {
            version: Version::TWO,
            lock_time: LockTime::ZERO,
            input: inputs,
            output: outputs,
        },
        funding_vout,
    ))
}

/// Get the predicted fee cost of a funding tx in satoshis.
fn funding_tx_fee_sat(
    feerate_per_kw: u32,
    inputs_weight: &[InputWeightPrediction],
    scripts: &[usize],
) -> Amount {
    let weight = predict_weight(inputs_weight.iter().copied(), scripts.iter().copied());
    Amount::from_sat((u64::from(feerate_per_kw) * weight.to_wu()) / 1000)
}

/// Builds the funding output redeem script per BOLT 3.
pub fn build_funding_redeemscript(pubkey1: &PublicKey, pubkey2: &PublicKey) -> ScriptBuf {
    let key1_bytes = pubkey1.serialize();
    let key2_bytes = pubkey2.serialize();
    let (lesser, greater) = if key1_bytes < key2_bytes {
        (&key1_bytes, &key2_bytes)
    } else {
        (&key2_bytes, &key1_bytes)
    };
    Builder::new()
        .push_opcode(opcodes::OP_PUSHNUM_2)
        .push_slice(lesser)
        .push_slice(greater)
        .push_opcode(opcodes::OP_PUSHNUM_2)
        .push_opcode(opcodes::OP_CHECKMULTISIG)
        .into_script()
}

fn pubkey(hex_str: &str) -> PublicKey {
    let bytes = hex::decode(hex_str).expect("valid hex");
    PublicKey::from_slice(&bytes).expect("valid pubkey")
}

fn main() {
    let cli = BitcoinCli {
        rpc_port: 18443,
        bitcoind_dir: PathBuf::from("/home/smite-test/.bitcoin"),
    };

    let utxos = cli.get_utxos();
    let change_spk = cli.get_new_address_script_pubkey();

    let (tx, _vout) = build_funding_transaction(
        &pubkey("023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb"),
        &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"),
        10_000_000,
        15_000,
        utxos,
        change_spk,
    )
    .expect("inputs should cover funding amount and fees");

    cli.sign_and_broadcast_tx(&tx);
    for _i in 1..=6 {
        cli.mine_block();
    }
}

@NishantBansal2003 NishantBansal2003 force-pushed the funding-tx branch 2 times, most recently from 7700d88 to 77e8459 Compare May 12, 2026 06:07
Comment thread smite/src/bitcoin.rs Outdated
Comment thread smite/src/bitcoin.rs Outdated
Comment thread smite/src/bitcoin.rs
Comment thread smite/src/bolt/funding.rs Outdated
Comment thread smite/src/bolt/funding.rs Outdated
Comment thread smite/src/bolt/funding.rs Outdated
Comment thread smite/src/bolt/funding.rs Outdated
Comment thread smite/src/bolt/funding.rs
Comment thread smite/src/bolt/funding.rs
Comment thread smite/src/bolt/funding.rs
@NishantBansal2003 NishantBansal2003 force-pushed the funding-tx branch 4 times, most recently from 852229c to c98cb10 Compare May 16, 2026 08:26
Comment thread smite/src/bitcoin.rs Outdated
Comment thread smite/src/bitcoin.rs
Comment on lines +222 to +231
// Safe because bitcoind descriptor wallets currently default to native
// SegWit, so signing does not alter the txid computed from the unsigned
// Transaction.
let broadcast_txid = String::from_utf8(broadcast_out.stdout)
.expect("sendrawtransaction should return a valid UTF-8 txid");
assert_eq!(
broadcast_txid.trim(),
tx.compute_txid().to_string(),
"sendrawtransaction returned unexpected txid"
);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

If we were to ever provide a transaction to this function that did not have only segwit inputs, this assertion would fail, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, so if the tx has non-segwit or non-taproot inputs i.e legacy inputs, this will fail. I think this is safe, since the current and subsequent versions of Bitcoin Core do not generate legacy addresses by default

Comment thread smite/src/bolt/funding.rs
@@ -0,0 +1,540 @@
//! BOLT 3 funding transaction construction.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I think neither funding.rs nor commitment.rs really belong in the bolt module. I think we should move them to a new tx module in a follow-up PR.

Comment thread smite/src/bolt/funding.rs Outdated
Comment thread smite/src/bolt/funding.rs Outdated
Comment thread smite/src/bolt/funding.rs
Comment thread smite/src/bolt/funding.rs Outdated
Comment thread smite/src/bolt/funding.rs
Comment thread smite/src/bolt/funding.rs
Signed-off-by: Nishant Bansal <nishant.bansal.282003@gmail.com>
Copy link
Copy Markdown
Owner

@morehouse morehouse left a comment

Choose a reason for hiding this comment

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

LGTM

@morehouse morehouse merged commit 5cc7249 into morehouse:master May 20, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants