From 71125bfde98c54db2ba554232313c7653d61f0ac Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 3 Mar 2026 21:01:06 +0000 Subject: [PATCH] feat(app): add Cashu ecash token parsing and inline chat display Create cashu.ts with token parser (cashuA... base64url decode), amount extraction, and mint URL formatting. Add CashuToken.vue inline component with copy-to-clipboard and open-in-wallet deep-link. Integrate detection in ChatMessage.vue with automatic token stripping from display text. Co-Authored-By: Claude Opus 4.6 --- .../app/src/components/chat/CashuToken.vue | 116 ++++++++++++++++++ .../app/src/components/chat/ChatMessage.vue | 21 ++++ packages/app/src/utils/cashu.ts | 98 +++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 packages/app/src/components/chat/CashuToken.vue create mode 100644 packages/app/src/utils/cashu.ts diff --git a/packages/app/src/components/chat/CashuToken.vue b/packages/app/src/components/chat/CashuToken.vue new file mode 100644 index 00000000..2a6c6868 --- /dev/null +++ b/packages/app/src/components/chat/CashuToken.vue @@ -0,0 +1,116 @@ + + + diff --git a/packages/app/src/components/chat/ChatMessage.vue b/packages/app/src/components/chat/ChatMessage.vue index 78f909c8..0438722b 100644 --- a/packages/app/src/components/chat/ChatMessage.vue +++ b/packages/app/src/components/chat/ChatMessage.vue @@ -83,6 +83,14 @@ /> +
+ +
+
{ return matches ? [...new Set(matches)] : [] }) +const cashuTokens = computed(() => { + if (isUser.value) return [] + return extractCashuTokens(props.message.content) +}) + const isCodeResponse = computed(() => !isUser.value && props.triggeringQuery.trim().toLowerCase() === '/code' ) @@ -278,6 +293,12 @@ const displayText = computed(() => { let text = stripContentTags(props.message.content) if (inlineNewsLinks.value.length > 0 || inlineWebsitesLinks.value.length > 0) text = stripMarkdownLinks(text) if (nostrUris.value.length > 0) text = text.replace(NOSTR_URI_RE, '').replace(/\n{3,}/g, '\n\n').trim() + if (cashuTokens.value.length > 0) { + for (const token of cashuTokens.value) { + text = text.replace(token, '') + } + text = text.replace(/\n{3,}/g, '\n\n').trim() + } return text }) diff --git a/packages/app/src/utils/cashu.ts b/packages/app/src/utils/cashu.ts new file mode 100644 index 00000000..7aa09384 --- /dev/null +++ b/packages/app/src/utils/cashu.ts @@ -0,0 +1,98 @@ +/** + * Cashu ecash token parsing and display utilities. + * AIUI is never a wallet — only parses, displays, and deep-links to external wallets. + */ + +interface CashuProof { + amount: number + id: string + secret: string + C: string +} + +interface CashuTokenEntry { + mint: string + proofs: CashuProof[] +} + +interface CashuTokenData { + token: CashuTokenEntry[] + unit?: string + memo?: string +} + +export interface ParsedCashuToken { + mint: string + amount: number + unit: string + memo?: string + raw: string +} + +/** Check if a string looks like a Cashu token */ +export function isCashuToken(text: string): boolean { + return text.trim().startsWith('cashuA') +} + +/** Extract Cashu tokens from a text string */ +export function extractCashuTokens(text: string): string[] { + const regex = /cashuA[A-Za-z0-9_-]+/g + return [...text.matchAll(regex)].map(m => m[0]) +} + +/** Parse a Cashu token string (cashuA...) into structured data */ +export function parseCashuToken(token: string): ParsedCashuToken | null { + if (!isCashuToken(token)) return null + + try { + // Remove 'cashuA' prefix and decode base64url + const encoded = token.slice(6) + const padded = encoded.replace(/-/g, '+').replace(/_/g, '/') + const json = atob(padded) + const data = JSON.parse(json) as CashuTokenData + + if (!data.token || !Array.isArray(data.token) || data.token.length === 0) { + return null + } + + const entry = data.token[0] + const totalAmount = entry.proofs.reduce((sum, p) => sum + p.amount, 0) + + return { + mint: entry.mint, + amount: totalAmount, + unit: data.unit ?? 'sat', + memo: data.memo, + raw: token, + } + } catch { + return null + } +} + +/** Format a mint URL for display (truncate) */ +export function formatMintUrl(url: string): string { + try { + const parsed = new URL(url) + const host = parsed.hostname + if (host.length > 30) { + return host.slice(0, 15) + '...' + host.slice(-12) + } + return host + } catch { + if (url.length > 30) { + return url.slice(0, 15) + '...' + url.slice(-12) + } + return url + } +} + +/** Format amount with unit */ +export function formatCashuAmount(amount: number, unit: string): string { + if (unit === 'sat' || unit === 'sats') { + if (amount >= 1_000_000) return `${(amount / 1_000_000).toFixed(2)}M sats` + if (amount >= 1_000) return `${(amount / 1_000).toFixed(amount >= 10_000 ? 0 : 1)}k sats` + return `${amount} sats` + } + return `${amount} ${unit}` +}