Skip to content
15 changes: 13 additions & 2 deletions app/features/receive/claim-cashu-token-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -284,6 +285,7 @@ export class ClaimCashuTokenService {
quotes.sparkReceiveQuote,
paymentPreimage,
sparkTransferId,
paidAmount,
);
return { success: true };
}
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -356,6 +362,11 @@ export class ClaimCashuTokenService {
resolve({
sparkTransferId: payment.id,
paymentPreimage: preimage,
paidAmount: new Money({
amount: Number(payment.amount),
currency: 'BTC',
unit: 'sat',
}),
});
};

Expand Down
5 changes: 4 additions & 1 deletion app/features/receive/receive-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,10 @@ export default function ReceiveInput() {
</LinkWithViewTransition>
</div>
<div /> {/* spacer */}
<Button onClick={handleContinue} disabled={inputValue.isZero()}>
<Button
onClick={handleContinue}
disabled={inputValue.isZero() && receiveAccount.type !== 'spark'}
>
Continue
</Button>
</div>
Expand Down
5 changes: 4 additions & 1 deletion app/features/receive/receive-spark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ const useCreateQuote = ({

useEffectNoStrictMode(() => {
if (!quote && createQuoteStatus === 'idle') {
createQuote({ account, amount });
createQuote({
account,
amount,
});
}
}, [quote, createQuoteStatus, createQuote, amount, account]);

Expand Down
7 changes: 5 additions & 2 deletions app/features/receive/spark-receive-quote-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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({
Expand All @@ -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,
},
}),
Expand Down
15 changes: 13 additions & 2 deletions app/features/receive/spark-receive-quote-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
/**
Expand Down Expand Up @@ -333,6 +334,7 @@ type OnSparkReceiveStateChangeCallbacks = {
paymentData: {
paymentPreimage: string;
sparkTransferId: string;
paidAmount: Money<'BTC'>;
},
) => void;
/**
Expand Down Expand Up @@ -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',
}),
});
};

Expand Down Expand Up @@ -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) {
Expand All @@ -483,6 +492,7 @@ export function useProcessSparkReceiveQuoteTasks() {
quote,
paymentPreimage,
sparkTransferId,
paidAmount,
);
},
retry: 3,
Expand Down Expand Up @@ -650,6 +660,7 @@ export function useProcessSparkReceiveQuoteTasks() {
quoteId,
paymentPreimage: paymentData.paymentPreimage,
sparkTransferId: paymentData.sparkTransferId,
paidAmount: paymentData.paidAmount,
},
{ scope: { id: `spark-receive-quote-${quoteId}` } },
);
Expand Down
7 changes: 6 additions & 1 deletion app/features/receive/spark-receive-quote-service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Money } from '~/lib/money';
import type { SparkReceiveQuote } from './spark-receive-quote';
import {
type CreateQuoteBaseParams,
Expand Down Expand Up @@ -76,13 +77,17 @@ 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.
*/
async complete(
quote: SparkReceiveQuote,
paymentPreimage: string,
sparkTransferId: string,
paidAmount: Money<'BTC'>,
): Promise<SparkReceiveQuote> {
if (quote.state === 'PAID') {
return quote;
Expand All @@ -95,7 +100,7 @@ export class SparkReceiveQuoteService {
}

return this.repository.complete({
quote,
quote: { ...quote, amount: paidAmount as Money },
paymentPreimage,
sparkTransferId,
});
Expand Down
129 changes: 129 additions & 0 deletions app/features/send/cashu-send-quote-service.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mock>,
): 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);
});
});
14 changes: 6 additions & 8 deletions app/features/send/cashu-send-quote-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 4 additions & 1 deletion app/features/send/send-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -258,7 +261,7 @@ export function SendInput() {
<div className="flex items-center justify-end">
<Button
onClick={() => handleContinue(inputValue, convertedValue)}
disabled={inputValue.isZero()}
disabled={inputValue.isZero() && !isAmountlessBolt11Allowed}
loading={status === 'quoting' || isContinuing}
>
Continue
Expand Down
Loading
Loading