Skip to content

Perkins-Fund/NLx402

Repository files navigation

NLx402 Facilitator API

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.


Install & Run

pip install -r requirements.txt && bash wsgi.sh

What this service does

1) Wallet auth (Phantom-style)

Provides 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

2) Public metadata / health

Provides endpoints for:

  • Health checks
  • Supported token mints and service metadata
  • Simple transaction count

3) NLx402 paywall flow (402 challenge → pay → verify → access)

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:

  1. Client calls GET /protected → server returns 402 + a payment “invoice” (recipient ATA, amount, nonce, expires_at, mint, etc.)
  2. Client pays on-chain and sends X-PAYMENT header to GET /protected
  3. Server checks:
    • payment was previously verified (anti-tamper)
    • nonce not expired
    • tx not already used
    • tx actually paid correct amount to correct place
  4. Server returns 200 with an x402 receipt payload.

Key in-memory stores (important behavior)

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 /protected returns 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.)

Rate limiting

The app uses flask_limiter with:

  • default limit: 30/second
  • several endpoints limited to 5/minute

The limiter key is:

  1. x-api-key header (or query param), else
  2. the client IP (get_remote_address())

This means authenticated callers are rate-limited per API key; unauthenticated callers per IP.


Endpoints

Auth endpoints

GET /api/auth/me

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.

POST /api/auth/nonce (rate-limited 5/min)

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.


POST /api/auth/verify (rate-limited 5/min)

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 nonce appears in message
  • 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 }

POST /api/auth/balances (rate-limited 5/min)

Purpose: Gate a feature based on holding enough THRT.

Body JSON:

  • wallet_id (must exist in phantom_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: ... }

POST /api/auth/tokens (rate-limited 5/min)

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: [...] }

POST /api/auth/apikey (rate-limited 5/min)

Purpose: Mint an API key for the wallet and store in Mongo.

Body JSON:

  • wallet_id
  • selected_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: ... }

Public endpoints

GET /api/transactions/total

Returns total count of stored tx records.

GET /api/health

Simple health response { ok: true, status: "healthy" }

GET /api/metadata

Returns supported mints from mongo.get_tokens() plus env metadata:

  • version
  • network
  • supported_chains

NLx402 / x402 paywall endpoints

POST /verify

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 }
  • 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.


GET /protected

Purpose: The core paywall endpoint.

  • If unpaid: returns 402 with payment instructions.
  • If paid and valid: returns 200 with x402 receipt.
  • If payment invalid/expired: returns 402 or 401 depending 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)

Case A — No x-payment header (issue a 402 invoice)

  • Loads user via API key → gets:
    • wallet_id (stored as username)
    • 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

Case B — x-payment header present (validate payment)

Expects x-payment to be JSON, containing:

  • tx (transaction signature/hash)
  • nonce (the nonce previously issued)

Validation checks:

  1. nonce exists in nonces_security_hashes
  2. is_verified == True (client must have called /verify successfully)
  3. nonce exists in pending_nonces
  4. nonce not expired (else it deletes nonce entries)
  5. tx not already used (mongo.get_tx(sig))
  6. chain validation:
    • paid = await x402.watch_transaction(sig, amount, wallet_id, mint)
  7. 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).


Example paywall usage (typical client flow)

  1. Request protected resource (no payment yet):
  • GET /protected with x-api-key
  • server returns 402 + invoice JSON
  1. Client verifies invoice integrity:
  • POST /verify with nonce + payment_data exactly as issued
  • server flips is_verified = True
  1. Client pays on-chain and retries:
  • GET /protected with:
    • x-api-key
    • x-payment: {"tx":"<sig>","nonce":"<nonce>"}
  1. Server returns 200 + receipt payload on success.

License

AGPL-3.0 — if you deploy and modify this as a network service, you must make the source available under AGPL terms.

About

Facilitator API source code for NLx402

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors