A pay-per-article feed reader powered by the Lightning Network and the L402 protocol.
Articles from real RSS feeds are gated behind HTTP 402 Payment Required. Readers connect their own Lightning node from the browser via Lightning Node Connect, pay a few sats per article, and read instantly. No accounts, no subscriptions, no personal data.
Browser (Next.js + LNC WASM)
│
├── /api/feeds ← free (whitelisted)
├── /api/articles/:id ← 402 Payment Required
│
▼
Aperture (L402 reverse proxy)
│
▼
Go Content API (RSS/Atom → articles)
│
▼
Provider LND (creates invoices)
User's Lightning Node ◄──── LNC (encrypted WebSocket via mailbox relay) ────► Browser
| Layer | Tech |
|---|---|
| Frontend | Next.js, MobX (root store pattern), Emotion |
| Backend | Go stdlib HTTP server, RSS/Atom parser (zero external dependencies) |
| L402 Gateway | Aperture reverse proxy |
| Lightning | litd (watch-only) + remote signer, LNC for browser-to-node |
| Network | Bitcoin testnet, Neutrino light client (BIP 157/158) |
- The Go API fetches RSS feeds from Bitcoin Optech and Lightning Engineering on startup, then refreshes every 30 minutes.
- Feed listings (
/feeds,/feeds/:id/articles) are free. Full article content (/articles/:id) is gated by Aperture. - When a reader requests a gated article, Aperture responds with
402 Payment Requiredand aWWW-Authenticateheader containing a macaroon and a Lightning invoice. - The browser's L402 client pays the invoice via the reader's own node (connected through LNC), then retries the request with the
Authorization: L402 <macaroon>:<preimage>header. - Aperture verifies the payment proof and proxies the request to the Go API, which returns the full article.
- The L402 token is cached in localStorage so paid articles persist across sessions.
x402 never holds or touches user funds. The reader's browser connects directly to their own Lightning node via LNC. Payments are initiated client-side — the server only verifies proof of payment (the preimage), never the payment itself.
LNC uses the Noise Protocol Framework (Noise_XX with Curve25519, ChaCha20-Poly1305, SHA-256) for end-to-end encryption between the browser and the Lightning node. The mailbox relay at mailbox.terminal.lightning.today routes encrypted messages but cannot read them.
Initial pairing uses SPAKE2 (Password-Authenticated Key Exchange) derived from the 10-word pairing phrase. After first connection, ephemeral ECDH keypairs are used for subsequent sessions — no long-term secrets remain in the browser.
The litd node runs in watch-only mode — it has no private keys. All signing operations are forwarded to a separate lnd-signer container via gRPC. If the watch-only node is compromised, no funds can be moved without the signer.
L402 tokens consist of a macaroon (authorization credential) and a preimage (proof of payment). Macaroons are signed by Aperture's root key and include caveats that scope access. Tokens are stored in the browser's localStorage — they prove you paid but cannot be used to make further payments.
The provider node (creates invoices) and the user's node (pays invoices) are separate. This ensures payments flow through real Lightning channels with actual routing, not self-payments on the same node.
- No server-side authentication — there are no user accounts. Access is granted per-request via L402 tokens.
- No content sanitization — RSS feed content is rendered as HTML via
dangerouslySetInnerHTML. Feed sources are hardcoded and trusted. If adding untrusted feeds, add server-side sanitization (e.g.,bluemondayin Go). - Testnet only — all Lightning infrastructure runs on Bitcoin testnet. Do not use with real funds without auditing the full stack.
x402/
├── packages/
│ ├── api/ Go content API (zero external deps)
│ │ ├── main.go HTTP server
│ │ └── store/
│ │ ├── store.go In-memory article store
│ │ ├── fetcher.go RSS/Atom feed parser
│ │ └── seed.go Fallback seed data
│ └── web/ Next.js frontend
│ └── src/
│ ├── api/ LNC wrapper, L402 client, credential store
│ ├── app/ Next.js app router
│ ├── components/ UI components (Emotion styled)
│ └── store/ MobX stores, views, and models
├── aperture/ Git submodule — L402 reverse proxy (built from source)
├── lightning-agent-tools/ Git submodule — litd/signer setup scripts
├── docker-compose.provider.yml
├── provider-lnd.conf
├── aperture.yaml.example Aperture config template
└── setup.sh Automated Lightning infrastructure setup
- Go 1.24+ (required to build Aperture; 1.23+ sufficient for the API alone)
- Node.js 20+
- Docker and Docker Compose
git clone --recurse-submodules https://github.com/PraneethGunas/x402.git
cd x402If already cloned without submodules:
git submodule update --init --recursiveAperture does not publish pre-built binaries — it must be compiled from the submodule:
cd aperture/cmd/aperture
go install
cd ../../..This places the binary in $(go env GOPATH)/bin/. Ensure this is in your PATH:
export PATH=$PATH:$(go env GOPATH)/binAdd the line above to ~/.zshrc or ~/.bashrc to make it permanent.
Run the automated setup script to start litd (watch-only) + remote signer on testnet with Neutrino (no full Bitcoin node required):
bash setup.shThis will:
- Pull Docker images for litd and lnd-signer
- Start both containers on a shared Docker network
- Initialize the signer wallet
- Create the watch-only wallet on litd
- Store credentials in
~/.lnget/(never committed)
Then start the provider node (creates invoices for Aperture):
docker compose -f docker-compose.provider.yml up -dWait for both nodes to sync with testnet (check with docker logs litd and docker logs provider-lnd).
Copy the example config and extract provider node credentials:
mkdir -p ~/.aperture/provider-creds
# Copy TLS cert and macaroon from provider container
docker cp provider-lnd:/root/.lnd/tls.cert ~/.aperture/provider-creds/tls.cert
docker cp provider-lnd:/root/.lnd/data/chain/bitcoin/testnet/invoice.macaroon ~/.aperture/provider-creds/invoice.macaroon
# Copy config template
cp aperture.yaml.example ~/.aperture/aperture.yamlThe example config points Aperture at the provider node on port 11009 and proxies to the Go API on port 8090, with /feeds* whitelisted (free) and everything else gated at 50 sats.
The provider and user nodes need a payment channel. First, peer them:
# Get provider pubkey
PROVIDER_PK=$(docker exec provider-lnd lncli --network=testnet getinfo | grep identity_pubkey | cut -d'"' -f4)
# Connect litd's Docker network to provider
docker network connect $(docker network ls -q --filter name=litd) provider-lnd
# Get provider IP on shared network
PROVIDER_IP=$(docker inspect provider-lnd --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' | head -1)
# Peer
docker exec litd lncli --network=testnet connect $PROVIDER_PK@$PROVIDER_IP:9735Then fund and open a channel (requires testnet coins — use a testnet faucet or send from an existing testnet wallet):
# Get a deposit address, fund it, then open a channel
docker exec provider-lnd lncli --network=testnet newaddress p2wkh
# ... send testnet coins to the address ...
docker exec provider-lnd lncli --network=testnet openchannel --node_key <litd-pubkey> --local_amt 20000After the channel confirms (~10 min on testnet), push sats to the user's side so they can pay for articles:
docker exec litd lncli --network=testnet addinvoice --amt 10000
docker exec provider-lnd lncli --network=testnet payinvoice --force <invoice>Start all three processes (in separate terminals or use &):
# Terminal 1: Go API (fetches RSS feeds on startup)
cd packages/api && go run . -port 8090
# Terminal 2: Aperture (L402 reverse proxy)
aperture --configfile=~/.aperture/aperture.yaml
# Terminal 3: Frontend
cd packages/web && npm install && npm run devOpen http://localhost:3000.
Generate an LNC pairing phrase from your litd node:
docker exec litd litcli --network=testnet sessions add --type admin --label x402In the browser:
- Click Connect Wallet in the header
- Enter the pairing phrase and choose a password
- After connecting, click any article to pay and read
- Custom LNC credential store — Fixes an upstream bug in
@lightninglabs/lnc-webwhereclear(memoryOnly=true)wipes the encryption password, causing credentials to persist as empty strings. Our store ignoresclear(memoryOnly=true)entirely. - Budget is optional — Users can pay for articles without setting a spending limit. The budget system is opt-in guardrails, not a gate.
- L402 tokens persist — Paid article tokens are cached in localStorage with a 200-entry cap and automatic eviction of the oldest entries.
- No external dependencies in the Go API — RSS/Atom parsing, HTML stripping, and URL rewriting all use the standard library.
- Two-node payment architecture — A separate provider node creates invoices for Aperture so payments flow through real Lightning channels, not self-payments.
- Next.js rewrite proxy — The frontend proxies
/api/*requests to Aperture through Next.js rewrites. This avoids CORS issues where theWWW-Authenticateheader was inaccessible cross-origin in some browsers despiteAccess-Control-Expose-Headersbeing set. - Aperture built from source — Aperture does not publish pre-built binaries. The repo includes it as a git submodule so the exact version is pinned and reproducible.
The Go binary path is not in your shell's PATH. Add it:
export PATH=$PATH:$(go env GOPATH)/binTo make it permanent, add the line to your ~/.zshrc or ~/.bashrc.
If go env GOPATH returns empty, Go defaults to ~/go. You can set it explicitly:
export GOPATH=$HOME/go
export PATH=$PATH:$GOPATH/binOn first connection: The pairing phrase has a 10-minute window from creation. If it expires, generate a new one with litcli sessions add.
On reconnect after page refresh: This was caused by an upstream bug in lnc-web where the built-in credential store's clear(memoryOnly=true) wipes the encryption password after key exchange. Subsequent writes to localStorage save empty strings. On reconnect, empty keys mean the wrong stream ID is derived, and the mailbox relay returns "stream not found." The custom X402CredentialStore fixes this by ignoring clear(memoryOnly=true) entirely.
If the article endpoint returns 402 but nothing happens in the browser:
- Check the browser console for
[L402]logs. WWW-Authenticateheader is null — This happens with cross-origin requests when the response header casing doesn't matchAccess-Control-Expose-Headers. The fix is the Next.js rewrite proxy (requests go throughlocalhost:3000/api/*so they're same-origin).EOFonSendPaymentV2— No route exists between the user's node and the provider node. Check that a channel is open with sufficient balance on the user's side:docker exec litd lncli --network=testnet listchannels --active_only
Aperture maintains a persistent connection to its backend. If the Go API process restarts (e.g., during development), Aperture may lose the upstream and crash. Restart Aperture after restarting the API.
If the provider opened the channel, all balance is on the provider's side. The user can't pay. Push sats to the user's side:
# Create invoice on user node
docker exec litd lncli --network=testnet addinvoice --amt 10000
# Pay from provider
docker exec provider-lnd lncli --network=testnet payinvoice --force <invoice>If litd and provider-lnd can't peer with each other, they may be on separate Docker networks. Connect them:
docker network connect <litd-network-name> provider-lndThen peer:
docker exec litd lncli --network=testnet connect <provider-pubkey>@<provider-ip>:9735This happens when the channel amount is too large relative to the wallet balance — LND reserves funds for anchor channel fee bumping. Use a smaller --local_amt (e.g., 20000 instead of 30000).
MIT