Skip to content

feat: add NWC-321 pay and receive methods with BOLT-11 support - #2543

Open
rolznz wants to merge 1 commit into
masterfrom
feat/nwc-321-pay-receive
Open

feat: add NWC-321 pay and receive methods with BOLT-11 support#2543
rolznz wants to merge 1 commit into
masterfrom
feat/nwc-321-pay-receive

Conversation

@rolznz

@rolznz rolznz commented Aug 12, 2026

Copy link
Copy Markdown
Member

Closes #2504

Implements the NWC-321 (BIP-321 Lightning Payments) pay and receive methods with the minimum scope: BOLT-11 instructions only.

JS SDK implementation: getAlby/js-sdk#576

pay

  • Parses the payment param as a BIP-321 URI (case-insensitive scheme and parameter keys, so fully-uppercased QR URIs work) and selects the lightning (BOLT-11) instruction.
  • Returns UNSUPPORTED_PAYMENT_INSTRUCTION if the URI has no lightning param (e.g. only lno=).
  • Validates the invoice network against the node network and returns UNSUPPORTED_NETWORK on mismatch.
  • Rejects conflicting or invalid amounts before payment: a request amount or BIP-321 amount parameter that contradicts the invoice amount, or a zero amount. For zero-amount invoices, the request amount or the URI amount funds the payment.
  • Rejects payer_note with BAD_REQUEST — BOLT-11 cannot deliver payer-provided messages, and NWC-321 requires the note to be delivered or the request rejected before payment.
  • Rejects unknown req- prefixed parameters (including req-pop) per BIP-321; the optional pop parameter is ignored, as BIP-321 permits for optional parameters.
  • Responds with the spec's result shape: transaction_id, state, instruction_type: "bolt11", amount, fees_paid, payment_hash, preimage, created_at, settled_at.

receive

  • Creates a BOLT-11 invoice and returns bip321: "bitcoin:?lightning=<invoice>" plus transaction_id.
  • A missing/null amount (spec's variable-amount case) returns BAD_REQUEST, since zero-amount invoices are not supported by the transactions service.

Wiring

  • pay reuses the pay_invoice scope and receive reuses make_invoice — no new scopes and no frontend changes; the existing 6-hour stale-request guard applies automatically.
  • All backends (LDK, LND, CLN, Cashu, Phoenixd, Bark) advertise both methods; Cashu/Phoenixd/Bark method lists were also converted from string literals to the models constants.
  • Tests cover: successful pay/receive, BOLT-12-only URI, invalid URI, wrong network, conflicting param/URI amounts, matching URI amount, zero-amount invoice funded via URI amount, payer_note rejection, unknown req- parameter rejection, and missing receive amount. tests.MockNodeInfo.Network changed from testnet to signet to match the signet (lntbs) mock invoices now that the network is validated.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added NIP-47 pay and receive methods across supported Lightning clients.
    • Added payment support for BIP-321 Bitcoin URIs containing BOLT-11 invoices.
    • Added invoice creation through receive requests, returning a BIP-321 Lightning URI and transaction ID.
    • Added validation for amounts, networks, URI parameters, invoice types, and payer notes.
    • Added clear errors for unsupported payment instructions and networks.
  • Permissions

    • Added permission mappings for paying and creating invoices.
  • Testing

    • Added coverage for successful payments and invoice creation, validation failures, network mismatches, and unsupported requests.

Implements the NWC-321 (BIP-321 Lightning Payments) pay and receive
methods, limited to BOLT-11 instructions:

- pay parses the BIP-321 URI, selects the lightning (BOLT-11)
  instruction and rejects URIs without one
  (UNSUPPORTED_PAYMENT_INSTRUCTION), validates the invoice network
  against the node network (UNSUPPORTED_NETWORK), rejects conflicting
  or invalid amounts, unknown req- parameters and payer_note
  (undeliverable over BOLT-11)
- receive returns a BIP-321 URI containing a single BOLT-11 invoice;
  a variable amount is rejected as zero-amount invoices are not
  supported
- both methods reuse the existing pay_invoice / make_invoice scopes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Added NIP-47 pay and receive commands. The implementation supports BIP-321 URIs with BOLT-11 invoices, validates amounts and networks, creates or submits transactions, maps errors, and updates permissions and LN client capabilities.

Changes

NWC-321 commands

Layer / File(s) Summary
Method contracts and advertised capabilities
constants/constants.go, nip47/models/models.go, nip47/permissions/permissions.go, lnclient/..., tests/mock_ln_client.go
Added method and error constants. Updated permissions, supported-method declarations, and mock network data.
BIP-321 pay flow
nip47/controllers/pay_controller.go, nip47/controllers/pay_controller_test.go
Added BOLT-11 payment handling, URI parsing, network and amount validation, error mapping, transaction responses, and tests.
BIP-321 receive flow and dispatch
nip47/controllers/receive_controller.go, nip47/controllers/receive_controller_test.go, nip47/event_handler.go
Added invoice creation, BIP-321 response generation, validation tests, and event routing for pay and receive.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant NIP47Client
  participant EventHandler
  participant Controller
  participant LNService
  NIP47Client->>EventHandler: pay or receive request
  EventHandler->>Controller: dispatch NIP-47 method
  Controller->>LNService: submit payment or create invoice
  LNService-->>Controller: transaction result
  Controller-->>NIP47Client: BIP-321 response or mapped error
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement issue #2504 by adding BOLT-11 pay and receive commands, validation, permissions, dispatch, backend support, and tests.
Out of Scope Changes check ✅ Passed All changes support the NWC-321 pay and receive objectives, including error handling, permissions, backend method advertisement, dispatch, and tests.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main changes: NWC-321 pay and receive methods with BOLT-11 support.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/nwc-321-pay-receive

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@rolznz
rolznz requested review from bumi and frnandu August 12, 2026 14:31

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@nip47/controllers/pay_controller.go`:
- Around line 298-307: Update the amount-parsing logic around the whole and
fractional ParseUint calls to wrap each returned error with fmt.Errorf using
descriptive context identifying the failing BTC amount component and the
original error via %w; preserve the existing zero-value returns and validation
flow.
- Line 163: Guard transaction.Preimage in the existing-settlement response
construction before dereferencing it. Handle a nil preimage safely while
preserving the current Preimage assignment for non-nil values, preventing the
handler from panicking on NULL database rows.

In `@nip47/controllers/receive_controller.go`:
- Around line 34-44: Update the receive controller’s amount validation before
MakeInvoice to reject both nil and zero amounts with ERROR_BAD_REQUEST; in
nip47/controllers/receive_controller.go lines 34-44, extend the existing check
while preserving its response and return behavior. Add a regression case in
nip47/controllers/receive_controller_test.go lines 83-102 that submits amount 0
and asserts ERROR_BAD_REQUEST.

In `@nip47/event_handler.go`:
- Around line 441-446: Redact pay and receive request parameters in
nip47/event_handler.go:441-446 before the existing full-params log, then remove
sensitive payment data from each affected log: omit payment at
nip47/controllers/pay_controller.go:57-63, bolt11 at :90-94 and :127-139, and
request description and amount at nip47/controllers/receive_controller.go:46-60
while retaining request/app identifiers; use WithError(err).Error(...) for
receive invoice failures.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: da6bcfc1-cec8-4623-8317-adde61efa3f9

📥 Commits

Reviewing files that changed from the base of the PR and between 5125418 and c2ae53d.

📒 Files selected for processing (15)
  • constants/constants.go
  • lnclient/bark/bark.go
  • lnclient/cashu/cashu.go
  • lnclient/cln/cln.go
  • lnclient/ldk/ldk.go
  • lnclient/lnd/lnd.go
  • lnclient/phoenixd/phoenixd.go
  • nip47/controllers/pay_controller.go
  • nip47/controllers/pay_controller_test.go
  • nip47/controllers/receive_controller.go
  • nip47/controllers/receive_controller_test.go
  • nip47/event_handler.go
  • nip47/models/models.go
  • nip47/permissions/permissions.go
  • tests/mock_ln_client.go

Amount: transaction.AmountMsat,
FeesPaid: transaction.FeeMsat,
PaymentHash: transaction.PaymentHash,
Preimage: *transaction.Preimage,

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map and inspect the transaction type and every SendPaymentSync implementation.
while IFS= read -r file; do
  if rg -q 'SendPaymentSync|type\s+\w*Transaction\b|Preimage' "$file"; then
    ast-grep outline "$file" --items all
    rg -n -C 5 'SendPaymentSync|type\s+\w*Transaction\b|Preimage' "$file"
  fi
done < <(fd -t f -e go)

Repository: getAlby/hub

Length of output: 202


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate files ---'
fd -t f -e go | sort | sed -n '1,240p'

printf '%s\n' '--- relevant symbols ---'
rg -n -C 6 'SendPaymentSync|type[[:space:]]+[A-Za-z0-9_]*Transaction|Preimage' -g '*.go' .

Repository: getAlby/hub

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- SendPaymentSync definitions and calls ---'
rg -n -C 12 'func[[:space:]]+\([^)]*\)[[:space:]]*SendPaymentSync|func[[:space:]]+SendPaymentSync|SendPaymentSync[[:space:]]*\(' -g '*.go' . \
  | grep -v '_test.go' | sed -n '1,420p'

printf '%s\n' '--- transaction model declarations ---'
rg -n -C 20 'type[[:space:]]+Transaction[[:space:]]+struct|Preimage[[:space:]]+\*string|Preimage[[:space:]]+\*' -g '*.go' . \
  | grep -v '_test.go' | sed -n '1,260p'

printf '%s\n' '--- pay handler around the dereference ---'
sed -n '125,180p' nip47/controllers/pay_controller.go

Repository: getAlby/hub

Length of output: 38221


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- transaction service files ---'
fd -t f transactions | sort

printf '%s\n' '--- exact service implementation ---'
rg -n -C 18 'SendPaymentSync' transactions -g '*.go' | sed -n '1,520p'

printf '%s\n' '--- PayInvoiceResponse declaration and uses ---'
rg -n -C 16 'type[[:space:]]+PayInvoiceResponse|PayInvoiceResponse[[:space:]]*\{' -g '*.go' . \
  | grep -v 'tests/mocks' | sed -n '1,520p'

printf '%s\n' '--- successful response return statements ---'
rg -n -C 12 'return[[:space:]]+&lnclient\.PayInvoiceResponse|return[[:space:]]+&PayInvoiceResponse|PayInvoiceResponse\{' -g '*.go' lnclient tests | grep -v 'tests/mocks' | sed -n '1,520p'

Repository: getAlby/hub

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- SendPaymentSync complete control flow ---'
sed -n '301,475p' transactions/transactions_service.go

printf '%s\n' '--- settlement implementation ---'
rg -n -C 24 'func[[:space:]]+\([^)]*\)[[:space:]]*markTransactionSettled|func[[:space:]]+markTransactionSettled|markTransactionSettled[[:space:]]*\(' transactions -g '*.go' | sed -n '1,360p'

printf '%s\n' '--- self-payment implementation ---'
rg -n -C 24 'interceptSelfPayment' transactions -g '*.go' | sed -n '1,300p'

printf '%s\n' '--- all assignments to db.Transaction.Preimage ---'
rg -n -C 8 '\.Preimage[[:space:]]*=|Preimage:[[:space:]]*&|Preimage:[[:space:]]*[A-Za-z_][A-Za-z0-9_]*' transactions -g '*.go' | grep -v '_test.go' | sed -n '1,360p'

Repository: getAlby/hub

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- markTransactionSettled definition ---'
rg -n -C 35 'markTransactionSettled' transactions/transactions_service.go | sed -n '1,320p'

printf '%s\n' '--- transaction conversion after settlement ---'
rg -n -C 24 'func[[:space:]]+.*Transaction|Preimage[[:space:]]*:' transactions/transactions_service.go | sed -n '1,360p'

printf '%s\n' '--- direct behavioral shape check ---'
python3 - <<'PY'
from pathlib import Path
p = Path("transactions/transactions_service.go")
s = p.read_text()
needle = "markTransactionSettled"
for i, line in enumerate(s.splitlines(), 1):
    if needle in line:
        print(f"{i}: {line}")
PY

Repository: getAlby/hub

Length of output: 26512


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1445,1535p' transactions/transactions_service.go

Repository: getAlby/hub

Length of output: 3323


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 35 'func[[:space:]]+\([^)]*\)[[:space:]]*findSettledTransaction|findSettledTransaction[[:space:]]*\(' transactions/transactions_service.go

Repository: getAlby/hub

Length of output: 8028


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

source = Path("transactions/transactions_service.go").read_text()
start = source.index("func (svc *transactionsService) markTransactionSettled")
end = source.index("\n}\n\n// createSettledTransactionFromNotification", start) + 2
body = source[start:end]

checks = {
    "empty preimage rejected": re.search(r'if preimage == "" \{\s*return nil, errors\.New\("no preimage in payment"\)', body, re.S),
    "new settlement writes pointer": re.search(r'"Preimage":\s*&preimage', body),
    "existing settlement returned unchanged": re.search(
        r'if existingSettledTransaction != nil \{.*?settledTransaction = existingSettledTransaction',
        body, re.S
    ),
}
for name, matched in checks.items():
    print(f"{name}: {'yes' if matched else 'no'}")
if not all(checks.values()):
    raise SystemExit(1)
PY

Repository: getAlby/hub

Length of output: 257


Guard the nullable transaction.Preimage before dereferencing it. The existing-settlement path can return a database row with a NULL preimage, which can panic this handler.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nip47/controllers/pay_controller.go` at line 163, Guard transaction.Preimage
in the existing-settlement response construction before dereferencing it. Handle
a nil preimage safely while preserving the current Preimage assignment for
non-nil values, preventing the handler from panicking on NULL database rows.

Comment on lines +298 to +307
whole, err := strconv.ParseUint(intPart, 10, 64)
if err != nil {
return 0, err
}
if whole > 21_000_000 {
return 0, fmt.Errorf("amount too large")
}
frac, err := strconv.ParseUint(fracPart, 10, 64)
if err != nil {
return 0, err

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Wrap ParseUint errors with parse context.

Lines 300 and 307 return parser errors without identifying the failing BTC amount component. Wrap both errors with %w.

Proposed fix
 if err != nil {
-	return 0, err
+	return 0, fmt.Errorf("parse whole BTC amount: %w", err)
 }
 ...
 if err != nil {
-	return 0, err
+	return 0, fmt.Errorf("parse fractional BTC amount: %w", err)
 }

As per coding guidelines, **/*.go requires errors to use fmt.Errorf("context: %w", err) for debugging.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
whole, err := strconv.ParseUint(intPart, 10, 64)
if err != nil {
return 0, err
}
if whole > 21_000_000 {
return 0, fmt.Errorf("amount too large")
}
frac, err := strconv.ParseUint(fracPart, 10, 64)
if err != nil {
return 0, err
whole, err := strconv.ParseUint(intPart, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse whole BTC amount: %w", err)
}
if whole > 21_000_000 {
return 0, fmt.Errorf("amount too large")
}
frac, err := strconv.ParseUint(fracPart, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse fractional BTC amount: %w", err)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nip47/controllers/pay_controller.go` around lines 298 - 307, Update the
amount-parsing logic around the whole and fractional ParseUint calls to wrap
each returned error with fmt.Errorf using descriptive context identifying the
failing BTC amount component and the original error via %w; preserve the
existing zero-value returns and validation flow.

Source: Coding guidelines

Comment on lines +34 to +44
if receiveParams.Amount == nil {
// variable-amount (zero-amount) invoices are not supported
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: &models.Error{
Code: constants.ERROR_BAD_REQUEST,
Message: "amount is required",
},
}, nostr.Tags{})
return
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject zero receive amounts.

A request with "amount": 0 passes the current check and reaches MakeInvoice. This creates a variable-amount invoice, but this PR supports fixed-amount BOLT-11 receive requests only.

  • nip47/controllers/receive_controller.go#L34-L44: Reject both a nil amount and an amount of zero before calling MakeInvoice.
  • nip47/controllers/receive_controller_test.go#L83-L102: Add a regression test for "amount": 0 and assert ERROR_BAD_REQUEST.
📍 Affects 2 files
  • nip47/controllers/receive_controller.go#L34-L44 (this comment)
  • nip47/controllers/receive_controller_test.go#L83-L102
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nip47/controllers/receive_controller.go` around lines 34 - 44, Update the
receive controller’s amount validation before MakeInvoice to reject both nil and
zero amounts with ERROR_BAD_REQUEST; in nip47/controllers/receive_controller.go
lines 34-44, extend the existing check while preserving its response and return
behavior. Add a regression case in nip47/controllers/receive_controller_test.go
lines 83-102 that submits amount 0 and asserts ERROR_BAD_REQUEST.

Comment thread nip47/event_handler.go
Comment on lines +441 to +446
case models.PAY_METHOD:
controller.
HandlePayEvent(ctx, nip47Request, requestEvent.ID, &app, publishResponse)
case models.RECEIVE_METHOD:
controller.
HandleReceiveEvent(ctx, nip47Request, requestEvent.ID, app.ID, publishResponse)

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not log payment request data.

The new flows log full request parameters, BIP-321 URIs, BOLT-11 invoices, descriptions, and amounts. These values can expose payment and user information in application logs.

  • nip47/event_handler.go#L441-L446: Redact pay and receive request parameters before the existing full-params log executes.
  • nip47/controllers/pay_controller.go#L57-L63: Remove payment from the error log fields.
  • nip47/controllers/pay_controller.go#L90-L94: Remove bolt11 from the decode-failure log fields.
  • nip47/controllers/pay_controller.go#L127-L139: Remove bolt11 from payment lifecycle log fields.
  • nip47/controllers/receive_controller.go#L46-L60: Remove request description and amount from log fields. Retain request and app identifiers. Use WithError(err).Error(...) for invoice failures.

As per coding guidelines, **/*.{go,ts,tsx} says, “Never log sensitive data such as seeds, macaroons, or tokens.”

📍 Affects 3 files
  • nip47/event_handler.go#L441-L446 (this comment)
  • nip47/controllers/pay_controller.go#L57-L63
  • nip47/controllers/pay_controller.go#L90-L94
  • nip47/controllers/pay_controller.go#L127-L139
  • nip47/controllers/receive_controller.go#L46-L60
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nip47/event_handler.go` around lines 441 - 446, Redact pay and receive
request parameters in nip47/event_handler.go:441-446 before the existing
full-params log, then remove sensitive payment data from each affected log: omit
payment at nip47/controllers/pay_controller.go:57-63, bolt11 at :90-94 and
:127-139, and request description and amount at
nip47/controllers/receive_controller.go:46-60 while retaining request/app
identifiers; use WithError(err).Error(...) for receive invoice failures.

Source: Coding guidelines

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.

Add support for new NWC pay and receive commands

1 participant