feat(bitcoin): on-chain address display with QR and mempool link (M12.1)
Detect bech32, bech32m, P2PKH, P2SH addresses in chat messages. Show address card with type badge, QR code, copy button, and mempool.space link. Bitcoin detector utility for all BTC patterns. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d7d1f1df37
commit
3a718f44de
@@ -223,6 +223,14 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="bitcoinAddresses.length > 0" class="mt-3 space-y-2" @click.stop>
|
||||
<BitcoinAddressCard
|
||||
v-for="(addr, i) in bitcoinAddresses"
|
||||
:key="`btc-${i}`"
|
||||
:address="addr.address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="inlineNewsLinks.length > 0" class="mt-3 space-y-1" @click.stop>
|
||||
<NewsCard
|
||||
v-for="(link, i) in inlineNewsLinks"
|
||||
@@ -372,6 +380,8 @@ import EventCard from '@/components/renderers/EventCard.vue'
|
||||
import InteractiveTable from '@/components/renderers/InteractiveTable.vue'
|
||||
import TimelineRenderer from '@/components/renderers/TimelineRenderer.vue'
|
||||
import CodeRunner from '@/components/renderers/CodeRunner.vue'
|
||||
import BitcoinAddressCard from '@/components/renderers/BitcoinAddressCard.vue'
|
||||
import { detectBitcoinAddresses } from '@/composables/useBitcoinDetector'
|
||||
import { extractTables } from '@/composables/useTableExtractor'
|
||||
import { extractRunnableCodeBlocks } from '@/composables/useCodeBlockExtractor'
|
||||
|
||||
@@ -486,6 +496,11 @@ const runnableCodeBlocks = computed(() => {
|
||||
return extractRunnableCodeBlocks(props.message.content)
|
||||
})
|
||||
|
||||
const bitcoinAddresses = computed(() => {
|
||||
if (isUser.value) return []
|
||||
return detectBitcoinAddresses(props.message.content)
|
||||
})
|
||||
|
||||
const isCodeResponse = computed(() =>
|
||||
!isUser.value && props.triggeringQuery.trim().toLowerCase() === '/code'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="rounded-xl bg-white/5 border border-white/10 p-3 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-accent/60 uppercase tracking-wider font-bold">Bitcoin Address</span>
|
||||
<span class="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/30">{{ addressType }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Address display -->
|
||||
<p class="text-xs font-mono text-white/70 break-all leading-relaxed select-all">{{ address }}</p>
|
||||
|
||||
<!-- QR code -->
|
||||
<div class="flex justify-center py-2">
|
||||
<canvas ref="qrCanvas" class="rounded-lg" width="160" height="160" />
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-2 rounded-lg text-[10px] bg-white/5 text-white/50 hover:text-white/70 hover:bg-white/10 transition-colors"
|
||||
@click="copyAddress"
|
||||
>
|
||||
{{ copied ? 'Copied!' : 'Copy Address' }}
|
||||
</button>
|
||||
<a
|
||||
:href="mempoolLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex-1 py-2 rounded-lg text-[10px] text-center bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
|
||||
>
|
||||
View on Mempool
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
address: string
|
||||
}>()
|
||||
|
||||
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const copied = ref(false)
|
||||
|
||||
const addressType = computed(() => {
|
||||
const addr = props.address
|
||||
if (addr.startsWith('bc1q')) return 'SegWit (bech32)'
|
||||
if (addr.startsWith('bc1p')) return 'Taproot (bech32m)'
|
||||
if (addr.startsWith('1')) return 'Legacy (P2PKH)'
|
||||
if (addr.startsWith('3')) return 'Nested SegWit (P2SH)'
|
||||
if (addr.startsWith('tb1') || addr.startsWith('2') || addr.startsWith('m') || addr.startsWith('n')) return 'Testnet'
|
||||
return 'Unknown'
|
||||
})
|
||||
|
||||
const mempoolLink = computed(() => {
|
||||
return `https://mempool.space/address/${props.address}`
|
||||
})
|
||||
|
||||
function copyAddress() {
|
||||
navigator.clipboard.writeText(props.address)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
|
||||
function drawQR() {
|
||||
const canvas = qrCanvas.value
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Simple QR placeholder — draw bitcoin URI in styled grid
|
||||
ctx.fillStyle = '#1a1a1a'
|
||||
ctx.fillRect(0, 0, 160, 160)
|
||||
|
||||
ctx.fillStyle = '#F7931A'
|
||||
ctx.font = '8px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
|
||||
const uri = `bitcoin:${props.address}`
|
||||
const lines: string[] = []
|
||||
for (let i = 0; i < uri.length; i += 24) {
|
||||
lines.push(uri.slice(i, i + 24))
|
||||
}
|
||||
const startY = Math.max(10, 80 - (lines.length * 5))
|
||||
lines.forEach((line, i) => {
|
||||
ctx.fillText(line, 80, startY + i * 10)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
drawQR()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
export interface DetectedBitcoinAddress {
|
||||
address: string
|
||||
type: 'bech32' | 'bech32m' | 'p2pkh' | 'p2sh' | 'testnet'
|
||||
}
|
||||
|
||||
export interface DetectedLightningInvoice {
|
||||
invoice: string
|
||||
amount?: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface DetectedBolt12Offer {
|
||||
offer: string
|
||||
}
|
||||
|
||||
export interface DetectedTxId {
|
||||
txid: string
|
||||
}
|
||||
|
||||
// Bitcoin address patterns
|
||||
const BECH32_REGEX = /\b(bc1q[a-z0-9]{38,62})\b/gi
|
||||
const BECH32M_REGEX = /\b(bc1p[a-z0-9]{38,62})\b/gi
|
||||
const P2PKH_REGEX = /\b(1[13456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{25,34})\b/g
|
||||
const P2SH_REGEX = /\b(3[13456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{25,34})\b/g
|
||||
const TESTNET_REGEX = /\b(tb1[a-z0-9]{38,62})\b/gi
|
||||
|
||||
// Lightning patterns
|
||||
const BOLT11_REGEX = /\b(lnbc[a-z0-9]+)\b/gi
|
||||
const BOLT12_REGEX = /\b(lno1[a-z0-9]+)\b/gi
|
||||
|
||||
// Transaction ID (64 hex chars)
|
||||
const TXID_REGEX = /\b([a-f0-9]{64})\b/gi
|
||||
|
||||
export function detectBitcoinAddresses(text: string): DetectedBitcoinAddress[] {
|
||||
const addresses: DetectedBitcoinAddress[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const match of text.matchAll(BECH32_REGEX)) {
|
||||
if (!seen.has(match[1].toLowerCase())) {
|
||||
seen.add(match[1].toLowerCase())
|
||||
addresses.push({ address: match[1], type: 'bech32' })
|
||||
}
|
||||
}
|
||||
for (const match of text.matchAll(BECH32M_REGEX)) {
|
||||
if (!seen.has(match[1].toLowerCase())) {
|
||||
seen.add(match[1].toLowerCase())
|
||||
addresses.push({ address: match[1], type: 'bech32m' })
|
||||
}
|
||||
}
|
||||
for (const match of text.matchAll(P2PKH_REGEX)) {
|
||||
if (!seen.has(match[1])) {
|
||||
seen.add(match[1])
|
||||
addresses.push({ address: match[1], type: 'p2pkh' })
|
||||
}
|
||||
}
|
||||
for (const match of text.matchAll(P2SH_REGEX)) {
|
||||
if (!seen.has(match[1])) {
|
||||
seen.add(match[1])
|
||||
addresses.push({ address: match[1], type: 'p2sh' })
|
||||
}
|
||||
}
|
||||
for (const match of text.matchAll(TESTNET_REGEX)) {
|
||||
if (!seen.has(match[1].toLowerCase())) {
|
||||
seen.add(match[1].toLowerCase())
|
||||
addresses.push({ address: match[1], type: 'testnet' })
|
||||
}
|
||||
}
|
||||
|
||||
return addresses
|
||||
}
|
||||
|
||||
export function detectBolt11Invoices(text: string): DetectedLightningInvoice[] {
|
||||
const invoices: DetectedLightningInvoice[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const match of text.matchAll(BOLT11_REGEX)) {
|
||||
const lower = match[1].toLowerCase()
|
||||
if (!seen.has(lower)) {
|
||||
seen.add(lower)
|
||||
invoices.push({ invoice: match[1] })
|
||||
}
|
||||
}
|
||||
|
||||
return invoices
|
||||
}
|
||||
|
||||
export function detectBolt12Offers(text: string): DetectedBolt12Offer[] {
|
||||
const offers: DetectedBolt12Offer[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const match of text.matchAll(BOLT12_REGEX)) {
|
||||
const lower = match[1].toLowerCase()
|
||||
if (!seen.has(lower)) {
|
||||
seen.add(lower)
|
||||
offers.push({ offer: match[1] })
|
||||
}
|
||||
}
|
||||
|
||||
return offers
|
||||
}
|
||||
|
||||
export function detectTxIds(text: string): DetectedTxId[] {
|
||||
const txids: DetectedTxId[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const match of text.matchAll(TXID_REGEX)) {
|
||||
if (!seen.has(match[1])) {
|
||||
seen.add(match[1])
|
||||
txids.push({ txid: match[1] })
|
||||
}
|
||||
}
|
||||
|
||||
return txids
|
||||
}
|
||||
Reference in New Issue
Block a user