Add NWC (Nostr Wallet Connect) as alternative Lightning backend#396
Add NWC (Nostr Wallet Connect) as alternative Lightning backend#396themikemoniker wants to merge 16 commits into
Conversation
|
bump up my post |
46061bd to
a160fb5
Compare
|
Hey! 👋 This PR is ready for review. Here's a quick orientation: What this addsA Where to start reviewing
What's tested
Known limitations (intentional, documented in MERGE.md)
Thanks for the review! |
3ab0cee to
d9eec2f
Compare
|
@iorch are you taking this PR over? guess i can test this with my new phoenixd + nwc... |
|
@chrisguida yes, it was outdated, and had some hardcoded configuration. |
Sharmaz
left a comment
There was a problem hiding this comment.
Frontend Reviewed, Requested Changes, fix the comments.
There was a problem hiding this comment.
Tested this end-to-end against a phoenixd-NWC fork I'm running, plus full static review of the diff. Lots to like — clean separation of LightningBackend, the nwc/ package is self-contained and easy to read, and the unit tests cover the URI parser and the service-level conversions thoroughly. CI is green and ktlintCheck passes.
That said, this isn't ready to merge yet. Live testing surfaced a handful of blocking bugs and several UX issues that I think need to be addressed before this lands. Findings are grouped by severity below; references to file paths use the PR's nwc-integration branch.
Blocking
B1. Nip47Info deserialization is too strict and crashes get_info on real-wallet payloads
First call to /wallet/getinfo against my (admittedly non-spec) phoenixd-NWC fork produced:
Unhandled exception: Expected JsonPrimitive, but had JsonArray as the serialized body of string at element: $.block_hash
JSON input: []
Two things wrong here:
- The
block_hashfield is never read by Ambrosia (grep blockHashinservices/NwcService.ktreturns nothing — only theNip47Types.kt:68-69declaration). Failing the entireget_infocall for an unused field is gratuitous. - The FE renders this as the same "Lightning node is not available. Make sure phoenixd is running." error that phoenixd connection failures produce — actively misleading for an NWC user. Root cause is
Wallet.ktroutes returning a genericPhoenixServiceExceptionmapping for any backend failure (Handler.kt:90-93).
Suggested fixes:
- Drop
blockHashfromNip47Infoentirely (preferred — it's unused). - Add
coerceInputValues = truetolenientJsoninNwcClient.kt:28for broader robustness against future spec drift. - Add a JSON deserialization round-trip test using a captured wallet response (Alby Hub / Mutiny / Zeus). The current tests construct
Nip47Info(...)in-memory and miss this entire failure mode. - Also: when the backend is
NwcService, the wallet error path shouldn't surface phoenixd-flavored copy. Either a newNwcConnectionExceptionflow inHandler.ktor a generic backend-agnostic message.
B2. payInvoice reports wrong amount and empty payment hash on success
Paid a 1-sat fixed-amount invoice end-to-end. Payment went through (toast: "Payment sent successfully"), but the confirmation modal shows:
Amount Sent: 0 sats, Routing Fee: 0 sats, Payment Hash: (empty, with "Copy" button)
Bug in services/NwcService.kt:149-159:
recipientAmountSat = request.amountSat ?: 0, // null when invoice carries amount → 0
paymentHash = "", // hardcoded emptyNIP-47 pay_invoice returns only preimage and feesPaid. The amount and hash both have to be derived from the bolt11 string (Ambrosia already has a Bolt11Decoder used by PhoenixService for extractDescription). NwcServiceTest.kt:130-140 only asserts routingFeeSat and paymentPreimage — neither of these fields is covered.
B3. NWC activation requires a manual server restart with no in-app trigger
After completing onboarding with NWC selected, the wallet page shows the same "Lightning node not available" error from B1 until the operator manually runs docker compose -f docker-compose.dev-nwc.yml restart ambrosia-dev (or equivalent for native installs). Root cause: configureWallet() in api/Wallet.kt:36-43 reads nwc-uri from environment.config exactly once at module init. The "NWC URI saved — restart required to activate" toast tells the user a restart is needed but provides no path to do it from the UI.
In a docker deployment (which this PR explicitly ships dev tooling for), there is no in-app way to recover. Suggestions in priority order:
- Drop the "restart required" model — make
configureWallet()re-resolve backend on each request, or on a config-change event. - Have the setup endpoint trigger a graceful self-restart after persisting NWC URI.
- At minimum, route the wallet error through a different exception class so the UI message matches reality ("NWC not yet active — restart the server" instead of "phoenixd not running").
B4. "Close Channel" button is clickable but always 501s under NWC
getNodeInfo() in NwcService.kt:130-147 synthesizes a fake channel with state: "NORMAL" to keep the existing <ChannelCard> UI happy. That FE component then enables the "Close Channel" button whenever state === "NORMAL" (ChannelCard.jsx:91). Clicking it triggers closeChannel → UnsupportedBackendOperationException → 501 → unhandled error in the UI. Easy to miss in review because it requires clicking a button to discover the failure.
The whole "Lightning Channels" card is dead weight under NWC — see U2 below for the recommended fix.
B5. Base branch is development but PR is rebased onto main (per MERGE.md)
GitHub reports mergeable: CONFLICTING. MERGE.md explicitly states it's rebased onto origin/main (v0.6.0-beta). Whatever the intended target, this needs a clean rebase before merge.
Important (should fix before merge)
I1. feesPaid units mismatch in transaction listings
Nip47Transaction.feesPaid is millisats per spec. toIncomingPayment/toOutgoingPayment (NwcService.kt:290, 310) pass it straight to IncomingPayment.fees / OutgoingPayment.fees. Compare with payInvoice (line 154) which correctly divides by 1000. Listings will show fees 1000× too large.
I2. Resource leak — NwcService.create() never closes
NwcService.create() constructs an HttpClient(CIO) and CoroutineScope(SupervisorJob() + Dispatchers.IO) (NwcService.kt:243-249) but nothing closes them on server shutdown. Each configureWallet() call leaks both. Tolerable today because configureWallet() is called once at startup, but fragile.
I3. chain defaults silently to "mainnet" if wallet omits network
NwcService.kt:143 — chain = info.network ?: "mainnet". A wallet that doesn't return network (or returns an unrecognized value) would silently mislabel the chain on regtest/testnet. At minimum log a warning and surface "unknown" instead of "mainnet".
I4. Connect-and-poll race on first request
NwcService.create() returns synchronously while the relay connect happens in a fire-and-forget scope.launch{} (NwcService.kt:254-262). The wallet routes are immediately bound. A request that arrives before connect() completes will hit NostrRelay.send() which throws NwcConnectionException("Not connected to relay"). Hard to hit in practice (the relay handshake is fast) but worth either gating routes behind a ready flag or awaiting connect.
I5. No re-subscribe after relay reconnect
Acknowledged in MERGE.md but worth restating: NostrRelay reconnects with exponential backoff on disconnect (good), but NwcClient.connect() only subscribes once at startup. After a reconnect the relay no longer has the #p filter, so NIP-47 responses get lost. Manifests as the wallet UI silently going stale after a network blip.
I6. Polling makes one round-trip per pending invoice per cycle
pollPendingInvoices() (NwcService.kt:67-92) calls lookup_invoice for every entry every 3 seconds. With N concurrent unpaid invoices that's N×20 relay events per minute, all going through one WebSocket. A single list_transactions(unpaid=false, from=oldestPendingTime) would pull all settlements in one round-trip and let you reconcile against pendingInvoices. Important for any deployment with more than a handful of concurrent invoices.
UX
U1. NWC onboarding flow communicates the restart requirement, but only via a transient toast
The "NWC URI saved" toast disappears after a few seconds. After it's gone, the user has no indication that a restart is required and no way to recover. See B3 for full fix; at minimum surface a persistent banner on the post-onboarding landing page until the backend is actually active.
U2. "Lightning Channels" card under NWC is duplicative and confusing
The synthesized nwc-virtual channel has:
channelId: nwc-virtual— meaningless to a non-node-runner.balanceSat == capacitySat == nodeBalance— duplicates the "Total Balance" card directly above it.inboundLiquiditySat: 0— wrong; NWC has no way to query inbound, so claiming 0 is misleading.- "Close Channel" button enabled (B4).
Clean fix: have NwcService.getNodeInfo() return channels: emptyList() and let the FE handle empty gracefully (it already does — the section just disappears). Honest, requires no FE change.
U3. docker-compose.dev-nwc.yml ports bound to 127.0.0.1 hang in browser
Compose binds 127.0.0.1:3001:3001. Next.js 16's allowedDevOrigins blocks 127.0.0.1 as cross-origin from localhost, killing dev-runtime hydration. First-time reviewers will see a blank "Loading…" page with no error. Fix: bind as localhost:3001:3001, or add allowedDevOrigins: ['127.0.0.1'] to client/next.config.js, or document in the compose file header.
U4. Compose file's documented NWC_URI=... shortcut doesn't actually work
The header comment in docker-compose.dev-nwc.yml says:
# Start with NWC already configured (skip onboarding):
# NWC_URI="nostr+walletconnect://..." docker compose -f docker-compose.dev-nwc.yml up -d --build
But the ambrosia-dev service has no environment: block forwarding NWC_URI. The host env var won't reach the container. Add:
environment:
NWC_URI: ${NWC_URI:-}Nits
- N1: Drop
MERGE.mdfrom the repo. It belongs in the PR description (and is largely already duplicated there). Source-tree pollution of design doc + future-work notes. - N2:
Nip47Info.blockHash(B1 above) and the unusedNip47Transactionextension functions could be slimmed down — thedescriptionHash,metadata,expiresAt-as-fallback fields aren't surfaced anywhere downstream. - N3:
getNodeInfo()makes two relay round-trips (getInfo+getBalance). Consider caching the balance for ~3 seconds since the same data feeds the Total Balance card and the synthesized channel. - N4:
NwcService.getOutgoingPaymentByHashuseslookupInvoice(which is for incoming) and converts the result toOutgoingPayment. NIP-47 has no equivalent of "outgoing lookup by hash" — this should probably also throwUnsupportedBackendOperationExceptionfor clarity, since the result for a real outgoing-only wallet is going to be wrong. - N5: Existing review comments from @Sharmaz weren't addressed — generic abbreviated names in
Onboarding.jsx:111, unnecessary comments in tests andOnboarding.jsx, restaurant-related changes inlib/modules.js. - N6: Add a regtest path to the test instructions. Phoenixd has no regtest LSP so it can't be used for regtest testing; CLN + cln-nip47 plugin is the standard path. A
docker-compose.regtest-nwc.ymloverlay (bitcoind regtest → CLN → cln-nip47 → Ambrosia) would make this PR much easier to review and would unblock regtest CI integration tests. - N7:
pendingRequestsinNwcClientdoesn't TTL strandedCompletableDeferredentries on relay-mid-request drops — acknowledged inMERGE.md. Minor since the 30swithTimeoutcleans up, but worth fixing alongside I6.
What I verified works
- All 24 server unit tests pass on Java 21 (
NwcServiceTest,NwcUriParserTest). - All 80 client unit tests pass.
ktlintCheckis clean.- NWC URI parsing across the cases the unit tests cover.
- After the deserialization fix on the wallet side and a server restart: relay connection, subscription,
get_info→ node info renders,make_invoice→ invoice generated and tracked, payment polling detectssettledAtwithin ~3s,PaymentNotifierbroadcasts over/ws/payments, UI receives the event. Receive flow works end-to-end.
Test environment
- Local Ambrosia in
docker-compose.dev-nwc.yml(Linux, Java 21, Node 20). - A phoenixd-NWC fork as the wallet (chrisguida/phoenixd@electrum-nwc-bolt12), patched mid-test to drop a non-spec
block_hash: []fromget_info. The wallet-side bug isn't relevant to this PR, but it surfaced the deserialization brittleness in B1. - NWC URI over a public Nostr relay.
This is a substantial and useful contribution and most of these are mechanical to fix. I'd like to see B1–B5 resolved before merge, plus a triage pass on I1–I6. Happy to re-review once those are addressed.
|
Thanks for the thorough review — really useful findings. Here's what's been addressed: Blocking
Important
UX / Docker
Nits
Regression tests added for B1 ( |
|
@iorch nice! will review again soon |
Introduce a LightningBackend interface that both PhoenixService and a new
NwcService implement. A --nwc-uri CLI arg determines which backend is
active. Wallet API routes return the same JSON shapes regardless of
backend, so the client works unchanged. Any NWC-compatible wallet (Alby
Hub, Mutiny, Zeus) can now power the POS without running a self-hosted
Lightning node.
The NWC protocol layer (nwc/ package) handles NIP-47 request/response
over Nostr relays using nostrino for secp256k1 crypto and Ktor WebSocket
client for relay communication. Payment detection uses invoice polling
since NWC has no webhook equivalent.
Backend changes:
- nwc/: NwcClient, NostrRelay, Nip47Types, NwcConnectionInfo, NwcUriParser
- services/NwcService.kt: invoice creation, balance, payment polling
- services/LightningBackend.kt: shared interface for Phoenix and NWC
- api/InitialSetup.kt: persist nwc-uri to ambrosia.conf on setup,
typed InitialSetupResponse to fix mixed-type serialization
- config/ConfigFile.kt: fix split('=').last() bug (values with '=' signs)
- Api.kt: --nwc-uri flag selects NwcService at startup
Frontend changes:
- Onboarding: new Lightning Backend step (phoenixd vs NWC + URI input)
- StepsSummary: show wallet backend in summary step
- Wallet: case-insensitive channel state comparison (NORMAL)
- HistoryTab: correct type discriminators and fees units for NWC payments
- docker-compose.dev-nwc.yml: isolated dev stack with hot-reload client
…nient decoder block_hash: [] from real wallets caused get_info to crash — the field was typed String? but received a JSON array. The field is never read downstream; dropping it lets ignoreUnknownKeys handle any value a wallet sends. coerceInputValues = true added for broader spec-drift resilience.
…d chain fallback B2: payInvoice decoded recipientAmountSat and paymentHash from the bolt11 string via Bolt11Decoder — NIP-47 pay_invoice only returns preimage. B4/U2: getNodeInfo returns channels = emptyList(). The synthesized nwc-virtual channel with state NORMAL was enabling the Close Channel button, which always returned 501. I1: feesPaid in toIncomingPayment and toOutgoingPayment now divided by 1000; NIP-47 reports fees in millisats. I3: null network field logs a warning and returns "unknown" instead of silently claiming "mainnet". N4: getOutgoingPaymentByHash throws UnsupportedBackendOperationException — NIP-47 lookup_invoice is for incoming payments only.
configureWallet() read nwc-uri once at startup from MapApplicationConfig, so saving the URI during onboarding had no effect until restart. Backend is now held in an AtomicReference; InitialSetup calls reinitializeNwcBackend() immediately after writing to ambrosia.conf — wallet is live on the next request.
…dev-nwc compose
U3: 127.0.0.1:3001 -> localhost:3001 — Next.js 16 blocked 127.0.0.1 as
cross-origin from localhost, causing a blank dev page with no error.
U4: Added environment: NWC_URI: ${NWC_URI:-} to the ambrosia-dev service
so the documented one-liner actually reaches the container.
Nip47DeserializationTest: 4 cases verifying block_hash: [] no longer crashes deserialization and unknown fields are ignored correctly. Bolt11DecoderTest: 6 cases for extractAmountSat and extractPaymentHash using the BOLT11 spec test vector (lnbc2500u, 250 000 sat). NwcServiceTest: empty channel list, unknown chain, getOutgoingPaymentByHash unsupported, feesPaid millisat-to-sat conversion in incoming/outgoing listings, and explicit amountSat passthrough in payInvoice.
modules.js: removed types: ["restaurant"] added by this PR to 5 routes in the orders and spaces modules — restaurant is disabled, the restriction made those routes inaccessible for all business types. Onboarding.jsx: renamed res -> setupResponse. Onboarding.test.jsx: extracted navigateToStep(button, targetStep) helper and replaced commented click sequences throughout.
Needless blank line in PhoenixService.kt and alignment whitespace in NwcServiceTest.kt — both introduced when resolving merge conflicts against upstream/development.
Address review findings I2, I4, I5, I6 from PR olympus-btc#396: I2 — Resource cleanup on shutdown and hot-reload: - LightningBackend extends AutoCloseable with a no-op default close(). - NwcService.close() cancels the polling job, cancels its CoroutineScope, and closes the underlying NwcClient. - NwcClient.close() now closes the HttpClient too (not only the relay). - configureWallet() subscribes ApplicationStopping to close the active backend on Ktor shutdown. - reinitializeNwcBackend() closes the previous backend before replacing it via AtomicReference.getAndSet, so hot-reload no longer leaks. I5 — Re-subscribe NIP-47 filter on relay reconnect: - NostrRelay.connect() accepts an onConnected callback invoked after every successful WebSocket open (initial connect and any backoff reconnect). - NwcClient passes the subscribe(subId, filter) call as that callback so the #p filter is re-issued whenever the relay forgets it. I4 — Block wallet routes until the backend is ready: - NwcService exposes a CompletableDeferred<Unit> ready signal. - create() completes it after the relay handshake + initial subscription succeed and startPolling() returns; it completes exceptionally if initialization throws. - All public NwcService methods that touch nwcClient await ready first, so requests arriving before the handshake suspend instead of throwing "Not connected to relay". I6 — Batch invoice polling with list_transactions: - pollPendingInvoices() now issues a single list_transactions call per cycle (type=incoming, unpaid=false, from=oldestPendingCreatedAt) and reconciles results against pendingInvoices, instead of one lookup_invoice per pending hash. One round-trip per cycle regardless of how many invoices are outstanding. Tests: - NwcServiceTest: existing polling tests rewritten against the batched contract; added regression tests for the ready gate (I4), the close contract (I2), and the single-round-trip guarantee for N pending invoices (I6).
After B3 the server hot-reloads the NWC backend when the URI is saved (reinitializeNwcBackend in InitialSetup.kt), so the toast and the WalletBackendStep hint that say "restart the server to activate" are stale and misleading. Replace the copy with text that matches reality: the backend activates immediately when nwcSaved is true.
N2 — Slim Nip47 types: - Nip47Info: drop alias, color, methods, notifications. Only pubkey, network, blockHeight are surfaced downstream. - Nip47Transaction: drop descriptionHash and metadata. Neither field is surfaced anywhere; the rest (description, preimage, paymentHash, amount, feesPaid, createdAt, expiresAt, settledAt) stays. N3 — Remove redundant get_balance round-trip in getNodeInfo: - getNodeInfo() was calling nwcClient.getBalance() and computing a balanceSat that was never used in the NodeInfo return (channels is always emptyList per U2). Dead call removed; one fewer relay round-trip per request. Tests: - Nip47DeserializationTest: drop alias assertions and verify the dropped fields now round-trip cleanly as ignored unknown keys. - NwcServiceTest: drop unused getBalance stubs in the getNodeInfo tests; add a regression that verifies getNodeInfo never calls getBalance.
NostrRelay.connect() now accepts an onDisconnect callback that fires
after every successful WebSocket close (initial connect failures are
still surfaced via connected.completeExceptionally and don't trigger
the callback).
NwcClient passes a callback that completes every entry in
pendingRequests exceptionally with NwcConnectionException("Relay
disconnected") and clears the map. Previously each stranded
CompletableDeferred sat for up to 30s until withTimeout cleaned it up;
now callers receive the connection error immediately and the map is
freed.
Phoenixd has no regtest LSP so it cannot back the POS in regtest, which
makes the existing dev-nwc compose unsuitable for reviewing the NWC
backend end-to-end. This change ships a self-contained overlay that
reviewers can stand up with a single `docker compose up --build`:
bitcoind (regtest)
── lightningd (CLN v25.02 + cln_nwc plugin baked in)
── nostr-relay (scsibug/nostr-rs-relay)
── ambrosia-dev (NWC_URI wired in via env)
── client-dev (Next.js dev server)
Image and tooling:
- docker/cln-regtest-nwc/Dockerfile builds CLN with gudnuf/cln_nwc and
its Python deps installed. The plugin's hardcoded relay URL
(wss://relay.getalby.com/v1) is rewritten at build time to the
in-stack relay so the loop stays offline.
- docker-compose.regtest-nwc.yml binds every port to 127.0.0.1 and uses
*_regtest_* volume names so it never collides with the production or
dev-nwc stacks.
- docker/cln-regtest-nwc/README.md walks through the bring-up:
bitcoin-cli createwallet + 101-block maturity, lightning-cli nwc-create
to mint the URI, paste into onboarding or restart ambrosia-dev with
NWC_URI set.
This unblocks regtest CI integration tests and gives reviewers a
deterministic environment to verify B1/B2/B3/B4/I1/I2/I4/I5/I6/U1
without leaning on a real wallet.
elementsproject/lightningd:v25.02 runs as root (not as a `lightning` user) and stores its data at /root/.lightning (per LIGHTNINGD_DATA env var on the upstream image). My initial Dockerfile and compose assumed a `lightning` user that doesn't exist, which made the image build fail on `chown -R lightning:lightning`. The upstream entrypoint also prepends `--network=$LIGHTNINGD_NETWORK` before our args, so passing a second `--network=regtest` from compose would have caused a duplicate-flag error. Override via the env var instead.
… test After the NWC wallet-backend step was inserted at position 2, the user account form moved to step 3. Aligns this test with the sibling password-validation tests that already navigate via navigateToStep.
Introduce a LightningBackend interface that both PhoenixService and a new NwcService implement. A --nwc-uri CLI arg determines which backend is active. Wallet API routes return the same JSON shapes regardless of backend, so the client works unchanged.
The NWC protocol layer (nwc/ package) handles NIP-47 request/response over Nostr relays using nostrino for secp256k1 crypto and Ktor WebSocket client for relay communication. Payment detection uses invoice polling since NWC has no webhook equivalent.