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

payjoin / rust-payjoin / 17103180854

20 Aug 2025 03:36PM UTC coverage: 86.529%. First build
17103180854

Pull #977

github

web-flow
Merge 29b5df59e into d7cec10fd
Pull Request #977: Replace String clones with references in CLI RPC methods

4 of 8 new or added lines in 2 files covered. (50.0%)

7862 of 9086 relevant lines covered (86.53%)

513.28 hits per line

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

96.89
/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> {
14✔
28
        let client =
14✔
29
            Client::builder().use_rustls_tls().build().context("Failed to create HTTP client")?;
14✔
30

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

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

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

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

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

84
        let response =
63✔
85
            request.send().await.with_context(|| format!("RPC '{}': connection failed", method))?;
63✔
86

87
        let json = response
63✔
88
            .json::<RpcResponse<T>>()
63✔
89
            .await
63✔
90
            .with_context(|| format!("RPC '{}': invalid response", method))?;
63✔
91

92
        match json {
63✔
93
            RpcResponse::Success { result, .. } => Ok(result),
59✔
94
            RpcResponse::Error { error, .. } =>
4✔
95
                Err(anyhow!("RPC '{}' failed: {}", method, error.message)),
4✔
96
        }
97
    }
63✔
98

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

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

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

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

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

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

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

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

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

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

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

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

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

200
    pub async fn network(&self) -> Result<Network> {
19✔
201
        let info = self.get_blockchain_info().await?;
19✔
202
        let chain = info["chain"].as_str().ok_or_else(|| anyhow!("Missing chain field"))?;
19✔
203
        match chain {
19✔
204
            "main" => Ok(Network::Bitcoin),
19✔
205
            "test" => Ok(Network::Testnet),
19✔
206
            "regtest" => Ok(Network::Regtest),
19✔
207
            "signet" => Ok(Network::Signet),
×
208
            other => Err(anyhow!("Unknown network: {}", other)),
×
209
        }
210
    }
19✔
211
}
212

213
/// JSON-RPC response envelope
214
#[derive(Serialize, Deserialize, Debug)]
215
#[serde(untagged)]
216
enum RpcResponse<T> {
217
    Success { result: T, error: Option<Value>, id: Value },
218
    Error { result: Option<Value>, error: RpcError, id: Value },
219
}
220

221
#[derive(Serialize, Deserialize, Debug)]
222
struct RpcError {
223
    code: i32,
224
    message: String,
225
}
226

227
/// Result type for testmempoolaccept RPC call - minimal struct for our use case
228
#[derive(Debug, Deserialize)]
229
pub struct TestMempoolAcceptResult {
230
    pub allowed: bool,
231
    // Ignore additional fields that Bitcoin Core v29 may include
232
}
233

234
/// Result type for getaddressinfo RPC call - minimal struct for our use case
235
#[derive(Debug, Deserialize)]
236
pub struct GetAddressInfoResult {
237
    #[serde(rename = "ismine")]
238
    pub is_mine: bool,
239
}
240

241
/// Result type for listunspent RPC call - compatible with both v26 and v29+
242
#[derive(Debug, Deserialize)]
243
pub struct ListUnspentResult {
244
    pub txid: String,
245
    pub vout: u32,
246
    #[serde(rename = "scriptPubKey")]
247
    pub script_pubkey: String,
248
    pub amount: f64,
249
    // Optional fields for compatibility with newer Bitcoin Core versions
250
    #[serde(rename = "redeemScript")]
251
    pub redeem_script: Option<String>,
252
    // Ignore additional fields that Bitcoin Core v29+ may include
253
}
254

255
/// Result type for finalizepsbt RPC call - compatible with both v26 and v29+
256
#[derive(Debug, Deserialize)]
257
pub struct FinalizePsbtResult {
258
    pub hex: Option<String>,
259
}
260

261
#[cfg(test)]
262
mod tests {
263
    use std::collections::HashMap;
264

265
    use super::*;
266

267
    const TEST_AMOUNT_SATS: u64 = 100_000;
268
    const INVALID_ADDRESS: &str = "invalid_bitcoin_address_12345";
269

270
    fn assert_rpc_error_format(error_msg: &str, method: &str, expected_keywords: &[&str]) {
4✔
271
        assert!(error_msg.contains(method));
4✔
272
        assert!(expected_keywords.iter().any(|&keyword| error_msg.contains(keyword)));
4✔
273
    }
4✔
274

275
    #[tokio::test]
276
    async fn test_rpc_error_messages_invalid_bitcoin_address() {
2✔
277
        use payjoin_test_utils::init_bitcoind;
278

279
        let bitcoind = init_bitcoind().expect("Bitcoin Core required for this test");
2✔
280
        let rpc_url = format!("http://127.0.0.1:{}", bitcoind.params.rpc_socket.port());
2✔
281
        let auth = Auth::CookieFile(bitcoind.params.cookie_file.clone());
2✔
282
        let rpc = AsyncBitcoinRpc::new(rpc_url, auth).await.unwrap();
2✔
283

284
        let outputs = HashMap::from([(
2✔
285
            INVALID_ADDRESS.to_string(),
2✔
286
            payjoin::bitcoin::Amount::from_sat(TEST_AMOUNT_SATS),
2✔
287
        )]);
2✔
288

289
        let error = rpc
2✔
290
            .wallet_create_funded_psbt(&[], &outputs, None, None, None)
2✔
291
            .await
2✔
292
            .expect_err("Should fail due to invalid address");
2✔
293
        let error_msg = error.to_string();
2✔
294
        println!("{}", error_msg);
2✔
295

296
        assert_rpc_error_format(
2✔
297
            &error_msg,
2✔
298
            "walletcreatefundedpsbt",
2✔
299
            &["address", "Invalid", "invalid"],
2✔
300
        );
2✔
301
    }
2✔
302

303
    #[tokio::test]
304
    async fn test_rpc_error_messages_insufficient_funds() {
2✔
305
        use payjoin_test_utils::init_bitcoind;
306

307
        let bitcoind = init_bitcoind().expect("Bitcoin Core required for this test");
2✔
308
        let _wallet = bitcoind.create_wallet("empty_wallet").unwrap();
2✔
309
        let rpc_url =
2✔
310
            format!("http://127.0.0.1:{}/wallet/empty_wallet", bitcoind.params.rpc_socket.port());
2✔
311
        let auth = Auth::CookieFile(bitcoind.params.cookie_file.clone());
2✔
312
        let rpc = AsyncBitcoinRpc::new(rpc_url, auth).await.unwrap();
2✔
313

314
        let valid_address =
2✔
315
            rpc.get_new_address(None, None).await.unwrap().assume_checked_ref().to_string();
2✔
316
        let outputs =
2✔
317
            HashMap::from([(valid_address, payjoin::bitcoin::Amount::from_sat(TEST_AMOUNT_SATS))]);
2✔
318

319
        let error = rpc
2✔
320
            .wallet_create_funded_psbt(&[], &outputs, None, None, None)
2✔
321
            .await
2✔
322
            .expect_err("Should fail due to insufficient funds");
2✔
323
        let error_msg = error.to_string();
2✔
324
        println!("{}", error_msg);
2✔
325

326
        assert_rpc_error_format(
2✔
327
            &error_msg,
2✔
328
            "walletcreatefundedpsbt",
2✔
329
            &["fund", "balance", "amount", "Insufficient"],
2✔
330
        );
2✔
331
    }
2✔
332
}
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