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}