@bsv/wallet-relay
Enables mobile-to-desktop wallet pairing via QR codes and encrypted WebSocket tunnels. A web app (desktop browser) shows a QR code; user scans with their mobile BSV wallet; all wallet operations (signing, key retrieval, etc.) are proxied over HTTPS+WSS relay servers to the mobile without exposing keys or trust chains to the desktop. Provides both the relay server infrastructure (Node.js) and React frontend components for web apps to add "Connect Mobile Wallet" functionality.
Install
npm install @bsv/wallet-relayQuick start
Set up relay server (Express + Node.js)
import express from 'express'
import { createServer } from 'http'
import cors from 'cors'
import { WalletRelayService } from '@bsv/wallet-relay'
import { ProtoWallet, PrivateKey } from '@bsv/sdk'
const app = express()
app.use(cors({
origin: process.env.ORIGIN,
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,
relayUrl: process.env.RELAY_URL,
origin: process.env.ORIGIN
})
server.listen(3000)Create session and get QR code (frontend React)
import { useWalletRelayClient } from '@bsv/wallet-relay/react'
import { useEffect, useState } from 'react'
function WalletConnection() {
const { createSession } = useWalletRelayClient({
apiUrl: 'https://relay.example.com',
autoCreate: false
})
const [qrData, setQrData] = useState<string | null>(null)
const [sessionId, setSessionId] = useState<string | null>(null)
useEffect(() => {
const setup = async () => {
const session = await createSession()
setSessionId(session.sessionId)
setQrData(session.qrDataUrl) // Base64 PNG
}
setup()
}, [])
return (
<div>
{qrData && <img src={qrData} alt="Scan to pair wallet" />}
<p>Scan with your BSV wallet app</p>
</div>
)
}
What it provides
Server-Side (Node.js)
- WalletRelayService — All-in-one relay server; auto-registers REST routes (
GET /api/session,POST /api/request/:id) and WebSocket endpoint - QRSessionManager — Session lifecycle management, status tracking, cleanup
- WebSocketRelay — WebSocket server with message routing, topic validation, token auth
- WalletRequestHandler — Converts JSON-RPC calls to wallet method invocations
Client-Side (Browser)
- WalletRelayClient — Direct HTTP/WebSocket client for session management and RPC calls
- useWalletRelayClient — React hook wrapping client
- useWalletSession — Hook for session state management
- WalletConnectionModal — Pre-built UI component for QR pairing flow
- QRDisplay — Standalone QR code display component
- RequestLog — UI for displaying pending/completed RPC requests
Shared Utilities
- Encryption —
encryptEnvelope(),decryptEnvelope()for AES-256-GCM authenticated encryption - URI handling —
buildPairingUri(),parsePairingUri()for QR encoding - Signature verification —
verifyPairingSignature()for ECDSA signature validation - Encoding —
bytesToBase64url(),base64urlToBytes()for URL-safe binary
CLI Scaffolding
- npx @bsv/wallet-relay init — Command to scaffold Express backend + React frontend
Common patterns
Use WalletConnectionModal component
import { WalletConnectionModal } from '@bsv/wallet-relay/react'
import { useState } from 'react'
function App() {
const [showQR, setShowQR] = useState(false)
return (
<>
<WalletConnectionModal
onLocalWallet={(wallet) => {
console.log('Local wallet connected')
// Use WalletClient directly.
}}
onMobileQR={() => setShowQR(true)}
installUrl="https://desktop.bsvb.tech"
/>
</>
)
}
Send wallet RPC call from desktop to mobile
import { WalletRelayClient } from '@bsv/wallet-relay/client'
import { P2PKH } from '@bsv/sdk'
async function sendPayment(client: WalletRelayClient) {
const lockingScript = new P2PKH()
.lock('1EvmsbpAY7nESLkN4ajLTMbvsaQ1HpJPGX')
.toHex()
const response = await client.sendRequest('createAction', {
description: 'Send payment',
outputs: [{
satoshis: 5000,
lockingScript,
outputDescription: 'payment output'
}]
})
if (response.error) {
console.error('Mobile rejected:', response.error)
} else {
console.log('Action created:', response.result)
}
}Mobile wallet implementation
import { WalletPairingSession, parsePairingUri } from '@bsv/wallet-relay/client'
// Scan desktop QR code to get the pairing URI.
const { params, error } = parsePairingUri(scannedQR)
if (!params) throw new Error(error ?? 'Invalid pairing URI')
const session = new WalletPairingSession(myWalletInstance, params, {
autoApproveMethods: new Set(['getPublicKey'])
})
session.onRequest(async (method, params) => {
// Forward approved requests to the local mobile wallet implementation.
return (myWalletInstance as any)[method](params)
})
await session.resolveRelay()
await session.connect()Key concepts
- QR Pairing — Desktop displays QR code encoding relay URL + session ID. Mobile scans and establishes WebSocket connection.
- Encrypted Relay — All wallet requests/responses are AES-256-GCM encrypted. Relay server never sees plaintext.
- Stateless Sessions — Each pairing session is isolated. Multiple desktops can pair to same mobile (each gets own session).
- Desktop Token — Opaque token returned from
GET /api/sessionand required onPOST /api/request/:id. Ensures only the frontend that created the session can use it. - Wallet Method Forwarding — Desktop sends standard JSON-RPC calls (createAction, signAction, getPublicKey, etc.) to mobile wallet.
- No Key Export — Mobile never exports private keys. Desktop sees only signatures, public keys, and action results.
- Session Expiry — Sessions timeout after inactivity (typically 24 hours). Mobile can pair new desktop anytime.
When to use this
- You're building a web app that needs to integrate with mobile wallets
- You want mobile users to pair with desktop for signing without downloading software
- You need secure wallet proxying over untrusted networks
- You're building a cross-device wallet experience
When NOT to use this
- Use @bsv/wallet-toolbox directly if you need local wallet without pairing
- Use @bsv/sdk if you only need transaction building, not wallet integration
Spec conformance
- ECDH key agreement — Pairing uses ECDH for shared secret derivation
- AES-256-GCM — Authenticated encryption for all message envelopes
- JSON-RPC 2.0 — Standard format for all wallet method calls
- WebSocket — RFC 6455 WebSocket protocol with TLS
- Base64url — URL-safe base64 for encoding binary data in URLs
Common pitfalls
Backend key stability —
PrivateKeymust be the same across server restarts. Store in env var or secure vault, never generate new key each start.
Missing X-Desktop-Token header —
POST /api/request/:idrequiresX-Desktop-Tokenheader. Browser CORS preflight must allow this header inallowedHeaders.
CORS misconfiguration — If frontend and backend are different origins, CORS headers must be set. Missing
Access-Control-Allow-CredentialsorAccess-Control-Allow-Headerswill cause browser to block requests.
WebSocket TLS mismatch — If frontend is HTTPS but relay is ws:// (not wss://), browser blocks upgrade. Always use wss:// in production.
Relay URL in QR — The relay URL in QR is public. If relay is on internal network, mobile can't reach it. Use publicly routable URL or tunnel.
Related packages
- @bsv/sdk — Cryptographic primitives and ProtoWallet
- @bsv/wallet-toolbox — Wallet implementation for backend
- @bsv/btms — Token protocol integration