PSBT Developer Guide
This guide provides technical documentation for developers working with the PSBT (Partially Signed Bitcoin Transaction) implementation in go-crypto-wallet.
Table of Contents
- Architecture Overview
- PSBT Infrastructure
- Use Case Layer
- Testing Strategy
- Adding New Features
- Debugging
- Performance Considerations
Architecture Overview
Clean Architecture Layers
The PSBT implementation follows Clean Architecture principles:
┌─────────────────────────────────────────────────┐
│ Interface Adapters Layer │
│ (CLI, Wallet Adapters) │
│ - internal/interface-adapters/cli/ │
│ - internal/interface-adapters/wallet/ │
└───────────────┬─────────────────────────────────┘
│
┌───────────────▼─────────────────────────────────┐
│ Application Layer (Use Cases) │
│ - internal/application/usecase/watch/btc/ │
│ - internal/application/usecase/keygen/btc/ │
│ - internal/application/usecase/sign/btc/ │
└───────────────┬─────────────────────────────────┘
│
┌───────────────▼─────────────────────────────────┐
│ Domain Layer (Business Logic) │
│ - internal/domain/transaction/ │
│ - internal/domain/account/ │
│ - internal/domain/key/ │
└─────────────────────────────────────────────────┘
│
┌───────────────▼─────────────────────────────────┐
│ Infrastructure Layer │
│ - internal/infrastructure/api/btc/btc/ │
│ - internal/infrastructure/storage/file/ │
│ - internal/infrastructure/repository/ │
└─────────────────────────────────────────────────┘PSBT Flow Through Layers
User Command (CLI)
│
▼
Interface Adapter (e.g., watch/btc.BTCWatch)
│
▼
Use Case (e.g., CreateTransactionUseCase)
│
├──> Infrastructure: Bitcoin API (CreatePSBT)
├──> Infrastructure: File Storage (WritePSBTFile)
└──> Infrastructure: Database (InsertTransaction)PSBT Infrastructure
Bitcoin API Layer
Location: internal/infrastructure/api/btc/btc/psbt.go
Core PSBT Methods
1. CreatePSBT
// CreatePSBT creates a PSBT from a wire.MsgTx and previous transaction data
func (b *Bitcoin) CreatePSBT(
msgTx *wire.MsgTx,
prevTxs []PrevTx,
) (string, error)Purpose: Creates an unsigned PSBT from transaction inputs and outputs.
Implementation:
// Create PSBT packet
packet, err := psbt.NewFromUnsignedTx(msgTx)
// Add witness UTXO information for SegWit/Taproot inputs
for i, input := range msgTx.TxIn {
prevOut := prevTxs[i]
packet.Inputs[i].WitnessUtxo = &wire.TxOut{
Value: prevOut.Amount,
PkScript: prevOut.ScriptPubKey,
}
// Add additional metadata (derivation paths, etc.)
}
// Serialize to base64
return packet.B64Encode()2. SignPSBTWithKey
// SignPSBTWithKey signs a PSBT with provided private keys (offline)
func (b *Bitcoin) SignPSBTWithKey(
psbtBase64 string,
wifs []string,
) (string, bool, error)Purpose: Signs PSBT inputs with provided WIF private keys (offline signing).
Implementation:
// Decode base64 PSBT
psbtBytes, err := base64.StdEncoding.DecodeString(psbtBase64)
if err != nil {
return "", false, fmt.Errorf("failed to decode PSBT: %w", err)
}
// Parse PSBT
packet, err := psbt.NewFromRawBytes(bytes.NewReader(psbtBytes), false)
if err != nil {
return "", false, fmt.Errorf("failed to parse PSBT: %w", err)
}
// Parse private keys
privKeys := parseWIFs(wifs)
// Sign each input
for i := range packet.Inputs {
// Determine signature type based on input type
if isTaprootInput(packet.Inputs[i]) {
// Schnorr signature (BIP340)
sig, err := schnorr.Sign(privKey, sigHash)
} else {
// ECDSA signature
sig, err := ecdsa.Sign(privKey, sigHash)
}
// Add signature to PSBT
packet.Inputs[i].PartialSigs = append(
packet.Inputs[i].PartialSigs,
psbt.PartialSig{PubKey: pubKey, Signature: sig},
)
}
// Check if fully signed
isSigned := isComplete(packet)
return packet.B64Encode(), isSigned, nil3. FinalizePSBT
// FinalizePSBT finalizes a fully signed PSBT
func (b *Bitcoin) FinalizePSBT(psbtBase64 string) (string, error)Purpose: Combines signatures into final scriptSig/witness for broadcasting.
Implementation:
// Decode base64 PSBT
psbtBytes, err := base64.StdEncoding.DecodeString(psbtBase64)
if err != nil {
return "", fmt.Errorf("failed to decode PSBT: %w", err)
}
// Parse PSBT
packet, err := psbt.NewFromRawBytes(bytes.NewReader(psbtBytes), false)
if err != nil {
return "", fmt.Errorf("failed to parse PSBT: %w", err)
}
// Finalize each input
for i := range packet.Inputs {
err := psbt.Finalize(packet, i)
if err != nil {
return "", fmt.Errorf("failed to finalize input %d: %w", i, err)
}
}
return packet.B64Encode(), nil4. ExtractTransaction
// ExtractTransaction extracts the final transaction from a finalized PSBT
func (b *Bitcoin) ExtractTransaction(psbtBase64 string) (*wire.MsgTx, error)Purpose: Extracts the final, broadcastable transaction from PSBT.
Implementation:
// Decode base64 PSBT
psbtBytes, err := base64.StdEncoding.DecodeString(psbtBase64)
if err != nil {
return nil, fmt.Errorf("failed to decode PSBT: %w", err)
}
// Parse PSBT
packet, err := psbt.NewFromRawBytes(bytes.NewReader(psbtBytes), false)
if err != nil {
return nil, fmt.Errorf("failed to parse PSBT: %w", err)
}
// Extract transaction
tx, err := psbt.Extract(packet)
if err != nil {
return nil, fmt.Errorf("failed to extract transaction: %w", err)
}
return tx, nilHelper Methods
5. IsPSBTComplete
// IsPSBTComplete checks if a PSBT has all required signatures
func (b *Bitcoin) IsPSBTComplete(psbtBase64 string) (bool, error)6. ParsePSBT
// ParsePSBT parses a PSBT and returns structured data
func (b *Bitcoin) ParsePSBT(psbtBase64 string) (*ParsedPSBT, error)7. ValidatePSBT
// ValidatePSBT validates PSBT format and structure
func (b *Bitcoin) ValidatePSBT(psbtBase64 string) errorFile Storage Layer
Location: internal/infrastructure/storage/file/transaction.go
PSBT File Operations
1. WritePSBTFile
// WritePSBTFile writes a PSBT to a file with .psbt extension
func (r *TransactionFileRepository) WritePSBTFile(
path string,
psbtBase64 string,
) (string, error)Implementation:
// Validate PSBT format
if !isValidBase64(psbtBase64) {
return "", errors.New("invalid PSBT base64 encoding")
}
// Add .psbt extension if missing
if !strings.HasSuffix(path, ".psbt") {
path += ".psbt"
}
// Create parent directory if needed
os.MkdirAll(filepath.Dir(path), 0755)
// Write PSBT to file
err := os.WriteFile(path, []byte(psbtBase64), 0644)
return path, err2. ReadPSBTFile
// ReadPSBTFile reads a PSBT from a file
func (r *TransactionFileRepository) ReadPSBTFile(path string) (string, error)Implementation:
// Validate extension
if !strings.HasSuffix(strings.ToLower(path), ".psbt") {
return "", fmt.Errorf("invalid PSBT file extension: %s", path)
}
// Security: Prevent path traversal
cleanPath := filepath.Clean(path)
if r.filePath != "" && !strings.HasPrefix(cleanPath, r.filePath) {
return "", fmt.Errorf("path traversal attempt detected: %s", path)
}
// Read file
data, err := os.ReadFile(cleanPath)
if err != nil {
return "", fmt.Errorf("failed to read PSBT file: %w", err)
}
return string(data), nilUse Case Layer
Watch Wallet Use Cases
CreateTransactionUseCase
Location: internal/application/usecase/watch/btc/create_transaction.go
Responsibility: Create unsigned PSBT for transactions.
Key Method:
func (u *createTransactionUseCase) Execute(
ctx context.Context,
input watchusecase.CreateTransactionInput,
) (watchusecase.CreateTransactionOutput, error)PSBT Flow:
- Select UTXOs for inputs
- Calculate outputs (recipient + change)
- Create
wire.MsgTx - Get previous transaction data for inputs
- Call
btcClient.CreatePSBT(msgTx, prevTxs) - Write PSBT to file
- Store transaction metadata in database
Code Example:
// Create transaction
msgTx, err := u.btcClient.CreateRawTransaction(inputs, outputs)
// Get previous transaction data
previousTxs, err := u.getPreviousTransactions(inputs)
// Create PSBT
psbtBase64, err := u.btcClient.CreatePSBT(msgTx, previousTxs.PrevTxs)
// Write PSBT file
path := u.txFileRepo.CreateFilePath(actionType, domainTx.TxTypeUnsigned, txID, 0)
generatedFileName, err := u.txFileRepo.WritePSBTFile(path, psbtBase64)
return watchusecase.CreateTransactionOutput{
TransactionHex: psbtBase64,
FileName: generatedFileName,
}, nilSendTransactionUseCase
Location: internal/application/usecase/watch/btc/send_transaction.go
Responsibility: Finalize and broadcast fully signed PSBT.
Key Method:
func (u *sendTransactionUseCase) Execute(
ctx context.Context,
input watchusecase.SendTransactionInput,
) (watchusecase.SendTransactionOutput, error)PSBT Flow:
- Detect file format (PSBT vs legacy)
- For PSBT: Read PSBT file
- Validate PSBT is fully signed
- Finalize PSBT
- Extract transaction
- Convert to hex
- Broadcast transaction
- Update database
Code Example:
func (u *sendTransactionUseCase) processPSBTFile(filePath string) (string, error) {
// Read PSBT
psbtBase64, err := u.txFileRepo.ReadPSBTFile(filePath)
// Validate fully signed
isComplete, err := u.btcClient.IsPSBTComplete(psbtBase64)
if !isComplete {
return "", errors.New("PSBT is not fully signed")
}
// Finalize PSBT
finalizedPSBT, err := u.btcClient.FinalizePSBT(psbtBase64)
// Extract transaction
msgTx, err := u.btcClient.ExtractTransaction(finalizedPSBT)
// Convert to hex
hexTx, err := u.btcClient.ToHex(msgTx)
return hexTx, nil
}Keygen Wallet Use Cases
SignTransactionUseCase (Keygen)
Location: internal/application/usecase/keygen/btc/sign_transaction.go
Responsibility: Add first signature to PSBT (offline).
PSBT Flow:
- Read unsigned PSBT
- Determine sender account
- Get account private keys
- Sign PSBT with keys (offline, no RPC)
- Write partially/fully signed PSBT
Code Example:
func (u *signTransactionUseCase) signMultisigPSBT(
psbtBase64 string,
senderAccount domainAccount.AccountType,
) (string, bool, error) {
// Get account keys
accountKeys, err := u.accountKeyRepo.GetAll(senderAccount, 0)
// Extract WIFs from keys
wifs := extractWIFs(accountKeys)
// Sign PSBT offline (no Bitcoin Core RPC)
signedPSBT, isSigned, err := u.btc.SignPSBTWithKey(psbtBase64, wifs)
return signedPSBT, isSigned, nil
}Sign Wallet Use Cases
SignTransactionUseCase (Sign)
Location: internal/application/usecase/sign/btc/sign_transaction.go
Responsibility: Add second+ signature to PSBT (offline).
PSBT Flow:
- Read partially signed PSBT
- Get auth private key
- Sign PSBT with auth key (offline)
- Write fully signed PSBT
Code Example:
func (u *signTransactionUseCase) signMultisigPSBT(
psbtBase64 string,
) (string, bool, error) {
// Get auth key (explicit authType)
authKey, err := u.authKeyRepo.GetOne(u.authType)
// Sign PSBT offline
signedPSBT, isSigned, err := u.btc.SignPSBTWithKey(
psbtBase64,
[]string{authKey.WalletImportFormat},
)
return signedPSBT, isSigned, nil
}Testing Strategy
Unit Tests
Location: internal/application/usecase/*/btc/*_test.go
Current Approach:
- Constructor tests verify use case instantiation
- Interface compliance tests verify correct interface implementation
Example:
func TestNewSignTransactionUseCase(t *testing.T) {
t.Run("creates use case successfully with nil dependencies", func(t *testing.T) {
useCase := btc.NewSignTransactionUseCase(
nil, // btc
nil, // accountKeyRepo
nil, // txFileRepo
nil, // multisigAccount
domainWallet.WalletTypeKeygen,
"auth1",
)
assert.NotNil(t, useCase)
})
t.Run("returns correct interface type", func(t *testing.T) {
useCase := btc.NewSignTransactionUseCase(...)
assert.Implements(t, (*keygusecase.SignTransactionUseCase)(nil), useCase)
})
}Integration Tests
Requirements for Full Integration Tests:
Mock Bitcoin Client
- CreatePSBT
- SignPSBTWithKey
- FinalizePSBT
- ExtractTransaction
- IsPSBTComplete
Mock Repositories
- TransactionFileRepository (read/write PSBT)
- AccountKeyRepository (get keys)
- AuthKeyRepository (get auth keys)
- BTCTxRepository (database operations)
Test Fixtures
- Sample PSBTs (unsigned, partially signed, fully signed)
- Sample private keys (WIF format)
- Sample transaction data
Example Integration Test:
func TestSignTransactionUseCase_Integration(t *testing.T) {
// Setup mocks
mockBTC := &mockBitcoinClient{}
mockKeyRepo := &mockAccountKeyRepository{}
mockFileRepo := &mockTransactionFileRepository{}
// Create use case
useCase := btc.NewSignTransactionUseCase(
mockBTC,
mockKeyRepo,
mockFileRepo,
nil,
domainWallet.WalletTypeKeygen,
"auth1",
)
// Setup test data
unsignedPSBT := loadTestPSBT("testdata/unsigned.psbt")
mockFileRepo.On("ReadPSBTFile", mock.Anything).Return(unsignedPSBT, nil)
mockKeyRepo.On("GetAll", mock.Anything, mock.Anything).Return(testKeys, nil)
mockBTC.On("SignPSBTWithKey", mock.Anything, mock.Anything).Return(signedPSBT, true, nil)
// Execute
output, err := useCase.Sign(context.Background(), input)
// Assert
assert.NoError(t, err)
assert.True(t, output.IsComplete)
assert.NotEmpty(t, output.SignedData)
}End-to-End Tests
Manual E2E Test on Testnet:
# 1. Create unsigned PSBT
./watch create deposit --fee 0.00001
# 2. Sign with Keygen
./keygen sign --file deposit_*_unsigned_0_*.psbt
# 3. Broadcast
./watch send --file deposit_*_signed_1_*.psbt
# 4. Verify on blockchain
bitcoin-cli -testnet getrawtransaction <txid> 1Automated E2E Tests:
See docs/TESTING_STRATEGY.md for comprehensive testing approach.
Adding New Features
Adding Support for New Address Type
Example: Adding P2TR Multisig (Script Path)
Step 1: Update Address Generation
Location: internal/infrastructure/wallet/key/btc/hdwallet.go
// Add Taproot multisig address generation
func (h *HDWallet) GenerateTaprootMultisigAddress(
pubKeys []*btcec.PublicKey,
threshold int,
) (string, error) {
// Create Taproot script tree
script := createMultisigScript(pubKeys, threshold)
taprootKey := txscript.ComputeTaprootOutputKey(internalKey, script)
// Generate address
address, err := btcutil.NewAddressTaproot(
schnorr.SerializePubKey(taprootKey),
h.chainConfig,
)
return address.EncodeAddress(), nil
}Step 2: Update PSBT Creation
Location: internal/infrastructure/api/btc/btc/psbt.go
// Update CreatePSBT to include Taproot witness data
func (b *Bitcoin) CreatePSBT(msgTx *wire.MsgTx, prevTxs []PrevTx) (string, error) {
packet, err := psbt.NewFromUnsignedTx(msgTx)
for i, input := range msgTx.TxIn {
packet.Inputs[i].WitnessUtxo = &wire.TxOut{
Value: prevTxs[i].Amount,
PkScript: prevTxs[i].ScriptPubKey,
}
// Add Taproot-specific data
if isTaprootOutput(prevTxs[i].ScriptPubKey) {
packet.Inputs[i].TaprootInternalKey = prevTxs[i].InternalKey
packet.Inputs[i].TaprootScriptTree = prevTxs[i].ScriptTree
}
}
return packet.B64Encode(), nil
}Step 3: Update Signing Logic
Location: internal/infrastructure/api/btc/btc/psbt.go
// Update SignPSBTWithKey for Taproot script path
func (b *Bitcoin) SignPSBTWithKey(psbtBase64 string, wifs []string) (string, bool, error) {
// Decode base64 PSBT
psbtBytes, err := base64.StdEncoding.DecodeString(psbtBase64)
if err != nil {
return "", false, fmt.Errorf("failed to decode PSBT: %w", err)
}
// Parse PSBT
packet, err := psbt.NewFromRawBytes(bytes.NewReader(psbtBytes), false)
if err != nil {
return "", false, fmt.Errorf("failed to parse PSBT: %w", err)
}
for i := range packet.Inputs {
if isTaprootScriptPath(packet.Inputs[i]) {
// Schnorr signature for Taproot script path
sig, err := signTaprootScriptPath(privKey, packet, i)
} else if isTaprootKeyPath(packet.Inputs[i]) {
// Schnorr signature for Taproot key path
sig, err := schnorr.Sign(privKey, sigHash)
} else {
// ECDSA for legacy/SegWit
sig, err := ecdsa.Sign(privKey, sigHash)
}
packet.Inputs[i].PartialSigs = append(
packet.Inputs[i].PartialSigs,
psbt.PartialSig{PubKey: pubKey, Signature: sig},
)
}
return packet.B64Encode(), isComplete(packet), nil
}Step 4: Add Tests
func TestSignPSBTWithKey_TaprootScriptPath(t *testing.T) {
// Create Taproot multisig PSBT
psbt := createTestTaprootMultisigPSBT(t)
// Sign with key
signed, isComplete, err := btc.SignPSBTWithKey(psbt, []string{testWIF})
// Verify
assert.NoError(t, err)
assert.True(t, isComplete)
assert.NotEmpty(t, signed)
}Debugging
Debugging PSBT Issues
Enable Debug Logging
// In code
logger.SetLevel(logger.DebugLevel)
// Or via config
[logger]
level = "debug"Inspect PSBT with Bitcoin Core
# Decode PSBT
bitcoin-cli decodepsbt "$(cat transaction.psbt)"
# Analyze PSBT
bitcoin-cli analyzepsbt "$(cat transaction.psbt)"Output shows:
- Inputs and their metadata
- Outputs
- Current signatures
- Missing signatures
- Fee estimation
Common Issues and Solutions
Issue 1: "PSBT missing witness UTXO"
Solution: Ensure witness UTXO data is added in CreatePSBT:
packet.Inputs[i].WitnessUtxo = &wire.TxOut{
Value: prevTxs[i].Amount,
PkScript: prevTxs[i].ScriptPubKey,
}Issue 2: "Invalid signature"
Solution: Verify correct signature algorithm:
- Taproot → Schnorr (BIP340)
- Legacy/SegWit → ECDSA
Issue 3: "PSBT not finalizing"
Solution: Check all required signatures present:
isComplete, err := btc.IsPSBTComplete(psbtBase64)
if !isComplete {
// Add missing signatures
}Debugging Tools
PSBT Inspector Script
#!/bin/bash
# inspect_psbt.sh
PSBT_FILE="$1"
if [ ! -f "$PSBT_FILE" ]; then
echo "Usage: $0 <psbt_file>"
exit 1
fi
echo "=== PSBT Analysis ==="
bitcoin-cli analyzepsbt "$(cat "$PSBT_FILE")"
echo ""
echo "=== PSBT Decode ==="
bitcoin-cli decodepsbt "$(cat "$PSBT_FILE")"Performance Considerations
PSBT vs CSV Performance
Benchmark Results:
| Operation | CSV | PSBT | Difference |
|---|---|---|---|
| Create Transaction | ~50ms | ~80ms | +60% |
| Parse Transaction | ~5ms | ~15ms | +200% |
| Sign Transaction | ~100ms | ~120ms | +20% |
| Finalize | ~10ms | ~30ms | +200% |
| Total (2-of-2) | ~165ms | ~245ms | +48% |
Analysis:
- PSBT has ~50% overhead due to richer metadata
- Still well within acceptable performance (<1s for complete flow)
- Benefits (standardization, compatibility) outweigh performance cost
Optimization Opportunities
Caching
go// Cache parsed PSBTs to avoid re-parsing type PSBTCache struct { cache map[string]*psbt.Packet mu sync.RWMutex }Parallel Signing (future)
go// Sign multiple inputs in parallel var wg sync.WaitGroup for i := range packet.Inputs { wg.Add(1) go func(idx int) { defer wg.Done() signInput(packet, idx, privKey) }(i) } wg.Wait()Streaming for Large PSBTs
go// Stream PSBT data instead of loading into memory reader := bufio.NewReader(file) packet, err := psbt.NewFromRawBytesReader(reader)
Additional Resources
Documentation
Code References
- Bitcoin API:
internal/infrastructure/api/btc/btc/psbt.go - File Storage:
internal/infrastructure/storage/file/transaction.go - Watch Use Cases:
internal/application/usecase/watch/btc/ - Keygen Use Cases:
internal/application/usecase/keygen/btc/ - Sign Use Cases:
internal/application/usecase/sign/btc/
Tools
- Bitcoin Core - PSBT decoding/analysis
- btcdeb - Bitcoin script debugger
- PSBT Toolkit - Bitcoin Core PSBT tools
Last Updated: 2025-01-27 Version: 1.0 (PSBT Phase 2 Complete)