Skip to content
Draft
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,18 @@ In addition to electrs's original configuration options, a few new options are a
- `--cors <origins>` - origins allowed to make cross-site request (optional, defaults to none).
- `--address-search` - enables the by-prefix address search index.
- `--index-unspendables` - enables indexing of provably unspendable outputs.
- `--enable-mining-rest` - enables cached mining-related HTTP endpoints.
- `--utxos-limit <num>` - maximum number of utxos to return per address.
- `--electrum-txs-limit <num>` - maximum number of txs to return per address in the electrum server (does not apply for the http api).
- `--electrum-banner <text>` - welcome banner text for electrum server.

### 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.

Additional options with the `liquid` feature:
- `--parent-network <network>` - the parent network this chain is pegged to.

Expand Down
7 changes: 7 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub struct Config {
pub light_mode: bool,
pub address_search: bool,
pub index_unspendables: bool,
pub enable_mining_rest: bool,
pub cors: Option<String>,
pub precache_scripts: Option<String>,
pub utxos_limit: usize,
Expand Down Expand Up @@ -212,6 +213,11 @@ impl Config {
.long("index-unspendables")
.help("Enable indexing of provably unspendable outputs")
)
.arg(
Arg::with_name("enable_mining_rest")
.long("enable-mining-rest")
.help("Enable cached mining-related HTTP endpoints")
)
.arg(
Arg::with_name("cors")
.long("cors")
Expand Down Expand Up @@ -531,6 +537,7 @@ impl Config {
light_mode: m.is_present("light_mode"),
address_search: m.is_present("address_search"),
index_unspendables: m.is_present("index_unspendables"),
enable_mining_rest: m.is_present("enable_mining_rest"),
cors: m.value_of("cors").map(|s| s.to_string()),
precache_scripts: m.value_of("precache_scripts").map(|s| s.to_string()),
db_block_cache_mb: value_t_or_exit!(m, "db_block_cache_mb", usize),
Expand Down
47 changes: 44 additions & 3 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,11 @@ fn parse_jsonrpc_reply(mut reply: Value, method: &str, expected_id: u64) -> Resu
.as_str()
.map_or_else(|| err.to_string(), |s| s.to_string());
match code {
// RPC_IN_WARMUP -> retry by later reconnection
-28 => bail!(ErrorKind::Connection(err.to_string())),
// RPC_IN_WARMUP -> retry by later reconnection, except for
// getblocktemplate where callers need the RPC code surfaced.
-28 if method != "getblocktemplate" => {
bail!(ErrorKind::Connection(err.to_string()))
}
code => bail!(ErrorKind::RpcError(code, msg, method.to_string())),
}
}
Expand Down Expand Up @@ -890,6 +893,11 @@ impl Daemon {
Ok(serde_json::from_value(res).chain_err(|| "invalid getrawmempool reply")?)
}

#[trace]
pub fn getblocktemplate(&self, rules: &[&str]) -> Result<Value> {
self.request("getblocktemplate", json!([{ "rules": rules }]))
}

#[trace]
pub fn broadcast(&self, tx: &Transaction) -> Result<Txid> {
self.broadcast_raw(&serialize_hex(tx))
Expand Down Expand Up @@ -1040,7 +1048,9 @@ impl Daemon {

#[cfg(test)]
mod tests {
use super::recycle_due;
use super::{parse_jsonrpc_reply, recycle_due};
use crate::errors::{Error, ErrorKind};
use serde_json::json;
use std::time::Duration;

const COOLDOWN: Duration = Duration::from_secs(30);
Expand Down Expand Up @@ -1077,4 +1087,35 @@ mod tests {
fn expired_after_cooldown_retries() {
assert!(recycle_due(secs(600), MAX_AGE, Some(secs(31)), COOLDOWN));
}

#[test]
fn getblocktemplate_warmup_is_not_retried_as_connection() {
let reply = json!({
"result": null,
"error": { "code": -28, "message": "warming up" },
"id": 1
});
match parse_jsonrpc_reply(reply, "getblocktemplate", 1) {
Err(Error(ErrorKind::RpcError(-28, message, method), _)) => {
assert_eq!(message, "warming up");
assert_eq!(method, "getblocktemplate");
}
other => panic!("unexpected getblocktemplate warmup result: {:?}", other),
}
}

#[test]
fn other_warmup_errors_remain_connection_errors() {
let reply = json!({
"result": null,
"error": { "code": -28, "message": "warming up" },
"id": 1
});
match parse_jsonrpc_reply(reply, "getblockchaininfo", 1) {
Err(Error(ErrorKind::Connection(message), _)) => {
assert!(message.contains(r#""code":-28"#));
}
other => panic!("unexpected getblockchaininfo warmup result: {:?}", other),
}
}
}
2 changes: 1 addition & 1 deletion src/new_index/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ pub mod zmq;
pub use self::db::{DBRow, DB};
pub use self::fetch::{BlockEntry, FetchFrom};
pub use self::mempool::Mempool;
pub use self::query::Query;
pub use self::query::{Query, GETBLOCKTEMPLATE_TTL};
pub use self::schema::{
compute_script_hash, parse_hash, ChainQuery, FundingInfo, GetAmountVal, Indexer, ScriptStats,
SpendingInfo, SpendingInput, Store, TxHistoryInfo, TxHistoryKey, TxHistoryRow, Utxo,
Expand Down
55 changes: 54 additions & 1 deletion src/new_index/query.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::collections::{BTreeSet, HashMap};
use std::sync::{Arc, RwLock, RwLockReadGuard};
use std::sync::{Arc, Mutex, RwLock, RwLockReadGuard};
use std::time::{Duration, Instant};

use crate::chain::{Network, OutPoint, Transaction, TxOut, Txid};
Expand All @@ -10,6 +10,7 @@ use crate::new_index::{ChainQuery, Mempool, ScriptStats, SpendingInput, Utxo};
use crate::util::{is_spendable, BlockId, Bytes, TransactionStatus};

use electrs_macros::trace;
use serde_json::Value;

#[cfg(feature = "liquid")]
use crate::{
Expand All @@ -18,6 +19,7 @@ use crate::{
};

const FEE_ESTIMATES_TTL: u64 = 60; // seconds
pub const GETBLOCKTEMPLATE_TTL: u64 = 15; // seconds

const CONF_TARGETS: [u16; 28] = [
1u16, 2u16, 3u16, 4u16, 5u16, 6u16, 7u16, 8u16, 9u16, 10u16, 11u16, 12u16, 13u16, 14u16, 15u16,
Expand All @@ -31,10 +33,18 @@ pub struct Query {
config: Arc<Config>,
cached_estimates: RwLock<(HashMap<u16, f64>, Option<Instant>)>,
cached_relayfee: RwLock<Option<f64>>,
cached_block_template: Mutex<BlockTemplateCache>,
#[cfg(feature = "liquid")]
asset_db: Option<Arc<RwLock<AssetRegistry>>>,
}

#[derive(Default)]
struct BlockTemplateCache {
tip: Option<crate::chain::BlockHash>,
fetched_at: Option<Instant>,
value: Option<Value>,
}

impl Query {
#[cfg(not(feature = "liquid"))]
pub fn new(
Expand All @@ -50,6 +60,7 @@ impl Query {
config,
cached_estimates: RwLock::new((HashMap::new(), None)),
cached_relayfee: RwLock::new(None),
cached_block_template: Mutex::new(BlockTemplateCache::default()),
}
}

Expand Down Expand Up @@ -90,6 +101,34 @@ impl Query {
self.daemon.submit_package(txhex, maxfeerate, maxburnamount)
}

#[trace]
pub fn getblocktemplate(&self) -> Result<Value> {
let tip = self.chain.best_hash();
{
let cache = self.cached_block_template.lock().unwrap();
if cache.tip == Some(tip)
&& cache.fetched_at.map_or(false, |fetched_at| {
fetched_at.elapsed() < Duration::from_secs(GETBLOCKTEMPLATE_TTL)
})
{
if let Some(value) = &cache.value {
return Ok(value.clone());
}
}
}

let value = self
.daemon
.getblocktemplate(block_template_rules(self.config.network_type))?;
let mut cache = self.cached_block_template.lock().unwrap();
*cache = BlockTemplateCache {
tip: Some(tip),
fetched_at: Some(Instant::now()),
value: Some(value.clone()),
};
Ok(value)
}

#[trace]
pub fn utxo(&self, scripthash: &[u8]) -> Result<Vec<Utxo>> {
let mut utxos = self.chain.utxo(scripthash, self.config.utxos_limit)?;
Expand Down Expand Up @@ -267,6 +306,7 @@ impl Query {
asset_db,
cached_estimates: RwLock::new((HashMap::new(), None)),
cached_relayfee: RwLock::new(None),
cached_block_template: Mutex::new(BlockTemplateCache::default()),
}
}

Expand Down Expand Up @@ -300,3 +340,16 @@ impl Query {
Ok((total_num, results))
}
}

#[cfg(not(feature = "liquid"))]
fn block_template_rules(network: Network) -> &'static [&'static str] {
match network {
Network::Signet => &["segwit", "signet"],
_ => &["segwit"],
}
}

#[cfg(feature = "liquid")]
fn block_template_rules(_network: Network) -> &'static [&'static str] {
&["segwit"]
}
93 changes: 91 additions & 2 deletions src/rest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::chain::{
};
use crate::config::Config;
use crate::errors;
use crate::new_index::{compute_script_hash, Query, SpendingInput, Utxo};
use crate::new_index::{compute_script_hash, Query, SpendingInput, Utxo, GETBLOCKTEMPLATE_TTL};
#[cfg(feature = "liquid")]
use crate::util::optional_value_for_newer_blocks;
use crate::util::{
Expand Down Expand Up @@ -38,6 +38,7 @@ use {
};

use serde::Serialize;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::num::ParseIntError;
use std::os::unix::fs::FileTypeExt;
Expand Down Expand Up @@ -1150,6 +1151,15 @@ fn handle_request(
json_response(query.estimate_fee_map(), TTL_SHORT)
}

(&Method::GET, Some(&"block-template"), None, None, None, None) => {
if !config.enable_mining_rest {
return Err(HttpError::forbidden(
"mining REST endpoints are disabled".to_string(),
));
}
getblocktemplate_response(query.getblocktemplate())
}

#[cfg(feature = "liquid")]
(&Method::GET, Some(&"assets"), Some(&"registry"), None, None, None) => {
let start_index: usize = query_params
Expand Down Expand Up @@ -1291,14 +1301,58 @@ where
}

fn json_response<T: Serialize>(value: T, ttl: u32) -> Result<Response<Full<Bytes>>, HttpError> {
json_response_with_status(value, StatusCode::OK, ttl)
}

fn json_response_with_status<T: Serialize>(
value: T,
status: StatusCode,
ttl: u32,
) -> Result<Response<Full<Bytes>>, HttpError> {
let value = serde_json::to_string(&value)?;
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/json")
.header("Cache-Control", format!("public, max-age={:}", ttl))
.body(Full::new(Bytes::from(value)))
.unwrap())
}

fn getblocktemplate_response(
result: errors::Result<JsonValue>,
) -> Result<Response<Full<Bytes>>, HttpError> {
match result {
Ok(template) => json_response(template, GETBLOCKTEMPLATE_TTL as u32),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we probably want to document that the endpoint is for block explorers and not mining work as the 15 second staleness could be an issue there.

Err(err) => {
if let Some((code, message)) = getblocktemplate_rpc_error(&err) {
return json_response_with_status(
json!({ "error": { "code": code, "message": message } }),
StatusCode::BAD_GATEWAY,
0,
);
}
Err(HttpError::from(err))
}
}
}

fn getblocktemplate_rpc_error(err: &errors::Error) -> Option<(i64, String)> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review finding:

The Connection error handling here probably will not behave as intended.
Daemon::request() retries ErrorKind::Connection internally, so this REST error path is unlikely to see those errors.

For example, bitcoind warmup error -28 is converted into ErrorKind::Connection, which means /getblocktemplate would keep retrying instead of returning the intended 502 JSON response.

One related concern: Query::getblocktemplate() holds the block template cache mutex while making the daemon RPC call. If that RPC gets stuck retrying, later /getblocktemplate requests will block behind the same mutex.

match err.kind() {
errors::ErrorKind::RpcError(code, message, method) if method == "getblocktemplate" => {
Some((*code, message.clone()))
}
errors::ErrorKind::Connection(message) => parse_rpc_error_json(message),
_ => None,
}
}

fn parse_rpc_error_json(message: &str) -> Option<(i64, String)> {
let value: JsonValue = serde_json::from_str(message).ok()?;
let code = value.get("code")?.as_i64()?;
let message = value.get("message")?.as_str()?.to_string();
Some((code, message))
}

#[trace]
fn blocks(query: &Query, start_height: Option<usize>) -> Result<Response<Full<Bytes>>, HttpError> {
let mut values = Vec::new();
Expand Down Expand Up @@ -1381,6 +1435,10 @@ impl HttpError {
fn not_found(msg: String) -> Self {
HttpError(StatusCode::NOT_FOUND, msg)
}

fn forbidden(msg: String) -> Self {
HttpError(StatusCode::FORBIDDEN, msg)
}
}

impl From<String> for HttpError {
Expand Down Expand Up @@ -1455,7 +1513,7 @@ impl From<address::AddressError> for HttpError {

#[cfg(test)]
mod tests {
use crate::rest::HttpError;
use crate::{errors, rest::HttpError};
use serde_json::Value;
use std::collections::HashMap;

Expand Down Expand Up @@ -1520,4 +1578,35 @@ mod tests {

assert!(err.is_err());
}

#[test]
fn test_parse_rpc_error_json() {
assert_eq!(
super::parse_rpc_error_json(r#"{"code":-28,"message":"warming up"}"#),
Some((-28, "warming up".to_string()))
);
assert_eq!(super::parse_rpc_error_json("not json"), None);
}

#[test]
fn test_getblocktemplate_rpc_error() {
let err: errors::Error = errors::ErrorKind::RpcError(
-8,
"getblocktemplate must be called with the segwit rule set".to_string(),
"getblocktemplate".to_string(),
)
.into();
assert_eq!(
super::getblocktemplate_rpc_error(&err),
Some((
-8,
"getblocktemplate must be called with the segwit rule set".to_string()
))
);

let other_method: errors::Error =
errors::ErrorKind::RpcError(-5, "Block not found".to_string(), "getblock".to_string())
.into();
assert_eq!(super::getblocktemplate_rpc_error(&other_method), None);
}
}
1 change: 1 addition & 0 deletions tests/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ impl TestRunner {
light_mode: false,
address_search: true,
index_unspendables: false,
enable_mining_rest: true,
cors: None,
precache_scripts: None,
utxos_limit: 100,
Expand Down
Loading
Loading