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}