import { ref, readonly, computed } from 'vue' import { useNostr } from './useNostr.js' import { finalizeEvent } from 'nostr-tools' import * as nip04 from 'nostr-tools/nip04' import * as nip44 from 'nostr-tools/nip44' import { hexToBytes, bytesToHex } from 'nostr-tools/utils' import { authFetch } from '../lib/nostr-auth' type WalletMethod = 'nwc' | 'lnaddress' | null type PaymentStatus = 'idle' | 'invoiced' | 'paying' | 'confirmed' | 'failed' // Persist wallet method across navigations function loadStored(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) { try { if (value == null) localStorage.removeItem(key) else localStorage.setItem(key, JSON.stringify(value)) } catch { /* Safari private browsing quota exception */ } } const isWalletConnected = ref(!!loadStored('bf_wallet_method')) const walletMethod = ref(loadStored('bf_wallet_method')) const paymentStatus = ref('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: missing pubkey, relay, or secret') } // Validate hex before parsing — must be even-length hex string const trimmed = secret.trim() if (trimmed.length === 0 || trimmed.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(trimmed)) { throw new Error('Invalid NWC URL: secret is not valid hex (must be even-length hex string)') } return { pubkey, relay, secret: hexToBytes(trimmed) } } export function useWallet() { const { pubkey, bot } = useNostr() async function connectNWC(connectionString: string): Promise { if (!pubkey.value) throw new Error('Not logged in') // Validate the NWC URL format parseNwcUrl(connectionString) const res = await authFetch('/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 try { localStorage.setItem('bf_nwc_url', connectionString) } catch { /* quota */ } walletMethod.value = 'nwc' isWalletConnected.value = true store('bf_wallet_method', 'nwc') } async function connectLightningAddress(address: string): Promise { if (!pubkey.value) throw new Error('Not logged in') if (!address.includes('@')) { throw new Error('Invalid Lightning Address format') } const res = await authFetch('/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 { if (!pubkey.value) return await authFetch('/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) try { localStorage.removeItem('bf_nwc_url') } catch { /* quota */ } } async function checkWalletStatus(): Promise { if (!pubkey.value) return const res = await authFetch(`/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 { paymentStatus.value = 'invoiced' try { // Create invoice const invoiceRes = await authFetch('/api/payments/create-invoice', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ botId, pubkey: pubkey.value }), }) 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 } // Dev mode: server auto-confirmed, skip payment if (bolt11 === 'dev_auto_confirmed') { paymentStatus.value = 'confirmed' return paymentId } // If NWC connected, auto-pay via NWC and confirm directly const nwcUrl = localStorage.getItem('bf_nwc_url') let nwcValid = false if (nwcUrl) { try { parseNwcUrl(nwcUrl); nwcValid = true } catch { /* bad stored URL — fall through to poll */ } } if (nwcUrl && nwcValid) { paymentStatus.value = 'paying' try { const preimage = await payViaNWC(nwcUrl, bolt11) if (preimage) { await authFetch(`/api/payments/confirm/${paymentId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ preimage, pubkey: pubkey.value }), }) paymentStatus.value = 'confirmed' return paymentId } } catch (err) { console.warn('[Wallet] NWC payment failed, falling back to polling:', err) } // NWC failed or returned no preimage — fall through to polling paymentStatus.value = 'pending' } // No NWC — poll for confirmation (manual payment / QR code flow) for (let i = 0; i < 30; i++) { await new Promise(r => setTimeout(r, 2000)) const checkRes = await authFetch(`/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 { const res = await authFetch('/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 { const nwc = parseNwcUrl(nwcUrl) const secretHex = bytesToHex(nwc.secret) // Use NIP-04 encryption (BTCPay/LND compatibility) const content = await nip04.encrypt(secretHex, nwc.pubkey, JSON.stringify({ method: 'pay_invoice', params: { invoice: bolt11 } }), ) 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() reject(new Error('NWC payment timed out')) }, 30_000) ws.onopen = () => { const subId = Math.random().toString(36).slice(2, 10) ws.send(JSON.stringify(['REQ', subId, { kinds: [23195], authors: [nwc.pubkey], '#e': [event.id], }])) ws.send(JSON.stringify(['EVENT', event])) } ws.onmessage = async (msg) => { try { const data = JSON.parse(msg.data) if (data[0] === 'EVENT' && data[2]?.kind === 23195) { clearTimeout(timeout) ws.close() // Decrypt response — try NIP-04 first, fall back to NIP-44 let decrypted: string try { decrypted = await nip04.decrypt(secretHex, nwc.pubkey, data[2].content) } catch { const convKey = nip44.v2.utils.getConversationKey(nwc.secret, nwc.pubkey) decrypted = nip44.v2.decrypt(data[2].content, convKey) } const result = JSON.parse(decrypted) if (result.error) { reject(new Error(result.error.message || 'NWC payment failed')) } else { resolve(result.result?.preimage) } } } catch { // ignore parse errors } } ws.onerror = () => { clearTimeout(timeout) reject(new Error('NWC WebSocket error')) } }) }