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}