- 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>
322 lines
10 KiB
TypeScript
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'))
|
|
}
|
|
})
|
|
}
|