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, PartialEq, Eq, 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        self.presence_cached(txid, &mut std::collections::HashMap::new())
203    }
204
205    pub(crate) fn presence_cached(
206        &self,
207        txid: Txid,
208        blocks: &mut std::collections::HashMap<String, Presence>,
209    ) -> Result<Presence, Error> {
210        let result = self.call("getrawtransaction", &[json!(txid), json!(true)]);
211        let value = match result {
212            Ok(value) => value,
213            Err(Error::Rpc(bitcoincore_rpc::Error::JsonRpc(
214                bitcoincore_rpc::jsonrpc::Error::Rpc(error),
215            ))) if error.code == -5 => {
216                tracing::warn!(code = error.code, "transaction absent from node");
217                return Ok(Presence::Missing);
218            }
219            Err(Error::Missing(message)) => {
220                tracing::warn!(%message, "transaction absent from public sources");
221                return Ok(Presence::Missing);
222            }
223            Err(error) => return Err(error),
224        };
225        if config::confirmations(&value)? <= 0 {
226            let mempool = self.call("getrawmempool", &[])?;
227            return Ok(
228                if mempool
229                    .as_array()
230                    .context("invalid mempool")?
231                    .contains(&json!(txid))
232                {
233                    Presence::Mempool
234                } else {
235                    Presence::Missing
236                },
237            );
238        }
239        let hash = value["blockhash"]
240            .as_str()
241            .context("missing inclusion block")?;
242        let cached = blocks.get(hash);
243        for presence in cached.iter() {
244            return Ok((*presence).clone());
245        }
246        let header = self.call("getblockheader", &[json!(hash)])?;
247        let height = header["height"]
248            .as_u64()
249            .context("missing inclusion height")?;
250        let active = self.call("getblockhash", &[json!(height)])?;
251        if active != json!(hash) {
252            return Ok(Presence::Missing);
253        }
254        let presence = Presence::Confirmed {
255            height,
256            block_hash: hash.to_owned(),
257        };
258        blocks.insert(hash.to_owned(), presence.clone());
259        Ok(presence)
260    }
261
262    pub fn utxos(&self, signer: &impl IdentitySigner) -> Result<Vec<Utxo>, Error> {
263        self.verify_network()?;
264        if matches!(self.backend, Backend::Public(_)) {
265            return self.public_utxos(signer);
266        }
267        let script = urma_wallet::signing::script(signer)?;
268        let scan = self.call(
269            "scantxoutset",
270            &[
271                json!("start"),
272                json!([format!("raw({})", hex::encode(script.as_bytes()))]),
273            ],
274        )?;
275        ensure!(scan["success"] == true, "UTXO scan did not complete");
276        let rows = scan["unspents"].as_array().context("missing UTXO scan")?;
277        ensure!(rows.len() <= 1000, "UTXO client capacity exceeded");
278        let mut outputs = Vec::new();
279        for row in rows {
280            let amount = bitcoin::Amount::from_str_in(
281                &row["amount"].to_string(),
282                bitcoin::Denomination::Bitcoin,
283            )?;
284            outputs.push(Utxo {
285                txid: row["txid"].as_str().context("missing UTXO txid")?.parse()?,
286                vout: u32::try_from(row["vout"].as_u64().context("missing UTXO vout")?)?,
287                value: amount.to_sat(),
288                height: row["height"].as_u64().context("missing UTXO height")?,
289            });
290        }
291        outputs.sort_by_key(|output| (output.value, output.txid, output.vout));
292        Ok(outputs)
293    }
294
295    fn public_utxos(&self, signer: &impl IdentitySigner) -> Result<Vec<Utxo>, Error> {
296        let address = urma_wallet::address::receive_address(signer, self.chain)?;
297        let rows = self.call("addressutxos", &[json!(address)])?;
298        let rows = rows.as_array().context("missing public UTXOs")?;
299        ensure!(rows.len() <= 1000, "UTXO client capacity exceeded");
300        let mut outputs = Vec::new();
301        for row in rows {
302            if row["status"]["confirmed"] != true {
303                continue;
304            }
305            outputs.push(Utxo {
306                txid: row["txid"].as_str().context("missing UTXO txid")?.parse()?,
307                vout: u32::try_from(row["vout"].as_u64().context("missing UTXO index")?)?,
308                value: row["value"].as_u64().context("missing UTXO value")?,
309                height: row["status"]["block_height"]
310                    .as_u64()
311                    .context("missing UTXO height")?,
312            });
313        }
314        outputs.sort_by_key(|output| (output.value, output.txid, output.vout));
315        Ok(outputs)
316    }
317
318    pub fn available_utxos(&self, signer: &impl IdentitySigner) -> Result<Vec<Utxo>, Error> {
319        let expected = hex::encode(urma_wallet::signing::script(signer)?.as_bytes());
320        let mut available = Vec::new();
321        for output in self.utxos(signer)? {
322            let unspent = self.call(
323                "gettxout",
324                &[json!(output.txid), json!(output.vout), json!(true)],
325            )?;
326            if unspent.is_null() {
327                continue;
328            }
329            let confirmations = config::confirmations(&unspent)?;
330            if confirmations < 1 || (unspent["coinbase"] == true && confirmations < 100) {
331                continue;
332            }
333            ensure!(
334                unspent["scriptPubKey"]["hex"] == expected,
335                "live funding script disagrees with identity"
336            );
337            let amount = bitcoin::Amount::from_str_in(
338                &unspent["value"].to_string(),
339                bitcoin::Denomination::Bitcoin,
340            )?;
341            ensure!(
342                amount.to_sat() == output.value,
343                "live funding value disagrees with UTXO scan"
344            );
345            available.push(output);
346        }
347        Ok(available)
348    }
349
350    pub fn select_funding(
351        &self,
352        signer: &impl IdentitySigner,
353        minimum: u64,
354    ) -> Result<Funding, Error> {
355        for output in self.available_utxos(signer)? {
356            if output.value < minimum {
357                continue;
358            }
359            let block = self.call("getblockhash", &[json!(output.height)])?;
360            let raw = self.call(
361                "getrawtransaction",
362                &[json!(output.txid), json!(false), block],
363            )?;
364            let funding = Funding {
365                raw_transaction: raw.as_str().context("missing funding bytes")?.to_owned(),
366                vout: output.vout,
367            };
368            let (outpoint, previous) = funding.prevout()?;
369            ensure!(
370                outpoint.txid == output.txid && previous.value.to_sat() == output.value,
371                "funding transaction disagrees with scanned outpoint"
372            );
373            ensure!(
374                previous.script_pubkey == urma_wallet::signing::script(signer)?,
375                "funding identity mismatch"
376            );
377            return Ok(funding);
378        }
379        Err(Error::Missing(
380            "no confirmed identity UTXO large enough; fund its address and confirm first".into(),
381        ))
382    }
383}