npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@candypoets/lnuts

v0.3.1

Published

LNURL-pay to Cashu mint bridge with Nostr integration

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
  1. Each pubkey claims one [email protected] handle through NIP-98 HTTP authentication
  2. Payments come in via LNURL-pay endpoint using an invoice from that mint
  3. The selected Cashu mint generates tokens when payment is confirmed
  4. Tokens remain on the server until the recipient requests GET /api/proofs
  5. 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/lnuts

Configuration

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=true

LNUTS_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 start

Open 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 metadata
  • GET /.well-known/lnurlp/[alias]/callback - Generates Lightning invoice

API endpoints for managing claims:

  • POST /api/claims - Claim, update, or change the signer's handle
  • DELETE /api/claims/[alias] - Delete the signer's handle with NIP-98 auth
  • GET /api/claims/[alias] - Get information about a specific alias
  • GET /api/aliases?pubkey=[pubkey] - Query the active handle for a public key
  • GET /api/proofs?since=[timestamp] - Retrieve the signer's proofs with NIP-98 auth
  • GET /api/lnuts/status - Non-secret diagnostics data when the UI is enabled
  • GET /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 instance
  • options: Configuration options used by the handler
  • pool: SimplePool instance for Nostr operations
  • handle: SvelteKit-compatible handle function

Methods

  • start(): Start payment polling
  • stop(): Stop all services
  • getWallet(mintUrl): Get the cached Cashu wallet for a recipient-selected mint
  • pollPayments(): Manually trigger payment polling
  • clone(options): Create a new handler instance with modified options

Database Schema

Claims Table

  • alias: The claimed username
  • pubkey: Unique Nostr pubkey of the claimer; one active handle per pubkey
  • p2pk_pubkey: Optional P2PK pubkey for token locking
  • relay: Optional retained recipient metadata
  • claimed_at: Timestamp of claim

Payments Table

  • id: Unique payment ID
  • alias: Associated username
  • quote_id: Cashu mint quote ID
  • mint_quote_expiry: Mint-provided Unix timestamp when the invoice expires
  • amount: Payment amount in sats
  • invoice: Lightning invoice
  • comment: Optional payment comment
  • recipient_pubkey: Snapshot of the owner entitled to retrieve the proofs
  • locking_pubkey: Snapshot of the P2PK key used when minting the proofs
  • mint_preview_json: Temporary replay-safe mint state, cleared after proofs are stored
  • status: pending | minted | expired | failed
  • created_at: Payment creation timestamp
  • updated_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 ISSUED quote 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/alice

Response:

{
  "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:

  1. validates the signed kind 9734 request, recipient, amount, and relay list;
  2. asks the recipient-selected Cashu mint for an invoice whose description is the exact zap request;
  3. stores the zap request with the payment;
  4. mints the recipient's P2PK-locked proofs after payment; and
  5. signs and publishes a kind 9735 receipt 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 start

Testing

# 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/status

Examples

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 minted state
  • Verify the NIP-98 event URL exactly matches the public /api/proofs URL
  • Verify the event is signed by the pubkey that owned the alias when the invoice was created

LNURL Not Working

  • Ensure .well-known/lnurlp routes are properly configured
  • Verify domain configuration matches actual hostname
  • Check that aliases exist in claims table

License

MIT