Repository navigation
feat: smart source account selection for BOLT11 sends #1025
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
gudnuf
wants to merge
16
commits into
feature/universal-qr-scanner
from
feat/smart-source-account-selection
Closed
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
0656969
universal qr scanner with input classification
gudnuf 7824ea0
docs: smart source account selection for BOLT11 design spec
gudnuf dcb0588
docs: tighten smart source selection spec per review
gudnuf 4e395f6
cleanup
gudnuf 8fa624e
refactor(qr-scanner): address review feedback
gudnuf 5556ce7
refactor(bolt11): rename parseBolt11Invoice return field invoice → en…
gudnuf 2f997be
revert(lnurl): roll back lightning address case normalization
gudnuf e25f861
refactor(bolt11): clean and lowercase inside decodeBolt11
gudnuf 21c6583
chore: PR cleanup
gudnuf 0e25ab3
fix(send): set amount on store from bolt11 destination
gudnuf 7d9ce7f
refactor(bolt11): split decodeBolt11 return into { encoded, decoded }
gudnuf 9eed882
fix(send): correct comment on hash-clearing in send clientLoader
gudnuf edabe4e
fix(send): only seed amount from bolt11 if not already set
gudnuf 5c6c455
refactor(send): move bolt11 amount seeding from selectDestination to …
gudnuf 17e3972
fix(qr-scanner): clip scan-region box-shadow at desktop sizes
gudnuf a31c7f3
feat: smart source account selection for BOLT11 sends
gudnuf File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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