Files
botfights/frontend/src/composables/useWallet.ts
T
DorianandClaude Opus 4.6 74cb5cc728 fix: NWC payment timeout/error now rejects properly (BUG-F4)
- Timeout and WebSocket errors reject with proper Error objects
- Caller catches and falls through to poll-based confirmation
- Preimage undefined check prevents calling confirm with no preimage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:10:50 +00:00

322 lines
10 KiB
TypeScript

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<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) {
try {
if (value == null) localStorage.removeItem(key)
else localStorage.setItem(key, JSON.stringify(value))
} catch { /* Safari private browsing quota exception */ }
}
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: 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<void> {
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<void> {
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<void> {
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<void> {
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<string> {
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<string> {
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<string | undefined> {
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'))
}
})
}