Skip to content

Hardware Wallet PSBTs

walletrs does not talk USB or HID. Signing with hardware wallets is the client’s job. The server’s role for HWW flows is:

  1. Hold the wallet’s descriptor (compiled from registered customer-managed xpubs).
  2. Build the unsigned PSBT (fund_transaction).
  3. Combine partial signatures coming back from external signers (add_verify_transaction_signature).
  4. Finalize and broadcast once the threshold is met.

This guide walks the call sequence for a 2-of-3 multisig wallet with hardware-only signers.

For each hardware device, fetch its xpub through your client (e.g. via the desktop app talking to the device) and register it as a customer-managed key:

Terminal window
curl -sS -X POST http://127.0.0.1:8080/wallet/create_customer_managed_key \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{
"user_id": "treasury",
"device_id": "ledger-1",
"key_name": "ledger-1",
"xpub": "xpub6E...",
"fingerprint": "6fb270de",
"derivation_path": "m/48'\''/0'\''/0'\''/2'\''"
}'

Repeat for ledger-2, coldcard-1, etc. walletrs only stores the public xpub — no private material is ever sent to the server.

A 2-of-3 multisig:

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": "treasury",
"wallet_id": "treasury-2of3",
"network": "mainnet",
"preferred_script_type": "SCRIPT_TYPE_AUTO",
"spending_conditions": [{
"id": "primary",
"is_primary": true,
"timelock": 0,
"threshold": 2,
"policy": "MULTI",
"managed_key_ids": ["ledger-1", "ledger-2", "coldcard-1"]
}]
}'

This produces a wsh(sortedmulti(2, ...)) descriptor (or tr(...) if you nudge preferred_script_type to taproot).

Reveal an address, fund it, and update_wallet to sync — same as the end-to-end guide up through step 4.

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":"treasury-2of3",
"destination_address":"bc1q...",
"destination_value": 100000,
"fee_per_kb": 5000
}'

The response carries psbt (base64) and pruned_psbt (smaller, no witness_utxo redundancy if your HWW prefers it). Pull the PSBT out and ship it to each device.

Each hardware wallet signs a copy of the PSBT independently. The exact mechanism depends on the device — Ledger over USB, Coldcard over SD card, Trezor over USB, etc. Your client (typically the Sigvault desktop app for the integrated flow, or your own tool) coordinates this.

Key point: each device returns a partially-signed PSBT containing its signature plus the same inputs / outputs. walletrs recombines partials below.

For each signed PSBT coming back from a device:

Terminal window
curl -sS -X POST http://127.0.0.1:8080/wallet/add_verify_transaction_signature \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{
"wallet_id":"treasury-2of3",
"txid":"<TXID>",
"signedpsbt":"<base64 PSBT from device>",
"devicefingerprint":"6fb270de",
"devicederivationpath":"m/48'\''/0'\''/0'\''/2'\''"
}'

walletrs:

  1. Parses the incoming PSBT.
  2. Validates that devicefingerprint matches a key in the wallet’s descriptor.
  3. Verifies each new partial signature against the corresponding sighash.
  4. Merges the partials into the stored PSBT.

The response includes a status indicating where in the threshold you are (partial, complete, etc.). Repeat with the next device’s signature.

Once add_verify_transaction_signature has accumulated the threshold (2-of-3 in this example), finalize and broadcast:

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":"treasury-2of3","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":"treasury-2of3","txid":"<TXID>"}'

finalize constructs the final witness from the collected partials; broadcast sends through electrs.

Nothing prevents you from composing a wallet with both customer-managed (HWW) keys and system-managed (in-walletrs) keys in the same condition. For such a wallet:

  • Use sign_wallet_transaction with the system key’s device_id to produce its partial signature in-process.
  • Use the HWW flow (above) for each customer key’s partial.

walletrs treats both as partials feeding into the same threshold.

ErrorCause
INVALID_ARGUMENT: device fingerprint not in wallet descriptorThe devicefingerprint you passed doesn’t match any signer in the wallet’s descriptor. Double-check fingerprint formatting (8 lowercase hex chars).
INVALID_ARGUMENT: signature did not verifyThe partial signature in the PSBT is for a different sighash — typically because the device signed an earlier draft, or the wallet’s UTXO set changed between fund_transaction and the signature coming back. Rebuild and re-sign.
FAILED_PRECONDITION: not enough signatures to finalizeYou called finalize before reaching the threshold. Wait for one more partial.