Skip to content

feat: pay bolt11 Lightning invoices via /api/v1/send - #72

Open
xbuddhi wants to merge 5 commits into
mainfrom
xbuddhi/feat/api-invoice-payments
Open

xbuddhi wants to merge 5 commits into
mainfrom
xbuddhi/feat/api-invoice-payments

Conversation

@xbuddhi

@xbuddhi xbuddhi commented Sep 29, 2026

Copy link
Copy Markdown

Continues #66, which was closed automatically when its branch was renamed to xbuddhi/feat/api-invoice-payments. Review history is on #66.

Summary

Adds external bolt11 Lightning invoice payments to the existing /api/v1/send endpoint. When to is a lnbc... payment request (optionally lightning:-prefixed), the bot pays it externally via lnbits instead of doing an internal transfer.

What changed

Invoice payments

  • to matching a bolt11 invoice routes to sendToInvoice; the amount comes from the invoice (amountless invoices are rejected).
  • Same guardrails as internal sends: min/max limits and the per-wallet admin-approval threshold (amounts above it go through the Telegram approval flow).
  • Idempotency on the invoice payment hash: a short lock for concurrent requests plus a durable lock so an invoice can have only one open approval.

Settlement confirmation

  • lnbits answers once it hands the payment to its backend, not when it settles, so each payment is followed to a final state (settlement_timeout, default 60s, kept inside the 90s server write timeout).
  • 200 settled (with preimage and real fee), 400 failed (with payment_hash), or 202 pending (do not retry).
  • New GET /api/v1/send/payment/{payment_hash} to resolve a pending payment; approved payments that stay unresolved are recorded as in_flight, never expired.

Concurrent payments

  • Several invoices can be paid in parallel from one wallet. Each payment reserves amount + 2% fee reserve against the available balance (wallet − pots − other in-flight payments) until lnbits accepts it, so parallel payments cannot spend pot funds.

Security / review fixes

  • HMAC signatures are single use, and future timestamps are rejected (replay protection).
  • payment_hash is validated (64 hex chars) before it reaches an lnbits URL.
  • Settlement polling and analytics share one lnbits rate limiter.
  • Fee reserve uses integer math; approvals re-check the available balance.
  • Duplicate-approval check fails closed on storage errors; a failed Telegram approval delivery returns an error instead of a false 202.
  • The Telegram approve button expires with the pending transaction (24h), matching /api/v1/send/status.

lnbits 1.x

  • Payment time accepts RFC3339 strings; payment status (success/pending/failed) is read. Branch is up to date with main (lnbits 1.x account API).

⚠️ Behaviour changes for other integrations

  • All HMAC wallet endpoints (/send, /userbalance, /usertransactions, /referral/lookup, status endpoints) now reject a reused signature (401 Request already processed). Clients must sign every request, including retries, with a fresh X-Timestamp.
  • Approvals older than 24h can no longer be approved.

Docs

  • Provider guide: docs/invoice-payments-api.md
  • Updated docs/referral-api.md and README.md.
  • New config: api.send.settlement_timeout (seconds, default 60).

Testing

  • go build ./... clean
  • go test ./internal/lnbits/ ./internal/utils/ pass
  • Settlement classification, payment-hash validation, fee reserve, balance reservation (incl. 50 concurrent requests) and approval expiry are unit tested. The telegram package tests run from the repo root (i18n loads translations/ relative to the working directory).

🤖 Generated with Claude Code

xbuddhi added 5 commits July 20, 2026 19:11
Allow the API send endpoint to pay an external bolt11 invoice when the
`to` field is a `lnbc...` payment request (optionally `lightning:`-prefixed).

- Decode the invoice and take the amount from it; reject amountless invoices
- Enforce the same min/max and admin-approval thresholds as internal sends
- Idempotency lock + dedup keyed on the invoice payment hash
- Balance check reserves ~2% for routing fees; response reports the real fee
- Large invoices reuse the existing Telegram admin-approval flow
- Response adds `payment_hash` and `fee` (sats) for invoice payments

Docs: add provider-facing docs/invoice-payments-api.md and update
referral-api.md / README for invoice support.
Balance and approval safety:
- Re-check the *available* balance (wallet minus pot reservations) when an
  API transaction is approved, for both the internal-transfer and bolt11
  paths. Pot balances are DB-side reservations that never leave the lnbits
  wallet, so the raw balance let an approved payment spend pot-reserved sats
  that the submission-time check had excluded.
- Hold a per-sender lock across the balance-check-then-pay window in
  sendToInvoice, so concurrent requests cannot each pass the available
  balance check and together overdraw.

Invoice approval de-duplication:
- Persist the bolt11 payment hash on PendingTransaction.
- Add InvoiceApprovalLock, a Bunt record tying a payment hash to its
  outstanding approval, and reject a second request for an invoice that
  already has one waiting. The existing MemoCache lock only spans 5 minutes
  while an approval stays actionable for 24h, so it could not cover the
  approval window on its own.

Other:
- Return a generic error when an invoice payment fails; the raw error can
  carry the internal lnbits host/port. Details stay in the logs.
- Include the caller-supplied memo in the sender's Telegram confirmation,
  labelled separately from the invoice description.
- Build the API approval keyboard per request instead of mutating a shared
  ReplyMarkup. These requests originate from concurrent HTTP handlers, which
  the Telegram lockInterceptor does not serialise, so concurrent approvals
  could overwrite each other's callback data.
lnbits answers POST /api/v1/payments once the payment is handed to its
backend, not when it settles. Follow each outgoing bolt11 payment to a
terminal state before reporting it.

- /api/v1/send returns settled (200), failed (400) or pending (202) with
  the payment hash; add GET /api/v1/send/payment/{payment_hash} to poll
- admin-approved invoice payments also wait, and record failed/in_flight
- add settlement_timeout config, budgeted against the server write timeout
- accept lnbits 1.x RFC3339 payment timestamps and payment status

Security:
- reject replayed HMAC signatures and future timestamps
- validate payment_hash before it reaches an lnbits URL
- share one lnbits rate limiter between analytics and settlement checks
Review feedback:
- use integer math for the 2% routing fee reserve
- fail closed when the invoice approval lock cannot be read or written
- return an error, not 202, when the Telegram approval request cannot be
  delivered; mark the pending transaction failed and deactivate the button

Concurrent payments:
- replace the per-sender lock with balance reservations, so several
  invoices from one wallet can be paid at once without spending pot funds
- reservations cover API invoice/internal sends and both approval paths,
  and are released once lnbits accepts the payment

Approval expiry:
- the Telegram approve button now expires with the pending transaction
  (24h), matching what /api/v1/send/status reports

@0xMadie 0xMadie left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

BackendBanda Code Review — PR #72: bolt11 Lightning invoice payments

Review effort: 4/5 | Reviewer: backend-banda | Date: 2026-10-14


Summary

Solid, well-structured feature. The core correctness concerns (double-spend, false settlement confirmation, replay attacks) are all addressed. A few things need fixing or a decision before merge.


✅ What's good

HMAC single-use enforcement (middleware.go)

  • signatureSet.claim() with in-memory map + TTL = correct approach. Eviction on each call avoids unbounded growth. The TTL is 2 * tolerance seconds — wide enough to cover the full valid window.
  • Future-timestamp rejection (age < -tolerance) closes the captured-request replay window. Breaking change called out clearly in PR description.

Settlement polling (settlement.go)

  • WaitForOutgoingPayment → polling loop with exponential backoff (500ms → 3s cap × 1.5). Correctly stays inside the 90s write timeout via settlementBudget trimming against elapsed time since request start.
  • classifyPayment is appropriately conservative: paid:false with no status → PaymentStateUnknown, never failure. This avoids falsely telling callers their sats came back.
  • Settlement preimage zero-value guard (strings.Trim(preimage, "0") == "") is correct — lnbits returns all-zero preimage for unsettled payments.
  • Test coverage of all lnbits response shapes including public-key response (no status) → PaymentStateUnknown. Good.

Balance reservation under concurrency (balance_reservation.go)

  • Per-sender sync.Mutex serialises the check-then-reserve step. once.Do in Release() prevents double-release. The concurrent stress test (50 goroutines × 100 sat from 1000 balance → exactly 10 granted) is clean and passes.
  • Reservation released immediately after lnbits.Pay accepts, not after settlement polling: correct — lnbits already reflects the deduction once accepted, so holding longer would double-count.

Idempotency (two-layer)

  • Short MemoCache lock (concurrent requests, ~5 min) + durable InvoiceApprovalLock in Bunt (24h, for re-submitted approvals) — the gap between them is documented and addressed.
  • Invoice payment hash validated as ^[0-9a-f]{64}$ before use in lnbits URL. ✅

Approval flow (api_approval.go)

  • Per-request apiApprovalMarkup avoids the race between concurrent approval messages overwriting shared callback data.
  • Expiry check at approve-time (time.Now().After(approvalData.expiresAt())) with backwards-compat fallback for old approvals (CreatedAt + PendingTxExpiry). Correct.
  • Failed Telegram delivery → immediate error + rollback (abandonPendingTransaction), not a silent 202. ✅

🔴 Must-fix before merge

1. signatureSet is process-local — multi-instance deployments allow replay

var usedSignatures = &signatureSet{...} lives in Go process memory. If the bot runs as more than one instance (e.g. two pods, or a restart mid-tolerance-window), a captured signature can be replayed against the second instance.

For a single-process bot this is fine; for any scaled deployment it is not. Either:

  • Document explicitly that the bot must run as a single instance, and add a startup log warning if that changes, OR
  • Move the seen-signature store to Bunt (same pattern as InvoiceApprovalLock) with a TTL-based cleanup.

Action needed: at minimum, a comment in signatureSet stating the single-instance assumption. If multi-instance is in the roadmap, make it Bunt-backed now.

2. MemoCache TTL for the concurrent invoice lock is undocumented / unclear

s.MemoCache.SetNX(lockKey, "locked") is used without a TTL here (in sendToInvoice). The comment says "short lock...expires after 5 minutes" but I don't see where that TTL is set. If MemoCache entries never expire, the lock is permanent: a request that dies after SetNX but before defer Delete (e.g. server crash, panic) would permanently block that invoice from being paid.

Please confirm: does MemoCache have a global TTL? If not, add an explicit TTL to this SetNX call (e.g. 10 minutes), or document the GC/restart recovery path.

3. SendPaymentStatus (GET /api/v1/send/payment/{hash}) has a zero-timeout single lookup

settlement := s.Bot.Client.WaitForOutgoingPayment(*fromUser.Wallet, paymentHash, 0)

With timeout=0, WaitForOutgoingPayment does at most one rate-limited check. If lnbits is slow or the rate limiter is busy, this could return PaymentStateUnknown for a payment that would resolve within 1s. The caller gets unknown and has to retry anyway — so the endpoint is not saving them a round trip.

Suggest: a short timeout (e.g. 5s) so a single poll has a chance to land, while still returning promptly. Or document that callers should expect unknown and retry with backoff.


🟡 Non-blocking observations (address in follow-up or now, author's call)

4. lnbc... / lightning: prefix lowercased before invoice check

strings.ToLower(req.To) before lightning.IsInvoice — fine for the prefix, but bolt11 invoices themselves use bech32 which is case-insensitive. decodepay handles upper/lower, so this is safe. Just noting it's intentional.

5. Approval-flow invoice path: reservation acquired in approveAPITransactionHandler but not in executeApprovedInvoicePayment?

In the non-invoice approval path, ReserveBalance is called before the transfer and deferred-released. In executeApprovedInvoicePayment, I don't see an equivalent reservation — the approval handler reserves, but does it hold through the pay call? Please verify the reservation is not released before lnbits.Pay returns, or add one inside executeApprovedInvoicePayment.

6. payment_hash in pending transaction ID is included in plain text

id := fmt.Sprintf("pending-%s-invoice-%s-%d", ...) — the payment hash is part of the bunt key. Payment hashes are public on the Lightning network so this is not a secret, but if bunt keys are ever logged or exposed, it ties a username to a specific payment. Minor.

7. Test for SendPaymentStatus endpoint is missing

The settlement_test.go covers classifyPayment well. The HTTP handler itself (SendPaymentStatus) has no test. At minimum an integration-style test that exercises the auth check and the hash validation would catch regressions.


Verdict

Approve with blocking comments resolved. Issues 1 and 2 need a documented answer or a code fix; issue 3 needs at minimum a comment. Non-blocking items can go in a follow-up.

go build ./... and go test ./... are reported clean; I cannot run them locally (no repo access) but the test coverage on the new packages is solid.

⚠️ Note for merge: this PR changes the HMAC auth contract for ALL existing /send, /userbalance, /usertransactions, and status callers. Any client that retries with the same timestamp+signature will receive 401 Request already processed. Make sure existing integrations (CeyFi, DCA bot) sign fresh timestamps on every request including retries before merging to main.

— backend-banda (T-0025)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Review effort 4/5

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants