feat: useWallet composable — NWC + Lightning Address wallet management

Client-side wallet composable with NWC auto-pay, invoice polling,
Cashu token submission, and persistent wallet state. Follows useNostr
pattern with readonly exports and localStorage persistence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 01:27:36 +00:00
co-authored by Claude Opus 4.6
parent 460564a8ee
commit 131d7e587e
3 changed files with 284 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
import { ref, readonly, computed } from 'vue'
import { useNostr } from './useNostr.js'
import { finalizeEvent } from 'nostr-tools'
import * as nip44 from 'nostr-tools/nip44'
import { hexToBytes } from 'nostr-tools/utils'
type WalletMethod = 'nwc' | 'lnaddress' | null
type PaymentStatus = 'idle' | 'invoiced' | 'paying' | 'confirmed' | 'failed'
// Persist wallet method across navigations
function loadStored<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
} catch { return null }
}
function store(key: string, value: unknown) {
if (value == null) localStorage.removeItem(key)
else localStorage.setItem(key, JSON.stringify(value))
}
const isWalletConnected = ref(!!loadStored<string>('bf_wallet_method'))
const walletMethod = ref<WalletMethod>(loadStored<WalletMethod>('bf_wallet_method'))
const paymentStatus = ref<PaymentStatus>('idle')
const pendingPayment = ref<{ paymentId: string; bolt11: string } | null>(null)
interface NwcConfig {
pubkey: string
relay: string
secret: Uint8Array
}
function parseNwcUrl(url: string): NwcConfig {
const withoutScheme = url.replace('nostr+walletconnect://', '')
const [pubkey, queryString] = withoutScheme.split('?')
const params = new URLSearchParams(queryString)
const relay = params.get('relay')
const secret = params.get('secret')
if (!pubkey || !relay || !secret) {
throw new Error('Invalid NWC URL')
}
return { pubkey, relay, secret: hexToBytes(secret) }
}
export function useWallet() {
const { pubkey, bot } = useNostr()
async function connectNWC(connectionString: string): Promise<void> {
if (!pubkey.value) throw new Error('Not logged in')
// Validate the NWC URL format
parseNwcUrl(connectionString)
const res = await fetch('/api/payments/connect-wallet', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pubkey: pubkey.value,
method: 'nwc',
connectionData: connectionString,
}),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to connect wallet')
}
// Store NWC string locally for client-side payment sending
localStorage.setItem('bf_nwc_url', connectionString)
walletMethod.value = 'nwc'
isWalletConnected.value = true
store('bf_wallet_method', 'nwc')
}
async function connectLightningAddress(address: string): Promise<void> {
if (!pubkey.value) throw new Error('Not logged in')
if (!address.includes('@')) {
throw new Error('Invalid Lightning Address format')
}
const res = await fetch('/api/payments/connect-wallet', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pubkey: pubkey.value,
method: 'lnaddress',
connectionData: address,
}),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to connect wallet')
}
walletMethod.value = 'lnaddress'
isWalletConnected.value = true
store('bf_wallet_method', 'lnaddress')
}
async function disconnectWallet(): Promise<void> {
if (!pubkey.value) return
await fetch('/api/payments/disconnect-wallet', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: pubkey.value }),
})
walletMethod.value = null
isWalletConnected.value = false
paymentStatus.value = 'idle'
pendingPayment.value = null
store('bf_wallet_method', null)
localStorage.removeItem('bf_nwc_url')
}
async function checkWalletStatus(): Promise<void> {
if (!pubkey.value) return
const res = await fetch(`/api/payments/wallet-status?pubkey=${pubkey.value}`)
if (res.ok) {
const data = await res.json()
isWalletConnected.value = data.connected
walletMethod.value = data.method || null
if (data.connected) {
store('bf_wallet_method', data.method)
}
}
}
async function payEntryFee(botId: string): Promise<string> {
paymentStatus.value = 'invoiced'
try {
// Create invoice
const invoiceRes = await fetch('/api/payments/create-invoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ botId }),
})
if (!invoiceRes.ok) {
const err = await invoiceRes.json()
throw new Error(err.error || 'Failed to create invoice')
}
const { bolt11, paymentId } = await invoiceRes.json()
pendingPayment.value = { paymentId, bolt11 }
// If NWC connected, auto-pay via NWC
const nwcUrl = localStorage.getItem('bf_nwc_url')
if (nwcUrl) {
paymentStatus.value = 'paying'
await payViaNWC(nwcUrl, bolt11)
}
// Poll for confirmation
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 2000))
const checkRes = await fetch(`/api/payments/check/${paymentId}`)
if (checkRes.ok) {
const { status } = await checkRes.json()
if (status === 'confirmed') {
paymentStatus.value = 'confirmed'
return paymentId
}
if (status === 'failed') {
paymentStatus.value = 'failed'
throw new Error('Payment failed')
}
}
}
paymentStatus.value = 'failed'
throw new Error('Payment timed out')
} catch (err) {
if (paymentStatus.value !== 'confirmed') {
paymentStatus.value = 'failed'
}
throw err
}
}
async function submitCashuToken(botId: string, token: string): Promise<string> {
const res = await fetch('/api/payments/submit-cashu', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ botId, token }),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || 'Cashu redemption failed')
}
const data = await res.json()
if (data.status !== 'confirmed') {
throw new Error('Invalid or spent Cashu token')
}
paymentStatus.value = 'confirmed'
return data.paymentId
}
return {
isWalletConnected: readonly(isWalletConnected),
walletMethod: readonly(walletMethod),
paymentStatus: readonly(paymentStatus),
pendingPayment: readonly(pendingPayment),
connectNWC,
connectLightningAddress,
disconnectWallet,
checkWalletStatus,
payEntryFee,
submitCashuToken,
}
}
/** Send a pay_invoice request via NWC WebSocket */
async function payViaNWC(nwcUrl: string, bolt11: string): Promise<void> {
const nwc = parseNwcUrl(nwcUrl)
const conversationKey = nip44.v2.utils.getConversationKey(nwc.secret, nwc.pubkey)
const content = nip44.v2.encrypt(
JSON.stringify({ method: 'pay_invoice', params: { invoice: bolt11 } }),
conversationKey,
)
const event = finalizeEvent({
kind: 23194,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', nwc.pubkey]],
content,
}, nwc.secret)
return new Promise((resolve, reject) => {
const ws = new WebSocket(nwc.relay)
const timeout = setTimeout(() => {
ws.close()
// Don't reject — the server-side poll will catch confirmation
resolve()
}, 30_000)
ws.onopen = () => {
// Subscribe for response
const subId = Math.random().toString(36).slice(2, 10)
ws.send(JSON.stringify(['REQ', subId, {
kinds: [23195],
authors: [nwc.pubkey],
'#e': [event.id],
}]))
// Publish the payment request
ws.send(JSON.stringify(['EVENT', event]))
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
if (data[0] === 'EVENT' && data[2]?.kind === 23195) {
clearTimeout(timeout)
ws.close()
// Payment sent — server-side poll will confirm
resolve()
}
} catch {
// ignore
}
}
ws.onerror = () => {
clearTimeout(timeout)
// Don't reject — server-side poll is the source of truth
resolve()
}
})
}