Payment Channels

Complete examples for implementing payment channels on BSV, enabling instant, low-cost off-chain transactions with on-chain settlement guarantees.

Overview

Payment channels allow two parties to conduct multiple transactions off-chain while maintaining the security guarantees of on-chain settlement. By opening a channel with an initial funding transaction, parties can exchange signed state updates representing balance changes, and only broadcast the final state to the blockchain when closing the channel. This enables high-throughput, low-latency micropayments.

Related SDK Components:

Payment Channel Setup

import { Transaction, PrivateKey, PublicKey, Script, P2PKH, OP } from '@bsv/sdk'

/**
 * Payment Channel Manager
 *
 * Manages the complete lifecycle of a unidirectional payment channel.
 */
class PaymentChannelManager {
  /**
   * Create a payment channel funding transaction
   *
   * @param sender - Channel sender (pays into channel)
   * @param receiver - Channel receiver
   * @param fundingAmount - Amount to lock in channel
   * @param duration - Channel duration in seconds
   * @param utxo - UTXO to fund the channel
   * @returns Channel setup details
   */
  async createChannel(
    sender: PrivateKey,
    receiver: PublicKey,
    fundingAmount: number,
    duration: number,
    utxo: {
      txid: string
      vout: number
      satoshis: number
      script: Script
    }
  ): Promise<{
    channelId: string
    fundingTx: Transaction
    expiryTime: number
    initialBalance: {
      sender: number
      receiver: number
    }
  }> {
    try {
      if (fundingAmount < 1000) {
        throw new Error('Funding amount too small for channel')
      }

      if (fundingAmount > utxo.satoshis) {
        throw new Error('Insufficient funds in UTXO')
      }

      // Calculate expiry time
      const currentTime = Math.floor(Date.now() / 1000)
      const expiryTime = currentTime + duration

      // Create funding transaction
      const fundingTx = new Transaction()

      fundingTx.addInput({
        sourceTXID: utxo.txid,
        sourceOutputIndex: utxo.vout,
        unlockingScriptTemplate: new P2PKH().unlock(sender),
        sequence: 0xffffffff
      })

      // Create 2-of-2 multisig output for channel
      const channelScript = this.createChannelScript(
        sender.toPublicKey(),
        receiver,
        expiryTime
      )

      const fee = 500
      const channelAmount = fundingAmount - fee

      fundingTx.addOutput({
        satoshis: channelAmount,
        lockingScript: channelScript
      })

      // Add change output if needed
      const change = utxo.satoshis - fundingAmount - fee

      if (change > 546) {
        fundingTx.addOutput({
          satoshis: change,
          lockingScript: new P2PKH().lock(sender.toPublicKey().toHash())
        })
      }

      await fundingTx.sign()

      const channelId = fundingTx.id('hex')

      console.log('Payment channel created')
      console.log('Channel ID:', channelId)
      console.log('Capacity:', channelAmount, 'satoshis')
      console.log('Expires:', new Date(expiryTime * 1000).toISOString())

      return {
        channelId,
        fundingTx,
        expiryTime,
        initialBalance: {
          sender: channelAmount,
          receiver: 0
        }
      }
    } catch (error) {
      throw new Error(`Channel creation failed: ${error.message}`)
    }
  }

  /**
   * Create channel locking script
   *
   * Allows spending with both signatures or sender signature after timeout
   */
  private createChannelScript(
    senderPubKey: PublicKey,
    receiverPubKey: PublicKey,
    expiryTime: number
  ): Script {
    // Channel script:
    // IF
    //   2 <senderPubKey> <receiverPubKey> 2 OP_CHECKMULTISIG
    // ELSE
    //   <expiryTime> OP_CHECKLOCKTIMEVERIFY OP_DROP <senderPubKey> OP_CHECKSIG
    // ENDIF

    const script = new Script()

    // IF branch (cooperative close with both signatures)
    script.chunks.push({ op: OP.OP_IF })
    script.chunks.push({ op: OP.OP_2 })
    script.chunks.push({ data: senderPubKey.toBuffer() })
    script.chunks.push({ data: receiverPubKey.toBuffer() })
    script.chunks.push({ op: OP.OP_2 })
    script.chunks.push({ op: OP.OP_CHECKMULTISIG })

    // ELSE branch (unilateral close after timeout)
    script.chunks.push({ op: OP.OP_ELSE })
    script.chunks.push({ data: this.numberToBuffer(expiryTime) })
    script.chunks.push({ op: OP.OP_CHECKLOCKTIMEVERIFY })
    script.chunks.push({ op: OP.OP_DROP })
    script.chunks.push({ data: senderPubKey.toBuffer() })
    script.chunks.push({ op: OP.OP_CHECKSIG })

    script.chunks.push({ op: OP.OP_ENDIF })

    return script
  }

  /**
   * Convert number to buffer for script
   */
  private numberToBuffer(num: number): Buffer {
    if (num === 0) return Buffer.from([])

    const isNegative = num < 0
    const absNum = Math.abs(num)
    const bytes: number[] = []

    let n = absNum
    while (n > 0) {
      bytes.push(n & 0xff)
      n >>= 8
    }

    if (bytes[bytes.length - 1] & 0x80) {
      bytes.push(isNegative ? 0x80 : 0x00)
    } else if (isNegative) {
      bytes[bytes.length - 1] |= 0x80
    }

    return Buffer.from(bytes)
  }
}

/**
 * Usage Example
 */
async function setupChannelExample() {
  const manager = new PaymentChannelManager()

  const sender = PrivateKey.fromRandom()
  const receiver = PrivateKey.fromRandom()

  const utxo = {
    txid: 'funding-utxo...',
    vout: 0,
    satoshis: 1000000,
    script: new P2PKH().lock(sender.toPublicKey().toHash())
  }

  // Create channel with 500,000 sats for 24 hours
  const channel = await manager.createChannel(
    sender,
    receiver.toPublicKey(),
    500000,
    24 * 60 * 60,
    utxo
  )

  console.log('Channel ready for payments')
}

Channel State Updates

Channel Closing

Bidirectional Payment Channel

See Also

SDK Components:

Learning Paths:

Last updated