Skip to main content

urma_runtime/
bitcoin_rpc.rs

1use crate::commitment::PreparedReveal;
2use crate::config::Limits;
3use crate::config::MempoolPresence;
4use crate::config::{BITCOIN_MAX_FEE_RATE, BITCOIN_MAX_FEE_SATS, BITCOIN_RETURN_SATS};
5use crate::config::{RevealSelection, RpcScope, TxPresence};
6use crate::error::{Context, Error, bail, ensure};
7use crate::journal::decode_transaction;
8use crate::journal::{Plan, validate_plan};
9use crate::transaction::{decode, transaction};
10use crate::{
11    backend::{self, Evidence, Family, Locator, Observation, RecordSource},
12    container::{self, PrivateObject},
13};
14use bitcoin::{
15    Address, Amount, Block, BlockHash, Network, OutPoint, Transaction, TxOut, Txid, Witness,
16    consensus::{deserialize, serialize},
17    hashes::Hash,
18};
19use bitcoincore_rpc::{Auth, Client, RpcApi};
20use rand::rngs::OsRng;
21use serde_json::{Value, json};
22use std::{collections::BTreeMap, path::Path, str::FromStr};
23use urma_chain::validation;
24use urma_core::envelope;
25use urma_core::format::Urma;
26use urma_wallet::funding::Funding;
27
28pub struct Node {
29    rpc: Client,
30    network: Network,
31}
32
33impl Node {
34    pub fn connect(url: &str, cookie: &Path, wallet: RpcScope<'_>) -> Result<Self, Error> {
35        let mut endpoint = url.trim_end_matches('/').to_owned();
36        if let RpcScope::Wallet(wallet) = wallet {
37            ensure!(
38                !wallet.is_empty()
39                    && wallet
40                        .bytes()
41                        .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
42                "wallet name must contain only letters, numbers, '-' or '_'"
43            );
44            endpoint.push_str(&format!("/wallet/{wallet}"));
45        }
46        let rpc = Client::new(&endpoint, Auth::CookieFile(cookie.to_path_buf()))?;
47        let info: Value = rpc.call("getblockchaininfo", &[])?;
48        let network = supported_network(info["chain"].as_str().context("missing node chain")?)?;
49        Ok(Self { rpc, network })
50    }
51
52    pub fn connect_for_network(
53        url: &str,
54        cookie: &Path,
55        wallet: RpcScope<'_>,
56        network: Network,
57    ) -> Result<Self, Error> {
58        ensure!(
59            matches!(network, Network::Regtest | Network::Testnet4),
60            "only regtest and testnet4 are supported"
61        );
62        let node = Self::connect(url, cookie, wallet)?;
63        ensure!(
64            node.network == network,
65            "RPC node network differs from requested network"
66        );
67        Ok(node)
68    }
69
70    pub fn network(&self) -> Network {
71        self.network
72    }
73
74    pub fn call(&self, method: &str, args: &[Value]) -> Result<Value, Error> {
75        self.rpc
76            .call(method, args)
77            .with_context(|| format!("Bitcoin RPC {method}"))
78    }
79
80    fn height(&self) -> Result<u64, Error> {
81        self.call("getblockcount", &[])?
82            .as_u64()
83            .context("invalid block count")
84    }
85
86    fn block_hash(&self, height: u64) -> Result<BlockHash, Error> {
87        let value = self.call("getblockhash", &[json!(height)])?;
88        BlockHash::from_str(value.as_str().context("invalid block hash")?).map_err(Into::into)
89    }
90
91    fn address(&self) -> Result<Address, Error> {
92        let value = self.call("getnewaddress", &[json!("urma-lab"), json!("bech32m")])?;
93        Ok(
94            Address::from_str(value.as_str().context("invalid address")?)?
95                .require_network(self.network)?,
96        )
97    }
98
99    fn require_owned(&self, address: &Address) -> Result<(), Error> {
100        let info = self.call("getaddressinfo", &[json!(address.to_string())])?;
101        ensure!(
102            info["ismine"] == true && info["solvable"] == true,
103            "wallet does not own and solve address {address}"
104        );
105        Ok(())
106    }
107
108    fn confirmations(&self, txid: Txid) -> Result<TxPresence, Error> {
109        match self
110            .rpc
111            .call::<Value>("gettransaction", &[json!(txid.to_string())])
112        {
113            Ok(value) => Ok(TxPresence::Observed(
114                value["confirmations"]
115                    .as_i64()
116                    .context("missing confirmations")?,
117            )),
118            Err(bitcoincore_rpc::Error::JsonRpc(bitcoincore_rpc::jsonrpc::error::Error::Rpc(
119                e,
120            ))) if e.code == -5 => {
121                tracing::warn!(code = e.code, "transaction absent");
122                Ok(TxPresence::Missing)
123            }
124            Err(error) => Err(error.into()),
125        }
126    }
127
128    fn submit(&self, raw: &str) -> Result<Txid, Error> {
129        let tx = decode(raw, raw.len())?;
130        let txid = tx.compute_txid();
131        match self.confirmations(txid)? {
132            TxPresence::Observed(n) if n < 0 => {
133                bail!("transaction {txid} conflicts with the active chain")
134            }
135            TxPresence::Observed(n) if n > 0 => return Ok(txid),
136            TxPresence::Observed(count) => ensure!(count == 0, "invalid confirmation count"),
137            TxPresence::Missing => (),
138        }
139        match self
140            .rpc
141            .call::<Value>("getmempoolentry", &[json!(txid.to_string())])
142        {
143            Ok(entry) => {
144                ensure!(entry.is_object(), "invalid mempool entry");
145                return Ok(txid);
146            }
147            Err(bitcoincore_rpc::Error::JsonRpc(bitcoincore_rpc::jsonrpc::error::Error::Rpc(
148                e,
149            ))) if e.code == -5 => {
150                tracing::warn!(code = e.code, "transaction absent from mempool");
151            }
152            Err(error) => return Err(error.into()),
153        }
154        let acceptance = self.call("testmempoolaccept", &[json!([raw])])?;
155        ensure!(
156            acceptance[0]["allowed"] == true,
157            "policy rejected {txid}: {}",
158            acceptance[0]
159        );
160        let result = self.call("sendrawtransaction", &[json!(raw)])?;
161        ensure!(
162            result == txid.to_string(),
163            "broadcast returned a different transaction ID"
164        );
165        Ok(txid)
166    }
167
168    fn mine(&self, destination: &str) -> Result<(), Error> {
169        ensure!(
170            self.network == Network::Regtest,
171            "mining is restricted to regtest"
172        );
173        self.call("generatetoaddress", &[json!(1), json!(destination)])?;
174        Ok(())
175    }
176}
177
178pub(crate) fn supported_network(network: &str) -> Result<Network, Error> {
179    match network {
180        "regtest" => Ok(Network::Regtest),
181        "testnet4" => Ok(Network::Testnet4),
182        _ => bail!("only regtest and testnet4 are supported"),
183    }
184}
185
186pub fn prepare(node: &Node, records: &[Vec<u8>], input_bytes: usize) -> Result<Plan, Error> {
187    ensure!(
188        node.network == Network::Regtest,
189        "testnet4 preparation requires explicit funding"
190    );
191    let unspent = node.call("listunspent", &[json!(1)])?;
192    let coins = unspent.as_array().context("invalid unspent response")?;
193    let mut eligible = Vec::new();
194    for coin in coins {
195        if !coin["spendable"]
196            .as_bool()
197            .context("missing spendable flag")?
198        {
199            continue;
200        }
201        let script = coin["scriptPubKey"]
202            .as_str()
203            .context("missing coin script")?;
204        if !script.starts_with("0014") {
205            continue;
206        }
207        let amount = Amount::from_btc(coin["amount"].as_f64().context("missing coin amount")?)?;
208        eligible.push((amount, coin));
209    }
210    let selected = eligible
211        .iter()
212        .max_by_key(|(amount, coin)| (*amount, coin["txid"].to_string()))
213        .context("wallet has no confirmed spendable native P2WPKH coin")?
214        .1;
215    let previous = node.call("gettransaction", &[selected["txid"].clone()])?;
216    let funding = Funding {
217        raw_transaction: previous["hex"]
218            .as_str()
219            .context("missing funding transaction")?
220            .to_owned(),
221        vout: u32::try_from(selected["vout"].as_u64().context("missing funding vout")?)?,
222    };
223    prepare_with_funding(node, records, input_bytes, &funding, 1)
224}
225
226pub fn prepare_with_funding(
227    node: &Node,
228    records: &[Vec<u8>],
229    input_bytes: usize,
230    funding: &Funding,
231    fee_rate: u64,
232) -> Result<Plan, Error> {
233    validate_preparation(records, input_bytes, fee_rate)?;
234    let (funding_outpoint, funding_output) = funding.prevout()?;
235    let funding_address = Address::from_script(&funding_output.script_pubkey, node.network)?;
236    node.require_owned(&funding_address)?;
237    let start_height = node.height()?;
238    let return_address = node.address()?;
239    node.require_owned(&return_address)?;
240    let mut pending = Vec::with_capacity(records.len());
241    for record in records {
242        pending.push(PreparedReveal::new(
243            record,
244            &return_address.script_pubkey(),
245            fee_rate,
246            BITCOIN_RETURN_SATS,
247            &mut OsRng,
248        )?);
249    }
250    let outputs: Vec<TxOut> = pending.iter().map(PreparedReveal::output).collect();
251    let reveal_fee_sats: u64 = pending.iter().map(|item| item.fee).sum();
252    let (commit_tx, commit_fee_sats) = fund_commit(
253        funding_outpoint,
254        &funding_output,
255        &return_address,
256        outputs,
257        fee_rate,
258        reveal_fee_sats,
259    )?;
260    let commit_tx = sign_commit(node, &commit_tx, funding_outpoint, &funding_output)?;
261    ensure!(
262        commit_fee_sats >= u64::try_from(commit_tx.vsize())? * fee_rate,
263        "signed commit exceeds fee estimate"
264    );
265    let mut reveals = Vec::with_capacity(pending.len());
266    for (index, prepared) in pending.into_iter().enumerate() {
267        reveals.push(hex::encode(serialize(&prepared.sign(
268            &commit_tx,
269            u32::try_from(index)?,
270            &return_address.script_pubkey(),
271        )?)));
272    }
273    let plan = Plan {
274        version: Urma::VERSION,
275        network: node.network.to_string(),
276        object_id: hex::encode(container::inspect_header(&records[0])?.id),
277        start_height,
278        input_bytes,
279        fee_sats: commit_fee_sats + reveal_fee_sats,
280        commit_fee_sats,
281        reveal_fee_sats,
282        fee_rate_sat_vb: fee_rate,
283        funding: funding.clone(),
284        commit: hex::encode(serialize(&commit_tx)),
285        reveals,
286        mining_address: return_address.to_string(),
287    };
288    validate_plan(&plan)?;
289    Ok(plan)
290}
291
292fn validate_plan_for_node(node: &Node, plan: &Plan) -> Result<(), Error> {
293    validate_plan(plan)?;
294    ensure!(
295        node.network == supported_network(&plan.network)?,
296        "journal and RPC networks differ"
297    );
298    let payout = Address::from_str(&plan.mining_address)?.require_network(node.network)?;
299    node.require_owned(&payout)?;
300    let (_, previous) = plan.funding.prevout()?;
301    node.require_owned(&Address::from_script(
302        &previous.script_pubkey,
303        node.network,
304    )?)?;
305    Ok(())
306}
307
308fn transaction_status(node: &Node, raw: &str) -> Result<Value, Error> {
309    let id = decode_transaction(raw)?.compute_txid();
310    let confirmations = node.confirmations(id)?;
311    let in_mempool = match node
312        .rpc
313        .call::<Value>("getmempoolentry", &[json!(id.to_string())])
314    {
315        Ok(entry) => {
316            ensure!(entry.is_object(), "invalid mempool entry");
317            MempoolPresence::Present
318        }
319        Err(bitcoincore_rpc::Error::JsonRpc(bitcoincore_rpc::jsonrpc::error::Error::Rpc(
320            error,
321        ))) if error.code == -5 => {
322            tracing::warn!(?error, "transaction absent from mempool");
323            MempoolPresence::Absent
324        }
325        Err(error) => return Err(error.into()),
326    };
327    let status = match (confirmations, in_mempool) {
328        (TxPresence::Observed(n), _) if n < 0 => "conflicted",
329        (TxPresence::Observed(n), _) if n > 0 => "confirmed",
330        (_, MempoolPresence::Present) => "mempool",
331        (TxPresence::Observed(0), MempoolPresence::Absent) => "unconfirmed_not_in_mempool",
332        (TxPresence::Observed(n), MempoolPresence::Absent) => bail!("unexpected confirmations {n}"),
333        (TxPresence::Missing, MempoolPresence::Absent) => "unknown",
334    };
335    let count = match confirmations {
336        TxPresence::Missing => Value::Null,
337        TxPresence::Observed(n) => json!(n),
338    };
339    Ok(
340        json!({"txid": id.to_string(), "status": status, "confirmations": count,
341        "in_mempool": matches!(in_mempool, MempoolPresence::Present)}),
342    )
343}
344
345pub fn status_plan(node: &Node, plan: &Plan) -> Result<Value, Error> {
346    validate_plan(plan)?;
347    ensure!(
348        node.network == supported_network(&plan.network)?,
349        "journal and RPC networks differ"
350    );
351    let commit = transaction_status(node, &plan.commit)?;
352    let reveals = plan
353        .reveals
354        .iter()
355        .map(|raw| transaction_status(node, raw))
356        .collect::<Result<Vec<_>, Error>>()?;
357    let confirmed = reveals
358        .iter()
359        .filter(|status| status["status"] == "confirmed")
360        .count();
361    let conflicted =
362        commit["status"] == "conflicted" || reveals.iter().any(|s| s["status"] == "conflicted");
363    let status = if conflicted {
364        "conflicted"
365    } else if confirmed == reveals.len() && commit["status"] == "confirmed" {
366        "confirmed"
367    } else if commit["status"] == "confirmed" {
368        "reveals_pending"
369    } else if commit["status"] == "mempool" {
370        "commit_pending"
371    } else if commit["status"] == "unconfirmed_not_in_mempool" {
372        "commit_unconfirmed_not_in_mempool"
373    } else if reveals.iter().all(|reveal| reveal["status"] == "unknown") {
374        "unknown"
375    } else {
376        "inconsistent_node_view"
377    };
378    Ok(json!({"status": status,
379        "network": plan.network, "object_id": plan.object_id, "start_height": plan.start_height,
380        "planned_fee_sats": plan.fee_sats, "confirmed_chunks": confirmed, "total_chunks": reveals.len(),
381        "source": "local_rpc", "commit": commit, "reveals": reveals}))
382}
383
384pub fn broadcast_plan(
385    node: &Node,
386    plan: &Plan,
387    reveal_limit: RevealSelection,
388) -> Result<Value, Error> {
389    validate_plan_for_node(node, plan)?;
390    let commit_id = decode_transaction(&plan.commit)?.compute_txid();
391    if !node.confirmations(commit_id)?.confirmed() {
392        let (outpoint, previous) = plan.funding.prevout()?;
393        let coin = node.call(
394            "gettxout",
395            &[
396                json!(outpoint.txid.to_string()),
397                json!(outpoint.vout),
398                json!(false),
399            ],
400        )?;
401        ensure!(
402            coin["confirmations"]
403                .as_u64()
404                .context("missing funding confirmations")?
405                >= 1,
406            "funding must be confirmed and unspent in the node's chain before broadcast"
407        );
408        ensure!(
409            coin["scriptPubKey"]["hex"] == hex::encode(previous.script_pubkey.as_bytes())
410                && Amount::from_btc(coin["value"].as_f64().context("missing funding value")?)?
411                    == previous.value,
412            "funding node response differs from supplied previous transaction"
413        );
414    }
415    node.submit(&plan.commit)?;
416    if node.confirmations(commit_id)?.confirmed() {
417        for raw in plan
418            .reveals
419            .iter()
420            .take(reveal_limit.count(plan.reveals.len()))
421        {
422            node.submit(raw)?;
423        }
424    }
425    status_plan(node, plan)
426}
427
428pub fn publish_plan(
429    node: &Node,
430    plan: &Plan,
431    reveal_limit: RevealSelection,
432) -> Result<Value, Error> {
433    ensure!(
434        node.network == Network::Regtest && plan.network == "regtest",
435        "mining publication is restricted to regtest"
436    );
437    validate_plan_for_node(node, plan)?;
438    let commit_id = node.submit(&plan.commit)?;
439    if !node.confirmations(commit_id)?.confirmed() {
440        node.mine(&plan.mining_address)?;
441    }
442    ensure!(
443        node.confirmations(commit_id)?.confirmed(),
444        "commit not confirmed; resume later"
445    );
446    let limit = reveal_limit.count(plan.reveals.len());
447    let mut ids = Vec::new();
448    let mut needs_mining = false;
449    for raw in plan.reveals.iter().take(limit) {
450        let id = node.submit(raw)?;
451        needs_mining |= !node.confirmations(id)?.confirmed();
452        ids.push(id);
453    }
454    if needs_mining {
455        node.mine(&plan.mining_address)?;
456    }
457    for id in &ids {
458        ensure!(
459            node.confirmations(*id)?.confirmed(),
460            "reveal {id} not confirmed; resume later"
461        );
462    }
463    Ok(
464        json!({ "status": if limit == plan.reveals.len() { "confirmed" } else { "interrupted" },
465        "network": "regtest", "object_id": plan.object_id, "start_height": plan.start_height,
466        "input_bytes": plan.input_bytes, "total_chunks": plan.reveals.len(),
467        "confirmed_selected_chunks": ids.len(), "planned_fee_sats": plan.fee_sats,
468        "commit_txid": commit_id.to_string(), "reveal_txids": ids.iter().map(ToString::to_string).collect::<Vec<_>>() }),
469    )
470}
471
472pub fn validate_block(block: &Block, expected_hash: BlockHash) -> Result<(), Error> {
473    validation::validate_block(block, expected_hash).map_err(Error::from)
474}
475
476pub fn validate_block_for_network(
477    block: &Block,
478    expected_hash: BlockHash,
479    network: Network,
480) -> Result<(), Error> {
481    validation::validate_block_for_network(block, expected_hash, network).map_err(Error::from)
482}
483
484pub fn collect_records(
485    block: &Block,
486    key: &[u8; 32],
487    objects: &mut BTreeMap<[u8; 32], PrivateObject>,
488) -> Result<u64, Error> {
489    let mut rejected = 0;
490    for tx in &block.txdata {
491        for input in &tx.input {
492            if !envelope::is_candidate(&input.witness) {
493                continue;
494            }
495            match envelope::extract(&input.witness) {
496                Ok(parsed) => rejected += backend::accept_record(key, &parsed.record, objects)?,
497                Err(error) => {
498                    tracing::warn!(%error, "invalid URMA envelope rejected");
499                    rejected += 1;
500                }
501            }
502        }
503    }
504    Ok(rejected)
505}
506
507pub struct Scan {
508    pub objects: BTreeMap<[u8; 32], PrivateObject>,
509    pub start_height: u64,
510    pub tip_height: u64,
511    pub tip_hash: BlockHash,
512    pub scanned_bytes: usize,
513    pub rejected_records: u64,
514}
515
516impl Scan {
517    pub fn from_recovery(recovery: backend::Recovery) -> Result<Self, Error> {
518        let Locator::BlockRange {
519            start_height,
520            tip_height,
521            tip_hash,
522            ..
523        } = recovery.observation.locator
524        else {
525            bail!("Bitcoin scan requires a block-range observation");
526        };
527        Ok(Self {
528            objects: recovery.objects,
529            rejected_records: recovery.rejected_records,
530            start_height,
531            tip_height,
532            tip_hash: tip_hash.parse()?,
533            scanned_bytes: recovery.observation.scanned_bytes,
534        })
535    }
536}
537
538pub struct BitcoinRecords<'a> {
539    pub node: &'a Node,
540    pub start_height: u64,
541}
542
543impl RecordSource for BitcoinRecords<'_> {
544    fn read_records(
545        &self,
546        emit: &mut dyn FnMut(&[u8]) -> Result<(), Error>,
547    ) -> Result<Observation, Error> {
548        read_records(self.node, self.start_height, emit)
549    }
550}
551
552pub fn emit_core_validated_records(
553    block: &Block,
554    emit: &mut dyn FnMut(&[u8]) -> Result<(), Error>,
555) -> Result<(), Error> {
556    for tx in &block.txdata {
557        if !tx
558            .input
559            .iter()
560            .any(|input| envelope::is_candidate(&input.witness))
561        {
562            continue;
563        }
564        match envelope::extract_reveal(tx) {
565            Ok(parsed) => emit(&parsed.record)?,
566            Err(error) => tracing::warn!(%error, "invalid URMA reveal rejected"),
567        }
568    }
569    Ok(())
570}
571
572pub fn scan(node: &Node, key: &[u8; 32], start: u64) -> Result<Scan, Error> {
573    Scan::from_recovery(backend::recover(
574        &BitcoinRecords {
575            node,
576            start_height: start,
577        },
578        key,
579    )?)
580}
581
582fn read_records(
583    node: &Node,
584    start: u64,
585    emit: &mut dyn FnMut(&[u8]) -> Result<(), Error>,
586) -> Result<Observation, Error> {
587    let tip_height = node.height()?;
588    ensure!(start <= tip_height, "start height is beyond the chain tip");
589    let tip_hash = node.block_hash(tip_height)?;
590    let mut previous = if start == 0 {
591        BlockHash::all_zeros()
592    } else {
593        node.block_hash(start - 1)?
594    };
595    let mut scanned_bytes = 0;
596    for height in start..=tip_height {
597        let hash = node.block_hash(height)?;
598        let value = node.call("getblock", &[json!(hash.to_string()), json!(0)])?;
599        let encoded = value.as_str().context("invalid raw block")?;
600        ensure!(encoded.len() <= 8_000_000, "block exceeds byte limit");
601        let raw = hex::decode(encoded)?;
602        scanned_bytes += raw.len();
603        let block: Block = deserialize(&raw)?;
604        validate_block_for_network(&block, hash, node.network)
605            .with_context(|| format!("validate block {height}"))?;
606        ensure!(
607            block.header.prev_blockhash == previous,
608            "chain changed during scan"
609        );
610        previous = hash;
611        emit_core_validated_records(&block, emit)?;
612    }
613    ensure!(
614        previous == tip_hash && node.block_hash(tip_height)? == tip_hash,
615        "chain changed during scan; retry recovery"
616    );
617    Ok(Observation {
618        backend: Family::Bitcoin,
619        locator: Locator::BlockRange {
620            network: node.network.to_string(),
621            start_height: start,
622            tip_height,
623            tip_hash: tip_hash.to_string(),
624        },
625        evidence: Evidence::CommitmentsAndCoreChain,
626        scanned_bytes,
627    })
628}
629
630fn sign_commit(
631    node: &Node,
632    commit_tx: &Transaction,
633    funding_outpoint: OutPoint,
634    funding_output: &TxOut,
635) -> Result<Transaction, Error> {
636    let signed = node.call(
637        "signrawtransactionwithwallet",
638        &[
639            json!(hex::encode(serialize(&commit_tx))),
640            json!([{
641                "txid": funding_outpoint.txid.to_string(), "vout": funding_outpoint.vout,
642                "scriptPubKey": hex::encode(funding_output.script_pubkey.as_bytes()),
643                "amount": funding_output.value.to_btc()
644            }]),
645            json!("ALL"),
646        ],
647    )?;
648    ensure!(
649        signed["complete"] == true,
650        "wallet did not fully sign commit"
651    );
652    let commit = signed["hex"]
653        .as_str()
654        .context("missing signed commit")?
655        .to_owned();
656    let commit_tx = decode(&commit, commit.len())?;
657    Ok(commit_tx)
658}
659fn validate_preparation(
660    records: &[Vec<u8>],
661    input_bytes: usize,
662    fee_rate: u64,
663) -> Result<(), Error> {
664    if input_bytes > Limits::INPUT_BYTES {
665        return Err(Error::Capacity("client input capacity exceeded".into()));
666    }
667
668    ensure!(
669        (1..=Limits::INPUT_BYTES).contains(&input_bytes)
670            && records.len() == input_bytes.div_ceil(Urma::CHUNK_BYTES),
671        "invalid publication input size/count"
672    );
673    let first = container::inspect_header(&records[0])?;
674    for (index, record) in records.iter().enumerate() {
675        let header = container::inspect_header(record)?;
676        ensure!(
677            header.id == first.id
678                && header.discovery_tag == first.discovery_tag
679                && usize::try_from(header.index)? == index
680                && usize::try_from(header.count)? == records.len(),
681            "publication requires one ordered complete opaque record set"
682        );
683    }
684    ensure!(
685        (1..=BITCOIN_MAX_FEE_RATE).contains(&fee_rate),
686        "fee rate must be 1..={BITCOIN_MAX_FEE_RATE} sat/vB"
687    );
688    Ok(())
689}
690fn fund_commit(
691    funding_outpoint: OutPoint,
692    funding_output: &TxOut,
693    return_address: &Address,
694    outputs: Vec<TxOut>,
695    fee_rate: u64,
696    reveal_fee_sats: u64,
697) -> Result<(Transaction, u64), Error> {
698    let mut commit_tx = transaction(
699        funding_outpoint,
700        TxOut {
701            value: Amount::from_sat(BITCOIN_RETURN_SATS),
702            script_pubkey: return_address.script_pubkey(),
703        },
704    );
705    commit_tx.output = outputs.clone();
706    commit_tx.output.push(TxOut {
707        value: Amount::ZERO,
708        script_pubkey: return_address.script_pubkey(),
709    });
710    commit_tx.input[0].witness = Witness::from_slice(&[vec![0; 73], vec![0; 33]]);
711    let commit_fee_sats = u64::try_from(commit_tx.vsize())? * fee_rate;
712    commit_tx.input[0].witness = Witness::new();
713    let output_sats = outputs.iter().map(|o| o.value.to_sat()).sum::<u64>();
714    let change = funding_output
715        .value
716        .to_sat()
717        .checked_sub(output_sats + commit_fee_sats)
718        .context("funding coin does not cover outputs and fees")?;
719    ensure!(
720        change >= BITCOIN_RETURN_SATS,
721        "funding coin must leave at least {BITCOIN_RETURN_SATS} sats change"
722    );
723    commit_tx
724        .output
725        .last_mut()
726        .context("commit change missing")?
727        .value = Amount::from_sat(change);
728    ensure!(
729        commit_fee_sats + reveal_fee_sats <= BITCOIN_MAX_FEE_SATS,
730        "client fee cap exceeded"
731    );
732    Ok((commit_tx, commit_fee_sats))
733}