From 065696967c80bef4778e0f3c4f0091d000e9d371 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 21 Apr 2026 15:07:58 -0700 Subject: [PATCH 01/16] universal qr scanner with input classification --- app/features/scan/classify-input.test.ts | 163 ++++++++++++++++++++ app/features/scan/classify-input.ts | 41 +++++ app/features/scan/index.ts | 1 + app/features/send/destination-validators.ts | 72 +++++++++ app/features/send/send-provider.tsx | 14 +- app/features/send/send-store.ts | 144 +++++++++-------- app/lib/bolt11/bolt11.test.ts | 67 +++++--- app/lib/bolt11/index.ts | 16 +- app/lib/lnurl/index.ts | 9 +- app/routes/_protected._index.tsx | 11 +- app/routes/_protected.scan.tsx | 105 +++++++++++++ app/routes/_protected.send.tsx | 47 +++++- 12 files changed, 577 insertions(+), 113 deletions(-) create mode 100644 app/features/scan/classify-input.test.ts create mode 100644 app/features/scan/classify-input.ts create mode 100644 app/features/scan/index.ts create mode 100644 app/features/send/destination-validators.ts create mode 100644 app/routes/_protected.scan.tsx diff --git a/app/features/scan/classify-input.test.ts b/app/features/scan/classify-input.test.ts new file mode 100644 index 000000000..fc152f95f --- /dev/null +++ b/app/features/scan/classify-input.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from 'bun:test'; +import { type Token, getEncodedToken } from '@cashu/cashu-ts'; +import { classifyInput } from './classify-input'; + +// -- Test fixtures -- + +const CASHU_TOKEN: Token = { + mint: 'https://mint.example.com', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: 'test-secret-1', + C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', + }, + ], + unit: 'sat', +}; + +const CASHU_A_TOKEN = getEncodedToken(CASHU_TOKEN, { version: 3 }); +const CASHU_B_TOKEN = getEncodedToken(CASHU_TOKEN, { version: 4 }); + +// Real BOLT11 test vector from bolt11.test.ts (250,000 sats, "1 cup coffee") +const BOLT11_INVOICE = + 'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp'; + +// -- Tests -- + +describe('classifyInput', () => { + describe('cashu tokens', () => { + test('cashuA token string', () => { + const result = classifyInput(CASHU_A_TOKEN); + expect(result.type).toBe('cashu-token'); + if (result.type === 'cashu-token') { + expect(result.encoded).toBe(CASHU_A_TOKEN); + } + }); + + test('cashuB token string', () => { + const result = classifyInput(CASHU_B_TOKEN); + expect(result.type).toBe('cashu-token'); + if (result.type === 'cashu-token') { + expect(result.encoded).toBe(CASHU_B_TOKEN); + } + }); + + test('URL containing cashu token', () => { + const result = classifyInput(`https://example.com/#${CASHU_A_TOKEN}`); + expect(result.type).toBe('cashu-token'); + if (result.type === 'cashu-token') { + expect(result.encoded).toBe(CASHU_A_TOKEN); + } + }); + + test('token with leading/trailing whitespace', () => { + const result = classifyInput(` ${CASHU_A_TOKEN} `); + expect(result.type).toBe('cashu-token'); + }); + + test('cashu: URI prefix', () => { + const result = classifyInput(`cashu:${CASHU_A_TOKEN}`); + expect(result.type).toBe('cashu-token'); + if (result.type === 'cashu-token') { + expect(result.encoded).toBe(CASHU_A_TOKEN); + } + }); + }); + + describe('bolt11 invoices', () => { + test('raw bolt11 invoice with full decoded data', () => { + const result = classifyInput(BOLT11_INVOICE); + expect(result.type).toBe('bolt11'); + if (result.type === 'bolt11') { + expect(result.invoice).toBe(BOLT11_INVOICE); + expect(result.decoded).toEqual({ + amountMsat: 250000000, + amountSat: 250000, + expiryUnixMs: 1496314718000, + network: 'bitcoin', + description: '1 cup coffee', + paymentHash: + '0001020304050607080900010203040506070809000102030405060708090102', + }); + } + }); + + test('lightning: prefixed invoice', () => { + const result = classifyInput(`lightning:${BOLT11_INVOICE}`); + expect(result.type).toBe('bolt11'); + if (result.type === 'bolt11') { + expect(result.invoice).toBe(BOLT11_INVOICE); + } + }); + + test('LIGHTNING: uppercase prefix', () => { + const result = classifyInput(`LIGHTNING:${BOLT11_INVOICE}`); + expect(result.type).toBe('bolt11'); + if (result.type === 'bolt11') { + expect(result.invoice).toBe(BOLT11_INVOICE); + } + }); + }); + + describe('lightning addresses', () => { + test('valid lightning address', () => { + const result = classifyInput('user@domain.com'); + expect(result.type).toBe('ln-address'); + if (result.type === 'ln-address') { + expect(result.address).toBe('user@domain.com'); + } + }); + + test('uppercase address is lowercased', () => { + const result = classifyInput('USER@Domain.Com'); + expect(result.type).toBe('ln-address'); + if (result.type === 'ln-address') { + expect(result.address).toBe('user@domain.com'); + } + }); + + test('address with subdomain', () => { + const result = classifyInput('alice@pay.example.org'); + expect(result.type).toBe('ln-address'); + if (result.type === 'ln-address') { + expect(result.address).toBe('alice@pay.example.org'); + } + }); + }); + + describe('unknown inputs', () => { + test('empty string', () => { + expect(classifyInput('')).toEqual({ type: 'unknown' }); + }); + + test('whitespace only', () => { + expect(classifyInput(' ')).toEqual({ type: 'unknown' }); + }); + + test('random gibberish', () => { + expect(classifyInput('not a valid anything')).toEqual({ + type: 'unknown', + }); + }); + + test('email-like but invalid TLD', () => { + expect(classifyInput('user@x')).toEqual({ type: 'unknown' }); + }); + + test('bare URL without token', () => { + expect(classifyInput('https://example.com')).toEqual({ + type: 'unknown', + }); + }); + }); + + describe('classification priority', () => { + test('cashu token takes priority over everything', () => { + // A string that contains both a cashu token and an @ — cashu wins + const result = classifyInput(`user@domain.com ${CASHU_A_TOKEN}`); + expect(result.type).toBe('cashu-token'); + }); + }); +}); diff --git a/app/features/scan/classify-input.ts b/app/features/scan/classify-input.ts new file mode 100644 index 000000000..cf7743d9a --- /dev/null +++ b/app/features/scan/classify-input.ts @@ -0,0 +1,41 @@ +import { type DecodedBolt11, parseBolt11Invoice } from '~/lib/bolt11'; +import { extractCashuToken } from '~/lib/cashu/token'; +import { buildLightningAddressFormatValidator } from '~/lib/lnurl'; + +const validateLnAddressFormat = buildLightningAddressFormatValidator({ + message: 'invalid', + allowLocalhost: import.meta.env.MODE === 'development', +}); + +export type ClassifiedInput = + | { type: 'cashu-token'; encoded: string } + | { type: 'bolt11'; invoice: string; decoded: DecodedBolt11 } + | { type: 'ln-address'; address: string } + | { type: 'unknown' }; + +export function classifyInput(raw: string): ClassifiedInput { + const trimmed = raw.trim(); + + // 1. Cashu token (works on URLs, raw tokens, etc.) + const cashuResult = extractCashuToken(trimmed); + if (cashuResult) { + return { type: 'cashu-token', encoded: cashuResult.encoded }; + } + + // 2. BOLT11 invoice + const bolt11Result = parseBolt11Invoice(trimmed); + if (bolt11Result.valid) { + return { + type: 'bolt11', + invoice: bolt11Result.invoice, + decoded: bolt11Result.decoded, + }; + } + + // 3. Lightning address + if (validateLnAddressFormat(trimmed) === true) { + return { type: 'ln-address', address: trimmed.toLowerCase() }; + } + + return { type: 'unknown' }; +} diff --git a/app/features/scan/index.ts b/app/features/scan/index.ts new file mode 100644 index 000000000..dae39a34d --- /dev/null +++ b/app/features/scan/index.ts @@ -0,0 +1 @@ +export { classifyInput, type ClassifiedInput } from './classify-input'; diff --git a/app/features/send/destination-validators.ts b/app/features/send/destination-validators.ts new file mode 100644 index 000000000..2b051031a --- /dev/null +++ b/app/features/send/destination-validators.ts @@ -0,0 +1,72 @@ +import type { DecodedBolt11 } from '~/lib/bolt11'; +import { buildLightningAddressFormatValidator } from '~/lib/lnurl'; +import { type Currency, Money } from '~/lib/money'; + +export type ValidateResult = + | { + valid: false; + error: string; + } + | { + valid: true; + amount: Money | null; + currency: Currency; + unit: 'sat' | 'cent'; + }; + +/** + * Deep validation for a decoded BOLT11 invoice: checks network, expiry, and + * (optionally) amount. Returns a typed success/failure result. + */ +export const validateBolt11 = ( + { network, amountSat, expiryUnixMs }: DecodedBolt11, + { allowZeroAmount = false } = {}, +): ValidateResult => { + if (network !== 'bitcoin') { + return { + valid: false, + error: `Unsupported network: ${network}. Only Bitcoin mainnet is supported`, + }; + } + + if (expiryUnixMs) { + const expiresAt = new Date(expiryUnixMs); + const now = new Date(); + if (expiresAt < now) { + return { + valid: false, + error: 'Invoice expired', + }; + } + } + + if (!amountSat && !allowZeroAmount) { + return { + valid: false, + error: 'Amount is required for Lightning invoices', + }; + } + + return { + valid: true, + amount: amountSat + ? new Money({ + amount: amountSat, + currency: 'BTC' as Currency, + unit: 'sat', + }) + : null, + unit: 'sat', + currency: 'BTC', + }; +}; + +/** + * Format-level validator for Lightning addresses. Returns `true` if the input + * parses as a well-formed address, or an error message string otherwise. + */ +export const validateLightningAddressFormat = + buildLightningAddressFormatValidator({ + message: 'Invalid lightning address', + allowLocalhost: import.meta.env.MODE === 'development', + }); diff --git a/app/features/send/send-provider.tsx b/app/features/send/send-provider.tsx index 9d45d4d8c..849cdb13c 100644 --- a/app/features/send/send-provider.tsx +++ b/app/features/send/send-provider.tsx @@ -6,6 +6,7 @@ import { } from 'react'; import { useStore } from 'zustand'; import type { Account } from '~/features/accounts/account'; +import type { ClassifiedInput } from '~/features/scan'; import { useGetAccount } from '../accounts/account-hooks'; import { useCreateCashuLightningSendQuote } from './cashu-send-quote-hooks'; import { useCreateCashuSendSwapQuote } from './cashu-send-swap-hooks'; @@ -18,9 +19,19 @@ const SendContext = createContext(null); type Props = PropsWithChildren<{ /** Usually the user's default account. This sets the initial account to send from. */ initialAccount: Account; + /** + * Pre-validated destination from the route loader (e.g. from a QR scan + * or shared link). Seeds the initial store state so the component renders + * with the destination already selected — no post-mount side effect needed. + */ + initialDestination?: ClassifiedInput | null; }>; -export const SendProvider = ({ initialAccount, children }: Props) => { +export const SendProvider = ({ + initialAccount, + initialDestination, + children, +}: Props) => { const { mutateAsync: getInvoiceFromLud16 } = useGetInvoiceFromLud16(); const { mutateAsync: getCashuLightningQuote } = useCreateCashuLightningSendQuote(); @@ -32,6 +43,7 @@ export const SendProvider = ({ initialAccount, children }: Props) => { const [store] = useState(() => createSendStore({ initialAccount, + initialDestination, getAccount, getInvoiceFromLud16, getCashuLightningQuote, diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index 8d589b23c..028261837 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -4,17 +4,19 @@ import type { CashuAccount, SparkAccount, } from '~/features/accounts/account'; -import { type DecodedBolt11, parseBolt11Invoice } from '~/lib/bolt11'; +import type { ClassifiedInput } from '~/features/scan'; +import { parseBolt11Invoice } from '~/lib/bolt11'; import { parseCashuPaymentRequest } from '~/lib/cashu'; -import { - buildLightningAddressFormatValidator, - isValidLightningAddress, -} from '~/lib/lnurl'; -import { type Currency, Money } from '~/lib/money'; +import { isValidLightningAddress } from '~/lib/lnurl'; +import type { Currency, Money } from '~/lib/money'; import { type Contact, isContact } from '../contacts/contact'; import { DomainError } from '../shared/error'; import type { CashuLightningQuote } from './cashu-send-quote-service'; import type { CashuSwapQuote } from './cashu-send-swap-service'; +import { + validateBolt11, + validateLightningAddressFormat, +} from './destination-validators'; import type { SparkLightningQuote } from './spark-send-quote-service'; /** @@ -28,66 +30,6 @@ const getDefaultSendType = ( return accountType === 'cashu' ? 'CASHU_TOKEN' : 'BOLT11_INVOICE'; }; -const validateLightningAddressFormat = buildLightningAddressFormatValidator({ - message: 'Invalid lightning address', - allowLocalhost: import.meta.env.MODE === 'development', -}); - -type ValidateResult = - | { - valid: false; - error: string; - } - | { - valid: true; - amount: Money | null; - currency: Currency; - unit: 'sat' | 'cent'; - }; - -const validateBolt11 = ( - { network, amountSat, expiryUnixMs }: DecodedBolt11, - { allowZeroAmount = false } = {}, -): ValidateResult => { - if (network !== 'bitcoin') { - return { - valid: false, - error: `Unsupported network: ${network}. Only Bitcoin mainnet is supported`, - }; - } - - if (expiryUnixMs) { - const expiresAt = new Date(expiryUnixMs); - const now = new Date(); - if (expiresAt < now) { - return { - valid: false, - error: 'Invoice expired', - }; - } - } - - if (!amountSat && !allowZeroAmount) { - return { - valid: false, - error: 'Amount is required for Lightning invoices', - }; - } - - return { - valid: true, - amount: amountSat - ? new Money({ - amount: amountSat, - currency: 'BTC' as Currency, - unit: 'sat', - }) - : null, - unit: 'sat', - currency: 'BTC', - }; -}; - const pickAmountByCurrency = ( amounts: Money[], currency: T, @@ -200,6 +142,7 @@ export type SendState = State & Actions; type CreateSendStoreProps = { initialAccount: Account; + initialDestination?: ClassifiedInput | null; getAccount: (accountId: string) => Account; getInvoiceFromLud16: (params: { lud16: string; @@ -234,14 +177,61 @@ const isSendTypeSupportedForAccount = ( return supportedSendTypes[account.type].includes(sendType); }; +type InitialDestinationState = + | { + sendType: 'BOLT11_INVOICE'; + destination: string; + destinationDisplay: string; + destinationDetails?: null; + } + | { + sendType: 'LN_ADDRESS'; + destination: null; + destinationDisplay: string; + destinationDetails: { lnAddress: string }; + }; + +/** + * Turn a pre-validated `ClassifiedInput` from the loader into the initial + * destination fields of `SendState`. Returns `null` if the classified input + * is not a valid send destination (cashu-token, unknown). + */ +const classifiedToInitialState = ( + classified: ClassifiedInput, +): InitialDestinationState | null => { + switch (classified.type) { + case 'bolt11': + return { + sendType: 'BOLT11_INVOICE', + destination: classified.invoice, + destinationDisplay: `${classified.invoice.slice(0, 6)}...${classified.invoice.slice(-4)}`, + }; + case 'ln-address': + return { + sendType: 'LN_ADDRESS', + destination: null, + destinationDisplay: classified.address, + destinationDetails: { lnAddress: classified.address }, + }; + case 'cashu-token': + case 'unknown': + return null; + } +}; + export const createSendStore = ({ initialAccount, + initialDestination, getAccount, getInvoiceFromLud16, getCashuLightningQuote, getCashuSwapQuote, getSparkLightningQuote, }: CreateSendStoreProps) => { + const resolvedInitialDestination = initialDestination + ? classifiedToInitialState(initialDestination) + : null; + return create()((set, get) => { const getOrThrow = ( key: T, @@ -254,13 +244,17 @@ export const createSendStore = ({ return value; }; - return { - status: 'idle', - amount: null, - accountId: initialAccount.id, + const initialDestinationFields = resolvedInitialDestination ?? { sendType: getDefaultSendType(initialAccount.type), destination: null, destinationDisplay: null, + }; + + return { + status: 'idle' as const, + amount: null, + accountId: initialAccount.id, + ...initialDestinationFields, quote: null, cashuToken: null, @@ -343,19 +337,19 @@ export const createSendStore = ({ const bolt11ParseResult = parseBolt11Invoice(destination); if (bolt11ParseResult.valid) { - const invoice = bolt11ParseResult.decoded; const account = get().getSourceAccount(); const allowZeroAmount = account.type === 'spark'; - const result = validateBolt11(invoice, { allowZeroAmount }); + const result = validateBolt11(bolt11ParseResult.decoded, { + allowZeroAmount, + }); if (!result.valid) { return { success: false, error: result.error }; } - const cleanedDestination = destination.replace(/^lightning:/i, ''); set({ sendType: 'BOLT11_INVOICE', - destination: cleanedDestination, - destinationDisplay: `${cleanedDestination.slice(0, 6)}...${cleanedDestination.slice(-4)}`, + destination: bolt11ParseResult.invoice, + destinationDisplay: `${bolt11ParseResult.invoice.slice(0, 6)}...${bolt11ParseResult.invoice.slice(-4)}`, }); return { diff --git a/app/lib/bolt11/bolt11.test.ts b/app/lib/bolt11/bolt11.test.ts index bc534d1bf..5bb6a4515 100644 --- a/app/lib/bolt11/bolt11.test.ts +++ b/app/lib/bolt11/bolt11.test.ts @@ -1,30 +1,30 @@ import { describe, expect, it } from 'bun:test'; -import { decodeBolt11 } from './index'; +import { decodeBolt11, parseBolt11Invoice } from './index'; const invoice = 'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp'; const testnetInvoice = 'lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8'; +const expectedDecoded = { + amountMsat: 250000000, + amountSat: 250000, + createdAtUnixMs: 1496314658000, + expiryUnixMs: 1496314718000, + network: 'bitcoin', + description: '1 cup coffee', + paymentHash: + '0001020304050607080900010203040506070809000102030405060708090102', +}; + // NOTE: You can confirm these values using https://lightningdecoder.com describe('decodeBolt11', () => { it('should decode the invoice', () => { - const result = decodeBolt11(invoice); - expect(result).toEqual({ - amountMsat: 250000000, - amountSat: 250000, - createdAtUnixMs: 1496314658000, - expiryUnixMs: 1496314718000, - network: 'bitcoin', - description: '1 cup coffee', - paymentHash: - '0001020304050607080900010203040506070809000102030405060708090102', - }); + expect(decodeBolt11(invoice)).toEqual(expectedDecoded); }); it('should decode a testnet invoice', () => { - const result = decodeBolt11(testnetInvoice); - expect(result).toEqual({ + expect(decodeBolt11(testnetInvoice)).toEqual({ amountMsat: 2000000000, amountSat: 2000000, createdAtUnixMs: 1496314658000, @@ -35,18 +35,37 @@ describe('decodeBolt11', () => { '0001020304050607080900010203040506070809000102030405060708090102', }); }); +}); - it('should decode an invoice with a `lightning` prefix', () => { - const result = decodeBolt11(`lightning:${invoice}`); +describe('parseBolt11Invoice', () => { + it('should parse a raw invoice and return cleaned invoice string', () => { + const result = parseBolt11Invoice(invoice); expect(result).toEqual({ - amountMsat: 250000000, - amountSat: 250000, - createdAtUnixMs: 1496314658000, - expiryUnixMs: 1496314718000, - network: 'bitcoin', - description: '1 cup coffee', - paymentHash: - '0001020304050607080900010203040506070809000102030405060708090102', + valid: true, + invoice, + decoded: expectedDecoded, }); }); + + it('should strip lightning: prefix and return cleaned invoice', () => { + const result = parseBolt11Invoice(`lightning:${invoice}`); + expect(result).toEqual({ + valid: true, + invoice, + decoded: expectedDecoded, + }); + }); + + it('should strip LIGHTNING: uppercase prefix', () => { + const result = parseBolt11Invoice(`LIGHTNING:${invoice}`); + expect(result).toEqual({ + valid: true, + invoice, + decoded: expectedDecoded, + }); + }); + + it('should return valid: false for invalid input', () => { + expect(parseBolt11Invoice('not an invoice')).toEqual({ valid: false }); + }); }); diff --git a/app/lib/bolt11/index.ts b/app/lib/bolt11/index.ts index 518277e9b..b29655687 100644 --- a/app/lib/bolt11/index.ts +++ b/app/lib/bolt11/index.ts @@ -19,7 +19,7 @@ export type DecodedBolt11 = { * @param invoice invoice to decode */ export const decodeBolt11 = (invoice: string): DecodedBolt11 => { - const { sections } = bolt11Decoder.decode(invoice.replace(/^lightning:/, '')); + const { sections } = bolt11Decoder.decode(invoice); const amountSection = findSection(sections, 'amount'); const amountMsat = amountSection?.value @@ -62,15 +62,19 @@ export const decodeBolt11 = (invoice: string): DecodedBolt11 => { }; /** - * Checks if a string is a valid BOLT11 invoice - * @param invoice invoice to check + * Checks if a string is a valid BOLT11 invoice. + * Returns the cleaned invoice (without lightning: prefix) and decoded data on success. + * @param invoice invoice to check (accepts optional lightning: prefix, case-insensitive) */ export const parseBolt11Invoice = ( invoice: string, -): { valid: true; decoded: DecodedBolt11 } | { valid: false } => { +): + | { valid: true; invoice: string; decoded: DecodedBolt11 } + | { valid: false } => { try { - const decoded = decodeBolt11(invoice); - return { valid: true, decoded }; + const cleaned = invoice.replace(/^lightning:/i, ''); + const decoded = decodeBolt11(cleaned); + return { valid: true, invoice: cleaned, decoded }; } catch { return { valid: false }; } diff --git a/app/lib/lnurl/index.ts b/app/lib/lnurl/index.ts index 70339265c..a76ab29dc 100644 --- a/app/lib/lnurl/index.ts +++ b/app/lib/lnurl/index.ts @@ -113,10 +113,13 @@ export const buildLightningAddressFormatValidator = ({ return false; } + // Normalize to lowercase — LN addresses are case-insensitive per LUD-16 + const normalized = value.toLowerCase(); + // Handle localhost case if allowed if (allowLocalhost) { - const localhostRegex = /^[a-zA-Z0-9._%+-]+@localhost(:\d+)?$/; - if (localhostRegex.test(value)) { + const localhostRegex = /^[a-z0-9._%+-]+@localhost(:\d+)?$/; + if (localhostRegex.test(normalized)) { return true; } } @@ -124,7 +127,7 @@ export const buildLightningAddressFormatValidator = ({ // Lightning address format is described here https://datatracker.ietf.org/doc/html/rfc5322#section-3.4.1 // Split into local part and domain - const [localPart, domain] = value.split('@'); + const [localPart, domain] = normalized.split('@'); // Check if we have both parts if (!localPart || !domain) { diff --git a/app/routes/_protected._index.tsx b/app/routes/_protected._index.tsx index 4492bf949..ca2dd05e3 100644 --- a/app/routes/_protected._index.tsx +++ b/app/routes/_protected._index.tsx @@ -1,4 +1,4 @@ -import { Clock, GiftIcon, UserCircle2 } from 'lucide-react'; +import { Clock, GiftIcon, ScanLine, UserCircle2 } from 'lucide-react'; import type { LinksFunction } from 'react-router'; import agicashIcon192 from '~/assets/icon-192x192.png'; import { @@ -40,7 +40,14 @@ export default function Index() { return ( - + + + + {giftCardsEnabled && ( { + const result = classifyInput(raw); + + switch (result.type) { + case 'cashu-token': { + const hash = `#${result.encoded}`; + // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it + // See https://github.com/remix-run/remix/discussions/10721 + window.history.replaceState(null, '', hash); + navigate( + { pathname: '/receive/cashu/token', hash }, + { transition: 'slideLeft', applyTo: 'newView' }, + ); + break; + } + + case 'bolt11': { + const validation = validateBolt11(result.decoded, { + allowZeroAmount: true, + }); + if (!validation.valid) { + toast({ + title: 'Invalid invoice', + description: validation.error, + variant: 'destructive', + }); + return; + } + const hash = `#${result.invoice}`; + // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it + // See https://github.com/remix-run/remix/discussions/10721 + window.history.replaceState(null, '', hash); + navigate( + { pathname: '/send', hash }, + { transition: 'slideLeft', applyTo: 'newView' }, + ); + break; + } + + case 'ln-address': { + const hash = `#${result.address}`; + // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it + // See https://github.com/remix-run/remix/discussions/10721 + window.history.replaceState(null, '', hash); + navigate( + { pathname: '/send', hash }, + { transition: 'slideLeft', applyTo: 'newView' }, + ); + break; + } + + case 'unknown': + toast({ + title: 'Invalid QR code', + description: + 'Please scan a cashu token, lightning invoice, or lightning address', + variant: 'destructive', + }); + break; + } + }; + + const handlePaste = async () => { + const text = await readClipboard(); + if (text) handleInput(text); + }; + + return ( + + + + Scan + + + + + + + ); +} diff --git a/app/routes/_protected.send.tsx b/app/routes/_protected.send.tsx index 7e8ec9750..4321a048b 100644 --- a/app/routes/_protected.send.tsx +++ b/app/routes/_protected.send.tsx @@ -1,14 +1,57 @@ import { Outlet, useSearchParams } from 'react-router'; import { useAccountOrDefault } from '~/features/accounts/account-hooks'; +import { classifyInput } from '~/features/scan'; import { SendProvider } from '~/features/send'; +import { validateBolt11 } from '~/features/send/destination-validators'; +import { toast } from '~/hooks/use-toast'; +import type { Route } from './+types/_protected.send'; -export default function SendLayout() { +export async function clientLoader() { + const hash = window.location.hash.slice(1); + if (!hash) return { initialDestination: null }; + + window.history.replaceState( + null, + '', + window.location.pathname + window.location.search, + ); + + const classified = classifyInput(hash); + + if (classified.type === 'unknown') return { initialDestination: null }; + // A cashu token shouldn't land here, but be defensive if it does. + if (classified.type === 'cashu-token') return { initialDestination: null }; + + if (classified.type === 'bolt11') { + const result = validateBolt11(classified.decoded, { + allowZeroAmount: true, + }); + if (!result.valid) { + toast({ + title: 'Invalid invoice', + description: result.error, + variant: 'destructive', + duration: 8000, + }); + return { initialDestination: null }; + } + } + + return { initialDestination: classified }; +} + +clientLoader.hydrate = true as const; + +export default function SendLayout({ loaderData }: Route.ComponentProps) { const [searchParams] = useSearchParams(); const accountId = searchParams.get('accountId'); const initialAccount = useAccountOrDefault(accountId); return ( - + ); From 7824ea023078617dc1febea0f85edd1027fe7344 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 21 Apr 2026 15:12:33 -0700 Subject: [PATCH 02/16] docs: smart source account selection for BOLT11 design spec --- ...1-smart-source-account-selection-design.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-21-smart-source-account-selection-design.md diff --git a/docs/superpowers/specs/2026-04-21-smart-source-account-selection-design.md b/docs/superpowers/specs/2026-04-21-smart-source-account-selection-design.md new file mode 100644 index 000000000..a09bdc72b --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-smart-source-account-selection-design.md @@ -0,0 +1,161 @@ +# Smart Source Account Selection for BOLT11 — Design + +**Status:** Draft +**Date:** 2026-04-21 +**Branch:** `feat/universal-qr-scanner` (builds on the universal QR scanner work) +**Author:** gudnuf (+ Claude) + +## Motivation + +The universal QR scanner (commit `5d7d62ff`) classifies pasted and camera input and routes BOLT11 invoices to `/send`, but the send flow always initializes with the user's default account. When a scanned invoice was generated by a mint the user already has an account at, the send should prefer that same-mint account — the mint can often route the payment internally with zero or minimal fees. + +Many Cashu mints embed a fixed string in the BOLT11 `description` field of the invoices they generate (e.g. `"Minibits"`). When we recognize that string, we can infer which mint created the invoice and, if the user has a matching BTC account at that mint with sufficient balance, preselect it as the source. + +## Goals + +1. When a scanned or pasted BOLT11's `description` matches a configured mint, preselect the user's BTC cashu account at that mint as the send source. +2. Apply the same selection rule at all three BOLT11 entry points: `/scan` → `/send` navigation, `/send` `clientLoader` (URL hash / deep link), and `selectDestination` inside the send store (manual paste). +3. Keep the rule a pure, testable function with no hook dependencies. +4. Fail safe — if anything in the lookup breaks, fall back silently to the default account. +5. Validate the env var configuration at build time so misconfiguration can't reach production. + +## Non-goals + +- LN-address smart selection. LN addresses resolve asynchronously; applying the same rule there is a future extension. +- USD accounts. Scope restricted to BTC cashu accounts for this pass — USD→BTC conversion via NUT-05 adds exchange-rate complexity. +- Adding/removing mints based on scanned invoices. If the user doesn't have an account at the matched mint, we fall back to the default — we never prompt or silently onboard a mint. +- Runtime-editable configuration (no DB table, no admin UI). The data is essentially static. +- Dynamic detection from `/v1/info` mint name. Considered and rejected for this pass — explicit env config is simpler and deterministic. + +## Rule + +For a classified BOLT11: + +``` +if description is set + and description ∈ configured map + and user has a BTC cashu account at the mapped mintUrl + and invoice has a non-null amount + and account balance ≥ invoice amount +then use that account as the send source +else use the user's default account +``` + +Zero-amount invoices never match — we can't balance-check. The user will pick a source manually on the send page. + +Balance comparison uses invoice amount with **no fee-reserve buffer**. A small chance exists that the matched account has just enough to cover the invoice but not the melt fee — the existing send flow surfaces an error at confirm-time in that edge case. This is acceptable for an optimization path. + +## Components + +### 1. Config schema and env var + +**New file:** `app/features/send/mint-description-config.ts` + +```ts +import { z } from 'zod'; + +export const MintDescriptionMapSchema = z.record(z.string(), z.string().url()); +export type MintDescriptionMap = z.infer; + +// Used by vite.config.ts to validate the raw env var string at build time. +export const JsonMintDescriptionMapSchema = z + .string() + .transform((s) => JSON.parse(s)) + .pipe(MintDescriptionMapSchema); +``` + +**Env var:** `VITE_MINT_DESCRIPTION_MAP` — JSON string mapping description → mintUrl, e.g. + +```json +{"Minibits":"https://mint.minibits.cash/Bitcoin"} +``` + +Keys are exact BOLT11 description strings. Values are mint URLs (must be valid URLs per schema). + +**Build-time validation:** `vite.config.ts#validateEnv()` imports `JsonMintDescriptionMapSchema` and parses `VITE_MINT_DESCRIPTION_MAP` when present, mirroring the existing `VITE_GIFT_CARDS` pattern. Malformed JSON or a value that isn't a URL fails the build. + +### 2. Pure selector + +**New file:** `app/features/send/smart-source-selection.ts` + +```ts +type SmartSelectionInput = { + decoded: DecodedBolt11; + accounts: ExtendedAccount<'cashu'>[]; // caller passes BTC cashu accounts + defaultAccount: Account; +}; + +function selectSourceAccountForBolt11( + input: SmartSelectionInput, +): Account; +``` + +Parses `VITE_MINT_DESCRIPTION_MAP` once at module load (safe to throw; build pre-validated). Exposes the selection logic as a pure synchronous function. + +**Decision branches** (evaluated top to bottom; first mismatch returns `defaultAccount`): + +1. `decoded.description` is set +2. Lookup in the parsed map returns a `mintUrl` +3. `accounts.find(a => a.mintUrl === mintUrl)` returns a match +4. `decoded.amount` is non-null +5. Matched account's balance ≥ `decoded.amount` +6. Return the matched account + +### 3. Call sites + +All three sites pass the pre-filtered BTC cashu account list and the user's default account into `selectSourceAccountForBolt11`. + +**a) `/send` `clientLoader`** (`app/routes/_protected.send.tsx`) + +After `classifyInput`, for `type === 'bolt11'`, read the accounts cache directly (loader has no hooks). Call the selector. Return `{ initialDestination, initialAccountId }` from the loader. `SendLayout` passes `initialAccountId` to the send provider — overrides the `accountId` query param for this flow. + +**b) `/scan` navigate handler** (`app/routes/_protected.scan.tsx`) + +For `type === 'bolt11'` after `validateBolt11`, read the accounts cache, call the selector, append `?accountId=` to the `/send` URL before navigating. The `/send` loader then sees the explicit id and uses it. + +Alternative: skip this wiring on the scan side and let the loader handle it uniformly. Pick whichever keeps the diff smallest — functionally equivalent because the loader already does lookup on the hash. + +**c) `selectDestination` in `send-store`** (`app/features/send/send-store.ts`) + +In the BOLT11 branch, after validation, call the selector with `deps.getAccounts()` + `deps.getDefaultAccount()`. If the selected account differs from current `state.accountId`, update it. + +### 4. Tests + +**New:** `app/features/send/smart-source-selection.test.ts` — covers the 6 decision branches with fabricated inputs. Pure function, no mocks. + +## File changes summary + +| File | Change | +|---|---| +| `app/features/send/mint-description-config.ts` | **new** — schema + build-time-safe parse of `VITE_MINT_DESCRIPTION_MAP` | +| `app/features/send/smart-source-selection.ts` | **new** — pure selector function | +| `app/features/send/smart-source-selection.test.ts` | **new** — unit tests | +| `vite.config.ts` | `validateEnv()` also validates `VITE_MINT_DESCRIPTION_MAP` | +| `app/routes/_protected.send.tsx` | loader calls selector for `bolt11`, returns `initialAccountId` | +| `app/routes/_protected.scan.tsx` | (optional — may be handled entirely by the loader) | +| `app/features/send/send-store.ts` | `selectDestination` BOLT11 branch calls selector + updates `accountId` | +| `app/features/send/send-provider.tsx` | threads `initialAccountId` into `createSendStore` | +| `.env.example` | document `VITE_MINT_DESCRIPTION_MAP` | + +## Edge cases and failure modes + +| Case | Behavior | +|---|---| +| Env var missing or empty | Empty map, selector always returns default. No warning. | +| Env var malformed | Build fails. Cannot ship misconfigured. | +| Invoice has no description | Default account. | +| Description doesn't match any mapped key | Default account. | +| Matched mint but user has no BTC account at it | Default account. | +| Matched account exists but balance < invoice amount | Default account. | +| Zero-amount invoice | Default account (can't balance-check). | +| Matched account balance covers invoice but not fee reserve | Selected anyway; error surfaces at melt-quote confirmation (existing path). | +| Two accounts at the same mintUrl (shouldn't happen for BTC, but) | First match — deterministic, no UX impact given currency narrows to BTC. | + +No toasts, no warnings to the user. Smart selection is an optimization — invisible when it works, indistinguishable from current flow when it doesn't. + +## Out of scope / future + +- LN-address smart selection (requires async LNURL resolution before selection). +- USD account selection for BOLT11 (requires exchange-rate awareness and NUT-05 USD→BTC path). +- Auto-detecting mint identity from `/v1/info` (eliminates config but adds async fetching per scan). +- Admin-managed / DB-backed mint description table. From dcb0588b19b7ed74d3af07bc39ce60fcbee594a1 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 21 Apr 2026 15:14:40 -0700 Subject: [PATCH 03/16] docs: tighten smart source selection spec per review --- ...1-smart-source-account-selection-design.md | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-04-21-smart-source-account-selection-design.md b/docs/superpowers/specs/2026-04-21-smart-source-account-selection-design.md index a09bdc72b..a3a1b5461 100644 --- a/docs/superpowers/specs/2026-04-21-smart-source-account-selection-design.md +++ b/docs/superpowers/specs/2026-04-21-smart-source-account-selection-design.md @@ -79,6 +79,9 @@ Keys are exact BOLT11 description strings. Values are mint URLs (must be valid U **New file:** `app/features/send/smart-source-selection.ts` ```ts +// Imports: `DecodedBolt11` from `~/lib/bolt11`, +// `Account` and `ExtendedAccount` from `~/features/accounts/account`. + type SmartSelectionInput = { decoded: DecodedBolt11; accounts: ExtendedAccount<'cashu'>[]; // caller passes BTC cashu accounts @@ -109,19 +112,22 @@ All three sites pass the pre-filtered BTC cashu account list and the user's defa After `classifyInput`, for `type === 'bolt11'`, read the accounts cache directly (loader has no hooks). Call the selector. Return `{ initialDestination, initialAccountId }` from the loader. `SendLayout` passes `initialAccountId` to the send provider — overrides the `accountId` query param for this flow. -**b) `/scan` navigate handler** (`app/routes/_protected.scan.tsx`) - -For `type === 'bolt11'` after `validateBolt11`, read the accounts cache, call the selector, append `?accountId=` to the `/send` URL before navigating. The `/send` loader then sees the explicit id and uses it. - -Alternative: skip this wiring on the scan side and let the loader handle it uniformly. Pick whichever keeps the diff smallest — functionally equivalent because the loader already does lookup on the hash. +**b) `/scan` navigate handler** — *no changes required.* The scan route already navigates to `/send#`, and the `/send` loader runs the selector for every BOLT11 it parses. One entry point for scan + deep-link coverage. **c) `selectDestination` in `send-store`** (`app/features/send/send-store.ts`) -In the BOLT11 branch, after validation, call the selector with `deps.getAccounts()` + `deps.getDefaultAccount()`. If the selected account differs from current `state.accountId`, update it. +In the BOLT11 branch, after validation, call the selector with `deps.getAccounts()` + `deps.getDefaultAccount()`. Update `state.accountId` to the selected account. This only runs when the user selects a new destination — a user who manually switches accounts *and then* stays on the same destination will not be stomped, because `selectDestination` is not re-invoked without a new input. If the user pastes a new BOLT11 after changing the account, smart selection re-applies for the new destination (desirable: new input, new best-source decision). ### 4. Tests -**New:** `app/features/send/smart-source-selection.test.ts` — covers the 6 decision branches with fabricated inputs. Pure function, no mocks. +**New:** `app/features/send/smart-source-selection.test.ts` — pure function, no mocks. One test per decision branch: + +1. Description is missing → default +2. Description is set but unmapped → default +3. Description maps to a mint the user has no BTC account at → default +4. Matched mint + account, but invoice amount is null (zero-amount) → default +5. Matched mint + account, but balance < invoice amount → default +6. Matched mint + account + balance ≥ invoice amount → matched account ## File changes summary @@ -132,7 +138,7 @@ In the BOLT11 branch, after validation, call the selector with `deps.getAccounts | `app/features/send/smart-source-selection.test.ts` | **new** — unit tests | | `vite.config.ts` | `validateEnv()` also validates `VITE_MINT_DESCRIPTION_MAP` | | `app/routes/_protected.send.tsx` | loader calls selector for `bolt11`, returns `initialAccountId` | -| `app/routes/_protected.scan.tsx` | (optional — may be handled entirely by the loader) | +| `app/routes/_protected.scan.tsx` | *no changes* — selection runs in the `/send` loader | | `app/features/send/send-store.ts` | `selectDestination` BOLT11 branch calls selector + updates `accountId` | | `app/features/send/send-provider.tsx` | threads `initialAccountId` into `createSendStore` | | `.env.example` | document `VITE_MINT_DESCRIPTION_MAP` | From 4e395f687cd686a633e53827b3749b5ee76268a5 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 21 Apr 2026 15:29:10 -0700 Subject: [PATCH 04/16] cleanup --- app/features/receive/receive-input.tsx | 8 +- app/features/receive/receive-scanner.tsx | 8 +- app/features/scan/classify-input.ts | 17 +++++ app/features/scan/index.ts | 9 ++- app/features/send/send-provider.tsx | 10 +-- app/features/send/send-store.ts | 28 +++---- app/features/transfer/transfer-input.tsx | 2 + app/features/transfer/transfer-scanner.tsx | 2 + app/routes/_protected._index.tsx | 4 +- app/routes/_protected.scan.tsx | 89 +++++++++------------- app/routes/_protected.send.tsx | 18 +++-- 11 files changed, 103 insertions(+), 92 deletions(-) diff --git a/app/features/receive/receive-input.tsx b/app/features/receive/receive-input.tsx index 82145c390..dd90cdbcb 100644 --- a/app/features/receive/receive-input.tsx +++ b/app/features/receive/receive-input.tsx @@ -14,6 +14,7 @@ import { toAccountSelectorOption, } from '~/features/accounts/account-selector'; import { accountOfflineToast } from '~/features/accounts/utils'; +import { classifyInput, isReceiveInput } from '~/features/scan'; import { ConvertedMoneySwitcher } from '~/features/shared/converted-money-switcher'; import { getDefaultUnit } from '~/features/shared/currencies'; import useAnimation from '~/hooks/use-animation'; @@ -21,7 +22,6 @@ import { useMoneyInput } from '~/hooks/use-money-input'; import { useRedirectTo } from '~/hooks/use-redirect-to'; import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link'; import { useToast } from '~/hooks/use-toast'; -import { extractCashuToken } from '~/lib/cashu'; import { readClipboard } from '~/lib/read-clipboard'; import { LinkWithViewTransition, @@ -90,8 +90,8 @@ export default function ReceiveInput() { return; } - const encodedToken = extractCashuToken(clipboardContent)?.encoded; - if (!encodedToken) { + const classified = classifyInput(clipboardContent); + if (!isReceiveInput(classified)) { toast({ title: 'Invalid input', description: 'Please paste a valid cashu token', @@ -100,7 +100,7 @@ export default function ReceiveInput() { return; } - const hash = `#${encodedToken}`; + const hash = `#${classified.encoded}`; // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it // See https://github.com/remix-run/remix/discussions/10721 diff --git a/app/features/receive/receive-scanner.tsx b/app/features/receive/receive-scanner.tsx index b6f20ac15..429ecc9ae 100644 --- a/app/features/receive/receive-scanner.tsx +++ b/app/features/receive/receive-scanner.tsx @@ -5,9 +5,9 @@ import { PageHeaderTitle, } from '~/components/page'; import { QRScanner } from '~/components/qr-scanner'; +import { classifyInput, isReceiveInput } from '~/features/scan'; import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link'; import { useToast } from '~/hooks/use-toast'; -import { extractCashuToken } from '~/lib/cashu'; import { useNavigateWithViewTransition } from '~/lib/transitions'; import { useReceiveStore } from './receive-provider'; @@ -30,8 +30,8 @@ export default function ReceiveScanner() { { - const encodedToken = extractCashuToken(scannedContent)?.encoded; - if (!encodedToken) { + const classified = classifyInput(scannedContent); + if (!isReceiveInput(classified)) { toast({ title: 'Invalid input', description: 'Please scan a valid cashu token', @@ -40,7 +40,7 @@ export default function ReceiveScanner() { return; } - const hash = `#${encodedToken}`; + const hash = `#${classified.encoded}`; // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it // See https://github.com/remix-run/remix/discussions/10721 diff --git a/app/features/scan/classify-input.ts b/app/features/scan/classify-input.ts index cf7743d9a..41602b537 100644 --- a/app/features/scan/classify-input.ts +++ b/app/features/scan/classify-input.ts @@ -13,6 +13,23 @@ export type ClassifiedInput = | { type: 'ln-address'; address: string } | { type: 'unknown' }; +export type SendInput = Extract< + ClassifiedInput, + { type: 'bolt11' } | { type: 'ln-address' } +>; + +export type ReceiveInput = Extract; + +export const isSendInput = (input: ClassifiedInput): input is SendInput => { + return input.type === 'bolt11' || input.type === 'ln-address'; +}; + +export const isReceiveInput = ( + input: ClassifiedInput, +): input is ReceiveInput => { + return input.type === 'cashu-token'; +}; + export function classifyInput(raw: string): ClassifiedInput { const trimmed = raw.trim(); diff --git a/app/features/scan/index.ts b/app/features/scan/index.ts index dae39a34d..d1eb72791 100644 --- a/app/features/scan/index.ts +++ b/app/features/scan/index.ts @@ -1 +1,8 @@ -export { classifyInput, type ClassifiedInput } from './classify-input'; +export { + classifyInput, + isReceiveInput, + isSendInput, + type ClassifiedInput, + type ReceiveInput, + type SendInput, +} from './classify-input'; diff --git a/app/features/send/send-provider.tsx b/app/features/send/send-provider.tsx index 849cdb13c..2f0f97354 100644 --- a/app/features/send/send-provider.tsx +++ b/app/features/send/send-provider.tsx @@ -6,7 +6,7 @@ import { } from 'react'; import { useStore } from 'zustand'; import type { Account } from '~/features/accounts/account'; -import type { ClassifiedInput } from '~/features/scan'; +import type { SendInput } from '~/features/scan'; import { useGetAccount } from '../accounts/account-hooks'; import { useCreateCashuLightningSendQuote } from './cashu-send-quote-hooks'; import { useCreateCashuSendSwapQuote } from './cashu-send-swap-hooks'; @@ -20,11 +20,11 @@ type Props = PropsWithChildren<{ /** Usually the user's default account. This sets the initial account to send from. */ initialAccount: Account; /** - * Pre-validated destination from the route loader (e.g. from a QR scan - * or shared link). Seeds the initial store state so the component renders - * with the destination already selected — no post-mount side effect needed. + * Pre-validated destination from the route loader (e.g. QR scan or shared link). + * Used to initialize the store so send starts with destination preselected, + * instead of setting it later in an effect. */ - initialDestination?: ClassifiedInput | null; + initialDestination?: SendInput | null; }>; export const SendProvider = ({ diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index 028261837..1baf9d1fb 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -4,7 +4,7 @@ import type { CashuAccount, SparkAccount, } from '~/features/accounts/account'; -import type { ClassifiedInput } from '~/features/scan'; +import type { SendInput } from '~/features/scan'; import { parseBolt11Invoice } from '~/lib/bolt11'; import { parseCashuPaymentRequest } from '~/lib/cashu'; import { isValidLightningAddress } from '~/lib/lnurl'; @@ -142,7 +142,7 @@ export type SendState = State & Actions; type CreateSendStoreProps = { initialAccount: Account; - initialDestination?: ClassifiedInput | null; + initialDestination?: SendInput | null; getAccount: (accountId: string) => Account; getInvoiceFromLud16: (params: { lud16: string; @@ -191,31 +191,21 @@ type InitialDestinationState = destinationDetails: { lnAddress: string }; }; -/** - * Turn a pre-validated `ClassifiedInput` from the loader into the initial - * destination fields of `SendState`. Returns `null` if the classified input - * is not a valid send destination (cashu-token, unknown). - */ -const classifiedToInitialState = ( - classified: ClassifiedInput, -): InitialDestinationState | null => { - switch (classified.type) { +const sendInputToInitialState = (input: SendInput): InitialDestinationState => { + switch (input.type) { case 'bolt11': return { sendType: 'BOLT11_INVOICE', - destination: classified.invoice, - destinationDisplay: `${classified.invoice.slice(0, 6)}...${classified.invoice.slice(-4)}`, + destination: input.invoice, + destinationDisplay: `${input.invoice.slice(0, 6)}...${input.invoice.slice(-4)}`, }; case 'ln-address': return { sendType: 'LN_ADDRESS', destination: null, - destinationDisplay: classified.address, - destinationDetails: { lnAddress: classified.address }, + destinationDisplay: input.address, + destinationDetails: { lnAddress: input.address }, }; - case 'cashu-token': - case 'unknown': - return null; } }; @@ -229,7 +219,7 @@ export const createSendStore = ({ getSparkLightningQuote, }: CreateSendStoreProps) => { const resolvedInitialDestination = initialDestination - ? classifiedToInitialState(initialDestination) + ? sendInputToInitialState(initialDestination) : null; return create()((set, get) => { diff --git a/app/features/transfer/transfer-input.tsx b/app/features/transfer/transfer-input.tsx index 5becfe0d8..ed8d3cfe0 100644 --- a/app/features/transfer/transfer-input.tsx +++ b/app/features/transfer/transfer-input.tsx @@ -121,6 +121,8 @@ export default function TransferInput() { const hash = `#${encodedToken}`; + // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it + // See https://github.com/remix-run/remix/discussions/10721 window.history.replaceState(null, '', hash); navigate( { diff --git a/app/features/transfer/transfer-scanner.tsx b/app/features/transfer/transfer-scanner.tsx index 66f91c7c3..da0cc77b0 100644 --- a/app/features/transfer/transfer-scanner.tsx +++ b/app/features/transfer/transfer-scanner.tsx @@ -42,6 +42,8 @@ export default function TransferScanner() { const hash = `#${encodedToken}`; + // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it + // See https://github.com/remix-run/remix/discussions/10721 window.history.replaceState(null, '', hash); navigate( { diff --git a/app/routes/_protected._index.tsx b/app/routes/_protected._index.tsx index ca2dd05e3..d48d22697 100644 --- a/app/routes/_protected._index.tsx +++ b/app/routes/_protected._index.tsx @@ -1,4 +1,4 @@ -import { Clock, GiftIcon, ScanLine, UserCircle2 } from 'lucide-react'; +import { Clock, GiftIcon, Scan, UserCircle2 } from 'lucide-react'; import type { LinksFunction } from 'react-router'; import agicashIcon192 from '~/assets/icon-192x192.png'; import { @@ -46,7 +46,7 @@ export default function Index() { transition="slideUp" applyTo="newView" > - + {giftCardsEnabled && ( { const result = classifyInput(raw); - switch (result.type) { - case 'cashu-token': { - const hash = `#${result.encoded}`; - // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it - // See https://github.com/remix-run/remix/discussions/10721 - window.history.replaceState(null, '', hash); - navigate( - { pathname: '/receive/cashu/token', hash }, - { transition: 'slideLeft', applyTo: 'newView' }, - ); - break; - } + if (isReceiveInput(result)) { + const hash = `#${result.encoded}`; + // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it + // See https://github.com/remix-run/remix/discussions/10721 + window.history.replaceState(null, '', hash); + navigate( + { pathname: '/receive/cashu/token', hash }, + { transition: 'slideLeft', applyTo: 'newView' }, + ); + return; + } - case 'bolt11': { + if (isSendInput(result)) { + if (result.type === 'bolt11') { const validation = validateBolt11(result.decoded, { allowZeroAmount: true, }); @@ -45,38 +47,25 @@ export default function ScanPage() { }); return; } - const hash = `#${result.invoice}`; - // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it - // See https://github.com/remix-run/remix/discussions/10721 - window.history.replaceState(null, '', hash); - navigate( - { pathname: '/send', hash }, - { transition: 'slideLeft', applyTo: 'newView' }, - ); - break; - } - - case 'ln-address': { - const hash = `#${result.address}`; - // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it - // See https://github.com/remix-run/remix/discussions/10721 - window.history.replaceState(null, '', hash); - navigate( - { pathname: '/send', hash }, - { transition: 'slideLeft', applyTo: 'newView' }, - ); - break; } - case 'unknown': - toast({ - title: 'Invalid QR code', - description: - 'Please scan a cashu token, lightning invoice, or lightning address', - variant: 'destructive', - }); - break; + const hash = `#${result.type === 'bolt11' ? result.invoice : result.address}`; + // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it + // See https://github.com/remix-run/remix/discussions/10721 + window.history.replaceState(null, '', hash); + navigate( + { pathname: '/send', hash }, + { transition: 'slideLeft', applyTo: 'newView' }, + ); + return; } + + toast({ + title: 'Invalid QR code', + description: + 'Please scan a cashu token, lightning invoice, or lightning address', + variant: 'destructive', + }); }; const handlePaste = async () => { @@ -90,16 +79,14 @@ export default function ScanPage() { Scan - + - + + + ); } diff --git a/app/routes/_protected.send.tsx b/app/routes/_protected.send.tsx index 4321a048b..df0a5a556 100644 --- a/app/routes/_protected.send.tsx +++ b/app/routes/_protected.send.tsx @@ -1,15 +1,19 @@ import { Outlet, useSearchParams } from 'react-router'; import { useAccountOrDefault } from '~/features/accounts/account-hooks'; -import { classifyInput } from '~/features/scan'; +import { type SendInput, classifyInput, isSendInput } from '~/features/scan'; import { SendProvider } from '~/features/send'; import { validateBolt11 } from '~/features/send/destination-validators'; import { toast } from '~/hooks/use-toast'; import type { Route } from './+types/_protected.send'; -export async function clientLoader() { +export async function clientLoader(): Promise<{ + initialDestination: SendInput | null; +}> { const hash = window.location.hash.slice(1); if (!hash) return { initialDestination: null }; + // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it + // See https://github.com/remix-run/remix/discussions/10721 window.history.replaceState( null, '', @@ -18,9 +22,9 @@ export async function clientLoader() { const classified = classifyInput(hash); - if (classified.type === 'unknown') return { initialDestination: null }; - // A cashu token shouldn't land here, but be defensive if it does. - if (classified.type === 'cashu-token') return { initialDestination: null }; + if (!isSendInput(classified)) { + return { initialDestination: null }; + } if (classified.type === 'bolt11') { const result = validateBolt11(classified.decoded, { @@ -37,7 +41,9 @@ export async function clientLoader() { } } - return { initialDestination: classified }; + return { + initialDestination: classified, + }; } clientLoader.hydrate = true as const; From 8fa624e83dd29fb374efb3faffd7c13f18466db5 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Sat, 25 Apr 2026 10:33:10 -0700 Subject: [PATCH 05/16] refactor(qr-scanner): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - revert(receive): use extractCashuToken directly in paste/scan handlers classifyInput is semantically a send-destination classifier and receive only handles cashu tokens, so the indirection added no value. - refactor(scan): replace isSendInput/isReceiveInput type guards with a direction field on ClassifiedInput; classifyInput returns null instead of {type:'unknown'} for unrecognized input. - refactor(send): pre-fill validation routes through selectDestination so it gets the same account-aware allowZeroAmount logic as manual entry, and so the store stays the single validation site. Fixes a latent bug where Cashu accounts + zero-amount BOLT11 invoices passed pre-fill but failed at quote creation with "Cashu accounts do not support amountless lightning invoices". - refactor(send): clientLoader is now a raw passthrough — reads hash, clears URL, returns string|null. SendProvider orchestrates the selectDestination init and toasts on failure. - rename(send): destination-validators.ts -> validation.ts. Addresses review feedback on #971. --- app/features/receive/receive-input.tsx | 8 +- app/features/receive/receive-scanner.tsx | 8 +- app/features/scan/classify-input.test.ts | 103 +++++++++--------- app/features/scan/classify-input.ts | 51 +++++---- app/features/scan/index.ts | 2 - app/features/send/send-provider.tsx | 38 +++++-- app/features/send/send-store.ts | 54 +-------- ...estination-validators.ts => validation.ts} | 0 app/routes/_protected.scan.tsx | 62 +++++------ app/routes/_protected.send.tsx | 30 +---- 10 files changed, 152 insertions(+), 204 deletions(-) rename app/features/send/{destination-validators.ts => validation.ts} (100%) diff --git a/app/features/receive/receive-input.tsx b/app/features/receive/receive-input.tsx index dd90cdbcb..82145c390 100644 --- a/app/features/receive/receive-input.tsx +++ b/app/features/receive/receive-input.tsx @@ -14,7 +14,6 @@ import { toAccountSelectorOption, } from '~/features/accounts/account-selector'; import { accountOfflineToast } from '~/features/accounts/utils'; -import { classifyInput, isReceiveInput } from '~/features/scan'; import { ConvertedMoneySwitcher } from '~/features/shared/converted-money-switcher'; import { getDefaultUnit } from '~/features/shared/currencies'; import useAnimation from '~/hooks/use-animation'; @@ -22,6 +21,7 @@ import { useMoneyInput } from '~/hooks/use-money-input'; import { useRedirectTo } from '~/hooks/use-redirect-to'; import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link'; import { useToast } from '~/hooks/use-toast'; +import { extractCashuToken } from '~/lib/cashu'; import { readClipboard } from '~/lib/read-clipboard'; import { LinkWithViewTransition, @@ -90,8 +90,8 @@ export default function ReceiveInput() { return; } - const classified = classifyInput(clipboardContent); - if (!isReceiveInput(classified)) { + const encodedToken = extractCashuToken(clipboardContent)?.encoded; + if (!encodedToken) { toast({ title: 'Invalid input', description: 'Please paste a valid cashu token', @@ -100,7 +100,7 @@ export default function ReceiveInput() { return; } - const hash = `#${classified.encoded}`; + const hash = `#${encodedToken}`; // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it // See https://github.com/remix-run/remix/discussions/10721 diff --git a/app/features/receive/receive-scanner.tsx b/app/features/receive/receive-scanner.tsx index 429ecc9ae..b6f20ac15 100644 --- a/app/features/receive/receive-scanner.tsx +++ b/app/features/receive/receive-scanner.tsx @@ -5,9 +5,9 @@ import { PageHeaderTitle, } from '~/components/page'; import { QRScanner } from '~/components/qr-scanner'; -import { classifyInput, isReceiveInput } from '~/features/scan'; import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link'; import { useToast } from '~/hooks/use-toast'; +import { extractCashuToken } from '~/lib/cashu'; import { useNavigateWithViewTransition } from '~/lib/transitions'; import { useReceiveStore } from './receive-provider'; @@ -30,8 +30,8 @@ export default function ReceiveScanner() { { - const classified = classifyInput(scannedContent); - if (!isReceiveInput(classified)) { + const encodedToken = extractCashuToken(scannedContent)?.encoded; + if (!encodedToken) { toast({ title: 'Invalid input', description: 'Please scan a valid cashu token', @@ -40,7 +40,7 @@ export default function ReceiveScanner() { return; } - const hash = `#${classified.encoded}`; + const hash = `#${encodedToken}`; // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it // See https://github.com/remix-run/remix/discussions/10721 diff --git a/app/features/scan/classify-input.test.ts b/app/features/scan/classify-input.test.ts index fc152f95f..e862cb36e 100644 --- a/app/features/scan/classify-input.test.ts +++ b/app/features/scan/classify-input.test.ts @@ -30,72 +30,78 @@ describe('classifyInput', () => { describe('cashu tokens', () => { test('cashuA token string', () => { const result = classifyInput(CASHU_A_TOKEN); - expect(result.type).toBe('cashu-token'); - if (result.type === 'cashu-token') { - expect(result.encoded).toBe(CASHU_A_TOKEN); - } + expect(result).toEqual({ + direction: 'receive', + type: 'cashu-token', + encoded: CASHU_A_TOKEN, + }); }); test('cashuB token string', () => { const result = classifyInput(CASHU_B_TOKEN); - expect(result.type).toBe('cashu-token'); - if (result.type === 'cashu-token') { - expect(result.encoded).toBe(CASHU_B_TOKEN); - } + expect(result).toEqual({ + direction: 'receive', + type: 'cashu-token', + encoded: CASHU_B_TOKEN, + }); }); test('URL containing cashu token', () => { const result = classifyInput(`https://example.com/#${CASHU_A_TOKEN}`); - expect(result.type).toBe('cashu-token'); - if (result.type === 'cashu-token') { - expect(result.encoded).toBe(CASHU_A_TOKEN); - } + expect(result).toEqual({ + direction: 'receive', + type: 'cashu-token', + encoded: CASHU_A_TOKEN, + }); }); test('token with leading/trailing whitespace', () => { const result = classifyInput(` ${CASHU_A_TOKEN} `); - expect(result.type).toBe('cashu-token'); + expect(result?.type).toBe('cashu-token'); }); test('cashu: URI prefix', () => { const result = classifyInput(`cashu:${CASHU_A_TOKEN}`); - expect(result.type).toBe('cashu-token'); - if (result.type === 'cashu-token') { - expect(result.encoded).toBe(CASHU_A_TOKEN); - } + expect(result).toEqual({ + direction: 'receive', + type: 'cashu-token', + encoded: CASHU_A_TOKEN, + }); }); }); describe('bolt11 invoices', () => { test('raw bolt11 invoice with full decoded data', () => { const result = classifyInput(BOLT11_INVOICE); - expect(result.type).toBe('bolt11'); - if (result.type === 'bolt11') { - expect(result.invoice).toBe(BOLT11_INVOICE); - expect(result.decoded).toEqual({ + expect(result).toEqual({ + direction: 'send', + type: 'bolt11', + invoice: BOLT11_INVOICE, + decoded: { amountMsat: 250000000, amountSat: 250000, + createdAtUnixMs: 1496314658000, expiryUnixMs: 1496314718000, network: 'bitcoin', description: '1 cup coffee', paymentHash: '0001020304050607080900010203040506070809000102030405060708090102', - }); - } + }, + }); }); test('lightning: prefixed invoice', () => { const result = classifyInput(`lightning:${BOLT11_INVOICE}`); - expect(result.type).toBe('bolt11'); - if (result.type === 'bolt11') { + expect(result?.type).toBe('bolt11'); + if (result?.type === 'bolt11') { expect(result.invoice).toBe(BOLT11_INVOICE); } }); test('LIGHTNING: uppercase prefix', () => { const result = classifyInput(`LIGHTNING:${BOLT11_INVOICE}`); - expect(result.type).toBe('bolt11'); - if (result.type === 'bolt11') { + expect(result?.type).toBe('bolt11'); + if (result?.type === 'bolt11') { expect(result.invoice).toBe(BOLT11_INVOICE); } }); @@ -104,52 +110,51 @@ describe('classifyInput', () => { describe('lightning addresses', () => { test('valid lightning address', () => { const result = classifyInput('user@domain.com'); - expect(result.type).toBe('ln-address'); - if (result.type === 'ln-address') { - expect(result.address).toBe('user@domain.com'); - } + expect(result).toEqual({ + direction: 'send', + type: 'ln-address', + address: 'user@domain.com', + }); }); test('uppercase address is lowercased', () => { const result = classifyInput('USER@Domain.Com'); - expect(result.type).toBe('ln-address'); - if (result.type === 'ln-address') { - expect(result.address).toBe('user@domain.com'); - } + expect(result).toEqual({ + direction: 'send', + type: 'ln-address', + address: 'user@domain.com', + }); }); test('address with subdomain', () => { const result = classifyInput('alice@pay.example.org'); - expect(result.type).toBe('ln-address'); - if (result.type === 'ln-address') { - expect(result.address).toBe('alice@pay.example.org'); - } + expect(result).toEqual({ + direction: 'send', + type: 'ln-address', + address: 'alice@pay.example.org', + }); }); }); describe('unknown inputs', () => { test('empty string', () => { - expect(classifyInput('')).toEqual({ type: 'unknown' }); + expect(classifyInput('')).toBeNull(); }); test('whitespace only', () => { - expect(classifyInput(' ')).toEqual({ type: 'unknown' }); + expect(classifyInput(' ')).toBeNull(); }); test('random gibberish', () => { - expect(classifyInput('not a valid anything')).toEqual({ - type: 'unknown', - }); + expect(classifyInput('not a valid anything')).toBeNull(); }); test('email-like but invalid TLD', () => { - expect(classifyInput('user@x')).toEqual({ type: 'unknown' }); + expect(classifyInput('user@x')).toBeNull(); }); test('bare URL without token', () => { - expect(classifyInput('https://example.com')).toEqual({ - type: 'unknown', - }); + expect(classifyInput('https://example.com')).toBeNull(); }); }); @@ -157,7 +162,7 @@ describe('classifyInput', () => { test('cashu token takes priority over everything', () => { // A string that contains both a cashu token and an @ — cashu wins const result = classifyInput(`user@domain.com ${CASHU_A_TOKEN}`); - expect(result.type).toBe('cashu-token'); + expect(result?.type).toBe('cashu-token'); }); }); }); diff --git a/app/features/scan/classify-input.ts b/app/features/scan/classify-input.ts index 41602b537..e473e3979 100644 --- a/app/features/scan/classify-input.ts +++ b/app/features/scan/classify-input.ts @@ -8,41 +8,36 @@ const validateLnAddressFormat = buildLightningAddressFormatValidator({ }); export type ClassifiedInput = - | { type: 'cashu-token'; encoded: string } - | { type: 'bolt11'; invoice: string; decoded: DecodedBolt11 } - | { type: 'ln-address'; address: string } - | { type: 'unknown' }; - -export type SendInput = Extract< - ClassifiedInput, - { type: 'bolt11' } | { type: 'ln-address' } ->; - -export type ReceiveInput = Extract; - -export const isSendInput = (input: ClassifiedInput): input is SendInput => { - return input.type === 'bolt11' || input.type === 'ln-address'; -}; - -export const isReceiveInput = ( - input: ClassifiedInput, -): input is ReceiveInput => { - return input.type === 'cashu-token'; -}; - -export function classifyInput(raw: string): ClassifiedInput { + | { direction: 'receive'; type: 'cashu-token'; encoded: string } + | { + direction: 'send'; + type: 'bolt11'; + invoice: string; + decoded: DecodedBolt11; + } + | { direction: 'send'; type: 'ln-address'; address: string }; + +export type SendInput = Extract; +export type ReceiveInput = Extract; + +export function classifyInput(raw: string): ClassifiedInput | null { const trimmed = raw.trim(); // 1. Cashu token (works on URLs, raw tokens, etc.) const cashuResult = extractCashuToken(trimmed); if (cashuResult) { - return { type: 'cashu-token', encoded: cashuResult.encoded }; + return { + direction: 'receive', + type: 'cashu-token', + encoded: cashuResult.encoded, + }; } // 2. BOLT11 invoice const bolt11Result = parseBolt11Invoice(trimmed); if (bolt11Result.valid) { return { + direction: 'send', type: 'bolt11', invoice: bolt11Result.invoice, decoded: bolt11Result.decoded, @@ -51,8 +46,12 @@ export function classifyInput(raw: string): ClassifiedInput { // 3. Lightning address if (validateLnAddressFormat(trimmed) === true) { - return { type: 'ln-address', address: trimmed.toLowerCase() }; + return { + direction: 'send', + type: 'ln-address', + address: trimmed.toLowerCase(), + }; } - return { type: 'unknown' }; + return null; } diff --git a/app/features/scan/index.ts b/app/features/scan/index.ts index d1eb72791..6d008f7a6 100644 --- a/app/features/scan/index.ts +++ b/app/features/scan/index.ts @@ -1,7 +1,5 @@ export { classifyInput, - isReceiveInput, - isSendInput, type ClassifiedInput, type ReceiveInput, type SendInput, diff --git a/app/features/send/send-provider.tsx b/app/features/send/send-provider.tsx index 2f0f97354..72765d52a 100644 --- a/app/features/send/send-provider.tsx +++ b/app/features/send/send-provider.tsx @@ -6,7 +6,7 @@ import { } from 'react'; import { useStore } from 'zustand'; import type { Account } from '~/features/accounts/account'; -import type { SendInput } from '~/features/scan'; +import { useToast } from '~/hooks/use-toast'; import { useGetAccount } from '../accounts/account-hooks'; import { useCreateCashuLightningSendQuote } from './cashu-send-quote-hooks'; import { useCreateCashuSendSwapQuote } from './cashu-send-swap-hooks'; @@ -20,11 +20,11 @@ type Props = PropsWithChildren<{ /** Usually the user's default account. This sets the initial account to send from. */ initialAccount: Account; /** - * Pre-validated destination from the route loader (e.g. QR scan or shared link). - * Used to initialize the store so send starts with destination preselected, - * instead of setting it later in an effect. + * Raw destination string from the route loader (e.g. QR scan or shared link + * passed via URL hash). Routed through `selectDestination` so it follows the + * same parsing/validation/smart-selection path as manual entry. */ - initialDestination?: SendInput | null; + initialDestination?: string | null; }>; export const SendProvider = ({ @@ -32,6 +32,7 @@ export const SendProvider = ({ initialDestination, children, }: Props) => { + const { toast } = useToast(); const { mutateAsync: getInvoiceFromLud16 } = useGetInvoiceFromLud16(); const { mutateAsync: getCashuLightningQuote } = useCreateCashuLightningSendQuote(); @@ -40,17 +41,34 @@ export const SendProvider = ({ useCreateSparkLightningSendQuote(); const getAccount = useGetAccount(); - const [store] = useState(() => - createSendStore({ + const [store] = useState(() => { + const sendStore = createSendStore({ initialAccount, - initialDestination, getAccount, getInvoiceFromLud16, getCashuLightningQuote, getCashuSwapQuote, getSparkLightningQuote, - }), - ); + }); + + if (initialDestination) { + sendStore + .getState() + .selectDestination(initialDestination) + .then((result) => { + if (!result.success) { + toast({ + title: 'Invalid destination', + description: result.error, + variant: 'destructive', + duration: 8000, + }); + } + }); + } + + return sendStore; + }); return {children}; }; diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index 1baf9d1fb..47b523d1f 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -4,7 +4,6 @@ import type { CashuAccount, SparkAccount, } from '~/features/accounts/account'; -import type { SendInput } from '~/features/scan'; import { parseBolt11Invoice } from '~/lib/bolt11'; import { parseCashuPaymentRequest } from '~/lib/cashu'; import { isValidLightningAddress } from '~/lib/lnurl'; @@ -13,11 +12,8 @@ import { type Contact, isContact } from '../contacts/contact'; import { DomainError } from '../shared/error'; import type { CashuLightningQuote } from './cashu-send-quote-service'; import type { CashuSwapQuote } from './cashu-send-swap-service'; -import { - validateBolt11, - validateLightningAddressFormat, -} from './destination-validators'; import type { SparkLightningQuote } from './spark-send-quote-service'; +import { validateBolt11, validateLightningAddressFormat } from './validation'; /** * Returns the default send type based on account type. @@ -142,7 +138,6 @@ export type SendState = State & Actions; type CreateSendStoreProps = { initialAccount: Account; - initialDestination?: SendInput | null; getAccount: (accountId: string) => Account; getInvoiceFromLud16: (params: { lud16: string; @@ -177,51 +172,14 @@ const isSendTypeSupportedForAccount = ( return supportedSendTypes[account.type].includes(sendType); }; -type InitialDestinationState = - | { - sendType: 'BOLT11_INVOICE'; - destination: string; - destinationDisplay: string; - destinationDetails?: null; - } - | { - sendType: 'LN_ADDRESS'; - destination: null; - destinationDisplay: string; - destinationDetails: { lnAddress: string }; - }; - -const sendInputToInitialState = (input: SendInput): InitialDestinationState => { - switch (input.type) { - case 'bolt11': - return { - sendType: 'BOLT11_INVOICE', - destination: input.invoice, - destinationDisplay: `${input.invoice.slice(0, 6)}...${input.invoice.slice(-4)}`, - }; - case 'ln-address': - return { - sendType: 'LN_ADDRESS', - destination: null, - destinationDisplay: input.address, - destinationDetails: { lnAddress: input.address }, - }; - } -}; - export const createSendStore = ({ initialAccount, - initialDestination, getAccount, getInvoiceFromLud16, getCashuLightningQuote, getCashuSwapQuote, getSparkLightningQuote, }: CreateSendStoreProps) => { - const resolvedInitialDestination = initialDestination - ? sendInputToInitialState(initialDestination) - : null; - return create()((set, get) => { const getOrThrow = ( key: T, @@ -234,17 +192,13 @@ export const createSendStore = ({ return value; }; - const initialDestinationFields = resolvedInitialDestination ?? { - sendType: getDefaultSendType(initialAccount.type), - destination: null, - destinationDisplay: null, - }; - return { status: 'idle' as const, amount: null, accountId: initialAccount.id, - ...initialDestinationFields, + sendType: getDefaultSendType(initialAccount.type), + destination: null, + destinationDisplay: null, quote: null, cashuToken: null, diff --git a/app/features/send/destination-validators.ts b/app/features/send/validation.ts similarity index 100% rename from app/features/send/destination-validators.ts rename to app/features/send/validation.ts diff --git a/app/routes/_protected.scan.tsx b/app/routes/_protected.scan.tsx index bf8e6ead9..0ce48f0d3 100644 --- a/app/routes/_protected.scan.tsx +++ b/app/routes/_protected.scan.tsx @@ -9,8 +9,8 @@ import { } from '~/components/page'; import { QRScanner } from '~/components/qr-scanner'; import { Button } from '~/components/ui/button'; -import { classifyInput, isReceiveInput, isSendInput } from '~/features/scan'; -import { validateBolt11 } from '~/features/send/destination-validators'; +import { classifyInput } from '~/features/scan'; +import { validateBolt11 } from '~/features/send/validation'; import { useToast } from '~/hooks/use-toast'; import { readClipboard } from '~/lib/read-clipboard'; import { useNavigateWithViewTransition } from '~/lib/transitions'; @@ -22,7 +22,17 @@ export default function ScanPage() { const handleInput = (raw: string) => { const result = classifyInput(raw); - if (isReceiveInput(result)) { + if (!result) { + toast({ + title: 'Invalid QR code', + description: + 'Please scan a cashu token, lightning invoice, or lightning address', + variant: 'destructive', + }); + return; + } + + if (result.direction === 'receive') { const hash = `#${result.encoded}`; // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it // See https://github.com/remix-run/remix/discussions/10721 @@ -34,38 +44,28 @@ export default function ScanPage() { return; } - if (isSendInput(result)) { - if (result.type === 'bolt11') { - const validation = validateBolt11(result.decoded, { - allowZeroAmount: true, + if (result.type === 'bolt11') { + const validation = validateBolt11(result.decoded, { + allowZeroAmount: true, + }); + if (!validation.valid) { + toast({ + title: 'Invalid invoice', + description: validation.error, + variant: 'destructive', }); - if (!validation.valid) { - toast({ - title: 'Invalid invoice', - description: validation.error, - variant: 'destructive', - }); - return; - } + return; } - - const hash = `#${result.type === 'bolt11' ? result.invoice : result.address}`; - // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it - // See https://github.com/remix-run/remix/discussions/10721 - window.history.replaceState(null, '', hash); - navigate( - { pathname: '/send', hash }, - { transition: 'slideLeft', applyTo: 'newView' }, - ); - return; } - toast({ - title: 'Invalid QR code', - description: - 'Please scan a cashu token, lightning invoice, or lightning address', - variant: 'destructive', - }); + const hash = `#${result.type === 'bolt11' ? result.invoice : result.address}`; + // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it + // See https://github.com/remix-run/remix/discussions/10721 + window.history.replaceState(null, '', hash); + navigate( + { pathname: '/send', hash }, + { transition: 'slideLeft', applyTo: 'newView' }, + ); }; const handlePaste = async () => { diff --git a/app/routes/_protected.send.tsx b/app/routes/_protected.send.tsx index df0a5a556..86649c53f 100644 --- a/app/routes/_protected.send.tsx +++ b/app/routes/_protected.send.tsx @@ -1,13 +1,10 @@ import { Outlet, useSearchParams } from 'react-router'; import { useAccountOrDefault } from '~/features/accounts/account-hooks'; -import { type SendInput, classifyInput, isSendInput } from '~/features/scan'; import { SendProvider } from '~/features/send'; -import { validateBolt11 } from '~/features/send/destination-validators'; -import { toast } from '~/hooks/use-toast'; import type { Route } from './+types/_protected.send'; export async function clientLoader(): Promise<{ - initialDestination: SendInput | null; + initialDestination: string | null; }> { const hash = window.location.hash.slice(1); if (!hash) return { initialDestination: null }; @@ -20,30 +17,7 @@ export async function clientLoader(): Promise<{ window.location.pathname + window.location.search, ); - const classified = classifyInput(hash); - - if (!isSendInput(classified)) { - return { initialDestination: null }; - } - - if (classified.type === 'bolt11') { - const result = validateBolt11(classified.decoded, { - allowZeroAmount: true, - }); - if (!result.valid) { - toast({ - title: 'Invalid invoice', - description: result.error, - variant: 'destructive', - duration: 8000, - }); - return { initialDestination: null }; - } - } - - return { - initialDestination: classified, - }; + return { initialDestination: hash }; } clientLoader.hydrate = true as const; From 5556ce7544f5121e44f84fcf8de38f1e061971b2 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Sat, 25 Apr 2026 11:38:25 -0700 Subject: [PATCH 06/16] =?UTF-8?q?refactor(bolt11):=20rename=20parseBolt11I?= =?UTF-8?q?nvoice=20return=20field=20invoice=20=E2=86=92=20encoded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback. The function returns the bech32-encoded invoice string (with lightning: prefix stripped); 'encoded' describes that more precisely and avoids shadowing the input parameter name. --- app/features/scan/classify-input.ts | 2 +- app/features/send/send-store.ts | 4 ++-- app/lib/bolt11/bolt11.test.ts | 6 +++--- app/lib/bolt11/index.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/features/scan/classify-input.ts b/app/features/scan/classify-input.ts index e473e3979..40536369f 100644 --- a/app/features/scan/classify-input.ts +++ b/app/features/scan/classify-input.ts @@ -39,7 +39,7 @@ export function classifyInput(raw: string): ClassifiedInput | null { return { direction: 'send', type: 'bolt11', - invoice: bolt11Result.invoice, + invoice: bolt11Result.encoded, decoded: bolt11Result.decoded, }; } diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index 47b523d1f..78c59f385 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -292,8 +292,8 @@ export const createSendStore = ({ set({ sendType: 'BOLT11_INVOICE', - destination: bolt11ParseResult.invoice, - destinationDisplay: `${bolt11ParseResult.invoice.slice(0, 6)}...${bolt11ParseResult.invoice.slice(-4)}`, + destination: bolt11ParseResult.encoded, + destinationDisplay: `${bolt11ParseResult.encoded.slice(0, 6)}...${bolt11ParseResult.encoded.slice(-4)}`, }); return { diff --git a/app/lib/bolt11/bolt11.test.ts b/app/lib/bolt11/bolt11.test.ts index 5bb6a4515..dcab57b0c 100644 --- a/app/lib/bolt11/bolt11.test.ts +++ b/app/lib/bolt11/bolt11.test.ts @@ -42,7 +42,7 @@ describe('parseBolt11Invoice', () => { const result = parseBolt11Invoice(invoice); expect(result).toEqual({ valid: true, - invoice, + encoded: invoice, decoded: expectedDecoded, }); }); @@ -51,7 +51,7 @@ describe('parseBolt11Invoice', () => { const result = parseBolt11Invoice(`lightning:${invoice}`); expect(result).toEqual({ valid: true, - invoice, + encoded: invoice, decoded: expectedDecoded, }); }); @@ -60,7 +60,7 @@ describe('parseBolt11Invoice', () => { const result = parseBolt11Invoice(`LIGHTNING:${invoice}`); expect(result).toEqual({ valid: true, - invoice, + encoded: invoice, decoded: expectedDecoded, }); }); diff --git a/app/lib/bolt11/index.ts b/app/lib/bolt11/index.ts index b29655687..9eeb59de1 100644 --- a/app/lib/bolt11/index.ts +++ b/app/lib/bolt11/index.ts @@ -69,12 +69,12 @@ export const decodeBolt11 = (invoice: string): DecodedBolt11 => { export const parseBolt11Invoice = ( invoice: string, ): - | { valid: true; invoice: string; decoded: DecodedBolt11 } + | { valid: true; encoded: string; decoded: DecodedBolt11 } | { valid: false } => { try { const cleaned = invoice.replace(/^lightning:/i, ''); const decoded = decodeBolt11(cleaned); - return { valid: true, invoice: cleaned, decoded }; + return { valid: true, encoded: cleaned, decoded }; } catch { return { valid: false }; } From 2f997be0790a25bbb4a69870b12ee3ec071a286a Mon Sep 17 00:00:00 2001 From: gudnuf Date: Sat, 25 Apr 2026 11:54:16 -0700 Subject: [PATCH 07/16] revert(lnurl): roll back lightning address case normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validator's mixed-case rejection isn't reachable from any UI entry point — send-input.tsx and the QR scanner both lowercase before calling the validator — so the normalization in 41923900 didn't fix any observable bug. Reverting to keep the diff minimal. --- app/lib/lnurl/index.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/lib/lnurl/index.ts b/app/lib/lnurl/index.ts index a76ab29dc..70339265c 100644 --- a/app/lib/lnurl/index.ts +++ b/app/lib/lnurl/index.ts @@ -113,13 +113,10 @@ export const buildLightningAddressFormatValidator = ({ return false; } - // Normalize to lowercase — LN addresses are case-insensitive per LUD-16 - const normalized = value.toLowerCase(); - // Handle localhost case if allowed if (allowLocalhost) { - const localhostRegex = /^[a-z0-9._%+-]+@localhost(:\d+)?$/; - if (localhostRegex.test(normalized)) { + const localhostRegex = /^[a-zA-Z0-9._%+-]+@localhost(:\d+)?$/; + if (localhostRegex.test(value)) { return true; } } @@ -127,7 +124,7 @@ export const buildLightningAddressFormatValidator = ({ // Lightning address format is described here https://datatracker.ietf.org/doc/html/rfc5322#section-3.4.1 // Split into local part and domain - const [localPart, domain] = normalized.split('@'); + const [localPart, domain] = value.split('@'); // Check if we have both parts if (!localPart || !domain) { From e25f86110c16beceb600e2e4922958d1aee020ba Mon Sep 17 00:00:00 2001 From: gudnuf Date: Sat, 25 Apr 2026 12:02:01 -0700 Subject: [PATCH 08/16] refactor(bolt11): clean and lowercase inside decodeBolt11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the lightning: prefix strip and lowercase normalization into decodeBolt11 — bech32 requires uniform case and the lightning: prefix is case-insensitive per BIP21. The cleaned bech32 string is returned alongside the decoded fields. parseBolt11Invoice destructures the result so callers read result.encoded for the canonical form and result.decoded for the extracted fields, with no nested 'decoded.encoded' awkwardness. Also lowercase before lightning-address validation in classify-input, since the validator's local-part regex only accepts lowercase. --- app/features/scan/classify-input.ts | 8 +++--- app/features/send/send-store.ts | 5 ++-- app/lib/bolt11/bolt11.test.ts | 42 ++++++++++++++++++++--------- app/lib/bolt11/index.ts | 24 ++++++++++------- 4 files changed, 52 insertions(+), 27 deletions(-) diff --git a/app/features/scan/classify-input.ts b/app/features/scan/classify-input.ts index 40536369f..edbc1141a 100644 --- a/app/features/scan/classify-input.ts +++ b/app/features/scan/classify-input.ts @@ -44,12 +44,14 @@ export function classifyInput(raw: string): ClassifiedInput | null { }; } - // 3. Lightning address - if (validateLnAddressFormat(trimmed) === true) { + // 3. Lightning address — lowercase before validation since the format + // validator's local-part regex only accepts lowercase characters. + const lowered = trimmed.toLowerCase(); + if (validateLnAddressFormat(lowered) === true) { return { direction: 'send', type: 'ln-address', - address: trimmed.toLowerCase(), + address: lowered, }; } diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index 78c59f385..e2ddfa270 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -290,10 +290,11 @@ export const createSendStore = ({ return { success: false, error: result.error }; } + const { encoded } = bolt11ParseResult; set({ sendType: 'BOLT11_INVOICE', - destination: bolt11ParseResult.encoded, - destinationDisplay: `${bolt11ParseResult.encoded.slice(0, 6)}...${bolt11ParseResult.encoded.slice(-4)}`, + destination: encoded, + destinationDisplay: `${encoded.slice(0, 6)}...${encoded.slice(-4)}`, }); return { diff --git a/app/lib/bolt11/bolt11.test.ts b/app/lib/bolt11/bolt11.test.ts index dcab57b0c..2b9687e73 100644 --- a/app/lib/bolt11/bolt11.test.ts +++ b/app/lib/bolt11/bolt11.test.ts @@ -20,11 +20,36 @@ const expectedDecoded = { // NOTE: You can confirm these values using https://lightningdecoder.com describe('decodeBolt11', () => { it('should decode the invoice', () => { - expect(decodeBolt11(invoice)).toEqual(expectedDecoded); + expect(decodeBolt11(invoice)).toEqual({ + encoded: invoice, + ...expectedDecoded, + }); + }); + + it('should strip lightning: prefix', () => { + expect(decodeBolt11(`lightning:${invoice}`)).toEqual({ + encoded: invoice, + ...expectedDecoded, + }); + }); + + it('should strip LIGHTNING: prefix case-insensitively', () => { + expect(decodeBolt11(`LIGHTNING:${invoice}`)).toEqual({ + encoded: invoice, + ...expectedDecoded, + }); + }); + + it('should lowercase an uppercase invoice', () => { + expect(decodeBolt11(invoice.toUpperCase())).toEqual({ + encoded: invoice, + ...expectedDecoded, + }); }); it('should decode a testnet invoice', () => { expect(decodeBolt11(testnetInvoice)).toEqual({ + encoded: testnetInvoice, amountMsat: 2000000000, amountSat: 2000000, createdAtUnixMs: 1496314658000, @@ -38,7 +63,7 @@ describe('decodeBolt11', () => { }); describe('parseBolt11Invoice', () => { - it('should parse a raw invoice and return cleaned invoice string', () => { + it('should parse a raw invoice', () => { const result = parseBolt11Invoice(invoice); expect(result).toEqual({ valid: true, @@ -47,17 +72,8 @@ describe('parseBolt11Invoice', () => { }); }); - it('should strip lightning: prefix and return cleaned invoice', () => { - const result = parseBolt11Invoice(`lightning:${invoice}`); - expect(result).toEqual({ - valid: true, - encoded: invoice, - decoded: expectedDecoded, - }); - }); - - it('should strip LIGHTNING: uppercase prefix', () => { - const result = parseBolt11Invoice(`LIGHTNING:${invoice}`); + it('should strip lightning: prefix and lowercase', () => { + const result = parseBolt11Invoice(`LIGHTNING:${invoice.toUpperCase()}`); expect(result).toEqual({ valid: true, encoded: invoice, diff --git a/app/lib/bolt11/index.ts b/app/lib/bolt11/index.ts index 9eeb59de1..1f7588770 100644 --- a/app/lib/bolt11/index.ts +++ b/app/lib/bolt11/index.ts @@ -15,11 +15,17 @@ export type DecodedBolt11 = { }; /** - * Decodes a BOLT11 invoice - * @param invoice invoice to decode + * Decodes a BOLT11 invoice. Strips an optional `lightning:` prefix + * (case-insensitive per BIP21) and lowercases the result — bech32 requires + * uniform case, and lowercase is the canonical form. The cleaned bech32 + * string is returned alongside the decoded fields. + * @param invoice invoice to decode (accepts optional lightning: prefix, case-insensitive) */ -export const decodeBolt11 = (invoice: string): DecodedBolt11 => { - const { sections } = bolt11Decoder.decode(invoice); +export const decodeBolt11 = ( + invoice: string, +): DecodedBolt11 & { encoded: string } => { + const encoded = invoice.replace(/^lightning:/i, '').toLowerCase(); + const { sections } = bolt11Decoder.decode(encoded); const amountSection = findSection(sections, 'amount'); const amountMsat = amountSection?.value @@ -51,6 +57,7 @@ export const decodeBolt11 = (invoice: string): DecodedBolt11 => { } return { + encoded, amountMsat, amountSat, createdAtUnixMs, @@ -62,8 +69,8 @@ export const decodeBolt11 = (invoice: string): DecodedBolt11 => { }; /** - * Checks if a string is a valid BOLT11 invoice. - * Returns the cleaned invoice (without lightning: prefix) and decoded data on success. + * Checks if a string is a valid BOLT11 invoice. Returns the cleaned bech32 + * form and the decoded fields on success. * @param invoice invoice to check (accepts optional lightning: prefix, case-insensitive) */ export const parseBolt11Invoice = ( @@ -72,9 +79,8 @@ export const parseBolt11Invoice = ( | { valid: true; encoded: string; decoded: DecodedBolt11 } | { valid: false } => { try { - const cleaned = invoice.replace(/^lightning:/i, ''); - const decoded = decodeBolt11(cleaned); - return { valid: true, encoded: cleaned, decoded }; + const { encoded, ...decoded } = decodeBolt11(invoice); + return { valid: true, encoded, decoded }; } catch { return { valid: false }; } From 21c65834c25b6cf9fa92d8746f716bb92bca661a Mon Sep 17 00:00:00 2001 From: gudnuf Date: Sat, 25 Apr 2026 12:13:02 -0700 Subject: [PATCH 09/16] chore: PR cleanup - Tighten initialDestination prop doc comment - Remove smart-source-selection design spec (deferred to follow-up PR) --- app/features/send/send-provider.tsx | 6 +- ...1-smart-source-account-selection-design.md | 167 ------------------ 2 files changed, 1 insertion(+), 172 deletions(-) delete mode 100644 docs/superpowers/specs/2026-04-21-smart-source-account-selection-design.md diff --git a/app/features/send/send-provider.tsx b/app/features/send/send-provider.tsx index 72765d52a..08a303afa 100644 --- a/app/features/send/send-provider.tsx +++ b/app/features/send/send-provider.tsx @@ -19,11 +19,7 @@ const SendContext = createContext(null); type Props = PropsWithChildren<{ /** Usually the user's default account. This sets the initial account to send from. */ initialAccount: Account; - /** - * Raw destination string from the route loader (e.g. QR scan or shared link - * passed via URL hash). Routed through `selectDestination` so it follows the - * same parsing/validation/smart-selection path as manual entry. - */ + /** Raw destination string used to initialize the store; parsed via `selectDestination`. */ initialDestination?: string | null; }>; diff --git a/docs/superpowers/specs/2026-04-21-smart-source-account-selection-design.md b/docs/superpowers/specs/2026-04-21-smart-source-account-selection-design.md deleted file mode 100644 index a3a1b5461..000000000 --- a/docs/superpowers/specs/2026-04-21-smart-source-account-selection-design.md +++ /dev/null @@ -1,167 +0,0 @@ -# Smart Source Account Selection for BOLT11 — Design - -**Status:** Draft -**Date:** 2026-04-21 -**Branch:** `feat/universal-qr-scanner` (builds on the universal QR scanner work) -**Author:** gudnuf (+ Claude) - -## Motivation - -The universal QR scanner (commit `5d7d62ff`) classifies pasted and camera input and routes BOLT11 invoices to `/send`, but the send flow always initializes with the user's default account. When a scanned invoice was generated by a mint the user already has an account at, the send should prefer that same-mint account — the mint can often route the payment internally with zero or minimal fees. - -Many Cashu mints embed a fixed string in the BOLT11 `description` field of the invoices they generate (e.g. `"Minibits"`). When we recognize that string, we can infer which mint created the invoice and, if the user has a matching BTC account at that mint with sufficient balance, preselect it as the source. - -## Goals - -1. When a scanned or pasted BOLT11's `description` matches a configured mint, preselect the user's BTC cashu account at that mint as the send source. -2. Apply the same selection rule at all three BOLT11 entry points: `/scan` → `/send` navigation, `/send` `clientLoader` (URL hash / deep link), and `selectDestination` inside the send store (manual paste). -3. Keep the rule a pure, testable function with no hook dependencies. -4. Fail safe — if anything in the lookup breaks, fall back silently to the default account. -5. Validate the env var configuration at build time so misconfiguration can't reach production. - -## Non-goals - -- LN-address smart selection. LN addresses resolve asynchronously; applying the same rule there is a future extension. -- USD accounts. Scope restricted to BTC cashu accounts for this pass — USD→BTC conversion via NUT-05 adds exchange-rate complexity. -- Adding/removing mints based on scanned invoices. If the user doesn't have an account at the matched mint, we fall back to the default — we never prompt or silently onboard a mint. -- Runtime-editable configuration (no DB table, no admin UI). The data is essentially static. -- Dynamic detection from `/v1/info` mint name. Considered and rejected for this pass — explicit env config is simpler and deterministic. - -## Rule - -For a classified BOLT11: - -``` -if description is set - and description ∈ configured map - and user has a BTC cashu account at the mapped mintUrl - and invoice has a non-null amount - and account balance ≥ invoice amount -then use that account as the send source -else use the user's default account -``` - -Zero-amount invoices never match — we can't balance-check. The user will pick a source manually on the send page. - -Balance comparison uses invoice amount with **no fee-reserve buffer**. A small chance exists that the matched account has just enough to cover the invoice but not the melt fee — the existing send flow surfaces an error at confirm-time in that edge case. This is acceptable for an optimization path. - -## Components - -### 1. Config schema and env var - -**New file:** `app/features/send/mint-description-config.ts` - -```ts -import { z } from 'zod'; - -export const MintDescriptionMapSchema = z.record(z.string(), z.string().url()); -export type MintDescriptionMap = z.infer; - -// Used by vite.config.ts to validate the raw env var string at build time. -export const JsonMintDescriptionMapSchema = z - .string() - .transform((s) => JSON.parse(s)) - .pipe(MintDescriptionMapSchema); -``` - -**Env var:** `VITE_MINT_DESCRIPTION_MAP` — JSON string mapping description → mintUrl, e.g. - -```json -{"Minibits":"https://mint.minibits.cash/Bitcoin"} -``` - -Keys are exact BOLT11 description strings. Values are mint URLs (must be valid URLs per schema). - -**Build-time validation:** `vite.config.ts#validateEnv()` imports `JsonMintDescriptionMapSchema` and parses `VITE_MINT_DESCRIPTION_MAP` when present, mirroring the existing `VITE_GIFT_CARDS` pattern. Malformed JSON or a value that isn't a URL fails the build. - -### 2. Pure selector - -**New file:** `app/features/send/smart-source-selection.ts` - -```ts -// Imports: `DecodedBolt11` from `~/lib/bolt11`, -// `Account` and `ExtendedAccount` from `~/features/accounts/account`. - -type SmartSelectionInput = { - decoded: DecodedBolt11; - accounts: ExtendedAccount<'cashu'>[]; // caller passes BTC cashu accounts - defaultAccount: Account; -}; - -function selectSourceAccountForBolt11( - input: SmartSelectionInput, -): Account; -``` - -Parses `VITE_MINT_DESCRIPTION_MAP` once at module load (safe to throw; build pre-validated). Exposes the selection logic as a pure synchronous function. - -**Decision branches** (evaluated top to bottom; first mismatch returns `defaultAccount`): - -1. `decoded.description` is set -2. Lookup in the parsed map returns a `mintUrl` -3. `accounts.find(a => a.mintUrl === mintUrl)` returns a match -4. `decoded.amount` is non-null -5. Matched account's balance ≥ `decoded.amount` -6. Return the matched account - -### 3. Call sites - -All three sites pass the pre-filtered BTC cashu account list and the user's default account into `selectSourceAccountForBolt11`. - -**a) `/send` `clientLoader`** (`app/routes/_protected.send.tsx`) - -After `classifyInput`, for `type === 'bolt11'`, read the accounts cache directly (loader has no hooks). Call the selector. Return `{ initialDestination, initialAccountId }` from the loader. `SendLayout` passes `initialAccountId` to the send provider — overrides the `accountId` query param for this flow. - -**b) `/scan` navigate handler** — *no changes required.* The scan route already navigates to `/send#`, and the `/send` loader runs the selector for every BOLT11 it parses. One entry point for scan + deep-link coverage. - -**c) `selectDestination` in `send-store`** (`app/features/send/send-store.ts`) - -In the BOLT11 branch, after validation, call the selector with `deps.getAccounts()` + `deps.getDefaultAccount()`. Update `state.accountId` to the selected account. This only runs when the user selects a new destination — a user who manually switches accounts *and then* stays on the same destination will not be stomped, because `selectDestination` is not re-invoked without a new input. If the user pastes a new BOLT11 after changing the account, smart selection re-applies for the new destination (desirable: new input, new best-source decision). - -### 4. Tests - -**New:** `app/features/send/smart-source-selection.test.ts` — pure function, no mocks. One test per decision branch: - -1. Description is missing → default -2. Description is set but unmapped → default -3. Description maps to a mint the user has no BTC account at → default -4. Matched mint + account, but invoice amount is null (zero-amount) → default -5. Matched mint + account, but balance < invoice amount → default -6. Matched mint + account + balance ≥ invoice amount → matched account - -## File changes summary - -| File | Change | -|---|---| -| `app/features/send/mint-description-config.ts` | **new** — schema + build-time-safe parse of `VITE_MINT_DESCRIPTION_MAP` | -| `app/features/send/smart-source-selection.ts` | **new** — pure selector function | -| `app/features/send/smart-source-selection.test.ts` | **new** — unit tests | -| `vite.config.ts` | `validateEnv()` also validates `VITE_MINT_DESCRIPTION_MAP` | -| `app/routes/_protected.send.tsx` | loader calls selector for `bolt11`, returns `initialAccountId` | -| `app/routes/_protected.scan.tsx` | *no changes* — selection runs in the `/send` loader | -| `app/features/send/send-store.ts` | `selectDestination` BOLT11 branch calls selector + updates `accountId` | -| `app/features/send/send-provider.tsx` | threads `initialAccountId` into `createSendStore` | -| `.env.example` | document `VITE_MINT_DESCRIPTION_MAP` | - -## Edge cases and failure modes - -| Case | Behavior | -|---|---| -| Env var missing or empty | Empty map, selector always returns default. No warning. | -| Env var malformed | Build fails. Cannot ship misconfigured. | -| Invoice has no description | Default account. | -| Description doesn't match any mapped key | Default account. | -| Matched mint but user has no BTC account at it | Default account. | -| Matched account exists but balance < invoice amount | Default account. | -| Zero-amount invoice | Default account (can't balance-check). | -| Matched account balance covers invoice but not fee reserve | Selected anyway; error surfaces at melt-quote confirmation (existing path). | -| Two accounts at the same mintUrl (shouldn't happen for BTC, but) | First match — deterministic, no UX impact given currency narrows to BTC. | - -No toasts, no warnings to the user. Smart selection is an optimization — invisible when it works, indistinguishable from current flow when it doesn't. - -## Out of scope / future - -- LN-address smart selection (requires async LNURL resolution before selection). -- USD account selection for BOLT11 (requires exchange-rate awareness and NUT-05 USD→BTC path). -- Auto-detecting mint identity from `/v1/info` (eliminates config but adds async fetching per scan). -- Admin-managed / DB-backed mint description table. From 0e25ab30b006ab1247c493db06816bb118aa2d1a Mon Sep 17 00:00:00 2001 From: gudnuf Date: Sun, 26 Apr 2026 09:21:52 -0700 Subject: [PATCH 10/16] fix(send): set amount on store from bolt11 destination When SendProvider initialized the store via selectDestination on a hash destination (QR scan path), the invoice's amount was returned in the result but never written to the store, so the input field initialized to 0. Set it on the store so useMoneyInput's mount-time read picks it up. Skip when the invoice has no amount to preserve user-typed values for zero-amount invoices. --- app/features/send/send-store.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index e2ddfa270..175771ee9 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -295,6 +295,9 @@ export const createSendStore = ({ sendType: 'BOLT11_INVOICE', destination: encoded, destinationDisplay: `${encoded.slice(0, 6)}...${encoded.slice(-4)}`, + // Only set when the invoice carries an amount; for zero-amount + // invoices, preserve whatever the user had typed. + ...(result.amount && { amount: result.amount }), }); return { From 7d9ce7fcc686e8162b15267efd19096de02716d7 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 27 Apr 2026 10:34:14 -0700 Subject: [PATCH 11/16] refactor(bolt11): split decodeBolt11 return into { encoded, decoded } Per review feedback, callers can now take the decoded object directly instead of destructuring around an additional `encoded` property. --- .../receive/cashu-receive-quote-core.ts | 4 ++- app/features/send/cashu-send-quote-service.ts | 4 ++- app/features/send/send-confirmation.tsx | 4 ++- app/lib/bolt11/bolt11.test.ts | 26 ++++++++++--------- app/lib/bolt11/index.ts | 20 +++++++------- 5 files changed, 34 insertions(+), 24 deletions(-) diff --git a/app/features/receive/cashu-receive-quote-core.ts b/app/features/receive/cashu-receive-quote-core.ts index 2f8e5b6f3..e8478990e 100644 --- a/app/features/receive/cashu-receive-quote-core.ts +++ b/app/features/receive/cashu-receive-quote-core.ts @@ -281,7 +281,9 @@ export async function getLightningQuote( }) : undefined; - const { paymentHash } = decodeBolt11(mintQuoteResponse.request); + const { + decoded: { paymentHash }, + } = decodeBolt11(mintQuoteResponse.request); return { mintQuote: mintQuoteResponse, diff --git a/app/features/send/cashu-send-quote-service.ts b/app/features/send/cashu-send-quote-service.ts index 7ff407c17..24f6881c6 100644 --- a/app/features/send/cashu-send-quote-service.ts +++ b/app/features/send/cashu-send-quote-service.ts @@ -296,7 +296,9 @@ export class CashuSendQuoteService { unit: cashuUnit, }); - const { paymentHash } = decodeBolt11(sendQuote.paymentRequest); + const { + decoded: { paymentHash }, + } = decodeBolt11(sendQuote.paymentRequest); return this.cashuSendRepository.create({ userId: userId, diff --git a/app/features/send/send-confirmation.tsx b/app/features/send/send-confirmation.tsx index e67aa8674..796a1cc89 100644 --- a/app/features/send/send-confirmation.tsx +++ b/app/features/send/send-confirmation.tsx @@ -234,7 +234,9 @@ export const PayBolt11Confirmation = ({ destinationDetails, }); - const { description } = decodeBolt11(destination); + const { + decoded: { description }, + } = decodeBolt11(destination); return ( { it('should decode the invoice', () => { expect(decodeBolt11(invoice)).toEqual({ encoded: invoice, - ...expectedDecoded, + decoded: expectedDecoded, }); }); it('should strip lightning: prefix', () => { expect(decodeBolt11(`lightning:${invoice}`)).toEqual({ encoded: invoice, - ...expectedDecoded, + decoded: expectedDecoded, }); }); it('should strip LIGHTNING: prefix case-insensitively', () => { expect(decodeBolt11(`LIGHTNING:${invoice}`)).toEqual({ encoded: invoice, - ...expectedDecoded, + decoded: expectedDecoded, }); }); it('should lowercase an uppercase invoice', () => { expect(decodeBolt11(invoice.toUpperCase())).toEqual({ encoded: invoice, - ...expectedDecoded, + decoded: expectedDecoded, }); }); it('should decode a testnet invoice', () => { expect(decodeBolt11(testnetInvoice)).toEqual({ encoded: testnetInvoice, - amountMsat: 2000000000, - amountSat: 2000000, - createdAtUnixMs: 1496314658000, - expiryUnixMs: 1496318258000, - network: 'testnet', - description: undefined, - paymentHash: - '0001020304050607080900010203040506070809000102030405060708090102', + decoded: { + amountMsat: 2000000000, + amountSat: 2000000, + createdAtUnixMs: 1496314658000, + expiryUnixMs: 1496318258000, + network: 'testnet', + description: undefined, + paymentHash: + '0001020304050607080900010203040506070809000102030405060708090102', + }, }); }); }); diff --git a/app/lib/bolt11/index.ts b/app/lib/bolt11/index.ts index 1f7588770..f8dc110b9 100644 --- a/app/lib/bolt11/index.ts +++ b/app/lib/bolt11/index.ts @@ -23,7 +23,7 @@ export type DecodedBolt11 = { */ export const decodeBolt11 = ( invoice: string, -): DecodedBolt11 & { encoded: string } => { +): { encoded: string; decoded: DecodedBolt11 } => { const encoded = invoice.replace(/^lightning:/i, '').toLowerCase(); const { sections } = bolt11Decoder.decode(encoded); @@ -58,13 +58,15 @@ export const decodeBolt11 = ( return { encoded, - amountMsat, - amountSat, - createdAtUnixMs, - expiryUnixMs, - network, - description, - paymentHash, + decoded: { + amountMsat, + amountSat, + createdAtUnixMs, + expiryUnixMs, + network, + description, + paymentHash, + }, }; }; @@ -79,7 +81,7 @@ export const parseBolt11Invoice = ( | { valid: true; encoded: string; decoded: DecodedBolt11 } | { valid: false } => { try { - const { encoded, ...decoded } = decodeBolt11(invoice); + const { encoded, decoded } = decodeBolt11(invoice); return { valid: true, encoded, decoded }; } catch { return { valid: false }; From 9eed8829f3e7d1b4626fbc1d4f08a346dd8470c0 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 27 Apr 2026 10:34:29 -0700 Subject: [PATCH 12/16] fix(send): correct comment on hash-clearing in send clientLoader The previous comment described the write-side (scan page setting hash before navigation), not the read-side (clearing hash after consuming it). Per review feedback. --- app/routes/_protected.send.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routes/_protected.send.tsx b/app/routes/_protected.send.tsx index 86649c53f..be4dd2e35 100644 --- a/app/routes/_protected.send.tsx +++ b/app/routes/_protected.send.tsx @@ -9,8 +9,8 @@ export async function clientLoader(): Promise<{ const hash = window.location.hash.slice(1); if (!hash) return { initialDestination: null }; - // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it - // See https://github.com/remix-run/remix/discussions/10721 + // Strip the hash from the URL after reading it so refreshes / back-navigation + // don't re-apply the destination. window.history.replaceState( null, '', From edabe4e568c8b4e75a3f2528b2f74b3e37348027 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 27 Apr 2026 10:46:38 -0700 Subject: [PATCH 13/16] fix(send): only seed amount from bolt11 if not already set Preserves a user-typed amount when a destination is selected, instead of overriding it with the invoice amount. --- app/features/send/send-store.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index 175771ee9..b94d15aa9 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -295,9 +295,7 @@ export const createSendStore = ({ sendType: 'BOLT11_INVOICE', destination: encoded, destinationDisplay: `${encoded.slice(0, 6)}...${encoded.slice(-4)}`, - // Only set when the invoice carries an amount; for zero-amount - // invoices, preserve whatever the user had typed. - ...(result.amount && { amount: result.amount }), + ...(get().amount === null && { amount: result.amount }), }); return { From 5c6c455d8de10838266a55985f8849e9b2240dac Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 27 Apr 2026 11:03:31 -0700 Subject: [PATCH 14/16] refactor(send): move bolt11 amount seeding from selectDestination to provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectDestination no longer touches the store's amount field. The scanner-driven path (SendProvider mount) now sets amount explicitly after resolution, since SendInput will read from the store on its initial render. The manual paste path doesn't need a store update — SendInput is already mounted and updates its input from the return value directly. --- app/features/send/send-provider.tsx | 4 ++++ app/features/send/send-store.ts | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/features/send/send-provider.tsx b/app/features/send/send-provider.tsx index 08a303afa..58cd1057b 100644 --- a/app/features/send/send-provider.tsx +++ b/app/features/send/send-provider.tsx @@ -59,6 +59,10 @@ export const SendProvider = ({ variant: 'destructive', duration: 8000, }); + return; + } + if (result.data.type === 'BOLT11_INVOICE' && result.data.amount) { + sendStore.setState({ amount: result.data.amount }); } }); } diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index b94d15aa9..e2ddfa270 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -295,7 +295,6 @@ export const createSendStore = ({ sendType: 'BOLT11_INVOICE', destination: encoded, destinationDisplay: `${encoded.slice(0, 6)}...${encoded.slice(-4)}`, - ...(get().amount === null && { amount: result.amount }), }); return { From 17e397287af75c9c575b195093b25f7df47a4c12 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 27 Apr 2026 11:16:51 -0700 Subject: [PATCH 15/16] fix(qr-scanner): clip scan-region box-shadow at desktop sizes The scan-region overlay uses a 9999px box-shadow as a dimming vignette. On mobile the scanner section is fixed-fullscreen so the shadow has nowhere to escape, but on desktop it's a 400x400 relative box and the shadow leaks across the full viewport, dimming the page header text and the paste button. Adding sm:overflow-hidden clips it. --- app/components/qr-scanner/qr-scanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/qr-scanner/qr-scanner.tsx b/app/components/qr-scanner/qr-scanner.tsx index 30cb32714..b65706925 100644 --- a/app/components/qr-scanner/qr-scanner.tsx +++ b/app/components/qr-scanner/qr-scanner.tsx @@ -144,7 +144,7 @@ export const QRScanner = ({ onDecode }: QRScannerProps) => { }, [throttledDecode]); return ( -
+
{cameraError ? (
From a31c7f3b023c49991c3950beffafe6b308902d5b Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 21 Apr 2026 16:20:02 -0700 Subject: [PATCH 16/16] feat: smart source account selection for BOLT11 sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a scanned/pasted BOLT11's description matches a configured mint URL, preselect the user's cashu account at that mint as the send source if the balance covers the invoice. Falls back to the user's default (loader path) or current account (manual paste) otherwise. Configuration via VITE_MINT_DESCRIPTION_MAP (description → mint URL JSON map), validated at build time. Selector is a pure function; USD candidates use the BTC-USD exchange rate for balance comparison and are skipped if rate is unavailable. Zero-amount invoices and unmatched descriptions silently fall through to the existing default-account behavior. --- .env.example | 5 +- app/features/send/mint-description-config.ts | 19 ++ .../send/pick-account-for-destination.test.ts | 179 ++++++++++++++++++ .../send/pick-account-for-destination.ts | 66 +++++++ app/features/send/send-provider.tsx | 5 +- app/features/send/send-store.ts | 10 + vite.config.ts | 4 + 7 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 app/features/send/mint-description-config.ts create mode 100644 app/features/send/pick-account-for-destination.test.ts create mode 100644 app/features/send/pick-account-for-destination.ts diff --git a/.env.example b/.env.example index 686c851ee..758e29e85 100644 --- a/.env.example +++ b/.env.example @@ -26,4 +26,7 @@ VITE_BREEZ_API_KEY='MIIBaTCCARugAwIBAgIHPxIwQzGEUzAFBgMrZXAwEDEOMAwGA1UEAxMFQnJl # Event system (webhook from Supabase triggers) WEBHOOK_SECRET=dev-webhook-secret RESEND_API_KEY= -RESEND_WELCOME_TEMPLATE_ID=51320094-3fc9-416f-9c4a-de4fff0fc5e2 \ No newline at end of file +RESEND_WELCOME_TEMPLATE_ID=51320094-3fc9-416f-9c4a-de4fff0fc5e2 +# Map of invoice description -> mint URL for smart source-account selection on bolt11 sends. +# Example: {"Minibits":"https://mint.minibits.cash/Bitcoin"} +VITE_MINT_DESCRIPTION_MAP= diff --git a/app/features/send/mint-description-config.ts b/app/features/send/mint-description-config.ts new file mode 100644 index 000000000..370176a0f --- /dev/null +++ b/app/features/send/mint-description-config.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +/** + * Mapping of mint description strings to mint URLs. + * + * @example + * ```json + * { + * "Minibits": "https://mint.minibits.cash/Bitcoin" + * } + * ``` + */ +export const MintDescriptionMapSchema = z.record(z.string(), z.url()); +export type MintDescriptionMap = z.infer; + +export const JsonMintDescriptionMapSchema = z + .string() + .transform((str) => JSON.parse(str)) + .pipe(MintDescriptionMapSchema); diff --git a/app/features/send/pick-account-for-destination.test.ts b/app/features/send/pick-account-for-destination.test.ts new file mode 100644 index 000000000..c351f3be0 --- /dev/null +++ b/app/features/send/pick-account-for-destination.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from 'bun:test'; +import type { Account, CashuAccount } from '~/features/accounts/account'; +import type { DecodedBolt11 } from '~/lib/bolt11'; +import { pickAccountForDestination } from './pick-account-for-destination'; + +const MINT_URL = 'https://mint.minibits.cash/Bitcoin'; +const MAP = { Minibits: MINT_URL }; + +const buildBolt11 = (overrides: Partial): DecodedBolt11 => ({ + amountMsat: 1_000_000, + amountSat: 1000, + createdAtUnixMs: 1_700_000_000_000, + expiryUnixMs: 1_700_000_003_600_000, + network: 'bitcoin', + description: undefined, + paymentHash: 'deadbeef', + ...overrides, +}); + +const buildCashuAccount = ( + mintUrl: string, + currency: 'BTC' | 'USD', + balance: number, +): CashuAccount => + ({ + id: `cashu-${mintUrl}-${currency}`, + name: `test-${currency}`, + type: 'cashu', + purpose: 'transactional', + state: 'active', + isOnline: true, + currency, + createdAt: '2026-01-01T00:00:00Z', + version: 1, + expiresAt: null, + mintUrl, + isTestMint: false, + keysetCounters: {}, + proofs: balance > 0 ? [{ amount: balance }] : [], + wallet: {} as never, + }) as unknown as CashuAccount; + +const DEFAULT_ACCOUNT: Account = { + id: 'default-account', + name: 'default', + type: 'spark', +} as unknown as Account; + +describe('pickAccountForDestination', () => { + test('returns default when description is missing', () => { + const result = pickAccountForDestination({ + decodedDestination: buildBolt11({ description: undefined }), + accounts: [buildCashuAccount(MINT_URL, 'BTC', 5000)], + defaultAccount: DEFAULT_ACCOUNT, + mintDescriptionMap: MAP, + }); + expect(result).toBe(DEFAULT_ACCOUNT); + }); + + test('returns default when description is set but unmapped', () => { + const result = pickAccountForDestination({ + decodedDestination: buildBolt11({ description: 'Some other mint' }), + accounts: [buildCashuAccount(MINT_URL, 'BTC', 5000)], + defaultAccount: DEFAULT_ACCOUNT, + mintDescriptionMap: MAP, + }); + expect(result).toBe(DEFAULT_ACCOUNT); + }); + + test('returns default when description maps to a mint user has no account at', () => { + const result = pickAccountForDestination({ + decodedDestination: buildBolt11({ description: 'Minibits' }), + accounts: [ + buildCashuAccount('https://mint.other.example/Bitcoin', 'BTC', 5000), + ], + defaultAccount: DEFAULT_ACCOUNT, + mintDescriptionMap: MAP, + }); + expect(result).toBe(DEFAULT_ACCOUNT); + }); + + test('returns default for zero-amount invoice (amountSat undefined)', () => { + const result = pickAccountForDestination({ + decodedDestination: buildBolt11({ + description: 'Minibits', + amountSat: undefined, + amountMsat: undefined, + }), + accounts: [buildCashuAccount(MINT_URL, 'BTC', 5000)], + defaultAccount: DEFAULT_ACCOUNT, + mintDescriptionMap: MAP, + }); + expect(result).toBe(DEFAULT_ACCOUNT); + }); + + test('returns BTC account when balance covers invoice', () => { + const matched = buildCashuAccount(MINT_URL, 'BTC', 5000); + const result = pickAccountForDestination({ + decodedDestination: buildBolt11({ + description: 'Minibits', + amountSat: 1000, + }), + accounts: [matched], + defaultAccount: DEFAULT_ACCOUNT, + mintDescriptionMap: MAP, + }); + expect(result).toBe(matched); + }); + + test('ignores non-cashu accounts in the input list', () => { + const matched = buildCashuAccount(MINT_URL, 'BTC', 5000); + const result = pickAccountForDestination({ + decodedDestination: buildBolt11({ + description: 'Minibits', + amountSat: 1000, + }), + accounts: [DEFAULT_ACCOUNT, matched], + defaultAccount: DEFAULT_ACCOUNT, + mintDescriptionMap: MAP, + }); + expect(result).toBe(matched); + }); + + test('returns BTC account when balance exactly equals invoice', () => { + const matched = buildCashuAccount(MINT_URL, 'BTC', 1000); + const result = pickAccountForDestination({ + decodedDestination: buildBolt11({ + description: 'Minibits', + amountSat: 1000, + }), + accounts: [matched], + defaultAccount: DEFAULT_ACCOUNT, + mintDescriptionMap: MAP, + }); + expect(result).toBe(matched); + }); + + test('returns default when only candidate is BTC and balance is below invoice', () => { + const result = pickAccountForDestination({ + decodedDestination: buildBolt11({ + description: 'Minibits', + amountSat: 5000, + }), + accounts: [buildCashuAccount(MINT_URL, 'BTC', 1000)], + defaultAccount: DEFAULT_ACCOUNT, + mintDescriptionMap: MAP, + }); + expect(result).toBe(DEFAULT_ACCOUNT); + }); + + test('skips USD candidates at the matched mint (USD support deferred)', () => { + const usdAccount = buildCashuAccount(MINT_URL, 'USD', 1_000_000); + const result = pickAccountForDestination({ + decodedDestination: buildBolt11({ + description: 'Minibits', + amountSat: 1000, + }), + accounts: [usdAccount], + defaultAccount: DEFAULT_ACCOUNT, + mintDescriptionMap: MAP, + }); + expect(result).toBe(DEFAULT_ACCOUNT); + }); + + test('picks first BTC candidate that covers when multiple are present', () => { + const btcShort = buildCashuAccount(MINT_URL, 'BTC', 100); + const btcCovers = buildCashuAccount(MINT_URL, 'BTC', 5000); + const result = pickAccountForDestination({ + decodedDestination: buildBolt11({ + description: 'Minibits', + amountSat: 1000, + }), + accounts: [btcShort, btcCovers], + defaultAccount: DEFAULT_ACCOUNT, + mintDescriptionMap: MAP, + }); + expect(result).toBe(btcCovers); + }); +}); diff --git a/app/features/send/pick-account-for-destination.ts b/app/features/send/pick-account-for-destination.ts new file mode 100644 index 000000000..40d29b652 --- /dev/null +++ b/app/features/send/pick-account-for-destination.ts @@ -0,0 +1,66 @@ +import { type Account, getAccountBalance } from '~/features/accounts/account'; +import type { DecodedBolt11 } from '~/lib/bolt11'; +import { Money } from '~/lib/money'; +import { + type MintDescriptionMap, + MintDescriptionMapSchema, +} from './mint-description-config'; + +const parseMap = (): MintDescriptionMap => { + const raw = import.meta.env.VITE_MINT_DESCRIPTION_MAP; + if (!raw) return {}; + // Validated at build time by vite.config.ts — safe to throw here. + return MintDescriptionMapSchema.parse(JSON.parse(raw)); +}; + +export const MINT_DESCRIPTION_MAP: MintDescriptionMap = parseMap(); + +type PickAccountForDestinationInput = { + decodedDestination: DecodedBolt11; + accounts: Account[]; + defaultAccount: Account; + mintDescriptionMap?: MintDescriptionMap; +}; + +/** + * Picks the best account to pay a BOLT11 invoice. + * + * If the invoice's description matches a configured mint, returns the first + * BTC cashu account at that mint whose balance covers the invoice amount. + * Falls back to the supplied default account otherwise. + * + * USD candidates are not evaluated yet — to be revisited. + */ +export const pickAccountForDestination = ({ + decodedDestination, + accounts, + defaultAccount, + mintDescriptionMap = MINT_DESCRIPTION_MAP, +}: PickAccountForDestinationInput): Account => { + if (!decodedDestination.description) return defaultAccount; + + const mintUrl = mintDescriptionMap[decodedDestination.description]; + if (!mintUrl) return defaultAccount; + + const candidates = accounts.filter( + (a) => a.type === 'cashu' && a.mintUrl === mintUrl && a.currency === 'BTC', + ); + if (candidates.length === 0) return defaultAccount; + + if (decodedDestination.amountSat === undefined) return defaultAccount; + + const invoiceBtc = new Money({ + amount: decodedDestination.amountSat, + currency: 'BTC', + unit: 'sat', + }); + + for (const candidate of candidates) { + const balance = getAccountBalance(candidate) as Money<'BTC'> | undefined; + if (balance?.greaterThanOrEqual(invoiceBtc)) { + return candidate; + } + } + + return defaultAccount; +}; diff --git a/app/features/send/send-provider.tsx b/app/features/send/send-provider.tsx index 58cd1057b..67c7c42cf 100644 --- a/app/features/send/send-provider.tsx +++ b/app/features/send/send-provider.tsx @@ -7,7 +7,7 @@ import { import { useStore } from 'zustand'; import type { Account } from '~/features/accounts/account'; import { useToast } from '~/hooks/use-toast'; -import { useGetAccount } from '../accounts/account-hooks'; +import { useAccountsCache, useGetAccount } from '../accounts/account-hooks'; import { useCreateCashuLightningSendQuote } from './cashu-send-quote-hooks'; import { useCreateCashuSendSwapQuote } from './cashu-send-swap-hooks'; import { type SendState, type SendStore, createSendStore } from './send-store'; @@ -36,11 +36,14 @@ export const SendProvider = ({ const { mutateAsync: getSparkLightningQuote } = useCreateSparkLightningSendQuote(); const getAccount = useGetAccount(); + const accountsCache = useAccountsCache(); + const getAccounts = () => accountsCache.getAll() ?? []; const [store] = useState(() => { const sendStore = createSendStore({ initialAccount, getAccount, + getAccounts, getInvoiceFromLud16, getCashuLightningQuote, getCashuSwapQuote, diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index e2ddfa270..d9f9552d8 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -12,6 +12,7 @@ import { type Contact, isContact } from '../contacts/contact'; import { DomainError } from '../shared/error'; import type { CashuLightningQuote } from './cashu-send-quote-service'; import type { CashuSwapQuote } from './cashu-send-swap-service'; +import { pickAccountForDestination } from './pick-account-for-destination'; import type { SparkLightningQuote } from './spark-send-quote-service'; import { validateBolt11, validateLightningAddressFormat } from './validation'; @@ -139,6 +140,7 @@ export type SendState = State & Actions; type CreateSendStoreProps = { initialAccount: Account; getAccount: (accountId: string) => Account; + getAccounts: () => Account[]; getInvoiceFromLud16: (params: { lud16: string; amount: Money<'BTC'>; @@ -175,6 +177,7 @@ const isSendTypeSupportedForAccount = ( export const createSendStore = ({ initialAccount, getAccount, + getAccounts, getInvoiceFromLud16, getCashuLightningQuote, getCashuSwapQuote, @@ -290,8 +293,15 @@ export const createSendStore = ({ return { success: false, error: result.error }; } + const pickedAccount = pickAccountForDestination({ + decodedDestination: bolt11ParseResult.decoded, + accounts: getAccounts(), + defaultAccount: account, + }); + const { encoded } = bolt11ParseResult; set({ + accountId: pickedAccount.id, sendType: 'BOLT11_INVOICE', destination: encoded, destinationDisplay: `${encoded.slice(0, 6)}...${encoded.slice(-4)}`, diff --git a/vite.config.ts b/vite.config.ts index dda1bc4ac..a4a0949f0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,7 @@ import { defineConfig, loadEnv } from 'vite'; import devtoolsJson from 'vite-plugin-devtools-json'; import tsconfigPaths from 'vite-tsconfig-paths'; import { JsonGiftCardConfigSchema } from './app/features/gift-cards/gift-card-config'; +import { JsonMintDescriptionMapSchema } from './app/features/send/mint-description-config'; const sentryConfig: SentryReactRouterBuildOptions = { org: 'make-prisms', @@ -24,6 +25,9 @@ function validateEnv(mode: string) { if (env.VITE_GIFT_CARDS) { JsonGiftCardConfigSchema.parse(env.VITE_GIFT_CARDS); } + if (env.VITE_MINT_DESCRIPTION_MAP) { + JsonMintDescriptionMapSchema.parse(env.VITE_MINT_DESCRIPTION_MAP); + } } export default defineConfig((config) => {