Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
489 changes: 489 additions & 0 deletions docs/invoice-payments-api.md

Large diffs are not rendered by default.

44 changes: 37 additions & 7 deletions docs/referral-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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. `[email protected]`)
2. Telegram ID (5–15 digit number)
3. Telegram username
1. bolt11 Lightning invoice (`lnbc...` / `lightning:lnbc...`)
2. Lightning address (e.g. `[email protected]`)
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

Expand Down Expand Up @@ -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
Expand Down
7 changes: 1 addition & 6 deletions internal/api/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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"`
Expand Down Expand Up @@ -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
}

Expand Down
68 changes: 65 additions & 3 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"net/http/httputil"
"strconv"
"strings"
"sync"
"time"

"github.com/LightningTipBot/LightningTipBot/internal"
Expand Down Expand Up @@ -133,10 +134,56 @@ 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.
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 == "" {
Expand All @@ -160,8 +207,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
}
Expand Down Expand Up @@ -205,8 +254,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)
Expand Down
Loading
Loading