Skip to main content

urma_runtime/
node.rs

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