feat: support zero-amount BOLT11 invoices (pay cashu/spark, create spark)#1049
feat: support zero-amount BOLT11 invoices (pay cashu/spark, create spark)#1049orveth wants to merge 8 commits into
Conversation
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.
…5 amountless) Cashu mints that advertise NUT-05 `options.amountless` accept melt quotes for amountless BOLT11 invoices when the wallet attaches the `amount_msat` option. The cashu-ts wallet wraps that protocol detail when its second `createMeltQuoteBolt11` argument is set. - Add `canMeltAmountless()` to ExtendedMintInfo, delegating to the cashu-ts NUT-05 amountless check after a NUT-05-disabled gate. - Lift the spark-only gate in send-store into a shared `canAccountPayAmountlessBolt11` helper that also unlocks cashu accounts whose mint advertises amountless support. - Drop the "Cashu accounts do not support amountless lightning invoices" guard in cashu-send-quote-service and pass the user-supplied amount in msat to `createMeltQuoteBolt11` when the decoded invoice is amountless. - Reuse the same helper for the Continue-button predicate so cashu and spark behave identically.
The Breez SDK's `bolt11Invoice` receive method already accepts an optional `amountSats`, but the agicash wrapper over-restricted the type to require a Money amount. Relaxing the wrapper unlocks the zero-amount BOLT11 receive path for spark. - Make `amount` optional on `GetLightningQuoteParams` and `useCreateSparkReceiveQuote` props; conditionally include `amountSats` only when defined and fall back to a zero-sat invoice amount when neither the decoded invoice nor the caller supplies one. - Pass `amount: undefined` from receive-spark when the user-supplied amount is zero, so the SDK creates an amountless invoice rather than a 0-sat invoice. - Drop the zero-input lockout on the receive-input Continue button for spark accounts (cashu zero-amount receive is intentionally out of scope and still gated).
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`.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
| ? 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' })); |
There was a problem hiding this comment.
we should make sure that when the quote gets completed the amount paid is properly updated in the encrypted transaction details
There was a problem hiding this comment.
or maybe even better to change the type to amount can be undefined
There was a problem hiding this comment.
or maybe even better to change the type to amount can be undefined
what do you mean? what would be undefined?
There was a problem hiding this comment.
Wired up in cebf851. The actual paid amount from the SDK paymentSucceeded event (payment.amount) is now propagated through useOnSparkReceiveStateChange -> completeReceiveQuote mutation -> SparkReceiveQuoteService.complete(..., paidAmount). The repository re-encrypts quote.amount = paidAmount into the SparkLightningReceiveDbDataSchema, which the existing complete_spark_receive_quote SQL function writes to both spark_receive_quotes.encrypted_data and transactions.encrypted_transaction_details in the same transaction. Same path is used in claim-cashu-token-service.ts (cashu-token-to-spark receive) for consistency.
There was a problem hiding this comment.
Took the simpler route per the facilitator decision: keep amount: Money required throughout the wallet code (zero-Money encodes "amountless"), and convert to undefined only at the literal wallet.receivePayment call site via a ternary. See a2a31ef. Push back if you prefer the optional-Money plumbing instead and I will redo it.
There was a problem hiding this comment.
That alt is moot now — the design landed in a2a31ef does not propagate undefined at all. Internally amount stays a non-optional Money and the only undefined is at the literal SDK call: amountSats: amount.isZero() ? undefined : amount.toNumber('sat'). Zero-Money encodes the amountless case.
There was a problem hiding this comment.
or maybe even better to change the type to amount can be undefined
what do you mean? what would be undefined?
the amount is undefined. The amount is not yet set until the payer chooses how much to send.
| * 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 { |
There was a problem hiding this comment.
I don't think this belongs here its not a. protocol extention. Also, why do we need a new method for it. mintInfo.supportsAmountless(method, unit) is probably sufficient.
There was a problem hiding this comment.
Removed in 0273718. The wrapper added no agicash-specific logic; the only consumer (canAccountPayAmountlessBolt11 in send-store.ts) now calls mintInfo.supportsAmountless('bolt11', unit) directly. cashu-ts's supportsAmountless does not check the NUT-05 disabled flag, so I kept that as a single inline guard at the call site. Tests for the gating logic moved from protocol-extensions.test.ts to send-store.test.ts.
| createQuote({ account, amount }); | ||
| createQuote({ | ||
| account, | ||
| amount: amount.isZero() ? undefined : amount, |
There was a problem hiding this comment.
is this needed? would it work if we just pass zero amount instead of undefined?
There was a problem hiding this comment.
Simplified per your suggestion in a2a31ef — receive-spark.tsx now passes amount straight through. The amount.isZero() ? undefined : amount.toNumber('sat') ternary lives only at the literal Breez SDK call site in spark-receive-quote-core.ts.
| type: 'bolt11Invoice', | ||
| description: description ?? '', | ||
| amountSats: amount.toNumber('sat'), | ||
| ...(amount !== undefined && { amountSats: amount.toNumber('sat') }), |
There was a problem hiding this comment.
why not amountSats: amount ? amount.toNumber('sat') : undefined?
There was a problem hiding this comment.
Applied in a2a31ef. Now: amountSats: amount.isZero() ? undefined : amount.toNumber('sat').
There was a problem hiding this comment.
Investigated and added a docstring in 0402c44. Two module-load failures in agicash-db/database.client.ts make the polyfill necessary:
(window as any).agicashRealtime = agicashRealtimeClient;at module top level (no typeof guard).createClient(...)triggers a Supabase auth fetch that callsisLoggedIn()->window.localStorage.getItem(...), also without a typeof guard.
I tried mock.module('~/features/agicash-db/database.client', ...) from inside cashu-send-quote-service.test.ts, but it does not work: bun hoists the static import { CashuSendQuoteService } above any mock.module() call in the same file, so the real database.client.ts evaluates first and throws ReferenceError: window is not defined before the mock can register. Verified by removing the preload and running the test.
Two alternatives to the polyfill, neither obviously better:
- Make
database.client.ts/auth.tsSSR-safe (guard thewindowreferences withtypeof window !== 'undefined'). Cleaner long-term but is a non-test runtime change. - Use a separate
mock.module()preload file instead of the polyfill. Same shape as today, just a different mechanism.
Kept the polyfill and added a docstring explaining exactly which lines trigger which failure. Happy to take either alternative if you'd prefer one over the other.
| ? 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' })); |
There was a problem hiding this comment.
or maybe even better to change the type to amount can be undefined
what do you mean? what would be undefined?
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.
…boundary
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.
…ess 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.
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.
Summary
Pay zero-amount from spark (~1 line):
Pay zero-amount from cashu (NUT-05 amountless):
ExtendedMintInfo.canMeltAmountless()(delegates to cashu-tssupportsAmountlessafter a NUT-05-disabled gate).send-store.tsinto a sharedcanAccountPayAmountlessBolt11helper that also unlocks cashu accounts whose mint advertises amountless support."Cashu accounts do not support amountless lightning invoices"guard incashu-send-quote-service.tsand callwallet.createMeltQuoteBolt11(paymentRequest, amount_msat)for amountless invoices — cashu-ts wraps theoptions.amountlesspayload internally when its second arg is set.Create zero-amount on spark (~3 changes):
amount?: Moneyoptional onGetLightningQuoteParamsanduseCreateSparkReceiveQuote's props; conditionally includeamountSatsin theBreezSdk.receivePaymentcall.receive-spark.tsx: passamount: undefinedwhen the user-entered amount is zero so the SDK creates an amountless invoice rather than a 0-sat invoice.receive-input.tsx: drop the zero-input lockout on the Continue button for spark accounts.Out of scope (intentionally not touched)
Test plan
bun run typecheckcleanbun run check:allclean (only pre-existingvite-env.d.tswarnings)bun test— 126 / 126 pass (10 new, all green)ExtendedMintInfo.canMeltAmountless— NUT-5 disabled, no amountless method, bolt11/sat advertised, unit-mismatchedvalidateBolt11withallowZeroAmount: true— amountless passes, non-bitcoin networks still failCashuSendQuoteService.getLightningQuote— confirmscreateMeltQuoteBolt11is called with the user-supplied amount in msat for amountless invoices, without it for amounted invoicesNotes for reviewers
app/test-setup.tsis registered viabunfig.toml [test] preload. It polyfillswindow,window.location, andwindow.localStoragefor the bun test environment so service modules that transitively loadagicash-db/database.client.ts(which referenceswindowat module scope) can be imported in tests. The polyfill is gated bytypeof === 'undefined'checks so it is a no-op in any environment that already provides those globals.canAccountPayAmountlessBolt11is exported fromsend-store.tsso the send-input button predicate uses the same gate as the destination validator — cashu and spark behave identically.