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