Skip to content

End-to-end Transaction

This guide walks the entire lifecycle of a wallet: create a system-managed key, build a single-sig wallet from it, fund a regtest address, sign and broadcast a spend. It uses the simplest possible shape (one SINGLE spending condition) so you can focus on the call sequence.

For taproot wallets with multiple spending paths, see Taproot Spending Paths. For PSBTs signed externally, see Hardware Wallet PSBTs.

The examples below are paired: the same call shown as a Rust gRPC call and as a curl HTTP call.

  • walletrs running on regtest with WALLETRS_KEK set (system-managed keys require it). The bundled Docker Compose stack does this for you.
  • A bearer token in $TOKEN (curl) or wired into a Rust WalletServiceClient interceptor.
  • bitcoin-cli access to the regtest bitcoind for funding.

The key’s xpriv lives encrypted under WALLETRS_KEK inside walletrs and never leaves the server.

let key = client
.create_system_managed_key(walletrpc::CreateSystemManagedKeysRequest {
user_id: "alice".into(),
device_id: "alice-hot".into(),
key_name: "primary".into(),
})
.await?
.into_inner();
println!("xpub: {}, fingerprint: {}", key.xpub, key.fingerprint);
Terminal window
curl -sS -X POST http://127.0.0.1:8080/wallet/create_system_managed_key \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"user_id":"alice","device_id":"alice-hot","key_name":"primary"}'

The response carries xpub, fingerprint, and derivation_path. Save the device_id — you’ll reference it on the wallet.

One SINGLE spending condition tied to the key gives you a single-sig segwit-v0 (wpkh) wallet. Switch to taproot by setting preferred_script_type = SCRIPT_TYPE_TAPROOT.

use walletrpc::{
CreateGenericWalletRequest, PolicyType, PreferredScriptType, SpendingCondition,
};
let wallet = client
.create_generic_wallet(CreateGenericWalletRequest {
user_id: "alice".into(),
wallet_id: "alice-wallet-1".into(),
spending_conditions: vec![SpendingCondition {
id: "primary".into(),
is_primary: true,
timelock: 0,
threshold: 1,
policy: PolicyType::Single as i32,
managed_key_ids: vec!["alice-hot".into()],
}],
network: "regtest".into(),
preferred_script_type: PreferredScriptType::ScriptTypeAuto as i32,
})
.await?
.into_inner();
Terminal window
curl -sS -X POST http://127.0.0.1:8080/wallet/create_generic_wallet \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{
"user_id": "alice",
"wallet_id": "alice-wallet-1",
"network": "regtest",
"preferred_script_type": "SCRIPT_TYPE_AUTO",
"spending_conditions": [{
"id": "primary",
"is_primary": true,
"timelock": 0,
"threshold": 1,
"policy": "SINGLE",
"managed_key_ids": ["alice-hot"]
}]
}'

The response includes the external_descriptor (BIP-388) and, for taproot wallets, the taproot_leaf_info.

let addr = client
.reveal_next_address(walletrpc::RevealNextAddressRequest {
wallet_id: "alice-wallet-1".into(),
num: 1,
change: false,
})
.await?
.into_inner()
.addresses[0]
.address
.clone();
Terminal window
curl -sS -X POST http://127.0.0.1:8080/wallet/reveal_next_address \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"wallet_id":"alice-wallet-1","num":1,"change":false}'

Send some regtest BTC to the returned address (e.g. via bitcoin-cli sendtoaddress), mine a block, then sync the wallet:

Terminal window
curl -sS -X POST http://127.0.0.1:8080/wallet/update \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"wallet_id":"alice-wallet-1"}'

fund_transaction constructs an unsigned PSBT to a destination address.

let funded = client
.fund_wallet_transaction(walletrpc::FundWalletTransactionRequest {
wallet_id: "alice-wallet-1".into(),
destination_address: "bcrt1q...".into(),
destination_value: 50_000,
change_address: "".into(),
fee_per_kb: 1_000,
selected_leaf_hash: "".into(), // empty for non-taproot or default path
spend_change: false,
})
.await?
.into_inner();
Terminal window
curl -sS -X POST http://127.0.0.1:8080/wallet/fund_transaction \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{
"wallet_id":"alice-wallet-1",
"destination_address":"bcrt1q...",
"destination_value": 50000,
"fee_per_kb": 1000,
"spend_change": false
}'

Save the returned txid — every subsequent call references it.

For system-managed keys, signing is in-process: walletrs decrypts the xpriv, signs, discards the plaintext.

client
.sign_wallet_transaction(walletrpc::SignWalletTransactionRequest {
wallet_id: "alice-wallet-1".into(),
txid: funded.txid.clone(),
device_id: "alice-hot".into(),
})
.await?;
Terminal window
curl -sS -X POST http://127.0.0.1:8080/wallet/sign_transaction \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"wallet_id":"alice-wallet-1","txid":"<TXID>","device_id":"alice-hot"}'

For PSBTs signed externally, skip this step and use add_verify_transaction_signature per partial signature instead. See Hardware Wallet PSBTs.

client
.finalize_wallet_transaction(walletrpc::FinalizeWalletTransactionRequest {
wallet_id: "alice-wallet-1".into(),
txid: funded.txid.clone(),
})
.await?;
client
.broadcast_wallet_transaction(walletrpc::BroadcastWalletTransactionRequest {
wallet_id: "alice-wallet-1".into(),
txid: funded.txid,
})
.await?;
Terminal window
curl -sS -X POST http://127.0.0.1:8080/wallet/finalize_transaction \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"wallet_id":"alice-wallet-1","txid":"<TXID>"}'
curl -sS -X POST http://127.0.0.1:8080/wallet/broadcast_transaction \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"wallet_id":"alice-wallet-1","txid":"<TXID>"}'

Mine a block, then update the wallet again — the transaction should appear in get_transactions.

SymptomLikely cause
INVALID_ARGUMENT: Wallet has no spendable UTXOsMissed step 3’s update after funding. Check the address received the coins on-chain, then call update_wallet.
INTERNAL: Failed to decrypt managed keyWALLETRS_KEK mismatch between the run that created the key and the current run.
NOT_FOUND: Wallet not foundwallet_id typo, or the wallet was created on a previous run with different storage configuration.
INVALID_ARGUMENT: Leaf hash 'X' does not match any spending pathTaproot wallet — pass an empty string for selected_leaf_hash to default to the keypath, or use "keypath" explicitly.