Skip to content

Multi-Signature Transactions

Multi-signature (multisig) transactions require multiple signatures to spend funds, providing enhanced security and shared control over Bitcoin outputs. This guide covers implementing multisig transactions using the BSV TypeScript SDK, from basic 2-of-3 setups to advanced patterns.

Table of Contents

  1. Multi-Signature Fundamentals
  2. Basic 2-of-3 Implementation
  3. Funding and Spending
  4. Advanced Patterns
  5. Error Handling and Validation
  6. Best Practices
  7. Testing
  8. Troubleshooting

Multi-Signature Fundamentals

Multi-signature transactions use the OP_CHECKMULTISIG opcode to require multiple valid signatures from a set of public keys. Common patterns include:

  • 2-of-2: Both parties must sign (joint accounts)
  • 2-of-3: Any 2 of 3 parties must sign (escrow with arbiter)
  • 3-of-5: Any 3 of 5 parties must sign (corporate governance)

Key Concepts

  • Threshold: Minimum number of required signatures
  • Public Key Set: All possible signers
  • Signature Ordering: Signatures must match public key order
  • OP_0 Bug: Required extra OP_0 due to Bitcoin's OP_CHECKMULTISIG implementation

Basic 2-of-3 Implementation

Step 1: Generate Key Pairs

import { PrivateKey, PublicKey } from '@bsv/sdk'

// Generate three key pairs for the multisig
const key1 = PrivateKey.fromRandom()
const key2 = PrivateKey.fromRandom()
const key3 = PrivateKey.fromRandom()

const pubKey1 = key1.toPublicKey()
const pubKey2 = key2.toPublicKey()
const pubKey3 = key3.toPublicKey()

console.log('Generated 3 key pairs for 2-of-3 multisig')

Step 2: Create Multisig Script Template

import { Script, OP, ScriptTemplate, Transaction, UnlockingScript, LockingScript, ScriptTemplateUnlock } from '@bsv/sdk'

class MultiSigTemplate implements ScriptTemplate {
  private threshold: number
  private publicKeys: PublicKey[]

  constructor(threshold: number, publicKeys: PublicKey[]) {
    if (threshold > publicKeys.length) {
      throw new Error('Threshold cannot exceed number of public keys')
    }
    if (threshold < 1) {
      throw new Error('Threshold must be at least 1')
    }
    if (publicKeys.length > 16) {
      throw new Error('Maximum 16 public keys allowed')
    }

    this.threshold = threshold
    this.publicKeys = publicKeys.sort((a, b) => {
      const aStr = a.toString()
      const bStr = b.toString()
      return aStr.localeCompare(bStr)
    })
  }

  lock(): LockingScript {
    const script = new Script()

    // Push threshold (OP_1 through OP_16)
    if (this.threshold <= 16) {
      script.writeOpCode(OP.OP_1 + this.threshold - 1)
    } else {
      script.writeNumber(this.threshold)
    }

    // Push all public keys
    for (const pubKey of this.publicKeys) {
      const pubKeyHex = pubKey.toString()
      const pubKeyBytes = Array.from(Buffer.from(pubKeyHex, 'hex'))
      script.writeBin(pubKeyBytes)
    }

    // Push number of public keys
    if (this.publicKeys.length <= 16) {
      script.writeOpCode(OP.OP_1 + this.publicKeys.length - 1)
    } else {
      script.writeNumber(this.publicKeys.length)
    }

    // Add OP_CHECKMULTISIG
    script.writeOpCode(OP.OP_CHECKMULTISIG)

    return new LockingScript(script.chunks)
  }

  unlock(privateKeys: PrivateKey[]): ScriptTemplateUnlock {
    if (privateKeys.length < this.threshold) {
      throw new Error(`Need at least ${this.threshold} private keys`)
    }

    return {
      sign: async (tx: Transaction, inputIndex: number): Promise<UnlockingScript> => {
        const script = new Script()

        // Add OP_0 (required due to OP_CHECKMULTISIG bug)
        script.writeOpCode(OP.OP_0)

        // Create signatures with the first 'threshold' keys
        const signingKeys = privateKeys.slice(0, this.threshold)

        // Note: In a real implementation, you would create proper signatures here
        // This is a simplified example for demonstration
        for (let i = 0; i < this.threshold; i++) {
          // Placeholder for signature - in real implementation would sign transaction
          const dummySig = new Array(72).fill(0x30) // Dummy DER signature
          script.writeBin(dummySig)
        }

        return new UnlockingScript(script.chunks)
      },
      estimateLength: async (): Promise<number> => {
        // OP_0 + (threshold * signature_length)
        return 1 + (this.threshold * 73)
      }
    }
}

Step 3: Create Multisig Address

import { Hash } from '@bsv/sdk'

// Create 2-of-3 multisig template
const multisigTemplate = new MultiSigTemplate(2, [pubKey1, pubKey2, pubKey3])
const lockingScript = multisigTemplate.lock()

// Create script hash using available Hash methods
const scriptBytes = lockingScript.toBinary()
const scriptHash = Hash.sha256(Array.from(scriptBytes))

console.log('Multisig locking script:', lockingScript.toASM())
console.log('Script hash:', Buffer.from(scriptHash).toString('hex'))

Funding the Multisig Address

Using WalletClient

import { WalletClient } from '@bsv/sdk'

async function fundMultisig(wallet: WalletClient, scriptHash: number[], amount: number) {
  try {
    const actionResult = await wallet.createAction({
      description: 'Fund 2-of-3 multisig address',
      outputs: [
        {
          satoshis: amount,
          lockingScript: Buffer.from(scriptHash).toString('hex'),
          outputDescription: 'Multisig funding'
        }
      ],
    })

    if (actionResult.txid) {
      console.log('Multisig funded with transaction:', actionResult.txid)
      return actionResult.txid
    } else {
      throw new Error('Failed to fund multisig address')
    }
  } catch (error) {
    console.error('Error funding multisig:', error)
    throw error
  }
}

// Example usage
const wallet = new WalletClient('https://staging-dojo.babbage.systems')
await wallet.authenticate()
const fundingTxid = await fundMultisig(wallet, scriptHash, 100) // 100 satoshis

Spending from Multisig

Step 1: Create Spending Transaction

import { Transaction } from '@bsv/sdk'

async function createMultisigSpendingTx(
  fundingTxid: string,
  outputIndex: number,
  amount: number,
  recipientScript: Script,
  multisigTemplate: MultiSigTemplate
): Promise<Transaction> {

  const tx = new Transaction()

  // Add input from multisig funding transaction
  tx.inputs = [{
    sourceTXID: fundingTxid,
    sourceOutputIndex: outputIndex,
    unlockingScript: new Script(), // Will be filled later
    sequence: 0xffffffff
  }] as any

  // Add output (subtract small fee)
  tx.outputs = [{
    satoshis: amount - 100, // 100 satoshi fee
    lockingScript: recipientScript
  }] as any

  return tx
}

Step 2: Generate Signatures

function signMultisigTransaction(
  transaction: Transaction,
  inputIndex: number,
  privateKeys: PrivateKey[],
  multisigScript: Script,
  inputAmount: number
): Buffer[] {

  const signatures: Buffer[] = []

  for (const privateKey of privateKeys) {
    try {
      // Create a signature for the transaction
      // Note: This is a simplified example - in production you would use proper signature hash
      const testMessage = Array.from(Buffer.from('transaction_signature_data'))
      const signature = privateKey.sign(testMessage)

      // Create signature buffer with SIGHASH flag
      const sigBytes = Array.from(Buffer.from(signature.toDER(), 'hex'))
      sigBytes.push(0x41) // SIGHASH_ALL | SIGHASH_FORKID

      signatures.push(Buffer.from(sigBytes))
    } catch (error) {
      console.error('Error signing with key:', error)
      throw error
    }
  }

  return signatures
}

Step 3: Complete Transaction

async function spendFromMultisig(
  fundingTxid: string,
  outputIndex: number,
  amount: number,
  recipientAddress: string,
  signingKeys: PrivateKey[], // 2 keys for 2-of-3
  multisigTemplate: MultiSigTemplate
): Promise<string> {

  try {
    // Create recipient script (P2PKH for simplicity)
    const recipientScript = new Script()
    recipientScript.writeOpCode(OP.OP_DUP)
    recipientScript.writeOpCode(OP.OP_HASH160)
    recipientScript.writeBin(Buffer.from(recipientAddress, 'hex'))
    recipientScript.writeOpCode(OP.OP_EQUALVERIFY)
    recipientScript.writeOpCode(OP.OP_CHECKSIG)

    // Create spending transaction
    const tx = await createMultisigSpendingTx(
      fundingTxid,
      outputIndex,
      amount,
      recipientScript,
      multisigTemplate
    )

    // Get multisig locking script
    const multisigScript = multisigTemplate.lock()

    // Generate signatures
    const signatures = signMultisigTransaction(
      tx,
      0, // First input
      signingKeys,
      multisigScript,
      amount
    )

    // Create unlocking script
    const unlockingScript = multisigTemplate.unlock(signatures, tx, 0)
    tx.inputs[0].unlockingScript = unlockingScript.sign()

    // Verify transaction
    const isValid = tx.verify()
    if (!isValid) {
      throw new Error('Transaction verification failed')
    }

    console.log('Multisig transaction created successfully')
    console.log('Transaction hex:', tx.toHex())

    return tx.toHex()

  } catch (error) {
    console.error('Error spending from multisig:', error)
    throw error
  }
}

// Example usage
const spendingTx = await spendFromMultisig(
  fundingTxid,
  0, // Output index
  1000, // Amount
  'recipient_address_hash160',
  [key1, key2], // 2 signatures for 2-of-3
  multisigTemplate
)

Advanced Multisig Patterns

Threshold Signature Coordination

class MultisigCoordinator {
  private template: MultiSigTemplate
  private participants: Map<string, PublicKey>
  private signatures: Map<string, Buffer>

  constructor(template: MultiSigTemplate, participants: PublicKey[]) {
    this.template = template
    this.participants = new Map()
    this.signatures = new Map()

    participants.forEach((pubKey, index) => {
      this.participants.set(`participant_${index}`, pubKey)
    })
  }

  addSignature(participantId: string, signature: Buffer): void {
    if (!this.participants.has(participantId)) {
      throw new Error('Unknown participant')
    }

    this.signatures.set(participantId, signature)
    console.log(`Signature added for ${participantId}`)
  }

  hasEnoughSignatures(): boolean {
    return this.signatures.size >= this.template['threshold']
  }

  getSignatures(): Buffer[] {
    const sigs = Array.from(this.signatures.values())
    return sigs.slice(0, this.template['threshold'])
  }

  createUnlockingScript(transaction: Transaction, inputIndex: number): UnlockingScript {
    if (!this.hasEnoughSignatures()) {
      throw new Error('Insufficient signatures')
    }

    return this.template.unlock(this.getSignatures(), transaction, inputIndex)
  }
}

Time-Locked Multisig

class TimeLockMultiSig extends MultiSigTemplate {
  private lockTime: number

  constructor(threshold: number, publicKeys: PublicKey[], lockTime: number) {
    super(threshold, publicKeys)
    this.lockTime = lockTime
  }

  lock(): Script {
    const script = new Script()

    // Add time lock (simplified - just add the number and drop it)
    script.writeNumber(this.lockTime)
    script.writeOpCode(OP.OP_DROP)

    // Add standard multisig
    const multisigScript = super.lock()
    const scriptBytes = multisigScript.toBinary()
    script.writeBin(Array.from(scriptBytes))

    return script
  }
}

// Usage
const timeLockMultisig = new TimeLockMultiSig(
  2, // 2-of-3
  [pubKey1, pubKey2, pubKey3],
  1640995200 // Unix timestamp
)

Error Handling and Validation

Comprehensive Error Handling

class MultisigError extends Error {
  constructor(message: string, public code: string) {
    super(message)
    this.name = 'MultisigError'
  }
}

function validateMultisigSetup(
  threshold: number,
  publicKeys: PublicKey[]
): void {
  if (threshold < 1) {
    throw new MultisigError('Threshold must be at least 1', 'INVALID_THRESHOLD')
  }

  if (threshold > publicKeys.length) {
    throw new MultisigError(
      'Threshold cannot exceed number of public keys',
      'THRESHOLD_TOO_HIGH'
    )
  }

  if (publicKeys.length > 16) {
    throw new MultisigError(
      'Maximum 16 public keys allowed',
      'TOO_MANY_KEYS'
    )
  }

  // Check for duplicate keys
  const keySet = new Set(publicKeys.map(k => k.toString()))
  if (keySet.size !== publicKeys.length) {
    throw new MultisigError('Duplicate public keys detected', 'DUPLICATE_KEYS')
  }
}

function validateSignatures(
  signatures: Buffer[],
  expectedCount: number
): void {
  if (signatures.length !== expectedCount) {
    throw new MultisigError(
      `Expected ${expectedCount} signatures, got ${signatures.length}`,
      'INVALID_SIGNATURE_COUNT'
    )
  }

  for (let i = 0; i < signatures.length; i++) {
    if (signatures[i].length < 70 || signatures[i].length > 73) {
      throw new MultisigError(
        `Invalid signature length at index ${i}`,
        'INVALID_SIGNATURE_LENGTH'
      )
    }
  }
}

Transaction Verification

function verifyMultisigTransaction(
  transaction: Transaction,
  inputIndex: number,
  multisigScript: Script,
  inputAmount: number
): boolean {
  try {
    // Verify transaction structure
    if (!transaction.inputs || transaction.inputs.length === 0) {
      throw new Error('Transaction has no inputs')
    }

    if (!transaction.outputs || transaction.outputs.length === 0) {
      throw new Error('Transaction has no outputs')
    }

    // Verify specific input
    const input = transaction.inputs[inputIndex]
    if (!input) {
      throw new Error(`Input at index ${inputIndex} does not exist`)
    }

    // Verify unlocking script
    if (!input.unlockingScript) {
      throw new Error('Input has no unlocking script')
    }

    // Verify transaction signature
    const isValid = transaction.verify()
    if (!isValid) {
      throw new Error('Transaction signature verification failed')
    }

    console.log('Multisig transaction verification passed')
    return true

  } catch (error) {
    console.error('Multisig transaction verification failed:', error)
    return false
  }
}

Best Practices

Key Management

  1. Secure Key Generation: Always use cryptographically secure random number generation
  2. Key Distribution: Distribute keys securely and never transmit private keys over insecure channels
  3. Key Storage: Store private keys in secure hardware or encrypted storage
  4. Key Backup: Implement proper backup and recovery procedures
// Secure key generation example
function generateSecureKeyPair(): { privateKey: PrivateKey, publicKey: PublicKey } {
  const privateKey = PrivateKey.fromRandom()
  const publicKey = privateKey.toPublicKey()

  // Validate key pair
  const testMessage = Buffer.from('test message')
  const signature = privateKey.sign(testMessage)
  const isValid = publicKey.verify(testMessage, signature)

  if (!isValid) {
    throw new Error('Generated key pair failed validation')
  }

  return { privateKey, publicKey }
}

Transaction Construction

  1. Fee Calculation: Always account for transaction fees
  2. Input Validation: Validate all inputs before signing
  3. Output Verification: Verify output amounts and scripts
  4. Signature Ordering: Maintain consistent signature ordering
function calculateMultisigFee(
  inputCount: number,
  outputCount: number,
  threshold: number
): number {
  // Base transaction size
  const baseSize = 10 // version + locktime + input/output counts

  // Input size (outpoint + sequence + unlocking script)
  const inputSize = 36 + 4 + (1 + threshold * 73) // Estimated unlocking script size

  // Output size (value + locking script)
  const outputSize = 8 + 25 // Estimated P2PKH output size

  const totalSize = baseSize + (inputCount * inputSize) + (outputCount * outputSize)

  // 1 satoshi per byte
  return totalSize
}

Security Considerations

  1. Signature Verification: Always verify signatures before broadcasting
  2. Script Validation: Validate all scripts before use
  3. Amount Verification: Double-check all amounts
  4. Replay Protection: Use proper SIGHASH flags
function secureMultisigSpend(
  transaction: Transaction,
  inputIndex: number,
  multisigScript: Script,
  inputAmount: number,
  expectedOutputAmount: number
): boolean {

  // Verify transaction structure
  if (!verifyMultisigTransaction(transaction, inputIndex, multisigScript, inputAmount)) {
    return false
  }

  // Verify output amounts
  const totalOutput = transaction.outputs.reduce((sum, output) => sum + output.satoshis, 0)
  const fee = inputAmount - totalOutput

  if (fee < 0) {
    console.error('Invalid transaction: outputs exceed inputs')
    return false
  }

  if (fee > inputAmount * 0.1) { // Fee should not exceed 10% of input
    console.error('Warning: High transaction fee detected')
  }

  // Verify expected output amount
  if (totalOutput !== expectedOutputAmount) {
    console.error('Output amount mismatch')
    return false
  }

  return true
}

Testing Multisig Implementation

Unit Tests

import { describe, it, expect } from '@jest/globals'

describe('MultiSigTemplate', () => {
  let keys: PrivateKey[]
  let pubKeys: PublicKey[]
  let template: MultiSigTemplate

  beforeEach(() => {
    keys = [
      PrivateKey.fromRandom(),
      PrivateKey.fromRandom(),
      PrivateKey.fromRandom()
    ]
    pubKeys = keys.map(k => k.toPublicKey())
    template = new MultiSigTemplate(2, pubKeys)
  })

  it('should create valid locking script', () => {
    const lockingScript = template.lock()
    expect(lockingScript).toBeDefined()
    expect(lockingScript.toASM()).toContain('OP_CHECKMULTISIG')
  })

  it('should create valid unlocking script', () => {
    const tx = new Transaction()
    const signatures = [Buffer.alloc(72), Buffer.alloc(72)]

    const unlockingScript = template.unlock(signatures, tx, 0)
    expect(unlockingScript.script).toBeDefined()
    expect(unlockingScript.estimatedLength).toBeGreaterThan(0)
  })

  it('should reject invalid threshold', () => {
    expect(() => new MultiSigTemplate(4, pubKeys)).toThrow('Threshold cannot exceed')
    expect(() => new MultiSigTemplate(0, pubKeys)).toThrow('Threshold must be at least 1')
  })
})

Integration Tests

describe('Multisig Integration', () => {
  it('should create and spend from multisig', async () => {
    // This would be a full integration test
    // involving actual transaction creation and verification

    const keys = [PrivateKey.fromRandom(), PrivateKey.fromRandom(), PrivateKey.fromRandom()]
    const pubKeys = keys.map(k => k.toPublicKey())
    const template = new MultiSigTemplate(2, pubKeys)

    // Create mock funding transaction
    const fundingTx = new Transaction()
    // ... setup funding transaction

    // Create spending transaction
    const spendingTx = new Transaction()
    // ... setup spending transaction

    // Sign with 2 keys
    const signatures = signMultisigTransaction(
      spendingTx,
      0,
      [keys[0], keys[1]],
      template.lock(),
      1000
    )

    // Create unlocking script
    const unlockingScript = template.unlock(signatures, spendingTx, 0)
    spendingTx.inputs[0].unlockingScript = unlockingScript.sign()

    // Verify transaction
    expect(spendingTx.verify()).toBe(true)
  })
})

Troubleshooting Common Issues

Signature Ordering Problems

Problem: OP_CHECKMULTISIG fails due to incorrect signature ordering.

Solution: Ensure signatures are provided in the same order as public keys in the locking script.

function orderSignatures(
  signatures: Map<string, Buffer>,
  publicKeys: PublicKey[]
): Buffer[] {
  const orderedSigs: Buffer[] = []

  for (const pubKey of publicKeys) {
    const pubKeyHex = pubKey.toString()
    const pubKeyBytes = Array.from(Buffer.from(pubKeyHex, 'hex'))
    if (signatures.has(pubKeyHex)) {
      orderedSigs.push(signatures.get(pubKeyHex)!)
    }
  }

  return orderedSigs
}

OP_CHECKMULTISIG Bug

Problem: OP_CHECKMULTISIG consumes an extra value from the stack.

Solution: Always push OP_0 before signatures in the unlocking script.

// Correct unlocking script construction
const script = new Script()
script.writeOpCode(OP.OP_0) // Required for OP_CHECKMULTISIG bug
script.writeBin(signature1)
script.writeBin(signature2)

Fee Calculation Errors

Problem: Insufficient fees cause transaction rejection.

Solution: Properly calculate fees based on transaction size.

function estimateMultisigTxSize(
  inputCount: number,
  outputCount: number,
  threshold: number,
  keyCount: number
): number {
  const baseSize = 10
  const inputSize = 36 + 4 + 1 + (threshold * 73) + 1 // Include OP_0
  const outputSize = 8 + 25

  return baseSize + (inputCount * inputSize) + (outputCount * outputSize)
}

Conclusion

Multi-signature transactions provide enhanced security through distributed key management. The BSV TypeScript SDK offers flexible tools for implementing various multisig patterns, from simple 2-of-3 schemes to complex threshold signatures with time locks.

Key takeaways:

  • Always validate inputs and signatures
  • Implement proper error handling
  • Use secure key generation and storage
  • Test thoroughly before production use
  • Consider the OP_CHECKMULTISIG bug in script construction

For more advanced patterns, consider exploring the SDK's script template system and custom script construction capabilities.

While the WalletClient provides simplified transaction creation, understanding multi-signature transactions enables you to build sophisticated applications requiring multiple approvals and enhanced security.