From 3a718f44de113dc0cf840dc525bd22d046a6741c Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 4 Mar 2026 00:32:59 +0000 Subject: [PATCH] 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 --- .../app/src/components/chat/ChatMessage.vue | 15 +++ .../renderers/BitcoinAddressCard.vue | 95 +++++++++++++++ .../app/src/composables/useBitcoinDetector.ts | 114 ++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 packages/app/src/components/renderers/BitcoinAddressCard.vue create mode 100644 packages/app/src/composables/useBitcoinDetector.ts diff --git a/packages/app/src/components/chat/ChatMessage.vue b/packages/app/src/components/chat/ChatMessage.vue index 0b4d2d29..071ca4e7 100644 --- a/packages/app/src/components/chat/ChatMessage.vue +++ b/packages/app/src/components/chat/ChatMessage.vue @@ -223,6 +223,14 @@ /> +
+ +
+
{ 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' ) diff --git a/packages/app/src/components/renderers/BitcoinAddressCard.vue b/packages/app/src/components/renderers/BitcoinAddressCard.vue new file mode 100644 index 00000000..a8686f8d --- /dev/null +++ b/packages/app/src/components/renderers/BitcoinAddressCard.vue @@ -0,0 +1,95 @@ + + + diff --git a/packages/app/src/composables/useBitcoinDetector.ts b/packages/app/src/composables/useBitcoinDetector.ts new file mode 100644 index 00000000..20e5c1b0 --- /dev/null +++ b/packages/app/src/composables/useBitcoinDetector.ts @@ -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() + + 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() + + 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() + + 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() + + for (const match of text.matchAll(TXID_REGEX)) { + if (!seen.has(match[1])) { + seen.add(match[1]) + txids.push({ txid: match[1] }) + } + } + + return txids +}