Skip to content

feat: smart source account selection for BOLT11 sends - #1025

Closed
gudnuf wants to merge 16 commits into
feature/universal-qr-scannerfrom
feat/smart-source-account-selection
Closed

gudnuf wants to merge 16 commits into
feature/universal-qr-scannerfrom
feat/smart-source-account-selection

Conversation

@gudnuf

@gudnuf gudnuf commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Stacks on #971 (universal QR scanner).

Summary

  • When a scanned/pasted BOLT11 invoice's description matches a configured mint URL, preselects 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 account (loader path) or current account (manual paste) when nothing matches.
  • 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 existing default-account behavior.

Design doc was landed in #971 and explains the full shape: docs/superpowers/specs/2026-04-21-smart-source-account-selection-design.md.

Test plan

  • Scan a BOLT11 whose description matches a configured mint → source account switches to the matching cashu account
  • Scan a BOLT11 whose description matches but balance is insufficient → falls back to default account
  • Scan a BOLT11 whose description does not match any configured mint → default account stays selected
  • Paste a BOLT11 on the send page (manual path) → same rules apply, no regression
  • ?accountId=X URL override still wins over smart selection in loader path
  • Zero-amount invoice → smart selection skipped, falls through normally
  • USD candidate considered only when BTC-USD rate is available; excluded when rate is missing

@vercel

vercel Bot commented Apr 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agicash Ready Ready Preview, Comment Apr 28, 2026 6:45pm

Request Review

@supabase

supabase Bot commented Apr 24, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project hrebgkfhjpkbxpztqqke because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

expect(result).toBe(DEFAULT_ACCOUNT);
});

test('returns default for zero-amount invoice (amountSat undefined)', () => {

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.

why default for 0 amount?

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.

My thinking was that its not going to be guaranteed the matching account will have enough balance because we don't know how much the user will send.

The alternative is to pick the account with the matching description if that account has a balance greater than 0. Maybe that's what we should do and then in the case that the user wants to send more than their balance they can change accounts.

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 that sounds better to me

Comment thread .env.example
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

Comment thread app/features/send/send-store.ts Outdated
return { success: false, error: result.error };
}

const smartSelected = selectSourceAccountForBolt11({

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.

should we also do smart selection when initial account is selected?

also, I wonder if this might be annoying if user selects account to send from and then picks the destination because in that case this could change user's selection

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.

should we also do smart selection when initial account is selected?

Do you mean if accountId is set in the queryParams? Probably not, I would need to check why that gets set.

also, I wonder if this might be annoying if user selects account to send from and then picks the destination because in that case this could change user's selection

Hmm yea. I'm not sure exactly. I think it would be, but there's also a case to be made that it should pick the destination because why not if you have sufficient balance on your gift card.

Here's a breakdown of when the account is selected and overridden as is.

image

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.

Hmm yea. I'm not sure exactly. I think it would be, but there's also a case to be made that it should pick the destination because why not if you have sufficient balance on your gift card.

I guess if user doesn't want to spend gift card for this current purchases. Maybe they are saving rewards to then later spend for some bigger purchase if that makes any sense?

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.

does it make sense to use smart picked over one provided in the url when both hash and accountId are provided in url? I guess the question is when does the url query param gets set

export const MINT_DESCRIPTION_MAP: MintDescriptionMap = parseMap();

type SmartSelectionInput = {
decoded: DecodedBolt11;

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.

i would call this just detination

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.

I went for decodedDestination

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.

i am not sure I like the name smart selection

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.

changed to pick-account-for-destintation

if (!mintUrl) return defaultAccount;

const candidates = accounts.filter(
(a) => a.mintUrl === mintUrl && a.currency === 'BTC',

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.

why hard code the currency here?

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 I didn't want to deal with exchange rate and gift card mints are only BTC right now, but we could make it work with USD.

If we do, then we need to have a heuristic to pick the account tif there's multiple currency accounts for the same mint. I'd probably just go with selecting the first account with sufficient balance because there's no way to know from which unit the invoice was generated.

);
if (candidates.length === 0) return defaultAccount;

if (decoded.amountSat === undefined) return defaultAccount;

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.

why default?

Comment thread app/features/send/smart-source-selection.ts Outdated
gudnuf added 14 commits April 27, 2026 11:09
- 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.
…coded

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.
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 4192390 didn't fix any
observable bug. Reverting to keep the diff minimal.
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.
- Tighten initialDestination prop doc comment
- Remove smart-source-selection design spec (deferred to follow-up PR)
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.
Per review feedback, callers can now take the decoded object directly
instead of destructuring around an additional `encoded` property.
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.
Preserves a user-typed amount when a destination is selected, instead
of overriding it with the invoice amount.
…provider

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.
@gudnuf
gudnuf force-pushed the feature/universal-qr-scanner branch from 31c34ae to 5c6c455 Compare April 27, 2026 18:10
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.
@gudnuf
gudnuf force-pushed the feat/smart-source-account-selection branch from c9dc126 to 01de2dc Compare April 28, 2026 00:43
@gudnuf
gudnuf force-pushed the feat/smart-source-account-selection branch from 01de2dc to 654e38b Compare April 28, 2026 18:36
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.
@gudnuf
gudnuf force-pushed the feat/smart-source-account-selection branch from bc900a6 to a31c7f3 Compare April 28, 2026 18:44
@gudnuf
gudnuf force-pushed the feature/universal-qr-scanner branch 4 times, most recently from a60d146 to ec9ea22 Compare April 30, 2026 17:49
@gudnuf

gudnuf commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

superseded by #1042

@gudnuf gudnuf closed this May 4, 2026
@gudnuf
gudnuf deleted the feat/smart-source-account-selection branch May 4, 2026 20:17
gudnuf added a commit that referenced this pull request May 4, 2026
Adds validPaymentDestinations: { descriptions, nodePubkeys } to the
gift-card config schema and a findMatchingOfferOrGiftCardAccount helper
that uses it to preselect the right cashu account for an incoming
BOLT11 invoice. Priority: offer > gift-card > default.

Selection happens in the send route loader (no first-render flash) and
on every paste/scan via the send store. Both populated lists in
validPaymentDestinations are required checks (AND); an empty list means
the dimension is unconstrained; both empty means the mint is
unconfigured and never matches.

To support pubkey matching, decodeBolt11 now recovers the payee node
key from the invoice signature — the n tag is optional in BOLT11 and
rarely included. Recovery uses a manual 5→8 bit packer to produce the
canonical preimage, since @scure/base's strict bech32.fromWords rejects
the partial trailing byte that real-world invoices commonly have.
nodePubkeys are normalized to lowercase at zod parse time.

Supersedes #1025. Production VITE_GIFT_CARDS must add
validPaymentDestinations to every entry before this deploys, or vite
will throw at startup.

This branch was successfully deployed

1 active deployment
Preview — a31c7f3b Deployed Apr 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants