Build, Sign and Broadcast PSBTs in Rust — Part 2
Exploring the technical side of Partially Signed Bitcoin Transactions by simulating a CoinJoin with separate wallet in Rust.
In this part, we’ll focus on completing the guide to demonstrate how to perform manual Coinjoins using PSBTs on the regtest network.
In the first part of the article, we went over the What, When and How of PSBTs and Coinjoins on the Bitcoin Network, and as well as setup the basic things we need to complete the implementation. You can check it out here.
Now let’s start by adding some of the rust crates we’ll be needing to get things done.
Add the following crates to your dependencies in cargo.toml
file:
dotenvy = "=0.15.7"
reqwest = { version = "0.12", features = ["blocking", "json"] }
serde_json = "1.0.115"
base64 = "0.13"
The above crates are needed for the following:
dotenvy
loads environment variables from a.env
file. We’ll need this to load the Bitcoin Coreauth
information details we’ll store in a.env
file at runtime.reqwest
is an HTTP client for Rust. We’ll use that to make RPC API calls to Bitcoin Core.serde_json
serializes and deserializes data. We’ll use that to serialize and deserialize responses from Core.base64
encodes and decodes base64 as bytes or utf8. We’ll use that to encode ourauth
credentials.
Are we good?
Next, create a .env
file at the root directory of the project and fill it in with the Core auth
details and the port
number Bitcoin Core is currently running on your machine.
RPC_USER=<your_user>
RPC_PASSWORD=<your_password>
RPC_HOST=<host_url>
We’ll move to creating a function to help us make RPC calls to Bitcoin Core and get responses back to us. To keep things simple, we will be keeping all the workflow functionality inside the same file (src/main.rs
) but separated into different functions.
I’ll be referring to Bitcoin Core as “Core” throughout the rest of the article. So always remember I’m referring to Bitcoin Core whenever you see the word “Core”.
Now open your src/main.rs
file and let’s start by importing the libraries we added to our project earlier.
use std::env;
use base64;
use dotenvy::dotenv;
use std::error::Error;
use reqwest::blocking::Client as ReqClient;
use serde_json::{json, Value};
Next let’s create a new function called send_rpc_request
, the function will accept two parameters for now, method
and params
. method
is the RPC API we’re sending the request to, while params
are the arguments passed to the method
. This function will return a Result
type of a valid JSON value or an Error.
fn send_rpc_request(method: &str, params: &Value) -> Result<Value, Box<dyn Error>> {
let rpc_url = env::var("RPC_HOST").expect("RPC_HOST not found in environment");
let rpc_user = env::var("RPC_USER").expect("RPC_USER not found in environment");
let rpc_password = env::var("RPC_PASSWORD").expect("RPC_PASSWORD not found in environment");
let client = ReqClient::new();
let credentials = format!("{}:{}", rpc_user, rpc_password);
let encoded_credentials = format!("Basic {}", base64::encode(credentials));
let auth = reqwest::header::HeaderValue::from_str(&encoded_credentials.as_str()).unwrap();
let request_body = json!({
"jsonrpc": "1.0",
"id": "curltest",
"method": method,
"params": params,
});
let response = client.post(rpc_url)
.header(reqwest::header::CONTENT_TYPE, "text/plain")
.header(reqwest::header::AUTHORIZATION, auth)
.body(request_body.to_string())
.send()?;
let response_text = response.text()?;
let json_response: Value = serde_json::from_str(&response_text)?;
Ok(json_response)
}
The function body of the code above does the following:
- Loads the
auth
details into the environment. - Creates a new
reqwest
client. - Creates a String interpolation with our
auth
details, encodes the credentials and converts the encodedauth
String to an HttpHeaderValue
. - Constructs the
request
body and makes an API call to Core with the provided details. - Deserialize response from Core and return the response.
Let’s be sure the function is working as it should by making an RPC call to Core to list the current loaded wallet utxos (unspent transaction outputs). Update your main
function with the code below:
fn main() {
dotenv().ok();
let response = send_rpc_request("listunspent", &json!([]));
match response {
Ok(unspent_tx_outputs) => {
let utxos: Option<Vec<UnspentTxOutputs>> = deserialize_response(&unspent_tx_outputs).unwrap();
for utxo in utxos.unwrap().iter() {
println!("{:?}", utxo);
}
}
Err(e) => println!("Error here: {:?}", e),
}
}
Looking at the code above you’ll observe there’s a lot going on, and somethings were introduced as well. Let me explain what is going on here below.
- We loaded the
.env
file from the current directory. - A request was made to Core to list the utxos of the current loaded wallet.
- Remember the
send_rpc_request
function returns aResult<Value, Box<dyn Error>>
so we’ll need to match on theValue
andError
- Deserialize the utxo
Value
, loop through it and print the details to the console. - Handle
Error
(printing to the console at the moment)
Now let’s look at the deserialize_response
function and the UnspentTxOutputs
struct. The raw response from Core is an Object with three keys, error
, id
and result
. We’re only interested in the result
because that’s where the response data we need is located. Hence, the need to have a dynamic function we can use to deserialize every response from Core to a simple data structure we can easily work with.
... # Other imports above
use serde::{de::DeserializeOwned, Serialize, Deserialize};
fn deserialize_response<T: DeserializeOwned>(response: &Value) -> Option<T> {
let json_response = &response["result"];
let deserialized: Option<T> = serde_json::from_value(json_response.to_owned()).ok();
deserialized
}
The UnspentTxOutputs
struct is needed so we can easily map the result
part of Core’s listunspent
response to it.
#[derive(Debug, Serialize, Deserialize)]
struct UnspentTxOutputs {
txid: String,
vout: u32,
address: String,
label: String,
scriptPubKey: String,
amount: f32,
confirmations: u32,
spendable: bool,
solvable: bool,
desc: String,
parent_descs: Vec<String>,
safe: bool,
}
The deserialized response from the listunspent
RPC call should be similar to this:
UnspentTxOutputs { txid: "a54aaa000bafd9e86273e40fcb855deb09d3dfb932f3d0bf664477b2364e2602",
vout: 0,
address: "bcrt1qtzn35x0wl292cp5d9ldns08qwmle5z290sp6nn",
label: "",
scriptPubKey: "001458a71a19eefa8aac068d2fdb383ce076ff9a0945",
amount: 50.0,
confirmations: 211,
spendable: true,
solvable: true,
desc: "wpkh([7c390502/84'/1'/0'/0/1]03104ab7dff56a9866...",
parent_descs: ["wpkh(tpubD6NzVbkrYhZ4Xd5aNa1Y3XmMaH..."],
safe: true
}
Create a PSBT
Creating a PSBT is pretty straightforward using the walletcreatefundedpsbt
RPC method. Just like the name implies, the RPC method creates and funds a transaction in the Partially Signed Transaction format.
This method takes in five arguments, one required argument — outputs, and four optional arguments. You can find the reference here. To keep things simple, we’ll only be working with inputs and outputs.
The input argument is an array containing the txid
and vout
of the previous transaction that you want to spend. While the output argument is an array of object with the destination address
as the key and the amount
you want to spend as the value.
Just to mention that I’ll be selecting the utxo for this transaction manually. I hope to write about automatic coin selection process/algorithms in the future and will link it here when I eventually write about it.
Let’s create the create_psbt
function:
fn create_psbt(input: Input, output: Vec<Value>) -> Result<Psbt, Box<dyn Error>> {
let utxo = vec![json!({
"txid": input.txid,
"vout": input.vout,
})];
let body = json!([utxo, output]);
let response = send_rpc_request("walletcreatefundedpsbt", &body);
match response {
Ok(value) => {
let psbt: Psbt = deserialize_response(&value).unwrap();
Ok(psbt)
},
Err(e) => {
Err(e)
}
}
}
The create_psbt
function above takes an input
of type Input
and output
of type Vec<Value>
as arguments. Builds a valid json
request body
off the arguments and sends a walletcreatefundedpsbt
RPC request to Core with the body
as params.
Next, we match
on the response
from Core and deserialize it using the deserialize_response
function we created earlier, then finally return the newly created psbt
or the error if there’s an error.
Our
deserialize_response
function returns anOption<T>
so feel free to handle theSome
andNone
case using pattern matching, rather thanunwrap
ping theSome
value as that may cause panic when we get aNone
rather thanSome
. We’re unwrapping here because we want to keep things straightforward and easy.
You’ll notice we introduced some new types in this function, find the struct
s for the types below:
#[derive(Debug, Clone)]
struct Input {
txid: String,
vout: u32,
}
#[derive(Debug, Deserialize)]
struct Psbt {
psbt: String,
fee: f64,
changepos: i32,
}
If everything works properly, the deserialized response should look like what we have below:
Psbt { psbt: "cHNidP8BAHECAAAAAQImTjayd0Rmv9DzMrnf0wnrXYXLD+RzYuj...",
fee: 2.82e-5,
changepos: 1
}
psbt
is the base64 encoded string of the raw transaction.fee
is the transaction fee that would be paid for the transaction. You can use theformat!
macro in rust to format the fee properly and convert it to eight decimal places to get a value like0.00000282
.changepos
is the position of the added change output.
At this point, we’ve created a PSBT from one user’s wallet, we need to create another PSBT from the second user’s wallet, then join the PSBTs using the joinpsbts
RPC method.
Remember we’re simulating a manual Coinjoin (using different wallet.dat
files to represent different users) using PSBTs, so follow the steps below to create another PSBT:
- Load the second wallet, using
bitcoin-cli -rpcwallet=<wallet_name> loadwallet
. This can also be done via theloadwallet
RPC API but I prefer to do this via the CLI and get back to the code for other flows. - Make a request to
listunspent
transaction outputs, and manually select the utxo you’ll like to spend. - Make a request to the
walletcreatefundedpsbt
RPC API using thecreate_psbt
function we created earlier, with the utxo details (txid
andvout
), and the output details (destinationaddress
andamount
) as arguments.
If you’ve successfully loaded the wallet, making a request to the listunspent
RPC API will result in a panic (remember the issue with unwrap
that we talked about earlier?), and debugging further will leave you with an error similar to what we have below:
Object {"error": Object {"code": Number(-19), "message": String("Wallet file not specified (must request wallet RPC through /wallet/<filename> uri-path).")}, "id": String("curltest"), "result": Null}
The error is quite descriptive. Because we’ve loaded a new wallet (we now have more than one loaded wallet), we’ll need to specify the wallet we want to interact with anytime we want to make a wallet related request, otherwise we’ll get the error. To resolve the error, we’ll need to introduce a new parameter to our send_rpc_request
function, match on the parameter and construct the rpc_url
dynamically based on what the argument is (true or false, if it’s a wallet related request).
The updated send_rpc_request
function should look like this:
fn send_rpc_request(method: &str, params: &Value, wallet_request: bool) -> Result<Value, Box<dyn Error>> {
let rpc_url;
let client = ReqClient::new();
match wallet_request {
true => {
let rpc_host = env::var("RPC_HOST").expect("RPC_HOST not found in environment");
rpc_url = format!("{rpc_host}/wallet/<wallet_name_here>");
},
false => {
rpc_url = env::var("RPC_HOST").expect("RPC_HOST not found in environment");
},
}
...other codes
}
Using the send_rpc_request
going forward will need us to specify if the RPC request is a wallet related request or not, i.e:
let response = send_rpc_request("listunspent", &json!([]), true);
Now that we’ve resolved the error, go ahead and complete the rest of the steps above to create a PSBT for the second user (newly loaded wallet).
Join the PSBTs
Joining the two PSBTs we’ve created involves using the joinpsbts
RPC method to join the two PSBT into one PSBT. Remember the PSBTs we’ve created needs to be distinct with different inputs and outputs. The joinpsbts
method only accepts a json array of the psbt
s you wish to join. You can find the reference here.
Let’s create a join_psbts
function to join the two PSBT we’ve created into one “Large” PSBT:
fn join_psbts() -> Result<String, Box<dyn Error>> {
let user_a_wallet_psbt = env::var("USER_A_WALLET_PSBT").expect("User A PSBT not found in environment");
let user_b_wallet_psbt = env::var("USER_B_WALLET_PSBT").expect("User B PSBT not found in environment");
let psbts = json!([user_a_wallet_psbt, user_b_wallet_psbt]);
let body = json!([psbts]);
let response = send_rpc_request("joinpsbts", &body, false);
match response {
Ok(psbt) => {
let result: String = deserialize_response(&psbt).unwrap();
Ok(result)
},
Err(e) => {
Err(e)
}
}
}
The join_psbts
function doesn’t accept any parameter, and this is intentional because I stored the psbts we created earlier in the .env
file to keep things simple just for the purpose of this article. But you might want to modify the function to accept the json array of psbts as an argument of the function.
The codes in the function should be familiar by now as we only made a request to Core, match
on the response and deserialized the data. The deserialized response is a base64-encoded partially signed transaction string:
response: "cHNidP8BANgCAAAAAlwv0rI6NAcQhlQWapBdVmoqFaKy4ATlYefqRt..."
Wallet Process PSBT
Every user involved in the Coinjoin transaction that had their psbt joined in the step above needs to sign the joined psbt in their individual wallet. The walletprocesspsbt
RPC method is what we’ll use to accomplish this. The method accepts the joined psbt string as a required argument and other optional arguments which can be found in the reference here.
fn wallet_process_psbt(psbt: String) -> Result<WalletProcessPsbt, Box<dyn Error>> {
let body = json!([psbt]);
let response = send_rpc_request("walletprocesspsbt", &body, true);
match response {
Ok(psbt) => {
let result: WalletProcessPsbt = deserialize_response(&psbt).unwrap();
Ok(result)
},
Err(e) => {
Err(e)
}
}
}
The wallet_process_psbt
function above accepts the joined psbt as argument, and have the users sign the inputs from their wallet. Remember this is a wallet related request so we’ll need to specify each wallet name we’re processing the psbt from in our send_rpc_request
function (the two wallets we’ve been using to represent different users must sign the psbt, otherwise it won’t be complete).
The WalletProcessPsbt
type used in the function only contains the psbt
field and a complete
field:
#[derive(Debug, Deserialize)]
struct WalletProcessPsbt {
psbt: String,
complete: bool,
}
The result of this request if successful, will be similar to what we have below:
WalletProcessPsbt { psbt: "cHNidP8BANgCAAAAApBvG+3wPmX8Y3TLM0TwbLRexAyn5ufLN...", complete: false }
Combine PSBT
After all participants have updated their input information and signed the psbt using walletprocesspsbt
, we’ll need to combine all the signatures and input information into the same psbt using the combinepsbt
RPC method. The combinepsbt
RPC method only accepts a json
array of psbts and returns a psbt (base-64 encoded string value), you can find the detailed reference information here.
The combine_psbt
function is similar to the join_psbt
function we created earlier:
fn combine_psbt(psbt: String) -> Result<String, Box<dyn Error>> {
let body = json!([psbt]);
let request_body = json!([body]);
let response = send_rpc_request("combinepsbt", &request_body, false);
match response {
Ok(psbt) => {
let result: String = deserialize_response(&psbt).unwrap();
Ok(result)
},
Err(e) => {
Err(e)
}
}
}
I’m sure the codes in the function above needs no further explanation at this point since we’ve maintained the same pattern and have explained similar functionality earlier.
The result of the combinepsbt
method is a base-64 encoded psbt string that we can finalize to create a final network transaction.
response: "cHNidP8BANgCAAAAApBvG+3wPmX8Y3TLM0TwbLRexAyn5u......."
Finalize PSBT
Once we’re done combining the signatures and input information, we’ll need to create a final network transaction using the finalizepsbt
RPC method that we can broadcast to the network. The finalizepsbt
takes the combinedpsbt
result as a required argument and finalizes the inputs. If the transaction is fully signed, it will return a hex
-encoded transaction that we can broadcast to the network, otherwise we get back a psbt
value.
fn finalize_psbt(psbt: String) -> Result<FinalizedPsbtResponse, Box<dyn Error>> {
let body = json!([psbt]);
let response = send_rpc_request("finalizepsbt", &body, false);
match response {
Ok(details) => {
let result: FinalizedPsbtResponse = deserialize_response(&details).unwrap();
Ok(result)
},
Err(e) => {
Err(e)
}
}
}
The result of a fully signed transaction will be similar to what we have below:
FinalizedPsbtResponse {
hex: "02000000000101616548ca166aa95a28a9edbe55b4ace2642d599f5610...",
complete: true
}
Broadcast the transaction
We now have a fully signed transaction ready to be broadcasted to the network, and we’ll be using the sendrawtransaction
RPC method to broadcast the raw transaction. The sendrawtransaction
method accepts the raw transaction hexstring
as a required argument and returns the txid
for the transaction.
The broadcast transaction function:
fn broadcast_transaction(hex: String) -> Result<String, Box<dyn Error>> {
let body = json!([hex]);
let response = send_rpc_request("sendrawtransaction", &body, false);
match response {
Ok(txid) => {
let result: String = deserialize_response(&txid).unwrap();
Ok(result)
},
Err(e) => {
Err(e)
}
}
}
We’re only extracting the txid
from the response because that’s the only field important to us. If the request is successful, you’ll have a txid
for the transaction that you can look up on any explorer (if you’re on a public network), in our case we can do a gettransaction
RPC call to see the details of the transaction because we’re on the regtest
network.
response: "991f9b38b9233741ba28bbbf8bbb48cb80625f87c5279ae97e1f0e1991af55fa"
Conclusion
In this guide, we’ve successfully demonstrated how manual Coinjoins works using PSBTs by simulating different Coinjoin participants with seperate wallets (each wallet representing a different user). I know this is really a long one, but I’m glad I was been able to complete it regardless. Writing this taught me a lot and helped solidified my knowledge on these concepts, and I believe it will help someone out there as well.
I’ll be happy to receive feedback, so feel free to reach out to me here or on X. Code samples provided in the article can be found here, also feel free to improve on the codes where necessary.
References
- Ava Chow’s response on Stackexchange — https://bitcoin.stackexchange.com/questions/57253/how-do-i-spend-bitcoins-from-multiple-wallets-in-a-single-transaction/89169#89169
- How to properly combine PSBTS for Coinjoin — https://bitcoin.stackexchange.com/questions/93138/how-to-properly-combine-psbts-for-coinjoin
- Bitcoin RPC API Reference — https://developer.bitcoin.org/reference/rpc/index.html
- PSBTs BIP (0174) — https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki
- Amazing article on creating PSBTs with Bitcoin Core and NodeJS — https://medium.com/@teebams49/creating-partially-signed-bitcoin-transactions-psbts-with-bitcoin-core-and-node-js-bbb066c041b7