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