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/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 ? (
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/scan/classify-input.test.ts b/app/features/scan/classify-input.test.ts new file mode 100644 index 000000000..e862cb36e --- /dev/null +++ b/app/features/scan/classify-input.test.ts @@ -0,0 +1,168 @@ +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).toEqual({ + direction: 'receive', + type: 'cashu-token', + encoded: CASHU_A_TOKEN, + }); + }); + + test('cashuB token string', () => { + const result = classifyInput(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).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'); + }); + + test('cashu: URI prefix', () => { + const result = classifyInput(`cashu:${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).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.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).toEqual({ + direction: 'send', + type: 'ln-address', + address: 'user@domain.com', + }); + }); + + test('uppercase address is lowercased', () => { + const result = classifyInput('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).toEqual({ + direction: 'send', + type: 'ln-address', + address: 'alice@pay.example.org', + }); + }); + }); + + describe('unknown inputs', () => { + test('empty string', () => { + expect(classifyInput('')).toBeNull(); + }); + + test('whitespace only', () => { + expect(classifyInput(' ')).toBeNull(); + }); + + test('random gibberish', () => { + expect(classifyInput('not a valid anything')).toBeNull(); + }); + + test('email-like but invalid TLD', () => { + expect(classifyInput('user@x')).toBeNull(); + }); + + test('bare URL without token', () => { + expect(classifyInput('https://example.com')).toBeNull(); + }); + }); + + 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..edbc1141a --- /dev/null +++ b/app/features/scan/classify-input.ts @@ -0,0 +1,59 @@ +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 = + | { 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 { + 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.encoded, + decoded: bolt11Result.decoded, + }; + } + + // 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: lowered, + }; + } + + return null; +} diff --git a/app/features/scan/index.ts b/app/features/scan/index.ts new file mode 100644 index 000000000..6d008f7a6 --- /dev/null +++ b/app/features/scan/index.ts @@ -0,0 +1,6 @@ +export { + classifyInput, + type ClassifiedInput, + type ReceiveInput, + type SendInput, +} from './classify-input'; 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/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-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 ( (null); type Props = PropsWithChildren<{ /** Usually the user's default account. This sets the initial account to send from. */ initialAccount: Account; + /** Raw destination string used to initialize the store; parsed via `selectDestination`. */ + initialDestination?: string | null; }>; -export const SendProvider = ({ initialAccount, children }: Props) => { +export const SendProvider = ({ + initialAccount, + initialDestination, + children, +}: Props) => { + const { toast } = useToast(); const { mutateAsync: getInvoiceFromLud16 } = useGetInvoiceFromLud16(); const { mutateAsync: getCashuLightningQuote } = useCreateCashuLightningSendQuote(); @@ -28,17 +36,42 @@ export const SendProvider = ({ initialAccount, children }: Props) => { const { mutateAsync: getSparkLightningQuote } = useCreateSparkLightningSendQuote(); const getAccount = useGetAccount(); + const accountsCache = useAccountsCache(); + const getAccounts = () => accountsCache.getAll() ?? []; - const [store] = useState(() => - createSendStore({ + const [store] = useState(() => { + const sendStore = createSendStore({ initialAccount, getAccount, + getAccounts, 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; + } + if (result.data.type === 'BOLT11_INVOICE' && result.data.amount) { + sendStore.setState({ amount: result.data.amount }); + } + }); + } + + return sendStore; + }); return {children}; }; diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index 8d589b23c..d9f9552d8 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -4,18 +4,17 @@ import type { CashuAccount, SparkAccount, } from '~/features/accounts/account'; -import { type DecodedBolt11, parseBolt11Invoice } from '~/lib/bolt11'; +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 { pickAccountForDestination } from './pick-account-for-destination'; import type { SparkLightningQuote } from './spark-send-quote-service'; +import { validateBolt11, validateLightningAddressFormat } from './validation'; /** * Returns the default send type based on account type. @@ -28,66 +27,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, @@ -201,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'>; @@ -237,6 +177,7 @@ const isSendTypeSupportedForAccount = ( export const createSendStore = ({ initialAccount, getAccount, + getAccounts, getInvoiceFromLud16, getCashuLightningQuote, getCashuSwapQuote, @@ -255,7 +196,7 @@ export const createSendStore = ({ }; return { - status: 'idle', + status: 'idle' as const, amount: null, accountId: initialAccount.id, sendType: getDefaultSendType(initialAccount.type), @@ -343,19 +284,27 @@ 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, ''); + const pickedAccount = pickAccountForDestination({ + decodedDestination: bolt11ParseResult.decoded, + accounts: getAccounts(), + defaultAccount: account, + }); + + const { encoded } = bolt11ParseResult; set({ + accountId: pickedAccount.id, sendType: 'BOLT11_INVOICE', - destination: cleanedDestination, - destinationDisplay: `${cleanedDestination.slice(0, 6)}...${cleanedDestination.slice(-4)}`, + destination: encoded, + destinationDisplay: `${encoded.slice(0, 6)}...${encoded.slice(-4)}`, }); return { diff --git a/app/features/send/validation.ts b/app/features/send/validation.ts new file mode 100644 index 000000000..2b051031a --- /dev/null +++ b/app/features/send/validation.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/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/lib/bolt11/bolt11.test.ts b/app/lib/bolt11/bolt11.test.ts index bc534d1bf..7d2c06785 100644 --- a/app/lib/bolt11/bolt11.test.ts +++ b/app/lib/bolt11/bolt11.test.ts @@ -1,52 +1,89 @@ 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({ + encoded: invoice, + decoded: expectedDecoded, + }); + }); + + it('should strip lightning: prefix', () => { + expect(decodeBolt11(`lightning:${invoice}`)).toEqual({ + encoded: invoice, + decoded: expectedDecoded, + }); + }); + + it('should strip LIGHTNING: prefix case-insensitively', () => { + expect(decodeBolt11(`LIGHTNING:${invoice}`)).toEqual({ + encoded: invoice, + decoded: expectedDecoded, + }); + }); + + it('should lowercase an uppercase invoice', () => { + expect(decodeBolt11(invoice.toUpperCase())).toEqual({ + encoded: invoice, + decoded: expectedDecoded, }); }); it('should decode a testnet invoice', () => { - const result = decodeBolt11(testnetInvoice); + expect(decodeBolt11(testnetInvoice)).toEqual({ + encoded: testnetInvoice, + decoded: { + amountMsat: 2000000000, + amountSat: 2000000, + createdAtUnixMs: 1496314658000, + expiryUnixMs: 1496318258000, + network: 'testnet', + description: undefined, + paymentHash: + '0001020304050607080900010203040506070809000102030405060708090102', + }, + }); + }); +}); + +describe('parseBolt11Invoice', () => { + it('should parse a raw invoice', () => { + const result = parseBolt11Invoice(invoice); expect(result).toEqual({ - amountMsat: 2000000000, - amountSat: 2000000, - createdAtUnixMs: 1496314658000, - expiryUnixMs: 1496318258000, - network: 'testnet', - description: undefined, - paymentHash: - '0001020304050607080900010203040506070809000102030405060708090102', + valid: true, + encoded: invoice, + decoded: expectedDecoded, }); }); - it('should decode an invoice with a `lightning` prefix', () => { - const result = decodeBolt11(`lightning:${invoice}`); + it('should strip lightning: prefix and lowercase', () => { + const result = parseBolt11Invoice(`LIGHTNING:${invoice.toUpperCase()}`); expect(result).toEqual({ - amountMsat: 250000000, - amountSat: 250000, - createdAtUnixMs: 1496314658000, - expiryUnixMs: 1496314718000, - network: 'bitcoin', - description: '1 cup coffee', - paymentHash: - '0001020304050607080900010203040506070809000102030405060708090102', + valid: true, + encoded: 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..f8dc110b9 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.replace(/^lightning:/, '')); +export const decodeBolt11 = ( + invoice: string, +): { encoded: string; decoded: DecodedBolt11 } => { + const encoded = invoice.replace(/^lightning:/i, '').toLowerCase(); + const { sections } = bolt11Decoder.decode(encoded); const amountSection = findSection(sections, 'amount'); const amountMsat = amountSection?.value @@ -51,26 +57,32 @@ export const decodeBolt11 = (invoice: string): DecodedBolt11 => { } return { - amountMsat, - amountSat, - createdAtUnixMs, - expiryUnixMs, - network, - description, - paymentHash, + encoded, + decoded: { + amountMsat, + amountSat, + createdAtUnixMs, + expiryUnixMs, + network, + description, + paymentHash, + }, }; }; /** - * 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 bech32 + * form and the decoded fields 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; encoded: string; decoded: DecodedBolt11 } + | { valid: false } => { try { - const decoded = decodeBolt11(invoice); - return { valid: true, decoded }; + const { encoded, decoded } = decodeBolt11(invoice); + return { valid: true, encoded, decoded }; } catch { return { valid: false }; } diff --git a/app/routes/_protected._index.tsx b/app/routes/_protected._index.tsx index 4492bf949..d48d22697 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, Scan, 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); + + 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 + window.history.replaceState(null, '', hash); + navigate( + { pathname: '/receive/cashu/token', hash }, + { transition: 'slideLeft', applyTo: 'newView' }, + ); + return; + } + + if (result.type === 'bolt11') { + const validation = validateBolt11(result.decoded, { + allowZeroAmount: true, + }); + if (!validation.valid) { + toast({ + title: 'Invalid invoice', + description: validation.error, + variant: 'destructive', + }); + 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' }, + ); + }; + + 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..be4dd2e35 100644 --- a/app/routes/_protected.send.tsx +++ b/app/routes/_protected.send.tsx @@ -1,14 +1,37 @@ import { Outlet, useSearchParams } from 'react-router'; import { useAccountOrDefault } from '~/features/accounts/account-hooks'; import { SendProvider } from '~/features/send'; +import type { Route } from './+types/_protected.send'; -export default function SendLayout() { +export async function clientLoader(): Promise<{ + initialDestination: string | null; +}> { + const hash = window.location.hash.slice(1); + if (!hash) return { initialDestination: null }; + + // Strip the hash from the URL after reading it so refreshes / back-navigation + // don't re-apply the destination. + window.history.replaceState( + null, + '', + window.location.pathname + window.location.search, + ); + + return { initialDestination: hash }; +} + +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 ( - + ); 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) => {