This source candidate declares SDK peer ^2.4.1 || ^3.0.0. SDK3 remains
a coordinated proposal; see the qualification and migration limits
before adopting it.
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.
npm install @bsv/wallet-relayimport 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)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>
)
}
GET /api/session, POST /api/request/:id) and WebSocket endpointencryptEnvelope(), decryptEnvelope() for AES-256-GCM authenticated encryptionbuildPairingUri(), parsePairingUri() for QR encodingbytesToBase64url(), base64urlToBytes() for URL-safe binaryimport { 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"
/>
</>
)
}
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)
}
}import {
WalletPairingSession,
parsePairingUri,
verifyPairingSignature
} 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')
if (!(await verifyPairingSignature(params))) {
throw new Error('Unsigned or invalid pairing URI')
}
const session = new WalletPairingSession(myWalletInstance, params, {
autoApproveMethods: new Set(['getPublicKey']),
onApprovalRequired: async (method, requestParams) =>
await showApprovalModal(method, requestParams)
})
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()GET /api/session and required on POST /api/request/:id. Ensures only the frontend that created the session can use it.Backend key stability —
PrivateKeymust be the same across server restarts. Store in env var or secure vault, never generate new key each start.
Unsigned pairing or missing approval UI — Version 0.4 rejects unsigned pairing URIs, non-loopback HTTP origins or direct relay API URLs, and every non-auto-approved wallet method when
onApprovalRequiredis absent. Enable signed QR codes and wire an explicit approval callback before upgrading.
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.
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.