diff --git a/app/features/receive/claim-cashu-token-service.ts b/app/features/receive/claim-cashu-token-service.ts index f9e38cd3c..1509fd9ea 100644 --- a/app/features/receive/claim-cashu-token-service.ts +++ b/app/features/receive/claim-cashu-token-service.ts @@ -2,6 +2,7 @@ import type { Payment } from '@agicash/breez-sdk-spark'; import type { Token } from '@cashu/cashu-ts'; import type { QueryClient } from '@tanstack/react-query'; import { getExchangeRate } from '~/hooks/use-exchange-rate'; +import { Money } from '~/lib/money'; import type { Account, CashuAccount, SparkAccount } from '../accounts/account'; import { AccountsCache, accountsQueryOptions } from '../accounts/account-hooks'; import type { AccountRepository } from '../accounts/account-repository'; @@ -275,7 +276,7 @@ export class ClaimCashuTokenService { } if (quotes.destinationType === 'spark') { - const { sparkTransferId, paymentPreimage } = + const { sparkTransferId, paymentPreimage, paidAmount } = await this.waitForSparkReceiveToComplete( quotes.destinationAccount, quotes.sparkReceiveQuote, @@ -284,6 +285,7 @@ export class ClaimCashuTokenService { quotes.sparkReceiveQuote, paymentPreimage, sparkTransferId, + paidAmount, ); return { success: true }; } @@ -311,7 +313,11 @@ export class ClaimCashuTokenService { private waitForSparkReceiveToComplete( account: SparkAccount, quote: SparkReceiveQuote, - ): Promise<{ sparkTransferId: string; paymentPreimage: string }> { + ): Promise<{ + sparkTransferId: string; + paymentPreimage: string; + paidAmount: Money<'BTC'>; + }> { const timeoutMs = 10_000; return new Promise((resolve, reject) => { @@ -356,6 +362,11 @@ export class ClaimCashuTokenService { resolve({ sparkTransferId: payment.id, paymentPreimage: preimage, + paidAmount: new Money({ + amount: Number(payment.amount), + currency: 'BTC', + unit: 'sat', + }), }); }; diff --git a/app/features/receive/receive-input.tsx b/app/features/receive/receive-input.tsx index 82145c390..25dc188f6 100644 --- a/app/features/receive/receive-input.tsx +++ b/app/features/receive/receive-input.tsx @@ -177,7 +177,10 @@ export default function ReceiveInput() {
{/* spacer */} -
diff --git a/app/features/receive/receive-spark.tsx b/app/features/receive/receive-spark.tsx index 1d8dab6d6..dd15ccfa1 100644 --- a/app/features/receive/receive-spark.tsx +++ b/app/features/receive/receive-spark.tsx @@ -55,7 +55,10 @@ const useCreateQuote = ({ useEffectNoStrictMode(() => { if (!quote && createQuoteStatus === 'idle') { - createQuote({ account, amount }); + createQuote({ + account, + amount, + }); } }, [quote, createQuoteStatus, createQuote, amount, account]); diff --git a/app/features/receive/spark-receive-quote-core.ts b/app/features/receive/spark-receive-quote-core.ts index 874858f4c..6a0ee6b6f 100644 --- a/app/features/receive/spark-receive-quote-core.ts +++ b/app/features/receive/spark-receive-quote-core.ts @@ -40,7 +40,8 @@ export type GetLightningQuoteParams = { */ wallet: BreezSdk; /** - * The amount to receive. + * The amount to receive. Pass a zero-valued Money to create an amountless + * (zero-amount) BOLT11 invoice that the payer specifies the amount on. */ amount: Money; /** @@ -221,6 +222,8 @@ export type RepositoryCreateQuoteParams = { /** * Gets a Breez SDK lightning receive quote for the given amount. * This is a pure function that calls Breez SDK and can be used by both client and server. + * When `amount` is zero, the SDK creates an amountless BOLT11 invoice and + * the returned quote's invoice amount falls back to zero sats. * @returns The Spark lightning receive quote. */ export async function getLightningQuote({ @@ -234,7 +237,7 @@ export async function getLightningQuote({ paymentMethod: { type: 'bolt11Invoice', description: description ?? '', - amountSats: amount.toNumber('sat'), + amountSats: amount.isZero() ? undefined : amount.toNumber('sat'), receiverIdentityPubkey, }, }), diff --git a/app/features/receive/spark-receive-quote-hooks.ts b/app/features/receive/spark-receive-quote-hooks.ts index a413ee058..6cc8cbcaf 100644 --- a/app/features/receive/spark-receive-quote-hooks.ts +++ b/app/features/receive/spark-receive-quote-hooks.ts @@ -14,7 +14,7 @@ import { sumProofs, useOnMeltQuoteStateChange, } from '~/lib/cashu'; -import type { Money } from '~/lib/money'; +import { Money } from '~/lib/money'; import { useLatest } from '~/lib/use-latest'; import type { SparkAccount } from '../accounts/account'; import { @@ -265,7 +265,8 @@ type CreateProps = { */ account: SparkAccount; /** - * The amount to receive. + * The amount to receive. Pass a zero-valued Money to create an amountless + * (zero-amount) BOLT11 invoice that the payer specifies the amount on. */ amount: Money; /** @@ -333,6 +334,7 @@ type OnSparkReceiveStateChangeCallbacks = { paymentData: { paymentPreimage: string; sparkTransferId: string; + paidAmount: Money<'BTC'>; }, ) => void; /** @@ -398,6 +400,11 @@ export function useOnSparkReceiveStateChange({ onCompletedRef.current(quote.id, { sparkTransferId: payment.id, paymentPreimage: preimage, + paidAmount: new Money({ + amount: Number(payment.amount), + currency: 'BTC', + unit: 'sat', + }), }); }; @@ -469,10 +476,12 @@ export function useProcessSparkReceiveQuoteTasks() { quoteId, paymentPreimage, sparkTransferId, + paidAmount, }: { quoteId: string; paymentPreimage: string; sparkTransferId: string; + paidAmount: Money<'BTC'>; }) => { const quote = pendingQuotesCache.get(quoteId); if (!quote) { @@ -483,6 +492,7 @@ export function useProcessSparkReceiveQuoteTasks() { quote, paymentPreimage, sparkTransferId, + paidAmount, ); }, retry: 3, @@ -650,6 +660,7 @@ export function useProcessSparkReceiveQuoteTasks() { quoteId, paymentPreimage: paymentData.paymentPreimage, sparkTransferId: paymentData.sparkTransferId, + paidAmount: paymentData.paidAmount, }, { scope: { id: `spark-receive-quote-${quoteId}` } }, ); diff --git a/app/features/receive/spark-receive-quote-service.ts b/app/features/receive/spark-receive-quote-service.ts index 7022de617..bb1b4ad1d 100644 --- a/app/features/receive/spark-receive-quote-service.ts +++ b/app/features/receive/spark-receive-quote-service.ts @@ -1,3 +1,4 @@ +import type { Money } from '~/lib/money'; import type { SparkReceiveQuote } from './spark-receive-quote'; import { type CreateQuoteBaseParams, @@ -76,6 +77,9 @@ export class SparkReceiveQuoteService { * @param quote - The spark receive quote to complete. * @param paymentPreimage - The payment preimage from the lightning payment. * @param sparkTransferId - The Spark transfer ID from the completed transfer. + * @param paidAmount - The actual amount paid by the sender. For amountless + * invoices the sender determines the amount, so the stored amount is updated + * here to reflect what was actually received. * @returns The updated quote. * @throws An error if the quote is not in UNPAID state. */ @@ -83,6 +87,7 @@ export class SparkReceiveQuoteService { quote: SparkReceiveQuote, paymentPreimage: string, sparkTransferId: string, + paidAmount: Money<'BTC'>, ): Promise { if (quote.state === 'PAID') { return quote; @@ -95,7 +100,7 @@ export class SparkReceiveQuoteService { } return this.repository.complete({ - quote, + quote: { ...quote, amount: paidAmount as Money }, paymentPreimage, sparkTransferId, }); diff --git a/app/features/send/cashu-send-quote-service.test.ts b/app/features/send/cashu-send-quote-service.test.ts new file mode 100644 index 000000000..196496c92 --- /dev/null +++ b/app/features/send/cashu-send-quote-service.test.ts @@ -0,0 +1,129 @@ +import { + afterEach, + beforeEach, + describe, + expect, + jest, + mock, + test, +} from 'bun:test'; +import type { CashuAccount } from '~/features/accounts/account'; +import { Money } from '~/lib/money'; +import { CashuSendQuoteService } from './cashu-send-quote-service'; + +const amountlessInvoice = + 'lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq8rkx3yf5tcsyz3d73gafnh3cax9rn449d9p5uxz9ezhhypd0elx87sjle52x86fux2ypatgddc6k63n7erqz25le42c4u4ecky03ylcqca784w'; + +const amountedInvoice = + 'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp'; + +const fakeProof = { + id: 'p1', + accountId: 'acc1', + userId: 'user1', + keysetId: 'k1', + amount: 1000, + secret: 'secret-1', + unblindedSignature: '0x', + publicKeyY: '0x', + dleq: null, + witness: null, + state: 'unspent', + version: 1, + createdAt: '2026-01-01T00:00:00Z', + reservedAt: null, +}; + +const buildAccount = ( + createMeltQuoteBolt11: ReturnType, +): CashuAccount => { + const wallet = { + createMeltQuoteBolt11, + selectProofsToSend: () => ({ + send: [{ secret: 'secret-1', amount: 1000 }], + }), + getFeesForProofs: () => 0, + }; + return { + id: 'acc1', + name: 'test', + type: 'cashu', + purpose: 'transactional', + state: 'active', + isOnline: true, + currency: 'BTC', + createdAt: '2026-01-01T00:00:00Z', + version: 1, + expiresAt: null, + mintUrl: 'https://test.mint', + isTestMint: false, + keysetCounters: {}, + proofs: [fakeProof] as never, + wallet: wallet as never, + }; +}; + +const meltQuoteResponse = { + quote: 'q1', + amount: 100, + fee_reserve: 5, + state: 'UNPAID', + expiry: Math.floor(Date.now() / 1000) + 3600, + request: '', + unit: 'sat', +}; + +describe('CashuSendQuoteService.getLightningQuote', () => { + beforeEach(() => { + // The bolt11 test invoices are from 2017 (created 2017-06-01T10:57:38Z). + // The amounted variant has a 60-second expiry, so pin the clock just + // after creation so the service's expiry check does not flag it. + jest.useFakeTimers(); + jest.setSystemTime(new Date('2017-06-01T10:58:00Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('passes amountInMsat when invoice is amountless + user supplies amount', async () => { + const createMeltQuoteBolt11 = mock(async () => meltQuoteResponse); + const account = buildAccount(createMeltQuoteBolt11); + const service = new CashuSendQuoteService({ + // Repository is unused on this code path. + create: () => null, + } as never); + + await service.getLightningQuote({ + account, + paymentRequest: amountlessInvoice, + amount: new Money<'BTC'>({ + amount: 100, + currency: 'BTC', + unit: 'sat', + }) as Money, + }); + + expect(createMeltQuoteBolt11).toHaveBeenCalledTimes(1); + expect(createMeltQuoteBolt11).toHaveBeenCalledWith( + amountlessInvoice, + 100_000, // 100 sat → 100_000 msat + ); + }); + + test('does NOT pass amountInMsat when invoice already has an amount', async () => { + const createMeltQuoteBolt11 = mock(async () => meltQuoteResponse); + const account = buildAccount(createMeltQuoteBolt11); + const service = new CashuSendQuoteService({ + create: () => null, + } as never); + + await service.getLightningQuote({ + account, + paymentRequest: amountedInvoice, + }); + + expect(createMeltQuoteBolt11).toHaveBeenCalledTimes(1); + expect(createMeltQuoteBolt11).toHaveBeenCalledWith(amountedInvoice); + }); +}); diff --git a/app/features/send/cashu-send-quote-service.ts b/app/features/send/cashu-send-quote-service.ts index 24f6881c6..9d392a44a 100644 --- a/app/features/send/cashu-send-quote-service.ts +++ b/app/features/send/cashu-send-quote-service.ts @@ -137,17 +137,15 @@ export class CashuSendQuoteService { throw new Error('Unknown send amount'); } - // TODO: remove this once cashu-ts supports amountless lightning invoices - if (!invoice.amountMsat) { - throw new Error( - 'Cashu accounts do not support amountless lightning invoices', - ); - } - const cashuUnit = getCashuUnit(account.currency); const wallet = account.wallet; - const meltQuote = await wallet.createMeltQuoteBolt11(paymentRequest); + const meltQuote = invoice.amountMsat + ? await wallet.createMeltQuoteBolt11(paymentRequest) + : await wallet.createMeltQuoteBolt11( + paymentRequest, + amountRequestedInBtc.toNumber('msat'), + ); const amountWithLightningFee = meltQuote.amount + meltQuote.fee_reserve; diff --git a/app/features/send/send-input.tsx b/app/features/send/send-input.tsx index 253a6773b..348f8ff84 100644 --- a/app/features/send/send-input.tsx +++ b/app/features/send/send-input.tsx @@ -49,6 +49,7 @@ import type { Contact } from '../contacts/contact'; import { getDefaultUnit } from '../shared/currencies'; import { DomainError, getErrorMessage } from '../shared/error'; import { useSendStore } from './send-provider'; +import { canAccountPayAmountlessBolt11 } from './send-store'; export function SendInput() { const { toast } = useToast(); @@ -71,6 +72,8 @@ export function SendInput() { const continueSend = useSendStore((s) => s.proceedWithSend); const status = useSendStore((s) => s.status); + const isAmountlessBolt11Allowed = canAccountPayAmountlessBolt11(sendAccount); + const sendAmountCurrencyUnit = sendAmount ? getDefaultUnit(sendAmount.currency) : undefined; @@ -258,7 +261,7 @@ export function SendInput() {