Skip to content

Commit f5d8528

Browse files
committed
feat(electrum): return JSON-RPC 2.0 error objects (port of mempool/electrs#103)
Implements #221. All Electrum RPC error replies are now JSON-RPC 2.0 error objects with codes instead of bare strings: -32601 unknown method, -32602 invalid params (via a new ErrorKind::InvalidParams raised by the param helpers), 1 for history-too-large, 2 for bitcoind RPC errors, -32603 otherwise. Malformed input now gets a reply instead of a dropped connection: -32700 for unparseable JSON (id null) and -32600 for valid JSON that is not a request object. A batch containing an unknown method answers its remaining entries instead of killing the connection. Deviations from the mempool port: replies always include 'id' (null when undetectable, per spec) and error messages do not echo request params back. Oversized batches and non-protocol bytes still close the connection. Co-authored-by: junderw <jonathan.underwood4649@gmail.com>
1 parent 4607b77 commit f5d8528

3 files changed

Lines changed: 209 additions & 73 deletions

File tree

src/electrum/server.rs

Lines changed: 151 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,27 @@ const MAX_ARRAY_BATCH: usize = 20;
3434
#[cfg(feature = "electrum-discovery")]
3535
use crate::electrum::{DiscoveryManager, ServerFeatures};
3636

37+
fn invalid_params(msg: impl Into<String>) -> Error {
38+
ErrorKind::InvalidParams(msg.into()).into()
39+
}
40+
3741
// TODO: Sha256dHash should be a generic hash-container (since script hash is single SHA256)
3842
fn hash_from_value(val: Option<&Value>) -> Result<Sha256dHash> {
39-
let script_hash = val.chain_err(|| "missing hash")?;
40-
let script_hash = script_hash.as_str().chain_err(|| "non-string hash")?;
41-
let script_hash = script_hash.parse().chain_err(|| "non-hex hash")?;
43+
let script_hash = val.ok_or_else(|| invalid_params("missing hash"))?;
44+
let script_hash = script_hash
45+
.as_str()
46+
.ok_or_else(|| invalid_params("non-string hash"))?;
47+
let script_hash = script_hash
48+
.parse()
49+
.map_err(|_| invalid_params("non-hex hash"))?;
4250
Ok(script_hash)
4351
}
4452

4553
fn usize_from_value(val: Option<&Value>, name: &str) -> Result<usize> {
46-
let val = val.chain_err(|| format!("missing {}", name))?;
47-
let val = val.as_u64().chain_err(|| format!("non-integer {}", name))?;
54+
let val = val.ok_or_else(|| invalid_params(format!("missing {}", name)))?;
55+
let val = val
56+
.as_u64()
57+
.ok_or_else(|| invalid_params(format!("non-integer {}", name)))?;
4858
Ok(val as usize)
4959
}
5060

@@ -56,8 +66,10 @@ fn usize_from_value_or(val: Option<&Value>, name: &str, default: usize) -> Resul
5666
}
5767

5868
fn bool_from_value(val: Option<&Value>, name: &str) -> Result<bool> {
59-
let val = val.chain_err(|| format!("missing {}", name))?;
60-
let val = val.as_bool().chain_err(|| format!("not a bool {}", name))?;
69+
let val = val.ok_or_else(|| invalid_params(format!("missing {}", name)))?;
70+
let val = val
71+
.as_bool()
72+
.ok_or_else(|| invalid_params(format!("not a bool {}", name)))?;
6173
Ok(val)
6274
}
6375

@@ -68,6 +80,52 @@ fn bool_from_value_or(val: Option<&Value>, name: &str, default: bool) -> Result<
6880
bool_from_value(val, name)
6981
}
7082

83+
// JSON-RPC 2.0 error codes (https://www.jsonrpc.org/specification#error_object),
84+
// plus the application-level codes used by ElectrumX and romanz/electrs.
85+
#[repr(i16)]
86+
#[derive(Clone, Copy, PartialEq, Eq)]
87+
enum JsonRpcV2Error {
88+
ParseError = -32700,
89+
InvalidRequest = -32600,
90+
MethodNotFound = -32601,
91+
InvalidParams = -32602,
92+
InternalError = -32603,
93+
BadRequest = 1,
94+
DaemonError = 2,
95+
}
96+
97+
impl JsonRpcV2Error {
98+
#[inline]
99+
fn into_i16(self) -> i16 {
100+
self as i16
101+
}
102+
}
103+
104+
fn jsonrpc_code(e: &Error) -> JsonRpcV2Error {
105+
match e.kind() {
106+
ErrorKind::InvalidParams(_) => JsonRpcV2Error::InvalidParams,
107+
ErrorKind::TooPopular => JsonRpcV2Error::BadRequest,
108+
ErrorKind::RpcError(..) => JsonRpcV2Error::DaemonError,
109+
_ => JsonRpcV2Error::InternalError,
110+
}
111+
}
112+
113+
#[inline]
114+
fn json_rpc_error(
115+
input: impl core::fmt::Display,
116+
id: Option<&Value>,
117+
code: JsonRpcV2Error,
118+
) -> Value {
119+
json!({
120+
"jsonrpc": "2.0",
121+
"id": id.unwrap_or(&Value::Null),
122+
"error": {
123+
"code": code.into_i16(),
124+
"message": format!("{}", input),
125+
},
126+
})
127+
}
128+
71129
// TODO: implement caching and delta updates
72130
#[trace]
73131
fn get_status_hash(txs: Vec<(Txid, Option<BlockId>)>, query: &Query) -> Option<FullHash> {
@@ -200,9 +258,10 @@ impl Connection {
200258

201259
let features = params
202260
.get(0)
203-
.chain_err(|| "missing features param")?
261+
.ok_or_else(|| invalid_params("missing features param"))?
204262
.clone();
205-
let features = serde_json::from_value(features).chain_err(|| "invalid features")?;
263+
let features =
264+
serde_json::from_value(features).map_err(|_| invalid_params("invalid features"))?;
206265

207266
discovery.add_server_request(self.addr.ip(), features)?;
208267
Ok(json!(true))
@@ -288,7 +347,7 @@ impl Connection {
288347
}
289348

290349
fn blockchain_scripthash_subscribe(&mut self, params: &[Value]) -> Result<Value> {
291-
let script_hash = hash_from_value(params.get(0)).chain_err(|| "bad script_hash")?;
350+
let script_hash = hash_from_value(params.get(0))?;
292351

293352
let history_txids = get_history(&self.query, &script_hash[..], self.txs_limit)?;
294353
let status_hash = get_status_hash(history_txids, &self.query)
@@ -301,7 +360,7 @@ impl Connection {
301360
}
302361

303362
fn blockchain_scripthash_unsubscribe(&mut self, params: &[Value]) -> Result<Value> {
304-
let script_hash = hash_from_value(params.get(0)).chain_err(|| "bad script_hash")?;
363+
let script_hash = hash_from_value(params.get(0))?;
305364

306365
match self.status_hashes.remove(&script_hash) {
307366
None => Ok(json!(false)),
@@ -314,7 +373,7 @@ impl Connection {
314373

315374
#[cfg(not(feature = "liquid"))]
316375
fn blockchain_scripthash_get_balance(&self, params: &[Value]) -> Result<Value> {
317-
let script_hash = hash_from_value(params.get(0)).chain_err(|| "bad script_hash")?;
376+
let script_hash = hash_from_value(params.get(0))?;
318377
let (chain_stats, mempool_stats) = self.query.stats(&script_hash[..]);
319378

320379
Ok(json!({
@@ -324,7 +383,7 @@ impl Connection {
324383
}
325384

326385
fn blockchain_scripthash_get_history(&self, params: &[Value]) -> Result<Value> {
327-
let script_hash = hash_from_value(params.get(0)).chain_err(|| "bad script_hash")?;
386+
let script_hash = hash_from_value(params.get(0))?;
328387
let history_txids = get_history(&self.query, &script_hash[..], self.txs_limit)?;
329388

330389
Ok(json!(history_txids
@@ -342,7 +401,7 @@ impl Connection {
342401
}
343402

344403
fn blockchain_scripthash_listunspent(&self, params: &[Value]) -> Result<Value> {
345-
let script_hash = hash_from_value(params.get(0)).chain_err(|| "bad script_hash")?;
404+
let script_hash = hash_from_value(params.get(0))?;
346405
let utxos = self.query.utxo(&script_hash[..])?;
347406

348407
let to_json = |utxo: Utxo| {
@@ -370,8 +429,11 @@ impl Connection {
370429
}
371430

372431
fn blockchain_transaction_broadcast(&self, params: &[Value]) -> Result<Value> {
373-
let tx = params.get(0).chain_err(|| "missing tx")?;
374-
let tx = tx.as_str().chain_err(|| "non-string tx")?.to_string();
432+
let tx = params.get(0).ok_or_else(|| invalid_params("missing tx"))?;
433+
let tx = tx
434+
.as_str()
435+
.ok_or_else(|| invalid_params("non-string tx"))?
436+
.to_string();
375437
let txid = self.query.broadcast_raw(&tx)?;
376438
if let Err(e) = self.sender.try_send(Message::PeriodicUpdate) {
377439
warn!("failed to issue PeriodicUpdate after broadcast: {}", e);
@@ -380,9 +442,11 @@ impl Connection {
380442
}
381443

382444
fn blockchain_transaction_get(&self, params: &[Value]) -> Result<Value> {
383-
let tx_hash = Txid::from(hash_from_value(params.get(0)).chain_err(|| "bad tx_hash")?);
445+
let tx_hash = Txid::from(hash_from_value(params.get(0))?);
384446
let verbose = match params.get(1) {
385-
Some(value) => value.as_bool().chain_err(|| "non-bool verbose value")?,
447+
Some(value) => value
448+
.as_bool()
449+
.ok_or_else(|| invalid_params("non-bool verbose value"))?,
386450
None => false,
387451
};
388452

@@ -400,15 +464,15 @@ impl Connection {
400464

401465
#[trace]
402466
fn blockchain_transaction_get_merkle(&self, params: &[Value]) -> Result<Value> {
403-
let txid = Txid::from(hash_from_value(params.get(0)).chain_err(|| "bad tx_hash")?);
467+
let txid = Txid::from(hash_from_value(params.get(0))?);
404468
let height = usize_from_value(params.get(1), "height")?;
405469
let blockid = self
406470
.query
407471
.chain()
408472
.tx_confirming_block(&txid)
409473
.ok_or_else(|| "tx not found or is unconfirmed")?;
410474
if blockid.height != height {
411-
bail!("invalid confirmation height provided");
475+
return Err(invalid_params("invalid confirmation height provided"));
412476
}
413477
let (merkle, pos) = get_tx_merkle_proof(self.query.chain(), &txid, &blockid.hash)
414478
.chain_err(|| "cannot create merkle proof")?;
@@ -474,10 +538,16 @@ impl Connection {
474538
#[cfg(feature = "electrum-discovery")]
475539
"server.add_peer" => self.server_add_peer(&params),
476540

477-
&_ => bail!("unknown method {} {:?}", method, params),
541+
&_ => {
542+
warn!("rpc #{} unknown method {} {:?}", id, method, params);
543+
return Ok(json_rpc_error(
544+
format!("unknown method {}", method),
545+
Some(id),
546+
JsonRpcV2Error::MethodNotFound,
547+
));
548+
}
478549
};
479550
timer.observe_duration();
480-
// TODO: return application errors should be sent to the client
481551
Ok(match result {
482552
Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}),
483553
Err(e) => {
@@ -488,7 +558,7 @@ impl Connection {
488558
params,
489559
e.display_chain()
490560
);
491-
json!({"jsonrpc": "2.0", "id": id, "error": format!("{}", e)})
561+
json_rpc_error(&e, Some(id), jsonrpc_code(&e))
492562
}
493563
})
494564
}
@@ -566,25 +636,28 @@ impl Connection {
566636
trace!("RPC {:?}", msg);
567637
match msg {
568638
Message::Request(line) => {
569-
let cmd: Value = from_str(&line).chain_err(|| "invalid JSON format")?;
570-
if let Value::Array(arr) = cmd {
571-
if arr.len() > MAX_ARRAY_BATCH {
572-
bail!(
573-
"Too many elements in batch requests {} max:{}",
574-
arr.len(),
575-
MAX_ARRAY_BATCH
576-
);
639+
let reply = match from_str::<Value>(&line) {
640+
Ok(Value::Array(arr)) => {
641+
if arr.len() > MAX_ARRAY_BATCH {
642+
bail!(
643+
"Too many elements in batch requests {} max:{}",
644+
arr.len(),
645+
MAX_ARRAY_BATCH
646+
);
647+
}
648+
let mut result = Vec::with_capacity(arr.len());
649+
for el in arr {
650+
result.push(self.handle_value(el, &empty_params));
651+
}
652+
Value::Array(result)
577653
}
578-
let mut result = Vec::with_capacity(arr.len());
579-
for el in arr {
580-
let reply = self.handle_value(el, &empty_params)?;
581-
result.push(reply)
654+
Ok(cmd) => self.handle_value(cmd, &empty_params),
655+
Err(err) => {
656+
warn!("[{}] invalid JSON request: {}", self.addr, err);
657+
json_rpc_error("parse error", None, JsonRpcV2Error::ParseError)
582658
}
583-
self.send_values(&[Value::Array(result)])?
584-
} else {
585-
let reply = self.handle_value(cmd, &empty_params)?;
586-
self.send_values(&[reply])?
587-
}
659+
};
660+
self.send_values(&[reply])?
588661
}
589662
Message::PeriodicUpdate => {
590663
let values = self
@@ -597,41 +670,46 @@ impl Connection {
597670
}
598671
}
599672

600-
fn handle_value(&mut self, cmd: Value, empty_params: &Value) -> Result<Value> {
673+
fn handle_value(&mut self, cmd: Value, empty_params: &Value) -> Value {
601674
let start_time = Instant::now();
602-
Ok(
603-
match (
604-
cmd.get("method"),
605-
cmd.get("params").unwrap_or_else(|| empty_params),
606-
cmd.get("id"),
607-
) {
608-
(Some(&Value::String(ref method)), &Value::Array(ref params), Some(ref id)) => {
609-
let reply = self.handle_command(method, params, id)?;
610-
611-
conditionally_log_rpc_event!(
612-
self,
613-
json!({
614-
"event": "rpc_response",
615-
"method": method,
616-
"params": if self.rpc_logging.hide_params {
617-
Value::Null
618-
} else {
619-
json!(params)
620-
},
621-
"request_size": serde_json::to_vec(&cmd).map(|v| v.len()).unwrap_or(0),
622-
"response_size": reply.to_string().as_bytes().len(),
623-
"duration_micros": start_time.elapsed().as_micros(),
624-
"id": id,
625-
})
626-
);
675+
match (
676+
cmd.get("method"),
677+
cmd.get("params").unwrap_or_else(|| empty_params),
678+
cmd.get("id"),
679+
) {
680+
(Some(&Value::String(ref method)), &Value::Array(ref params), Some(ref id)) => {
681+
let reply = self.handle_command(method, params, id).unwrap_or_else(|e| {
682+
json_rpc_error(
683+
format!("{} failed: {}", method, e),
684+
Some(id),
685+
JsonRpcV2Error::InternalError,
686+
)
687+
});
688+
689+
conditionally_log_rpc_event!(
690+
self,
691+
json!({
692+
"event": "rpc_response",
693+
"method": method,
694+
"params": if self.rpc_logging.hide_params {
695+
Value::Null
696+
} else {
697+
json!(params)
698+
},
699+
"request_size": serde_json::to_vec(&cmd).map(|v| v.len()).unwrap_or(0),
700+
"response_size": reply.to_string().as_bytes().len(),
701+
"duration_micros": start_time.elapsed().as_micros(),
702+
"id": id,
703+
})
704+
);
627705

628-
reply
629-
}
630-
_ => {
631-
bail!("invalid command: {}", cmd)
632-
}
633-
},
634-
)
706+
reply
707+
}
708+
_ => {
709+
warn!("[{}] invalid request: {}", self.addr, cmd);
710+
json_rpc_error("invalid request", cmd.get("id"), JsonRpcV2Error::InvalidRequest)
711+
}
712+
}
635713
}
636714

637715
#[trace]

src/errors.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ error_chain! {
2424
display("Too many history entries")
2525
}
2626

27+
InvalidParams(msg: String) {
28+
description("Invalid RPC params")
29+
display("{}", msg)
30+
}
31+
2732
#[cfg(feature = "electrum-discovery")]
2833
ElectrumClient(e: electrum_client::Error) {
2934
description("Electrum client error")

0 commit comments

Comments
 (0)