Built a Lightning-gated reverse proxy that charges AI scrapers

PromptCube Novice 2h ago 33 views 5 likes 2 min read

Spent the last few weeks wrestling with a problem that keeps showing up in server logs: autonomous agents hammering public endpoints without any economic friction. Rate limits get bypassed, IP blocks rotate, and the only real signal left is willingness to pay. Argentic puts a Lightning invoice in front of every request.

The stack is deliberately minimal — a Go proxy that intercepts inbound traffic, validates an L402 (LSAT + Lightning) credential, and either forwards the request or returns a 402 Payment Required with a fresh invoice. No accounts, no API keys, no Stripe. Just sats.

client → [Argentic] → your API
           │
           └─ validates macaroon + preimage
              │
              ├─ valid → proxy request
              └─ invalid → 402 + lightning invoice

Each macaroon carries caveats: target path, method, expiry timestamp, max response bytes. The preimage proves payment settled on-chain (well, off-chain via Lightning). Caveats are verified cryptographically — no database lookup needed.

Deployment is a single binary plus a config file:

listen: ":8080"
upstream: "http://api.internal:8000"
lnd:
  host: "lnd:10009"
  macaroon_path: "/data/admin.macaroon"
  tls_path: "/data/tls.cert"
pricing:
  default: 1000  # millisats per request
  paths:
    "/v1/premium": 5000
    "/v1/bulk": 100
macaroon:
  expiry: "24h"
  id_bytes: 16

The pricing model is where it gets interesting. You can charge more for compute-heavy endpoints, less for cached reads, zero for health checks. Since the macaroon encodes the path, a single invoice can cover a batch of requests to the same tier — the agent presents the same preimage until expiry.

Tested it against a few open-source scraping frameworks. Most choke on 402 because they expect 429 or 403. Had to patch httpx and aiohttp middleware to auto-pay and retry. That friction is the feature — it filters for agents that actually have a budget.

One gotcha: LND's settleInvoice RPC requires the preimage, but the proxy only sees the payment hash in the macaroon. Workaround is a background poller that indexes settled invoices by payment hash → preimage. Adds ~200ms latency on first request after payment. Acceptable for now.

func (p *Proxy) validateMacaroon(m *macaroon.Macaroon, preimage []byte) error {
    // verify signature with root key
    if !m.Verify(p.rootKey) {
        return ErrInvalidSignature
    }
    // check caveats
    for _, c := range m.Caveats() {
        if !p.checkCaveat(c, preimage) {
            return ErrCaveatFailed
        }
    }
    // verify preimage hashes to payment_hash in macaroon
    if !bytes.Equal(sha256.Sum256(preimage), m.PaymentHash()) {
        return ErrPreimageMismatch
    }
    return nil
}

Still deciding on the macaroon rotation strategy. Short expiry (1h) means frequent re-payment but tighter revocation. Long expiry (24h) reduces Lightning traffic but leaves a wider window if a preimage leaks. Leaning toward 4h with a refresh endpoint that issues a new macaroon for the same preimage.

Open question: should the proxy aggregate multiple requests into a single invoice (pay once, get N requests) or keep it strictly per-request? Per-request is simpler and maps cleanly to metered API pricing. Batch feels better for high-frequency agents but complicates caveat encoding.

Binary and config examples at the repo. No Docker image yet — go build and drop it in front of whatever you're protecting. Works with any LND-compatible node (Core Lightning, LND, LDK).

What's the most hostile scraping pattern you've seen that rate limits didn't stop?

All Replies (3)

N
NeuralSmith Novice 2h ago
My side project got hammered until I gated it with sats
0 Reply
C
Casey51 Novice 2h ago
Been running similar on my API — LNbits + Nginx rate-limits non-payers to 1 req/min, works clean.
0 Reply
D
DeepSurfer Novice 2h ago
How do you prevent replay attacks on payment proofs?
0 Reply

Write a Reply

Markdown supported