Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
target
.DS_Store
*db/
*.log
*.sublime*
Expand Down
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,31 @@ See `$ cargo run --release --bin electrs -- --help` for the full list of options
### Mining-related HTTP endpoints

`GET /block-template` is available only with `--enable-mining-rest`. It proxies
the daemon's `getblocktemplate` template-mode response and caches successful
responses for 15 seconds, invalidating early when electrs indexes a new tip.
Callers that require fresher templates should account for this cache behavior.
the daemon's `getblocktemplate` response unchanged on Bitcoin-compatible chains.
On Liquid, it instead decodes the complete proposal returned by
`getnewblockhex` and projects the recoverable header, transaction, fee,
coinbase, and witness-commitment data into the same response shape. Fields that
have no equivalent mining semantics for signed dynafed blocks use compatibility
defaults or are omitted. The Liquid response is intended for template inspection
and distribution, not block reconstruction or federation signing.

Successful responses are cached for 15 seconds and invalidated early when
electrs indexes a new tip. Cache misses are coalesced into one daemon request.
Responses use `Cache-Control: no-store`, so downstream caches do not extend the
internal lifetime.

Template RPCs use an isolated daemon connection with a 30-second I/O timeout.
Failures are retained internally for one second to prevent HTTP pollers from
immediately repeating the same failing RPC, while error responses remain
`Cache-Control: no-store`. Malformed Bitcoin templates that cannot be validated
against the indexed tip are rejected with `502 Bad Gateway`; they are not served
or cached.

All connections to the configured daemon RPC endpoint are expected to expose a
coherent chain view. Deployments using an L4 load balancer must keep its daemon
backends synchronized or provide backend affinity. A template that conflicts
with electrs' indexed tip is rejected rather than serving potentially stale
mining work.

## License

Expand Down
133 changes: 116 additions & 17 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ lazy_static! {

const MAX_ATTEMPTS: u32 = 5;
const RETRY_WAIT_DURATION: Duration = Duration::from_secs(1);
const BLOCK_TEMPLATE_RPC_TIMEOUT: Duration = Duration::from_secs(30);

#[trace]
fn parse_hash<T>(value: &Value) -> Result<T>
Expand Down Expand Up @@ -222,6 +223,44 @@ pub trait CookieGetter: Send + Sync {
fn get(&self) -> Result<Vec<u8>>;
}

#[derive(Clone)]
struct ConnectionConfig {
addr: SocketAddr,
fallback: Option<SocketAddr>,
cookie_getter: Arc<dyn CookieGetter>,
signal: Waiter,
max_age: Option<Duration>,
}

impl ConnectionConfig {
fn connect(&self) -> Result<Connection> {
Connection::new(
self.addr,
self.fallback,
Arc::clone(&self.cookie_getter),
self.signal.clone(),
self.max_age,
)
}

fn connect_once(&self, io_timeout: Duration) -> Result<Connection> {
let (conn, active_addr) = tcp_connect_once(self.addr, self.fallback)?;
conn.set_read_timeout(Some(io_timeout))
.chain_err(|| "failed to configure one-shot daemon read timeout")?;
conn.set_write_timeout(Some(io_timeout))
.chain_err(|| "failed to configure one-shot daemon write timeout")?;
Connection::from_stream(
conn,
active_addr,
self.addr,
self.fallback,
Arc::clone(&self.cookie_getter),
self.signal.clone(),
None, // a one-shot connection never needs proactive recycling
)
}
}

struct Connection {
tx: TcpStream,
rx: Lines<BufReader<TcpStream>>,
Expand Down Expand Up @@ -516,10 +555,10 @@ pub struct Daemon {
daemon_dir: PathBuf,
blocks_dir: PathBuf,
network: Network,
connection_config: ConnectionConfig,
conn: Mutex<Connection>,
message_id: Counter, // for monotonic JSONRPC 'id'
signal: Waiter,
conn_max_age: Option<Duration>,

rpc_threads: Arc<rayon::ThreadPool>,

Expand All @@ -542,20 +581,22 @@ impl Daemon {
metrics: &Metrics,
conn_max_age: Option<Duration>,
) -> Result<Daemon> {
let connection_config = ConnectionConfig {
addr: daemon_rpc_addr,
fallback: daemon_rpc_fallback_addr,
cookie_getter,
signal: signal.clone(),
max_age: conn_max_age,
};
let conn = connection_config.connect()?;
let daemon = Daemon {
daemon_dir: daemon_dir.clone(),
blocks_dir: blocks_dir.clone(),
network,
conn: Mutex::new(Connection::new(
daemon_rpc_addr,
daemon_rpc_fallback_addr,
cookie_getter,
signal.clone(),
conn_max_age,
)?),
connection_config,
conn: Mutex::new(conn),
message_id: Counter::new(),
signal: signal.clone(),
conn_max_age,
rpc_threads: Arc::new(
rayon::ThreadPoolBuilder::new()
.num_threads(daemon_parallelism)
Expand Down Expand Up @@ -616,10 +657,10 @@ impl Daemon {
daemon_dir: self.daemon_dir.clone(),
blocks_dir: self.blocks_dir.clone(),
network: self.network,
connection_config: self.connection_config.clone(),
conn: Mutex::new(self.conn.lock().unwrap().reconnect()?),
message_id: Counter::new(),
signal: self.signal.clone(),
conn_max_age: self.conn_max_age,
rpc_threads: self.rpc_threads.clone(),
latency: self.latency.clone(),
size: self.size.clone(),
Expand Down Expand Up @@ -664,8 +705,12 @@ impl Daemon {
}

#[trace]
fn call_jsonrpc(&self, method: &str, request: &Value) -> Result<Value> {
let mut conn = self.conn.lock().unwrap();
fn call_jsonrpc_on_connection(
&self,
method: &str,
request: &Value,
conn: &mut Connection,
) -> Result<Value> {
// Proactively recycle connections older than the configured max age. Re-establishing
// the TCP connection lets a fronting load balancer (e.g. a Kubernetes ClusterSetIP)
// re-select a backend, so a long-lived connection does not stay pinned to a stale
Expand Down Expand Up @@ -711,6 +756,12 @@ impl Daemon {
Ok(result)
}

#[trace]
fn call_jsonrpc(&self, method: &str, request: &Value) -> Result<Value> {
let mut conn = self.conn.lock().unwrap();
self.call_jsonrpc_on_connection(method, request, &mut conn)
}

#[trace(method = %method)]
fn handle_request(&self, method: &str, params: &Value) -> Result<Value> {
let id = self.message_id.next();
Expand Down Expand Up @@ -746,9 +797,15 @@ impl Daemon {
self.retry_request(method, &params)
}

/// Perform one RPC on a fresh connection isolated from singleton RPC users.
/// Connection and warmup failures are returned to the caller without retrying.
#[trace]
fn request_no_retry(&self, method: &str, params: Value) -> Result<Value> {
self.handle_request(method, &params)
fn request_once(&self, method: &str, params: Value, io_timeout: Duration) -> Result<Value> {
let id = self.message_id.next();
let req = json!({"method": method, "params": params, "id": id});
let mut conn = self.connection_config.connect_once(io_timeout)?;
let reply = self.call_jsonrpc_on_connection(method, &req, &mut conn)?;
parse_jsonrpc_reply(reply, method, id)
}

#[trace]
Expand Down Expand Up @@ -938,9 +995,24 @@ impl Daemon {
Ok(serde_json::from_value(res).chain_err(|| "invalid getrawmempool reply")?)
}

#[cfg(not(feature = "liquid"))]
#[trace]
pub fn getblocktemplate(&self, rules: &[&str]) -> Result<Value> {
self.request_no_retry("getblocktemplate", json!([{ "rules": rules }]))
self.request_once(
"getblocktemplate",
json!([{ "rules": rules }]),
BLOCK_TEMPLATE_RPC_TIMEOUT,
)
}

#[cfg(feature = "liquid")]
#[trace]
pub fn getnewblockhex(&self) -> Result<String> {
let value = self.request_once("getnewblockhex", json!([]), BLOCK_TEMPLATE_RPC_TIMEOUT)?;
value
.as_str()
.map(str::to_owned)
.chain_err(|| "non-string getnewblockhex response")
}

#[trace]
Expand Down Expand Up @@ -1093,9 +1165,12 @@ impl Daemon {

#[cfg(test)]
mod tests {
use super::{parse_jsonrpc_reply, recycle_due};
use crate::errors::{Error, ErrorKind};
use super::{parse_jsonrpc_reply, recycle_due, ConnectionConfig, CookieGetter};
use crate::errors::{Error, ErrorKind, Result};
use crate::signal::Waiter;
use serde_json::json;
use std::net::TcpListener;
use std::sync::Arc;
use std::time::Duration;

const COOLDOWN: Duration = Duration::from_secs(30);
Expand All @@ -1105,6 +1180,30 @@ mod tests {
Duration::from_secs(n)
}

struct StaticCookie;

impl CookieGetter for StaticCookie {
fn get(&self) -> Result<Vec<u8>> {
Ok(b"user:password".to_vec())
}
}

#[test]
fn one_shot_connection_uses_endpoint_timeout() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let config = ConnectionConfig {
addr: listener.local_addr().unwrap(),
fallback: None,
cookie_getter: Arc::new(StaticCookie),
signal: Waiter::start(crossbeam_channel::never()),
max_age: None,
};

let connection = config.connect_once(secs(2)).unwrap();
assert_eq!(connection.tx.read_timeout().unwrap(), Some(secs(2)));
assert_eq!(connection.tx.write_timeout().unwrap(), Some(secs(2)));
}

#[test]
fn no_max_age_never_recycles() {
// Unlimited (the default): never recycle, regardless of age.
Expand Down
Loading
Loading