@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 projectsThe 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.jsonneeds"moduleResolution": "bundler"(or"node16"/"nodenext") to resolve the@bsv/wallet-relay/reactand@bsv/wallet-relay/clientsubpath 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:5173RELAY_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 bycreateSession()when itsOriginheader is missing.
Production note: in a typical deployment, both
RELAY_URLandORIGINshare 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.apiUrlmust 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
allowedOriginsallowlist (string[],RegExp, or predicate) toWalletRelayServiceand the built-inGET /api/sessionroute forwards each browser'sOriginheader intocreateSession({ 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
allowedOriginsnor a constructororiginis supplied, the relay accepts browser origins from previously unknown domains.ORIGINremains 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-separatedALLOWED_ORIGINSwhitelist. 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 onlocalhost,127.0.0.1, or[::1]; anhttp://<lan-ip>QR is rejected.ORIGINstays as the Vite URL for browser CORS. This variable is not needed in production.
3. App setup
npx @bsv/wallet-relay initGenerates 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/clientOptions: --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 frontendBackend
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.
desktopTokenis returned byGET /api/sessionand must be sent as anX-Desktop-Tokenheader on everyPOST /api/request/:idcall. 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.WalletRelayClientstores it insessionStorageby default solely to support same-tab refresh; usepersistSession: falsefor higher-assurance pages and clear it withdisconnect().useWalletRelayClient/WalletRelayClienthandle the header automatically. If you callrelay.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-Tokenin CORSallowedHeaders; otherwise the browser's preflight blocksPOST /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 andallowedOrigins. CSP is owned by the embedding app, not injected by the relay; itsconnect-srcmust 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
onLocalWalletimmediately, 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 })wherecounterpartyis the other party's identity public key - The relay routes ciphertext blobs — it never decrypts anything
- The pairing bootstrap sends
mobileIdentityKeyunencrypted in the outer envelope once (onpairing_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:browserThe 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.
