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}