1use crate::config;
2use crate::endpoints::{ElectrumTarget, PublicEndpoint, Wire};
3use crate::error::{Context, Error, ensure};
4use crate::pinning::{Pinned, Pins};
5use crate::transport::{BlockEncoding, Evidence, Provider};
6use bitcoin::{
7 BlockHash, Txid,
8 block::Header,
9 consensus::deserialize,
10 hashes::{Hash, sha256},
11};
12use rustls::{ClientConfig, ClientConnection, ServerName, StreamOwned};
13use serde_json::{Value, json};
14use std::{
15 collections::HashMap,
16 io::{BufRead, BufReader, Read, Write},
17 net::{TcpStream, ToSocketAddrs},
18 sync::{Arc, Mutex},
19 time::{Duration, Instant},
20};
21use urma_chain::{decode as chain_decode, observation::Chain};
22
23enum Stream {
24 Plain(TcpStream),
25 Tls(StreamOwned<ClientConnection, TcpStream>),
26}
27
28impl Stream {
29 fn socket(&self) -> &TcpStream {
30 match self {
31 Self::Plain(socket) => socket,
32 Self::Tls(stream) => &stream.sock,
33 }
34 }
35}
36
37impl Read for Stream {
38 fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
39 match self {
40 Self::Plain(socket) => socket.read(buffer),
41 Self::Tls(stream) => stream.read(buffer),
42 }
43 }
44}
45
46impl Write for Stream {
47 fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
48 match self {
49 Self::Plain(socket) => socket.write(buffer),
50 Self::Tls(stream) => stream.write(buffer),
51 }
52 }
53 fn flush(&mut self) -> std::io::Result<()> {
54 match self {
55 Self::Plain(socket) => socket.flush(),
56 Self::Tls(stream) => stream.flush(),
57 }
58 }
59}
60
61struct Session {
62 stream: BufReader<Stream>,
63 next_id: u64,
64 unspent: HashMap<String, (Instant, Vec<Value>)>,
65}
66
67impl Session {
68 fn open(target: &ElectrumTarget, pins: &Arc<Pins>) -> Result<Self, Error> {
69 let socket = connect(target)?;
70 let stream = match target.wire {
71 Wire::PlainTcp => {
72 tracing::warn!(host = %target.host, port = target.port, "electrum endpoint explicitly configured without TLS");
73 Stream::Plain(socket)
74 }
75 Wire::Tls => {
76 let verifier = Pinned {
77 pins: pins.clone(),
78 key: format!("{}_{}", target.host, target.port),
79 };
80 let tls = ClientConfig::builder()
81 .with_safe_defaults()
82 .with_custom_certificate_verifier(Arc::new(verifier))
83 .with_no_client_auth();
84 let name = ServerName::try_from(target.host.as_str())
85 .map_err(|cause| Error::Invalid(format!("electrum host name: {cause}")))?;
86 let connection = ClientConnection::new(Arc::new(tls), name)?;
87 Stream::Tls(StreamOwned::new(connection, socket))
88 }
89 };
90 Ok(Self {
91 stream: BufReader::new(stream),
92 next_id: 1,
93 unspent: HashMap::new(),
94 })
95 }
96
97 fn request(&mut self, method: &str, params: Value, timeout: Duration) -> Result<Value, Error> {
98 let id = self.next_id;
99 self.next_id = id
100 .checked_add(1)
101 .context("electrum request counter overflow")?;
102 let socket = self.stream.get_ref().socket();
103 socket.set_read_timeout(Some(timeout))?;
104 socket.set_write_timeout(Some(timeout))?;
105 let mut line =
106 serde_json::to_vec(&json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}))?;
107 line.push(b'\n');
108 let stream = self.stream.get_mut();
109 stream.write_all(&line)?;
110 stream.flush()?;
111 loop {
112 let reply = self.read_line()?;
113 if reply["id"] != json!(id) {
114 tracing::debug!(method = %reply["method"], "skipping electrum notification");
115 continue;
116 }
117 if !reply["error"].is_null() {
118 return Err(server_error(method, &reply["error"]));
119 }
120 return Ok(reply
121 .get("result")
122 .context("electrum reply omitted result")?
123 .clone());
124 }
125 }
126
127 fn read_line(&mut self) -> Result<Value, Error> {
128 let mut buffer = Vec::new();
129 let limit = config::ELECTRUM_MAX_LINE_BYTES;
130 let bound = u64::try_from(limit)?
131 .checked_add(1)
132 .context("line bound overflow")?;
133 let count = (&mut self.stream)
134 .take(bound)
135 .read_until(b'\n', &mut buffer)?;
136 if count == 0 {
137 return Err(Error::Io(std::io::Error::new(
138 std::io::ErrorKind::UnexpectedEof,
139 "electrum server closed the connection",
140 )));
141 }
142 ensure!(buffer.len() <= limit, "electrum reply exceeds capacity");
143 ensure!(
144 buffer.last() == Some(&b'\n'),
145 "electrum reply was truncated"
146 );
147 Ok(serde_json::from_slice(&buffer)?)
148 }
149}
150
151fn connect(target: &ElectrumTarget) -> Result<TcpStream, Error> {
152 let mut last = Error::Missing(format!("{} resolved to no address", target.host));
153 for address in (target.host.as_str(), target.port).to_socket_addrs()? {
154 match TcpStream::connect_timeout(&address, config::ELECTRUM_CONNECT_TIMEOUT) {
155 Ok(socket) => {
156 socket.set_nodelay(true)?;
157 return Ok(socket);
158 }
159 Err(error) => {
160 tracing::warn!(%address, %error, "electrum address unreachable");
161 last = Error::Io(error);
162 }
163 }
164 }
165 Err(last)
166}
167
168fn server_error(method: &str, error: &Value) -> Error {
169 let message = error["message"].to_string();
170 let lowered = message.to_ascii_lowercase();
171 if lowered.contains("no such mempool or blockchain transaction")
172 || lowered.contains("not found")
173 {
174 return Error::Missing(config::ABSENT_TRANSACTION.into());
175 }
176 Error::Unsupported(format!("electrum {method} failed: {message}"))
177}
178
179enum Link {
180 Closed,
181 Open(Session),
182}
183
184pub struct Electrum {
185 endpoint: PublicEndpoint,
186 target: ElectrumTarget,
187 pins: Arc<Pins>,
188 link: Mutex<Link>,
189 seen: Mutex<HashMap<BlockHash, u64>>,
190}
191
192impl Electrum {
193 pub fn new(endpoint: PublicEndpoint, pins: Arc<Pins>) -> Result<Self, Error> {
194 let target = endpoint.electrum_target()?;
195 Ok(Self {
196 endpoint,
197 target,
198 pins,
199 link: Mutex::new(Link::Closed),
200 seen: Mutex::new(HashMap::new()),
201 })
202 }
203
204 fn session<T>(
205 &self,
206 chain: Chain,
207 work: impl Fn(&mut Session) -> Result<T, Error>,
208 ) -> Result<T, Error> {
209 let mut link = match self.link.lock() {
210 Ok(link) => link,
211 Err(poisoned) => {
212 tracing::error!(endpoint = %self.endpoint.url(), "electrum link lock poisoned");
213 poisoned.into_inner()
214 }
215 };
216 let mut fresh = false;
217 loop {
218 if matches!(*link, Link::Closed) {
219 let mut session = Session::open(&self.target, &self.pins)?;
220 match self.handshake(&mut session, chain) {
221 Ok(()) => *link = Link::Open(session),
222 Err(error) => {
223 tracing::warn!(endpoint = %self.endpoint.url(), %error, "electrum handshake failed");
224 return Err(error);
225 }
226 }
227 fresh = true;
228 }
229 let Link::Open(session) = &mut *link else {
230 return Err(Error::Invalid("electrum link closed during request".into()));
231 };
232 match work(session) {
233 Ok(value) => return Ok(value),
234 Err(Error::Io(cause)) if fresh => {
235 tracing::warn!(endpoint = %self.endpoint.url(), error = %cause, "electrum connection dropped on a fresh session");
236 *link = Link::Closed;
237 return Err(Error::Io(cause));
238 }
239 Err(Error::Io(cause)) => {
240 tracing::warn!(endpoint = %self.endpoint.url(), error = %cause, "electrum connection dropped; reconnecting and retrying once");
241 *link = Link::Closed;
242 }
243 Err(error) => return Err(error),
244 }
245 }
246 }
247
248 fn handshake(&self, session: &mut Session, chain: Chain) -> Result<(), Error> {
249 let timeout = config::method_timeout("getblockchaininfo");
250 let version = session.request(
251 "server.version",
252 json!(["urma/0.2", config::ELECTRUM_PROTOCOL]),
253 timeout,
254 )?;
255 ensure!(
256 version[1] == json!(config::ELECTRUM_PROTOCOL),
257 "electrum server negotiated protocol {} instead of {}",
258 version[1],
259 config::ELECTRUM_PROTOCOL
260 );
261 let genesis =
262 header_hash(&session.request("blockchain.block.header", json!([0]), timeout)?)?;
263 ensure!(
264 genesis == chain.genesis()?.0,
265 "electrum server genesis does not match {}",
266 chain.label()
267 );
268 tracing::debug!(endpoint = %self.endpoint.url(), server = %version[0], "electrum session verified");
269 Ok(())
270 }
271
272 fn remember(&self, hash: BlockHash, height: u64) {
273 match self.seen.lock() {
274 Ok(mut seen) => {
275 seen.insert(hash, height);
276 }
277 Err(poisoned) => {
278 tracing::error!(endpoint = %self.endpoint.url(), "electrum header table lock poisoned");
279 poisoned.into_inner().insert(hash, height);
280 }
281 }
282 }
283
284 fn seen_height(&self, hash: BlockHash) -> Result<u64, Error> {
285 let seen = match self.seen.lock() {
286 Ok(seen) => seen,
287 Err(poisoned) => {
288 tracing::error!(endpoint = %self.endpoint.url(), "electrum header table lock poisoned");
289 poisoned.into_inner()
290 }
291 };
292 match seen.get(&hash) {
293 Some(height) => Ok(*height),
294 None => Err(Error::Unsupported(format!(
295 "electrum server has not served block {hash} to this session"
296 ))),
297 }
298 }
299
300 fn tip(&self, session: &mut Session) -> Result<(u64, BlockHash), Error> {
301 let timeout = config::method_timeout("getblockchaininfo");
302 let tip = session.request("blockchain.headers.subscribe", json!([]), timeout)?;
303 let height = tip["height"]
304 .as_u64()
305 .context("electrum tip height missing")?;
306 let hash = header_hash(&tip["hex"])?;
307 self.remember(hash, height);
308 Ok((height, hash))
309 }
310
311 fn block_hash(&self, session: &mut Session, height: u64) -> Result<BlockHash, Error> {
312 let timeout = config::method_timeout("getblockhash");
313 let header = session.request("blockchain.block.header", json!([height]), timeout)?;
314 let hash = header_hash(&header)?;
315 self.remember(hash, height);
316 Ok(hash)
317 }
318
319 fn raw_transaction(&self, session: &mut Session, txid: &Txid) -> Result<String, Error> {
320 let timeout = config::method_timeout("getrawtransaction");
321 let raw = session.request("blockchain.transaction.get", json!([txid]), timeout)?;
322 Ok(raw
323 .as_str()
324 .context("electrum transaction bytes missing")?
325 .to_owned())
326 }
327
328 fn verbose_transaction(&self, session: &mut Session, txid: &Txid) -> Result<Value, Error> {
329 let timeout = config::method_timeout("getrawtransaction");
330 let verbose =
331 session.request("blockchain.transaction.get", json!([txid, true]), timeout)?;
332 let confirmations = config::confirmations(&verbose)?;
333 if confirmations <= 0 {
334 return Ok(json!({"confirmations":0}));
335 }
336 let hash: BlockHash = verbose["blockhash"]
337 .as_str()
338 .context("electrum inclusion block missing")?
339 .parse()?;
340 let (tip, _) = self.tip(session)?;
341 let depth = u64::try_from(confirmations)?;
342 let height = tip
343 .checked_add(1)
344 .and_then(|next| next.checked_sub(depth))
345 .context("electrum confirmations exceed tip height")?;
346 ensure!(
347 self.block_hash(session, height)? == hash,
348 "electrum inclusion block disagrees with its header chain"
349 );
350 Ok(json!({"confirmations":confirmations,"blockhash":hash}))
351 }
352
353 fn listunspent(
354 &self,
355 session: &mut Session,
356 script: &bitcoin::Script,
357 ) -> Result<Vec<Value>, Error> {
358 let key = scripthash(script);
359 let now = Instant::now();
360 let cached = session.unspent.get(&key);
361 for (fetched, rows) in cached.iter() {
362 if now.duration_since(*fetched) < config::ELECTRUM_UNSPENT_CACHE {
363 tracing::debug!(endpoint = %self.endpoint.url(), rows = rows.len(), "unspent list served from the session cache");
364 return Ok(rows.clone());
365 }
366 }
367 let timeout = config::method_timeout("addressutxos");
368 let rows = session.request("blockchain.scripthash.listunspent", json!([key]), timeout)?;
369 let rows = rows.as_array().context("electrum unspent list missing")?;
370 session.unspent.insert(key, (now, rows.clone()));
371 Ok(rows.clone())
372 }
373
374 fn address_utxos(
375 &self,
376 session: &mut Session,
377 chain: Chain,
378 address: &str,
379 ) -> Result<Value, Error> {
380 let script = urma_wallet::address::destination_script(address, chain)?;
381 let mut outputs = Vec::new();
382 for row in self.listunspent(session, &script)? {
383 let height = row["height"]
384 .as_u64()
385 .context("electrum unspent height missing")?;
386 outputs.push(json!({
387 "txid": row["tx_hash"],
388 "vout": row["tx_pos"],
389 "value": row["value"],
390 "status": {"confirmed": height > 0, "block_height": height},
391 }));
392 }
393 Ok(Value::Array(outputs))
394 }
395
396 fn txout(&self, session: &mut Session, chain: Chain, args: &[Value]) -> Result<Value, Error> {
397 let txid: Txid = args
398 .first()
399 .context("missing txid")?
400 .as_str()
401 .context("invalid txid")?
402 .parse()?;
403 let vout = args
404 .get(1)
405 .context("missing vout")?
406 .as_u64()
407 .context("invalid vout")?;
408 let raw = self.raw_transaction(session, &txid)?;
409 let transaction = chain_decode::transaction(&hex::decode(raw)?, chain)?;
410 ensure!(
411 transaction.compute_txid() == txid,
412 "electrum transaction hash mismatch"
413 );
414 let output = transaction
415 .output
416 .get(usize::try_from(vout)?)
417 .context("transaction output index out of range")?;
418 let mut unspent = None;
419 for row in self.listunspent(session, &output.script_pubkey)? {
420 if row["tx_hash"] == json!(txid) && row["tx_pos"] == json!(vout) {
421 unspent = Some(row);
422 }
423 }
424 let Some(row) = unspent else {
425 return Ok(Value::Null);
426 };
427 let height = row["height"]
428 .as_u64()
429 .context("electrum unspent height missing")?;
430 let confirmations = if height > 0 {
431 let (tip, _) = self.tip(session)?;
432 tip.checked_sub(height)
433 .and_then(|n| n.checked_add(1))
434 .context("chain changed during output lookup")?
435 } else {
436 0
437 };
438 Ok(json!({
439 "confirmations": confirmations,
440 "coinbase": transaction.is_coinbase(),
441 "value": serde_json::from_str::<Value>(&output.value.to_string_in(bitcoin::Denomination::Bitcoin))?,
442 "scriptPubKey": {"hex": hex::encode(output.script_pubkey.as_bytes())},
443 }))
444 }
445
446 fn dispatch(
447 &self,
448 session: &mut Session,
449 chain: Chain,
450 method: &str,
451 args: &[Value],
452 ) -> Result<Value, Error> {
453 match method {
454 "getblockchaininfo" => {
455 let (height, hash) = self.tip(session)?;
456 Ok(json!({"blocks":height,"bestblockhash":hash,"initialblockdownload":false}))
457 }
458 "getblockhash" => {
459 let height = args
460 .first()
461 .context("missing block height")?
462 .as_u64()
463 .context("invalid block height")?;
464 Ok(json!(self.block_hash(session, height)?))
465 }
466 "getblockheader" => {
467 let hash: BlockHash = args
468 .first()
469 .context("missing block hash")?
470 .as_str()
471 .context("invalid block hash")?
472 .parse()?;
473 let height = self.seen_height(hash)?;
474 Ok(json!({"hash":hash,"height":height}))
475 }
476 "getrawtransaction" => {
477 let txid: Txid = args
478 .first()
479 .context("missing txid")?
480 .as_str()
481 .context("invalid txid")?
482 .parse()?;
483 if args.get(1).context("missing transaction verbosity")? == &json!(true) {
484 return self.verbose_transaction(session, &txid);
485 }
486 Ok(json!(self.raw_transaction(session, &txid)?))
487 }
488 "gettxout" => self.txout(session, chain, args),
489 "addressutxos" => {
490 let address = args
491 .first()
492 .context("missing address")?
493 .as_str()
494 .context("invalid address")?;
495 self.address_utxos(session, chain, address)
496 }
497 "sendrawtransaction" => {
498 let raw = args.first().context("missing broadcast transaction")?;
499 let timeout = config::method_timeout(method);
500 session.unspent.clear();
501 session.request("blockchain.transaction.broadcast", json!([raw]), timeout)
502 }
503 other => Err(Error::Unsupported(format!(
504 "electrum servers do not serve {other}"
505 ))),
506 }
507 }
508}
509
510impl Electrum {
511 pub fn probe(&self, chain: Chain, method: &str, params: Value) -> Result<Value, Error> {
512 ensure!(
513 config::ELECTRUM_PROBE_METHODS.contains(&method),
514 "{method} is not a read-only electrum probe"
515 );
516 let timeout = config::method_timeout("addressutxos");
517 self.session(chain, |session| {
518 session.request(method, params.clone(), timeout)
519 })
520 }
521}
522
523impl Provider for Electrum {
524 fn label(&self) -> String {
525 self.endpoint.url().to_owned()
526 }
527
528 fn evidence(&self) -> Evidence {
529 Evidence::PublicProviderObservation
530 }
531
532 fn block_encoding(&self) -> BlockEncoding {
533 BlockEncoding::Core
534 }
535
536 fn supports(&self, method: &str) -> bool {
537 !matches!(method, "getblock" | "getrawmempool" | "testmempoolaccept")
538 }
539
540 fn call(&self, chain: Chain, method: &str, args: &[Value]) -> Result<Value, Error> {
541 self.session(chain, |session| self.dispatch(session, chain, method, args))
542 }
543}
544
545fn header_hash(value: &Value) -> Result<BlockHash, Error> {
546 let bytes = hex::decode(value.as_str().context("electrum header bytes missing")?)?;
547 ensure!(bytes.len() == 80, "electrum header is not 80 bytes");
548 let header: Header = deserialize(&bytes)?;
549 Ok(header.block_hash())
550}
551
552fn scripthash(script: &bitcoin::Script) -> String {
553 let mut digest = sha256::Hash::hash(script.as_bytes()).to_byte_array();
554 digest.reverse();
555 hex::encode(digest)
556}