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}