Skip to main content

urma_runtime/
node.rs

1use crate::config;
2use crate::error::{Context, Error, ensure};
3use crate::{
4    endpoints::{self, PublicEndpoint},
5    transport::Pool,
6};
7use bitcoin::{Transaction, Txid};
8use bitcoincore_rpc::{Auth, Client, RpcApi};
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11use std::{collections::hash_map::Entry, path::PathBuf};
12use urma_chain::{
13    decode::{self as chain_decode, DecodeError},
14    observation::Chain,
15    validation::BlockValidationError,
16};
17use urma_identity::identity::IdentitySigner;
18use urma_wallet::funding::Funding;
19
20#[derive(Clone)]
21pub struct NodeConfig {
22    pub chain: Chain,
23    pub rpc_url: String,
24    pub cookie_file: PathBuf,
25}
26
27pub struct Node {
28    chain: Chain,
29    backend: Backend,
30}
31
32enum Backend {
33    Local(Client),
34    Public(Pool),
35}
36
37#[derive(Clone, Debug, Serialize, Deserialize)]
38pub struct Utxo {
39    pub txid: Txid,
40    pub vout: u32,
41    pub value: u64,
42    pub height: u64,
43}
44
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(tag = "status", rename_all = "snake_case")]
47pub enum Presence {
48    Missing,
49    Mempool,
50    Confirmed { height: u64, block_hash: String },
51}
52
53impl Node {
54    pub fn connect(config: NodeConfig) -> Result<Self, Error> {
55        let url = url::Url::parse(&config.rpc_url)?;
56        ensure!(
57            url.scheme() == "http"
58                && url.username().is_empty()
59                && url.password().iter().count() == 0
60                && url.query().iter().count() == 0
61                && url.fragment().iter().count() == 0
62                && url.path() == "/",
63            "RPC requires a local HTTP origin and cookie authentication"
64        );
65        let host = url
66            .host_str()
67            .context("missing RPC host")?
68            .trim_matches(['[', ']']);
69        ensure!(
70            host.parse::<std::net::IpAddr>()?.is_loopback(),
71            "RPC must use a loopback IP"
72        );
73        let client = Client::new(url.as_str(), Auth::CookieFile(config.cookie_file.clone()))?;
74        let node = Self {
75            chain: config.chain,
76            backend: Backend::Local(client),
77        };
78        node.verify_network()?;
79        Ok(node)
80    }
81
82    pub fn public(chain: Chain) -> Result<Self, Error> {
83        Self::with_public_sources(chain, endpoints::defaults(chain))
84    }
85
86    pub fn with_public_sources(
87        chain: Chain,
88        endpoints: Vec<PublicEndpoint>,
89    ) -> Result<Self, Error> {
90        let sources = Pool::new(endpoints)?;
91        let node = Self {
92            chain,
93            backend: Backend::Public(sources),
94        };
95        node.verify_network()?;
96        Ok(node)
97    }
98
99    pub fn is_public(&self) -> bool {
100        matches!(self.backend, Backend::Public(_))
101    }
102
103    pub fn inclusion_evidence(&self) -> &'static str {
104        match &self.backend {
105            Backend::Local(_) => "local_validating_node",
106            Backend::Public(_) => "public_provider_observation",
107        }
108    }
109
110    pub fn chain(&self) -> Chain {
111        self.chain
112    }
113
114    pub fn tip_height(&self) -> Result<u64, Error> {
115        Ok(self.tip()?.0)
116    }
117
118    pub fn block_hash(&self, height: u64) -> Result<bitcoin::BlockHash, Error> {
119        let value = self.call("getblockhash", &[json!(height)])?;
120        Ok(value.as_str().context("missing block hash")?.parse()?)
121    }
122
123    pub fn block(&self, height: u64) -> Result<bitcoin::Block, Error> {
124        let hash = self.block_hash(height)?;
125        let value = self.call("getblock", &[json!(hash), json!(0)])?;
126        let raw = hex::decode(value.as_str().context("missing block bytes")?)?;
127        let decoded = match &self.backend {
128            Backend::Local(_) => chain_decode::block(&raw, self.chain, hash),
129            Backend::Public(_) => chain_decode::esplora_block(&raw, self.chain, hash),
130        };
131        let block = decoded.map_err(|cause| match cause {
132            DecodeError::Integrity(BlockValidationError::HashMismatch) => {
133                Error::Invalid("RPC block hash mismatch".into())
134            }
135            DecodeError::Integrity(BlockValidationError::MerkleRootMismatch) => {
136                Error::Invalid("RPC block merkle root mismatch".into())
137            }
138            cause => Error::from(cause),
139        })?;
140        Ok(block)
141    }
142
143    pub fn confirmations(&self, txid: Txid) -> Result<u32, Error> {
144        match self.presence(txid)? {
145            Presence::Confirmed { height, .. } => Ok(u32::try_from(
146                self.tip_height()?
147                    .checked_sub(height)
148                    .context("chain changed during confirmation lookup")?
149                    .checked_add(1)
150                    .context("confirmation count overflow")?,
151            )?),
152            Presence::Missing | Presence::Mempool => Ok(0),
153        }
154    }
155
156    pub fn call(&self, method: &str, args: &[Value]) -> Result<Value, Error> {
157        match &self.backend {
158            Backend::Local(client) => Ok(client.call(method, args)?),
159            Backend::Public(sources) => sources.call(self.chain, method, args),
160        }
161    }
162
163    pub fn require_txindex(&self) -> Result<(), Error> {
164        if matches!(self.backend, Backend::Public(_)) {
165            return self.verify_network();
166        }
167        let indexes = self.call("getindexinfo", &[json!("txindex")])?;
168        ensure!(
169            indexes["txindex"]["synced"] == true,
170            "publication and recovery require synchronized txindex=1 on the local node"
171        );
172        Ok(())
173    }
174
175    pub fn verify_network(&self) -> Result<(), Error> {
176        let genesis = self.call("getblockhash", &[json!(0)])?;
177        ensure!(
178            genesis.as_str() == Some(self.chain().genesis()?.0.to_string().as_str()),
179            "RPC genesis does not match selected chain"
180        );
181        Ok(())
182    }
183
184    pub fn tip(&self) -> Result<(u64, String), Error> {
185        let info = self.call("getblockchaininfo", &[])?;
186        Ok((
187            info["blocks"].as_u64().context("missing height")?,
188            info["bestblockhash"]
189                .as_str()
190                .context("missing tip hash")?
191                .to_owned(),
192        ))
193    }
194
195    pub fn transaction(&self, txid: Txid) -> Result<Transaction, Error> {
196        let raw = self.call("getrawtransaction", &[json!(txid), json!(false)])?;
197        let raw = raw.as_str().context("missing raw transaction")?;
198        let transaction = chain_decode::transaction(&hex::decode(raw)?, self.chain)?;
199        ensure!(
200            transaction.compute_txid() == txid,
201            "RPC transaction hash mismatch"
202        );
203        Ok(transaction)
204    }
205
206    pub fn presence(&self, txid: Txid) -> Result<Presence, Error> {
207        self.presence_cached(txid, &mut std::collections::HashMap::new())
208    }
209
210    pub(crate) fn presence_cached(
211        &self,
212        txid: Txid,
213        blocks: &mut std::collections::HashMap<String, Presence>,
214    ) -> Result<Presence, Error> {
215        let result = self.call("getrawtransaction", &[json!(txid), json!(true)]);
216        let value = match result {
217            Ok(value) => value,
218            Err(Error::Rpc(bitcoincore_rpc::Error::JsonRpc(
219                bitcoincore_rpc::jsonrpc::Error::Rpc(error),
220            ))) if error.code == -5 => {
221                tracing::warn!(code = error.code, "transaction absent from node");
222                return Ok(Presence::Missing);
223            }
224            Err(Error::Missing(message)) => {
225                tracing::warn!(%message, "transaction absent from public sources");
226                return Ok(Presence::Missing);
227            }
228            Err(error) => return Err(error),
229        };
230        if config::confirmations(&value)? <= 0 {
231            let mempool = self.call("getrawmempool", &[])?;
232            return Ok(
233                if mempool
234                    .as_array()
235                    .context("invalid mempool")?
236                    .contains(&json!(txid))
237                {
238                    Presence::Mempool
239                } else {
240                    Presence::Missing
241                },
242            );
243        }
244        let hash = value["blockhash"]
245            .as_str()
246            .context("missing inclusion block")?;
247        let slot = match blocks.entry(hash.to_owned()) {
248            Entry::Occupied(cached) => return Ok(cached.get().clone()),
249            Entry::Vacant(slot) => slot,
250        };
251        let header = self.call("getblockheader", &[json!(hash)])?;
252        let height = header["height"]
253            .as_u64()
254            .context("missing inclusion height")?;
255        let active = self.call("getblockhash", &[json!(height)])?;
256        if active != json!(hash) {
257            return Ok(Presence::Missing);
258        }
259        let presence = Presence::Confirmed {
260            height,
261            block_hash: hash.to_owned(),
262        };
263        slot.insert(presence.clone());
264        Ok(presence)
265    }
266
267    pub fn utxos(&self, signer: &impl IdentitySigner) -> Result<Vec<Utxo>, Error> {
268        self.verify_network()?;
269        if matches!(self.backend, Backend::Public(_)) {
270            return self.public_utxos(signer);
271        }
272        let script = urma_wallet::signing::script(signer)?;
273        let scan = self.call(
274            "scantxoutset",
275            &[
276                json!("start"),
277                json!([format!("raw({})", hex::encode(script.as_bytes()))]),
278            ],
279        )?;
280        ensure!(scan["success"] == true, "UTXO scan did not complete");
281        let rows = scan["unspents"].as_array().context("missing UTXO scan")?;
282        ensure!(rows.len() <= 1000, "UTXO client capacity exceeded");
283        let mut outputs = Vec::new();
284        for row in rows {
285            let amount = bitcoin::Amount::from_str_in(
286                &row["amount"].to_string(),
287                bitcoin::Denomination::Bitcoin,
288            )?;
289            outputs.push(Utxo {
290                txid: row["txid"].as_str().context("missing UTXO txid")?.parse()?,
291                vout: u32::try_from(row["vout"].as_u64().context("missing UTXO vout")?)?,
292                value: amount.to_sat(),
293                height: row["height"].as_u64().context("missing UTXO height")?,
294            });
295        }
296        outputs.sort_by_key(|output| (output.value, output.txid, output.vout));
297        Ok(outputs)
298    }
299
300    fn public_utxos(&self, signer: &impl IdentitySigner) -> Result<Vec<Utxo>, Error> {
301        let address = urma_wallet::address::receive_address(signer, self.chain)?;
302        let rows = self.call("addressutxos", &[json!(address)])?;
303        let rows = rows.as_array().context("missing public UTXOs")?;
304        ensure!(rows.len() <= 1000, "UTXO client capacity exceeded");
305        let mut outputs = Vec::new();
306        for row in rows {
307            if row["status"]["confirmed"] != true {
308                continue;
309            }
310            outputs.push(Utxo {
311                txid: row["txid"].as_str().context("missing UTXO txid")?.parse()?,
312                vout: u32::try_from(row["vout"].as_u64().context("missing UTXO index")?)?,
313                value: row["value"].as_u64().context("missing UTXO value")?,
314                height: row["status"]["block_height"]
315                    .as_u64()
316                    .context("missing UTXO height")?,
317            });
318        }
319        outputs.sort_by_key(|output| (output.value, output.txid, output.vout));
320        Ok(outputs)
321    }
322
323    pub fn available_utxos(&self, signer: &impl IdentitySigner) -> Result<Vec<Utxo>, Error> {
324        let expected = hex::encode(urma_wallet::signing::script(signer)?.as_bytes());
325        let mut available = Vec::new();
326        for output in self.utxos(signer)? {
327            let unspent = self.call(
328                "gettxout",
329                &[json!(output.txid), json!(output.vout), json!(true)],
330            )?;
331            if unspent.is_null() {
332                continue;
333            }
334            let confirmations = config::confirmations(&unspent)?;
335            if confirmations < 1 || (unspent["coinbase"] == true && confirmations < 100) {
336                continue;
337            }
338            ensure!(
339                unspent["scriptPubKey"]["hex"] == expected,
340                "live funding script disagrees with identity"
341            );
342            let amount = bitcoin::Amount::from_str_in(
343                &unspent["value"].to_string(),
344                bitcoin::Denomination::Bitcoin,
345            )?;
346            ensure!(
347                amount.to_sat() == output.value,
348                "live funding value disagrees with UTXO scan"
349            );
350            available.push(output);
351        }
352        Ok(available)
353    }
354
355    pub fn select_funding(
356        &self,
357        signer: &impl IdentitySigner,
358        minimum: u64,
359    ) -> Result<Funding, Error> {
360        for output in self.available_utxos(signer)? {
361            if output.value < minimum {
362                continue;
363            }
364            let block = self.call("getblockhash", &[json!(output.height)])?;
365            let raw = self.call(
366                "getrawtransaction",
367                &[json!(output.txid), json!(false), block],
368            )?;
369            let funding = Funding {
370                raw_transaction: raw.as_str().context("missing funding bytes")?.to_owned(),
371                vout: output.vout,
372            };
373            let (outpoint, previous) = funding.prevout()?;
374            ensure!(
375                outpoint.txid == output.txid && previous.value.to_sat() == output.value,
376                "funding transaction disagrees with scanned outpoint"
377            );
378            ensure!(
379                previous.script_pubkey == urma_wallet::signing::script(signer)?,
380                "funding identity mismatch"
381            );
382            return Ok(funding);
383        }
384        Err(Error::Missing(
385            "no confirmed identity UTXO large enough; fund its address and confirm first".into(),
386        ))
387    }
388}