• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

payjoin / rust-payjoin / 16977800232

14 Aug 2025 10:07PM UTC coverage: 86.259% (+0.3%) from 85.916%
16977800232

push

github

web-flow
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

227 of 238 new or added lines in 5 files covered. (95.38%)

40 existing lines in 5 files now uncovered.

7709 of 8937 relevant lines covered (86.26%)

521.67 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.41
/payjoin-cli/src/app/rpc.rs
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> {
10✔
28
        let client =
10✔
29
            Client::builder().use_rustls_tls().build().context("Failed to create HTTP client")?;
10✔
30

31
        // Load credentials once at initialization - no repeated file I/O
32
        let (username, password) = match auth {
10✔
NEW
33
            Auth::UserPass(user, pass) => (user, pass),
×
34
            Auth::CookieFile(path) => {
10✔
35
                let cookie = tokio::fs::read_to_string(&path)
10✔
36
                    .await
10✔
37
                    .with_context(|| format!("Failed to read cookie file: {path:?}"))?;
10✔
38
                let parts: Vec<&str> = cookie.trim().split(':').collect();
10✔
39
                if parts.len() != 2 {
10✔
NEW
40
                    return Err(anyhow!("Invalid cookie format in file: {path:?}"));
×
41
                }
10✔
42
                (parts[0].to_string(), parts[1].to_string())
10✔
43
            }
44
        };
45

46
        Ok(Self { client, url, username, password })
10✔
47
    }
10✔
48

49
    /// Get base URL without wallet path for blockchain-level calls
50
    fn get_base_url(&self) -> String {
19✔
51
        if let Some(pos) = self.url.find("/wallet/") {
19✔
52
            self.url[..pos].to_string()
19✔
53
        } else {
NEW
54
            self.url.clone()
×
55
        }
56
    }
19✔
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>
57✔
60
    where
57✔
61
        T: for<'de> Deserialize<'de>,
57✔
62
    {
57✔
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 {
57✔
66
            "getblockchaininfo" | "getnetworkinfo" | "getmininginfo" | "getblockcount"
57✔
67
            | "getbestblockhash" | "getblock" | "getblockhash" | "gettxout" => self.get_base_url(),
38✔
68
            _ => self.url.clone(),
38✔
69
        };
70

71
        let request_body = json!({
57✔
72
            "jsonrpc": "2.0",
57✔
73
            "method": method,
57✔
74
            "params": params,
57✔
75
            "id": 1
57✔
76
        });
77

78
        let request = self
57✔
79
            .client
57✔
80
            .post(&url)
57✔
81
            .json(&request_body)
57✔
82
            .basic_auth(&self.username, Some(&self.password));
57✔
83

84
        let response = request.send().await.context("Failed to send RPC request")?;
57✔
85

86
        if !response.status().is_success() {
57✔
NEW
87
            return Err(anyhow!("RPC request failed with status: {}", response.status()));
×
88
        }
57✔
89

90
        let json: RpcResponse<T> = response.json().await.context("Failed to parse RPC response")?;
57✔
91

92
        match json {
57✔
93
            RpcResponse::Success { result, .. } => Ok(result),
57✔
NEW
94
            RpcResponse::Error { error, .. } => Err(anyhow!("RPC error: {:?}", error)),
×
95
        }
96
    }
57✔
97

98
    pub async fn wallet_create_funded_psbt(
3✔
99
        &self,
3✔
100
        inputs: &[Value],
3✔
101
        outputs: &HashMap<String, Amount>,
3✔
102
        locktime: Option<u32>,
3✔
103
        options: Option<Value>,
3✔
104
        bip32derivs: Option<bool>,
3✔
105
    ) -> Result<WalletCreateFundedPsbt> {
3✔
106
        let outputs_btc: HashMap<String, f64> =
3✔
107
            outputs.iter().map(|(addr, amount)| (addr.clone(), amount.to_btc())).collect();
3✔
108

109
        let locktime = locktime.unwrap_or(0);
3✔
110
        let options = options.unwrap_or_else(|| json!({}));
3✔
111
        let bip32derivs = bip32derivs.unwrap_or(true);
3✔
112

113
        let params = json!([inputs, outputs_btc, locktime, options, bip32derivs]);
3✔
114
        self.call_rpc("walletcreatefundedpsbt", params).await
3✔
115
    }
3✔
116

117
    pub async fn wallet_process_psbt(
9✔
118
        &self,
9✔
119
        psbt: &str,
9✔
120
        sign: Option<bool>,
9✔
121
        sighash_type: Option<String>,
9✔
122
        bip32derivs: Option<bool>,
9✔
123
    ) -> Result<WalletProcessPsbt> {
9✔
124
        let sign = sign.unwrap_or(true);
9✔
125
        let sighash_type = sighash_type.unwrap_or_else(|| "ALL".to_string());
9✔
126
        let bip32derivs = bip32derivs.unwrap_or(true);
9✔
127

128
        let params = json!([psbt, sign, sighash_type, bip32derivs]);
9✔
129
        self.call_rpc("walletprocesspsbt", params).await
9✔
130
    }
9✔
131

132
    pub async fn finalize_psbt(
3✔
133
        &self,
3✔
134
        psbt: &str,
3✔
135
        extract: Option<bool>,
3✔
136
    ) -> Result<FinalizePsbtResult> {
3✔
137
        let extract = extract.unwrap_or(true);
3✔
138
        let params = json!([psbt, extract]);
3✔
139
        self.call_rpc("finalizepsbt", params).await
3✔
140
    }
3✔
141

142
    pub async fn test_mempool_accept(
3✔
143
        &self,
3✔
144
        rawtxs: &[String],
3✔
145
    ) -> Result<Vec<TestMempoolAcceptResult>> {
3✔
146
        let params = json!([rawtxs]);
3✔
147
        self.call_rpc("testmempoolaccept", params).await
3✔
148
    }
3✔
149

150
    pub async fn send_raw_transaction(&self, hex: &[u8]) -> Result<Txid> {
3✔
151
        use payjoin::bitcoin::hex::DisplayHex;
152
        let hex_string = hex.to_lower_hex_string();
3✔
153
        let params = json!([hex_string]);
3✔
154
        let txid_string: String = self.call_rpc("sendrawtransaction", params).await?;
3✔
155
        Ok(txid_string.parse()?)
3✔
156
    }
3✔
157

158
    pub async fn get_address_info(&self, address: &Address) -> Result<GetAddressInfoResult> {
9✔
159
        let params = json!([address.to_string()]);
9✔
160
        self.call_rpc("getaddressinfo", params).await
9✔
161
    }
9✔
162

163
    pub async fn get_new_address(
5✔
164
        &self,
5✔
165
        label: Option<&str>,
5✔
166
        address_type: Option<&str>,
5✔
167
    ) -> Result<Address<payjoin::bitcoin::address::NetworkUnchecked>> {
5✔
168
        let params = if label.is_none() && address_type.is_none() {
5✔
169
            json!([])
5✔
170
        } else {
NEW
171
            json!([label, address_type])
×
172
        };
173

174
        let address_string: String = self.call_rpc("getnewaddress", params).await?;
5✔
175
        let addr: payjoin::bitcoin::Address<payjoin::bitcoin::address::NetworkUnchecked> =
5✔
176
            address_string.parse().context("Failed to parse address")?;
5✔
177
        Ok(addr)
5✔
178
    }
5✔
179

180
    pub async fn list_unspent(
3✔
181
        &self,
3✔
182
        minconf: Option<u32>,
3✔
183
        maxconf: Option<u32>,
3✔
184
        addresses: Option<&[Address]>,
3✔
185
        include_unsafe: Option<bool>,
3✔
186
        query_options: Option<Value>,
3✔
187
    ) -> Result<Vec<ListUnspentResult>> {
3✔
188
        let addresses_str: Option<Vec<String>> =
3✔
189
            addresses.map(|addrs| addrs.iter().map(|a| a.to_string()).collect());
3✔
190
        let params = json!([minconf, maxconf, addresses_str, include_unsafe, query_options]);
3✔
191
        self.call_rpc("listunspent", params).await
3✔
192
    }
3✔
193

194
    pub async fn get_blockchain_info(&self) -> Result<serde_json::Value> {
19✔
195
        let params = json!([]);
19✔
196
        self.call_rpc("getblockchaininfo", params).await
19✔
197
    }
19✔
198

199
    pub async fn network(&self) -> Result<Network> {
19✔
200
        let info = self.get_blockchain_info().await?;
19✔
201
        let chain = info["chain"].as_str().ok_or_else(|| anyhow!("Missing chain field"))?;
19✔
202
        match chain {
19✔
203
            "main" => Ok(Network::Bitcoin),
19✔
204
            "test" => Ok(Network::Testnet),
19✔
205
            "regtest" => Ok(Network::Regtest),
19✔
NEW
206
            "signet" => Ok(Network::Signet),
×
NEW
207
            other => Err(anyhow!("Unknown network: {}", other)),
×
208
        }
209
    }
19✔
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
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc