diff --git a/frontend/package.json b/frontend/package.json index 26d99ed..403025d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "kaplay": "^3001.0.19", + "nostr-tools": "^2.23.3", "vue": "^3.5.13", "vue-router": "^4.5.1" }, diff --git a/frontend/src/composables/useWallet.ts b/frontend/src/composables/useWallet.ts new file mode 100644 index 0000000..1cef5a9 --- /dev/null +++ b/frontend/src/composables/useWallet.ts @@ -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(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('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') + } + return { pubkey, relay, secret: hexToBytes(secret) } +} + +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 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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() + } + }) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbb4a7c..65f2526 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: kaplay: specifier: ^3001.0.19 version: 3001.0.19 + nostr-tools: + specifier: ^2.23.3 + version: 2.23.3(typescript@5.9.3) vue: specifier: ^3.5.13 version: 3.5.29(typescript@5.9.3)