Copyright (c) PCEF 2025
NLx402 Facilitator API — Licensed under AGPL-3.0
This Flask app implements a Solana token paywall using an “HTTP 402 Payment Required” flow (“x402 / NLx402” style), plus a simple wallet-based auth flow (Phantom-style nonce + signature) to issue API keys and select a mint.
pip install -r requirements.txt && bash wsgi.shProvides endpoints to:
- Issue a short-lived nonce to a wallet pubkey
- Verify an Ed25519 signature over a message that includes that nonce
- Maintain a short-lived in-memory session (
phantom_sessions) - Allow the wallet to fetch its token list and balances
- Issue a server API key bound to the wallet + a selected token mint
Provides endpoints for:
- Health checks
- Supported token mints and service metadata
- Simple transaction count
Implements:
- A paywall endpoint (
/protected) that returns 402 with payment instructions when no payment is provided. - A separate verification endpoint (
/verify) that confirms the client didn’t tamper with the server-issued payment “challenge” data.
High level flow:
- Client calls
GET /protected→ server returns402+ a payment “invoice” (recipient ATA, amount, nonce, expires_at, mint, etc.) - Client pays on-chain and sends
X-PAYMENTheader toGET /protected - Server checks:
- payment was previously verified (anti-tamper)
- nonce not expired
- tx not already used
- tx actually paid correct amount to correct place
- Server returns
200with an x402 receipt payload.
This server keeps several security-critical pieces in memory:
-
phantom_nonces:{ pubkey -> { nonce, timestamp } }
Used during signature challenge. -
phantom_sessions:{ pubkey -> { nonce, created_timestamp, signature, expiration_timestamp } }
Created after successful signature verification. Required to call some auth endpoints. -
pending_nonces:{ nonce -> { amount, expires_at, paid, lock_time, tx? } }
Created when/protectedreturns a 402 invoice. -
nonces_security_hashes:{ nonce -> { security_hash, locked_at, original_data_string, is_verified } }
Used to detect tampering of the payment “invoice” details.
Because these are in-memory:
- restarting the server invalidates outstanding sessions / nonces
- this won’t scale across multiple instances without shared storage (Redis, DB, etc.)
The app uses flask_limiter with:
- default limit:
30/second - several endpoints limited to
5/minute
The limiter key is:
x-api-keyheader (or query param), else- the client IP (
get_remote_address())
This means authenticated callers are rate-limited per API key; unauthenticated callers per IP.
Purpose: Validate an API key and return associated account details.
Requires:
- Header:
x-api-key
Behavior:
- Looks up the user by API key via
mongo.find_user(api_key=...) - Returns wallet id (
username), selected mint, created time.
Purpose: Create a short-lived nonce for wallet signature auth.
Body JSON:
pubkey(base58-encoded Solana pubkey)
Behavior:
- Generates
nonce = secrets.token_urlsafe(18) - Stores in
phantom_nonces[pubkey] = { nonce, timestamp } - Returns
{ "nonce": "..." }
Security note: Nonce TTL is phantom_nonce_ttl = 600 seconds.
Purpose: Verify Ed25519 signature for Phantom-style login.
Body JSON:
pubkey(base58)message(string that must include the nonce)signature(base64 encoded signature)
Behavior:
- Loads stored nonce for that pubkey
- Enforces TTL
- Ensures
nonceappears inmessage - Decodes signature from base64 → bytes
- Verifies signature using
nacl.signing.VerifyKey(pubkey is base58-decoded) - On success:
- deletes the nonce
- creates
phantom_sessions[pubkey]with expiration timestamp - returns
{ ok: true, wallet: pubkey }
Purpose: Gate a feature based on holding enough THRT.
Body JSON:
wallet_id(must exist inphantom_sessions)
Behavior:
- Checks session exists
- Calls
settings.find_specific_token(wallet_id, rpc_url, thrt_ca) - If token held:
- fetches token price
- computes required amount for
usd_amount_needed = 500 - compares to amount held
- Returns
{ ok: true }or{ ok: false, error: ... }
Purpose: List tokens in wallet for a verified wallet session (and prevent duplicates).
Body JSON:
wallet_id
Behavior:
- Rejects if user already exists in DB (
mongo.find_user(wallet_id=...)) - Requires a valid, unexpired
phantom_sessions[wallet_id] - Calls
settings.get_tokens_in_wallet(wallet_id, rpc_url) - Returns
{ ok: true, tokens: [...] }
Purpose: Mint an API key for the wallet and store in Mongo.
Body JSON:
wallet_idselected_mint
Behavior:
- Rejects if wallet already exists in DB
- Requires valid, unexpired phantom session
- Generates api key:
settings.generate_api_key(wallet_id) - Stores user:
mongo.insert_new_user(wallet_id, api_key, selected_mint) - Returns
{ ok: true, apikey: ... }
Returns total count of stored tx records.
Simple health response { ok: true, status: "healthy" }
Returns supported mints from mongo.get_tokens() plus env metadata:
- version
- network
- supported_chains
Purpose: Verify that the client’s payment_data matches the server-issued “invoice” payload for a given nonce (anti-tamper).
Form fields:
payment_data(string or JSON-ish payload passed as a form value)nonce
Headers:
x-api-key(must be valid in Mongo)
Behavior:
- Confirms API key is valid
- Confirms nonce exists in
nonces_security_hashes - Recomputes:
new_hash = settings.generate_nlx402_security_hash(payment_data) - Compares to stored
security_hash - If matches:
- sets
nonces_security_hashes[nonce]['is_verified'] = True - returns
{ ok: true }
- sets
- If mismatch:
- returns 403 “Tampering detected”
What this protects: a client cannot change recipient/mint/amount/expiry and then present a tx for the altered terms.
Purpose: The core paywall endpoint.
- If unpaid: returns
402with payment instructions. - If paid and valid: returns
200with x402 receipt. - If payment invalid/expired: returns
402or401depending on failure.
Headers:
x-api-key(required; maps to wallet + selected mint)x-total-price(optional; default"0.5"; must parse to float > 0)x-payment(optional; when present, tries to validate payment)
- Loads user via API key → gets:
wallet_id(stored asusername)mint(selected mint)
- Computes recipient ATA via
await x402.get_merch(wallet_id, mint) - Generates:
nonce = uuid4- token price via
x402.get_token_price(mint) - required
amount = calculate_amount_of_tokens(price, total_price) - expiry:
lock_time + 200
- Stores:
pending_nonces[nonce] = { amount, expires_at, ... }nonces_security_hashes[nonce] = { security_hash, is_verified: False, ... }
- Returns HTTP 402 with invoice payload:
- version, chain, network, mint, recipient, decimals, nonce, expires_at, amount
Expects x-payment to be JSON, containing:
tx(transaction signature/hash)nonce(the nonce previously issued)
Validation checks:
- nonce exists in
nonces_security_hashes is_verified == True(client must have called/verifysuccessfully)- nonce exists in
pending_nonces - nonce not expired (else it deletes nonce entries)
- tx not already used (
mongo.get_tx(sig)) - chain validation:
paid = await x402.watch_transaction(sig, amount, wallet_id, mint)
- if paid:
- marks nonce paid
- inserts tx record:
mongo.insert_tx(sig, wallet_id, amount) - returns 200 with an x402 receipt
If not paid, it returns a 402 with an x402-shaped payload (note: this code currently returns "status": "paid" even on the failure branch; that’s likely a bug in the else block).
- Request protected resource (no payment yet):
GET /protectedwithx-api-key- server returns
402+ invoice JSON
- Client verifies invoice integrity:
POST /verifywithnonce+payment_dataexactly as issued- server flips
is_verified = True
- Client pays on-chain and retries:
GET /protectedwith:x-api-keyx-payment: {"tx":"<sig>","nonce":"<nonce>"}
- Server returns 200 + receipt payload on success.
AGPL-3.0 — if you deploy and modify this as a network service, you must make the source available under AGPL terms.