Skip to main content
SatSigner provides comprehensive multi-signature wallet support with industry-standard PSBT (Partially Signed Bitcoin Transaction) workflows for secure, collaborative transaction signing.

Multi-Signature Overview

What is Multisig?

Multi-signature wallets require multiple private keys to authorize a transaction, providing:
  • Enhanced Security - No single point of failure
  • Shared Custody - Distributed key management
  • Governance - Multi-party approval process
  • Recovery Options - Backup keys prevent loss

Signature Schemes

Multisig uses M-of-N configurations:
// M = signatures required
// N = total keys

// Common configurations:
1-of-2  // Single sig with backup
2-of-2  // Dual approval
2-of-3  // Standard multisig (most popular)
3-of-5  // Organization treasury
5-of-7  // Large organization
Recommended Configurations:
Use CaseConfigurationReasoning
Personal with backup1-of-2Simple recovery
Joint account2-of-2Requires both parties
Family savings2-of-3Flexible + secure
Company treasury3-of-5Distributed control
Large organization5-of-9Byzantine fault tolerance

Creating Multisig Wallets

Account Builder Configuration

type MultisigAccount = {
  name: string
  network: Network
  policyType: 'multisig'
  
  // Multisig configuration
  keyCount: number        // Total keys (N)
  keysRequired: number    // Signatures needed (M)
  scriptVersion: 'P2WSH' | 'P2SH-P2WSH' | 'P2SH'
  
  // Individual keys
  keys: Key[]
}

Setting Up 2-of-3 Multisig

Step 1: Configure Multisig Parameters
const multisigSetup = {
  name: "Company Treasury",
  network: "bitcoin",
  policyType: "multisig",
  keyCount: 3,
  keysRequired: 2,
  scriptVersion: "P2WSH"  // Native SegWit multisig
}
Step 2: Add Keys Each key can be added via different methods:
// Key 1: Generate new seed
const key1 = {
  index: 0,
  name: "CFO Key",
  creationType: "generateMnemonic",
  mnemonicWordCount: 24,
  scriptVersion: "P2WSH"
}

// Key 2: Import from hardware wallet
const key2 = {
  index: 1,
  name: "CEO Hardware Wallet",
  creationType: "importExtendedPub",
  extendedPublicKey: "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5",
  fingerprint: "a1b2c3d4",
  scriptVersion: "P2WSH"
}

// Key 3: Import extended public key
const key3 = {
  index: 2,
  name: "Backup Key",
  creationType: "importExtendedPub",
  extendedPublicKey: "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL",
  fingerprint: "e5f6g7h8",
  scriptVersion: "P2WSH"
}
Step 3: Generate Descriptor SatSigner automatically creates the output descriptor:
// Multisig descriptor format
const descriptor = `wsh(sortedmulti(2,
  [a1b2c3d4/48h/0h/0h/2h]xpub.../0/*,
  [e5f6g7h8/48h/0h/0h/2h]xpub.../0/*,
  [12345678/48h/0h/0h/2h]xpub.../0/*
))`

// Key features:
// - wsh() = P2WSH wrapped
// - sortedmulti() = Lexicographically sorted keys
// - 2 = Signatures required
// - [fingerprint/path] = Key identification
// - 0/* = External addresses
// - 1/* = Change addresses (internal descriptor)

Script Types for Multisig

P2WSH (Recommended)
  • Native SegWit multisig
  • Lowest transaction fees
  • Bech32 addresses (bc1…)
  • Modern, efficient
P2SH-P2WSH
  • Nested SegWit multisig
  • Backward compatible
  • Addresses start with 3…
  • Moderate fees
P2SH (Legacy)
  • Original multisig
  • Addresses start with 3…
  • Highest fees
  • Maximum compatibility

Key Management Requirements

Critical Rules:
  1. Unique Fingerprints - Each key must be from different seed
// Validation check
const fingerprints = keys.map(k => k.fingerprint)
const uniqueFingerprints = new Set(fingerprints)
if (uniqueFingerprints.size !== fingerprints.length) {
  throw new Error('Duplicate fingerprints detected')
}
  1. Unique Extended Public Keys - No key reuse
const xpubs = keys.map(k => k.extendedPublicKey)
const uniqueXpubs = new Set(xpubs)
if (uniqueXpubs.size !== xpubs.length) {
  throw new Error('Duplicate extended public keys')
}
  1. Consistent Script Version - All keys same type
  2. Proper Derivation Paths - Follow BIP48 standard

PSBT Workflow

What is PSBT?

Partially Signed Bitcoin Transaction (BIP 174):
  • Standard format for unsigned/partially signed transactions
  • Contains all information needed for signing
  • Enables coordination across multiple devices
  • Privacy-preserving - no key material exchanged

PSBT Structure

type PSBT = {
  // Global data
  unsignedTx: Transaction,
  version: number,
  
  // Per-input data
  inputs: {
    witnessUtxo?: {
      script: Buffer
      value: number
    },
    witnessScript?: Buffer,  // Multisig script
    bip32Derivation?: {
      pubkey: Buffer
      fingerprint: Buffer
      path: string
    }[],
    partialSig?: {          // Signatures collected
      pubkey: Buffer
      signature: Buffer
    }[]
  }[],
  
  // Per-output data
  outputs: {
    bip32Derivation?: {...}[]
  }[]
}

Complete PSBT Flow

1. Create Transaction (Coordinator)
// Build transaction
const txBuilder = await new TxBuilder().create()

// Add UTXOs
await txBuilder.addUtxos(
  selectedUtxos.map(u => ({ txid: u.txid, vout: u.vout }))
)

// Add recipients
for (const output of outputs) {
  const scriptPubKey = await getScriptPubKeyFromAddress(
    output.address,
    network
  )
  await txBuilder.addRecipient(scriptPubKey, output.amount)
}

// Set fee and finish
await txBuilder.feeAbsolute(fee)
const txBuilderResult = await txBuilder.finish(wallet)

// Get unsigned PSBT
const unsignedPsbt = txBuilderResult.psbt.toBase64()
2. Share PSBT with Cosigners Multiple sharing methods:
// QR Code (animated for large PSBTs)
exportPsbtAsQR(unsignedPsbt)

// File export
exportPsbtAsFile(unsignedPsbt, 'transaction.psbt')

// BBQr (Bitcoin Block Quick Response)
const bbqr = encodeBBQr(unsignedPsbt)

// Base64 text (small transactions)
shareAsText(unsignedPsbt)
3. Each Cosigner Signs
// Import PSBT
const psbtBase64 = importPsbtFromQR() // or file, clipboard, etc.

// Verify PSBT matches account
const match = await findMatchingAccount(psbtBase64, accounts)

if (!match) {
  throw new Error('PSBT does not match any account')
}

// Extract transaction details
const txData = extractTransactionDataFromPSBT(
  psbtBase64,
  match.account
)

// Review transaction
console.log('Sending:', txData.outputs)
console.log('Fee:', txData.fee)
console.log('From account:', match.account.name)

// Sign with this cosigner's key
const signedPsbt = await signPSBTWithSeed(
  psbtBase64,
  mnemonic,
  match.account.keys[0].scriptVersion
)

// Export signed PSBT
exportSignedPsbt(signedPsbt)
4. Combine Signatures
// Collect signed PSBTs from cosigners
const signedPsbts = [
  signedPsbt1Base64,  // From cosigner 1
  signedPsbt2Base64,  // From cosigner 2
  signedPsbt3Base64   // From cosigner 3 (if applicable)
]

// Combine all signatures
const combinedPsbt = combinePsbts(signedPsbts)

// Verify threshold met
const validation = getSignedPSBTValidationInfo(combinedPsbt)
const signaturesCount = validation.signatures.length

if (signaturesCount >= account.keysRequired) {
  console.log('✓ Threshold met:', signaturesCount, 'of', account.keysRequired)
} else {
  console.log('Need more signatures:', signaturesCount, '/', account.keysRequired)
}
5. Finalize and Broadcast
// Extract final transaction
const psbt = Psbt.fromBase64(combinedPsbt)
const finalTx = psbt.extractTransaction()

// Broadcast to network
const txid = await blockchain.broadcast(finalTx)

console.log('Transaction broadcast:', txid)

PSBT Signing Process

Signing with Mnemonic

function signPSBTWithSeed(
  psbtBase64: string,
  seedWords: string,
  scriptType: 'P2WSH' | 'P2SH' | 'P2SH-P2WSH'
): SigningResult {
  const psbt = bitcoinjs.Psbt.fromBase64(psbtBase64)
  
  // Derive key from mnemonic
  const seed = bip39.mnemonicToSeedSync(seedWords.trim())
  const root = bip32.fromSeed(seed)
  const fingerprint = root.fingerprint.toString('hex')
  
  // Find matching derivations in PSBT
  const derivations = extractPSBTDerivations(psbtBase64)
  const matchingDerivations = derivations.filter(
    d => d.fingerprint === fingerprint
  )
  
  if (matchingDerivations.length === 0) {
    throw new Error('No matching derivation for this seed')
  }
  
  // Sign each matching input
  let signedInputs = 0
  for (const derivation of matchingDerivations) {
    const derivedKey = root.derivePath(derivation.path)
    
    // Verify public key matches
    if (derivedKey.publicKey.toString('hex') !== derivation.pubkey) {
      continue
    }
    
    // Create signer
    const signer = {
      publicKey: derivedKey.publicKey,
      sign: (hash: Buffer) => {
        return ecc.sign(hash, derivedKey.privateKey)
      }
    }
    
    // Sign input
    try {
      psbt.signInput(derivation.inputIndex, signer)
      signedInputs++
    } catch (error) {
      console.error('Failed to sign input:', error)
    }
  }
  
  return {
    success: signedInputs > 0,
    signedPSBT: psbt.toBase64(),
    signedInputsCount: signedInputs
  }
}

Signature Verification

function getSignedPSBTValidationInfo(signedPsbt: string) {
  const psbt = bitcoinjs.Psbt.fromBase64(signedPsbt)
  
  const validation = {
    isValid: true,
    errors: [],
    warnings: [],
    signatures: [],
    inputs: []
  }
  
  // Check each input for signatures
  psbt.data.inputs.forEach((input, inputIndex) => {
    const inputInfo = {
      index: inputIndex,
      hasPartialSigs: false,
      partialSigs: [],
      hasWitnessUtxo: !!input.witnessUtxo,
      hasWitnessScript: !!input.witnessScript
    }
    
    // Collect signatures
    if (input.partialSig && input.partialSig.length > 0) {
      inputInfo.hasPartialSigs = true
      input.partialSig.forEach(sig => {
        inputInfo.partialSigs.push({
          pubkey: sig.pubkey.toString('hex'),
          signature: sig.signature.toString('hex')
        })
        validation.signatures.push({
          inputIndex,
          pubkey: sig.pubkey.toString('hex'),
          signature: sig.signature.toString('hex')
        })
      })
    } else {
      validation.warnings.push(
        `Input ${inputIndex} has no signatures`
      )
    }
    
    validation.inputs.push(inputInfo)
  })
  
  return validation
}

Account Matching

Finding Matching Account

async function findMatchingAccount(
  psbtBase64: string,
  accounts: Account[]
): Promise<AccountMatchResult | null> {
  // Extract derivations from PSBT
  const derivations = extractPSBTDerivations(psbtBase64)
  
  if (derivations.length === 0) return null
  
  const psbtFingerprints = [...new Set(
    derivations.map(d => d.fingerprint)
  )]
  
  // Check each account
  for (const account of accounts) {
    if (account.keys.length === 0) continue
    
    const accountFingerprints = []
    const fingerprintToKeyIndex = new Map()
    
    // Extract account key fingerprints
    for (let i = 0; i < account.keys.length; i++) {
      const key = account.keys[i]
      const fingerprint = await extractKeyFingerprint(key)
      
      if (fingerprint) {
        accountFingerprints.push(fingerprint)
        fingerprintToKeyIndex.set(fingerprint, i)
      }
    }
    
    // Check if all PSBT fingerprints match account
    const allMatch = psbtFingerprints.every(fp =>
      accountFingerprints.includes(fp)
    )
    
    if (allMatch) {
      const firstMatch = derivations.find(d =>
        accountFingerprints.includes(d.fingerprint)
      )
      
      return {
        account,
        cosignerIndex: fingerprintToKeyIndex.get(
          firstMatch.fingerprint
        ),
        fingerprint: firstMatch.fingerprint,
        derivationPath: firstMatch.derivationPath,
        publicKey: firstMatch.publicKey
      }
    }
  }
  
  return null
}

Multi-Device Coordination

Coordinator Role

The coordinator device:
  1. Creates the wallet configuration
  2. Builds transactions
  3. Collects signatures from cosigners
  4. Combines and broadcasts final transaction

Cosigner Role

Each cosigner device:
  1. Imports wallet configuration
  2. Receives unsigned PSBTs
  3. Reviews transaction details
  4. Signs with their key
  5. Returns signed PSBT

Configuration Export/Import

Export Configuration:
const walletConfig = {
  name: account.name,
  network: account.network,
  policyType: 'multisig',
  keyCount: account.keyCount,
  keysRequired: account.keysRequired,
  scriptVersion: account.keys[0].scriptVersion,
  descriptor: account.keys[0].secret.externalDescriptor
}

// Share as QR code
exportAsQR(JSON.stringify(walletConfig))

// Or as file
exportAsFile(walletConfig, 'multisig-config.json')
Import Configuration:
// Import from QR or file
const config = importWalletConfig()

// Create account from config
const account = createAccountFromConfig(config)

// Add user's key to the configuration
account.keys[userKeyIndex] = {
  ...config.keys[userKeyIndex],
  secret: userMnemonic,  // Or imported key
  iv: randomIv()
}

Security Considerations

Key Distribution

Best Practices:
  1. Geographic Distribution - Keys in different physical locations
  2. Device Diversity - Different hardware/software
  3. Key Holder Diversity - Different trusted parties
  4. Secure Communication - Encrypted channels for PSBT sharing
  5. Verification Process - Multiple people verify transactions

PSBT Security

Always Verify:
// Check PSBT before signing
const checks = [
  // 1. Matches your account
  verifyAccountMatch(psbt, account),
  
  // 2. UTXOs are yours
  verifyUtxosOwnership(psbt, account.utxos),
  
  // 3. Recipient addresses correct
  verifyRecipients(psbt, expectedRecipients),
  
  // 4. Amounts correct
  verifyAmounts(psbt, expectedAmounts),
  
  // 5. Fee reasonable
  verifyFeeRange(psbt, minFee, maxFee),
  
  // 6. No unexpected inputs
  verifyNoExtraInputs(psbt, expectedInputs)
]

if (!checks.every(c => c === true)) {
  throw new Error('PSBT validation failed')
}

Attack Vectors

Address Substitution:
  • Attacker modifies recipient address
  • Mitigation: Always verify addresses on multiple devices
Amount Manipulation:
  • Attacker changes payment amounts
  • Mitigation: Review all amounts before signing
Fee Attack:
  • Excessive fees to drain funds
  • Mitigation: Set maximum acceptable fee rate
PSBT Tampering:
  • Modified PSBT between signers
  • Mitigation: Verify original PSBT hash before signing

Advanced Features

Threshold Signatures

Optimize for different scenarios:
// Hot wallet with cold backup
1-of-2: Daily use key + Offline backup

// Shared custody
2-of-2: Both parties must approve

// Flexible with backup
2-of-3: Any 2 keys (protects against loss)

// Corporate governance
3-of-5: Requires majority approval

Descriptor Backup

Critical Information:
const backupData = {
  // Wallet identification
  name: account.name,
  network: account.network,
  created: account.createdAt,
  
  // Policy
  policyType: 'multisig',
  keysRequired: 2,
  keyCount: 3,
  
  // Descriptor (most important!)
  descriptor: account.keys[0].secret.externalDescriptor,
  
  // Your key information
  yourKeyIndex: 0,
  yourFingerprint: account.keys[0].fingerprint,
  
  // Recovery instructions
  notes: "Key 1 stored in safe deposit box"
}

// Store securely
// - Encrypted cloud backup
// - Physical paper backup
// - Hardware wallet storage
Recovery Process:
  1. Gather descriptor and threshold keys
  2. Import descriptor into compatible wallet
  3. Restore each key from backup
  4. Reconstruct multisig wallet
  5. Sync with blockchain

Watch-Only Multisig

Monitor multisig wallet without signing ability:
// Import with descriptor only (no keys)
const watchOnlyMultisig = {
  name: "Company Treasury (Watch-Only)",
  policyType: "watchonly",
  keys: [{
    creationType: "importDescriptor",
    externalDescriptor: descriptor,
    fingerprint: undefined  // No key access
  }]
}
Use Cases:
  • Accounting/audit
  • Portfolio tracking
  • Public transparency
  • Coordinator node

Troubleshooting

PSBT Won’t Sign

Common Issues:
  1. Wrong account - PSBT fingerprint doesn’t match
  2. Incorrect script type - P2WSH vs P2SH mismatch
  3. Missing witness data - PSBT incomplete
  4. Already signed - This key already signed
Solutions:
// Verify fingerprint match
const psbtFps = extractPSBTDerivations(psbt)
  .map(d => d.fingerprint)
const accountFps = account.keys.map(k => k.fingerprint)
const matches = psbtFps.filter(fp => accountFps.includes(fp))

console.log('Matching fingerprints:', matches)

Cannot Broadcast

Possible Causes:
  1. Insufficient signatures - Below threshold
  2. Invalid signatures - Signature verification failed
  3. Double spend - UTXOs already spent
  4. Fee too low - Below minimum relay fee
Check Before Broadcasting:
// Verify threshold
const sigCount = countSignatures(combinedPsbt)
if (sigCount < account.keysRequired) {
  throw new Error(`Need ${account.keysRequired - sigCount} more signatures`)
}

// Verify signatures valid
const validation = validateSignedPSBT(combinedPsbt, account)
if (!validation) {
  throw new Error('Invalid signatures detected')
}

Lost Cosigner Key

If a key is lost:
  1. Verify backup keys - Ensure you have threshold keys
  2. Sweep funds - Move to new multisig with remaining keys
  3. Create new wallet - Generate fresh multisig setup
  4. Update procedures - Improve key backup process
SatSigner’s multisig support provides enterprise-grade security with user-friendly PSBT workflows for safe, collaborative Bitcoin custody.

Build docs developers (and LLMs) love