From 8646719550c9fc1865f9a6db60745497c7cc14a8 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 5 May 2026 06:03:17 -0700 Subject: [PATCH 1/8] feat(send): pay amountless BOLT11 invoices from spark accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow Continue when input amount is zero on the send-input screen if the selected source account is a spark wallet. The send-store gate already permits amountless invoices for spark, and the spark send service falls back to the user-supplied amount when the invoice has none — only the Continue button predicate was over-restrictive. --- app/features/send/send-input.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/features/send/send-input.tsx b/app/features/send/send-input.tsx index 253a6773b..bd7835ea4 100644 --- a/app/features/send/send-input.tsx +++ b/app/features/send/send-input.tsx @@ -71,6 +71,8 @@ export function SendInput() { const continueSend = useSendStore((s) => s.proceedWithSend); const status = useSendStore((s) => s.status); + const isAmountlessBolt11Allowed = sendAccount.type === 'spark'; + const sendAmountCurrencyUnit = sendAmount ? getDefaultUnit(sendAmount.currency) : undefined; @@ -258,7 +260,7 @@ export function SendInput() {
{/* spacer */} -
diff --git a/app/features/receive/receive-spark.tsx b/app/features/receive/receive-spark.tsx index 1d8dab6d6..9314a1275 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: amount.isZero() ? undefined : 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..1a7a042c2 100644 --- a/app/features/receive/spark-receive-quote-core.ts +++ b/app/features/receive/spark-receive-quote-core.ts @@ -40,9 +40,10 @@ export type GetLightningQuoteParams = { */ wallet: BreezSdk; /** - * The amount to receive. + * The amount to receive. If omitted, the SDK will create an amountless + * (zero-amount) BOLT11 invoice that the payer specifies the amount on. */ - amount: Money; + amount?: Money; /** * The Spark public key of the receiver used to create invoices on behalf of another user. * If provided, the incoming payment can only be claimed by the Spark wallet that controls the specified public key. @@ -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 omitted, 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'), + ...(amount !== undefined && { amountSats: amount.toNumber('sat') }), receiverIdentityPubkey, }, }), @@ -253,7 +256,8 @@ export async function getLightningQuote({ const invoice = bolt11.decoded; const invoiceAmount = invoice.amountMsat ? new Money({ amount: invoice.amountMsat, currency: 'BTC', unit: 'msat' }) - : (amount as Money<'BTC'>); + : ((amount as Money<'BTC'> | undefined) ?? + new Money({ amount: 0, currency: 'BTC', unit: 'sat' })); const { receiveRequestId, status, createdAt, updatedAt } = response.lightningReceiveDetails; diff --git a/app/features/receive/spark-receive-quote-hooks.ts b/app/features/receive/spark-receive-quote-hooks.ts index a413ee058..696012df4 100644 --- a/app/features/receive/spark-receive-quote-hooks.ts +++ b/app/features/receive/spark-receive-quote-hooks.ts @@ -265,9 +265,10 @@ type CreateProps = { */ account: SparkAccount; /** - * The amount to receive. + * The amount to receive. If omitted, an amountless (zero-amount) BOLT11 + * invoice is created and the payer specifies the amount on send. */ - amount: Money; + amount?: Money; /** * Description to include in the Lightning invoice memo. */ From 10b9cfcdf6b27ea29bacbc2da3f01af166524778 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 5 May 2026 06:04:53 -0700 Subject: [PATCH 4/8] test: cover canMeltAmountless + amountless melt-quote path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add bun unit tests for the new code paths: - `ExtendedMintInfo.canMeltAmountless` — covers NUT-5 disabled, no method advertising amountless, bolt11/sat amountless advertised, and unit-mismatched amountless support. - `validateBolt11` with `allowZeroAmount: true` — confirms amountless invoices pass while non-bitcoin networks still fail. - `CashuSendQuoteService.getLightningQuote` — verifies that `createMeltQuoteBolt11` is invoked with the user-supplied amount in msat for amountless invoices and without when the invoice already encodes an amount. Add a small `app/test-setup.ts` that polyfills browser globals (`window`, `window.location`, `window.localStorage`) for the bun test environment. The cashu service module loads `agicash-db/database.client.ts` at import time, which references `window` at module scope. Wire it up via bunfig.toml `preload`. --- .../send/cashu-send-quote-service.test.ts | 129 ++++++++++++++++++ app/features/send/validation.test.ts | 57 ++++++++ app/lib/cashu/protocol-extensions.test.ts | 99 ++++++++++++++ app/test-setup.ts | 38 ++++++ bunfig.toml | 3 +- 5 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 app/features/send/cashu-send-quote-service.test.ts create mode 100644 app/features/send/validation.test.ts create mode 100644 app/lib/cashu/protocol-extensions.test.ts create mode 100644 app/test-setup.ts 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/validation.test.ts b/app/features/send/validation.test.ts new file mode 100644 index 000000000..8283a4a5c --- /dev/null +++ b/app/features/send/validation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test'; +import type { DecodedBolt11 } from '~/lib/bolt11'; +import { validateBolt11 } from './validation'; + +const buildDecoded = ( + overrides: Partial = {}, +): DecodedBolt11 => ({ + amountMsat: 250_000_000, + amountSat: 250_000, + createdAtUnixMs: Date.now(), + expiryUnixMs: Date.now() + 60_000, + network: 'bitcoin', + description: undefined, + paymentHash: + '0001020304050607080900010203040506070809000102030405060708090102', + ...overrides, +}); + +describe('validateBolt11', () => { + test('passes a non-zero amount invoice without allowZeroAmount', () => { + const result = validateBolt11(buildDecoded()); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.amount?.toNumber('sat')).toBe(250_000); + } + }); + + test('rejects amountless invoices by default', () => { + const result = validateBolt11( + buildDecoded({ amountMsat: undefined, amountSat: undefined }), + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toMatch(/amount/i); + } + }); + + test('accepts amountless invoices with allowZeroAmount: true', () => { + const result = validateBolt11( + buildDecoded({ amountMsat: undefined, amountSat: undefined }), + { allowZeroAmount: true }, + ); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.amount).toBeNull(); + expect(result.currency).toBe('BTC'); + } + }); + + test('rejects non-bitcoin networks even when allowZeroAmount is true', () => { + const result = validateBolt11( + buildDecoded({ network: 'testnet', amountMsat: undefined }), + { allowZeroAmount: true }, + ); + expect(result.valid).toBe(false); + }); +}); diff --git a/app/lib/cashu/protocol-extensions.test.ts b/app/lib/cashu/protocol-extensions.test.ts new file mode 100644 index 000000000..788abd694 --- /dev/null +++ b/app/lib/cashu/protocol-extensions.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'bun:test'; +import type { GetInfoResponse } from '@cashu/cashu-ts'; +import { ExtendedMintInfo } from './protocol-extensions'; + +const baseInfo = { + name: 'test mint', + pubkey: '02abcdef', + version: '0.1.0', + contact: [], +}; + +const buildInfo = (nut5: GetInfoResponse['nuts']['5']): GetInfoResponse => ({ + ...baseInfo, + nuts: { + '4': { methods: [], disabled: false }, + '5': nut5, + }, +}); + +describe('ExtendedMintInfo.canMeltAmountless', () => { + test('returns false when NUT-5 is disabled', () => { + const info = new ExtendedMintInfo( + buildInfo({ + disabled: true, + methods: [ + { + method: 'bolt11', + unit: 'sat', + min_amount: 1, + max_amount: 1_000_000, + options: { amountless: true }, + }, + ], + }), + ); + expect(info.canMeltAmountless()).toBe(false); + }); + + test('returns false when NUT-5 has no method advertising amountless', () => { + const info = new ExtendedMintInfo( + buildInfo({ + disabled: false, + methods: [ + { + method: 'bolt11', + unit: 'sat', + min_amount: 1, + max_amount: 1_000_000, + }, + { + method: 'bolt12', + unit: 'sat', + min_amount: 1, + max_amount: 1_000_000, + options: { description: true }, + }, + ], + }), + ); + expect(info.canMeltAmountless()).toBe(false); + }); + + test('returns true when bolt11/sat method advertises amountless', () => { + const info = new ExtendedMintInfo( + buildInfo({ + disabled: false, + methods: [ + { + method: 'bolt11', + unit: 'sat', + min_amount: 1, + max_amount: 1_000_000, + options: { amountless: true }, + }, + ], + }), + ); + expect(info.canMeltAmountless()).toBe(true); + }); + + test('returns false when amountless is advertised on a different unit', () => { + const info = new ExtendedMintInfo( + buildInfo({ + disabled: false, + methods: [ + { + method: 'bolt11', + unit: 'usd', + min_amount: 1, + max_amount: 1_000_000, + options: { amountless: true }, + }, + ], + }), + ); + expect(info.canMeltAmountless('bolt11', 'sat')).toBe(false); + expect(info.canMeltAmountless('bolt11', 'usd')).toBe(true); + }); +}); diff --git a/app/test-setup.ts b/app/test-setup.ts new file mode 100644 index 000000000..4a393d0b9 --- /dev/null +++ b/app/test-setup.ts @@ -0,0 +1,38 @@ +// Bun test preload. Polyfills browser globals that some module-level code +// depends on (most notably `app/features/agicash-db/database.client.ts`), +// so unit tests can load services that transitively pull in those modules. +// +// Wire this up via bunfig.toml: [test] preload = ["./app/test-setup.ts"] + +// biome-ignore lint/suspicious/noExplicitAny: shim for module-load side effects +const g = globalThis as any; + +if (typeof g.window === 'undefined') { + g.window = g; +} + +if (typeof g.window.location === 'undefined') { + g.window.location = { + protocol: 'http:', + hostname: 'localhost', + href: 'http://localhost/', + }; +} + +if (typeof g.window.localStorage === 'undefined') { + const store = new Map(); + g.window.localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => store.clear(), + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { + return store.size; + }, + }; +} diff --git a/bunfig.toml b/bunfig.toml index d38314e06..03c735c98 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,3 @@ [test] -root = "./app" \ No newline at end of file +root = "./app" +preload = ["./app/test-setup.ts"] \ No newline at end of file From cebf85169c4a237973b76e1cefe536f4fb5d51e5 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 6 May 2026 06:23:21 -0700 Subject: [PATCH 5/8] fix(receive-spark): record actual paid amount on quote completion For amountless BOLT11 invoices, the sender determines the amount when paying. The receive quote was created with the user-input amount (which may be zero), so the encrypted transaction details stored that placeholder value. After the payment completes, propagate the actual paid amount from the SDK's Payment event through the completion path so it is encrypted into both spark_receive_quotes.encrypted_data and the corresponding transactions.encrypted_transaction_details row. --- app/features/receive/claim-cashu-token-service.ts | 15 +++++++++++++-- app/features/receive/spark-receive-quote-hooks.ts | 12 +++++++++++- .../receive/spark-receive-quote-service.ts | 7 ++++++- 3 files changed, 30 insertions(+), 4 deletions(-) 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/spark-receive-quote-hooks.ts b/app/features/receive/spark-receive-quote-hooks.ts index 696012df4..dccdd5330 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 { @@ -334,6 +334,7 @@ type OnSparkReceiveStateChangeCallbacks = { paymentData: { paymentPreimage: string; sparkTransferId: string; + paidAmount: Money<'BTC'>; }, ) => void; /** @@ -399,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', + }), }); }; @@ -470,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) { @@ -484,6 +492,7 @@ export function useProcessSparkReceiveQuoteTasks() { quote, paymentPreimage, sparkTransferId, + paidAmount, ); }, retry: 3, @@ -651,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, }); From a2a31ef885a864dab3ff24e77d79237f266c4707 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 6 May 2026 06:24:56 -0700 Subject: [PATCH 6/8] refactor(receive-spark): keep amount: Money internal, convert at SDK boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the Money | undefined propagation through the receive-quote helpers. The amountless-invoice trigger is now a single ternary at the literal Breez SDK call site (amountSats: amount.isZero() ? undefined : amount.toNumber('sat')). Internally amount is always a non-optional Money, with isZero() encoding the amountless-receive case. Per josip's review: zero-Money is the right encoding for 'amountless' internally — the only place we need the undefined SDK shape is the literal SDK call. --- app/features/receive/receive-spark.tsx | 2 +- app/features/receive/spark-receive-quote-core.ts | 11 +++++------ app/features/receive/spark-receive-quote-hooks.ts | 6 +++--- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/app/features/receive/receive-spark.tsx b/app/features/receive/receive-spark.tsx index 9314a1275..dd15ccfa1 100644 --- a/app/features/receive/receive-spark.tsx +++ b/app/features/receive/receive-spark.tsx @@ -57,7 +57,7 @@ const useCreateQuote = ({ if (!quote && createQuoteStatus === 'idle') { createQuote({ account, - amount: amount.isZero() ? undefined : amount, + 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 1a7a042c2..6a0ee6b6f 100644 --- a/app/features/receive/spark-receive-quote-core.ts +++ b/app/features/receive/spark-receive-quote-core.ts @@ -40,10 +40,10 @@ export type GetLightningQuoteParams = { */ wallet: BreezSdk; /** - * The amount to receive. If omitted, the SDK will create an amountless + * 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; + amount: Money; /** * The Spark public key of the receiver used to create invoices on behalf of another user. * If provided, the incoming payment can only be claimed by the Spark wallet that controls the specified public key. @@ -222,7 +222,7 @@ 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 omitted, the SDK creates an amountless BOLT11 invoice and + * 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. */ @@ -237,7 +237,7 @@ export async function getLightningQuote({ paymentMethod: { type: 'bolt11Invoice', description: description ?? '', - ...(amount !== undefined && { amountSats: amount.toNumber('sat') }), + amountSats: amount.isZero() ? undefined : amount.toNumber('sat'), receiverIdentityPubkey, }, }), @@ -256,8 +256,7 @@ export async function getLightningQuote({ const invoice = bolt11.decoded; const invoiceAmount = invoice.amountMsat ? new Money({ amount: invoice.amountMsat, currency: 'BTC', unit: 'msat' }) - : ((amount as Money<'BTC'> | undefined) ?? - new Money({ amount: 0, currency: 'BTC', unit: 'sat' })); + : (amount as Money<'BTC'>); const { receiveRequestId, status, createdAt, updatedAt } = response.lightningReceiveDetails; diff --git a/app/features/receive/spark-receive-quote-hooks.ts b/app/features/receive/spark-receive-quote-hooks.ts index dccdd5330..6cc8cbcaf 100644 --- a/app/features/receive/spark-receive-quote-hooks.ts +++ b/app/features/receive/spark-receive-quote-hooks.ts @@ -265,10 +265,10 @@ type CreateProps = { */ account: SparkAccount; /** - * The amount to receive. If omitted, an amountless (zero-amount) BOLT11 - * invoice is created and the payer specifies the amount on send. + * 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; + amount: Money; /** * Description to include in the Lightning invoice memo. */ From 027371868fb362241d31e809a3cff590397035ae Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 6 May 2026 06:27:12 -0700 Subject: [PATCH 7/8] refactor(cashu): use supportsAmountless directly, drop canMeltAmountless wrapper The canMeltAmountless helper was a thin wrapper that did not encode any agicash-specific protocol extension; per gudnuf's review it does not belong on ExtendedMintInfo. Drop it and call cashu-ts MintInfo's native supportsAmountless('bolt11', unit) at the only consumer (canAccountPayAmountlessBolt11). cashu-ts's supportsAmountless does not check the NUT-05 disabled flag, so guard that here as a single inline check at the call site. Tests for the gating logic move from protocol-extensions.test.ts onto send-store.test.ts where the predicate now lives. --- app/features/send/send-store.test.ts | 116 ++++++++++++++++++++++ app/features/send/send-store.ts | 11 +- app/lib/cashu/protocol-extensions.test.ts | 99 ------------------ app/lib/cashu/protocol-extensions.ts | 10 -- 4 files changed, 124 insertions(+), 112 deletions(-) create mode 100644 app/features/send/send-store.test.ts delete mode 100644 app/lib/cashu/protocol-extensions.test.ts diff --git a/app/features/send/send-store.test.ts b/app/features/send/send-store.test.ts new file mode 100644 index 000000000..98b740000 --- /dev/null +++ b/app/features/send/send-store.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from 'bun:test'; +import type { GetInfoResponse } from '@cashu/cashu-ts'; +import type { + Account, + CashuAccount, + SparkAccount, +} from '~/features/accounts/account'; +import { ExtendedMintInfo } from '~/lib/cashu/protocol-extensions'; +import { canAccountPayAmountlessBolt11 } from './send-store'; + +const baseInfo = { + name: 'test mint', + pubkey: '02abcdef', + version: '0.1.0', + contact: [], +}; + +const buildMintInfo = ( + nut5: GetInfoResponse['nuts']['5'], +): ExtendedMintInfo => { + return new ExtendedMintInfo({ + ...baseInfo, + nuts: { + '4': { methods: [], disabled: false }, + '5': nut5, + }, + }); +}; + +const buildCashuAccount = (mintInfo: ExtendedMintInfo): CashuAccount => { + return { + type: 'cashu', + currency: 'BTC', + wallet: { getMintInfo: () => mintInfo }, + } as unknown as CashuAccount; +}; + +const sparkAccount: SparkAccount = { type: 'spark' } as unknown as SparkAccount; + +describe('canAccountPayAmountlessBolt11', () => { + test('returns true for spark accounts unconditionally', () => { + expect(canAccountPayAmountlessBolt11(sparkAccount as Account)).toBe(true); + }); + + test('returns false when NUT-5 is disabled even if amountless is advertised', () => { + const account = buildCashuAccount( + buildMintInfo({ + disabled: true, + methods: [ + { + method: 'bolt11', + unit: 'sat', + min_amount: 1, + max_amount: 1_000_000, + options: { amountless: true }, + }, + ], + }), + ); + expect(canAccountPayAmountlessBolt11(account as Account)).toBe(false); + }); + + test('returns false when NUT-5 has no method advertising amountless', () => { + const account = buildCashuAccount( + buildMintInfo({ + disabled: false, + methods: [ + { + method: 'bolt11', + unit: 'sat', + min_amount: 1, + max_amount: 1_000_000, + }, + ], + }), + ); + expect(canAccountPayAmountlessBolt11(account as Account)).toBe(false); + }); + + test('returns true when bolt11/sat method advertises amountless', () => { + const account = buildCashuAccount( + buildMintInfo({ + disabled: false, + methods: [ + { + method: 'bolt11', + unit: 'sat', + min_amount: 1, + max_amount: 1_000_000, + options: { amountless: true }, + }, + ], + }), + ); + expect(canAccountPayAmountlessBolt11(account as Account)).toBe(true); + }); + + test('returns false when amountless is advertised on a different unit', () => { + const account = buildCashuAccount( + buildMintInfo({ + disabled: false, + methods: [ + { + method: 'bolt11', + unit: 'usd', + min_amount: 1, + max_amount: 1_000_000, + options: { amountless: true }, + }, + ], + }), + ); + // Account's currency is BTC, which maps to cashu unit 'sat'. + expect(canAccountPayAmountlessBolt11(account as Account)).toBe(false); + }); +}); diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index d6f31f1eb..056c23cd6 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -23,9 +23,14 @@ import type { SparkLightningQuote } from './spark-send-quote-service'; */ export const canAccountPayAmountlessBolt11 = (account: Account): boolean => { if (account.type === 'spark') return true; - return account.wallet - .getMintInfo() - .canMeltAmountless('bolt11', getCashuProtocolUnit(account.currency)); + const mintInfo = account.wallet.getMintInfo(); + // cashu-ts MintInfo.supportsAmountless does not check the NUT-05 disabled + // flag, so guard it here. + if (mintInfo.nuts['5']?.disabled) return false; + return mintInfo.supportsAmountless( + 'bolt11', + getCashuProtocolUnit(account.currency), + ); }; /** diff --git a/app/lib/cashu/protocol-extensions.test.ts b/app/lib/cashu/protocol-extensions.test.ts deleted file mode 100644 index 788abd694..000000000 --- a/app/lib/cashu/protocol-extensions.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import type { GetInfoResponse } from '@cashu/cashu-ts'; -import { ExtendedMintInfo } from './protocol-extensions'; - -const baseInfo = { - name: 'test mint', - pubkey: '02abcdef', - version: '0.1.0', - contact: [], -}; - -const buildInfo = (nut5: GetInfoResponse['nuts']['5']): GetInfoResponse => ({ - ...baseInfo, - nuts: { - '4': { methods: [], disabled: false }, - '5': nut5, - }, -}); - -describe('ExtendedMintInfo.canMeltAmountless', () => { - test('returns false when NUT-5 is disabled', () => { - const info = new ExtendedMintInfo( - buildInfo({ - disabled: true, - methods: [ - { - method: 'bolt11', - unit: 'sat', - min_amount: 1, - max_amount: 1_000_000, - options: { amountless: true }, - }, - ], - }), - ); - expect(info.canMeltAmountless()).toBe(false); - }); - - test('returns false when NUT-5 has no method advertising amountless', () => { - const info = new ExtendedMintInfo( - buildInfo({ - disabled: false, - methods: [ - { - method: 'bolt11', - unit: 'sat', - min_amount: 1, - max_amount: 1_000_000, - }, - { - method: 'bolt12', - unit: 'sat', - min_amount: 1, - max_amount: 1_000_000, - options: { description: true }, - }, - ], - }), - ); - expect(info.canMeltAmountless()).toBe(false); - }); - - test('returns true when bolt11/sat method advertises amountless', () => { - const info = new ExtendedMintInfo( - buildInfo({ - disabled: false, - methods: [ - { - method: 'bolt11', - unit: 'sat', - min_amount: 1, - max_amount: 1_000_000, - options: { amountless: true }, - }, - ], - }), - ); - expect(info.canMeltAmountless()).toBe(true); - }); - - test('returns false when amountless is advertised on a different unit', () => { - const info = new ExtendedMintInfo( - buildInfo({ - disabled: false, - methods: [ - { - method: 'bolt11', - unit: 'usd', - min_amount: 1, - max_amount: 1_000_000, - options: { amountless: true }, - }, - ], - }), - ); - expect(info.canMeltAmountless('bolt11', 'sat')).toBe(false); - expect(info.canMeltAmountless('bolt11', 'usd')).toBe(true); - }); -}); diff --git a/app/lib/cashu/protocol-extensions.ts b/app/lib/cashu/protocol-extensions.ts index 5ac3867cc..58e060fa5 100644 --- a/app/lib/cashu/protocol-extensions.ts +++ b/app/lib/cashu/protocol-extensions.ts @@ -54,16 +54,6 @@ export class ExtendedMintInfo extends MintInfo { get agicash(): AgicashMintExtension | undefined { return (this.cache as ExtendedGetInfoResponse).agicash; } - - /** - * Returns true if the mint supports paying amountless BOLT11 invoices - * via NUT-05 melt for the given method and unit. NUT-05 must be enabled - * and at least one method entry must advertise `options.amountless = true`. - */ - canMeltAmountless(method = 'bolt11', unit = 'sat'): boolean { - if (this.nuts['5']?.disabled) return false; - return this.supportsAmountless(method, unit); - } } export type ExtendedMintQuoteBolt11Response = MintQuoteBolt11Response & From 0402c44cf84a4dabe969fae10b14ab4dc607cb4a Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 6 May 2026 06:27:48 -0700 Subject: [PATCH 8/8] chore(test): document why test-setup polyfill is required Replace the brief comment with a precise explanation of the two module-load failures in agicash-db/database.client.ts (top-level `(window as any).agicashRealtime` assignment, plus Supabase client construction triggering `isLoggedIn()` -> `window.localStorage` with no typeof guard) and why `mock.module()` cannot replace them from a test file (static imports hoist above the call). Answers josip's review question. --- app/test-setup.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/test-setup.ts b/app/test-setup.ts index 4a393d0b9..2148e3505 100644 --- a/app/test-setup.ts +++ b/app/test-setup.ts @@ -1,6 +1,19 @@ // Bun test preload. Polyfills browser globals that some module-level code -// depends on (most notably `app/features/agicash-db/database.client.ts`), -// so unit tests can load services that transitively pull in those modules. +// depends on, so that unit tests can load services which transitively +// pull in those modules. +// +// Specifically, `app/features/agicash-db/database.client.ts` evaluates at +// module load: +// - `(window as any).agicashRealtime = ...` (top-level assignment) +// - `createClient(...)` which kicks off a Supabase auth fetch that calls +// `isLoggedIn()` -> `window.localStorage.getItem(...)` (no typeof guard) +// Both throw `ReferenceError: window is not defined` under bun test. +// +// `mock.module()` cannot replace those exports from inside a test file, +// because static imports are hoisted above any `mock.module()` call in the +// same file, so the real module evaluates before the mock is registered. +// A preload polyfill is the simplest fix and stays a no-op in environments +// that already have these globals (browser, jsdom, etc.). // // Wire this up via bunfig.toml: [test] preload = ["./app/test-setup.ts"]