Skip to content

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

Open
orveth wants to merge 8 commits into
masterfrom
cashu-zero-amount-invoices
Open

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

Conversation

@orveth
Copy link
Copy Markdown
Contributor

@orveth orveth commented May 5, 2026

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.

orveth added 4 commits May 5, 2026 06:03
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`.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 5, 2026

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

Project Deployment Actions Updated (UTC)
agicash Ready Ready Preview, Comment May 6, 2026 1:32pm

Request Review

@supabase
Copy link
Copy Markdown

supabase Bot commented May 5, 2026

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').

Comment thread app/test-setup.ts
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?

orveth added 4 commits May 6, 2026 06:23
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.
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