Skip to content

PraneethGunas/x402

Repository files navigation

x402

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.

Architecture

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)

How It Works

  1. The Go API fetches RSS feeds from Bitcoin Optech and Lightning Engineering on startup, then refreshes every 30 minutes.
  2. Feed listings (/feeds, /feeds/:id/articles) are free. Full article content (/articles/:id) is gated by Aperture.
  3. When a reader requests a gated article, Aperture responds with 402 Payment Required and a WWW-Authenticate header containing a macaroon and a Lightning invoice.
  4. 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.
  5. Aperture verifies the payment proof and proxies the request to the Go API, which returns the full article.
  6. The L402 token is cached in localStorage so paid articles persist across sessions.

Security Model

No Custody

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 Transport Security

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.

Watch-Only + Remote Signer

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 Token Security

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.

Two-Node Separation

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.

What's NOT in Scope

  • 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., bluemonday in Go).
  • Testnet only — all Lightning infrastructure runs on Bitcoin testnet. Do not use with real funds without auditing the full stack.

Project Structure

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

Prerequisites

  • Go 1.24+ (required to build Aperture; 1.23+ sufficient for the API alone)
  • Node.js 20+
  • Docker and Docker Compose

Setup

1. Clone with submodules

git clone --recurse-submodules https://github.com/PraneethGunas/x402.git
cd x402

If already cloned without submodules:

git submodule update --init --recursive

2. Build Aperture from source

Aperture 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)/bin

Add the line above to ~/.zshrc or ~/.bashrc to make it permanent.

3. Set up Lightning infrastructure

Run the automated setup script to start litd (watch-only) + remote signer on testnet with Neutrino (no full Bitcoin node required):

bash setup.sh

This 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 -d

Wait for both nodes to sync with testnet (check with docker logs litd and docker logs provider-lnd).

4. Configure Aperture

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.yaml

The 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.

5. Open a channel between nodes

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:9735

Then 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 20000

After 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>

6. Start the application

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 dev

Open http://localhost:3000.

7. Connect a wallet

Generate an LNC pairing phrase from your litd node:

docker exec litd litcli --network=testnet sessions add --type admin --label x402

In the browser:

  1. Click Connect Wallet in the header
  2. Enter the pairing phrase and choose a password
  3. After connecting, click any article to pay and read

Key Technical Decisions

  • Custom LNC credential store — Fixes an upstream bug in @lightninglabs/lnc-web where clear(memoryOnly=true) wipes the encryption password, causing credentials to persist as empty strings. Our store ignores clear(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 the WWW-Authenticate header was inaccessible cross-origin in some browsers despite Access-Control-Expose-Headers being 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.

Troubleshooting

aperture: command not found

The Go binary path is not in your shell's PATH. Add it:

export PATH=$PATH:$(go env GOPATH)/bin

To make it permanent, add the line to your ~/.zshrc or ~/.bashrc.

GOPATH not set

If go env GOPATH returns empty, Go defaults to ~/go. You can set it explicitly:

export GOPATH=$HOME/go
export PATH=$PATH:$GOPATH/bin

LNC connection timeout / "stream not found"

On 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.

402 response but payment doesn't trigger

If the article endpoint returns 402 but nothing happens in the browser:

  1. Check the browser console for [L402] logs.
  2. WWW-Authenticate header is null — This happens with cross-origin requests when the response header casing doesn't match Access-Control-Expose-Headers. The fix is the Next.js rewrite proxy (requests go through localhost:3000/api/* so they're same-origin).
  3. EOF on SendPaymentV2 — 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 crashes when Go API restarts

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.

Channel has 0 local balance

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>

Docker containers on different networks

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-lnd

Then peer:

docker exec litd lncli --network=testnet connect <provider-pubkey>@<provider-ip>:9735

reserved wallet balance invalidated when opening channel

This 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).

License

MIT

About

Pay-per-article feed reader powered by L402 and the Lightning Network

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors