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:
co-authored by
Claude Opus 4.6
parent
460564a8ee
commit
131d7e587e
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user