@candypoets/lnuts
v0.3.1
Published
LNURL-pay to Cashu mint bridge with Nostr integration
Maintainers
Readme
lnuts
Turn any Node server into a full LNURL-pay server with just a few configuration variables!
A Nostr-native LNURL-pay server that bridges Lightning payments to Cashu tokens.
Anyone can claim an [email protected] address, select a mint, and retrieve the
resulting ecash through a NIP-98-authenticated endpoint.
Features
- LNURL-pay Endpoint: Serve
/.well-known/lnurlp/[alias]for Lightning payments - Nostr Integration: Claim and manage handles through NIP-98 authentication
- NIP-57 zaps: Optionally issue zap-committed invoices and publish zap receipts
- Cashu Mint Bridge: Automatically mint Cashu tokens when payments are received
- Recipient-selected mints: Each claimed address chooses its own Cashu mint
- Private proof retrieval: Owners pull proofs through NIP-98 HTTP authentication
- Automatic Polling: Continuously polls paid MintQuotes and stores the resulting tokens
- Built-in diagnostics: Inspect configuration, claims, and LNURL-pay responses at
/lnuts - SQLite Storage: Persistent storage for claims and payments
Architecture
User → Lightning Payment → LNURL-pay → Cashu Mint → Authenticated proof API → User- Each pubkey claims one
[email protected]handle through NIP-98 HTTP authentication - Payments come in via LNURL-pay endpoint using an invoice from that mint
- The selected Cashu mint generates tokens when payment is confirmed
- Tokens remain on the server until the recipient requests
GET /api/proofs - A valid NIP-98 event proves ownership before any proofs are returned
Installation
Requires Node.js 22.4 or newer (matching the installed cashu-ts runtime).
npm install @candypoets/lnutsConfiguration
Set up environment variables:
# Required in production
LNUTS_DOMAIN=mydomain.com
# Optional
LNUTS_BASE_URL=https://mydomain.com
LNUTS_DATABASE_PATH=./data/lnuts.db
LNUTS_DEFAULT_RELAYS=wss://relay.damus.io,wss://relay.nostr.band,wss://nos.lol
# Optional: enables NIP-57 zap receipts
LNUTS_ZAP_NSEC=nsec1...
LNUTS_PORT=3000
LNUTS_DEBUG_UI=true
LNUTS_AUTO_START=trueLNUTS_BASE_URL is especially useful locally, for example
http://localhost:3000; otherwise it is derived from LNUTS_DOMAIN.
It must be the exact public HTTP(S) origin seen by clients, including the
scheme and port when applicable. For example, use http://befree:5173, not
befree:5173.
There is no server-wide default mint: every claim must include a mint tag.
When LNUTS_ZAP_NSEC is configured, LNURL discovery advertises NIP-57 support
and the corresponding public key. Keep the key stable and secret: it signs zap
receipts for every handle served by this deployment. Without it, normal
LNURL-pay and proof retrieval continue to work unchanged.
Standalone server and diagnostics UI
No host framework is required:
cp .env.example .env
npm run build
npm startOpen http://localhost:3000/lnuts. The page can:
- connect to a NIP-07 browser extension or accept an npub/hex public key;
- list names already claimed by that key;
- show each Lightning address, LNURL-pay URL, and encoded LNURL;
- probe an alias and display the raw LNURL-pay response;
- show the effective non-secret server configuration.
For a local UI-only smoke test without contacting the mint, set
LNUTS_AUTO_START=false. Turn it back on to exercise invoice creation,
payment polling and minting.
Usage with SvelteKit
1. Quick Start with Middleware
The simplest way to add lnuts to your SvelteKit app:
// src/hooks.server.ts
import { createLnutsMiddleware } from "@candypoets/lnuts/server";
export const handle = createLnutsMiddleware();2. Advanced: Compose with Other Middleware
// src/hooks.server.ts
import { sequence } from "@sveltejs/kit/hooks";
import { withLnuts } from "@candypoets/lnuts/server";
const myAuthMiddleware = async ({ event, resolve }) => {
// Your auth logic
return resolve(event);
};
export const handle = sequence(withLnuts(), myAuthMiddleware);3. Create Required Routes
LNURL-pay endpoints are automatically handled by the middleware at:
GET /.well-known/lnurlp/[alias]- Returns LNURL metadataGET /.well-known/lnurlp/[alias]/callback- Generates Lightning invoice
API endpoints for managing claims:
POST /api/claims- Claim, update, or change the signer's handleDELETE /api/claims/[alias]- Delete the signer's handle with NIP-98 authGET /api/claims/[alias]- Get information about a specific aliasGET /api/aliases?pubkey=[pubkey]- Query the active handle for a public keyGET /api/proofs?since=[timestamp]- Retrieve the signer's proofs with NIP-98 authGET /api/lnuts/status- Non-secret diagnostics data when the UI is enabledGET /lnuts- Built-in diagnostics UI when enabled
All routes are automatically handled by the middleware - no additional route files needed!
Usage with Node.js / Express
Express Server Integration
import express from "express";
import { createExpressMiddleware } from "@candypoets/lnuts/server";
const app = express();
const port = 3000;
// Add body parser for JSON
app.use(express.json());
// Apply lnuts middleware - it handles all routes automatically
app.use(
createExpressMiddleware({
// Optional: override environment variables
domain: "mydomain.com",
}),
);
// Your other routes
app.get("/", (req, res) => {
res.send("Hello World!");
});
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});All routes are automatically handled by the middleware - no additional configuration needed!
Handler Class API
Constructor Options
interface LnutsOptions {
dbPath?: string; // SQLite file or containing directory
nostrRelays?: string[]; // Nostr relays to connect to
zapSecretKey?: string; // Optional nsec/hex signer for NIP-57 receipts
domain?: string; // Domain name for LNURL metadata
baseUrl?: string; // Public origin, including scheme
debugUi?: boolean; // Serve /lnuts and its status endpoint
autoStart?: boolean; // Whether to auto-start services (default: true)
}Creating an Instance
import { LnutsHandler } from "@candypoets/lnuts";
// Using environment variables
const lnuts = new LnutsHandler();
// With custom options
const lnuts = new LnutsHandler({
domain: "mydomain.com",
zapSecretKey: process.env.LNUTS_ZAP_NSEC,
dbPath: "./data/lnuts.db",
autoStart: false, // Manual service start
});
// Manually start services if autoStart is false
await lnuts.start();Properties
db: Direct access to the SQLite database instanceoptions: Configuration options used by the handlerpool: SimplePool instance for Nostr operationshandle: SvelteKit-compatible handle function
Methods
start(): Start payment pollingstop(): Stop all servicesgetWallet(mintUrl): Get the cached Cashu wallet for a recipient-selected mintpollPayments(): Manually trigger payment pollingclone(options): Create a new handler instance with modified options
Database Schema
Claims Table
alias: The claimed usernamepubkey: Unique Nostr pubkey of the claimer; one active handle per pubkeyp2pk_pubkey: Optional P2PK pubkey for token lockingrelay: Optional retained recipient metadataclaimed_at: Timestamp of claim
Payments Table
id: Unique payment IDalias: Associated usernamequote_id: Cashu mint quote IDmint_quote_expiry: Mint-provided Unix timestamp when the invoice expiresamount: Payment amount in satsinvoice: Lightning invoicecomment: Optional payment commentrecipient_pubkey: Snapshot of the owner entitled to retrieve the proofslocking_pubkey: Snapshot of the P2PK key used when minting the proofsmint_preview_json: Temporary replay-safe mint state, cleared after proofs are storedstatus: pending | minted | expired | failedcreated_at: Payment creation timestampupdated_at: Last update timestamp
Automatic Services
Payment Polling
The handler automatically polls the Cashu mint for paid invoices:
- Default interval: 1 second
- Automatically mints tokens when payment is confirmed
- Polls each quote until the mint-provided expiry and performs a final check
- Keeps temporary mint and network failures pending for retry
- Persists the prepared mint outputs before issuance so an
ISSUEDquote can be recovered after a lost response or process restart - Stores tokens for authenticated retrieval
Replay recovery uses the mint's NUT-19 response cache and must occur within the
retention window advertised by that mint. A legacy ISSUED payment created
without prepared mint state is marked failed explicitly because its randomized
proof secrets cannot be reconstructed.
API Endpoints
Claim or Change Handle
POST /api/claims
Send the handle settings as JSON and authenticate the exact request with a
kind 27235 NIP-98 event. The event must include the SHA-256 hash of the exact
request body in its payload tag.
{
"alias": "alice",
"mintUrl": "https://mint.example.com",
"p2pkPubkey": "02abc..."
}A pubkey can have only one active handle. A newer authenticated request with a different alias changes it; submitting the same alias updates its mint and P2PK settings. The target alias must not belong to another pubkey.
Delete Handle
DELETE /api/claims/[alias]
Sign a NIP-98 event for the exact DELETE URL and send it in the
Authorization header. Deleting a handle does not delete or transfer historical
proofs:
const url = "https://mydomain.com/api/claims/alice";
const authEvent = finalizeEvent(
{
kind: 27235,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", url],
["method", "DELETE"],
],
content: "",
},
secretKey,
);
await fetch(url, {
method: "DELETE",
headers: {
Authorization: `Nostr ${btoa(JSON.stringify(authEvent))}`,
},
});After deletion, the pubkey can claim another handle using a newer NIP-98 request. Old authenticated requests cannot be replayed to restore a deleted handle.
Get Alias Information
GET /api/claims/[alias]
Get information about a specific claimed alias:
curl http://localhost:3000/api/claims/aliceResponse:
{
"status": "success",
"data": {
"alias": "alice",
"pubkey": "user_pubkey",
"relay": "wss://relay.damus.io",
"p2pk_pubkey": "02abc...",
"claimed_at": "2024-01-01T00:00:00Z"
}
}Query Handle by Public Key
GET /api/aliases?pubkey=[pubkey]
Get the active handle owned by a public key. The response remains an array for API compatibility but contains at most one entry:
curl http://localhost:3000/api/aliases?pubkey=02abc123...Response:
{
"status": "success",
"data": [
{
"alias": "alice",
"relay": "wss://relay.damus.io",
"p2pk_pubkey": "02abc...",
"claimed_at": "2024-01-01T00:00:00Z"
},
{
"alias": "bob",
"relay": "wss://nos.lol",
"p2pk_pubkey": null,
"claimed_at": "2024-01-02T00:00:00Z"
}
]
}Retrieve proofs
GET /api/proofs?since=[timestamp]
The request must carry a valid NIP-98
Authorization header. The signed event must be kind 27235, no more than 60
seconds old, and contain the exact public URL—including the since query
parameter—and HTTP method:
import { finalizeEvent } from "nostr-tools";
const since = localStorage.getItem("lnuts-received-through") || "0";
const url = `https://mydomain.com/api/proofs?since=${since}`;
const authEvent = finalizeEvent(
{
kind: 27235,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", url],
["method", "GET"],
],
content: "",
},
secretKey,
);
const response = await fetch(url, {
headers: {
Authorization: `Nostr ${btoa(JSON.stringify(authEvent))}`,
},
});
const { data } = await response.json();
// Save this only after importing every returned token successfully.
localStorage.setItem("lnuts-received-through", String(data.receivedThrough));since is a Unix timestamp in milliseconds meaning “I have received everything
through this time.” Omit it on the first request. The response contains
data.proofs and data.receivedThrough; use receivedThrough as the next
request's since value.
Each proof entry contains paymentId, alias, amount in millisatoshis,
mintUrl, mintedAt, and the Cashu token. Timestamp boundaries are inclusive,
so a payment at the exact boundary can appear again. Clients must deduplicate by
paymentId; this intentional overlap prevents payments from being missed.
Client Utilities
The library provides utility functions to help clients interact with the lnuts API:
The claim utilities construct the JSON body and its matching NIP-98 event:
import {
constructClaimRequest,
constructClaimAuthorizationEvent,
postClaimRequest,
} from "@candypoets/lnuts/utils";
import { generateSecretKey, getPublicKey, finalizeEvent } from "nostr-tools";
const secretKey = generateSecretKey();
const pubkey = getPublicKey(secretKey);
const url = "https://mydomain.com/api/claims";
const request = constructClaimRequest(
"alice",
"https://mint.example.com",
"wss://relay.damus.io",
"02abc123...",
);
const unsignedAuth = await constructClaimAuthorizationEvent(
request,
pubkey,
url,
);
const signedAuth = finalizeEvent(unsignedAuth, secretKey);
const result = await postClaimRequest(request, signedAuth, url);
if (result.status === "success") {
console.log(`Successfully claimed [email protected]!`);
}Nostr authentication
Claims, handle deletion, and proof retrieval all use short-lived kind 27235
NIP-98 events. Claim requests additionally require a payload tag containing
the SHA-256 hash of the exact JSON body. No custom event kinds are used, and
proofs are never published to Nostr relays.
NIP-57 zaps
Zap support is optional. Set LNUTS_ZAP_NSEC (or pass zapSecretKey) to make
LNURL discovery return allowsNostr: true and nostrPubkey. The callback then:
- validates the signed kind
9734request, recipient, amount, and relay list; - asks the recipient-selected Cashu mint for an invoice whose description is the exact zap request;
- stores the zap request with the payment;
- mints the recipient's P2PK-locked proofs after payment; and
- signs and publishes a kind
9735receipt to the requested relays.
The selected mint must advertise NUT-23 Bolt11-description support. lnuts verifies that the returned invoice has the requested amount and commits to the exact zap request, either through its description hash or exact description. Some strict NIP-57 clients may require the description-hash form, while current NUT-23 mints commonly return the exact-description form. Signed receipts are stored before relay delivery, and failed relay publication is retried without minting the proofs again. The mint transaction itself is prepared and stored before issuance, so a lost mint response can be retried with the exact same blinded outputs when the mint supports NUT-19.
Zap polling stops at the earlier of the mint quote expiry and the BOLT11 invoice expiry. If the mint returns a non-expiring quote, lnuts uses the invoice's expiry (BOLT11 defaults this to one hour).
Security Considerations
Database Security
- Default location is configurable via
dbPath - Ensure proper file permissions
- Regular backups recommended
- Treat the database as secret: pending mint previews and minted Cashu proofs contain bearer-value material
- Protect
LNUTS_ZAP_NSEC; changing it invalidates receipts for outstanding zap invoices
Development
# Install dependencies
npm install
# Run the self-contained server with environment variables
LNUTS_DOMAIN=localhost:3000 \
LNUTS_BASE_URL=http://localhost:3000 \
npm startTesting
# Test LNURL endpoint
curl http://localhost:3000/.well-known/lnurlp/alice
# Test callback
curl "http://localhost:3000/.well-known/lnurlp/alice/callback?amount=1000000"
# Submit a claim using a NIP-98 Authorization header
curl -X POST http://localhost:3000/api/claims \
-H "Content-Type: application/json" \
-H "Authorization: Nostr <base64-kind-27235-event>" \
-d '{"alias":"alice","mintUrl":"https://testnut.cashu.space"}'
# Get alias information
curl http://localhost:3000/api/claims/alice
# Query aliases by public key
curl http://localhost:3000/api/aliases?pubkey=02abc...
# Test the diagnostics status
curl http://localhost:3000/api/lnuts/statusExamples
The package can run standalone or as SvelteKit/Express middleware; no generated route files are required.
Troubleshooting
Services Not Starting
- Verify the mint selected in the recipient's claim is accessible
- Check database file permissions
Proofs Not Available
- Verify the payment has reached the
mintedstate - Verify the NIP-98 event URL exactly matches the public
/api/proofsURL - Verify the event is signed by the pubkey that owned the alias when the invoice was created
LNURL Not Working
- Ensure
.well-known/lnurlproutes are properly configured - Verify domain configuration matches actual hostname
- Check that aliases exist in claims table
License
MIT
