feat: pay bolt11 Lightning invoices via /api/v1/send - #66
Conversation
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.
📝 WalkthroughWalkthroughBolt11 invoices can now be submitted to the Send API, validated and paid externally through LNBits, or routed through Telegram admin approval. Pending transactions and responses persist invoice-specific data, while documentation covers authentication, endpoints, constraints, and integration flows. ChangesBolt11 invoice payments
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Provider
participant SendAPI
participant InvoicePayment
participant LNBits
participant TelegramApproval
Provider->>SendAPI: submit Bolt11 invoice
SendAPI->>InvoicePayment: decode and validate invoice
InvoicePayment->>LNBits: pay immediately or create approval
LNBits-->>InvoicePayment: payment hash and routing fee
InvoicePayment->>TelegramApproval: process approval when required
TelegramApproval-->>Provider: return or publish payment status
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
docs/invoice-payments-api.md (1)
30-32: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueSpecify a language for fenced code blocks. Fenced code blocks without a language specifier trigger markdown lint warnings. Add
textto resolve them.
docs/invoice-payments-api.md#L30-L32: changeto```text.docs/invoice-payments-api.md#L41-L43: changeto```text.docs/invoice-payments-api.md#L310-L328: changeto```text.🤖 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 `@docs/invoice-payments-api.md` around lines 30 - 32, Specify the text language for all three fenced code blocks in docs/invoice-payments-api.md: update the fences at lines 30-32, 41-43, and 310-328 from untyped fences to text fences, preserving their contents.
🤖 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 `@internal/api/send.go`:
- Around line 538-543: Update the payment failure handling in the Invoice
payment flow to keep logging the detailed err internally, but pass a generic
payment-failed message to RespondError instead of formatting err into the API
response. Preserve the existing ErrorLogger.LogPaymentError call and response
status behavior.
- Around line 559-563: Update the sender confirmation message construction near
senderConfirmationMsg so the provided API memo is included in the Telegram
notification, alongside the existing invoice description when present. Reuse the
existing formatting and escaping conventions, while preserving the current
amount and send behavior.
- Around line 487-488: In the payment flow around GetUserAvailableBalance and
the subsequent invoice payment, acquire an exclusive per-user lock using the
existing MemoCache before checking balance, and hold it through the payment
execution. Ensure the lock is released on every success and error path so
concurrent requests for the same user are serialized while different users
remain independent.
In `@internal/telegram/api_approval.go`:
- Around line 245-292: Update CreateAPIInvoiceApprovalRequest to construct a
fresh local ReplyMarkup for each approval request instead of mutating the shared
apiApprovalConfirmationMenu. Build the approve and cancel buttons and their row
on this per-call markup, then pass it to bot.Telegram.Send so concurrent
requests retain their own callback data.
---
Nitpick comments:
In `@docs/invoice-payments-api.md`:
- Around line 30-32: Specify the text language for all three fenced code blocks
in docs/invoice-payments-api.md: update the fences at lines 30-32, 41-43, and
310-328 from untyped fences to text fences, preserving their contents.
🪄 Autofix (Beta)
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
Run ID: a679854e-370f-4d24-bcb9-55bda467d90b
📒 Files selected for processing (6)
README.mddocs/invoice-payments-api.mddocs/referral-api.mdinternal/api/pending_transaction.gointernal/api/send.gointernal/telegram/api_approval.go
| fromUserStr := GetUserStr(from.Telegram) | ||
|
|
||
| // Re-check balance (with fee reserve) before paying | ||
| balance, err := bot.GetUserBalance(from) |
There was a problem hiding this comment.
executeApprovedInvoicePayment re-checks balance with GetUserBalance (raw wallet balance) instead of GetUserAvailableBalance (wallet minus pot reservations) used at submission time in send.go.
| RespondError(w, "This invoice is already being processed") | ||
| return | ||
| } | ||
| defer s.MemoCache.Delete(lockKey) |
There was a problem hiding this comment.
The invoice idempotency lock (SetNX on payment hash) is released via defer as soon as sendToInvoice returns, including on the 202 pending-approval path, before the payment is actually executed.
| RespondError(w, fmt.Sprintf("Insufficient balance: %s available, %s required", thirdparty.FormatSatsWithLKR(balance), thirdparty.FormatSatsWithLKR(amount))) | ||
| return | ||
| } | ||
| if float64(amount) > float64(balance)*0.98 { |
There was a problem hiding this comment.
Money-critical fee-reserve comparison uses float64 arithmetic on satoshi integer amounts instead of integer math, contrary to the project's money-math convention.
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.
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/telegram/api_approval.go (1)
220-235: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftSerialize approved invoice payments per sender.
The mutex only serializes one approval ID. Two approvals for the same sender can concurrently pass the available-balance check and call
Wallet.Pay, spending funds reserved in pots. Acquire the same shared sender-payment lock used byService.sendToInvoicebefore the balance check and hold it through payment completion.🤖 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 `@internal/telegram/api_approval.go` around lines 220 - 235, Update approveAPITransactionHandler to acquire the shared sender-payment lock used by Service.sendToInvoice before GetUserAvailableBalance, and retain it until Wallet.Pay completes. Ensure all error and success paths release the lock, while preserving the existing balance validation and payment behavior.
🤖 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 `@internal/api/pending_transaction.go`:
- Around line 148-153: The invoice approval lock created in
internal/api/pending_transaction.go at lines 148-153 must use storage with a
lifetime matching PendingTransactionExpiry instead of the default five-minute
Bunt TTL; update the lock persistence around InvoiceApprovalLock and Set. Apply
the same approval-lifetime persistence change to callback/approval state in
internal/telegram/api_approval.go at lines 292-297, ensuring both records remain
available for the full approval window.
- Around line 123-125: In internal/api/pending_transaction.go:123-125,
distinguish Bunt’s missing-key result from other read errors: treat only an
absent record as no outstanding approval and propagate unexpected storage
failures. In internal/api/send.go:537-539, make approval creation fail when
recording the invoice lock returns an error, and clean up the pending state
before returning.
In `@internal/api/send.go`:
- Around line 540-542: Update the approval-delivery error path in the send
handler around CreateAPIInvoiceApprovalRequest: when delivery fails, roll back
or inactivate the pending transaction and invoice lock, then return an
appropriate error response instead of continuing to the HTTP 202 accepted
response. Preserve the existing success path only when approval delivery
completes successfully.
---
Outside diff comments:
In `@internal/telegram/api_approval.go`:
- Around line 220-235: Update approveAPITransactionHandler to acquire the shared
sender-payment lock used by Service.sendToInvoice before
GetUserAvailableBalance, and retain it until Wallet.Pay completes. Ensure all
error and success paths release the lock, while preserving the existing balance
validation and payment behavior.
🪄 Autofix (Beta)
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: 029f4233-196a-4bac-a79c-4969d9438a29
📒 Files selected for processing (3)
internal/api/pending_transaction.gointernal/api/send.gointernal/telegram/api_approval.go
| if err != nil { | ||
| // No record for this hash: nothing outstanding. | ||
| return nil |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Fail closed when durable approval deduplication is unavailable.
A Bunt read error is treated as “no approval exists,” while a write error is merely logged. Once the temporary request lock ends, the same invoice can receive another approval request.
internal/api/pending_transaction.go#L123-L125: distinguish an absent key from storage failures and propagate unexpected errors.internal/api/send.go#L537-L539: abort approval creation and clean up the pending state when recording the invoice lock fails.
📍 Affects 2 files
internal/api/pending_transaction.go#L123-L125(this comment)internal/api/send.go#L537-L539
🤖 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 `@internal/api/pending_transaction.go` around lines 123 - 125, In
internal/api/pending_transaction.go:123-125, distinguish Bunt’s missing-key
result from other read errors: treat only an absent record as no outstanding
approval and propagate unexpected storage failures. In
internal/api/send.go:537-539, make approval creation fail when recording the
invoice lock returns an error, and clean up the pending state before returning.
| lock := &InvoiceApprovalLock{ | ||
| Base: storage.New(storage.ID(invoiceApprovalLockKey(paymentHash))), | ||
| PaymentHash: paymentHash, | ||
| PendingTxID: pendingTxID, | ||
| } | ||
| return lock.Set(lock, bot.Bunt) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
The claimed 24-hour approval window uses five-minute cache records.
Both the invoice lock and the callback data are written through storage.New(...)/bot.Bunt. After the cache TTL, the approval either cannot be actioned or no longer blocks a duplicate invoice submission; PendingTransaction.ExpiryTime does not extend that storage lifetime.
internal/api/pending_transaction.go#L148-L153: store the invoice approval lock with a lifetime matchingPendingTransactionExpiry.internal/telegram/api_approval.go#L292-L297: persist callback/approval state for the same approval lifetime.
Based on learnings, the Bunt-backed transaction/confirmation cache enforces a 5-minute TTL.
📍 Affects 2 files
internal/api/pending_transaction.go#L148-L153(this comment)internal/telegram/api_approval.go#L292-L297
🤖 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 `@internal/api/pending_transaction.go` around lines 148 - 153, The invoice
approval lock created in internal/api/pending_transaction.go at lines 148-153
must use storage with a lifetime matching PendingTransactionExpiry instead of
the default five-minute Bunt TTL; update the lock persistence around
InvoiceApprovalLock and Set. Apply the same approval-lifetime persistence change
to callback/approval state in internal/telegram/api_approval.go at lines
292-297, ensuring both records remain available for the full approval window.
Source: Learnings
| if err := telegram.CreateAPIInvoiceApprovalRequest(s.Bot, fromUser, paymentRequest, amount, memo, pendingTx.ID, clientIP); err != nil { | ||
| log.Warnf("[api/send] Failed to send invoice approval request: %v", err) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Do not return accepted when approval delivery fails.
This logs the error but still returns HTTP 202 claiming an approval was sent. The pending transaction and invoice lock remain, so the user cannot approve or resubmit the payment. Roll back/inactivate the pending approval state and return an error.
🤖 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 `@internal/api/send.go` around lines 540 - 542, Update the approval-delivery
error path in the send handler around CreateAPIInvoiceApprovalRequest: when
delivery fails, roll back or inactivate the pending transaction and invoice
lock, then return an appropriate error response instead of continuing to the
HTTP 202 accepted response. Preserve the existing success path only when
approval delivery completes successfully.
Summary
Adds external bolt11 Lightning invoice payments to the existing
/api/v1/sendendpoint. When thetofield is alnbc...payment request (optionallylightning:-prefixed), the bot pays it externally via lnbits instead of doing an internal transfer.What changed
Send()—tomatching a bolt11 invoice routes to the newsendToInvoicehandler.SetNXlock + dedup keyed on the invoice payment hash prevents double-payment from concurrent/retried requests./api/v1/send/statusreflects the outcome.payment_hashandfee(sats) for invoice payments.API contract
POST /api/v1/send{ "from": "BiccoindeepaDSA", "to": "lnbc10u1p...", "memo": "optional" }200→{ "success": true, "payment_hash": "...", "fee": 3, "amount": 1000, ... }202→{ "success": false, "message": "... Transaction ID: ..." }Docs
docs/invoice-payments-api.mddocs/referral-api.mdandREADME.mdfor invoice support.Testing
go build ./...— cleango vet ./internal/api/...— cleanSummary by CodeRabbit
lightning:prefix), with invoice-only amount handling.payment_hash.payment_hashand routing fee (fee), and confirmations include the invoice description and optional memo.