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

@bsv/wallet-relay

v0.5.2

Published

BSV mobile wallet relay — relay server and pairing client

Readme

@bsv/wallet-relay

BSV mobile wallet QR pairing — relay server, session management, and desktop frontend utilities.

Lets any web app offer "connect via mobile wallet" as a signing or authentication option. The desktop shows a QR code; the user scans it with their BSV wallet app; from that point all wallet operations are handled by the mobile over an encrypted WebSocket relay. Wallet keys never leave the mobile device. The relay never sees plaintext.

Early release — use at your own discretion. This library is functional and used in production internally, but the API may change without notice. No stability guarantees are made until a v1.0 release is tagged.


Who needs what

| You are | What you need | | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | Web app developer adding mobile wallet support to a site | Backend: one WalletRelayService call. Frontend: useWalletRelayClient + WalletConnectionModal + QRDisplay from @bsv/wallet-relay/react | | Mobile wallet developer adding QR pairing to a wallet app | WalletPairingSession from @bsv/wallet-relay/client |

Most integrators are in the first group. If you're building a website that lets users connect their mobile BSV wallet, you do not touch the mobile side at all. The WalletPairingSession API exists for wallet app developers (like BSV Browser) who are implementing the mobile end of the protocol.


Quickstart — web app

1. Install

npm install @bsv/wallet-relay @bsv/sdk
npm install express cors ws qrcode   # backend peer deps — not needed for frontend-only projects

The optional Express runtime and its declarations are supplied by the backend application, so relay routes share one Express 4 or 5 type graph with the host.

@bsv/sdk is used throughout — backend wallet crypto, frontend local wallet detection, and mobile pairing. Install it in every layer of your project.

Wallet RPC byte fields are normalized and validated in both directions. The relay preserves historical number[], current Uint8Array, and numeric-key JSON wallet representations for direct and deferred-signing actions without changing non-byte RPC fields.

TypeScript: your tsconfig.json needs "moduleResolution": "bundler" (or "node16" / "nodenext") to resolve the @bsv/wallet-relay/react and @bsv/wallet-relay/client subpath exports.

2. Generate a stable backend key

The backend needs a fixed private key. The mobile derives its ECDH shared secret from the backend's identity key, which is embedded in the QR code — so the same key must be used across server restarts. Generate it once and store it in .env:

node --input-type=commonjs -e "const {PrivateKey}=require('@bsv/sdk'); console.log(PrivateKey.fromRandom().toHex())"

.env:

WALLET_PRIVATE_KEY=<hex output from above — keep secret, never commit>
RELAY_URL=ws://localhost:3000
ORIGIN=http://localhost:5173
  • RELAY_URL — WebSocket address the mobile connects to. In production use a publicly reachable URL (e.g. wss://yourapp.com).
  • ORIGIN — Default URL embedded in the QR when no per-session origin is supplied. Used as the fallback by createSession() when its Origin header is missing.

Production note: in a typical deployment, both RELAY_URL and ORIGIN share the same domain (wss://yourapp.com + https://yourapp.com). No extra configuration is needed. The relay can freely be on a separate domain or a third-party service — the mobile fetches the relay address from the origin server over HTTPS, making the origin's TLS certificate the trust anchor rather than hostname matching.

Desktop API transport: an absolute WalletRelayClient.apiUrl must use HTTPS in production because its requests carry the desktop session token and wallet RPC payloads. Root-relative same-origin paths and HTTP loopback hosts remain available; credential-bearing URLs, query strings, and fragments are rejected. Relay discovery does not follow redirects, has a 10-second deadline, and rejects responses larger than 256 KiB or containing invalid UTF-8.

Multi-app deployments: one relay can serve N webapps. Pass an allowedOrigins allowlist (string[], RegExp, or predicate) to WalletRelayService and the built-in GET /api/session route forwards each browser's Origin header into createSession({ origin }) — every QR points back at the calling webapp instead of the relay's own URL. See API.md for the full option.

Public-service default: when neither allowedOrigins nor a constructor origin is supplied, the relay accepts browser origins from previously unknown domains. ORIGIN remains the fallback URL placed in a QR; setting it as an environment variable does not silently create a WebSocket allowlist. The scaffold mirrors this with public reflected CORS by default and an optional comma-separated ALLOWED_ORIGINS whitelist. Origin checks and CORS are deployment controls, not authentication: desktop tokens, topic validation, QR signatures, and encrypted pairing remain enforced in either mode.

Local dev with split frontend/backend: a physical mobile device must use an HTTPS origin with a valid certificate (for example, a trusted development tunnel) to reach GET /api/session/:id. Plain HTTP pairing origins are accepted only on localhost, 127.0.0.1, or [::1]; an http://<lan-ip> QR is rejected. ORIGIN stays as the Vite URL for browser CORS. This variable is not needed in production.

3. App setup

npx @bsv/wallet-relay init

Generates a working Express backend and React+Vite frontend wired together. Existing files are never overwritten.

backend/
  server.ts          — Express + WalletRelayService, reads env vars
  .env.example       — copy to .env and fill in WALLET_PRIVATE_KEY
frontend/
  hooks/
    useWalletSession.ts    — re-exports useWalletRelayClient from @bsv/wallet-relay/react
  components/
    WalletConnectionModal.tsx  — styled wrapper around @bsv/wallet-relay/react WalletConnectionModal
    QRDisplay.tsx              — styled wrapper around @bsv/wallet-relay/react QRDisplay
    WalletActions.tsx          — buttons for each wallet method (app-specific, customise here)
    RequestLog.tsx             — styled wrapper around @bsv/wallet-relay/react RequestLog
  views/
    DesktopView.tsx    — composes all of the above
  types/
    wallet.ts          — WalletMethod (app-specific); re-exports shared types from @bsv/wallet-relay/client

Options: --nextjs for a Next.js project, --backend / --frontend for one side only, --backend-dir / --frontend-dir to control output directories.

The scaffolded files contain explicit CUSTOMIZE markers for application-owned wallet methods, UI copy, and the installUrl in WalletConnectionModal (defaulting to https://desktop.bsvb.tech, the BSV wallet with desktop and mobile support). These are consumer integration points, not unfinished relay library behavior.

After scaffolding:

cp backend/.env.example backend/.env
# Fill in WALLET_PRIVATE_KEY in backend/.env, then:
npm run dev --prefix backend
npm run dev --prefix frontend

Backend

import express from 'express'
import { createServer } from 'http'
import cors from 'cors'
import { ProtoWallet, PrivateKey } from '@bsv/sdk'
import { WalletRelayService } from '@bsv/wallet-relay'

const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',').map(value => value.trim())

const app = express()
app.use(
  cors({
    // Public by default; set ALLOWED_ORIGINS to opt into an exact whitelist.
    origin: allowedOrigins?.length ? allowedOrigins : true,
    allowedHeaders: ['Content-Type', 'Authorization', 'X-Desktop-Token']
  })
)
app.use(express.json())

const server = createServer(app)
const wallet = new ProtoWallet(PrivateKey.fromHex(process.env.WALLET_PRIVATE_KEY!))

new WalletRelayService({
  app,
  server,
  wallet,
  allowedOrigins: allowedOrigins?.length ? allowedOrigins : undefined
})

server.listen(3000)

That's the entire backend. WalletRelayService registers four REST routes and the /ws WebSocket endpoint automatically:

| Method | Path | Description | | -------- | ------------------ | ----------------------------------------------------------------------------------- | | GET | /api/session | Create session, return { sessionId, status, qrDataUrl, pairingUri, desktopToken } | | GET | /api/session/:id | Poll session status | | POST | /api/request/:id | Send a wallet RPC call to the paired mobile | | DELETE | /api/session/:id | Terminate session — closes the mobile's WebSocket so the mobile app is notified |

relayUrl and origin are optional — they default to process.env.RELAY_URL / process.env.ORIGIN, then ws://localhost:3000 / http://localhost:5173. For multi-app deployments, optionally pass allowedOrigins (a string[], RegExp, or predicate). With neither allowedOrigins nor an explicit constructor origin, origin validation is public-by-default. Supplying an explicit constructor origin retains the legacy exact-origin restriction:

new WalletRelayService({
  app,
  server,
  wallet,
  allowedOrigins: ['https://app-a.example.com', 'https://app-b.example.com']
  // or: /\.example\.com$/
  // or: (origin) => origin.endsWith('.example.com')
})

The built-in GET /api/session route forwards the request's Origin header into createSession({ origin }), so the QR points back at the calling webapp rather than the relay's own URL. Origins not in allowedOrigins get a 403.

Session creation and status responses are emitted with Cache-Control: no-store because the creation response contains a wallet-equivalent bearer token. Preserve that policy at reverse proxies and CDNs. The scaffold also caps JSON request bodies at 64 KiB; apply an equivalent bound when supplying your own parser or framework routes.

The service limits new sessions to 120 per minute and 1,000 live sessions by default. Tune maxSessionCreationsPerMinute / maxSessions for measured demand and add a source-aware rate limit at the public edge; the in-process global limit is a final resource boundary, not a substitute for distributed abuse controls.

desktopToken is returned by GET /api/session and must be sent as an X-Desktop-Token header on every POST /api/request/:id call. It grants the ability to ask the connected wallet to perform operations, so treat it as a wallet-equivalent bearer secret: do not log it, place it in URLs, copy it into the QR, or share it with the mobile. WalletRelayClient stores it in sessionStorage by default solely to support same-tab refresh; use persistSession: false for higher-assurance pages and clear it with disconnect(). useWalletRelayClient / WalletRelayClient handle the header automatically. If you call relay.sendRequest() directly from your own route handlers, forward the header yourself — see Next.js setup below.

For a direct desktop-role browser WebSocket, authenticate without putting the token in the routinely logged URL: new WebSocket(url, ['bsv-wallet-relay', 'bsv-wallet-relay-token.' + desktopToken]). The server selects and echoes only bsv-wallet-relay. The legacy ?token= form remains accepted for wire compatibility but should not be used by new clients.

WalletRelayClient retains at most 100 request-log entries by default; set maxLogEntries: 0 to disable retention or choose a bounded value up to 10,000. Wallet results may contain certificates, decrypted plaintext, linkage proofs, and other sensitive values, so render or export the debug log only in an appropriately trusted UI.

CORS and CSP: if your frontend and backend run on different origins, include X-Desktop-Token in CORS allowedHeaders; otherwise the browser's preflight blocks POST /api/request/:id. Public CORS should reflect the requesting origin without enabling cookie credentials. To opt into a whitelist, apply the same exact origins to CORS and allowedOrigins. CSP is owned by the embedding app, not injected by the relay; its connect-src must include the configured HTTP(S) origin and WS(S) relay. Do not use CORS or CSP as a substitute for relay authentication.

Sharing the server with other WebSocket services

The relay mounts at /ws and is non-greedy: it claims only its own path and ignores every other upgrade request, so a messaging service — or any other WebSocket endpoint — can share the same HTTP server without being rejected.

Change the path with path, or take over upgrade routing entirely with noServer:

import { WebSocketServer } from 'ws'

// Mount the relay at a different path
new WalletRelayService({ app, server, wallet, path: '/wallet-ws' })

// Or run your own upgrade dispatcher across several WS services
const relay = new WalletRelayService({ app, server, wallet, noServer: true })
const msgWss = new WebSocketServer({ noServer: true })

server.on('upgrade', (req, socket, head) => {
  const { pathname } = new URL(req.url ?? '', 'http://localhost')
  if (pathname === '/ws') relay.handleUpgrade(req, socket, head)
  else if (pathname === '/messaging')
    msgWss.handleUpgrade(req, socket, head, ws => msgWss.emit('connection', ws, req))
  else socket.destroy()
})

In noServer mode the relay attaches no upgrade listener of its own — you dispatch to relay.handleUpgrade(). In default mode, unmatched upgrade paths are left untouched for other listeners; if nothing ever claims a path, that socket stays open until the client times out (add an else socket.destroy() in your own dispatcher to reject it eagerly).

Frontend

@bsv/wallet-relay/react exports everything needed for wallet detection and QR pairing:

import { useState, useCallback } from 'react'
import type { WalletClient } from '@bsv/sdk'
import { useWalletRelayClient, WalletConnectionModal, QRDisplay } from '@bsv/wallet-relay/react'

export function App() {
  const [mode, setMode] = useState<'detecting' | 'local' | 'mobile'>('detecting')

  // autoCreate: false — only start a backend session when the user picks the mobile path
  const { session, error, createSession, cancelSession, sendRequest } = useWalletRelayClient({
    autoCreate: false
  })

  const handleLocalWallet = useCallback((wallet: WalletClient) => {
    setMode('local')
    // CUSTOMIZE: store the wallet in your application state.
  }, [])

  // Stop polling when the user navigates away from the QR screen
  useEffect(() => {
    if (mode !== 'mobile') return
    return () => {
      cancelSession()
    }
  }, [mode])

  return (
    <>
      {mode === 'detecting' && (
        <WalletConnectionModal
          onLocalWallet={handleLocalWallet}
          onMobileQR={() => {
            setMode('mobile')
            void createSession()
          }}
        />
      )}

      {mode === 'mobile' && (
        <>
          {error && <p>{error}</p>}
          <QRDisplay session={session} onRefresh={createSession} />
        </>
      )}

      {mode === 'local' && <>{/* CUSTOMIZE: render the local-wallet application. */}</>}
    </>
  )
}

WalletConnectionModal silently checks for a local BSV wallet first:

  • Local wallet found → calls onLocalWallet immediately, renders nothing
  • Not found → renders an install link and a "Connect via Mobile QR" button

QRDisplay shows the QR image, a status badge (pending / connected / disconnected / expired), and a refresh button when the session expires. Both components are unstyled — pass className, style, and per-element props to style them. See API.md for the full prop reference.

Next.js setup

The relay WebSocket is decoupled from your origin server — the mobile fetches the relay URL from your origin over HTTPS after scanning the QR. This means the WebSocket relay can run anywhere: a separate persistent service, or a third-party provider (Ably, Pusher, Soketi).

| Deployment | Supported | Notes | | ----------------------------- | --------- | ---------------------------------------------------------------------------------------------------------- | | Self-hosted / VPS / container | ✓ | Run WalletRelayService with built-in WebSocket relay | | Vercel / Netlify / serverless | ✓ | REST routes in serverless functions; point RELAY_URL at a separate relay service or third-party provider |

Relay service — deploy a minimal Express + WalletRelayService on Railway, Render, or Fly.io. This handles the WebSocket connections and session state. Set RELAY_URL in your Next.js environment to its wss:// address.

Next.js API routes call relay methods directly — no custom server required. You must forward X-Desktop-Token on the request route.

// app/api/session/route.ts
import { relay } from '@/lib/relay' // your WalletRelayService singleton

export async function GET() {
  const info = await relay.createSession()
  return Response.json(info)
}
// app/api/session/[id]/route.ts
import { relay } from '@/lib/relay'

export async function GET(_req: Request, { params }: { params: { id: string } }) {
  const info = relay.getSession(params.id)
  if (!info) return Response.json({ error: 'Session not found' }, { status: 404 })
  return Response.json(info)
}

export async function DELETE(req: Request, { params }: { params: { id: string } }) {
  const token = req.headers.get('x-desktop-token') ?? undefined
  if (!token) return Response.json({ error: 'Missing desktop token' }, { status: 401 })
  try {
    relay.deleteSession(params.id, token)
    return new Response(null, { status: 204 })
  } catch (err) {
    const msg = err instanceof Error ? err.message : 'Failed'
    const status = msg === 'Invalid desktop token' ? 401 : msg === 'Session not found' ? 404 : 500
    return Response.json({ error: msg }, { status })
  }
}
// app/api/request/[id]/route.ts
import { relay } from '@/lib/relay'

export async function POST(req: Request, { params }: { params: { id: string } }) {
  const { method, params: rpcParams } = (await req.json()) as { method: string; params: unknown }
  const token = req.headers.get('x-desktop-token') ?? undefined
  try {
    const result = await relay.sendRequest(params.id, method, rpcParams, token)
    return Response.json(result)
  } catch (err) {
    const msg = err instanceof Error ? err.message : 'Request failed'
    const status = msg === 'Invalid desktop token' ? 401 : msg.startsWith('Session is') ? 400 : 504
    return Response.json({ error: msg }, { status })
  }
}

The frontend is identical to the Vite/Express setup — useWalletRelayClient works the same regardless of backend framework.

4. Use the wallet proxy — no call site changes needed

useWalletRelayClient returns a wallet object alongside session. Once session?.status === 'connected', wallet is a fully WalletInterface-compatible proxy that forwards every call to the paired mobile over the relay. When the session is not connected, wallet is null.

This means you do not need separate code paths for mobile vs local wallet. The proxy has the same method signatures as WalletClient — swap the wallet reference in your existing wallet context and every call site in your app continues to work unchanged:

// Before (local WalletClient):
const { publicKey } = await wallet.getPublicKey({ identityKey: true })
const { certificates } = await wallet.listCertificates({ certifiers: [...], types: [...] })
const { txid } = await wallet.createAction({ description: 'Pay invoice', outputs: [...] })

// After (mobile relay proxy — identical call sites, zero changes):
const { publicKey } = await wallet.getPublicKey({ identityKey: true })
const { certificates } = await wallet.listCertificates({ certifiers: [...], types: [...] })
const { txid } = await wallet.createAction({ description: 'Pay invoice', outputs: [...] })

How it works: each method on the proxy captures its name at construction time, calls sendRequest(method, params) internally, unwraps res.result, and throws on res.error — so callers see a normal return value or a thrown Error, exactly as they would from a local WalletClient.

// Typical integration — inject into your existing wallet context on connect:
const { session, wallet } = useWalletRelayClient({ autoCreate: false })

useEffect(() => {
  if (session?.status === 'connected' && wallet) {
    setWalletContext(wallet) // drop-in — all existing wallet calls now route to mobile
  }
}, [session?.status, wallet])

If you want refreshes to keep the user paired without auto-creating a session for users who never clicked "Sign in with phone", add autoResume: true:

const { session, wallet, createSession } = useWalletRelayClient({
  autoCreate: false,
  autoResume: true // resume on mount only — never auto-create
})

The proxy is created once and cached for the lifetime of the session. Available methods: getPublicKey, listOutputs, createAction, signAction, createSignature, listActions, internalizeAction, acquireCertificate, relinquishCertificate, listCertificates, revealCounterpartyKeyLinkage.

Lower-level option: sendRequest(method, params) is still available if you need the raw { result, error, requestId, timestamp } response envelope — useful for request logging or custom error handling.


For mobile wallet developers

If you are building a BSV wallet app and want to support QR pairing with desktop web apps, use WalletPairingSession from @bsv/wallet-relay/client:

import { WalletClient } from '@bsv/sdk'
import {
  WalletPairingSession,
  parsePairingUri,
  verifyPairingSignature
} from '@bsv/wallet-relay/client'

const result = parsePairingUri(scannedUri)
if (result.error) {
  showError(result.error)
  return
}

// Verify the QR signature before trusting any of the fields
if (!(await verifyPairingSignature(result.params))) {
  showError('QR code signature is invalid — do not connect')
  return
}

// Show result.params.origin to the user — this is the domain they are about to connect to
await showApprovalUI(result.params.origin)

const wallet = new WalletClient('auto')
const session = new WalletPairingSession(wallet, result.params, {
  // Defaults to the full BSV Browser method set — override only if needed:
  // implementedMethods: new Set(['getPublicKey', 'createAction']),
  // autoApproveMethods: new Set(['getPublicKey']),
  onApprovalRequired: async (method, params) => await showApprovalModal(method, params),
  walletMeta: { name: 'My Wallet', version: '1.0' }
})

session
  // WalletClient implements WalletInterface but isn't string-indexed — cast required
  .onRequest(async (method, params) =>
    (wallet as Record<string, (p: unknown) => Promise<unknown>>)[method](params)
  )
  .on('connected', () => setStatus('connected'))
  .on('disconnected', () => setStatus('disconnected'))
  .on('error', msg => setError(msg))

// Fetch the relay URL from the origin server over HTTPS — must be called before connect()
await session.resolveRelay()
await session.connect()

The relay URL is no longer embedded in the QR code. Instead resolveRelay() verifies the required QR signature, then calls GET {origin}/api/session/{topic} over HTTPS and reads the relay field from the response. The origin's TLS certificate is the trust anchor — the relay itself can be hosted anywhere. Plain HTTP origins, cleartext non-loopback WebSockets, redirects, unsigned QR codes, malformed session responses, and unbounded responses are rejected; loopback HTTP/WS remains available for same-device development.

The short-lived QR is also a bearer pairing invitation. Its signature authenticates the backend to the phone; it does not identify which phone is expected. Anyone who can copy or scan an unexpired QR can pair their own wallet first, after which the relay pins that mobile identity key. Scanning normally requires physical proximity to the displayed code, but a copied image, screen share, support transcript, log, or exposed raw pairing URI removes that proximity barrier. Keep those values confidential and require explicit per-operation approval on the phone. The current security model intentionally relies on possession of the short-lived invitation plus the phone's later approvals; deployments that need a pre-identified phone must add that assurance at the application level.

The QR signature covers topic, backend identity key, origin, and expiry, but not the protocolID field or deep-link scheme. Those unsigned values are routing inputs, not authenticated authority. With the built-in service, tampering with either causes key-derivation or application-routing failure rather than granting wallet authority, but it can prevent pairing. Applications must not make authorization decisions from either unsigned value.

Methods outside autoApproveMethods fail closed when onApprovalRequired is omitted. Configure an approval UI for signing, spending, decryption, HMAC, and other privileged operations; the default auto-approved set contains only getPublicKey.

To resume after a network drop, call resolveRelay() again before reconnect() (it is a lightweight HTTP call):

const lastSeq = await SecureStore.getItemAsync(`lastseq_${topic}`)
await session.resolveRelay()
await session.reconnect(Number(lastSeq ?? 0))

DEFAULT_IMPLEMENTED_METHODS and DEFAULT_AUTO_APPROVE_METHODS are exported from @bsv/wallet-relay/client if you want to reference or extend the defaults.


React components

@bsv/wallet-relay/react exports six items:

| Export | Description | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | useWalletRelayClient | Session creation, status polling, wallet proxy (drop-in WalletInterface), cancelSession for cleanup on navigation, and sendRequest — the main hook for QR pairing | | WalletConnectionModal | Detects local wallet; shows install link + mobile QR button if none found | | QRDisplay | QR image with status badge and session refresh | | QRPairingCode | Tappable QR that opens the wallet://pair?… deeplink directly | | RequestLog | Live request/response log (useful for debugging and demo UIs) | | useQRPairing | Cross-platform deeplink hook — use directly in React Native |

All visual components are unstyled. Pass className, style, and per-element props to style them. See API.md for full prop documentation.

React Native — use useQRPairing directly instead of QRPairingCode:

import { Linking } from 'react-native'
import { useQRPairing } from '@bsv/wallet-relay/react'

const { open } = useQRPairing(pairingUri, { openUrl: Linking.openURL })

return (
  <TouchableOpacity onPress={open}>
    <Image source={{ uri: qrDataUrl }} style={styles.qr} />
  </TouchableOpacity>
)

Advanced usage — building blocks

All internal classes are exported for custom composition: custom session stores, non-Express frameworks, alternative transports.

import {
  WebSocketRelay,
  QRSessionManager,
  WalletRequestHandler,
  buildPairingUri,
  encryptEnvelope,
  decryptEnvelope
} from '@bsv/wallet-relay'

const sessions = new QRSessionManager()
const relay = new WebSocketRelay(server)
const handler = new WalletRequestHandler()

relay.onValidateTopic(topic => sessions.getSession(topic) !== null)
relay.onIncoming((topic, envelope, role) => {
  /* custom logic */
})
sessions.onSessionExpired(id => relay.removeTopic(id))

WebSocketRelay accepts the same path and noServer options as the facade (see Sharing the server with other WebSocket services); in noServer mode, dispatch upgrades to relay.handleUpgrade(req, socket, head).

The high-level facades (WalletRelayService, WalletPairingSession) follow semver strictly. The building blocks are stable but may have more targeted breaking changes between minor versions.


Encryption model

All messages use BSV wallet-native ECDH via @bsv/sdk. No custom crypto.

  • Each side calls wallet.encrypt({ protocolID, keyID: sessionId, counterparty }) where counterparty is the other party's identity public key
  • The relay routes ciphertext blobs — it never decrypts anything
  • The pairing bootstrap sends mobileIdentityKey unencrypted in the outer envelope once (on pairing_approved) so the backend can verify the inner payload. All subsequent messages use only the stored key.
  • RPC IDs, sequences, topics, and response shapes are checked exactly. Known BRC-100 wallet results are validated before either side trusts them; false verification, internalization, relinquishment, or authentication verdicts are rejected rather than returned as successful results.
  • Wire messages are limited to 64 KiB, the default in-memory session/topic cap is 1000, and a second mobile cannot replace the active mobile for a session.
  • Every fresh or reconnecting mobile socket must prove possession of its identity key within 15 seconds; merely knowing a session topic cannot reserve the mobile slot.

WalletLike throughout is Pick<WalletInterface, 'getPublicKey' | 'encrypt' | 'decrypt' | 'createSignature'> — satisfied by both ProtoWallet and WalletClient from @bsv/sdk.


Entry points

| Import | Environment | Contains | | -------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------- | | @bsv/wallet-relay | Node.js only | WalletRelayService, WebSocketRelay, QRSessionManager, WalletRequestHandler, shared utilities | | @bsv/wallet-relay/client | Browser + React Native | WalletRelayClient, WalletPairingSession, WalletMethodName, shared utilities, no Node.js deps | | @bsv/wallet-relay/react | React ≥17 | useWalletRelayClient, WalletConnectionModal, QRDisplay, QRPairingCode, RequestLog, useQRPairing |


Development and Distribution

Run development from the ts-stack repository root with Node.js 24.11 or newer and pnpm 10:

pnpm install
pnpm --filter @bsv/wallet-relay format:check
pnpm --filter @bsv/wallet-relay lint
pnpm --filter @bsv/wallet-relay typecheck
pnpm --filter @bsv/wallet-relay test:coverage
pnpm --filter @bsv/wallet-relay pack:check
pnpm --filter @bsv/wallet-relay test:browser

The coverage gate ratchets all four Jest metrics. pack:check installs the exact tarball into ESM and CommonJS consumers, validates root/client/React entrypoints and conditional declarations, and executes the installed CLI help path without writing files. The browser check bundles only ./client with Vite and esbuild, rejects server dependencies, validates source maps, and enforces raw, gzip, and Brotli budgets. Publishing and version changes are performed only by the repository release workflow.


API reference

See API.md for full parameter and method documentation.

License

Open BSV License Version 6. See LICENSE.txt.

Heartbeat tolerance and close diagnostics

Version 0.5 pings every 30 seconds and tolerates two consecutive missed pongs. Any inbound message resets the missed-pong counter. Set maxMissedHeartbeats: 1 on WalletRelayService or WebSocketRelay to retain the previous one-miss behavior. heartbeatIntervalMs accepts integers from 1 to 2147483647 milliseconds. No relay envelope or wallet RPC encoding changes are required.

Use onSocketClosed on the service (or onSocketClose on the relay) to record close code, cause, connection duration, and missed pongs. The diagnostic callback runs before disconnect bookkeeping; thrown errors and rejected promises are contained, and asynchronous logging is not awaited. Keep callbacks short and handle exporter failures inside your logging integration.