npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@b1narydt/channel-express-middleware

v0.1.9

Published

BSV payment channel Express middleware for off-chain micropayments

Readme

@bsv/payment-channel-middleware

Express middleware for BSV payment channels. Instead of one on-chain transaction per request, this manages channel lifecycles: one funding TX, many off-chain commitment updates, one closing TX.

Designed to pair with @bsv/auth-express-middleware for authenticated requests. For individual pay-per-request micropayments (one transaction per API call), see @bsv/payment-express-middleware.

Table of Contents

When to Use This

Use payment channels when your application needs high-frequency micropayments between a consumer and a platform. Channels are ideal when:

  • Streaming content -- music, video, or data feeds where each chunk costs a fraction of a cent
  • Metered APIs -- charge per-request for AI inference, geolocation, weather data, or any API where usage varies
  • Gaming -- in-game purchases, pay-per-action, or real-time reward distribution
  • IoT / machine-to-machine -- automated micro-billing between devices or services

Why channels instead of per-request payments? A per-request payment (via @bsv/payment-express-middleware) creates one on-chain transaction per API call. That works well for infrequent, higher-value payments. But for hundreds or thousands of small payments in a session, per-request transactions are too slow (each requires wallet interaction and on-chain confirmation) and too expensive (each incurs a mining fee).

Payment channels solve this by locking funds once at the start, exchanging instant off-chain commitments for each payment, and settling once at the end. A session with 1,000 micropayments requires only 2 on-chain transactions instead of 1,000.

Background

Payment channels allow two parties to exchange many small payments without broadcasting each one to the blockchain.

How it works:

  1. Opening: The consumer creates a funding transaction that locks satoshis into a 2-of-2 multisig output shared between the consumer and the platform. This is the only on-chain transaction until the channel closes.

  2. Updating: For each content chunk or API call, the consumer creates a new commitment transaction that redistributes the locked funds -- incrementing the platform's share by the chunk price and decrementing the consumer's refund. The consumer signs each commitment and sends it to the platform. These updates happen entirely off-chain; no broadcast is needed.

  3. Closing: When the consumer is done (or the channel is exhausted), either party can close by co-signing the latest commitment transaction and broadcasting it. The platform receives its accumulated payments and the consumer receives the remaining balance as a refund.

  4. Recovery: If the platform goes offline before closing, the consumer can send the latest commitment to a designated third-party recovery signer. The recovery signer co-signs and broadcasts the settlement, ensuring the consumer's funds are never locked permanently.

The result is that a session involving hundreds of micropayments requires only two on-chain transactions (open and close), with all intermediate payments settled instantly off-chain.

Features

  • Express middleware -- drop-in handler for payment channel lifecycle (discover, open, update, close)
  • ChannelClient SDK -- consumer-side client that manages the full channel protocol
  • KnexChannelStore -- production-ready persistence supporting SQLite, PostgreSQL, and MySQL
  • React hook -- usePaymentChannel for building payment-aware React components
  • Dynamic pricing -- per-content pricing and recipient splits via ChannelProvider
  • M-of-N multisig -- configurable multisig with 2-of-2, 2-of-3, and custom schemes
  • BRC-29 key derivation -- automatic per-channel address derivation for privacy and internalization
  • Client persistence -- automatic state persistence (IndexedDB in browser, filesystem in Node.js)
  • Settlement recovery -- consumer can recover funds when the platform is offline
  • BEEF delivery -- automatic settlement delivery to all recipients in PeerPayClient-compatible format (works with BSV Desktop)
  • Idle channel cleanup -- configurable auto-close of abandoned channels
  • Seamless channel transition -- close-then-open flow when a channel nears exhaustion
  • Balance warnings -- low-balance and critical-balance notifications
  • Estimation utilities -- calculate recommended funding and maximum chunks before opening
  • Optimistic locking -- concurrent request safety via version-based compare-and-swap

Installation

npm i @bsv/payment-channel-middleware

Peer Dependencies

| Package | Required | Purpose | |---------|----------|---------| | @bsv/auth-express-middleware | Yes | Request authentication (BRC-103/104/105) | | @bsv/wallet-toolbox | Optional | Standalone BRC-100 wallet with UTXO tracking | | knex | Optional | Database abstraction for KnexChannelStore | | better-sqlite3 | Optional | SQLite driver (default KnexChannelStore backend) | | @bsv/message-box-client | Optional | Settlement BEEF delivery to recipients | | react | Optional | React hook integration |

Install optional dependencies as needed:

# For SQLite persistence (recommended for development)
npm i knex better-sqlite3

# For PostgreSQL persistence (recommended for production)
npm i knex pg

# For settlement delivery to recipients via MessageBox
npm i @bsv/message-box-client

# For React integration
npm i react

Prerequisites

Wallet Setup

The middleware accepts any WalletInterface from @bsv/sdk. Two tiers are available depending on your deployment needs:

Tier 1: ProtoWallet (Lightweight)

For development, testing, and simple deployments where you don't need UTXO tracking:

import { ProtoWallet } from '@bsv/sdk'

const wallet = new ProtoWallet(new PrivateKey(process.env.PLATFORM_KEY_HEX))
  • All core functionality works: key derivation, signing, BRC-29 addresses
  • internalizeAction calls are non-fatal no-ops (the middleware handles this gracefully)
  • Platform does NOT track earned UTXOs -- settlement outputs are broadcast but not internalized
  • Good for dev/testing and platforms that sweep UTXOs separately

Tier 2: wallet-toolbox Wallet (Full BRC-100)

For production where the platform needs to track and spend earned revenue:

import { Setup } from '@bsv/wallet-toolbox'

const { wallet } = await Setup.createWalletClientNoEnv({
  chain: 'main',
  rootKeyHex: process.env.PLATFORM_ROOT_KEY,
  storageUrl: 'https://staging-storage.babbage.systems'
})
  • internalizeAction succeeds -- settlement outputs are tracked as wallet UTXOs
  • Platform can spend accumulated earnings via standard wallet operations
  • Required when the platform needs programmatic access to revenue

Where internalizeAction is called

| Call site | Purpose | Full wallet needed? | |-----------|---------|-------------------| | handleOpen (funding TX) | Track multisig output | No (always fails for multisig, expected) | | handleClose (settlement) | Track platform's recipient outputs | Yes, for revenue tracking | | client.close() (refund) | Track consumer's refund output | Yes, for consumer wallet | | client.checkPendingSettlements() | Recover missed settlements | Yes, for consumer wallet |

See docs/production-guide.md for full wallet-toolbox configuration.

Quick Start

Server Setup

import express from 'express'
import { createAuthMiddleware } from '@bsv/auth-express-middleware'
import { createChannelMiddleware } from '@bsv/payment-channel-middleware'
import { KnexChannelStore } from '@bsv/payment-channel-middleware/knex'
import { WalletClient } from '@bsv/sdk'

const wallet = new WalletClient('auto', 'localhost')

const app = express()

// Auth middleware must run first
app.use(createAuthMiddleware({ wallet }))

// Payment channel middleware
const channel = createChannelMiddleware({
  wallet,
  channelStore: new KnexChannelStore(), // SQLite default: ./data/channels.db
  chunkPrice: 100,       // satoshis per chunk
  recipients: [
    { name: 'Platform', identityKey: '02abc...', percentage: 100 }
  ]
})

app.get('/api/content/:id', channel, (req, res) => {
  // req.payment.satoshisPaid -- amount paid this chunk
  // req.channel.channelId   -- active channel ID
  // req.channel.remainingBalance -- sats remaining in channel
  res.json({ data: getContent(req.params.id) })
})

app.listen(3000)

Client Usage

import { ChannelClient } from '@bsv/payment-channel-middleware'
import { WalletClient } from '@bsv/sdk'

const wallet = new WalletClient('auto', 'localhost')
const client = new ChannelClient(wallet, 'https://api.example.com')

// Open a channel (discovers pricing, constructs funding TX)
const { channelId, remainingBalance } = await client.open('/api/content/song-1', {
  fundingAmount: 10000
})

// Pay for content chunks
const { response, receipt } = await client.update('/api/content/song-1')
console.log(response.body) // { data: '...' }
console.log(receipt.remainingBalance) // 9900

// Close when done (settles on-chain, returns refund)
const { refundAmount } = await client.close()

Configuration

ChannelMiddlewareOptions

| Option | Type | Default | Description | |--------|------|---------|-------------| | wallet | WalletInterface | required | BSV wallet for key derivation, signing, and internalizing | | channelStore | ChannelStore | KnexChannelStore() | Channel state persistence | | channelProvider | ChannelProvider | -- | Dynamic pricing/splits (alternative to chunkPrice + recipients) | | chunkPrice | number | -- | Static price per chunk in satoshis | | recipients | RecipientConfig[] | -- | Static recipient list with split percentages | | multisig | MultisigConfig | { required: 2, total: 2 } | Multisig configuration | | minFundingAmount | number | 1000 | Minimum funding amount in satoshis | | maxChannelAge | number | 86400000 | Maximum channel age in milliseconds (24h) | | feeModel | FeeModel | SatoshisPerKilobyte(100) | Dynamic settlement fee computation from TX size | | settlementFee | number | -- | Explicit settlement fee override (takes precedence over feeModel) | | dustLimit | number | 1 | Minimum output size in satoshis | | balanceWarning | BalanceWarningConfig | { low: 15%, critical: 2% } | Balance warning thresholds | | broadcaster | Broadcaster \| false | SDK default | Settlement TX broadcaster (false to disable) | | chainTracker | ChainTracker \| false | SDK default | BEEF merkle proof verifier (false to disable) | | idleClose | IdleCloseConfig | disabled | Auto-close idle channels | | deliveryService | DeliveryService | -- | BEEF delivery to settlement recipients | | reconcileOnStartup | boolean \| { concurrency } | true | Reconcile pending broadcasts on startup | | recoverySigner | string | -- | Default recovery signer identity key |

Either channelProvider or both chunkPrice and recipients must be provided.

ChannelClientOptions

| Option | Type | Default | Description | |--------|------|---------|-------------| | timeout | number | 30000 | Operation timeout in milliseconds | | retries | number | 3 | Number of retries on failure | | persist | boolean \| ClientPersistence | true | Client-side persistence (auto-detect environment) | | clearOnClose | boolean | false | Delete persisted data on channel close | | exhaustionThreshold | number | 80 | Percentage used before firing onTransitionReady | | transitionFundingAmount | number | original amount | Funding for new channel during transition | | recoverySigner | string | -- | Identity key for third-party recovery | | onLowBalance | (data) => void | -- | Callback after every payment with balance info | | onStateChange | (state, channelId?) => void | -- | Lifecycle state change callback | | onTransitionReady | (data) => void | -- | Fires when channel reaches exhaustion threshold | | onRecoveryNeeded | (data) => void | -- | Fires when close fails without recovery signer |

Channel Providers

Channel providers control per-content pricing and recipient splits.

Static (default)

When you pass chunkPrice and recipients directly to createChannelMiddleware, a StaticChannelProvider is used internally. Every content ID gets the same price and splits.

Dynamic

Use a FunctionChannelProvider for per-content pricing:

import { FunctionChannelProvider, createChannelMiddleware } from '@bsv/payment-channel-middleware'

const channel = createChannelMiddleware({
  wallet,
  channelStore,
  channelProvider: new FunctionChannelProvider(async (contentId) => {
    const content = await db.getContent(contentId)
    return {
      chunkPrice: content.isPremium ? 200 : 50,
      recipients: [
        { name: content.artistName, identityKey: content.artistKey, percentage: 70 },
        { name: 'Platform', identityKey: PLATFORM_KEY, percentage: 30 }
      ]
    }
  })
})

Or implement the ChannelProvider interface directly:

class MyChannelProvider implements ChannelProvider {
  async getChannelConfig(contentId: string): Promise<ChannelConfig> {
    // your logic
  }
}

Channel Store

InMemoryChannelStore

Suitable for development and testing. No persistence -- channel state is lost on restart.

import { InMemoryChannelStore } from '@bsv/payment-channel-middleware'

const store = new InMemoryChannelStore()

KnexChannelStore

Production-ready store supporting any Knex-compatible database. Lazy initialization with embedded migrations (no migration files needed).

import { KnexChannelStore } from '@bsv/payment-channel-middleware/knex'

// SQLite (zero config -- recommended for development)
const store = new KnexChannelStore()
// Creates ./data/channels.db with WAL mode

// SQLite with custom path
const store = new KnexChannelStore({ filename: '/var/data/channels.db' })

// PostgreSQL
const store = new KnexChannelStore({
  config: {
    client: 'pg',
    connection: process.env.DATABASE_URL
  }
})

// MySQL
const store = new KnexChannelStore({
  config: {
    client: 'mysql2',
    connection: process.env.DATABASE_URL
  }
})

// Pre-initialized Knex instance
import Knex from 'knex'
const db = Knex({ client: 'pg', connection: process.env.DATABASE_URL })
const store = new KnexChannelStore({ knex: db })

The ChannelStore interface can also be implemented directly for custom backends:

interface ChannelStore {
  get(channelId: string): Promise<ChannelState | null>
  set(channelId: string, state: ChannelState): Promise<void>
  delete(channelId: string): Promise<void>
  findActiveByConsumerAndContent(consumerIdentityKey: string, contentId: string): Promise<ChannelState | null>
  findClosedByConsumer(consumerIdentityKey: string): Promise<ChannelState[]>
  close(): Promise<void>
  // Optional methods for advanced features
  findPendingBroadcasts?(): Promise<ChannelState[]>
  findExpiredChannels?(cutoffTimestamp: number, limit?: number): Promise<ChannelState[]>
}

React Integration

import { PaymentChannelProvider, usePaymentChannel } from '@bsv/payment-channel-middleware/react'

function App() {
  return (
    <PaymentChannelProvider wallet={wallet} baseUrl="https://api.example.com">
      <ContentPlayer />
    </PaymentChannelProvider>
  )
}

function ContentPlayer() {
  const {
    status,        // 'idle' | 'opening' | 'active' | 'updating' | 'closing' | 'closed' | 'error'
    balance,       // current balance in satoshis (or null)
    channelId,     // active channel ID (or null)
    error,         // ChannelError (or null)
    percentUsed,   // 0-100
    lowBalance,    // boolean
    transitionReady,
    open,
    update,
    close,
    acceptTransition
  } = usePaymentChannel()

  const handlePlay = async () => {
    if (status === 'idle') {
      await open('/api/stream/song-1', { fundingAmount: 5000 })
    }
    const { response } = await update('/api/stream/song-1')
    playAudio(response.body)
  }

  return (
    <div>
      <p>Status: {status} | Balance: {balance} sats</p>
      <button onClick={handlePlay}>Play</button>
      <button onClick={close} disabled={status !== 'active'}>Stop</button>
    </div>
  )
}

Channel Lifecycle

Client                                Server
  |                                     |
  |--- GET /api/content/123 --------->|
  |<-- 402 { chunkPrice, recipients }--|  No x-bsv-channel header
  |                                     |
  |--- x-bsv-channel: { open } ------>|
  |<-- 200 { channelId, platformKey }--|  Funding TX validated, channel stored
  |                                     |
  |--- x-bsv-channel: { update } ---->|  Commitment TX + consumer signature
  |<-- 200 + content chunk ------------|  req.payment set, next() called
  |                                     |
  |--- x-bsv-channel: { update } ---->|  Updated commitment (sequence+1)
  |<-- 200 + content chunk ------------|
  |    ...repeat...                     |
  |                                     |
  |--- x-bsv-channel: { close } ----->|
  |<-- 200 { settlementTxid, beef } ---|  Platform co-signs, channel closed

Discovery (402)

When a request arrives without the x-bsv-channel header, the middleware returns 402 Payment Required with pricing information: chunkPrice, recipients, multisig config, and platformIdentityKey.

Open

The client sends a funding transaction (as Atomic BEEF) that locks funds into a multisig output. The middleware validates the funding output, derives BRC-29 payment addresses for all recipients and the consumer refund, and stores the channel state.

Update

For each payment, the client sends an updated commitment transaction with incrementing sequence numbers. The middleware validates the signature, commitment outputs, and balance, then sets req.payment and req.channel before calling next().

Close

Either party initiates close. The platform co-signs the latest commitment, broadcasts the settlement transaction, and returns the Atomic BEEF for consumer internalization.

Error Handling

Server Error Codes (CHANNEL_ERROR_CODES)

| Code | HTTP | Description | |------|------|-------------| | ERR_SERVER_MISCONFIGURED | 500 | Auth middleware not running before channel middleware | | ERR_CHANNEL_REQUIRED | 402 | No x-bsv-channel header; returns pricing info | | ERR_CHANNEL_NOT_FOUND | 404 | Channel ID not in store | | ERR_CHANNEL_EXPIRED | 400 | Channel exceeded maxChannelAge | | ERR_CHANNEL_CLOSED | 400 | Operation on a closed channel | | ERR_CHANNEL_EXHAUSTED | 400 | Not enough balance for the chunk | | ERR_INSUFFICIENT_BALANCE | 400 | Not enough funding for the requested chunk | | ERR_INVALID_SIGNATURE | 400 | Consumer signature fails verification | | ERR_STALE_SEQUENCE | 400 | Sequence number not strictly increasing | | ERR_INVALID_COMMITMENT | 400 | Commitment TX outputs don't match expected values | | ERR_FUNDING_TOO_LOW | 400 | Funding below minFundingAmount | | ERR_MALFORMED_HEADER | 400 | Invalid JSON or missing required fields | | ERR_DUPLICATE_CHUNK | 400 | Chunk index already committed | | ERR_CLOSE_FAILED | 500 | Settlement signing failed | | ERR_CHANNEL_INTERNAL | 500 | Unexpected internal error | | ERR_VERSION_CONFLICT | 409 | Concurrent update detected | | ERR_INVALID_BEEF | 400 | Invalid Atomic BEEF in funding TX | | ERR_MULTISIG_INVALID | 400 | Invalid multisig configuration |

Client Error Codes (CLIENT_ERROR_CODES)

| Code | Description | |------|-------------| | ERR_NOT_OPEN | No channel open; call open() first | | ERR_ALREADY_OPEN | Channel already open | | ERR_ALREADY_CLOSED | Channel already closed | | ERR_CHANNEL_EXHAUSTED | Channel balance insufficient | | ERR_NETWORK | Network error during operation | | ERR_DISCOVERY_FAILED | Pricing discovery failed | | ERR_OPEN_FAILED | Channel open request failed | | ERR_UPDATE_FAILED | Channel update request failed | | ERR_CLOSE_FAILED | Channel close request failed | | ERR_PENDING_CLOSE | Close in progress, retry with close() | | ERR_RESTORE_FAILED | Restored channel missing private key | | ERR_TRANSITION_FAILED | Channel transition failed | | ERR_TRANSITION_NOT_READY | Threshold not reached / prepare not called | | ERR_RECOVERY_FAILED | Recovery attempt failed | | ERR_RECOVERY_IN_PROGRESS | Recovery already running |

All server errors follow the format: { status: 'error', code: 'ERR_*', description: '...' }

Client errors are instances of ChannelError with .code and optional .statusCode properties.

Settlement Recovery

Platform-Side Recovery

When a consumer goes offline before receiving the close response, they can retrieve their settlement data later:

import { createSettlementRecoveryHandler } from '@bsv/payment-channel-middleware'

app.get('/api/settlements', authMiddleware, createSettlementRecoveryHandler({
  channelStore: store,
  platformIdentityKey: platformKey
}))

The consumer calls client.checkPendingSettlements('/api/settlements') to retrieve and internalize missed settlements.

Third-Party Recovery

When the platform itself is offline, a designated recovery signer can settle channels on behalf of both parties:

import { createRecoveryListener } from '@bsv/payment-channel-middleware'

const listener = createRecoveryListener({
  wallet: signerWallet,
  pollInterval: 30000,         // check every 30s
  auditLogPath: './recovery.jsonl'
})

await listener.start()
// listener.stop() for clean shutdown

The consumer's ChannelClient automatically sends recovery requests when recoverySigner is configured and the platform is unreachable during close.

Security Considerations

  • No private keys in client persistence. The consumer's ephemeral channel private key is never stored. Restored channels can close (using the platform's existing commitment) but cannot create new updates.

  • BRC-29 per-channel derivation. Each channel uses unique derived payment addresses via BRC-29, so outputs cannot be linked to the consumer's identity key or to each other. Both recipient and consumer refund outputs use BRC-29 addresses, enabling wallet internalization.

  • ANYONECANPAY | SINGLE sighash. Commitment transactions use SIGHASH_ANYONECANPAY | SIGHASH_SINGLE | SIGHASH_FORKID (0xC3), allowing each party to sign their output independently without locking the other's output.

  • Optimistic locking. Concurrent requests to the same channel are protected by version-based compare-and-swap, preventing double-spend through race conditions.

  • Optional broadcaster. Pass broadcaster: false to disable automatic settlement broadcasting for environments where you manage broadcast externally.

API Reference

See API Reference for complete documentation of all exported functions, classes, interfaces, and types.

For client-side usage patterns, see the docs/ directory:

Examples

The examples/ directory contains runnable examples for common integration patterns:

| Example | Description | |---------|-------------| | basic-server/ | Minimal server with payment channel middleware | | basic-client/ | Minimal ChannelClient consumer | | demo/ | Full lifecycle demo with mock wallets (no real BSV needed) | | knex-postgres/ | PostgreSQL production setup with KnexChannelStore | | react-app/ | React integration with usePaymentChannel hook | | recovery-setup/ | Third-party recovery signer configuration | | production-e2e/ | End-to-end tests with real BSV Desktop wallet and ARC broadcasting |

Resources

License

SEE LICENSE IN LICENSE.txt