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.

Ifeanyichukwu Amajuoyi
11 min readMay 8, 2024

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 Core auth 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 our auth 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:

  1. Loads the auth details into the environment.
  2. Creates a new reqwest client.
  3. Creates a String interpolation with our auth details, encodes the credentials and converts the encoded auth String to an Http HeaderValue.
  4. Constructs the request body and makes an API call to Core with the provided details.
  5. 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 a Result<Value, Box<dyn Error>> so we’ll need to match on the Value and Error
  • 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 an Option<T> so feel free to handle the Some and None case using pattern matching, rather than unwrapping the Some value as that may cause panic when we get a None rather than Some. 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 structs 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 the format! macro in rust to format the fee properly and convert it to eight decimal places to get a value like 0.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 the loadwallet 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 the create_psbt function we created earlier, with the utxo details (txid and vout), and the output details (destination address and amount) 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 psbts 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

  1. 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
  2. How to properly combine PSBTS for Coinjoin — https://bitcoin.stackexchange.com/questions/93138/how-to-properly-combine-psbts-for-coinjoin
  3. Bitcoin RPC API Reference — https://developer.bitcoin.org/reference/rpc/index.html
  4. PSBTs BIP (0174) — https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki
  5. 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

--

--