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 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}