Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
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=

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the idea is that we use this for our gift card mints so that when user goes to pink owl coffee place and scans the invoice we automatically select pink owl account if it has enough balance to cover the cost?

if so, why don't we use the exiting VITE_GIFT_CARDS env var to handle this too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because this can be used for any mint, not just a gift card. It can work for offer mints which don't use VITE_GIFT_CARDS which populates the discover section. It could also work for public mints, but that's probably out of scope

Maybe we should refactor a bit though, one problem I've seen with how things are now is that we rely on VITE_GIFT_CARDS to show the gift card image, so if the gift card image is in our code but not in this env var then the image doesn't show. Also, for gift cards we can show the disclaimer under the card like "This gift card is an alpha that expires on May 1st, 2026" and that's something that would be nice for offers too

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah doesn't seem like something we will need for non gift-card/offer mints anytime soon. especially since mints like minibits don't even have any description set

2 changes: 1 addition & 1 deletion app/components/qr-scanner/qr-scanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export const QRScanner = ({ onDecode }: QRScannerProps) => {
}, [throttledDecode]);

return (
<section className="fixed inset-0 h-screen w-screen sm:relative sm:aspect-square sm:h-[400px] sm:w-[400px]">
<section className="fixed inset-0 h-screen w-screen sm:relative sm:aspect-square sm:h-[400px] sm:w-[400px] sm:overflow-hidden">
<div className="relative h-full w-full">
{cameraError ? (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black p-6 text-center">
Expand Down
4 changes: 3 additions & 1 deletion app/features/receive/cashu-receive-quote-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,9 @@ export async function getLightningQuote(
})
: undefined;

const { paymentHash } = decodeBolt11(mintQuoteResponse.request);
const {
decoded: { paymentHash },
} = decodeBolt11(mintQuoteResponse.request);

return {
mintQuote: mintQuoteResponse,
Expand Down
168 changes: 168 additions & 0 deletions app/features/scan/classify-input.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
59 changes: 59 additions & 0 deletions app/features/scan/classify-input.ts
Original file line number Diff line number Diff line change
@@ -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<ClassifiedInput, { direction: 'send' }>;
export type ReceiveInput = Extract<ClassifiedInput, { direction: 'receive' }>;

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;
}
6 changes: 6 additions & 0 deletions app/features/scan/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {
classifyInput,
type ClassifiedInput,
type ReceiveInput,
type SendInput,
} from './classify-input';
4 changes: 3 additions & 1 deletion app/features/send/cashu-send-quote-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions app/features/send/mint-description-config.ts
Original file line number Diff line number Diff line change
@@ -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<typeof MintDescriptionMapSchema>;

export const JsonMintDescriptionMapSchema = z
.string()
.transform((str) => JSON.parse(str))
.pipe(MintDescriptionMapSchema);
Loading
Loading