Wallet Client Integration
Module 3B: Connecting to User Wallets (Frontend Paradigm)
This module teaches you how to integrate MetaNet Desktop Wallet into your dApp using the BSV SDK's WalletClient component. This is the standard approach for building non-custodial BSV applications where users control their own keys.
Note: This module is for Frontend Integration Development. If you're building backend services where you control keys, see Managing Wallets Server-Side instead.
What is WalletClient?
WalletClient is a pre-built SDK component that:
✅ Connects your dApp to MetaNet Desktop Wallet (BSV Desktop)
✅ Requests user signatures for transactions
✅ Handles all wallet communication via BRC protocol
✅ Manages UTXO selection (wallet handles)
✅ Calculates fees automatically (wallet handles)
✅ Broadcasts transactions (wallet handles)
You don't need to:
❌ Manage private keys
❌ Track UTXOs
❌ Calculate fees
❌ Handle change outputs
❌ Configure ARC broadcasting
The wallet does all of this for you!
Prerequisites
Before starting:
✅ Completed Development Environment Setup
✅ MetaNet Desktop Wallet installed and set up
✅ Basic understanding of React or your frontend framework
✅ Testnet BSV in your wallet (from faucet)
Part 1: WalletClient Basics
Installation
WalletClient is included in @bsv/sdk:
npm install @bsv/sdkImport
import { WalletClient } from '@bsv/sdk'Core Methods
import { WalletClient, P2PKH } from '@bsv/sdk'
const wallet = new WalletClient('auto')
// Connect to user's wallet substrate
await wallet.connectToSubstrate()
// Get user's identity public key
const { publicKey } = await wallet.getPublicKey({ identityKey: true })
// Create and send a transaction (auto-signed and broadcast)
const result = await wallet.createAction({
description: 'Send payment',
outputs: [{
lockingScript: new P2PKH().lock('recipient-address').toHex(),
satoshis: 1000,
outputDescription: 'Payment output'
}]
})
const txid = result.txidThese are the main methods you'll use! WalletClient handles the complexity of key management, UTXO selection, and broadcasting.
Part 2: Connecting to MetaNet Desktop Wallet
Basic Connection
import { WalletClient } from '@bsv/sdk'
async function connectWallet() {
try {
// 1. Create WalletClient instance
const wallet = new WalletClient('auto')
// 2. Connect to the wallet substrate
await wallet.connectToSubstrate()
// 3. Get user's identity public key
const { publicKey } = await wallet.getPublicKey({ identityKey: true })
console.log('Connected to wallet, public key:', publicKey)
return { wallet, publicKey }
} catch (error: any) {
if (error.code === 'USER_REJECTED') {
console.log('User rejected connection')
} else if (error.code === 'WALLET_NOT_FOUND') {
console.log('MetaNet Desktop Wallet not installed')
} else {
console.error('Connection failed:', error.message)
}
throw error
}
}Connection Flow
Your dApp MetaNet Desktop Wallet
│ │
│ connectToSubstrate() │
├───────────────────────────────>│
│ │
│ [Popup appears] │
│ "Allow dApp to connect?" │
│ │
│<───────────────────────────────┤
│ User clicks "Allow" │
│ │
│ Connection established │
│ getPublicKey({identityKey}) │
├───────────────────────────────>│
│ │
│<───────────────────────────────┤
│ Returns public key │
│ │React Hook for Wallet Connection
// hooks/useWallet.ts
import { useState, useEffect } from 'react'
import { WalletClient } from '@bsv/sdk'
export interface WalletState {
wallet: WalletClient | null
publicKey: string | null
connected: boolean
connecting: boolean
error: string | null
}
export function useWallet() {
const [state, setState] = useState<WalletState>({
wallet: null,
publicKey: null,
connected: false,
connecting: false,
error: null
})
const connect = async () => {
setState(prev => ({ ...prev, connecting: true, error: null }))
try {
const wallet = new WalletClient('auto')
await wallet.connectToSubstrate()
const { publicKey } = await wallet.getPublicKey({ identityKey: true })
setState({
wallet,
publicKey,
connected: true,
connecting: false,
error: null
})
console.log('✅ Wallet connected, public key:', publicKey)
} catch (error: any) {
setState({
wallet: null,
publicKey: null,
connected: false,
connecting: false,
error: error.message
})
console.error('❌ Connection failed:', error)
}
}
const disconnect = async () => {
// Note: WalletClient doesn't have a disconnect method
// Simply clear local state
setState({
wallet: null,
publicKey: null,
connected: false,
connecting: false,
error: null
})
}
// Auto-reconnect if wallet was previously connected
useEffect(() => {
const autoConnect = async () => {
const wasConnected = localStorage.getItem('wallet_connected')
if (wasConnected === 'true') {
await connect()
}
}
autoConnect()
}, [])
// Save connection state
useEffect(() => {
if (state.connected) {
localStorage.setItem('wallet_connected', 'true')
} else {
localStorage.removeItem('wallet_connected')
}
}, [state.connected])
return {
...state,
connect,
disconnect
}
}Wallet Connection Component
// components/WalletConnect.tsx
import React from 'react'
import { useWallet } from '../hooks/useWallet'
export const WalletConnect: React.FC = () => {
const { publicKey, connected, connecting, error, connect, disconnect } = useWallet()
if (connected && publicKey) {
return (
<div className="wallet-connected">
<p>Connected: {publicKey.substring(0, 12)}...{publicKey.substring(publicKey.length - 12)}</p>
<button onClick={disconnect}>Disconnect</button>
</div>
)
}
return (
<div className="wallet-not-connected">
{error && <p className="error">{error}</p>}
<button
onClick={connect}
disabled={connecting}
>
{connecting ? 'Connecting...' : 'Connect Wallet'}
</button>
{!connecting && (
<div className="wallet-instructions">
<p>To connect:</p>
<ul>
<li>Install MetaNet Desktop Wallet</li>
<li>Create or import a wallet</li>
<li>Click "Connect Wallet"</li>
</ul>
</div>
)}
</div>
)
}Part 3: Sending Transactions
Simple Payment
import { WalletClient, P2PKH } from '@bsv/sdk'
async function sendPayment(
wallet: WalletClient,
recipientAddress: string,
amountSatoshis: number
) {
try {
// Create action to send payment (auto-signed and broadcast)
const result = await wallet.createAction({
description: 'Send payment',
outputs: [{
lockingScript: new P2PKH().lock(recipientAddress).toHex(),
satoshis: amountSatoshis,
outputDescription: 'Payment to recipient'
}]
})
console.log('Payment sent!')
console.log('TXID:', result.txid)
console.log('Amount:', amountSatoshis, 'satoshis')
return result.txid
} catch (error: any) {
if (error.code === 'USER_REJECTED') {
console.log('User cancelled payment')
} else if (error.code === 'INSUFFICIENT_FUNDS') {
console.log('User has insufficient balance')
} else {
console.error('Payment failed:', error.message)
}
throw error
}
}
// Usage
const txid = await sendPayment(wallet, '1A1zP1...', 1000)What Happens Under the Hood
When you call wallet.createAction():
WalletClient sends transaction request to MetaNet Desktop Wallet
Wallet popup appears showing transaction details
User reviews:
Recipient address
Amount to send
Fee (calculated by wallet)
Total deduction from balance
User approves or rejects
Wallet:
Selects UTXOs
Calculates optimal fee
Creates change output
Signs transaction
Broadcasts to BSV network
WalletClient returns TXID
Your dApp never sees the private key!
Payment Component
// components/SendPayment.tsx
import React, { useState } from 'react'
import { WalletClient, P2PKH } from '@bsv/sdk'
interface SendPaymentProps {
wallet: WalletClient
}
export const SendPayment: React.FC<SendPaymentProps> = ({ wallet }) => {
const [recipient, setRecipient] = useState('')
const [amount, setAmount] = useState('')
const [sending, setSending] = useState(false)
const [txid, setTxid] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const handleSend = async () => {
setSending(true)
setError(null)
setTxid(null)
try {
// Validate inputs
if (!recipient) throw new Error('Recipient address required')
if (!amount || isNaN(Number(amount))) throw new Error('Valid amount required')
const amountSats = Math.floor(Number(amount) * 100_000_000) // BSV to satoshis
// Create action to send payment
const result = await wallet.createAction({
description: 'Send payment',
outputs: [{
lockingScript: new P2PKH().lock(recipient).toHex(),
satoshis: amountSats,
outputDescription: 'Payment to recipient'
}]
})
setTxid(result.txid!)
setRecipient('')
setAmount('')
console.log('✅ Payment successful:', result.txid)
} catch (err: any) {
if (err.code === 'USER_REJECTED') {
setError('Payment cancelled by user')
} else if (err.code === 'INSUFFICIENT_FUNDS') {
setError('Insufficient balance in wallet')
} else {
setError(err.message || 'Payment failed')
}
console.error('❌ Payment failed:', err)
} finally {
setSending(false)
}
}
return (
<div className="send-payment">
<h3>Send Payment</h3>
{txid && (
<div className="success-message">
<p>✅ Payment sent successfully!</p>
<p>TXID: <a href={`https://test.whatsonchain.com/tx/${txid}`} target="_blank" rel="noreferrer">{txid.substring(0, 16)}...</a></p>
</div>
)}
{error && <p className="error-message">❌ {error}</p>}
<div className="form">
<input
type="text"
placeholder="Recipient Address"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
disabled={sending}
/>
<input
type="number"
placeholder="Amount (BSV)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={sending}
step="0.00000001"
min="0"
/>
<button
onClick={handleSend}
disabled={sending || !recipient || !amount}
>
{sending ? 'Sending...' : 'Send Payment'}
</button>
</div>
<div className="info">
<p>The wallet will calculate the fee and request your approval.</p>
</div>
</div>
)
}Part 4: Multiple Outputs
Batch Payments
import { WalletClient, P2PKH } from '@bsv/sdk'
async function sendBatchPayment(wallet: WalletClient) {
const result = await wallet.createAction({
description: 'Batch payment',
outputs: [
{
lockingScript: new P2PKH().lock('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa').toHex(),
satoshis: 1000,
outputDescription: 'Payment 1'
},
{
lockingScript: new P2PKH().lock('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2').toHex(),
satoshis: 2000,
outputDescription: 'Payment 2'
},
{
lockingScript: new P2PKH().lock('1HLoD9E4SDFFPDiYfNYnkBLQ85Y51J3Zb1').toHex(),
satoshis: 3000,
outputDescription: 'Payment 3'
}
]
})
console.log('Batch payment sent:', result.txid)
console.log('Total sent: 6000 satoshis to 3 recipients')
return result.txid
}Payment with Metadata (OP_RETURN)
import { WalletClient, P2PKH, Script } from '@bsv/sdk'
async function sendPaymentWithMetadata(
wallet: WalletClient,
recipientAddress: string,
amountSatoshis: number,
metadata: any
) {
// Create OP_RETURN script with metadata
const metadataHex = Buffer.from(JSON.stringify(metadata)).toString('hex')
const opReturnScript = new Script()
opReturnScript.writeOpCode(Script.OP_FALSE)
opReturnScript.writeOpCode(Script.OP_RETURN)
opReturnScript.writeBin(Buffer.from(metadataHex, 'hex'))
const result = await wallet.createAction({
description: 'Payment with metadata',
outputs: [
{
lockingScript: new P2PKH().lock(recipientAddress).toHex(),
satoshis: amountSatoshis,
outputDescription: 'Payment'
},
{
lockingScript: opReturnScript.toHex(),
satoshis: 0,
outputDescription: 'Metadata'
}
]
})
console.log('Payment with metadata sent:', result.txid)
return result.txid
}
// Usage
await sendPaymentWithMetadata(
wallet,
'1A1zP1...',
1000,
{
type: 'INVOICE_PAYMENT',
invoiceId: 'INV-12345',
description: 'Payment for services',
timestamp: Date.now()
}
)Part 5: Message Signing
Sign and Verify Messages
import { WalletClient } from '@bsv/sdk'
async function signMessage(wallet: WalletClient, message: string) {
try {
// Hash the message
const messageBytes = Buffer.from(message, 'utf8')
// Request wallet to sign the message data
const { signature } = await wallet.createSignature({
data: Array.from(messageBytes),
protocolID: [0, 'message signing'],
keyID: '1',
description: 'Sign message'
})
console.log('Message signed!')
console.log('Message:', message)
console.log('Signature:', Buffer.from(signature).toString('hex'))
return signature
} catch (error: any) {
if (error.code === 'USER_REJECTED') {
console.log('User rejected signing')
} else {
console.error('Signing failed:', error.message)
}
throw error
}
}
// Usage
const signature = await signMessage(wallet, 'I agree to terms of service')Authentication with Signatures
// components/AuthWithWallet.tsx
import React, { useState } from 'react'
import { WalletClient } from '@bsv/sdk'
interface AuthProps {
wallet: WalletClient
onAuthenticated: (publicKey: string, signature: Uint8Array) => void
}
export const AuthWithWallet: React.FC<AuthProps> = ({ wallet, onAuthenticated }) => {
const [authenticating, setAuthenticating] = useState(false)
const handleAuth = async () => {
setAuthenticating(true)
try {
// Get user's identity public key
const { publicKey } = await wallet.getPublicKey({ identityKey: true })
// Create challenge message
const timestamp = Date.now()
const challenge = `Login to MyApp at ${timestamp}`
const messageBytes = Buffer.from(challenge, 'utf8')
// Request signature
const { signature } = await wallet.createSignature({
data: Array.from(messageBytes),
protocolID: [0, 'authentication'],
keyID: '1',
description: 'Authenticate to MyApp'
})
// Send to backend for verification
const response = await fetch('/api/auth/wallet', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
publicKey,
message: challenge,
signature: Buffer.from(signature).toString('hex')
})
})
if (response.ok) {
const { token } = await response.json()
localStorage.setItem('auth_token', token)
onAuthenticated(publicKey, signature)
console.log('✅ Authenticated with wallet')
} else {
throw new Error('Authentication failed')
}
} catch (error: any) {
console.error('❌ Authentication failed:', error)
alert(error.message)
} finally {
setAuthenticating(false)
}
}
return (
<button
onClick={handleAuth}
disabled={authenticating}
>
{authenticating ? 'Authenticating...' : 'Login with Wallet'}
</button>
)
}Part 6: Error Handling
Common Error Codes
try {
await wallet.createAction({ description: '...', outputs: [...] })
} catch (error: any) {
switch (error.code) {
case 'USER_REJECTED':
// User cancelled the transaction
console.log('User rejected transaction')
break
case 'INSUFFICIENT_FUNDS':
// User doesn't have enough balance
console.log('Insufficient funds')
alert('You don\'t have enough BSV for this transaction')
break
case 'WALLET_NOT_FOUND':
// MetaNet Desktop Wallet not installed
console.log('Wallet not installed')
alert('Please install MetaNet Desktop Wallet')
break
case 'WALLET_LOCKED':
// Wallet is locked
console.log('Wallet is locked')
alert('Please unlock your wallet')
break
case 'NETWORK_ERROR':
// Broadcasting failed
console.log('Network error')
alert('Transaction failed to broadcast. Please try again.')
break
case 'INVALID_OUTPUT':
// Output format is wrong
console.log('Invalid output')
alert('Invalid transaction output')
break
default:
console.error('Unknown error:', error)
alert(`Transaction failed: ${error.message}`)
}
}Comprehensive Error Handler
function handleWalletError(error: any, context: string) {
const errorMessages: Record<string, string> = {
'USER_REJECTED': 'You cancelled the request',
'INSUFFICIENT_FUNDS': 'Insufficient balance in your wallet',
'WALLET_NOT_FOUND': 'MetaNet Desktop Wallet not installed. Please install it from desktop.bsvb.tech',
'WALLET_LOCKED': 'Please unlock your wallet and try again',
'NETWORK_ERROR': 'Network error. Please check your connection and try again',
'INVALID_OUTPUT': 'Invalid transaction format',
'TIMEOUT': 'Request timed out. Please try again'
}
const userMessage = errorMessages[error.code] || `${context} failed: ${error.message}`
console.error(`❌ ${context} error:`, {
code: error.code,
message: error.message,
details: error
})
return userMessage
}
// Usage
try {
await wallet.sendTransaction({ outputs: [...] })
} catch (error) {
const message = handleWalletError(error, 'Payment')
alert(message)
}Part 7: Best Practices
1. Always Check Connection State
function ensureWalletConnected(wallet: WalletClient | null) {
if (!wallet) {
throw new Error('Wallet not connected. Please connect your wallet first.')
}
}
// Usage
async function sendPayment(wallet: WalletClient | null, recipient: string, amount: number) {
ensureWalletConnected(wallet)
return await wallet!.sendTransaction({ outputs: [{ address: recipient, satoshis: amount }] })
}2. Provide Clear Transaction Previews
// Show user what they're approving BEFORE calling wallet
function TransactionPreview({ recipient, amount }: { recipient: string, amount: number }) {
const fee = 500 // Estimated (wallet calculates actual)
const total = amount + fee
return (
<div className="tx-preview">
<h4>Transaction Preview</h4>
<p>Recipient: {recipient}</p>
<p>Amount: {amount} satoshis</p>
<p>Est. Fee: ~{fee} satoshis</p>
<p><strong>Total: ~{total} satoshis</strong></p>
<p className="note">Final fee determined by wallet</p>
</div>
)
}3. Handle User Cancellation Gracefully
async function requestPayment(wallet: WalletClient, recipient: string, amount: number) {
try {
const result = await wallet.sendTransaction({
outputs: [{ address: recipient, satoshis: amount }]
})
return { success: true, txid: result.txid }
} catch (error: any) {
if (error.code === 'USER_REJECTED') {
// Don't show error for user cancellation - it's intentional
return { success: false, cancelled: true }
}
// Show error for other failures
return { success: false, error: error.message }
}
}4. Provide Fallback Instructions
function WalletNotFound() {
return (
<div className="wallet-not-found">
<h3>MetaNet Desktop Wallet Required</h3>
<p>This dApp requires MetaNet Desktop Wallet to function.</p>
<div className="instructions">
<h4>Installation Steps:</h4>
<ol>
<li>Visit <a href="https://desktop.bsvb.tech" target="_blank" rel="noreferrer">desktop.bsvb.tech</a></li>
<li>Download MetaNet Desktop Wallet</li>
<li>Install and set up your wallet</li>
<li>Return to this page and click "Connect Wallet"</li>
</ol>
</div>
<div className="testnet-info">
<h4>Get Testnet BSV:</h4>
<p>Get testnet coins from MetaNet Desktop Wallet's faucet or <a href="https://discord.gg/bsv" target="_blank" rel="noreferrer">BSV Discord</a></p>
</div>
</div>
)
}5. Persist Connection State
// Save connection preference
useEffect(() => {
if (walletConnected) {
localStorage.setItem('wallet_auto_connect', 'true')
} else {
localStorage.removeItem('wallet_auto_connect')
}
}, [walletConnected])
// Auto-reconnect on page load
useEffect(() => {
const shouldAutoConnect = localStorage.getItem('wallet_auto_connect') === 'true'
if (shouldAutoConnect) {
connectWallet()
}
}, [])Part 8: Complete dApp Example
Here's a complete React dApp integrating all concepts:
// App.tsx
import React from 'react'
import { useWallet } from './hooks/useWallet'
import { WalletConnect } from './components/WalletConnect'
import { SendPayment } from './components/SendPayment'
import { TransactionHistory } from './components/TransactionHistory'
import './App.css'
function App() {
const { wallet, publicKey, connected, error } = useWallet()
return (
<div className="App">
<header>
<h1>My BSV dApp</h1>
<WalletConnect />
</header>
<main>
{connected && wallet ? (
<>
<div className="user-info">
<h2>Your Wallet</h2>
<p>Public Key: {publicKey?.substring(0, 20)}...</p>
</div>
<SendPayment wallet={wallet} />
<TransactionHistory publicKey={publicKey!} />
</>
) : (
<div className="connect-prompt">
<h2>Welcome to My BSV dApp</h2>
<p>Connect your MetaNet Desktop Wallet to get started</p>
{error && <p className="error">{error}</p>}
</div>
)}
</main>
<footer>
<p>Built with BSV SDK and WalletClient</p>
<a href="https://desktop.bsvb.tech" target="_blank" rel="noreferrer">
Learn more about MetaNet Desktop Wallet
</a>
</footer>
</div>
)
}
export default AppSummary
What You Learned:
✅ WalletClient connects your dApp to MetaNet Desktop Wallet
✅ Wallet handles all key management, UTXO selection, fees, and broadcasting
✅ Your dApp requests user approval for transactions
✅ Error handling for user rejections and failures
✅ Message signing for authentication
✅ Best practices for UX and state management
What You DON'T Need to Do:
❌ Manage private keys
❌ Track UTXOs
❌ Calculate fees
❌ Create change outputs
❌ Configure ARC
❌ Handle low-level transaction details
The wallet does all of this automatically!
Next Steps
Building Transactions - Learn transaction structure and SDK methods
BSV Fundamentals - Understand UTXOs, scripts, and blockchain concepts
Intermediate Projects - Build real-world dApps with WalletClient
Resources
Wallet Toolbox API: https://fast.brc.dev/
BRC Standards: https://hub.bsvblockchain.org/brc
MetaNet Desktop Wallet: https://desktop.bsvb.tech/
Get BSV - Orange Gateway: https://hub.bsvblockchain.org/demos-and-onboardings/onboardings/onboarding-catalog/get-bsv/orange-gateway
SDK Documentation: https://bsv-blockchain.github.io/ts-sdk/
Example dApps: code-features/
You're now ready to build non-custodial BSV dApps!
Last updated
