Skip to content

Commit 9ca2231

Browse files
authored
Replace bitcoincore-rpc with custom reqwest client (#945)
Eliminates minreq dependency conflict and adds HTTPS support by implementing async RPC client using reqwest and corepc-types. - Uses project-wide reqwest instead of minreq - Enables HTTPS connections via rustls - Implements 9 required RPC methods with sync wrapper - Supports cookie file and username/password auth Fixes #350
2 parents ae57a92 + a5a4bdf commit 9ca2231

File tree

11 files changed

+409
-110
lines changed

11 files changed

+409
-110
lines changed

Cargo-minimal.lock

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,17 @@ version = "0.8.7"
611611
source = "registry+https://github.com/rust-lang/crates.io-index"
612612
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
613613

614+
[[package]]
615+
name = "corepc-types"
616+
version = "0.8.0"
617+
source = "registry+https://github.com/rust-lang/crates.io-index"
618+
checksum = "5a7f2faedffc7c654e348e2da6c6416090525c6072979fee9681d620d1d398a4"
619+
dependencies = [
620+
"bitcoin",
621+
"serde",
622+
"serde_json",
623+
]
624+
614625
[[package]]
615626
name = "cpufeatures"
616627
version = "0.1.5"
@@ -1672,11 +1683,12 @@ version = "0.2.0"
16721683
dependencies = [
16731684
"anyhow",
16741685
"async-trait",
1675-
"bitcoincore-rpc",
16761686
"clap",
16771687
"config",
1688+
"corepc-types",
16781689
"dirs",
16791690
"env_logger",
1691+
"futures",
16801692
"http-body-util",
16811693
"hyper",
16821694
"hyper-rustls",
@@ -1689,6 +1701,7 @@ dependencies = [
16891701
"reqwest",
16901702
"rustls 0.22.4",
16911703
"serde",
1704+
"serde_json",
16921705
"sled",
16931706
"tempfile",
16941707
"tokio",

Cargo-recent.lock

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,17 @@ version = "0.8.7"
611611
source = "registry+https://github.com/rust-lang/crates.io-index"
612612
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
613613

614+
[[package]]
615+
name = "corepc-types"
616+
version = "0.8.0"
617+
source = "registry+https://github.com/rust-lang/crates.io-index"
618+
checksum = "5a7f2faedffc7c654e348e2da6c6416090525c6072979fee9681d620d1d398a4"
619+
dependencies = [
620+
"bitcoin",
621+
"serde",
622+
"serde_json",
623+
]
624+
614625
[[package]]
615626
name = "cpufeatures"
616627
version = "0.1.5"
@@ -1672,11 +1683,12 @@ version = "0.2.0"
16721683
dependencies = [
16731684
"anyhow",
16741685
"async-trait",
1675-
"bitcoincore-rpc",
16761686
"clap",
16771687
"config",
1688+
"corepc-types",
16781689
"dirs",
16791690
"env_logger",
1691+
"futures",
16801692
"http-body-util",
16811693
"hyper",
16821694
"hyper-rustls",
@@ -1689,6 +1701,7 @@ dependencies = [
16891701
"reqwest",
16901702
"rustls 0.22.4",
16911703
"serde",
1704+
"serde_json",
16921705
"sled",
16931706
"tempfile",
16941707
"tokio",

payjoin-cli/Cargo.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,20 @@ v2 = ["payjoin/v2", "payjoin/io"]
2828
[dependencies]
2929
anyhow = "1.0.70"
3030
async-trait = "0.1"
31-
bitcoincore-rpc = "0.19.0"
3231
clap = { version = "~4.0.32", features = ["derive"] }
3332
config = "0.13.3"
33+
corepc-types = "0.8.0"
3434
env_logger = "0.9.0"
35+
futures = "0.3"
3536
http-body-util = { version = "0.1", optional = true }
3637
hyper = { version = "1", features = ["http1", "server"], optional = true }
3738
hyper-rustls = { version = "0.26", optional = true }
3839
hyper-util = { version = "0.1", optional = true }
3940
log = "0.4.7"
4041
payjoin = { version = "0.24.0", default-features = false }
4142
rcgen = { version = "0.11.1", optional = true }
42-
reqwest = { version = "0.12", default-features = false }
43+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
44+
serde_json = "1.0"
4345
rustls = { version = "0.22.4", optional = true }
4446
serde = { version = "1.0.160", features = ["derive"] }
4547
sled = "0.34"

payjoin-cli/src/app/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
use std::collections::HashMap;
22

33
use anyhow::{anyhow, Result};
4-
use bitcoincore_rpc::bitcoin::Amount;
54
use payjoin::bitcoin::psbt::Psbt;
6-
use payjoin::bitcoin::FeeRate;
5+
use payjoin::bitcoin::{Amount, FeeRate};
76
use payjoin::{bitcoin, PjUri};
87
use tokio::signal;
98
use tokio::sync::watch;
109

1110
pub mod config;
11+
pub mod rpc;
1212
pub mod wallet;
1313
use crate::app::config::Config;
1414
use crate::app::wallet::BitcoindWallet;
@@ -20,7 +20,7 @@ pub(crate) mod v2;
2020

2121
#[async_trait::async_trait]
2222
pub trait App: Send + Sync {
23-
fn new(config: Config) -> Result<Self>
23+
async fn new(config: Config) -> Result<Self>
2424
where
2525
Self: Sized;
2626
fn wallet(&self) -> BitcoindWallet;

payjoin-cli/src/app/rpc.rs

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
use std::collections::HashMap;
2+
use std::path::PathBuf;
3+
4+
use anyhow::{anyhow, Context, Result};
5+
use corepc_types::v26::{WalletCreateFundedPsbt, WalletProcessPsbt};
6+
use payjoin::bitcoin::{Address, Amount, Network, Txid};
7+
use reqwest::Client;
8+
use serde::{Deserialize, Serialize};
9+
use serde_json::{json, Value};
10+
11+
/// Authentication method for Bitcoin Core RPC
12+
#[derive(Clone, Debug)]
13+
pub enum Auth {
14+
UserPass(String, String),
15+
CookieFile(PathBuf),
16+
}
17+
18+
/// Internal async Bitcoin RPC client using reqwest
19+
pub struct AsyncBitcoinRpc {
20+
client: Client,
21+
url: String,
22+
username: String,
23+
password: String,
24+
}
25+
26+
impl AsyncBitcoinRpc {
27+
pub async fn new(url: String, auth: Auth) -> Result<Self> {
28+
let client =
29+
Client::builder().use_rustls_tls().build().context("Failed to create HTTP client")?;
30+
31+
// Load credentials once at initialization - no repeated file I/O
32+
let (username, password) = match auth {
33+
Auth::UserPass(user, pass) => (user, pass),
34+
Auth::CookieFile(path) => {
35+
let cookie = tokio::fs::read_to_string(&path)
36+
.await
37+
.with_context(|| format!("Failed to read cookie file: {path:?}"))?;
38+
let parts: Vec<&str> = cookie.trim().split(':').collect();
39+
if parts.len() != 2 {
40+
return Err(anyhow!("Invalid cookie format in file: {path:?}"));
41+
}
42+
(parts[0].to_string(), parts[1].to_string())
43+
}
44+
};
45+
46+
Ok(Self { client, url, username, password })
47+
}
48+
49+
/// Get base URL without wallet path for blockchain-level calls
50+
fn get_base_url(&self) -> String {
51+
if let Some(pos) = self.url.find("/wallet/") {
52+
self.url[..pos].to_string()
53+
} else {
54+
self.url.clone()
55+
}
56+
}
57+
58+
/// Make a JSON-RPC call to Bitcoin Core
59+
async fn call_rpc<T>(&self, method: &str, params: serde_json::Value) -> Result<T>
60+
where
61+
T: for<'de> Deserialize<'de>,
62+
{
63+
// Determine which URL to use based on the method
64+
// Blockchain/network calls go to base URL, wallet calls go to wallet URL
65+
let url = match method {
66+
"getblockchaininfo" | "getnetworkinfo" | "getmininginfo" | "getblockcount"
67+
| "getbestblockhash" | "getblock" | "getblockhash" | "gettxout" => self.get_base_url(),
68+
_ => self.url.clone(),
69+
};
70+
71+
let request_body = json!({
72+
"jsonrpc": "2.0",
73+
"method": method,
74+
"params": params,
75+
"id": 1
76+
});
77+
78+
let request = self
79+
.client
80+
.post(&url)
81+
.json(&request_body)
82+
.basic_auth(&self.username, Some(&self.password));
83+
84+
let response = request.send().await.context("Failed to send RPC request")?;
85+
86+
if !response.status().is_success() {
87+
return Err(anyhow!("RPC request failed with status: {}", response.status()));
88+
}
89+
90+
let json: RpcResponse<T> = response.json().await.context("Failed to parse RPC response")?;
91+
92+
match json {
93+
RpcResponse::Success { result, .. } => Ok(result),
94+
RpcResponse::Error { error, .. } => Err(anyhow!("RPC error: {:?}", error)),
95+
}
96+
}
97+
98+
pub async fn wallet_create_funded_psbt(
99+
&self,
100+
inputs: &[Value],
101+
outputs: &HashMap<String, Amount>,
102+
locktime: Option<u32>,
103+
options: Option<Value>,
104+
bip32derivs: Option<bool>,
105+
) -> Result<WalletCreateFundedPsbt> {
106+
let outputs_btc: HashMap<String, f64> =
107+
outputs.iter().map(|(addr, amount)| (addr.clone(), amount.to_btc())).collect();
108+
109+
let locktime = locktime.unwrap_or(0);
110+
let options = options.unwrap_or_else(|| json!({}));
111+
let bip32derivs = bip32derivs.unwrap_or(true);
112+
113+
let params = json!([inputs, outputs_btc, locktime, options, bip32derivs]);
114+
self.call_rpc("walletcreatefundedpsbt", params).await
115+
}
116+
117+
pub async fn wallet_process_psbt(
118+
&self,
119+
psbt: &str,
120+
sign: Option<bool>,
121+
sighash_type: Option<String>,
122+
bip32derivs: Option<bool>,
123+
) -> Result<WalletProcessPsbt> {
124+
let sign = sign.unwrap_or(true);
125+
let sighash_type = sighash_type.unwrap_or_else(|| "ALL".to_string());
126+
let bip32derivs = bip32derivs.unwrap_or(true);
127+
128+
let params = json!([psbt, sign, sighash_type, bip32derivs]);
129+
self.call_rpc("walletprocesspsbt", params).await
130+
}
131+
132+
pub async fn finalize_psbt(
133+
&self,
134+
psbt: &str,
135+
extract: Option<bool>,
136+
) -> Result<FinalizePsbtResult> {
137+
let extract = extract.unwrap_or(true);
138+
let params = json!([psbt, extract]);
139+
self.call_rpc("finalizepsbt", params).await
140+
}
141+
142+
pub async fn test_mempool_accept(
143+
&self,
144+
rawtxs: &[String],
145+
) -> Result<Vec<TestMempoolAcceptResult>> {
146+
let params = json!([rawtxs]);
147+
self.call_rpc("testmempoolaccept", params).await
148+
}
149+
150+
pub async fn send_raw_transaction(&self, hex: &[u8]) -> Result<Txid> {
151+
use payjoin::bitcoin::hex::DisplayHex;
152+
let hex_string = hex.to_lower_hex_string();
153+
let params = json!([hex_string]);
154+
let txid_string: String = self.call_rpc("sendrawtransaction", params).await?;
155+
Ok(txid_string.parse()?)
156+
}
157+
158+
pub async fn get_address_info(&self, address: &Address) -> Result<GetAddressInfoResult> {
159+
let params = json!([address.to_string()]);
160+
self.call_rpc("getaddressinfo", params).await
161+
}
162+
163+
pub async fn get_new_address(
164+
&self,
165+
label: Option<&str>,
166+
address_type: Option<&str>,
167+
) -> Result<Address<payjoin::bitcoin::address::NetworkUnchecked>> {
168+
let params = if label.is_none() && address_type.is_none() {
169+
json!([])
170+
} else {
171+
json!([label, address_type])
172+
};
173+
174+
let address_string: String = self.call_rpc("getnewaddress", params).await?;
175+
let addr: payjoin::bitcoin::Address<payjoin::bitcoin::address::NetworkUnchecked> =
176+
address_string.parse().context("Failed to parse address")?;
177+
Ok(addr)
178+
}
179+
180+
pub async fn list_unspent(
181+
&self,
182+
minconf: Option<u32>,
183+
maxconf: Option<u32>,
184+
addresses: Option<&[Address]>,
185+
include_unsafe: Option<bool>,
186+
query_options: Option<Value>,
187+
) -> Result<Vec<ListUnspentResult>> {
188+
let addresses_str: Option<Vec<String>> =
189+
addresses.map(|addrs| addrs.iter().map(|a| a.to_string()).collect());
190+
let params = json!([minconf, maxconf, addresses_str, include_unsafe, query_options]);
191+
self.call_rpc("listunspent", params).await
192+
}
193+
194+
pub async fn get_blockchain_info(&self) -> Result<serde_json::Value> {
195+
let params = json!([]);
196+
self.call_rpc("getblockchaininfo", params).await
197+
}
198+
199+
pub async fn network(&self) -> Result<Network> {
200+
let info = self.get_blockchain_info().await?;
201+
let chain = info["chain"].as_str().ok_or_else(|| anyhow!("Missing chain field"))?;
202+
match chain {
203+
"main" => Ok(Network::Bitcoin),
204+
"test" => Ok(Network::Testnet),
205+
"regtest" => Ok(Network::Regtest),
206+
"signet" => Ok(Network::Signet),
207+
other => Err(anyhow!("Unknown network: {}", other)),
208+
}
209+
}
210+
}
211+
212+
/// JSON-RPC response envelope
213+
#[derive(Serialize, Deserialize, Debug)]
214+
#[serde(untagged)]
215+
enum RpcResponse<T> {
216+
Success { result: T, error: Option<Value>, id: Value },
217+
Error { result: Option<Value>, error: RpcError, id: Value },
218+
}
219+
220+
#[derive(Serialize, Deserialize, Debug)]
221+
struct RpcError {
222+
code: i32,
223+
message: String,
224+
}
225+
226+
/// Result type for testmempoolaccept RPC call - minimal struct for our use case
227+
#[derive(Debug, Deserialize)]
228+
pub struct TestMempoolAcceptResult {
229+
pub allowed: bool,
230+
// Ignore additional fields that Bitcoin Core v29 may include
231+
}
232+
233+
/// Result type for getaddressinfo RPC call - minimal struct for our use case
234+
#[derive(Debug, Deserialize)]
235+
pub struct GetAddressInfoResult {
236+
#[serde(rename = "ismine")]
237+
pub is_mine: bool,
238+
}
239+
240+
/// Result type for listunspent RPC call - compatible with both v26 and v29+
241+
#[derive(Debug, Deserialize)]
242+
pub struct ListUnspentResult {
243+
pub txid: String,
244+
pub vout: u32,
245+
#[serde(rename = "scriptPubKey")]
246+
pub script_pubkey: String,
247+
pub amount: f64,
248+
// Optional fields for compatibility with newer Bitcoin Core versions
249+
#[serde(rename = "redeemScript")]
250+
pub redeem_script: Option<String>,
251+
// Ignore additional fields that Bitcoin Core v29+ may include
252+
}
253+
254+
/// Result type for finalizepsbt RPC call - compatible with both v26 and v29+
255+
#[derive(Debug, Deserialize)]
256+
pub struct FinalizePsbtResult {
257+
pub hex: Option<String>,
258+
}

0 commit comments

Comments
 (0)