diff --git a/.env.example b/.env.example index 5b07dca7b..c7a66183a 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,7 @@ LNURL_SERVER_ENCRYPTION_KEY="a8861cc3e5b3caf5573fbba2da2851a4e608a836eef10074be3 # These mint + unit pairs cannot be added as an account. Set the unit to null to block the entire mint. # LNVoltz + USD: This mint has a bug that does not allow USD ecash to be melted. The current version is "Nutshell/0.18.1" VITE_CASHU_MINT_BLOCKLIST='[{"mintUrl":"https://mint.lnvoltz.com","unit":"usd"}]' -VITE_GIFT_CARDS='[{"url":"https://blockandbean.agi.cash","name":"Block and Bean","currency":"BTC","isDiscoverable":true},{"url":"https://pubkey.agi.cash","name":"Pubkey","currency":"BTC","isDiscoverable":true},{"url":"https://maple.agi.cash","name":"Maple","currency":"BTC","isDiscoverable":true},{"url":"https://compass.agi.cash","name":"Compass Coffee","currency":"BTC","isDiscoverable":true},{"url":"https://pinkowl.agi.cash","name":"Pink Owl Coffee","currency":"BTC","isDiscoverable":true},{"url":"https://shack.agi.cash","name":"The Shack","currency":"BTC","isDiscoverable":true}]' +VITE_GIFT_CARDS='[{"url":"https://blockandbean.agi.cash","name":"Block and Bean","currency":"BTC","isDiscoverable":true,"validPaymentDestinations":{"descriptions":["Block and Bean"],"nodePubkeys":[]}},{"url":"https://pubkey.agi.cash","name":"Pubkey","currency":"BTC","isDiscoverable":true,"validPaymentDestinations":{"descriptions":["Pubkey"],"nodePubkeys":[]}},{"url":"https://maple.agi.cash","name":"Maple","currency":"BTC","isDiscoverable":true,"validPaymentDestinations":{"descriptions":["Maple"],"nodePubkeys":[]}},{"url":"https://compass.agi.cash","name":"Compass Coffee","currency":"BTC","isDiscoverable":true,"validPaymentDestinations":{"descriptions":["Compass Coffee"],"nodePubkeys":[]}},{"url":"https://pinkowl.agi.cash","name":"Pink Owl Coffee","currency":"BTC","isDiscoverable":true,"validPaymentDestinations":{"descriptions":["Pink Owl Coffee"],"nodePubkeys":[]}},{"url":"https://shack.agi.cash","name":"The Shack","currency":"BTC","isDiscoverable":true,"validPaymentDestinations":{"descriptions":["The Shack"],"nodePubkeys":[]}}]' VITE_BREEZ_API_KEY='MIIBaTCCARugAwIBAgIHPxIwQzGEUzAFBgMrZXAwEDEOMAwGA1UEAxMFQnJlZXowHhcNMjYwNDA0MDg0MzIyWhcNMzYwNDAxMDg0MzIyWjApMRAwDgYDVQQKEwdBZ2ljYXNoMRUwEwYDVQQDEwxKb3NpcCBCb2pjaWMwKjAFBgMrZXADIQDQg/XL3yA8HKIgyimHU/Qbpxy0tvzris1fDUtEs6ldd6N7MHkwDgYDVR0PAQH/BAQDAgWgMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFNo5o+5ea0sNMlW/75VgGJCv2AcJMB8GA1UdIwQYMBaAFN6q1pJW843ndJIW/Ey2ILJrKJhrMBkGA1UdEQQSMBCBDmpvc2lwQGFnaS5jYXNoMAUGAytlcANBAK+gHYR+F8ssgm4LqWaom2FRIM9YjhCM6S1jh8Srh1APCf1TVGmPu4cRMmGWquTTe30dHObKfNvxI0SPFNhiSQA=' diff --git a/app/features/gift-cards/gift-card-config.ts b/app/features/gift-cards/gift-card-config.ts index b59c40eb7..22d32e64c 100644 --- a/app/features/gift-cards/gift-card-config.ts +++ b/app/features/gift-cards/gift-card-config.ts @@ -7,6 +7,12 @@ export const GiftCardConfigSchema = z.array( currency: z.enum(['USD', 'BTC']), isDiscoverable: z.boolean(), addCardDisclaimer: z.string().optional(), + validPaymentDestinations: z + .object({ + descriptions: z.array(z.string()), + nodePubkeys: z.array(z.string().toLowerCase()), + }) + .optional(), }), ); diff --git a/app/features/scan/classify-input.test.ts b/app/features/scan/classify-input.test.ts index e862cb36e..b2621e840 100644 --- a/app/features/scan/classify-input.test.ts +++ b/app/features/scan/classify-input.test.ts @@ -84,6 +84,8 @@ describe('classifyInput', () => { expiryUnixMs: 1496314718000, network: 'bitcoin', description: '1 cup coffee', + payeeNodeKey: + '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad', paymentHash: '0001020304050607080900010203040506070809000102030405060708090102', }, diff --git a/app/features/send/find-matching-offer-or-gift-card-account.test.ts b/app/features/send/find-matching-offer-or-gift-card-account.test.ts new file mode 100644 index 000000000..cad7e6ce8 --- /dev/null +++ b/app/features/send/find-matching-offer-or-gift-card-account.test.ts @@ -0,0 +1,497 @@ +import { describe, expect, test } from 'bun:test'; +import type { CashuAccount } from '~/features/accounts/account'; +import type { GiftCardInfo } from '~/features/gift-cards/use-discover-cards'; +import { type DecodedBolt11, decodeBolt11 } from '~/lib/bolt11'; +import { findMatchingOfferOrGiftCardAccount } from './find-matching-offer-or-gift-card-account'; + +const PINK_OWL_URL = 'https://pinkowl.agi.cash'; +const PINK_OWL_DESC = 'Pink Owl Coffee'; +const PINK_OWL_PUBKEY = + '02eadbd9e7557375161df8b646776a547c5cbc2e95b3071ec81553f8ec2cea3b8c'; +const OFFER_URL = 'https://offer.agi.cash'; + +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: PINK_OWL_DESC, + payeeNodeKey: '00'.repeat(33), + paymentHash: 'deadbeef', + ...overrides, +}); + +const buildGiftCard = (opts: { + url: string; + name?: string; + validPaymentDestinations?: { descriptions: string[]; nodePubkeys: string[] }; +}): GiftCardInfo => + ({ + url: opts.url, + name: opts.name ?? opts.url, + currency: 'BTC', + isDiscoverable: true, + image: '', + validPaymentDestinations: opts.validPaymentDestinations, + }) as GiftCardInfo; + +const buildCashuAccount = (opts: { + mintUrl: string; + purpose: 'gift-card' | 'offer' | 'transactional'; + currency?: 'BTC' | 'USD'; + balance: number; +}): CashuAccount => + ({ + id: `cashu-${opts.mintUrl}-${opts.purpose}-${opts.currency ?? 'BTC'}-${opts.balance}`, + name: `test-${opts.purpose}`, + type: 'cashu', + purpose: opts.purpose, + state: 'active', + isOnline: true, + currency: opts.currency ?? 'BTC', + createdAt: '2026-01-01T00:00:00Z', + version: 1, + expiresAt: null, + mintUrl: opts.mintUrl, + isTestMint: false, + keysetCounters: {}, + proofs: opts.balance > 0 ? [{ amount: opts.balance }] : [], + wallet: {} as never, + }) as unknown as CashuAccount; + +describe('findMatchingOfferOrGiftCardAccount', () => { + test('matches by description', () => { + const giftCard = buildCashuAccount({ + mintUrl: PINK_OWL_URL, + purpose: 'gift-card', + balance: 5000, + }); + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ description: PINK_OWL_DESC }), + accounts: [giftCard], + giftCards: [ + buildGiftCard({ + url: PINK_OWL_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [], + }, + }), + ], + }); + expect(result).toBe(giftCard); + }); + + test('matches by payee pubkey when invoice has no description', () => { + const giftCard = buildCashuAccount({ + mintUrl: PINK_OWL_URL, + purpose: 'gift-card', + balance: 5000, + }); + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ + description: undefined, + payeeNodeKey: PINK_OWL_PUBKEY, + }), + accounts: [giftCard], + giftCards: [ + buildGiftCard({ + url: PINK_OWL_URL, + validPaymentDestinations: { + descriptions: [], + nodePubkeys: [PINK_OWL_PUBKEY], + }, + }), + ], + }); + expect(result).toBe(giftCard); + }); + + test('matches when both description and pubkey are configured and both match', () => { + const giftCard = buildCashuAccount({ + mintUrl: PINK_OWL_URL, + purpose: 'gift-card', + balance: 5000, + }); + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ + description: PINK_OWL_DESC, + payeeNodeKey: PINK_OWL_PUBKEY, + }), + accounts: [giftCard], + giftCards: [ + buildGiftCard({ + url: PINK_OWL_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [PINK_OWL_PUBKEY], + }, + }), + ], + }); + expect(result).toBe(giftCard); + }); + + test('returns null when both are configured but only description matches', () => { + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ + description: PINK_OWL_DESC, + payeeNodeKey: '01'.repeat(33), + }), + accounts: [ + buildCashuAccount({ + mintUrl: PINK_OWL_URL, + purpose: 'gift-card', + balance: 5000, + }), + ], + giftCards: [ + buildGiftCard({ + url: PINK_OWL_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [PINK_OWL_PUBKEY], + }, + }), + ], + }); + expect(result).toBeNull(); + }); + + test('returns null when both are configured but only pubkey matches', () => { + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ + description: 'Some other merchant', + payeeNodeKey: PINK_OWL_PUBKEY, + }), + accounts: [ + buildCashuAccount({ + mintUrl: PINK_OWL_URL, + purpose: 'gift-card', + balance: 5000, + }), + ], + giftCards: [ + buildGiftCard({ + url: PINK_OWL_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [PINK_OWL_PUBKEY], + }, + }), + ], + }); + expect(result).toBeNull(); + }); + + test('returns null when neither description nor payee pubkey match the config', () => { + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ + description: 'Some other merchant', + payeeNodeKey: '01'.repeat(33), + }), + accounts: [ + buildCashuAccount({ + mintUrl: PINK_OWL_URL, + purpose: 'gift-card', + balance: 5000, + }), + ], + giftCards: [ + buildGiftCard({ + url: PINK_OWL_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [PINK_OWL_PUBKEY], + }, + }), + ], + }); + expect(result).toBeNull(); + }); + + test('returns null when no account exists at the matching mint', () => { + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ description: PINK_OWL_DESC }), + accounts: [], + giftCards: [ + buildGiftCard({ + url: PINK_OWL_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [], + }, + }), + ], + }); + expect(result).toBeNull(); + }); + + test('skips accounts at mints with no validPaymentDestinations config', () => { + const account = buildCashuAccount({ + mintUrl: PINK_OWL_URL, + purpose: 'gift-card', + balance: 5000, + }); + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ description: PINK_OWL_DESC }), + accounts: [account], + giftCards: [buildGiftCard({ url: PINK_OWL_URL })], + }); + expect(result).toBeNull(); + }); + + test('skips USD accounts (BOLT11 melts are BTC)', () => { + const usdGiftCard = buildCashuAccount({ + mintUrl: PINK_OWL_URL, + purpose: 'gift-card', + currency: 'USD', + balance: 1_000_000, + }); + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ description: PINK_OWL_DESC }), + accounts: [usdGiftCard], + giftCards: [ + buildGiftCard({ + url: PINK_OWL_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [], + }, + }), + ], + }); + expect(result).toBeNull(); + }); + + describe('balance handling', () => { + test('matches when invoice has no amount and account has positive balance', () => { + const giftCard = buildCashuAccount({ + mintUrl: PINK_OWL_URL, + purpose: 'gift-card', + balance: 5000, + }); + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ + description: PINK_OWL_DESC, + amountSat: undefined, + amountMsat: undefined, + }), + accounts: [giftCard], + giftCards: [ + buildGiftCard({ + url: PINK_OWL_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [], + }, + }), + ], + }); + expect(result).toBe(giftCard); + }); + + test('returns null when invoice has no amount and account is empty', () => { + const empty = buildCashuAccount({ + mintUrl: PINK_OWL_URL, + purpose: 'gift-card', + balance: 0, + }); + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ + description: PINK_OWL_DESC, + amountSat: undefined, + amountMsat: undefined, + }), + accounts: [empty], + giftCards: [ + buildGiftCard({ + url: PINK_OWL_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [], + }, + }), + ], + }); + expect(result).toBeNull(); + }); + + test('returns null when all candidates have insufficient balance', () => { + const offer = buildCashuAccount({ + mintUrl: OFFER_URL, + purpose: 'offer', + balance: 100, + }); + const giftCard = buildCashuAccount({ + mintUrl: PINK_OWL_URL, + purpose: 'gift-card', + balance: 200, + }); + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ + description: PINK_OWL_DESC, + amountSat: 5000, + }), + accounts: [offer, giftCard], + giftCards: [ + buildGiftCard({ + url: PINK_OWL_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [], + }, + }), + buildGiftCard({ + url: OFFER_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [], + }, + }), + ], + }); + expect(result).toBeNull(); + }); + }); + + describe('priority', () => { + test('picks offer over gift-card when both match and have sufficient balance', () => { + const giftCard = buildCashuAccount({ + mintUrl: PINK_OWL_URL, + purpose: 'gift-card', + balance: 5000, + }); + const offer = buildCashuAccount({ + mintUrl: OFFER_URL, + purpose: 'offer', + balance: 5000, + }); + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ description: PINK_OWL_DESC }), + accounts: [giftCard, offer], + giftCards: [ + buildGiftCard({ + url: PINK_OWL_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [], + }, + }), + buildGiftCard({ + url: OFFER_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [], + }, + }), + ], + }); + expect(result).toBe(offer); + }); + + test('falls through to gift-card when offer has insufficient balance', () => { + const offer = buildCashuAccount({ + mintUrl: OFFER_URL, + purpose: 'offer', + balance: 100, + }); + const giftCard = buildCashuAccount({ + mintUrl: PINK_OWL_URL, + purpose: 'gift-card', + balance: 5000, + }); + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ + description: PINK_OWL_DESC, + amountSat: 1000, + }), + accounts: [offer, giftCard], + giftCards: [ + buildGiftCard({ + url: PINK_OWL_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [], + }, + }), + buildGiftCard({ + url: OFFER_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [], + }, + }), + ], + }); + expect(result).toBe(giftCard); + }); + + test('picks the first matching offer when multiple offers qualify', () => { + const offerA = buildCashuAccount({ + mintUrl: OFFER_URL, + purpose: 'offer', + balance: 5000, + }); + const offerB = buildCashuAccount({ + mintUrl: `${OFFER_URL}-2`, + purpose: 'offer', + balance: 5000, + }); + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: buildBolt11({ description: PINK_OWL_DESC }), + accounts: [offerA, offerB], + giftCards: [ + buildGiftCard({ + url: OFFER_URL, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [], + }, + }), + buildGiftCard({ + url: `${OFFER_URL}-2`, + validPaymentDestinations: { + descriptions: [PINK_OWL_DESC], + nodePubkeys: [], + }, + }), + ], + }); + expect(result).toBe(offerA); + }); + }); + + describe('integration with decodeBolt11', () => { + test('matches a real Square invoice by recovered payee pubkey', () => { + const SQUARE_URL = 'https://square.agi.cash'; + const SQUARE_PUBKEY = + '02372c5d8559e4c0d3943b0e86360207491cb8ac669b7def06427860e566771828'; + const squareInvoice = + 'LNBC140N1P5C5EK3DQVG9NKJCMPWD5QPP5FH80A2FEYQGSLDHG9RGQGXPHQ5UK84TQ3G5RLQRJM9YKEPZFXCCSSP5HHLX69PEFNPUS9CSTJ7KY38WGHY83JHU35HRKEYDDKF37SQ5HE2S9QRSGQCQPCXQZFVRZJQDEPFKU9FNG2PS0V74MUP5T5TGFJ9XP2WLNZWTYCUHAY4G0FFCNT7ZXQK5QQ0TQQQQQQQQQQQQQQQQQQ9GRZJQVSMG807VRGZKPFJXCAG7FD43XCF50ZWRR7XNXFQWL6QEAYT0M7LXZQHKGQQXMCQQGQQQQQQQQQQQQQQ9G3XYWYGTDHKTALS3GJZDL0WPDCR3MPNLDUHHT94Y8FCS6ATY8T78R6V7MCZWXZ5C4T6FPDKVSFY35EUSNZPJFVQH58ZSMYC6J3Z22K7QPMRWCRK'; + + const giftCard = buildCashuAccount({ + mintUrl: SQUARE_URL, + purpose: 'gift-card', + balance: 5000, + }); + const result = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: decodeBolt11(squareInvoice).decoded, + accounts: [giftCard], + giftCards: [ + buildGiftCard({ + url: SQUARE_URL, + validPaymentDestinations: { + descriptions: [], + nodePubkeys: [SQUARE_PUBKEY], + }, + }), + ], + }); + expect(result).toBe(giftCard); + }); + }); +}); diff --git a/app/features/send/find-matching-offer-or-gift-card-account.ts b/app/features/send/find-matching-offer-or-gift-card-account.ts new file mode 100644 index 000000000..6ea542eb1 --- /dev/null +++ b/app/features/send/find-matching-offer-or-gift-card-account.ts @@ -0,0 +1,83 @@ +import { + type Account, + type CashuAccount, + getAccountBalance, +} from '~/features/accounts/account'; +import type { GiftCardInfo } from '~/features/gift-cards/use-discover-cards'; +import type { DecodedBolt11 } from '~/lib/bolt11'; +import { type Currency, Money } from '~/lib/money'; + +type FindMatchingOfferOrGiftCardAccountInput = { + decodedBolt11: DecodedBolt11; + accounts: Account[]; + giftCards: GiftCardInfo[]; +}; + +/** + * Finds an offer or a gift card account with sufficient balance matching a + * BOLT11 invoice. Priority: `offer` > `gift-card`. Returns null if nothing + * matches. + */ +export const findMatchingOfferOrGiftCardAccount = ({ + decodedBolt11, + accounts, + giftCards, +}: FindMatchingOfferOrGiftCardAccountInput): CashuAccount | null => { + const { description, payeeNodeKey, amountSat } = decodedBolt11; + + const invoiceAmount = amountSat + ? new Money({ amount: amountSat, currency: 'BTC', unit: 'sat' }) + : null; + + const isAllowedDestination = ( + config: NonNullable, + ): boolean => { + const { descriptions, nodePubkeys } = config; + + // Each populated list is a required check; empty = no constraint. + // Both empty = unconfigured mint, never matches. + if (descriptions.length === 0 && nodePubkeys.length === 0) return false; + if ( + descriptions.length > 0 && + (!description || !descriptions.includes(description)) + ) { + return false; + } + if (nodePubkeys.length > 0 && !nodePubkeys.includes(payeeNodeKey)) { + return false; + } + return true; + }; + + const giftCardByUrl = new Map(giftCards.map((g) => [g.url, g])); + + const offers: CashuAccount[] = []; + const cards: CashuAccount[] = []; + + for (const account of accounts) { + if (account.type !== 'cashu' || account.currency !== 'BTC') continue; + + const config = giftCardByUrl.get(account.mintUrl)?.validPaymentDestinations; + if (!config || !isAllowedDestination(config)) continue; + + if (account.purpose === 'offer') { + offers.push(account); + } else if (account.purpose === 'gift-card') { + cards.push(account); + } + } + + const hasSufficientBalance = (account: CashuAccount) => { + const balance = getAccountBalance(account); + if (!balance) return false; + return invoiceAmount + ? balance.greaterThanOrEqual(invoiceAmount) + : balance.isPositive(); + }; + + return ( + offers.find(hasSufficientBalance) ?? + cards.find(hasSufficientBalance) ?? + null + ); +}; diff --git a/app/features/send/resolve-destination.ts b/app/features/send/resolve-destination.ts index afa59fc4f..45f7e9123 100644 --- a/app/features/send/resolve-destination.ts +++ b/app/features/send/resolve-destination.ts @@ -1,4 +1,4 @@ -import { parseBolt11Invoice } from '~/lib/bolt11'; +import { type DecodedBolt11, parseBolt11Invoice } from '~/lib/bolt11'; import { parseCashuPaymentRequest } from '~/lib/cashu'; import { isValidLightningAddress } from '~/lib/lnurl'; import type { Money } from '~/lib/money'; @@ -12,6 +12,7 @@ export type SendDestination = destinationDisplay: string; destinationDetails?: null; amount: Money | null; + decoded: DecodedBolt11; } | { sendType: 'LN_ADDRESS'; @@ -80,6 +81,7 @@ export async function resolveSendDestination( destination: encoded, destinationDisplay: `${encoded.slice(0, 6)}...${encoded.slice(-4)}`, amount: validated.amount, + decoded: bolt11.decoded, }, }; } diff --git a/app/features/send/send-provider.tsx b/app/features/send/send-provider.tsx index a477e3a0c..7558e9c2e 100644 --- a/app/features/send/send-provider.tsx +++ b/app/features/send/send-provider.tsx @@ -6,7 +6,8 @@ import { } from 'react'; import { useStore } from 'zustand'; import type { Account } from '~/features/accounts/account'; -import { useGetAccount } from '../accounts/account-hooks'; +import { useAccountsCache, useGetAccount } from '../accounts/account-hooks'; +import { GIFT_CARDS } from '../gift-cards/use-discover-cards'; import { useCreateCashuLightningSendQuote } from './cashu-send-quote-hooks'; import { useCreateCashuSendSwapQuote } from './cashu-send-swap-hooks'; import type { SendDestination } from './resolve-destination'; @@ -35,12 +36,16 @@ export const SendProvider = ({ const { mutateAsync: getSparkLightningQuote } = useCreateSparkLightningSendQuote(); const getAccount = useGetAccount(); + const accountsCache = useAccountsCache(); + const getAccounts = () => accountsCache.getAll() ?? []; const [store] = useState(() => createSendStore({ initialAccount, initialDestination, getAccount, + getAccounts, + giftCards: GIFT_CARDS, getInvoiceFromLud16, getCashuLightningQuote, getCashuSwapQuote, diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index d89e1c645..caf91d79b 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -6,9 +6,11 @@ import type { } from '~/features/accounts/account'; import type { Currency, Money } from '~/lib/money'; import type { Contact } from '../contacts/contact'; +import type { GiftCardInfo } from '../gift-cards/use-discover-cards'; import { DomainError } from '../shared/error'; import type { CashuLightningQuote } from './cashu-send-quote-service'; import type { CashuSwapQuote } from './cashu-send-swap-service'; +import { findMatchingOfferOrGiftCardAccount } from './find-matching-offer-or-gift-card-account'; import { type SendDestination, resolveSendDestination, @@ -141,6 +143,8 @@ type CreateSendStoreProps = { /** Initial destination to send to, if any. */ initialDestination: SendDestination | null; getAccount: (accountId: string) => Account; + getAccounts: () => Account[]; + giftCards: GiftCardInfo[]; getInvoiceFromLud16: (params: { lud16: string; amount: Money<'BTC'>; @@ -178,6 +182,8 @@ export const createSendStore = ({ initialAccount, initialDestination, getAccount, + getAccounts, + giftCards, getInvoiceFromLud16, getCashuLightningQuote, getCashuSwapQuote, @@ -265,7 +271,19 @@ export const createSendStore = ({ destinationDisplay, destinationDetails, } = result.data; + + const matched = + result.data.sendType === 'BOLT11_INVOICE' + ? findMatchingOfferOrGiftCardAccount({ + decodedBolt11: result.data.decoded, + accounts: getAccounts(), + giftCards, + }) + : null; + const accountId = matched?.id ?? account.id; + set({ + accountId, sendType, destination, destinationDisplay, diff --git a/app/lib/bolt11/bolt11.test.ts b/app/lib/bolt11/bolt11.test.ts index 7d2c06785..653eb380f 100644 --- a/app/lib/bolt11/bolt11.test.ts +++ b/app/lib/bolt11/bolt11.test.ts @@ -6,6 +6,11 @@ const invoice = const testnetInvoice = 'lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8'; +// All BOLT11 spec test vectors are signed with the same demo private key, +// recovering to this public key. +const SPEC_PUBKEY = + '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad'; + const expectedDecoded = { amountMsat: 250000000, amountSat: 250000, @@ -13,6 +18,7 @@ const expectedDecoded = { expiryUnixMs: 1496314718000, network: 'bitcoin', description: '1 cup coffee', + payeeNodeKey: SPEC_PUBKEY, paymentHash: '0001020304050607080900010203040506070809000102030405060708090102', }; @@ -57,6 +63,7 @@ describe('decodeBolt11', () => { expiryUnixMs: 1496318258000, network: 'testnet', description: undefined, + payeeNodeKey: SPEC_PUBKEY, paymentHash: '0001020304050607080900010203040506070809000102030405060708090102', }, diff --git a/app/lib/bolt11/index.ts b/app/lib/bolt11/index.ts index f8dc110b9..2741d3aad 100644 --- a/app/lib/bolt11/index.ts +++ b/app/lib/bolt11/index.ts @@ -1,3 +1,6 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha2'; +import { bech32 } from '@scure/base'; import bolt11Decoder, { type Section } from 'light-bolt11-decoder'; // Per BOLT11 spec, the default expiry is 3600 seconds (1 hour) when the `x` tag is absent. @@ -11,6 +14,7 @@ export type DecodedBolt11 = { expiryUnixMs: number; network: string | undefined; description: string | undefined; + payeeNodeKey: string; paymentHash: string; }; @@ -50,6 +54,8 @@ export const decodeBolt11 = ( const descriptionSection = findSection(sections, 'description'); const description = descriptionSection?.value; + const payeeNodeKey = recoverPayeeNodeKey(encoded); + const paymentHashSection = findSection(sections, 'payment_hash'); const paymentHash = paymentHashSection?.value; if (!paymentHash) { @@ -65,6 +71,7 @@ export const decodeBolt11 = ( expiryUnixMs, network, description, + payeeNodeKey, paymentHash, }, }; @@ -97,6 +104,65 @@ const findSection = ( | undefined; }; +/** + * Packs an array of 5-bit groups into bytes, zero-padding any trailing + * partial byte. This is the form that BOLT11 hashes for signing — the + * data part may have a bit-length that isn't a multiple of 8, so a + * strict 5→8 base converter (e.g. `bech32.fromWords`) refuses it. + */ +const packFiveBitsToBytes = (words: number[]): Uint8Array => { + const out: number[] = []; + let buffer = 0; + let bits = 0; + for (const word of words) { + buffer = (buffer << 5) | word; + bits += 5; + while (bits >= 8) { + bits -= 8; + out.push((buffer >> bits) & 0xff); + } + } + if (bits > 0) { + out.push((buffer << (8 - bits)) & 0xff); + } + return new Uint8Array(out); +}; + +/** + * Recovers the payee node pubkey from a BOLT11 invoice's signature. + * + * Per BOLT11, every invoice is signed and the destination pubkey is + * derivable from the signature alone — the optional `n` tag is rarely + * included. This does ECDSA recovery against the canonical preimage + * `sha256(hrp_utf8 || data_5bit_packed_to_bytes)`. + * + * @returns 33-byte compressed pubkey as lowercase hex. + * @throws if the input is not a decodable bech32 BOLT11 invoice. + */ +const recoverPayeeNodeKey = (invoice: string): string => { + const { prefix, words } = bech32.decode( + invoice as `${string}1${string}`, + Number.MAX_SAFE_INTEGER, + ); + const sigBytes = bech32.fromWordsUnsafe(words.slice(-104)); + if (!sigBytes) { + throw new Error('Invalid lightning invoice: malformed signature'); + } + const dataBytes = packFiveBitsToBytes(words.slice(0, -104)); + const hrpBytes = new TextEncoder().encode(prefix); + const preimage = new Uint8Array(hrpBytes.length + dataBytes.length); + preimage.set(hrpBytes, 0); + preimage.set(dataBytes, hrpBytes.length); + const hash = sha256(preimage); + // BOLT11 stores the signature as r||s||recid (65 bytes). noble's + // 'recovered' format expects recid as the first byte, so reorder. + const recovered = new Uint8Array(65); + recovered[0] = sigBytes[64]; + recovered.set(sigBytes.subarray(0, 64), 1); + const sig = secp256k1.Signature.fromBytes(recovered, 'recovered'); + return Buffer.from(sig.recoverPublicKey(hash).toBytes(true)).toString('hex'); +}; + /** * @see https://github.com/lightning/bolts/blob/master/11-payment-encoding.md#human-readable-part */ diff --git a/app/routes/_protected.send.tsx b/app/routes/_protected.send.tsx index 653a572b0..e633e655a 100644 --- a/app/routes/_protected.send.tsx +++ b/app/routes/_protected.send.tsx @@ -1,19 +1,26 @@ import { Outlet, useSearchParams } from 'react-router'; -import { useAccountOrDefault } from '~/features/accounts/account-hooks'; +import { + AccountsCache, + useAccountOrDefault, +} from '~/features/accounts/account-hooks'; +import { GIFT_CARDS } from '~/features/gift-cards/use-discover-cards'; import { SendProvider } from '~/features/send'; +import { findMatchingOfferOrGiftCardAccount } from '~/features/send/find-matching-offer-or-gift-card-account'; import { type SendDestination, resolveSendDestination, } from '~/features/send/resolve-destination'; +import { getQueryClient } from '~/features/shared/query-client'; import { toast } from '~/hooks/use-toast'; import type { Route } from './+types/_protected.send'; export async function clientLoader(): Promise<{ initialDestination: SendDestination | null; + initialAccountId: string | null; }> { const hash = window.location.hash.slice(1); if (!hash) { - return { initialDestination: null }; + return { initialDestination: null, initialAccountId: null }; } // Strip the hash from the URL after reading it so refreshes / back-navigation @@ -35,18 +42,33 @@ export async function clientLoader(): Promise<{ variant: 'destructive', duration: 8000, }); - return { initialDestination: null }; + return { initialDestination: null, initialAccountId: null }; } - return { initialDestination: result.data }; + const initialDestination = result.data; + let initialAccountId: string | null = null; + if (initialDestination.sendType === 'BOLT11_INVOICE') { + const accountsCache = new AccountsCache(getQueryClient()); + const accounts = accountsCache.getAll() ?? []; + const matched = findMatchingOfferOrGiftCardAccount({ + decodedBolt11: initialDestination.decoded, + accounts, + giftCards: GIFT_CARDS, + }); + initialAccountId = matched?.id ?? null; + } + + return { initialDestination, initialAccountId }; } clientLoader.hydrate = true as const; export default function SendLayout({ loaderData }: Route.ComponentProps) { const [searchParams] = useSearchParams(); - const accountId = searchParams.get('accountId'); - const initialAccount = useAccountOrDefault(accountId); + const accountIdParam = searchParams.get('accountId'); + const initialAccount = useAccountOrDefault( + loaderData.initialAccountId ?? accountIdParam, + ); return (