From b89da36215aa2b44d874a44fcf99cb880ad03770 Mon Sep 17 00:00:00 2001 From: Buddhi Thilakshana Date: Fri, 16 Oct 2026 18:10:53 +0530 Subject: [PATCH 1/2] feat: pay bolt11 Lightning invoices via /api/v1/send /api/v1/send now accepts a bolt11 invoice (lnbc..., optionally with a lightning: prefix) as `to` and pays it externally. The amount comes from the invoice; amountless invoices are rejected. - Validate invoice amount against min/max limits and the admin approval threshold, and require a 2% routing-fee reserve. - Reserve sender balance per user so concurrent sends cannot overspend. - Idempotency on the payment hash: short in-memory lock plus a durable 24h approval lock, so the same invoice is never paid twice. - Wait for the payment to settle (settlement_timeout) before reporting success; unsettled payments return 202 pending, never a false failure. - Add GET /api/v1/send/payment/{payment_hash} to resolve in-flight payments (waits up to 5s per call). - Telegram approval flow supports invoices, with expiry checks and rollback when the approval message cannot be delivered. - HMAC: each signed request is single-use and future timestamps are rejected. Clients must sign a fresh timestamp on every retry. The used-signature set is in memory, so the bot must run as one instance. - Docs: docs/invoice-payments-api.md and updated referral-api.md. --- README.md | 3 +- config.yaml.example | 5 + docs/invoice-payments-api.md | 489 ++++++++++++++++++ docs/referral-api.md | 44 +- internal/api/analytics.go | 7 +- internal/api/middleware.go | 74 ++- internal/api/pending_transaction.go | 147 +++++- internal/api/send.go | 366 ++++++++++++- internal/api/server.go | 14 +- internal/config.go | 38 ++ internal/lnbits/settlement.go | 207 ++++++++ internal/lnbits/settlement_test.go | 140 +++++ internal/lnbits/types.go | 21 +- internal/storage/hooks.go | 30 ++ internal/telegram/api_approval.go | 233 ++++++++- internal/telegram/api_approval_test.go | 30 ++ internal/telegram/balance_reservation.go | 90 ++++ internal/telegram/balance_reservation_test.go | 72 +++ internal/utils/number_format.go | 7 + internal/utils/number_format_test.go | 23 + main.go | 4 + 21 files changed, 1979 insertions(+), 65 deletions(-) create mode 100644 docs/invoice-payments-api.md create mode 100644 internal/lnbits/settlement.go create mode 100644 internal/lnbits/settlement_test.go create mode 100644 internal/telegram/api_approval_test.go create mode 100644 internal/telegram/balance_reservation.go create mode 100644 internal/telegram/balance_reservation_test.go create mode 100644 internal/utils/number_format_test.go 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/config.yaml.example b/config.yaml.example index e2a4e965..de0eb9d4 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -77,6 +77,11 @@ api: admin_approval_threshold: 100000 max_memo_length: 280 rate_limit: 60 + # How long to wait for an outgoing bolt11 payment to actually settle before + # answering the caller. lnbits returns as soon as it has handed the payment + # to its backend, so without this wait a payment that is still routing (or + # never leaves the node) would be reported as a success. + settlement_timeout: 60 # seconds whitelisted_wallets: BiccoindeepaDSA: username: "BiccoindeepaDSA" diff --git a/docs/invoice-payments-api.md b/docs/invoice-payments-api.md new file mode 100644 index 00000000..5658e261 --- /dev/null +++ b/docs/invoice-payments-api.md @@ -0,0 +1,489 @@ +# 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` + +A `200` is returned only once the payment has **settled** — the preimage is in and the sats have left the node. A payment that is accepted but still routing returns `202` (see below), never `200`. + +```json +{ + "success": true, + "status": "settled", + "message": "Invoice paid successfully", + "from_user": "yourprovider", + "to_user": "lnbc10u1p3xyz...", + "amount": 1000, + "amount_lkr": "32.50", + "payment_hash": "3d2f...e91a", + "preimage": "6f1a...2c", + "fee": 1 +} +``` + +| Field | Description | +|----------------|-------------| +| `success` | `true` on a settled payment | +| `status` | `settled` on a `200`. See [settlement states](#settlement-states) | +| `amount` | Amount paid, in sats (from the invoice) | +| `payment_hash` | Payment hash of the settled invoice — use this as your reconciliation key | +| `preimage` | Proof of payment, returned once the payment settles | +| `fee` | Routing fee actually paid, in sats | +| `amount_lkr` | LKR value of the amount, if a price is available | + +#### Still in flight — `202 Accepted` + +If the payment does not settle within the operator's `settlement_timeout` (default 60s), it is reported as **unresolved**. It is neither a success nor a failure: the sats may still leave the node. + +```json +{ + "success": false, + "status": "pending", + "message": "Invoice payment is still in flight and has not settled yet. Poll /api/v1/send/payment/{payment_hash} for the final result. Do not retry this invoice.", + "from_user": "yourprovider", + "to_user": "lnbc10u1p3xyz...", + "amount": 1000, + "payment_hash": "3d2f...e91a" +} +``` + +**Do not retry the invoice and do not treat it as failed.** Poll `GET /api/v1/send/payment/{payment_hash}` until it resolves to `settled` or `failed`. + +#### 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, + "status": "awaiting_approval", + "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. + +> Both 202 responses mean "not done yet", but they need different handling. Switch on `status`: `awaiting_approval` waits on an operator (poll `/send/status/{id}`), `pending` is already in flight (poll `/send/payment/{payment_hash}`). + +#### 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", + "payment_hash": "3d2f...e91a", + "request_timestamp": "2026-07-17T10:00:00Z", + "expiry_time": "2026-07-18T10:00:00Z", + "approved_by": "operatoradmin", + "approval_time": "2026-07-17T10:04:12Z" +} +``` + +`payment_hash` is present for invoice transactions. It is the key for `GET /api/v1/send/payment/{payment_hash}`. + +#### Status values + +| Status | Meaning | +|-------------|---------| +| `pending` | Awaiting operator approval | +| `approved` | Approved, execution in progress | +| `executed` | Invoice paid and **settled** | +| `in_flight` | Approved and accepted by the node, but not settled within `settlement_timeout`. **Neither paid nor failed** | +| `failed` | Approved and attempted, but the payment did not route. The sats were not sent | +| `rejected` | Operator declined the payment | +| `expired` | Not approved within the 24‑hour window | + +Poll at a modest interval (e.g. every 5–15s). Transactions awaiting approval expire after **24 hours**. + +If an approved payment is accepted by the node but has not settled within `settlement_timeout`, the transaction moves to `in_flight`. This is deliberately a distinct status from `pending`: only `pending` transactions expire, so an in‑flight payment is never reported as `expired`. Resolve it with `GET /api/v1/send/payment/{payment_hash}` using the `payment_hash` from this response. + +> **Never treat `in_flight` as a failure.** The sats may already have left the node. Paying a different invoice for the same order while a transaction is `in_flight` risks paying twice. + +--- + +### 2.3 GET /api/v1/send/payment/{payment_hash} + +Resolves an outgoing invoice payment — use it whenever `POST /api/v1/send` returned `202` with `status: "pending"`, and for reconciliation after a network timeout. + +`GET` requests sign over an **empty body**: `MESSAGE = "GET" + "/api/v1/send/payment/{payment_hash}" + TIMESTAMP + ""`. + +This checks the node live and waits up to ~5 seconds for the payment to resolve before answering. If it is still `pending` or `unknown`, poll again with backoff until the status is terminal. + +#### Response — `200 OK` + +```json +{ + "success": true, + "status": "settled", + "message": "Payment is settled", + "from_user": "yourprovider", + "payment_hash": "3d2f...e91a", + "preimage": "6f1a...2c", + "fee": 1 +} +``` + +#### Settlement states + +| `status` | `success` | Meaning | What to do | +|-----------|-----------|---------|------------| +| `settled` | `true` | The preimage is in. The sats left the node and reached the destination | Done. Record `payment_hash` + `preimage` | +| `failed` | `false` | The node resolved the payment without a preimage. The sats are back in the wallet | Safe to pay a new invoice for the same order | +| `pending` | `false` | Still routing | Keep polling. **Do not** retry, and do not treat it as failed | +| `unknown` | `false` | The payment could not be looked up (node unreachable, or no record for this hash) | Keep polling; escalate to the operator if it persists | + +> `pending` and `unknown` are **not** failures. Paying a different invoice for the same order while one of these is outstanding risks paying twice. + +--- + +### 2.4 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. | +| **Concurrent payments** | Different invoices can be paid **in parallel** from the same wallet. Each in‑flight payment's amount plus fee reserve is held against your available balance until the Lightning backend accepts it, so parallel requests are rejected with the insufficient‑balance errors once the balance is used up. | +| **Replay protection** | Each signed request is accepted **once**. Sign every request (including retries) with a fresh `X-Timestamp`; resending an identical signed request returns `401 Request already processed`. | +| **Settlement confirmation** | A `200` is returned only after the payment **settles**. A payment still routing after `settlement_timeout` (default **60s**) returns `202` with `status: "pending"` — it is not a failure. | +| **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 rejected the payment | `Invoice payment failed` | +| `400` | Accepted but did not route — sats **not** sent | `Invoice payment failed: the payment could not be routed to the destination node` | +| `401` | Missing/expired timestamp, missing/invalid signature | `Invalid signature` / `Request expired` / `Missing timestamp` / `Request already processed` | +| `400` | Telegram approval request could not be delivered — nothing was paid | `Could not deliver the approval request via Telegram. Nothing was paid; please retry.` | +| `429` | Rate limited | — | + +The **routing‑failure** `400` (the last row above) carries the full send body rather than just `error`, so you keep the reconciliation key: + +```json +{ + "success": false, + "status": "failed", + "error": "Invoice payment failed: the payment could not be routed to the destination node", + "message": "Invoice payment failed: the payment could not be routed to the destination node", + "from_user": "yourprovider", + "to_user": "lnbc10u1p3xyz...", + "amount": 1000, + "payment_hash": "3d2f...e91a" +} +``` + +`error` is present on every `400`, so clients that read only `error` keep working unchanged. + +**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` via `GET /api/v1/send/payment/{payment_hash}`. + +A `400` for a payment failure means the sats were **not** sent and are back in the wallet — it is safe to pay a different invoice for the same order. That guarantee does not extend to `202 pending` or `unknown`, which are unresolved rather than failed. + +--- + +## 5. End‑to‑end flow + +``` + ┌─────────────────────────────┐ + │ 1. (optional) userbalance │ + │ check headroom + fee │ + └──────────────┬───────────────┘ + ▼ + ┌─────────────────────────────┐ + │ 2. POST /api/v1/send │ + │ { "to": "lnbc..." } │ + └──────────────┬───────────────┘ + ┌────────────────────┼────────────────────┐ + 200 OK │ 202 Accepted│ │ 400 + ▼ ▼ ▼ + ┌──────────────────┐ switch on `status` ┌────────────────────┐ + │ settled — record │ ┌──────────────┐ │ failed — sats NOT │ + │ hash + preimage │ │ │ │ sent, safe to │ + └──────────────────┘ ▼ ▼ │ retry a new invoice│ + ┌───────────────────┐ ┌──────────────────────────┐ │ + │ awaiting_approval │ │ pending — in flight │ │ + │ poll │ │ poll │ │ + │ /send/status/{id} │ │ /send/payment/{hash} │ │ + │ → executed | │ │ → settled | failed │ │ + │ rejected | │ │ NEVER retry a new invoice │ │ + │ expired | │ └──────────────────────────┘ │ + │ in_flight ──────┼──────────┘ │ + └───────────────────┘ └──────────────┘ +``` + +An approved payment that does not settle in time lands on `in_flight`. Take the `payment_hash` from the `/send/status/{id}` response and continue on `/send/payment/{payment_hash}`, exactly as for a `202 pending`. + +### 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 | failed) +# → { "status": "in_flight", "payment_hash": "3d2f...e91a" } +# not a terminal state — continue on /api/v1/send/payment/{payment_hash} +``` + +--- + +## 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 switching on `status`: `awaiting_approval` → poll `/send/status/{id}`; `pending` → poll `/send/payment/{payment_hash}`. +- [ ] Treat only `settled` as paid and only `failed` as not‑paid. Never book `pending`, `unknown` or `in_flight` as either. +- [ ] Follow an `in_flight` from `/send/status/{id}` onto `/send/payment/{payment_hash}`. It is not a terminal state and it never expires on its own. +- [ ] 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/analytics.go b/internal/api/analytics.go index ee0e1c2c..07cf5e3c 100644 --- a/internal/api/analytics.go +++ b/internal/api/analytics.go @@ -14,7 +14,6 @@ import ( "github.com/LightningTipBot/LightningTipBot/internal/telegram" "github.com/gorilla/mux" log "github.com/sirupsen/logrus" - "golang.org/x/time/rate" ) const ( @@ -28,10 +27,6 @@ const ( maxValidTimestamp int64 = 4102444800 ) -// lnbitsRateLimiter enforces a shared rate limit of 150 req/min (safely below LNbits' 200/min limit) -// across all concurrent analytics requests. -var lnbitsRateLimiter = rate.NewLimiter(rate.Every(400*time.Millisecond), 1) - // TransactionAnalyticsResponse represents the analytics data response type TransactionAnalyticsResponse struct { Status string `json:"status"` @@ -515,7 +510,7 @@ func fetchPaymentsWithRateLimit(ctx context.Context, s Service, user *lnbits.Use backoff := 2 * time.Second for attempt := 0; attempt <= maxRetries; attempt++ { - if err := lnbitsRateLimiter.Wait(ctx); err != nil { + if err := lnbits.RateLimiter.Wait(ctx); err != nil { return nil, err } diff --git a/internal/api/middleware.go b/internal/api/middleware.go index c4c87299..2512cf51 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -12,6 +12,7 @@ import ( "net/http/httputil" "strconv" "strings" + "sync" "time" "github.com/LightningTipBot/LightningTipBot/internal" @@ -133,10 +134,62 @@ func dump(r *http.Request) string { return string(x) } +// ctxKey is an unexported context key type, so values stored under it cannot +// collide with keys set by other packages. +type ctxKey string + +// requestStartKey carries the time a request entered the handler chain. +const requestStartKey ctxKey = "request_start" + +// RequestStart returns when the request entered the handler chain, and whether +// the middleware recorded it. Handlers use it to budget a slow wait against the +// server's WriteTimeout, which starts before the handler is called. +func RequestStart(ctx context.Context) (time.Time, bool) { + start, ok := ctx.Value(requestStartKey).(time.Time) + return start, ok +} + +// signatureSet remembers HMAC signatures that were already accepted, so each +// signed request can be used only once. +// +// The set lives in process memory, so this assumes the bot runs as a single +// instance: a second instance would not see signatures used on the first and +// would accept a replay. A restart is safe only because the set is empty for at +// most one timestamp window. Move it to shared storage (e.g. Bunt) before +// running more than one instance. +type signatureSet struct { + mu sync.Mutex + seen map[string]time.Time // signature -> when it can be forgotten +} + +var usedSignatures = &signatureSet{seen: make(map[string]time.Time)} + +// claim records sig and reports whether it was unused. ttl must cover the whole +// timestamp window the signature is valid for; after that the timestamp check +// rejects it anyway. +func (s *signatureSet) claim(sig string, ttl time.Duration) bool { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + for k, expiry := range s.seen { + if now.After(expiry) { + delete(s.seen, k) + } + } + if _, used := s.seen[sig]; used { + return false + } + s.seen[sig] = now.Add(ttl) + return true +} + // WalletHMACMiddleware validates HMAC signatures for wallet-based API endpoints // It identifies the sending wallet by validating the signature against each whitelisted wallet's secret func WalletHMACMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + requestStart := time.Now() + // Get timestamp from header for replay attack prevention timestampStr := r.Header.Get("X-Timestamp") if timestampStr == "" { @@ -160,8 +213,10 @@ func WalletHMACMiddleware(next http.HandlerFunc) http.HandlerFunc { tolerance = 300 // Default 5 minutes } - if now-timestamp > tolerance { - log.Warnf("Request timestamp too old (age: %d seconds, tolerance: %d)", now-timestamp, tolerance) + // Reject timestamps too far in either direction: a future timestamp + // would keep a captured request replayable until it came due. + if age := now - timestamp; age > tolerance || age < -tolerance { + log.Warnf("Request timestamp outside tolerance (age: %d seconds, tolerance: %d)", age, tolerance) http.Error(w, "Request expired", http.StatusUnauthorized) return } @@ -205,8 +260,21 @@ func WalletHMACMiddleware(next http.HandlerFunc) http.HandlerFunc { return } - // Add authenticated wallet info to request context + // A valid signature is single use. Without this, a captured /send + // request could be replayed inside the tolerance window to move the + // same sats again. + if !usedSignatures.claim(signature, time.Duration(2*tolerance)*time.Second) { + log.Warnf("Replayed HMAC signature rejected for wallet: %s", authenticatedWallet) + http.Error(w, "Request already processed", http.StatusUnauthorized) + return + } + + // Add authenticated wallet info to request context, along with the time + // the request entered the handler chain. Handlers that wait on + // something slow budget against the server's WriteTimeout, which is + // already ticking by the time they run (see sendToInvoice). ctx := context.WithValue(r.Context(), "authenticated_wallet", authenticatedWallet) + ctx = context.WithValue(ctx, requestStartKey, requestStart) r = r.WithContext(ctx) log.Debugf("Wallet API request authenticated for wallet: %s", authenticatedWallet) diff --git a/internal/api/pending_transaction.go b/internal/api/pending_transaction.go index 835b6b4f..677a6de8 100644 --- a/internal/api/pending_transaction.go +++ b/internal/api/pending_transaction.go @@ -1,6 +1,7 @@ package api import ( + "errors" "fmt" "time" @@ -8,6 +9,7 @@ import ( "github.com/LightningTipBot/LightningTipBot/internal/storage" "github.com/LightningTipBot/LightningTipBot/internal/telegram" log "github.com/sirupsen/logrus" + "github.com/tidwall/buntdb" ) // PendingTransaction represents a transaction awaiting admin approval @@ -20,6 +22,9 @@ 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" + PaymentHash string `json:"payment_hash,omitempty"` // bolt11 payment hash when PaymentType == "invoice" RequestTimestamp time.Time `json:"request_timestamp"` Status string `json:"status"` // "pending", "approved", "rejected", "expired" ApprovedBy string `json:"approved_by,omitempty"` @@ -30,12 +35,22 @@ type PendingTransaction struct { } const ( - PendingTransactionExpiry = 24 * time.Hour // Pending transactions expire after 24 hours - StatusPending = "pending" - StatusApproved = "approved" - StatusRejected = "rejected" - StatusExpired = "expired" - StatusExecuted = "executed" + PendingTransactionExpiry = storage.PendingTxExpiry // Pending transactions expire after 24 hours + + // The canonical values live in the storage package so the telegram approval + // handlers, which cannot import this package, write the same vocabulary. + StatusPending = storage.TxStatusPending + StatusApproved = storage.TxStatusApproved + StatusRejected = storage.TxStatusRejected + StatusExpired = storage.TxStatusExpired + StatusExecuted = storage.TxStatusExecuted + StatusFailed = storage.TxStatusFailed + StatusInFlight = storage.TxStatusInFlight + + // StatusAwaitingApproval is a SendResponse status only. It distinguishes a + // 202 that is waiting on an operator from a 202 whose payment is already in + // flight, which the caller has to handle very differently. + StatusAwaitingApproval = "awaiting_approval" ) // NewPendingTransaction creates a new pending transaction @@ -64,6 +79,111 @@ 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, + PaymentHash: paymentHash, + RequestTimestamp: time.Now(), + Status: StatusPending, + ExpiryTime: time.Now().Add(PendingTransactionExpiry), + ClientIP: clientIP, + } +} + +// InvoiceApprovalLock links a bolt11 payment hash to the approval request it +// created. PendingTransactions live in Bunt, a key-value store with no +// secondary indexes, so this record is what makes "does this invoice already +// have an approval waiting?" answerable. +// +// It complements, rather than replaces, the short-lived MemoCache lock taken in +// sendToInvoice: that one serialises concurrent in-flight requests but expires +// after 5 minutes, whereas an approval stays actionable for +// PendingTransactionExpiry (24h). Without this record a caller could re-submit +// the same invoice minutes later and queue up a second approval for it. +type InvoiceApprovalLock struct { + *storage.Base + PaymentHash string `json:"payment_hash"` + PendingTxID string `json:"pending_tx_id"` +} + +// invoiceApprovalLockKey is the Bunt key holding the outstanding approval for a +// bolt11 payment hash. +func invoiceApprovalLockKey(paymentHash string) string { + return fmt.Sprintf("invoice-approval-%s", paymentHash) +} + +// FindPendingInvoiceApproval returns the still-approvable request for this +// payment hash, or nil when the invoice is free to be submitted. Resolved +// (approved/rejected/executed) and expired requests do not block a retry. +// +// A storage error other than "not found" is returned rather than read as "no +// approval": failing open would let the same invoice get a second approval. +func FindPendingInvoiceApproval(bot *telegram.TipBot, paymentHash string) (*PendingTransaction, error) { + if paymentHash == "" { + return nil, nil + } + lock := &InvoiceApprovalLock{Base: storage.New(storage.ID(invoiceApprovalLockKey(paymentHash)))} + sn, err := lock.Get(lock, bot.Bunt) + if errors.Is(err, buntdb.ErrNotFound) { + // No record for this hash: nothing outstanding. + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read invoice approval lock: %w", err) + } + existing, ok := sn.(*InvoiceApprovalLock) + if !ok || existing.PendingTxID == "" { + return nil, nil + } + pt, err := LoadPendingTransaction(existing.PendingTxID, bot) + if errors.Is(err, buntdb.ErrNotFound) { + log.Warnf("[api/send] Invoice approval lock %s points at missing transaction %s", paymentHash, existing.PendingTxID) + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("load pending transaction %s: %w", existing.PendingTxID, err) + } + if !pt.CanBeApproved() { + return nil, nil + } + return pt, nil +} + +// abandonPendingTransaction marks a pending transaction failed when its +// approval request could not be set up, so it cannot be approved later and, +// for invoices, no longer blocks a resubmission. +func abandonPendingTransaction(bot *telegram.TipBot, pt *PendingTransaction) { + pt.Status = StatusFailed + if err := pt.SaveToDB(bot); err != nil { + log.Errorf("[api/send] Could not mark pending transaction %s failed: %v", pt.ID, err) + } +} + +// RecordPendingInvoiceApproval records that paymentHash now has an approval +// request outstanding, so duplicates are rejected for the approval window. +func RecordPendingInvoiceApproval(bot *telegram.TipBot, paymentHash, pendingTxID string) error { + if paymentHash == "" { + return nil + } + lock := &InvoiceApprovalLock{ + Base: storage.New(storage.ID(invoiceApprovalLockKey(paymentHash))), + PaymentHash: paymentHash, + PendingTxID: pendingTxID, + } + return lock.Set(lock, bot.Bunt) +} + // IsExpired checks if the pending transaction has expired func (pt *PendingTransaction) IsExpired() bool { return time.Now().After(pt.ExpiryTime) @@ -136,12 +256,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..cb0ca006 100644 --- a/internal/api/send.go +++ b/internal/api/send.go @@ -10,12 +10,14 @@ import ( "strings" "time" + "github.com/LightningTipBot/LightningTipBot/internal" "github.com/LightningTipBot/LightningTipBot/internal/lnbits" "github.com/LightningTipBot/LightningTipBot/internal/str" "github.com/LightningTipBot/LightningTipBot/internal/telegram" "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" ) @@ -29,14 +31,22 @@ type SendRequest struct { // SendResponse represents the JSON response for the send API type SendResponse struct { - Success bool `json:"success"` + Success bool `json:"success"` + Status string `json:"status,omitempty"` // settlement state for invoice payments: settled/pending/failed/unknown + // Error carries the same message as an ErrorResponse so a failed payment + // can keep the documented {"error": ...} shape while still returning the + // payment_hash the caller needs to reconcile. + Error string `json:"error,omitempty"` TransactionHash string `json:"transaction_hash,omitempty"` Message string `json:"message"` FromUser string `json:"from_user"` - ToUser string `json:"to_user"` - Amount int64 `json:"amount"` + ToUser string `json:"to_user,omitempty"` + Amount int64 `json:"amount,omitempty"` AmountLKR string `json:"amount_lkr,omitempty"` // LKR conversion Memo string `json:"memo,omitempty"` + PaymentHash string `json:"payment_hash,omitempty"` // set for bolt11 invoice payments + Preimage string `json:"preimage,omitempty"` // proof of payment, set once settled + Fee int64 `json:"fee,omitempty"` // routing fee in sats for invoice payments } // InternalNetworkMiddleware restricts access to internal network IPs (configurable) @@ -136,6 +146,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 @@ -196,15 +215,20 @@ func (s Service) Send(w http.ResponseWriter, r *http.Request) { return } - // Check sender's available balance (wallet balance - pot balance) - balance, err := s.Bot.GetUserAvailableBalance(fromUser) + // Check sender's available balance (wallet balance - pot balance) and + // reserve it for this send, so concurrent payments from the same sender + // cannot together spend sats reserved in pots. + reservation, balance, err := s.Bot.ReserveBalance(fromUser, req.Amount) 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 reservation != nil { + defer reservation.Release() + } - if balance < req.Amount { + if reservation == nil { log.Warnf("[api/send] Insufficient available balance for %s: %d < %d", fromUsername, balance, req.Amount) RespondError(w, fmt.Sprintf("Insufficient balance: %s available, %s required", thirdparty.FormatSatsWithLKR(balance), thirdparty.FormatSatsWithLKR(req.Amount))) return @@ -287,13 +311,17 @@ func (s Service) Send(w http.ResponseWriter, r *http.Request) { } // Send approval request using Telegram callback buttons (same as /send command) - err = telegram.CreateAPIApprovalRequest(s.Bot, fromUser, toIdentifier, req.Amount, req.Memo, pendingTx.ID, clientIP) + err = telegram.CreateAPIApprovalRequest(s.Bot, fromUser, toIdentifier, req.Amount, req.Memo, pendingTx.ID, clientIP, pendingTx.ExpiryTime) if err != nil { - log.Warnf("[api/send] Failed to send approval request: %v", err) + log.Errorf("[api/send] Failed to send approval request: %v", err) + abandonPendingTransaction(s.Bot, pendingTx) + RespondError(w, "Could not deliver the approval request via Telegram. Nothing was sent; please retry.") + return } response := SendResponse{ Success: false, + Status: StatusAwaitingApproval, Message: fmt.Sprintf("Transaction requires admin approval (amount: %s > threshold: %s). Approval request sent to you via Telegram. Transaction ID: %s", thirdparty.FormatSatsWithLKR(req.Amount), thirdparty.FormatSatsWithLKR(GetWalletAdminApprovalThreshold(walletID)), pendingTx.ID), FromUser: fromUsername, @@ -388,7 +416,10 @@ func (s Service) SendStatus(w http.ResponseWriter, r *http.Request) { return } - // Auto-mark as expired if past expiry time but still showing pending + // Auto-mark as expired if past expiry time but still awaiting approval. + // Only StatusPending is expirable: StatusInFlight means the payment was + // approved and accepted by the node, so reporting it as expired would tell + // the caller sats that may already have settled were never sent. status := pt.Status if status == StatusPending && pt.IsExpired() { status = StatusExpired @@ -402,6 +433,7 @@ func (s Service) SendStatus(w http.ResponseWriter, r *http.Request) { Amount int64 `json:"amount"` AmountLKR string `json:"amount_lkr,omitempty"` Memo string `json:"memo,omitempty"` + PaymentHash string `json:"payment_hash,omitempty"` RequestTimestamp time.Time `json:"request_timestamp"` ExpiryTime time.Time `json:"expiry_time"` ApprovedBy string `json:"approved_by,omitempty"` @@ -410,6 +442,8 @@ func (s Service) SendStatus(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) + // PaymentHash is set for invoice transactions, so a caller that sees + // in_flight can resolve the outcome via /api/v1/send/payment/{payment_hash}. json.NewEncoder(w).Encode(StatusResponse{ ID: pt.ID, Status: status, @@ -418,6 +452,7 @@ func (s Service) SendStatus(w http.ResponseWriter, r *http.Request) { Amount: pt.Amount, AmountLKR: getLKRValue(pt.Amount), Memo: pt.Memo, + PaymentHash: pt.PaymentHash, RequestTimestamp: pt.RequestTimestamp, ExpiryTime: pt.ExpiryTime, ApprovedBy: pt.ApprovedBy, @@ -425,6 +460,319 @@ 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) + // MemoCache gives every entry a 5 minute TTL (see main.go), so a request + // that dies before the deferred Delete cannot block the invoice forever. + 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, and reserve it + // until lnbits has the payment. Other payments from this sender may run + // concurrently: each one's reservation is subtracted here, so together + // they cannot spend sats reserved in pots. + reservation, balance, err := s.Bot.ReserveBalance(fromUser, utils.AmountWithFeeReserve(amount)) + 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 reservation != nil { + defer reservation.Release() + } + 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 reservation == nil { + 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) + + // The MemoCache lock above only spans 5 minutes, but an approval stays + // actionable for 24h. Reject a second request for an invoice that + // already has an approval waiting, so the same bolt11 cannot end up + // with two approvable requests. + existing, err := FindPendingInvoiceApproval(s.Bot, bolt11.PaymentHash) + if err != nil { + log.Errorf("[api/send] Could not check invoice %s for an outstanding approval: %v", bolt11.PaymentHash, err) + RespondError(w, "Failed to create pending transaction") + return + } + if existing != nil { + log.Warnf("[api/send] Invoice %s already has approval %s awaiting confirmation", bolt11.PaymentHash, existing.ID) + RespondError(w, fmt.Sprintf("This invoice already has an approval request awaiting confirmation (transaction ID: %s)", existing.ID)) + return + } + + 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 + } + // Without the durable lock a second approval could be queued for this + // invoice once the MemoCache lock lapses, so its failure is fatal. + if err := RecordPendingInvoiceApproval(s.Bot, bolt11.PaymentHash, pendingTx.ID); err != nil { + log.Errorf("[api/send] Could not record invoice approval lock for %s: %v", bolt11.PaymentHash, err) + abandonPendingTransaction(s.Bot, pendingTx) + RespondError(w, "Failed to create pending transaction") + return + } + if err := telegram.CreateAPIInvoiceApprovalRequest(s.Bot, fromUser, paymentRequest, amount, memo, pendingTx.ID, clientIP, pendingTx.ExpiryTime); err != nil { + log.Errorf("[api/send] Failed to send invoice approval request: %v", err) + abandonPendingTransaction(s.Bot, pendingTx) + RespondError(w, "Could not deliver the approval request via Telegram. Nothing was paid; please retry.") + return + } + + response := SendResponse{ + Success: false, + Status: StatusAwaitingApproval, + 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) + payStart := time.Now() + inv, err := fromUser.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: paymentRequest}, s.Bot.Client) + // lnbits now accounts for the payment in its own balance (or rejected + // it), so free the reservation before the long settlement wait. + reservation.Release() + 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) + } + // Keep the raw error in the logs only — it can carry the internal + // lnbits host/port, which must not reach the API caller. + RespondError(w, "Invoice payment failed") + return + } + + // lnbits answers as soon as it has handed the payment to its backend, which + // is not the same as the sats reaching the destination node. Follow the + // payment to a terminal state before telling anyone it succeeded. + paymentHash := inv.PaymentHash + if paymentHash == "" { + paymentHash = bolt11.PaymentHash + } + // lnbits itself blocks for a while before answering, so spend only what is + // left of the server's write budget on waiting. Overrunning it would drop + // the connection and leave the caller with exactly the ambiguous outcome + // this check exists to remove. + // + // The budget is measured from when the request entered the handler chain, + // not from the Pay call: the server's write deadline is already ticking + // through HMAC verification, the user lookup and the balance check. + elapsedSince := payStart + if start, ok := RequestStart(r.Context()); ok { + elapsedSince = start + } + settlementBudget := internal.APISendSettlementTimeout() + if available := ServerWriteTimeout - responseWriteMargin - time.Since(elapsedSince); settlementBudget > available { + settlementBudget = available + } + settlement := s.Bot.Client.WaitForOutgoingPayment(*fromUser.Wallet, paymentHash, settlementBudget) + + switch settlement.State { + case lnbits.PaymentStateFailed: + log.Errorf("[api/send] ❌ Invoice payment did not settle for %s (%d sat, hash %s): %v", fromUsername, amount, paymentHash, settlement.Err) + if s.Bot.ErrorLogger != nil { + s.Bot.ErrorLogger.LogPaymentError(fmt.Errorf("payment did not settle: %s", settlement.State), amount, bolt11.Description, paymentRequest, fromUser.Telegram) + } + failureMsg := fmt.Sprintf("❌ Invoice payment failed — the payment could not be routed and your %s was not sent.", thirdparty.FormatSatsWithLKR(amount)) + if _, serr := s.Bot.Telegram.Send(fromUser.Telegram, failureMsg); serr != nil { + log.Warnf("[api/send] Could not send failure notice to sender: %v", serr) + } + // Answer with the full SendResponse rather than a bare error: the + // caller still needs the payment_hash to reconcile this attempt, and + // `status` tells it apart from the pending case. `error` keeps the + // documented error-body shape intact for existing clients. + const failureReason = "Invoice payment failed: the payment could not be routed to the destination node" + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(SendResponse{ + Success: false, + Status: lnbits.PaymentStateFailed.String(), + Error: failureReason, + Message: failureReason, + FromUser: fromUsername, + ToUser: paymentRequest, + Amount: amount, + AmountLKR: getLKRValue(amount), + Memo: memo, + PaymentHash: paymentHash, + }) + return + + case lnbits.PaymentStatePending, lnbits.PaymentStateUnknown: + // The sats may still leave the node, so this is neither a success nor a + // failure. Report it as unresolved and let the caller poll the hash. + log.Warnf("[api/send] ⏳ Invoice payment unresolved for %s (%d sat, hash %s, state %s): %v", fromUsername, amount, paymentHash, settlement.State, settlement.Err) + pendingMsg := fmt.Sprintf("⏳ Invoice payment of %s is still in flight. You will not be charged twice — check back shortly for the final result.", thirdparty.FormatSatsWithLKR(amount)) + if _, serr := s.Bot.Telegram.Send(fromUser.Telegram, pendingMsg); serr != nil { + log.Warnf("[api/send] Could not send pending notice to sender: %v", serr) + } + response := SendResponse{ + Success: false, + Status: settlement.State.String(), + Message: "Invoice payment is still in flight and has not settled yet. Poll /api/v1/send/payment/{payment_hash} for the final result. Do not retry this invoice.", + FromUser: fromUsername, + ToUser: paymentRequest, + Amount: amount, + AmountLKR: getLKRValue(amount), + Memo: memo, + PaymentHash: paymentHash, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(response) + return + } + + log.Infof("[api/send] ✅ Invoice settled: %s (%d sat, fee %d sat)", fromUsername, amount, settlement.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📄 Invoice: %s", str.MarkdownEscape(bolt11.Description)) + } + if memo != "" { + senderConfirmationMsg += fmt.Sprintf("\n✉️ Memo: %s", str.MarkdownEscape(memo)) + } + 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, + Status: lnbits.PaymentStateSettled.String(), + Message: "Invoice paid successfully", + FromUser: fromUsername, + ToUser: paymentRequest, + Amount: amount, + AmountLKR: getLKRValue(amount), + Memo: memo, + PaymentHash: paymentHash, + Preimage: settlement.Preimage, + Fee: settlement.Fee, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// paymentStatusWait is how long SendPaymentStatus polls lnbits before answering. +const paymentStatusWait = 5 * time.Second + +// SendPaymentStatus handles GET /api/v1/send/payment/{payment_hash}. It resolves +// an outgoing invoice payment that /api/v1/send left unsettled, so a caller that +// got a 202 can find out whether the sats eventually left the node. +func (s Service) SendPaymentStatus(w http.ResponseWriter, r *http.Request) { + // The hash is placed in an lnbits URL, so accept only a real payment hash. + paymentHash := strings.ToLower(mux.Vars(r)["payment_hash"]) + if !lnbits.ValidPaymentHash(paymentHash) { + RespondError(w, "Invalid payment_hash: expected 64 hex characters") + return + } + + authenticatedWallet := r.Context().Value("authenticated_wallet") + if authenticatedWallet == nil { + log.Error("[api/send/payment] No authenticated wallet found in request context") + RespondError(w, "Authentication failed") + return + } + wallet, exists := GetWhitelistedWallets()[authenticatedWallet.(string)] + if !exists { + log.Errorf("[api/send/payment] Authenticated wallet %s not found in configuration", authenticatedWallet.(string)) + RespondError(w, "Invalid wallet configuration") + return + } + + fromUser, err := telegram.GetUserByTelegramUsername(wallet.Username, *s.Bot) + if err != nil { + log.Errorf("[api/send/payment] Could not find user %s: %v", wallet.Username, err) + RespondError(w, fmt.Sprintf("Sender '@%s' not found or has no wallet", wallet.Username)) + return + } + + // Wait briefly so a payment that is about to resolve is not reported as + // unknown. Callers that still get pending/unknown poll again with backoff. + settlement := s.Bot.Client.WaitForOutgoingPayment(*fromUser.Wallet, paymentHash, paymentStatusWait) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(SendResponse{ + Success: settlement.State == lnbits.PaymentStateSettled, + Status: settlement.State.String(), + Message: fmt.Sprintf("Payment is %s", settlement.State), + FromUser: wallet.Username, + PaymentHash: paymentHash, + Preimage: settlement.Preimage, + Fee: settlement.Fee, + }) +} + // 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/api/server.go b/internal/api/server.go index 1b01778b..2872f26c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -19,14 +19,24 @@ type Server struct { const ( StatusError = "ERROR" StatusOk = "OK" + + // ServerWriteTimeout bounds how long a handler has to produce a response. + // A handler that overruns it has its write fail, leaving the caller with a + // dropped connection instead of an answer — so any handler that waits on + // something slow must budget against this (see sendToInvoice). + ServerWriteTimeout = 90 * time.Second + + // responseWriteMargin is held back from ServerWriteTimeout so there is + // always room to serialise and flush the response. + responseWriteMargin = 10 * time.Second ) func NewServer(address string) *Server { srv := &http.Server{ Addr: address, // Good practice: enforce timeouts for servers you create! - WriteTimeout: 90 * time.Second, - ReadTimeout: 90 * time.Second, + WriteTimeout: ServerWriteTimeout, + ReadTimeout: ServerWriteTimeout, } apiServer := &Server{ httpServer: srv, diff --git a/internal/config.go b/internal/config.go index 6d34209b..bdaf16a2 100644 --- a/internal/config.go +++ b/internal/config.go @@ -4,7 +4,9 @@ import ( "fmt" "net" "net/url" + "os" "strings" + "time" "github.com/jinzhu/configor" log "github.com/sirupsen/logrus" @@ -115,6 +117,7 @@ type APISendConfiguration struct { RateLimit int `yaml:"rate_limit"` WhitelistedWallets map[string]WhitelistedWallet `yaml:"whitelisted_wallets"` TimestampTolerance int64 `yaml:"timestamp_tolerance"` // seconds + SettlementTimeout int64 `yaml:"settlement_timeout"` // seconds to wait for an outgoing payment to settle } type WhitelistedWallet struct { @@ -176,9 +179,35 @@ func GetWebhookURLParsed() *url.URL { return Configuration.Lnbits.WebhookPublicUrlParsed } +// underTest reports whether this binary is a `go test` binary. +// +// Package init runs before the testing package registers its flags, so +// flag.Lookup("test.v") is not yet usable here and os.Args is the only +// reliable signal. This exists because most packages reach this one through +// the import graph (lnbits -> satdress -> network -> internal), so without it +// a unit test that never talks to lnbits still cannot run unless a full +// operator config.yaml sits in its package directory. +func underTest() bool { + if strings.HasSuffix(os.Args[0], ".test") || strings.HasSuffix(os.Args[0], ".test.exe") { + return true + } + for _, arg := range os.Args[1:] { + if strings.HasPrefix(arg, "-test.") { + return true + } + } + return false +} + // checkLnbitsConfiguration validates the lnbits configuration func checkLnbitsConfiguration() { if Configuration.Lnbits.Url == "" { + // A missing lnbits url stays fatal in production: the bot cannot move + // sats without it. Under `go test` there is no wallet to talk to, so + // refusing to link would only make the package untestable. + if underTest() { + return + } panic(fmt.Errorf("please configure a lnbits url")) } if Configuration.Lnbits.LnbitsPublicUrl == "" { @@ -220,6 +249,9 @@ func setAPISendDefaults() { if Configuration.API.Send.RateLimit == 0 { Configuration.API.Send.RateLimit = 60 } + if Configuration.API.Send.SettlementTimeout == 0 { + Configuration.API.Send.SettlementTimeout = 60 // seconds + } // Set default whitelisted wallets if none configured if len(Configuration.API.Send.WhitelistedWallets) == 0 { @@ -246,6 +278,12 @@ func IsAPISendEnabled() bool { return Configuration.API.Send.Enabled } +// APISendSettlementTimeout returns how long to wait for an outgoing Lightning +// payment to settle before reporting it back as still pending. +func APISendSettlementTimeout() time.Duration { + return time.Duration(Configuration.API.Send.SettlementTimeout) * time.Second +} + // setAPIAnalyticsDefaults sets default values for API Analytics configuration func setAPIAnalyticsDefaults() { if !Configuration.API.Analytics.Enabled { diff --git a/internal/lnbits/settlement.go b/internal/lnbits/settlement.go new file mode 100644 index 00000000..fefb9ee1 --- /dev/null +++ b/internal/lnbits/settlement.go @@ -0,0 +1,207 @@ +package lnbits + +import ( + "context" + "errors" + "regexp" + "strings" + "time" + + "golang.org/x/time/rate" +) + +// RateLimiter enforces a shared rate limit of 150 req/min (safely below LNbits' +// 200/min limit) across every caller that polls lnbits in a loop. +var RateLimiter = rate.NewLimiter(rate.Every(400*time.Millisecond), 1) + +// rateLimitGrace lets a settlement check wait this long for a rate limit slot +// past its own deadline, so a zero-timeout lookup still gets its one check. +const rateLimitGrace = 2 * time.Second + +var paymentHashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// ValidPaymentHash reports whether s is a lowercase hex sha256 payment hash. +// Anything else must not reach an lnbits URL. +func ValidPaymentHash(s string) bool { + return paymentHashPattern.MatchString(s) +} + +// PaymentState describes what lnbits knows about an outgoing payment. +// +// A 2xx from POST /api/v1/payments only means lnbits accepted the payment and +// handed it to its funding backend. It says nothing about whether the sats +// actually reached the destination node, so every outgoing payment has to be +// followed up with a settlement check before it is reported as successful. +type PaymentState int + +const ( + // PaymentStateUnknown means lnbits could not be reached, or answered in a + // shape we cannot interpret. Never report this as success or failure. + PaymentStateUnknown PaymentState = iota + // PaymentStatePending means the payment is still in flight. + PaymentStatePending + // PaymentStateSettled means the preimage is in: the sats left the node. + PaymentStateSettled + // PaymentStateFailed means lnbits resolved the payment without a preimage + // and the sats are back in the wallet. + PaymentStateFailed +) + +func (s PaymentState) String() string { + switch s { + case PaymentStatePending: + return "pending" + case PaymentStateSettled: + return "settled" + case PaymentStateFailed: + return "failed" + } + return "unknown" +} + +// PaymentSettlement is the outcome of following an outgoing payment. +type PaymentSettlement struct { + State PaymentState + Fee int64 // routing fee in sats, set for settled payments + Preimage string // proof of payment, set for settled payments + Err error // last lookup error, if any +} + +// Settlement checks are polled rather than streamed because the lnbits SSE feed +// only carries incoming payments. Each check makes lnbits re-query its funding +// source (check_transaction_status -> check_payment_status), so the interval +// backs off to keep a long wait well inside the lnbits request rate limit. +const ( + settlementPollInitial = 500 * time.Millisecond + settlementPollMax = 3 * time.Second + settlementPollFactor = 1.5 +) + +// WaitForOutgoingPayment polls lnbits until paymentHash reaches a terminal +// state or timeout expires. +// +// A PaymentStatePending or PaymentStateUnknown result means the payment may +// still settle later: it must not be reported to the caller as either a success +// or a failure, and the sats must not be treated as returned. +func (c Client) WaitForOutgoingPayment(w Wallet, paymentHash string, timeout time.Duration) PaymentSettlement { + if !ValidPaymentHash(paymentHash) { + return PaymentSettlement{State: PaymentStateUnknown, Err: errors.New("no valid payment hash to check")} + } + + last := PaymentSettlement{State: PaymentStateUnknown} + deadline := time.Now().Add(timeout) + interval := settlementPollInitial + for { + ctx, cancel := context.WithTimeout(context.Background(), time.Until(deadline)+rateLimitGrace) + err := RateLimiter.Wait(ctx) + cancel() + if err != nil { + if last.Err == nil { + last.Err = err + } + return last + } + + payment, err := c.Payment(w, paymentHash) + if err != nil { + // This includes a 404 "Payment does not exist". lnbits marks a + // failed payment FAILED and keeps the row, so a missing record is + // not evidence that the sats came back — only that we cannot see + // the payment. Stay unsure rather than claim a refund. + last = PaymentSettlement{State: PaymentStateUnknown, Err: err} + } else { + switch state := classifyPayment(payment); state { + case PaymentStateSettled: + return PaymentSettlement{ + State: PaymentStateSettled, + Fee: settlementFeeSats(payment), + Preimage: settlementPreimage(payment), + } + case PaymentStateFailed: + return PaymentSettlement{State: PaymentStateFailed} + default: + last = PaymentSettlement{State: state} + } + } + + remaining := time.Until(deadline) + if remaining <= 0 { + return last + } + if remaining > interval { + remaining = interval + } + time.Sleep(remaining) + if interval = time.Duration(float64(interval) * settlementPollFactor); interval > settlementPollMax { + interval = settlementPollMax + } + } +} + +// classifyPayment maps an lnbits payment lookup onto a PaymentState. +// +// Every state is reported only on positive evidence, and anything else is +// PaymentStateUnknown. That matters in both directions: calling an in-flight +// payment "failed" would tell a user their sats came back when they have not, +// and calling it "settled" is the bug this whole check exists to prevent. +// +// lnbits answers GET /api/v1/payments/{hash} in several shapes: +// +// {"paid": true, "preimage": "..", "details": {..}} settled +// {"paid": false, "status": "failed", "details": {..}} failed +// {"paid": false, "status": "pending", "details": {..}} in flight +// {"paid": false, "preimage": ".."} in flight, unauthorised +// +// The last shape carries no status at all, which is why a missing status is +// never read as a failure. +func classifyPayment(p LNbitsPayment) PaymentState { + if p.Paid { + return PaymentStateSettled + } + // lnbits PaymentState is one of "success", "pending", "failed". Older + // versions send no status and describe the payment with Details.Pending. + switch strings.ToLower(firstNonEmpty(p.Status, p.Details.Status)) { + case "success": + return PaymentStateSettled + case "failed": + return PaymentStateFailed + case "pending": + return PaymentStatePending + } + if p.Details.Pending { + return PaymentStatePending + } + return PaymentStateUnknown +} + +// settlementFeeSats returns the routing fee in sats. lnbits reports Payment.fee +// in millisats, and signs it negative on some versions. +func settlementFeeSats(p LNbitsPayment) int64 { + fee := p.Details.Fee + if fee == 0 { + fee = p.Fee + } + if fee < 0 { + fee = -fee + } + return fee / 1000 +} + +// settlementPreimage returns the payment preimage, treating the all-zero +// placeholder lnbits reports for unsettled payments as absent. +func settlementPreimage(p LNbitsPayment) string { + preimage := firstNonEmpty(p.Preimage, p.Details.Preimage) + if strings.Trim(preimage, "0") == "" { + return "" + } + return preimage +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/lnbits/settlement_test.go b/internal/lnbits/settlement_test.go new file mode 100644 index 00000000..f02b2deb --- /dev/null +++ b/internal/lnbits/settlement_test.go @@ -0,0 +1,140 @@ +package lnbits + +import ( + "encoding/json" + "testing" +) + +// The payloads below are the response shapes lnbits returns from +// GET /api/v1/payments/{payment_hash}. lnbits builds them differently for an +// authorised wallet key and for public access, and older versions describe a +// payment with `pending` instead of `status`, so all of those shapes have to +// classify correctly. +func TestClassifyPayment(t *testing.T) { + tests := []struct { + name string + body string + want PaymentState + }{ + { + name: "settled", + body: `{"paid":true,"preimage":"6f1a2b","details":{"status":"success","pending":false,"fee":-1000}}`, + want: PaymentStateSettled, + }, + { + name: "settled by status alone", + body: `{"paid":false,"status":"success","details":{"status":"success"}}`, + want: PaymentStateSettled, + }, + { + name: "failed", + body: `{"paid":false,"status":"failed","details":{"status":"failed","pending":false}}`, + want: PaymentStateFailed, + }, + { + name: "in flight", + body: `{"paid":false,"status":"pending","preimage":"0000000000000000000000000000000000000000000000000000000000000000","details":{"status":"pending","pending":true}}`, + want: PaymentStatePending, + }, + { + // lnbits omits both details and status on the public response, so + // an in-flight payment looks identical to a resolved one. It must + // not be read as a failure. + name: "in flight, public response without details", + body: `{"paid":false,"preimage":"0000000000000000000000000000000000000000000000000000000000000000"}`, + want: PaymentStateUnknown, + }, + { + name: "legacy settled without status", + body: `{"paid":true,"preimage":"6f1a2b","details":{"pending":false,"fee":-2000}}`, + want: PaymentStateSettled, + }, + { + name: "legacy in flight without status", + body: `{"paid":false,"details":{"pending":true}}`, + want: PaymentStatePending, + }, + { + name: "no evidence either way", + body: `{"paid":false,"details":{"pending":false}}`, + want: PaymentStateUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var p LNbitsPayment + if err := json.Unmarshal([]byte(tt.body), &p); err != nil { + t.Fatalf("could not unmarshal lnbits response: %v", err) + } + if got := classifyPayment(p); got != tt.want { + t.Errorf("classifyPayment() = %v, want %v", got, tt.want) + } + }) + } +} + +// lnbits reports Payment.fee in millisats, negative for an outgoing payment. +func TestSettlementFeeSats(t *testing.T) { + tests := []struct { + name string + body string + want int64 + }{ + {name: "negative millisats in details", body: `{"details":{"fee":-1000}}`, want: 1}, + {name: "positive millisats in details", body: `{"details":{"fee":2500}}`, want: 2}, + {name: "top level fee", body: `{"fee":-3000}`, want: 3}, + {name: "no fee", body: `{"details":{}}`, want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var p LNbitsPayment + if err := json.Unmarshal([]byte(tt.body), &p); err != nil { + t.Fatalf("could not unmarshal lnbits response: %v", err) + } + if got := settlementFeeSats(p); got != tt.want { + t.Errorf("settlementFeeSats() = %d, want %d", got, tt.want) + } + }) + } +} + +// lnbits sends an all-zero preimage for a payment that has not settled, which +// is not proof of payment and must not be reported as one. +func TestSettlementPreimage(t *testing.T) { + zero := `{"preimage":"0000000000000000000000000000000000000000000000000000000000000000"}` + var p LNbitsPayment + if err := json.Unmarshal([]byte(zero), &p); err != nil { + t.Fatalf("could not unmarshal lnbits response: %v", err) + } + if got := settlementPreimage(p); got != "" { + t.Errorf("settlementPreimage() = %q, want empty for the all-zero placeholder", got) + } + + var real LNbitsPayment + if err := json.Unmarshal([]byte(`{"details":{"preimage":"6f1a2b"}}`), &real); err != nil { + t.Fatalf("could not unmarshal lnbits response: %v", err) + } + if got := settlementPreimage(real); got != "6f1a2b" { + t.Errorf("settlementPreimage() = %q, want %q", got, "6f1a2b") + } +} + +func TestValidPaymentHash(t *testing.T) { + valid := "0f3a9c1e2b4d5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c" + tests := map[string]bool{ + valid: true, + "": false, + valid[:63]: false, + valid + "0": false, + "../wallet": false, + valid[:60] + "%3Fx": false, + valid[:63] + "g": false, + } + for in, want := range tests { + if got := ValidPaymentHash(in); got != want { + t.Errorf("ValidPaymentHash(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/internal/lnbits/types.go b/internal/lnbits/types.go index 425497fb..3f2de32e 100644 --- a/internal/lnbits/types.go +++ b/internal/lnbits/types.go @@ -179,8 +179,13 @@ func (t *UnixTime) UnmarshalJSON(b []byte) error { } type Payment struct { - CheckingID string `json:"checking_id"` - Pending bool `json:"pending"` + CheckingID string `json:"checking_id"` + Pending bool `json:"pending"` + // Status is reported by lnbits 0.12 and newer ("success"/"pending"/"failed") + // and is absent on older versions, which describe a payment with Pending. + // omitempty keeps it out of the payloads this type is serialised into, so + // adding it does not change any existing API response. + Status string `json:"status,omitempty"` Amount int64 `json:"amount"` Fee int64 `json:"fee"` Memo string `json:"memo"` @@ -195,9 +200,15 @@ type Payment struct { } type LNbitsPayment struct { - Paid bool `json:"paid"` - Preimage string `json:"preimage"` - Details Payment `json:"details,omitempty"` + Paid bool `json:"paid"` + Preimage string `json:"preimage"` + // Status and Fee are populated by lnbits versions that return the payment + // itself rather than wrapping it in Details. omitempty keeps them out of + // the payloads this type is serialised into (/api/v1/paymentstatus and + // /api/v1/invoicestatus), so adding them changes no existing response. + Status string `json:"status,omitempty"` + Fee int64 `json:"fee,omitempty"` + Details Payment `json:"details,omitempty"` } type Payments []Payment diff --git a/internal/storage/hooks.go b/internal/storage/hooks.go index 932eb651..f468e279 100644 --- a/internal/storage/hooks.go +++ b/internal/storage/hooks.go @@ -1,6 +1,36 @@ package storage +import "time" + +// PendingTxExpiry is how long an approval request stays approvable. It lives +// here so the Telegram approve button enforces the same window that +// /api/v1/send/status reports as expired. +const PendingTxExpiry = 24 * time.Hour + // UpdatePendingTxStatusFn is set by the api package at startup to allow the // telegram approval handlers to update PendingTransaction status without // creating an import cycle (api imports telegram, telegram imports storage). var UpdatePendingTxStatusFn func(id, status, actor string) + +// PendingTransaction status values. +// +// These live here rather than in the api package because both api and telegram +// write them and telegram cannot import api. The api package re-exports them +// under its own names for callers that already use those. +const ( + TxStatusPending = "pending" + TxStatusApproved = "approved" + TxStatusRejected = "rejected" + TxStatusExpired = "expired" + TxStatusExecuted = "executed" + TxStatusFailed = "failed" + + // TxStatusInFlight marks an approved payment that lnbits accepted but that + // has not reached a terminal state within the settlement timeout. + // + // It is deliberately distinct from TxStatusPending: a pending transaction + // is reported as expired once its 24h approval window passes, which for an + // in-flight (and possibly settled) payment would tell the caller the sats + // were never sent and invite them to pay a second invoice. + TxStatusInFlight = "in_flight" +) diff --git a/internal/telegram/api_approval.go b/internal/telegram/api_approval.go index 4dee4283..eb009062 100644 --- a/internal/telegram/api_approval.go +++ b/internal/telegram/api_approval.go @@ -4,7 +4,9 @@ import ( "fmt" "regexp" "strconv" + "time" + "github.com/LightningTipBot/LightningTipBot/internal" "github.com/LightningTipBot/LightningTipBot/internal/errors" "github.com/LightningTipBot/LightningTipBot/internal/i18n" "github.com/LightningTipBot/LightningTipBot/internal/lnbits" @@ -14,16 +16,35 @@ import ( "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" "github.com/LightningTipBot/LightningTipBot/internal/utils" + decodepay "github.com/fiatjaf/ln-decodepay" log "github.com/sirupsen/logrus" tb "gopkg.in/lightningtipbot/telebot.v3" ) +// These package-level buttons exist only so the callback handlers can be +// registered by their unique key. The markup sent to a user is always built +// per request (see apiApprovalMarkup) because approval requests originate from +// concurrent HTTP handlers, which are not serialised by the Telegram +// lockInterceptor. var ( apiApprovalConfirmationMenu = &tb.ReplyMarkup{ResizeKeyboard: true} btnCancelAPITx = apiApprovalConfirmationMenu.Data("🚫 Cancel", "cancel_api_tx") btnApproveAPITx = apiApprovalConfirmationMenu.Data("✅ Approve & Send", "approve_api_tx") ) +// apiApprovalMarkup builds a fresh inline keyboard carrying the approval id as +// callback data. A new ReplyMarkup per call keeps concurrent approval requests +// from overwriting each other's callback data before Send serialises it. +func apiApprovalMarkup(approveText, id string) *tb.ReplyMarkup { + menu := &tb.ReplyMarkup{ResizeKeyboard: true} + menu.Inline( + menu.Row( + menu.Data(approveText, "approve_api_tx", id), + menu.Data("🚫 Cancel", "cancel_api_tx", id)), + ) + return menu +} + // isTelegramID checks if the identifier is a Telegram ID (numeric string) func isTelegramID(identifier string) bool { // Check if it's all digits and has reasonable length for Telegram ID @@ -39,10 +60,25 @@ 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"` OriginalRequest interface{} `json:"original_request" gorm:"-"` + // ExpiresAt is the PendingTransaction's expiry time. It is empty on + // approvals created before it was added; see expiresAt. + ExpiresAt time.Time `json:"expires_at,omitempty"` +} + +// expiresAt returns when this approval stops being approvable. Approvals +// saved without ExpiresAt fall back to their creation time plus the standard +// window, so an old approve button cannot stay valid forever. +func (a *APIApprovalData) expiresAt() time.Time { + if !a.ExpiresAt.IsZero() { + return a.ExpiresAt + } + return a.CreatedAt.Add(storage.PendingTxExpiry) } // approveAPITransactionHandler handles the approval of API transactions @@ -66,11 +102,29 @@ func (bot *TipBot) approveAPITransactionHandler(ctx intercept.Context) (intercep log.Errorf("[approveAPITransactionHandler] approval not active anymore") return ctx, errors.Create(errors.NotActiveError) } + // Past its window /api/v1/send/status already reports the request as + // expired, and the caller may have resubmitted it. Paying now would move + // sats the caller was told were never sent. + if time.Now().After(approvalData.expiresAt()) { + log.Warnf("[approveAPITransactionHandler] approval %s for transaction %s expired at %s", approvalData.ID, approvalData.TransactionID, approvalData.expiresAt().Format(time.RFC3339)) + approvalData.Inactivate(approvalData, bot.Bunt) + if storage.UpdatePendingTxStatusFn != nil { + storage.UpdatePendingTxStatusFn(approvalData.TransactionID, storage.TxStatusExpired, ctx.Callback().Sender.Username) + } + bot.tryEditMessage(ctx.Callback().Message, "⌛ This approval request has expired. Nothing was sent.", &tb.ReplyMarkup{}) + return ctx, nil + } defer approvalData.Set(approvalData, bot.Bunt) 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 { @@ -79,15 +133,21 @@ func (bot *TipBot) approveAPITransactionHandler(ctx intercept.Context) (intercep return ctx, err } - // Check sender's balance again - balance, err := bot.GetUserBalance(from) + // Check sender's balance again and reserve it until the transfer is done. + // This uses the *available* balance (wallet minus pots) less whatever the + // sender's other in-flight payments hold, so neither pot funds nor a + // concurrent payment's sats can be spent here. + reservation, balance, err := bot.ReserveBalance(from, approvalData.Amount) 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 ctx, err } + if reservation != nil { + defer reservation.Release() + } - if balance < approvalData.Amount { + if reservation == nil { log.Warnf("[approveAPITransactionHandler] Insufficient balance: %d < %d", balance, approvalData.Amount) bot.tryEditMessage(ctx.Callback().Message, fmt.Sprintf("❌ Insufficient balance: %s available, %s required", utils.FormatSats(balance), utils.FormatSats(approvalData.Amount)), &tb.ReplyMarkup{}) return ctx, errors.Create(errors.UnknownError) @@ -119,7 +179,7 @@ func (bot *TipBot) approveAPITransactionHandler(ctx intercept.Context) (intercep // 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) + storage.UpdatePendingTxStatusFn(approvalData.TransactionID, storage.TxStatusExecuted, ctx.Callback().Sender.Username) } log.Infof("[💸 api_send_approved] Send from %s to %s (%s).", fromUserStr, toUserStr, thirdparty.FormatSatsWithLKR(approvalData.Amount)) @@ -177,15 +237,156 @@ func (bot *TipBot) cancelAPITransactionHandler(ctx intercept.Context) (intercept // Update the PendingTransaction status so /api/v1/send/status reflects the real outcome. if storage.UpdatePendingTxStatusFn != nil { - storage.UpdatePendingTxStatusFn(approvalData.TransactionID, "rejected", c.Sender.Username) + storage.UpdatePendingTxStatusFn(approvalData.TransactionID, storage.TxStatusRejected, c.Sender.Username) } log.Infof("[cancelAPITransactionHandler] API transaction %s cancelled by @%s", approvalData.TransactionID, c.Sender.Username) 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) and reserve it until lnbits has the + // payment. Uses the *available* balance less the sender's other in-flight + // payments, so an approval cannot spend sats reserved in a pot or already + // held by a concurrent payment. + reservation, balance, err := bot.ReserveBalance(from, utils.AmountWithFeeReserve(approvalData.Amount)) + 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 reservation == nil { + 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 + } + defer reservation.Release() + + inv, err := from.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: approvalData.Invoice}, bot.Client) + // lnbits now accounts for the payment in its own balance (or rejected + // it), so free the reservation before the long settlement wait. + reservation.Release() + 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 + } + + // The approval is spent either way: lnbits has accepted the payment, so the + // button must not stay pressable while we wait for it to settle. + approvalData.Inactivate(approvalData, bot.Bunt) + + // lnbits does not always echo the payment hash back. Fall back to the one in + // the invoice itself so the settlement check still has something to follow. + paymentHash := inv.PaymentHash + if paymentHash == "" { + if decoded, derr := decodepay.Decodepay(approvalData.Invoice); derr == nil { + paymentHash = decoded.PaymentHash + } + } + + // lnbits answers as soon as it has handed the payment to its backend, which + // is not the same as the sats reaching the destination node. Follow the + // payment to a terminal state before reporting it as paid. + settlement := bot.Client.WaitForOutgoingPayment(*from.Wallet, paymentHash, internal.APISendSettlementTimeout()) + + switch settlement.State { + case lnbits.PaymentStateFailed: + log.Errorf("[💸 api_send_approved invoice] %s: payment %s did not settle: %v", fromUserStr, paymentHash, settlement.Err) + if bot.ErrorLogger != nil { + bot.ErrorLogger.LogPaymentError(fmt.Errorf("payment did not settle: %s", settlement.State), approvalData.Amount, approvalData.Memo, approvalData.Invoice, from.Telegram) + } + if storage.UpdatePendingTxStatusFn != nil { + storage.UpdatePendingTxStatusFn(approvalData.TransactionID, storage.TxStatusFailed, ctx.Callback().Sender.Username) + } + bot.tryEditMessage(ctx.Callback().Message, fmt.Sprintf("❌ Invoice payment failed — the payment could not be routed and your %s was not sent.", thirdparty.FormatSatsWithLKR(approvalData.Amount)), &tb.ReplyMarkup{}) + return + + case lnbits.PaymentStatePending, lnbits.PaymentStateUnknown: + // The sats may still leave the node, so this is neither a success nor a + // failure. Move the transaction to in_flight rather than leaving it + // pending: a pending transaction is reported as expired once its 24h + // window passes, which would tell the caller a payment that may well + // have settled was never sent. + log.Warnf("[💸 api_send_approved invoice] %s: payment %s unresolved (state %s): %v", fromUserStr, paymentHash, settlement.State, settlement.Err) + if storage.UpdatePendingTxStatusFn != nil { + storage.UpdatePendingTxStatusFn(approvalData.TransactionID, storage.TxStatusInFlight, ctx.Callback().Sender.Username) + } + bot.tryEditMessage(ctx.Callback().Message, fmt.Sprintf("⏳ Invoice payment of %s is still in flight.\n\nIt has not settled yet — check your balance shortly for the final result. Do not retry this invoice.", thirdparty.FormatSatsWithLKR(approvalData.Amount)), &tb.ReplyMarkup{}) + return + } + + // Update the PendingTransaction status so /api/v1/send/status reflects the real outcome. + if storage.UpdatePendingTxStatusFn != nil { + storage.UpdatePendingTxStatusFn(approvalData.TransactionID, storage.TxStatusExecuted, ctx.Callback().Sender.Username) + } + + log.Infof("[💸 api_send_approved invoice] %s paid invoice %s (%s, fee %d sat).", fromUserStr, paymentHash, thirdparty.FormatSatsWithLKR(approvalData.Amount), settlement.Fee) + + 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, expiresAt time.Time) 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, + ExpiresAt: expiresAt, + } + + if err := approvalData.Set(approvalData, bot.Bunt); err != nil { + log.Errorf("[CreateAPIInvoiceApprovalRequest] Failed to save approval data: %v", err) + return err + } + + if _, err := bot.Telegram.Send(fromUser.Telegram, confirmText, apiApprovalMarkup("✅ Approve & Pay", id), tb.ModeMarkdown); err != nil { + log.Errorf("[CreateAPIInvoiceApprovalRequest] Failed to send approval request: %v", err) + // The caller abandons this transaction, so a message that reached the + // user despite the error must not be approvable. + approvalData.Inactivate(approvalData, bot.Bunt) + 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 { +func CreateAPIApprovalRequest(bot *TipBot, fromUser *lnbits.User, toUsername string, amount int64, memo string, transactionID string, clientIP string, expiresAt time.Time) error { // Check if toUsername is actually a user ID and get the actual username actualUsername := toUsername if isTelegramID(toUsername) { @@ -198,7 +399,7 @@ func CreateAPIApprovalRequest(bot *TipBot, fromUser *lnbits.User, toUsername str } } } - + // Create confirmation text (same format as /send command) toUserStrMention := fmt.Sprintf("@%s", actualUsername) confirmText := fmt.Sprintf("Do you want to pay to %s?\n\n💸 Amount: %s", toUserStrMention, thirdparty.FormatSatsWithLKR(amount)) @@ -224,6 +425,7 @@ func CreateAPIApprovalRequest(bot *TipBot, fromUser *lnbits.User, toUsername str Message: confirmText, LanguageCode: fromUser.Telegram.LanguageCode, ClientIP: clientIP, + ExpiresAt: expiresAt, } // Save approval data to database @@ -233,22 +435,13 @@ func CreateAPIApprovalRequest(bot *TipBot, fromUser *lnbits.User, toUsername str return err } - // Create buttons (same pattern as send confirmation) - approveButton := apiApprovalConfirmationMenu.Data("✅ Approve & Send", "approve_api_tx") - cancelButton := apiApprovalConfirmationMenu.Data("🚫 Cancel", "cancel_api_tx") - approveButton.Data = id - cancelButton.Data = id - - apiApprovalConfirmationMenu.Inline( - apiApprovalConfirmationMenu.Row( - approveButton, - cancelButton), - ) - // Send approval request to the sender (from user) - _, err = bot.Telegram.Send(fromUser.Telegram, confirmText, apiApprovalConfirmationMenu, tb.ModeMarkdown) + _, err = bot.Telegram.Send(fromUser.Telegram, confirmText, apiApprovalMarkup("✅ Approve & Send", id), tb.ModeMarkdown) if err != nil { log.Errorf("[CreateAPIApprovalRequest] Failed to send approval request: %v", err) + // The caller abandons this transaction, so a message that reached the + // user despite the error must not be approvable. + approvalData.Inactivate(approvalData, bot.Bunt) return err } diff --git a/internal/telegram/api_approval_test.go b/internal/telegram/api_approval_test.go new file mode 100644 index 00000000..71d3a291 --- /dev/null +++ b/internal/telegram/api_approval_test.go @@ -0,0 +1,30 @@ +package telegram + +import ( + "testing" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/storage" +) + +func TestAPIApprovalExpiresAt(t *testing.T) { + created := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + explicit := created.Add(time.Hour) + + withExpiry := &APIApprovalData{Base: &storage.Base{CreatedAt: created}, ExpiresAt: explicit} + if got := withExpiry.expiresAt(); !got.Equal(explicit) { + t.Errorf("expiresAt() = %s, want the stored ExpiresAt %s", got, explicit) + } + + // Approvals saved before ExpiresAt existed fall back to created + window. + legacy := &APIApprovalData{Base: &storage.Base{CreatedAt: created}} + if got, want := legacy.expiresAt(), created.Add(storage.PendingTxExpiry); !got.Equal(want) { + t.Errorf("legacy expiresAt() = %s, want %s", got, want) + } + + // A record with no timestamps at all must count as expired, not valid forever. + empty := &APIApprovalData{Base: &storage.Base{}} + if !time.Now().After(empty.expiresAt()) { + t.Error("an approval with no timestamps must be treated as expired") + } +} diff --git a/internal/telegram/balance_reservation.go b/internal/telegram/balance_reservation.go new file mode 100644 index 00000000..f500734b --- /dev/null +++ b/internal/telegram/balance_reservation.go @@ -0,0 +1,90 @@ +package telegram + +import ( + "sync" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" +) + +// Payments from one sender may run concurrently, but lnbits only knows the raw +// wallet balance: it cannot see sats reserved in pots, so it would let +// concurrent payments that each passed the available-balance check together +// spend pot funds. Every balance-check-then-pay therefore reserves its sats +// here first, and the next check subtracts what is still reserved. +// +// A reservation only has to cover the gap until lnbits accepts the payment. +// From then on lnbits' own balance already reflects it (a pending outgoing +// payment is deducted immediately), so holding the reservation any longer +// would count the payment twice. + +// senderReservations is the in-flight state for one sender. +type senderReservations struct { + mu sync.Mutex // serialises balance checks for this sender + reserved int64 // sats held by payments lnbits has not accepted yet +} + +var ( + reservationsMu sync.Mutex + reservations = map[int64]*senderReservations{} +) + +func reservationsFor(telegramID int64) *senderReservations { + reservationsMu.Lock() + defer reservationsMu.Unlock() + state, ok := reservations[telegramID] + if !ok { + state = &senderReservations{} + reservations[telegramID] = state + } + return state +} + +// BalanceReservation holds sats for one payment until Release is called. +type BalanceReservation struct { + state *senderReservations + amount int64 + once sync.Once +} + +// Release returns the reserved sats. It is safe to call more than once, so a +// caller can release early and still defer it for the error paths. +func (r *BalanceReservation) Release() { + r.once.Do(func() { + r.state.mu.Lock() + r.state.reserved -= r.amount + r.state.mu.Unlock() + }) +} + +// ReserveBalance reserves need sats of user's available balance (wallet minus +// pots) that no other in-flight payment from user has reserved. +// +// It returns the balance that was available for this payment. The reservation +// is nil when that balance does not cover need; err is set only when the +// balance could not be read. Release the reservation as soon as lnbits has +// accepted or rejected the payment. +func (bot *TipBot) ReserveBalance(user *lnbits.User, need int64) (*BalanceReservation, int64, error) { + return reservationsFor(user.Telegram.ID).reserve(need, func() (int64, error) { + return bot.GetUserAvailableBalance(user) + }) +} + +// reserve is ReserveBalance with the balance lookup injected. +func (state *senderReservations) reserve(need int64, availableBalance func() (int64, error)) (*BalanceReservation, int64, error) { + state.mu.Lock() + defer state.mu.Unlock() + + balance, err := availableBalance() + if err != nil { + return nil, 0, err + } + available := balance - state.reserved + if available < 0 { + available = 0 + } + if available < need { + return nil, available, nil + } + state.reserved += need + return &BalanceReservation{state: state, amount: need}, available, nil +} diff --git a/internal/telegram/balance_reservation_test.go b/internal/telegram/balance_reservation_test.go new file mode 100644 index 00000000..984b3aa9 --- /dev/null +++ b/internal/telegram/balance_reservation_test.go @@ -0,0 +1,72 @@ +package telegram + +import ( + "errors" + "sync" + "testing" +) + +func TestReserveSubtractsInFlightPayments(t *testing.T) { + state := &senderReservations{} + balance := func() (int64, error) { return 1000, nil } + + first, available, err := state.reserve(600, balance) + if err != nil || first == nil || available != 1000 { + t.Fatalf("first reserve = %v, %d, %v; want a reservation with 1000 available", first, available, err) + } + + // The first payment is still in flight, so only 400 is left. + second, available, err := state.reserve(600, balance) + if err != nil || second != nil || available != 400 { + t.Fatalf("second reserve = %v, %d, %v; want no reservation with 400 available", second, available, err) + } + + // Releasing (twice, as early release plus defer does) frees it exactly once. + first.Release() + first.Release() + if state.reserved != 0 { + t.Fatalf("reserved = %d after release, want 0", state.reserved) + } + if third, _, _ := state.reserve(600, balance); third == nil { + t.Fatal("reserve after release should succeed") + } +} + +func TestReserveBalanceError(t *testing.T) { + state := &senderReservations{} + wantErr := errors.New("lnbits down") + res, _, err := state.reserve(1, func() (int64, error) { return 0, wantErr }) + if res != nil || !errors.Is(err, wantErr) { + t.Fatalf("reserve = %v, %v; want nil reservation and the lookup error", res, err) + } + if state.reserved != 0 { + t.Fatalf("reserved = %d after failed lookup, want 0", state.reserved) + } +} + +// Many concurrent payments must never reserve more than the balance. +func TestReserveConcurrent(t *testing.T) { + state := &senderReservations{} + balance := func() (int64, error) { return 1000, nil } + + var ( + wg sync.WaitGroup + mu sync.Mutex + granted int + ) + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if res, _, _ := state.reserve(100, balance); res != nil { + mu.Lock() + granted++ + mu.Unlock() + } + }() + } + wg.Wait() + if granted != 10 { + t.Fatalf("granted %d reservations of 100 from a balance of 1000, want 10", granted) + } +} diff --git a/internal/utils/number_format.go b/internal/utils/number_format.go index 9a8c7b34..c85237f2 100644 --- a/internal/utils/number_format.go +++ b/internal/utils/number_format.go @@ -62,3 +62,10 @@ func FormatSats(amount int64, withPlus ...bool) string { return sign + s + unit } + +// AmountWithFeeReserve returns amount plus a 2% routing fee reserve: the +// smallest balance from which amount can be paid with 2% left over. Integer +// math keeps sat amounts exact. +func AmountWithFeeReserve(amount int64) int64 { + return (amount*100 + 97) / 98 +} diff --git a/internal/utils/number_format_test.go b/internal/utils/number_format_test.go new file mode 100644 index 00000000..a399a83d --- /dev/null +++ b/internal/utils/number_format_test.go @@ -0,0 +1,23 @@ +package utils + +import "testing" + +func TestAmountWithFeeReserve(t *testing.T) { + tests := []struct{ amount, want int64 }{ + {0, 0}, + {98, 100}, // exactly 2% of 100 left over + {99, 102}, // rounds up, never under-reserves + {1, 2}, + {980_000_000, 1_000_000_000}, + {980_000_001, 1_000_000_002}, + } + for _, tt := range tests { + if got := AmountWithFeeReserve(tt.amount); got != tt.want { + t.Errorf("AmountWithFeeReserve(%d) = %d, want %d", tt.amount, got, tt.want) + } + // The reserve must leave at least 2%: amount <= 98% of the result. + if got := AmountWithFeeReserve(tt.amount); tt.amount*100 > got*98 { + t.Errorf("AmountWithFeeReserve(%d) = %d leaves less than 2%%", tt.amount, got) + } + } +} diff --git a/main.go b/main.go index ef32a1ec..5ba14526 100644 --- a/main.go +++ b/main.go @@ -148,6 +148,10 @@ func startApiServer(bot *telegram.TipBot) { // Send status endpoint — check approval status of a pending transaction s.AppendRoute(`/api/v1/send/status/{transaction_id}`, api.WalletHMACMiddleware(apiService.SendStatus), http.MethodGet) log.Infof("API SendStatus endpoint registered at /api/v1/send/status/{transaction_id} with wallet-based HMAC security") + + // Payment settlement endpoint — resolve an invoice payment left in flight + s.AppendRoute(`/api/v1/send/payment/{payment_hash}`, api.WalletHMACMiddleware(apiService.SendPaymentStatus), http.MethodGet) + log.Infof("API SendPaymentStatus endpoint registered at /api/v1/send/payment/{payment_hash} with wallet-based HMAC security") } else { log.Infof("API Send endpoint disabled in configuration") } From f6f479d8960dbf60146aaa24ae6782c07425511b Mon Sep 17 00:00:00 2001 From: Buddhi Thilakshana Date: Sat, 17 Oct 2026 07:47:29 +0530 Subject: [PATCH 2/2] test: cover HMAC middleware and invoice send handlers - HMAC: valid, expired, future, wrong secret, tampered body/path, missing headers, replay rejected, fresh-timestamp retry allowed. - /send: bad JSON, auth, unknown wallet, undecodable and amountless invoices, amount taken from the invoice for min/max checks. - /send/payment: hash validation and auth. --- internal/api/middleware_test.go | 223 ++++++++++++++++++++++++++++++++ internal/api/send_test.go | 145 +++++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 internal/api/middleware_test.go create mode 100644 internal/api/send_test.go diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go new file mode 100644 index 00000000..32b9192f --- /dev/null +++ b/internal/api/middleware_test.go @@ -0,0 +1,223 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal" +) + +const testWalletSecret = "test-secret" + +// withTestWallets swaps in a single whitelisted wallet for the duration of t. +func withTestWallets(t *testing.T) { + t.Helper() + saved := internal.Configuration.API.Send + t.Cleanup(func() { internal.Configuration.API.Send = saved }) + + internal.Configuration.API.Send.TimestampTolerance = 300 + internal.Configuration.API.Send.WhitelistedWallets = map[string]internal.WhitelistedWallet{ + "testwallet": {Username: "alice", HMACSecret: testWalletSecret}, + } +} + +// signedRequest builds a request signed the way API clients sign it. +func signedRequest(method, path, body string, timestamp int64, secret string) *http.Request { + ts := strconv.FormatInt(timestamp, 10) + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("X-Timestamp", ts) + r.Header.Set("X-HMAC-Signature", GenerateHMACSignature(method, path, ts, body, secret)) + return r +} + +// serveHMAC runs r through WalletHMACMiddleware and reports the response and +// the wallet the inner handler saw ("" if it was not reached). +func serveHMAC(r *http.Request) (*httptest.ResponseRecorder, string) { + var wallet string + next := func(w http.ResponseWriter, r *http.Request) { + wallet, _ = r.Context().Value("authenticated_wallet").(string) + if _, ok := RequestStart(r.Context()); !ok { + wallet = "missing request start" + } + w.WriteHeader(http.StatusOK) + } + rec := httptest.NewRecorder() + WalletHMACMiddleware(next)(rec, r) + return rec, wallet +} + +func TestWalletHMACMiddleware(t *testing.T) { + withTestWallets(t) + now := time.Now().Unix() + + tests := []struct { + name string + req func() *http.Request + wantCode int + wantBody string + }{ + { + name: "valid signature", + req: func() *http.Request { return signedRequest("POST", "/api/v1/send", `{"n":1}`, now, testWalletSecret) }, + wantCode: http.StatusOK, + }, + { + name: "timestamp slightly in the future is allowed", + req: func() *http.Request { + return signedRequest("POST", "/api/v1/send", `{"n":2}`, now+30, testWalletSecret) + }, + wantCode: http.StatusOK, + }, + { + name: "timestamp too old", + req: func() *http.Request { + return signedRequest("POST", "/api/v1/send", `{"n":3}`, now-301, testWalletSecret) + }, + wantCode: http.StatusUnauthorized, + wantBody: "Request expired", + }, + { + name: "timestamp too far in the future", + req: func() *http.Request { + return signedRequest("POST", "/api/v1/send", `{"n":4}`, now+301, testWalletSecret) + }, + wantCode: http.StatusUnauthorized, + wantBody: "Request expired", + }, + { + name: "wrong secret", + req: func() *http.Request { return signedRequest("POST", "/api/v1/send", `{"n":5}`, now, "other-secret") }, + wantCode: http.StatusUnauthorized, + wantBody: "Invalid signature", + }, + { + name: "body changed after signing", + req: func() *http.Request { + r := signedRequest("POST", "/api/v1/send", `{"amount":1}`, now, testWalletSecret) + tampered := signedRequest("POST", "/api/v1/send", `{"amount":1000000}`, now, testWalletSecret) + tampered.Header.Set("X-HMAC-Signature", r.Header.Get("X-HMAC-Signature")) + return tampered + }, + wantCode: http.StatusUnauthorized, + wantBody: "Invalid signature", + }, + { + name: "path changed after signing", + req: func() *http.Request { + r := signedRequest("GET", "/api/v1/userbalance", "", now, testWalletSecret) + other := httptest.NewRequest("GET", "/api/v1/usertransactions", nil) + other.Header = r.Header.Clone() + return other + }, + wantCode: http.StatusUnauthorized, + wantBody: "Invalid signature", + }, + { + name: "missing timestamp", + req: func() *http.Request { + r := signedRequest("POST", "/api/v1/send", `{"n":6}`, now, testWalletSecret) + r.Header.Del("X-Timestamp") + return r + }, + wantCode: http.StatusUnauthorized, + wantBody: "Missing timestamp", + }, + { + name: "invalid timestamp", + req: func() *http.Request { + r := signedRequest("POST", "/api/v1/send", `{"n":7}`, now, testWalletSecret) + r.Header.Set("X-Timestamp", "yesterday") + return r + }, + wantCode: http.StatusBadRequest, + wantBody: "Invalid timestamp", + }, + { + name: "missing signature", + req: func() *http.Request { + r := signedRequest("POST", "/api/v1/send", `{"n":8}`, now, testWalletSecret) + r.Header.Del("X-HMAC-Signature") + return r + }, + wantCode: http.StatusUnauthorized, + wantBody: "Missing signature", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec, wallet := serveHMAC(tt.req()) + if rec.Code != tt.wantCode { + t.Fatalf("status = %d, want %d (body %q)", rec.Code, tt.wantCode, rec.Body.String()) + } + if tt.wantCode == http.StatusOK { + if wallet != "testwallet" { + t.Errorf("handler saw wallet %q, want %q", wallet, "testwallet") + } + return + } + if wallet != "" { + t.Errorf("handler was reached for a rejected request") + } + if !strings.Contains(rec.Body.String(), tt.wantBody) { + t.Errorf("body = %q, want it to contain %q", rec.Body.String(), tt.wantBody) + } + }) + } +} + +func TestWalletHMACMiddlewareRejectsReplay(t *testing.T) { + withTestWallets(t) + ts := time.Now().Unix() + body := `{"to":"bob","amount":100,"memo":"replay-test"}` + + first, _ := serveHMAC(signedRequest("POST", "/api/v1/send", body, ts, testWalletSecret)) + if first.Code != http.StatusOK { + t.Fatalf("first request: status = %d, want 200 (body %q)", first.Code, first.Body.String()) + } + + // The exact same signed request again, as an attacker or a naive retry + // would send it. + replay, wallet := serveHMAC(signedRequest("POST", "/api/v1/send", body, ts, testWalletSecret)) + if replay.Code != http.StatusUnauthorized { + t.Fatalf("replay: status = %d, want 401", replay.Code) + } + if wallet != "" { + t.Errorf("handler was reached for a replayed request") + } + if !strings.Contains(replay.Body.String(), "Request already processed") { + t.Errorf("replay body = %q", replay.Body.String()) + } + + // A retry signed with a fresh timestamp is a new request and goes through. + retry, _ := serveHMAC(signedRequest("POST", "/api/v1/send", body, ts+1, testWalletSecret)) + if retry.Code != http.StatusOK { + t.Errorf("fresh retry: status = %d, want 200 (body %q)", retry.Code, retry.Body.String()) + } +} + +func TestSignatureSetClaim(t *testing.T) { + s := &signatureSet{seen: make(map[string]time.Time)} + + if !s.claim("a", time.Minute) { + t.Fatal("first claim of a = false, want true") + } + if s.claim("a", time.Minute) { + t.Fatal("second claim of a = true, want false") + } + if !s.claim("b", time.Minute) { + t.Fatal("claim of b = false, want true") + } + + // An expired entry is forgotten, so the set cannot grow without bound. + if !s.claim("old", -time.Second) { + t.Fatal("claim of old = false, want true") + } + if !s.claim("old", time.Minute) { + t.Error("claim of expired old = false, want true") + } +} diff --git a/internal/api/send_test.go b/internal/api/send_test.go new file mode 100644 index 00000000..7551dd02 --- /dev/null +++ b/internal/api/send_test.go @@ -0,0 +1,145 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/gorilla/mux" +) + +// Invoices from the BOLT11 spec examples. Their signatures are valid, so they +// decode, but they are long expired and can never be paid. +const ( + // 2500u = 250,000 sats. + testInvoice250k = "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp" + // No amount. + testInvoiceAmountless = "lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq8rkx3yf5tcsyz3d73gafnh3cax9rn449d9p5uxz9ezhhypd0elx87sjle52x86fux2ypatgddc6k63n7erqz25le42c4u4ecky03ylcqca784w" +) + +// authenticated returns r as WalletHMACMiddleware would pass it on. +func authenticated(r *http.Request, wallet string) *http.Request { + return r.WithContext(context.WithValue(r.Context(), "authenticated_wallet", wallet)) +} + +// These cases are all rejected before the handler touches the bot, lnbits or +// the memo cache, so a zero Service is enough. +func TestSendRejectsBadRequests(t *testing.T) { + withTestWallets(t) + internal.Configuration.API.Send.MinAmount = 1 + internal.Configuration.API.Send.MaxAmount = 1000 + + tests := []struct { + name string + body string + wallet string + wantBody string + }{ + {name: "invalid json", body: `{`, wallet: "testwallet", wantBody: "Invalid JSON request"}, + {name: "not authenticated", body: `{"to":"bob","amount":10}`, wantBody: "Authentication failed"}, + {name: "unknown wallet", body: `{"to":"bob","amount":10}`, wallet: "ghost", wantBody: "Invalid wallet configuration"}, + {name: "missing to", body: `{"amount":10}`, wallet: "testwallet", wantBody: "Missing 'to' field"}, + { + name: "invoice that does not decode", + body: `{"to":"lnbc1notarealinvoice"}`, + wallet: "testwallet", + wantBody: "Invalid Lightning invoice", + }, + { + name: "amountless invoice", + body: `{"to":"` + testInvoiceAmountless + `"}`, + wallet: "testwallet", + wantBody: "amountless invoices are not supported", + }, + { + // The request amount is ignored: the invoice asks for 250k sats, + // which is over the 1000 sat limit. + name: "invoice over max amount", + body: `{"to":"` + testInvoice250k + `","amount":1}`, + wallet: "testwallet", + wantBody: "Amount cannot exceed", + }, + { + name: "invoice with lightning: prefix and upper case", + body: `{"to":"LIGHTNING:` + strings.ToUpper(testInvoice250k) + `"}`, + wallet: "testwallet", + wantBody: "Amount cannot exceed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("POST", "/api/v1/send", strings.NewReader(tt.body)) + if tt.wallet != "" { + r = authenticated(r, tt.wallet) + } + rec := httptest.NewRecorder() + Service{}.Send(rec, r) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } + if !strings.Contains(rec.Body.String(), tt.wantBody) { + t.Errorf("body = %q, want it to contain %q", rec.Body.String(), tt.wantBody) + } + }) + } +} + +func TestSendRejectsInvoiceUnderMinAmount(t *testing.T) { + withTestWallets(t) + internal.Configuration.API.Send.MinAmount = 300_000 + internal.Configuration.API.Send.MaxAmount = 1_000_000 + + r := authenticated(httptest.NewRequest("POST", "/api/v1/send", + strings.NewReader(`{"to":"`+testInvoice250k+`","amount":500000}`)), "testwallet") + rec := httptest.NewRecorder() + Service{}.Send(rec, r) + + if !strings.Contains(rec.Body.String(), "Amount must be greater than") { + t.Errorf("body = %q, want a min amount error", rec.Body.String()) + } +} + +func TestSendPaymentStatusRejectsBadRequests(t *testing.T) { + withTestWallets(t) + validHash := strings.Repeat("ab", 32) + + tests := []struct { + name string + hash string + wallet string + wantBody string + }{ + {name: "empty hash", hash: "", wallet: "testwallet", wantBody: "Invalid payment_hash"}, + {name: "short hash", hash: "abc123", wallet: "testwallet", wantBody: "Invalid payment_hash"}, + {name: "non hex hash", hash: strings.Repeat("zz", 32), wallet: "testwallet", wantBody: "Invalid payment_hash"}, + {name: "path injection", hash: strings.Repeat("a", 60) + "/../", wallet: "testwallet", wantBody: "Invalid payment_hash"}, + // A valid hash passes validation (upper case is normalised) and the + // request then fails on authentication. + {name: "not authenticated", hash: strings.ToUpper(validHash), wantBody: "Authentication failed"}, + {name: "unknown wallet", hash: validHash, wallet: "ghost", wantBody: "Invalid wallet configuration"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/v1/send/payment/x", nil) + r = mux.SetURLVars(r, map[string]string{"payment_hash": tt.hash}) + if tt.wallet != "" { + r = authenticated(r, tt.wallet) + } + rec := httptest.NewRecorder() + Service{}.SendPaymentStatus(rec, r) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } + if !strings.Contains(rec.Body.String(), tt.wantBody) { + t.Errorf("body = %q, want it to contain %q", rec.Body.String(), tt.wantBody) + } + }) + } +}