diff --git a/README.md b/README.md index dc659307..137979d1 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,8 @@ The bot now includes a new HTTP API endpoint `/api/send` for programmatic Bitcoi - **Secure**: Only whitelisted sender accounts can use the API - **Network Restricted**: Limited to internal network access (10.0.0.0/24) -- **Flexible Recipients**: Send to any Telegram username or wallet ID +- **Flexible Recipients**: Send to any Telegram username, wallet ID, Lightning address, or bolt11 invoice +- **Bolt11 Invoices**: Pass a `lnbc...` invoice as `to` to pay it externally (amount is taken from the invoice; response includes `payment_hash` and `fee`) - **Transaction Logging**: All API payments are logged for audit purposes ### Quick Start diff --git a/docs/invoice-payments-api.md b/docs/invoice-payments-api.md new file mode 100644 index 00000000..8362c55a --- /dev/null +++ b/docs/invoice-payments-api.md @@ -0,0 +1,380 @@ +# Bitcoin Deepa — Invoice Payments API (Provider Integration Guide) + +This guide is for **payment providers** that integrate with Bitcoin Deepa to **pay bolt11 Lightning invoices** from a dedicated, operator-provisioned wallet. + +It is intentionally scoped to the invoice‑payment use case. It covers authentication, the endpoints you need, the limits and constraints of the Send API, error handling, and end‑to‑end examples. + +- **Base URL:** `https://bitcoindeepa.com` +- **Content type:** `application/json` +- **Auth:** HMAC‑SHA256 request signing (see below) +- **Amounts:** always in **satoshis** unless stated otherwise + +> You will be issued two secrets by the Bitcoin Deepa operator: +> a **wallet ID** (identifies your provider account) and an **HMAC secret** (used to sign requests). Keep the HMAC secret server‑side only. + +--- + +## 1. Authentication (HMAC request signing) + +Every request must carry two headers: + +| Header | Description | +|--------------------|-------------| +| `X-Timestamp` | Current Unix time in **seconds** | +| `X-HMAC-Signature` | Hex‑encoded HMAC‑SHA256 signature of the request (see algorithm) | + +### Signing algorithm + +Build the message to sign by concatenating, **in this exact order, with no separators**: + +``` +MESSAGE = HTTP_METHOD + REQUEST_PATH + TIMESTAMP + RAW_BODY +``` + +- `HTTP_METHOD` — uppercase, e.g. `POST` or `GET` +- `REQUEST_PATH` — path only, no host, no query string, e.g. `/api/v1/send` +- `TIMESTAMP` — the same value sent in `X-Timestamp` +- `RAW_BODY` — the exact JSON body bytes you send (empty string for `GET`) + +Then: + +``` +X-HMAC-Signature = hex( HMAC_SHA256( key = HMAC_SECRET, message = MESSAGE ) ) +``` + +### Rules + +- **Replay window:** the request is rejected if `now - X-Timestamp` exceeds the configured tolerance (**default 300 seconds / 5 minutes**). Sign and send promptly; do not reuse timestamps/signatures. +- **Byte‑exact body:** the signature is computed over the literal bytes you transmit. Serialize the JSON once and sign/send the identical bytes — re‑serializing (whitespace, key order changes) will break verification. +- **Wallet identification:** you do not send your wallet ID. The server identifies your provider account by matching the signature against the registered secret. An unmatched signature returns `401`. + +### Reference implementations + +**Bash / OpenSSL** + +```bash +TIMESTAMP=$(date +%s) +BODY='{"to":"lnbc10u1p3xyz..."}' +METHOD="POST" +PATH_ONLY="/api/v1/send" + +MESSAGE="${METHOD}${PATH_ONLY}${TIMESTAMP}${BODY}" +SIGNATURE=$(printf '%s' "$MESSAGE" | openssl dgst -sha256 -hmac "$HMAC_SECRET" | awk '{print $2}') + +curl -X POST "https://bitcoindeepa.com${PATH_ONLY}" \ + -H "Content-Type: application/json" \ + -H "X-Timestamp: ${TIMESTAMP}" \ + -H "X-HMAC-Signature: ${SIGNATURE}" \ + -d "$BODY" +``` + +**Node.js** + +```js +const crypto = require("crypto"); + +function signedHeaders(method, path, body, secret) { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const message = method + path + timestamp + body; + const signature = crypto.createHmac("sha256", secret).update(message).digest("hex"); + return { "X-Timestamp": timestamp, "X-HMAC-Signature": signature }; +} + +const body = JSON.stringify({ to: "lnbc10u1p3xyz..." }); +const headers = { + "Content-Type": "application/json", + ...signedHeaders("POST", "/api/v1/send", body, process.env.HMAC_SECRET), +}; +// send `body` unchanged with these headers +``` + +**Python** + +```python +import hmac, hashlib, time, json, requests + +def signed_headers(method, path, body, secret): + ts = str(int(time.time())) + msg = f"{method}{path}{ts}{body}" + sig = hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest() + return {"X-Timestamp": ts, "X-HMAC-Signature": sig} + +body = json.dumps({"to": "lnbc10u1p3xyz..."}, separators=(",", ":")) +headers = {"Content-Type": "application/json", **signed_headers("POST", "/api/v1/send", body, HMAC_SECRET)} +requests.post("https://bitcoindeepa.com/api/v1/send", data=body, headers=headers) +``` + +--- + +## 2. Endpoints + +For invoice payments you need one primary endpoint and two supporting ones. + +| Method | Path | Purpose | +|--------|------|---------| +| `POST` | `/api/v1/send` | **Pay a bolt11 invoice** (primary) | +| `GET` | `/api/v1/send/status/{transaction_id}` | Poll the status of a payment held for admin approval | +| `POST` | `/api/v1/userbalance` | Optional pre‑flight balance check | + +--- + +### 2.1 POST /api/v1/send — Pay a bolt11 invoice + +Detects that `to` is a bolt11 invoice (`lnbc...`, optionally prefixed with `lightning:`) and pays it externally over Lightning. + +#### Request body + +```json +{ + "to": "lnbc10u1p3xyz...", + "memo": "order-12345" +} +``` + +| Field | Type | Required | Description | +|----------|--------|----------|-------------| +| `to` | string | Yes | The bolt11 invoice to pay. `lightning:` prefix is accepted and stripped. | +| `amount` | int64 | No | **Ignored for invoices.** The amount is taken from the invoice. Amountless invoices are rejected. | +| `memo` | string | No | Optional. For invoice payments it is used only for your sender confirmation / logs; it is **not** attached to the Lightning payment. Max `max_memo_length` (default 280) chars. | + +#### Success — `200 OK` + +```json +{ + "success": true, + "message": "Invoice paid successfully", + "from_user": "yourprovider", + "to_user": "lnbc10u1p3xyz...", + "amount": 1000, + "amount_lkr": "32.50", + "payment_hash": "3d2f...e91a", + "fee": 1 +} +``` + +| Field | Description | +|----------------|-------------| +| `success` | `true` on a settled payment | +| `amount` | Amount paid, in sats (from the invoice) | +| `payment_hash` | Payment hash of the settled invoice — use this as your reconciliation key | +| `fee` | Routing fee actually paid, in sats (best effort; may be omitted if the post‑payment lookup fails) | +| `amount_lkr` | LKR value of the amount, if a price is available | + +#### Held for approval — `202 Accepted` + +If the invoice amount exceeds the wallet's admin‑approval threshold, the payment is **not** made immediately. It is held pending and an approval prompt is sent to the operator via Telegram. + +```json +{ + "success": false, + "message": "Transaction requires admin approval (amount: 60000 > threshold: 50000). Approval request sent to you via Telegram. Transaction ID: pending-yourprovider-invoice-3d2f...-1699999999", + "from_user": "yourprovider", + "to_user": "lnbc10u1p3xyz...", + "amount": 60000, + "amount_lkr": "1,950.00" +} +``` + +Extract the transaction ID from `message` (or track it yourself) and poll `GET /api/v1/send/status/{transaction_id}` until it resolves. The invoice must still be valid (unexpired) at the time of approval, or the payment will fail when executed. + +#### Error — `400 Bad Request` + +```json +{ "error": "Invoice must specify an amount (amountless invoices are not supported)" } +``` + +See [Error reference](#4-error-reference) for the full list. + +--- + +### 2.2 GET /api/v1/send/status/{transaction_id} + +Returns the current status of a payment that was held for admin approval (`202` response). +The `{transaction_id}` is the ID from the `202` response `message`. + +`GET` requests sign over an **empty body**: `MESSAGE = "GET" + "/api/v1/send/status/{transaction_id}" + TIMESTAMP + ""`. + +#### Response — `200 OK` + +```json +{ + "id": "pending-yourprovider-invoice-3d2f...-1699999999", + "status": "executed", + "from_user": "yourprovider", + "to_user": "lnbc10u1p3xyz...", + "amount": 60000, + "amount_lkr": "1,950.00", + "memo": "order-12345", + "request_timestamp": "2026-07-17T10:00:00Z", + "expiry_time": "2026-07-18T10:00:00Z", + "approved_by": "operatoradmin", + "approval_time": "2026-07-17T10:04:12Z" +} +``` + +#### Status values + +| Status | Meaning | +|------------|---------| +| `pending` | Awaiting operator approval | +| `approved` | Approved, execution in progress | +| `executed` | Invoice paid successfully | +| `rejected` | Operator declined the payment | +| `expired` | Not approved within the 24‑hour window | + +Poll at a modest interval (e.g. every 5–15s). Pending transactions expire after **24 hours**. + +--- + +### 2.3 POST /api/v1/userbalance — Optional pre‑flight check + +Lets you check a wallet balance before attempting a payment. Provide the Telegram ID associated with your provider account (supplied by the operator). + +#### Request body + +```json +{ "telegram_id": 123456789 } +``` + +#### Response — `200 OK` + +```json +{ + "success": true, + "telegram_id": 123456789, + "balance": 250000, + "balance_lkr": "8,125.00", + "username": "yourprovider", + "created_at": "2025-01-01T00:00:00Z", + "message": "Balance retrieved successfully" +} +``` + +Remember to leave headroom for the **routing‑fee reserve** (see limitations) — a balance exactly equal to the invoice amount will be rejected. + +--- + +## 3. Send API limitations & constraints + +These apply to invoice payments via `POST /api/v1/send`. + +| Constraint | Detail | +|------------|--------| +| **Whitelisted wallets only** | Only provider accounts registered by the operator (with an HMAC secret) can call the API. | +| **HMAC signing required** | Every request must be signed; unsigned or mismatched requests get `401`. | +| **Replay window** | Requests older than the timestamp tolerance (**default 300s**) are rejected. | +| **Amount source** | Amount is taken **from the invoice**. Any `amount` field in the body is ignored. | +| **Amountless invoices** | **Not supported** — rejected with `400`. The invoice must encode an amount. | +| **Minimum amount** | Invoice amount must be greater than `min_amount` (default **1 sat**). | +| **Maximum amount** | Invoice amount must not exceed the wallet's `max_amount` (global default **1,000,000 sats**; may be set lower per wallet). | +| **Admin approval threshold** | Amounts above `admin_approval_threshold` (global default **50,000 sats**; may be per wallet) are held for operator approval and return `202` instead of paying immediately. | +| **Routing‑fee reserve** | Your available balance must cover the amount **plus ~2%** for routing fees. If `amount > balance * 0.98`, the request is rejected even if `balance >= amount`. | +| **Idempotency** | Requests are de‑duplicated on the invoice **payment hash**. Concurrent or retried attempts to pay the same invoice will not double‑pay. | +| **Irreversibility** | A settled Lightning payment cannot be reversed. Validate the invoice/amount before sending. | +| **Invoice validity** | Expired or malformed invoices are rejected (either at decode time with `400`, or by the Lightning backend at pay time). | +| **Memo** | Cosmetic for invoice payments; capped at `max_memo_length` (default 280). | +| **Rate limiting** | The API is rate limited; back off on `429`/repeated failures. | +| **Amounts unit** | All amounts are integer **satoshis**. | + +> Exact numeric limits (min/max/threshold, fee reserve tolerance, timestamp tolerance) are set by the operator per deployment and may differ from the defaults above — confirm your provisioned values with the Bitcoin Deepa operator. + +--- + +## 4. Error reference + +Errors use a consistent JSON shape: + +```json +{ "error": "human-readable reason" } +``` + +| HTTP | When | Example `error` | +|------|------|-----------------| +| `400` | Malformed request or business rule violation | `Invalid Lightning invoice` | +| `400` | Amountless invoice | `Invoice must specify an amount (amountless invoices are not supported)` | +| `400` | Below minimum | `Amount must be greater than 1 sat` | +| `400` | Above maximum | `Amount cannot exceed 1,000,000 sats` | +| `400` | Insufficient funds | `Insufficient balance: 500 sats available, 1,000 sats required` | +| `400` | Fee reserve not covered | `Insufficient balance to cover routing fees: 1,000 sats available, 1,000 sats required plus a fee reserve` | +| `400` | Duplicate in flight | `This invoice is already being processed` | +| `400` | Backend pay failure | `Invoice payment failed: ` | +| `401` | Missing/expired timestamp, missing/invalid signature | `Invalid signature` / `Request expired` / `Missing timestamp` | +| `429` | Rate limited | — | + +**Ambiguous failures:** if `POST /api/v1/send` times out or returns a network error, **do not blindly retry** — the payment may have gone through. Because idempotency is keyed on the invoice payment hash, retrying the *same invoice* is safe (it will not double‑pay), but a fresh/different invoice for the same order could double‑pay. Prefer to reconcile using the `payment_hash`. + +--- + +## 5. End‑to‑end flow + +``` + ┌─────────────────────────────┐ + │ 1. (optional) userbalance │ + │ check headroom + fee │ + └──────────────┬───────────────┘ + ▼ + ┌─────────────────────────────┐ + │ 2. POST /api/v1/send │ + │ { "to": "lnbc..." } │ + └──────────────┬───────────────┘ + ┌─────────┴──────────┐ + 200 OK │ │ 202 Accepted + ▼ ▼ + ┌───────────────────┐ ┌──────────────────────────────┐ + │ paid — record │ │ held for approval │ + │ payment_hash, fee │ │ poll /send/status/{id} │ + └───────────────────┘ │ → executed | rejected | expired│ + └──────────────────────────────┘ +``` + +### Worked example — small payment (immediate) + +```bash +HMAC_SECRET="your-provisioned-secret" +TIMESTAMP=$(date +%s) +BODY='{"to":"lnbc10u1p3xyz...","memo":"order-12345"}' +MESSAGE="POST/api/v1/send${TIMESTAMP}${BODY}" +SIGNATURE=$(printf '%s' "$MESSAGE" | openssl dgst -sha256 -hmac "$HMAC_SECRET" | awk '{print $2}') + +curl -X POST "https://bitcoindeepa.com/api/v1/send" \ + -H "Content-Type: application/json" \ + -H "X-Timestamp: ${TIMESTAMP}" \ + -H "X-HMAC-Signature: ${SIGNATURE}" \ + -d "$BODY" +# → 200 { "success": true, "payment_hash": "…", "fee": 1, ... } +``` + +### Worked example — large payment (approval + poll) + +```bash +# 1) Submit — returns 202 with a transaction ID in `message` +# e.g. TX_ID="pending-yourprovider-invoice-3d2f...-1699999999" + +# 2) Poll status (GET signs over an empty body) +TIMESTAMP=$(date +%s) +MESSAGE="GET/api/v1/send/status/${TX_ID}${TIMESTAMP}" +SIGNATURE=$(printf '%s' "$MESSAGE" | openssl dgst -sha256 -hmac "$HMAC_SECRET" | awk '{print $2}') + +curl -X GET "https://bitcoindeepa.com/api/v1/send/status/${TX_ID}" \ + -H "X-Timestamp: ${TIMESTAMP}" \ + -H "X-HMAC-Signature: ${SIGNATURE}" +# → { "status": "executed" } (repeat until executed | rejected | expired) +``` + +--- + +## 6. Integration checklist + +- [ ] Store the wallet ID and HMAC secret server‑side (never ship the secret to clients). +- [ ] Sign over the **exact** transmitted body bytes; send them unchanged. +- [ ] Use fresh `X-Timestamp` per request (within the tolerance window). +- [ ] Validate the invoice amount against your own limits before sending. +- [ ] Keep a fee‑reserve buffer (~2%) above the invoice amount in the wallet. +- [ ] Persist the `payment_hash` for reconciliation; treat it as the source of truth. +- [ ] Handle `202` by polling `/send/status/{id}` until a terminal state. +- [ ] On network timeouts, reconcile by `payment_hash` rather than blind‑retrying with a new invoice. +- [ ] Back off on `429` and repeated `5xx`. + +--- + +*Confirm your provisioned limits (min/max/threshold), fee‑reserve tolerance, and timestamp tolerance with the Bitcoin Deepa operator — deployment values may differ from the defaults noted here.* diff --git a/docs/referral-api.md b/docs/referral-api.md index 1ac5a627..dae2ef27 100644 --- a/docs/referral-api.md +++ b/docs/referral-api.md @@ -72,7 +72,7 @@ Enabled when `api.send.enabled: true` in `config.yaml`. ### POST /api/v1/send -Sends a Lightning payment from a whitelisted wallet to any bot user, Telegram ID, or Lightning address. +Sends a Lightning payment from a whitelisted wallet to any bot user, Telegram ID, Lightning address, or bolt11 Lightning invoice. #### Request body @@ -86,15 +86,28 @@ Sends a Lightning payment from a whitelisted wallet to any bot user, Telegram ID | Field | Type | Required | Description | |----------|--------|----------|-------------| -| `to` | string | Yes | Recipient: Telegram username (no `@`), Telegram ID (numeric), or Lightning address | -| `amount` | int64 | Yes | Amount in satoshis | -| `memo` | string | No | Optional payment memo (max `max_memo_length` chars, default 280) | +| `to` | string | Yes | Recipient: Telegram username (no `@`), Telegram ID (numeric), Lightning address, or a bolt11 invoice (`lnbc...`, optionally prefixed with `lightning:`) | +| `amount` | int64 | Yes\* | Amount in satoshis. **Ignored for bolt11 invoices** — the amount is taken from the invoice itself | +| `memo` | string | No | Optional payment memo (max `max_memo_length` chars, default 280). For invoices it is used only for the sender's confirmation/logging | + +\* Not required when `to` is a bolt11 invoice. #### Recipient resolution order -1. Lightning address (e.g. `user@domain.com`) -2. Telegram ID (5–15 digit number) -3. Telegram username +1. bolt11 Lightning invoice (`lnbc...` / `lightning:lnbc...`) +2. Lightning address (e.g. `user@domain.com`) +3. Telegram ID (5–15 digit number) +4. Telegram username + +#### Paying a bolt11 invoice + +When `to` is a bolt11 invoice the bot pays it **externally** over Lightning: + +- The amount comes from the invoice. **Amountless invoices are rejected.** +- The invoice amount is validated against `min_amount`, `max_amount`, and `admin_approval_threshold` (same limits and approval flow as internal sends). +- A ~2% routing-fee reserve is required on top of the amount; if the balance cannot cover it the request is rejected. +- Idempotency is keyed on the invoice's payment hash, so retrying the same invoice will not double-pay. +- The success response additionally includes `payment_hash` and `fee` (routing fee in sats). #### Amount limits @@ -125,6 +138,23 @@ If a `memo` is provided, the API checks whether a transaction with that exact me } ``` +#### Invoice payment success response — `200 OK` + +When `to` is a bolt11 invoice, `payment_hash` and `fee` (sats) are included: + +```json +{ + "success": true, + "message": "Invoice paid successfully", + "from_user": "myservice", + "to_user": "lnbc10u1p...", + "amount": 1000, + "amount_lkr": "32.50", + "payment_hash": "abc123...", + "fee": 1 +} +``` + #### Pending approval response — `202 Accepted` ```json diff --git a/internal/api/pending_transaction.go b/internal/api/pending_transaction.go index 835b6b4f..062f431f 100644 --- a/internal/api/pending_transaction.go +++ b/internal/api/pending_transaction.go @@ -20,6 +20,8 @@ type PendingTransaction struct { ToUsername string `json:"to_username"` Amount int64 `json:"amount"` Memo string `json:"memo"` + PaymentType string `json:"payment_type,omitempty"` // "" / "internal" for user transfers, "invoice" for bolt11 payments + Invoice string `json:"invoice,omitempty"` // bolt11 payment request when PaymentType == "invoice" RequestTimestamp time.Time `json:"request_timestamp"` Status string `json:"status"` // "pending", "approved", "rejected", "expired" ApprovedBy string `json:"approved_by,omitempty"` @@ -64,6 +66,27 @@ func NewPendingTransaction(req *SendRequest, fromUser, toUser *lnbits.User, clie } } +// NewPendingInvoiceTransaction creates a new pending transaction for an external bolt11 invoice payment +func NewPendingInvoiceTransaction(fromUsername, invoice, paymentHash string, amount int64, memo string, fromUser *lnbits.User, clientIP string) *PendingTransaction { + id := fmt.Sprintf("pending-%s-invoice-%s-%d", fromUsername, paymentHash, time.Now().Unix()) + + return &PendingTransaction{ + Base: storage.New(storage.ID(id)), + ID: id, + FromUser: fromUser, + FromUsername: fromUsername, + ToUsername: invoice, + Amount: amount, + Memo: memo, + PaymentType: "invoice", + Invoice: invoice, + RequestTimestamp: time.Now(), + Status: StatusPending, + ExpiryTime: time.Now().Add(PendingTransactionExpiry), + ClientIP: clientIP, + } +} + // IsExpired checks if the pending transaction has expired func (pt *PendingTransaction) IsExpired() bool { return time.Now().After(pt.ExpiryTime) @@ -136,12 +159,15 @@ func LoadPendingTransaction(id string, bot *telegram.TipBot) (*PendingTransactio pendingTx.FromUser = fromUser } - toUser, err := telegram.GetUserByTelegramUsername(pendingTx.ToUsername, *bot) - if err != nil { - log.Warnf("[ADMIN APPROVAL] Could not load to user @%s: %v", pendingTx.ToUsername, err) - // Continue with nil user - this will be handled in the calling functions - } else { - pendingTx.ToUser = toUser + // Invoice payments have no internal recipient user, so skip the lookup. + if pendingTx.PaymentType != "invoice" { + toUser, err := telegram.GetUserByTelegramUsername(pendingTx.ToUsername, *bot) + if err != nil { + log.Warnf("[ADMIN APPROVAL] Could not load to user @%s: %v", pendingTx.ToUsername, err) + // Continue with nil user - this will be handled in the calling functions + } else { + pendingTx.ToUser = toUser + } } return pendingTx, nil diff --git a/internal/api/send.go b/internal/api/send.go index 8acc9ec3..0a8cdeb0 100644 --- a/internal/api/send.go +++ b/internal/api/send.go @@ -16,6 +16,7 @@ import ( "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" "github.com/LightningTipBot/LightningTipBot/internal/utils" "github.com/LightningTipBot/LightningTipBot/pkg/lightning" + decodepay "github.com/fiatjaf/ln-decodepay" "github.com/gorilla/mux" log "github.com/sirupsen/logrus" ) @@ -37,6 +38,8 @@ type SendResponse struct { Amount int64 `json:"amount"` AmountLKR string `json:"amount_lkr,omitempty"` // LKR conversion Memo string `json:"memo,omitempty"` + PaymentHash string `json:"payment_hash,omitempty"` // set for bolt11 invoice payments + Fee int64 `json:"fee,omitempty"` // routing fee in sats for invoice payments } // InternalNetworkMiddleware restricts access to internal network IPs (configurable) @@ -136,6 +139,15 @@ func (s Service) Send(w http.ResponseWriter, r *http.Request) { RespondError(w, "Missing 'to' field") return } + + // Check if 'to' is a bolt11 Lightning invoice — pay it externally. + // The amount comes from the invoice itself, so this path handles its own + // validation, idempotency, balance and approval checks. + if paymentRequest := strings.TrimPrefix(strings.ToLower(req.To), "lightning:"); lightning.IsInvoice(paymentRequest) { + s.sendToInvoice(w, r, walletID, fromUsername, paymentRequest, req.Memo) + return + } + if req.Amount <= GetMinAPITransactionAmount() { RespondError(w, fmt.Sprintf("Amount must be greater than %s", thirdparty.FormatSatsWithLKR(GetMinAPITransactionAmount()))) return @@ -425,6 +437,149 @@ func (s Service) SendStatus(w http.ResponseWriter, r *http.Request) { }) } +// sendToInvoice pays a bolt11 Lightning invoice externally via lnbits. +// The amount is taken from the invoice; amountless invoices are rejected. +func (s Service) sendToInvoice(w http.ResponseWriter, r *http.Request, walletID, fromUsername, paymentRequest, memo string) { + // Decode the invoice + bolt11, err := decodepay.Decodepay(paymentRequest) + if err != nil { + log.Errorf("[api/send] Could not decode invoice: %v", err) + RespondError(w, "Invalid Lightning invoice") + return + } + amount := int64(bolt11.MSatoshi / 1000) + if amount <= 0 { + RespondError(w, "Invoice must specify an amount (amountless invoices are not supported)") + return + } + + // Validate amount against configured limits + if amount <= GetMinAPITransactionAmount() { + RespondError(w, fmt.Sprintf("Amount must be greater than %s", thirdparty.FormatSatsWithLKR(GetMinAPITransactionAmount()))) + return + } + walletMaxAmount := GetWalletMaxAmount(walletID) + if amount > walletMaxAmount { + RespondError(w, fmt.Sprintf("Amount cannot exceed %s", thirdparty.FormatSatsWithLKR(walletMaxAmount))) + return + } + + // Idempotency: lock + dedup on the invoice payment hash so the same + // invoice cannot be paid twice by concurrent or retried requests. + if bolt11.PaymentHash != "" { + lockKey := fmt.Sprintf("api_send_invoice_%s", bolt11.PaymentHash) + if success := s.MemoCache.SetNX(lockKey, "locked"); !success { + log.Warnf("[api/send] Invoice %s is already processing", bolt11.PaymentHash) + RespondError(w, "This invoice is already being processed") + return + } + defer s.MemoCache.Delete(lockKey) + } + + // Get the sender user + fromUser, err := telegram.GetUserByTelegramUsername(fromUsername, *s.Bot) + if err != nil { + log.Errorf("[api/send] Could not find sender user %s: %v", fromUsername, err) + RespondError(w, fmt.Sprintf("Sender '@%s' not found or has no wallet", fromUsername)) + return + } + + // Check available balance with a ~2% routing fee reserve + balance, err := s.Bot.GetUserAvailableBalance(fromUser) + if err != nil { + log.Errorf("[api/send] Could not get available balance for %s: %v", fromUsername, err) + RespondError(w, "Could not check sender balance") + return + } + if balance < amount { + log.Warnf("[api/send] Insufficient available balance for %s: %d < %d", fromUsername, balance, amount) + RespondError(w, fmt.Sprintf("Insufficient balance: %s available, %s required", thirdparty.FormatSatsWithLKR(balance), thirdparty.FormatSatsWithLKR(amount))) + return + } + if float64(amount) > float64(balance)*0.98 { + RespondError(w, fmt.Sprintf("Insufficient balance to cover routing fees: %s available, %s required plus a fee reserve", thirdparty.FormatSatsWithLKR(balance), thirdparty.FormatSatsWithLKR(amount))) + return + } + + // Large invoices go through the same admin approval mechanism as internal sends + if amount > GetWalletAdminApprovalThreshold(walletID) { + log.Infof("[api/send] Large invoice payment requires admin approval: %s (%d sat(s))", fromUsername, amount) + clientIP := getClientIP(r) + pendingTx := NewPendingInvoiceTransaction(fromUsername, paymentRequest, bolt11.PaymentHash, amount, memo, fromUser, clientIP) + if err := pendingTx.SaveToDB(s.Bot); err != nil { + log.Errorf("[api/send] Failed to save pending invoice transaction: %v", err) + RespondError(w, "Failed to create pending transaction") + return + } + 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) + } + + response := SendResponse{ + Success: false, + Message: fmt.Sprintf("Transaction requires admin approval (amount: %s > threshold: %s). Approval request sent to you via Telegram. Transaction ID: %s", + thirdparty.FormatSatsWithLKR(amount), thirdparty.FormatSatsWithLKR(GetWalletAdminApprovalThreshold(walletID)), pendingTx.ID), + FromUser: fromUsername, + ToUser: paymentRequest, + Amount: amount, + AmountLKR: getLKRValue(amount), + Memo: memo, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(response) + return + } + + // Pay the invoice + log.Infof("[api/send] Paying invoice for %s (%d sat)", fromUsername, amount) + inv, err := fromUser.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: paymentRequest}, s.Bot.Client) + if err != nil { + log.Errorf("[api/send] Invoice payment failed for %s: %v", fromUsername, err) + if s.Bot.ErrorLogger != nil { + s.Bot.ErrorLogger.LogPaymentError(err, amount, bolt11.Description, paymentRequest, fromUser.Telegram) + } + RespondError(w, fmt.Sprintf("Invoice payment failed: %v", err)) + return + } + + // Read the settled payment to report the real routing fee. + // lnbits reports fee in millisats, so convert to sats. + var fee int64 + if payment, perr := s.Bot.Client.Payment(*fromUser.Wallet, inv.PaymentHash); perr == nil { + fee = payment.Details.Fee / 1000 + if fee < 0 { + fee = -fee + } + } + + log.Infof("[api/send] ✅ Invoice paid: %s (%d sat, fee %d sat)", fromUsername, amount, fee) + + // Send confirmation to sender + senderConfirmationMsg := fmt.Sprintf("✅ Invoice paid successfully!\n\n💸 Amount: %s", thirdparty.FormatSatsWithLKR(amount)) + if bolt11.Description != "" { + senderConfirmationMsg += fmt.Sprintf("\n✉️ %s", str.MarkdownEscape(bolt11.Description)) + } + if _, err := s.Bot.Telegram.Send(fromUser.Telegram, senderConfirmationMsg); err != nil { + log.Warnf("[api/send] Could not send confirmation to sender: %v", err) + } + + response := SendResponse{ + Success: true, + Message: "Invoice paid successfully", + FromUser: fromUsername, + ToUser: paymentRequest, + Amount: amount, + AmountLKR: getLKRValue(amount), + Memo: memo, + PaymentHash: inv.PaymentHash, + Fee: fee, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + // sendToLightningAddress handles sending to Lightning addresses func (s Service) sendToLightningAddress(fromUser *lnbits.User, lightningAddress string, amount int64, memo string) error { // This is a simplified implementation - you may need to implement the full Lightning address protocol diff --git a/internal/telegram/api_approval.go b/internal/telegram/api_approval.go index 4dee4283..c552e1d9 100644 --- a/internal/telegram/api_approval.go +++ b/internal/telegram/api_approval.go @@ -39,6 +39,8 @@ type APIApprovalData struct { ToUsername string `json:"to_username"` Amount int64 `json:"amount"` Memo string `json:"memo"` + PaymentType string `json:"payment_type,omitempty"` // "invoice" for bolt11 payments; empty for internal transfers + Invoice string `json:"invoice,omitempty"` // bolt11 payment request when PaymentType == "invoice" Message string `json:"message"` LanguageCode string `json:"language_code"` ClientIP string `json:"client_ip"` @@ -71,6 +73,12 @@ func (bot *TipBot) approveAPITransactionHandler(ctx intercept.Context) (intercep from := LoadUser(ctx) ResetUserState(from, bot) + // Invoice payment path: pay the bolt11 externally instead of an internal transfer + if approvalData.PaymentType == "invoice" { + bot.executeApprovedInvoicePayment(ctx, approvalData, from) + return ctx, nil + } + // Get recipient user toUser, err := GetUserByTelegramUsername(approvalData.ToUsername, *bot) if err != nil { @@ -184,6 +192,105 @@ func (bot *TipBot) cancelAPITransactionHandler(ctx intercept.Context) (intercept return ctx, nil } +// executeApprovedInvoicePayment pays an approved bolt11 invoice externally via lnbits. +func (bot *TipBot) executeApprovedInvoicePayment(ctx intercept.Context, approvalData *APIApprovalData, from *lnbits.User) { + fromUserStr := GetUserStr(from.Telegram) + + // Re-check balance (with fee reserve) before paying + balance, err := bot.GetUserBalance(from) + if err != nil { + log.Errorf("[approveAPITransactionHandler] Could not check sender balance: %v", err) + bot.tryEditMessage(ctx.Callback().Message, "❌ Approval failed: could not check balance", &tb.ReplyMarkup{}) + return + } + if balance < approvalData.Amount || float64(approvalData.Amount) > float64(balance)*0.98 { + log.Warnf("[approveAPITransactionHandler] Insufficient balance for invoice: %d < %d (+fees)", balance, approvalData.Amount) + bot.tryEditMessage(ctx.Callback().Message, fmt.Sprintf("❌ Insufficient balance: %s available, %s required (plus fee reserve)", utils.FormatSats(balance), utils.FormatSats(approvalData.Amount)), &tb.ReplyMarkup{}) + return + } + + inv, err := from.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: approvalData.Invoice}, bot.Client) + if err != nil { + log.Errorf("[approveAPITransactionHandler] Invoice payment failed for %s: %v", fromUserStr, err) + if bot.ErrorLogger != nil { + bot.ErrorLogger.LogPaymentError(err, approvalData.Amount, approvalData.Memo, approvalData.Invoice, from.Telegram) + } + bot.tryEditMessage(ctx.Callback().Message, "❌ Invoice payment failed", &tb.ReplyMarkup{}) + return + } + + approvalData.Inactivate(approvalData, bot.Bunt) + + // Update the PendingTransaction status so /api/v1/send/status reflects the real outcome. + if storage.UpdatePendingTxStatusFn != nil { + storage.UpdatePendingTxStatusFn(approvalData.TransactionID, "executed", ctx.Callback().Sender.Username) + } + + log.Infof("[💸 api_send_approved invoice] %s paid invoice %s (%s).", fromUserStr, inv.PaymentHash, thirdparty.FormatSatsWithLKR(approvalData.Amount)) + + successMsg := fmt.Sprintf("✅ Invoice approved and paid successfully!\n\n💸 Amount: %s", thirdparty.FormatSatsWithLKR(approvalData.Amount)) + if approvalData.Memo != "" { + successMsg += fmt.Sprintf("\n✉️ Memo: %s", str.MarkdownEscape(approvalData.Memo)) + } + if ctx.Callback().Message.Private() { + bot.tryDeleteMessage(ctx.Callback().Message) + bot.trySendMessage(ctx.Callback().Sender, successMsg) + } else { + bot.tryEditMessage(ctx.Callback().Message, successMsg, &tb.ReplyMarkup{}) + } +} + +// CreateAPIInvoiceApprovalRequest creates an approval request for an external bolt11 invoice payment. +// It reuses the same approve/cancel callback handlers as internal API transfers. +func CreateAPIInvoiceApprovalRequest(bot *TipBot, fromUser *lnbits.User, invoice string, amount int64, memo string, transactionID string, clientIP string) error { + confirmText := fmt.Sprintf("Do you want to pay this Lightning invoice?\n\n💸 Amount: %s", thirdparty.FormatSatsWithLKR(amount)) + if memo != "" { + confirmText += fmt.Sprintf("\n✉️ %s", str.MarkdownEscape(memo)) + } + confirmText += "\n\n🔔 *Admin Approval Required*\n" + confirmText += fmt.Sprintf("This transaction requires approval because the amount (%s) exceeds the threshold.", thirdparty.FormatSatsWithLKR(amount)) + + id := fmt.Sprintf("api-%d-%d-%s", fromUser.Telegram.ID, amount, RandStringRunes(5)) + + approvalData := &APIApprovalData{ + Base: storage.New(storage.ID(id)), + TransactionID: transactionID, + FromUser: fromUser, + ToUsername: "invoice", + Amount: amount, + Memo: memo, + PaymentType: "invoice", + Invoice: invoice, + Message: confirmText, + LanguageCode: fromUser.Telegram.LanguageCode, + ClientIP: clientIP, + } + + if err := approvalData.Set(approvalData, bot.Bunt); err != nil { + log.Errorf("[CreateAPIInvoiceApprovalRequest] Failed to save approval data: %v", err) + return err + } + + approveButton := apiApprovalConfirmationMenu.Data("✅ Approve & Pay", "approve_api_tx") + cancelButton := apiApprovalConfirmationMenu.Data("🚫 Cancel", "cancel_api_tx") + approveButton.Data = id + cancelButton.Data = id + + apiApprovalConfirmationMenu.Inline( + apiApprovalConfirmationMenu.Row( + approveButton, + cancelButton), + ) + + if _, err := bot.Telegram.Send(fromUser.Telegram, confirmText, apiApprovalConfirmationMenu, tb.ModeMarkdown); err != nil { + log.Errorf("[CreateAPIInvoiceApprovalRequest] Failed to send approval request: %v", err) + return err + } + + log.Infof("[CreateAPIInvoiceApprovalRequest] Sent invoice approval request to @%s for transaction %s", fromUser.Telegram.Username, transactionID) + return nil +} + // CreateAPIApprovalRequest creates an approval request for API transaction (similar to send confirmation) func CreateAPIApprovalRequest(bot *TipBot, fromUser *lnbits.User, toUsername string, amount int64, memo string, transactionID string, clientIP string) error { // Check if toUsername is actually a user ID and get the actual username