Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions apps/web-wallet/app/features/receive/claim-cashu-token-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Token } from '@cashu/cashu-ts';
import * as Sentry from '@sentry/react-router';
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 apps/web-wallet/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 apps/web-wallet/app/features/receive/receive-spark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ const useCreateQuote = ({

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

Expand Down
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 @@ -226,6 +227,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 @@ -240,7 +243,7 @@ export async function getLightningQuote({
paymentMethod: {
type: 'bolt11Invoice',
description: description ?? '',
amountSats: amount.toNumber('sat'),
amountSats: amount.isZero() ? undefined : amount.toNumber('sat'),
receiverIdentityPubkey,
descriptionHash,
},
Expand Down
15 changes: 13 additions & 2 deletions apps/web-wallet/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
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 apps/web-wallet/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 apps/web-wallet/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 apps/web-wallet/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 @@ -262,7 +265,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