Skip to content

feat: support zero-amount BOLT11 invoices (pay cashu/spark, create spark)#1049

Open
orveth wants to merge 1 commit into
masterfrom
cashu-zero-amount-invoices
Open

feat: support zero-amount BOLT11 invoices (pay cashu/spark, create spark)#1049
orveth wants to merge 1 commit into
masterfrom
cashu-zero-amount-invoices

Conversation

@orveth

@orveth orveth commented May 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Pay zero-amount from spark (~1 line):

  • Relax the Continue-button predicate on the send-input screen so that a spark source account can proceed even with an empty amount field — the spark send service already accepts a user-supplied amount and the destination-validation gate already permitted amountless BOLT11 for spark.

Pay zero-amount from cashu (NUT-05 amountless):

  • Add ExtendedMintInfo.canMeltAmountless() (delegates to cashu-ts supportsAmountless after a NUT-05-disabled gate).
  • Lift the spark-only gate in send-store.ts 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.ts and call wallet.createMeltQuoteBolt11(paymentRequest, amount_msat) for amountless invoices — cashu-ts wraps the options.amountless payload internally when its second arg is set.

Create zero-amount on spark (~3 changes):

  • Make amount?: Money optional on GetLightningQuoteParams and useCreateSparkReceiveQuote's props; conditionally include amountSats in the BreezSdk.receivePayment call.
  • receive-spark.tsx: pass amount: undefined when 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)

  • Min/max enforcement against mint quote response — the wallet does not enforce mint min/max anywhere today; tracked as a separate workstream.
  • Cashu zero-amount receive — blocked by NUT-04 spec, deferred.
  • No UI redesigns, no new flows or components, no refactors beyond what these workstreams require.

Test plan

  • bun run typecheck clean
  • bun run check:all clean (only pre-existing vite-env.d.ts warnings)
  • bun test — 126 / 126 pass (10 new, all green)
    • ExtendedMintInfo.canMeltAmountless — NUT-5 disabled, no amountless method, bolt11/sat advertised, unit-mismatched
    • validateBolt11 with allowZeroAmount: true — amountless passes, non-bitcoin networks still fail
    • CashuSendQuoteService.getLightningQuote — confirms createMeltQuoteBolt11 is called with the user-supplied amount in msat for amountless invoices, without it for amounted invoices
  • E2E smoke test against a NUT-05-amountless mint and a spark wallet — to be driven by alchemist after this PR is open

Notes for reviewers

  • A small app/test-setup.ts is registered via bunfig.toml [test] preload. It polyfills window, window.location, and window.localStorage for the bun test environment so service modules that transitively load agicash-db/database.client.ts (which references window at module scope) can be imported in tests. The polyfill is gated by typeof === 'undefined' checks so it is a no-op in any environment that already provides those globals.
  • canAccountPayAmountlessBolt11 is exported from send-store.ts so the send-input button predicate uses the same gate as the destination validator — cashu and spark behave identically.

@vercel

vercel Bot commented May 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agicash Ready Ready Preview, Comment Jun 5, 2026 10:17pm

Request Review

@supabase

supabase Bot commented May 5, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project hrebgkfhjpkbxpztqqke because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

? 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' }));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should make sure that when the quote gets completed the amount paid is properly updated in the encrypted transaction details

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or maybe even better to change the type to amount can be undefined

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or maybe even better to change the type to amount can be undefined

what do you mean? what would be undefined?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/lib/cashu/protocol-extensions.ts Outdated
* 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/features/receive/receive-spark.tsx Outdated
createQuote({ account, amount });
createQuote({
account,
amount: amount.isZero() ? undefined : amount,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this needed? would it work if we just pass zero amount instead of undefined?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplified per your suggestion in a2a31efreceive-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') }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not amountSats: amount ? amount.toNumber('sat') : undefined?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in a2a31ef. Now: amountSats: amount.isZero() ? undefined : amount.toNumber('sat').

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Investigated and added a docstring in 0402c44. Two module-load failures in agicash-db/database.client.ts make the polyfill necessary:

  1. (window as any).agicashRealtime = agicashRealtimeClient; at module top level (no typeof guard).
  2. createClient(...) triggers a Supabase auth fetch that calls isLoggedIn() -> 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.ts SSR-safe (guard the window references with typeof 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' }));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants