@candypoets/lnuts
v0.1.6
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 full Nostr-ready LNURL-pay server that bridges Lightning payments to Cashu tokens. Allows anyone to claim [email protected] addresses that will receive Cashu tokens via Lightning payments. The server sends both the Cashu proofs back to the Nostr address and the payment confirmation (kind 9735 events) making it fully zap-compatible.
Features
- LNURL-pay Endpoint: Serve
/.well-known/lnurlp/[alias]for Lightning payments - Nostr Integration: Claim addresses via Nostr kind 10020 events with optional P2PK locking
- Cashu Mint Bridge: Automatically mint Cashu tokens when payments are received
- Token Delivery: Deliver tokens back to users via Nostr kind 9321 events
- Zap Support: Sends payment confirmations via kind 9735 events for full Nostr zap compatibility
- Automatic Polling: Continuously polls for paid MintQuotes and delivers tokens
- Retry Mechanism: Automatic retry of failed token deliveries
- Admin API: Monitor and manage the system via REST endpoints
- SQLite Storage: Persistent storage for claims and payments
Architecture
User → Lightning Payment → LNURL-pay → Cashu Mint → Nostr Delivery → User- Users claim
[email protected]via Nostr kind 10020 events (supports P2PK pubkey for locking proofs) - Payments come in via LNURL-pay endpoint
- Cashu mint generates tokens when payment is confirmed
- Tokens are delivered back to user via kind 9321 events
- Payment confirmations are broadcast via kind 9735 events for zap compatibility
Installation
npm install @candypoets/lnutsConfiguration
Set up environment variables:
# Required
LNUTS_DOMAIN=mydomain.com
LNUTS_MINT_URL=https://mint.example.com
LNUTS_ADMIN_SECRET_KEY=your_admin_secret_key_or_nsec
# Optional
LNUTS_DATABASE_PATH=./data/lnuts.db
LNUTS_NOSTR_RELAYS=wss://relay.damus.io,wss://relay.nostr.band,wss://nos.lolUsage 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
Admin endpoints are automatically available at:
GET /api/admin/claims- List all claimed addressesGET /api/admin/payments- List all payment sessionsGET /api/admin/stats- System statistics
API endpoints for managing claims:
POST /api/claims- Submit a Nostr event to claim an aliasGET /api/claims/[alias]- Get information about a specific aliasGET /api/aliases?pubkey=[pubkey]- Query all aliases owned by a public key
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',
mintUrl: 'https://mint.example.com',
adminSecretKey: process.env.LNUTS_ADMIN_SECRET_KEY
}));
// 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; // Path to SQLite database file (default: from env or './lnuts.db')
mintUrl?: string; // Cashu mint URL (required)
nostrRelays?: string[]; // Nostr relays to connect to
adminSecretKey?: string; // Nostr private key for signing events (required)
domain?: string; // Domain name for LNURL metadata
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',
mintUrl: 'https://mint.example.com',
adminSecretKey: 'nsec1...' // or hex private key,
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 instancewallet: Cashu wallet instance for mint operationsmint: Cashu mint client instanceoptions: Configuration options used by the handlerservicePubkey: Derived public key for Nostr eventspool: SimplePool instance for Nostr operationshandle: SvelteKit-compatible handle function
Methods
start(): Manually start all services (polling, retry scheduler)stop(): Stop all servicespollPayments(): Manually trigger payment pollingretryFailedDeliveries(): Manually retry failed token deliveriesclone(options): Create a new handler instance with modified options
Database Schema
Claims Table
alias: The claimed usernamepubkey: Nostr pubkey of the claimerp2pk_pubkey: Optional P2PK pubkey for token lockingrelay: Preferred relay for deliveryclaimed_at: Timestamp of claim
Payments Table
id: Unique payment IDalias: Associated usernamequote_id: Cashu mint quote IDamount: Payment amount in satsinvoice: Lightning invoicecomment: Optional payment commentstatus: pending | paid | minted | delivered | failedcreated_at: Payment creation timestampupdated_at: Last update timestamp
Failed Deliveries Table
id: Unique delivery IDpayment_id: Reference to paymenterror: Error messageretry_count: Number of retry attemptscreated_at: Failure 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
- Delivers tokens via Nostr to the recipient
Retry Scheduler
Failed token deliveries are automatically retried:
- Default interval: 5 minutes
- Exponential backoff for repeated failures
- Maximum retry attempts before marking as permanently failed
API Endpoints
Submit Claim Event
POST /api/claims
Submit a Nostr kind 10020 event to claim an alias:
curl -X POST http://localhost:3000/api/claims \
-H "Content-Type: application/json" \
-d '{
"kind": 10020,
"tags": [
["name", "alice"],
["relay", "wss://relay.damus.io"],
["mint", "https://mint.example.com"],
["p2pk", "02abc..."]
],
"content": "",
"pubkey": "user_pubkey",
"created_at": 1234567890,
"id": "event_id",
"sig": "signature"
}'Response:
{
"status": "success",
"data": {
"alias": "alice",
"pubkey": "user_pubkey",
"relay": "wss://relay.damus.io",
"p2pk_pubkey": "02abc..."
}
}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 Aliases by Public Key
GET /api/aliases?pubkey=[pubkey]
Get all aliases owned by a specific public key:
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"
}
]
}Client Utilities
The library provides utility functions to help clients interact with the lnuts API:
constructClaimEvent
Creates an unsigned Nostr kind 10020 event for claiming an alias:
import { constructClaimEvent } from '@candypoets/lnuts/utils';
const claimEvent = constructClaimEvent(
'alice', // alias to claim
'pubkey_hex', // your Nostr public key
'https://mint.example.com', // Cashu mint URL
['wss://relay.damus.io'], // optional: preferred relays
'02abc...' // optional: P2PK pubkey for locking
);
// Sign the event with your Nostr private key
const signedEvent = finalizeEvent(claimEvent, privateKey);Parameters:
alias: The alias to claim (1-30 chars, alphanumeric and underscores only)pubkey: Nostr public key of the claimermintUrl: Cashu mint URL to associate with the claimrelays: (optional) Array of preferred relay URLs for token deliveryp2pkPubkey: (optional) P2PK public key for locking tokens
Returns an unsigned Nostr event ready for signing.
postClaimEvent
Submits a signed claim event to the lnuts API:
import { postClaimEvent } from '@candypoets/lnuts/utils';
import { finalizeEvent } from 'nostr-tools';
// Create and sign the claim event
const unsignedEvent = constructClaimEvent('alice', pubkey, mintUrl);
const signedEvent = finalizeEvent(unsignedEvent, privateKey);
// Submit the claim
try {
const result = await postClaimEvent(
signedEvent,
'mydomain.com' // optional: defaults to LNUTS_DOMAIN env var
);
if (result.status === 'success') {
console.log('Alias claimed successfully!');
} else {
console.error('Claim failed:', result.reason);
}
} catch (error) {
console.error('Error posting claim:', error);
}Parameters:
event: The signed Nostr kind 10020 claim eventdomain: (optional) The domain to claim the handle on
Returns a promise resolving to:
{
status: 'success' | 'error',
message?: string, // Success message
reason?: string // Error reason
}Complete Example
Here's a complete example of claiming an alias:
import { constructClaimEvent, postClaimEvent } from '@candypoets/lnuts/utils';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
// Generate or use existing Nostr keys
const secretKey = generateSecretKey();
const pubkey = getPublicKey(secretKey);
// Create the claim event
const claimEvent = constructClaimEvent(
'alice',
pubkey,
'https://mint.example.com',
['wss://relay.damus.io', 'wss://nos.lol'],
'02abc123...' // optional P2PK pubkey
);
// Sign the event
const signedEvent = finalizeEvent(claimEvent, secretKey);
// Submit the claim
const result = await postClaimEvent(signedEvent, 'mydomain.com');
if (result.status === 'success') {
console.log(`Successfully claimed [email protected]!`);
// The alias will now receive Lightning payments that get converted to Cashu tokens
}Nostr Events
Claim Event (Kind 10020)
Users claim addresses by publishing this event:
{
"kind": 10020,
"tags": [
["name", "alice"],
["relay", "wss://relay.damus.io"],
["mint", "https://mint.example.com"],
["p2pk", "02abc..."] // Optional: P2PK pubkey for token locking
],
"content": "",
"pubkey": "user_pubkey",
"created_at": 1234567890
}Token Delivery (Kind 9321)
Tokens are delivered via encrypted direct messages:
{
"kind": 9321,
"tags": [
["p", "recipient_pubkey"],
["amount", "1000"],
["mint", "https://mint.example.com"]
],
"content": "encrypted_cashu_token",
"pubkey": "service_pubkey",
"created_at": 1234567890
}Security Considerations
Admin Secret Key
- Store securely, never commit to version control
- Use environment variables or secure key management
- Supports both
nsec(bech32) and hex format
Database Security
- Default location is configurable via
dbPath - Ensure proper file permissions
- Regular backups recommended
Development
# Install dependencies
npm install
# Run with environment variables
LNUTS_DOMAIN=localhost \
LNUTS_MINT_URL=https://testnut.cashu.space \
LNUTS_ADMIN_SECRET_KEY=nsec1... \
node server.js
# For SvelteKit development
npm run devTesting
# 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 (requires valid Nostr event)
curl -X POST http://localhost:3000/api/claims \
-H "Content-Type: application/json" \
-d '{"kind":10020,"tags":[["name","alice"]],...}'
# Get alias information
curl http://localhost:3000/api/claims/alice
# Query aliases by public key
curl http://localhost:3000/api/aliases?pubkey=02abc...
# Test admin stats
curl http://localhost:3000/api/admin/statsExamples
See the examples/ directory for complete implementations:
- SvelteKit routes
- Express server
- Admin dashboard
Troubleshooting
Services Not Starting
- Check that
mintUrlandadminSecretKeyare properly configured - Verify the Cashu mint is accessible
- Check database file permissions
Tokens Not Delivering
- Verify Nostr relays are accessible
- Check recipient's relay preferences
- Review failed deliveries in the database
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
