feat(bitcoin): full Bitcoin ecosystem cards and composables (M12.2-M12.8)
M12.2: Fedimint ecash card with Fedi deep-link M12.3: BOLT12 offer card with QR and wallet deep-link M12.4: Nostr Wallet Connect (NWC) composable with NIP-47 scaffolding M12.5: LNURL-auth login composable with challenge generation M12.6: Live sat/fiat price from mempool.space/api/v1/prices (60s refresh) M12.7: Mempool.space tx viewer with confirmations, fee rate, block height M12.8: BOLT11 invoice decoder card with amount, expiry countdown, pay link All Bitcoin patterns auto-detected in chat messages via useBitcoinDetector. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3a718f44de
commit
c328d6498c
@@ -0,0 +1,70 @@
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const price = ref<number | null>(null)
|
||||
const currency = ref('USD')
|
||||
const lastUpdated = ref<number | null>(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
let initialized = false
|
||||
|
||||
async function fetchPrice() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetch('https://mempool.space/api/v1/prices')
|
||||
if (!res.ok) throw new Error('Failed to fetch price')
|
||||
const data = await res.json()
|
||||
price.value = data.USD ?? null
|
||||
lastUpdated.value = Date.now()
|
||||
} catch {
|
||||
// Keep existing price on error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
export function useBitcoinPrice() {
|
||||
function formatPrice(usd: number | null): string {
|
||||
if (usd === null) return '...'
|
||||
return '$' + usd.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
function satsToUsd(sats: number): string {
|
||||
if (!price.value) return '...'
|
||||
const usd = (sats / 100000000) * price.value
|
||||
if (usd < 0.01) return '<$0.01'
|
||||
return '$' + usd.toFixed(2)
|
||||
}
|
||||
|
||||
function usdToSats(usd: number): number | null {
|
||||
if (!price.value) return null
|
||||
return Math.round((usd / price.value) * 100000000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!initialized) {
|
||||
initialized = true
|
||||
fetchPrice()
|
||||
refreshTimer = setInterval(fetchPrice, 60000) // 60s refresh
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
initialized = false
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
price,
|
||||
currency,
|
||||
lastUpdated,
|
||||
isLoading,
|
||||
formatPrice,
|
||||
satsToUsd,
|
||||
usdToSats,
|
||||
fetchPrice,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const lnurlAuthUrl = ref<string | null>(null)
|
||||
const isAuthenticated = ref(false)
|
||||
const lightningIdentity = ref<string | null>(null)
|
||||
|
||||
const STORAGE_KEY = 'aiui-lnurl-auth-identity'
|
||||
|
||||
function loadIdentity() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
lightningIdentity.value = stored
|
||||
isAuthenticated.value = true
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
loadIdentity()
|
||||
|
||||
export function useLnurlAuth() {
|
||||
function generateLnurlAuthUrl(): string {
|
||||
// Generate a challenge for LNURL-auth
|
||||
// In production, this would come from the server
|
||||
const challenge = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
|
||||
const url = `lnurl-auth://auth?tag=login&k1=${challenge}`
|
||||
lnurlAuthUrl.value = url
|
||||
return url
|
||||
}
|
||||
|
||||
function setIdentity(pubkey: string) {
|
||||
lightningIdentity.value = pubkey
|
||||
isAuthenticated.value = true
|
||||
localStorage.setItem(STORAGE_KEY, pubkey)
|
||||
}
|
||||
|
||||
function clearIdentity() {
|
||||
lightningIdentity.value = null
|
||||
isAuthenticated.value = false
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
lnurlAuthUrl.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
lnurlAuthUrl,
|
||||
isAuthenticated,
|
||||
lightningIdentity,
|
||||
generateLnurlAuthUrl,
|
||||
setIdentity,
|
||||
clearIdentity,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'aiui-nwc-connection'
|
||||
|
||||
export interface NWCConnection {
|
||||
relayUrl: string
|
||||
walletPubkey: string
|
||||
secret: string
|
||||
}
|
||||
|
||||
const isConnected = ref(false)
|
||||
const balance = ref<number | null>(null)
|
||||
const connectionString = ref<string | null>(null)
|
||||
|
||||
function loadConnection(): NWCConnection | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
connectionString.value = stored
|
||||
isConnected.value = true
|
||||
return parseConnectionString(stored)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
|
||||
function parseConnectionString(str: string): NWCConnection | null {
|
||||
// Format: nostr+walletconnect://pubkey?relay=wss://...&secret=hex
|
||||
try {
|
||||
const url = new URL(str)
|
||||
const walletPubkey = url.hostname || url.pathname.replace('//', '')
|
||||
const relayUrl = url.searchParams.get('relay') ?? ''
|
||||
const secret = url.searchParams.get('secret') ?? ''
|
||||
if (!walletPubkey || !relayUrl || !secret) return null
|
||||
return { relayUrl, walletPubkey, secret }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function useNWC() {
|
||||
function connect(nwcString: string): boolean {
|
||||
const parsed = parseConnectionString(nwcString)
|
||||
if (!parsed) return false
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, nwcString)
|
||||
connectionString.value = nwcString
|
||||
isConnected.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
connectionString.value = null
|
||||
isConnected.value = false
|
||||
balance.value = null
|
||||
}
|
||||
|
||||
async function getBalance(): Promise<number | null> {
|
||||
const conn = loadConnection()
|
||||
if (!conn) return null
|
||||
|
||||
// NIP-47: Send get_balance request via relay
|
||||
// This is a simplified version — full implementation needs NIP-47 event signing
|
||||
return null
|
||||
}
|
||||
|
||||
async function payInvoice(bolt11: string): Promise<{ success: boolean; preimage?: string; error?: string }> {
|
||||
const conn = loadConnection()
|
||||
if (!conn) return { success: false, error: 'Not connected' }
|
||||
|
||||
// NIP-47: Send pay_invoice request via relay
|
||||
// Full implementation requires NIP-47 event creation and signing with the secret
|
||||
return { success: false, error: 'NWC pay requires NIP-47 event signing' }
|
||||
}
|
||||
|
||||
// Load on init
|
||||
loadConnection()
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
balance,
|
||||
connectionString,
|
||||
connect,
|
||||
disconnect,
|
||||
getBalance,
|
||||
payInvoice,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user