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 1 commit into
Conversation
|
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?
…ark) Rebased onto post-monorepo master; paths relocated app/ -> apps/web-wallet/app/ (bunfig.toml -> apps/web-wallet/bunfig.toml). 3-way merge; resolved send-input.tsx (master restructured surrounding JSX) + bunfig.toml newline. Original commits: - feat(send): pay amountless BOLT11 invoices from spark accounts - feat(send): pay amountless BOLT11 invoices from cashu accounts (NUT-05 amountless) - feat(receive): create amountless BOLT11 invoices on spark accounts - test: cover canMeltAmountless + amountless melt-quote path - fix(receive-spark): record actual paid amount on quote completion - refactor(receive-spark): keep amount: Money internal, convert at SDK boundary - refactor(cashu): use supportsAmountless directly, drop canMeltAmountless wrapper - chore(test): document why test-setup polyfill is required
0402c44 to
bfffd92
Compare
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.