Use when implementing npub.cash static Lightning address functionality - provides complete patterns for obtaining static Lightning addresses ([email protected]), managing npub.cash account settings, syncing quotes from npub.cash, and integrating with Lightning wallets
Complete implementation guide for integrating with npub.cash to obtain static Lightning addresses for users. npub.cash provides a service that generates static Lightning addresses in the format [email protected] based on a user's Nostr public key, allowing users to receive Lightning payments at a permanent address.
Core Capabilities:
[email protected])Note: This skill is optional for lightning-wallet implementations. Users can optionally use npub.cash to get a static Lightning address, but it's not required for basic Lightning wallet functionality.
IMPORTANT: Before adding dependencies, review your project's package.json to check if any of these packages already exist. If they do, verify the versions are compatible with the requirements below. Only add packages that are missing or need version updates.
Required packages:
npubcash-sdk@^0.2.0 - Official npub.cash SDK (minimum version 0.2.0, later versions acceptable)npubcash-types - TypeScript types for npub.cash (if not included in SDK)nostr-tools@^2.13.0 - Nostr protocol utilities (for NIP-98 authentication)Required skills (must be referenced/implemented):
qr-code-generator - QR code generation for displaying Lightning addresses (see qr-code-generator skill)lightning-wallet - Lightning wallet operations (optional integration target)Optional dependencies:
qr-code-generator skill)bitcoin-wallet skill for copy hook)Static Lightning Address:
[email protected]Dynamic Invoices:
npub.cash provides:
[email protected] formatThis skill (npub-cash-address):
lightning-address skill:
[email protected]) and gets an invoiceSet up NIP-98 authentication for npub.cash API:
// hooks/useNIP98.ts (prerequisite)
import { useCallback } from 'react';
import { useCurrentUser } from './useCurrentUser';
import type { NostrEvent } from '@nostrify/nostrify';
export interface NIP98Options {
url: string;
method: string;
body?: unknown;
}
/**
* Hook for creating NIP-98 HTTP Authentication headers
* Based on https://github.com/nostr-protocol/nips/blob/master/98.md
*/
export function useNIP98() {
const { user } = useCurrentUser();
/**
* Create a NIP-98 authorization header for HTTP requests
* @param options - The request details (url, method, optional body)
* @returns Promise<string> - The "Nostr base64..." authorization header value
*/
const createAuthHeader = useCallback(async (options: NIP98Options): Promise<string> => {
if (!user) {
throw new Error('User must be logged in to create NIP-98 auth header');
}
const { url, method, body } = options;
// Parse URL to remove query parameters (per NIP-98 spec)
const urlObj = new URL(url);
const urlWithoutQuery = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`;
const tags: string[][] = [
['u', urlWithoutQuery],
['method', method.toUpperCase()],
];
// If there's a request body (POST, PUT, PATCH), add payload hash
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
const bodyString = JSON.stringify(body);
const encoder = new TextEncoder();
const data = encoder.encode(bodyString);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const payloadHash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
tags.push(['payload', payloadHash]);
}
// Create and sign the NIP-98 event (kind 27235)
const event = await user.signer.signEvent({
kind: 27235,
content: '',
tags,
created_at: Math.floor(Date.now() / 1000),
}) as NostrEvent;
// Encode the event as base64
const eventJson = JSON.stringify(event);
const base64Event = btoa(eventJson);
return `Nostr ${base64Event}`;
}, [user]);
return {
createAuthHeader,
isLoggedIn: !!user,
pubkey: user?.pubkey,
signer: user?.signer,
};
}
Key Points:
"Nostr {base64Event}" format for Authorization headerComplete hook for npub.cash integration:
// hooks/wallet/useNpubCash.ts
import { useCallback, useState, useMemo, useRef } from 'react';
import { useNIP98 } from '../useNIP98';
import { NPCClient, JWTAuthProvider } from 'npubcash-sdk';
import type { Quote } from 'npubcash-types';
import type { Manager } from 'coco-cashu-core';
import type { EventTemplate, Event } from 'nostr-tools';
// optional import { useToast } from '@/hooks/useToast';
const NPUBCASH_BASE_URL = 'https://npubx.cash';
export interface NpubCashUserInfo {
pubkey: string;
name?: string;
mintUrl: string; // API returns camelCase
lockQuote: boolean; // API returns camelCase
}
/**
* Hook for interacting with npub.cash API using the official SDK
*/
export function useNpubCash() {
const { createAuthHeader, signer, isLoggedIn, pubkey } = useNIP98();
// Optional: User feedback notifications
// Option 1: Console logging
// const logMessage = (message: string) => console.log(message);
// Option 2: Toast notifications (if useToast hook is available)
// const { toast } = useToast();
// Option 3: No notification handler
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Ref to track last sync timestamp for incremental updates
const lastSyncRef = useRef<number>(0);
// Create NPCClient instance using the SDK with JWTAuthProvider for proper WebSocket auth
const npcClient = useMemo(() => {
if (!isLoggedIn || !signer) return null;
// Adapt NostrSigner to JWTAuthProvider's expected SigningFunc signature
const signingFunc = async (eventTemplate: EventTemplate): Promise<Event> => {
return await signer.signEvent(eventTemplate);
};
const auth = new JWTAuthProvider(NPUBCASH_BASE_URL, signingFunc);
return new NPCClient(NPUBCASH_BASE_URL, auth);
}, [isLoggedIn, signer]);
// Generic wrapper for SDK calls with loading state
const withLoading = useCallback(async <T>(operation: () => Promise<T>): Promise<T | null> => {
try {
setIsLoading(true);
setError(null);
return await operation();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
setError(errorMessage);
return null;
} finally {
setIsLoading(false);
}
}, []);
const setMint = useCallback(async (mintUrl: string): Promise<boolean> => {
if (!npcClient) return false;
const result = await withLoading(() => npcClient.settings.setMintUrl(mintUrl));
return result !== null;
}, [npcClient, withLoading]);
const getUserInfo = useCallback(async (): Promise<NpubCashUserInfo | null> => {
if (!npcClient) return null;
// Since SDK doesn't have getUserInfo, we'll use manual implementation
return withLoading(async () => {
const url = `${NPUBCASH_BASE_URL}/api/v2/user/info`;
const authHeader = await createAuthHeader({ url, method: 'GET' });
const response = await fetch(url, {
method: 'GET',
headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' },
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Failed to get user info (${response.status})`);
}
const data = await response.json();
return data.data.user;
});
}, [npcClient, createAuthHeader, withLoading]);
const getAllQuotes = useCallback(async (): Promise<Quote[]> => {
if (!npcClient) return [];
const result = await withLoading(() => npcClient.getAllQuotes());
return result || [];
}, [npcClient, withLoading]);
const getQuotesSince = useCallback(async (since: number): Promise<Quote[]> => {
if (!npcClient) return [];
const result = await withLoading(() => npcClient.getQuotesSince(since));
return result || [];
}, [npcClient, withLoading]);
const setLockQuotes = useCallback(async (lockQuotes: boolean): Promise<boolean> => {
if (!npcClient) return false;
const result = await withLoading(() => npcClient.settings.setLock(lockQuotes));
return result !== null;
}, [npcClient, withLoading]);
const subscribeToQuotes = useCallback((
onUpdate: (quoteId: string) => void,
onError?: (msg: string) => void
) => {
if (!npcClient) return () => {};
return npcClient.subscribe(onUpdate, onError);
}, [npcClient]);
const clearError = useCallback(() => setError(null), []);
return {
// Core methods
setMint,
getUserInfo,
// Quote management methods
getAllQuotes,
getQuotesSince,
setLockQuotes,
subscribeToQuotes,
// Status
isLoading,
error,
isLoggedIn,
pubkey,
clearError,
// SDK instance for advanced usage
npcClient,
};
}
Get user's static Lightning address from npub.cash:
// In your component
const { getUserInfo, isLoading, error } = useNpubCash();
// Get user info (includes static address)
const userInfo = await getUserInfo();
if (userInfo) {
// Extract username from pubkey or use name if available
const username = userInfo.name || extractUsernameFromPubkey(userInfo.pubkey);
const staticAddress = `${username}@npubx.cash`;
console.log('Static Lightning address:', staticAddress);
// Example: "[email protected]"
}
Show static address in wallet UI with QR code and copy functionality:
// components/StaticAddressDisplay.tsx
import { useEffect, useState } from 'react';
import { useNpubCash } from '@/hooks/wallet/useNpubCash';
import { useQRCodeGenerator } from '@/hooks/useQRCodeGenerator';
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
import { Button } from '@/components/ui/button';
import { Copy, Check } from 'lucide-react';
import { QRModal } from '@/components/QRModal';
export function StaticAddressDisplay() {
const { getUserInfo, isLoading } = useNpubCash();
const { generateQRCode, isGenerating: isGeneratingQR } = useQRCodeGenerator();
const { copy, copied } = useCopyToClipboard();
const [staticAddress, setStaticAddress] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const [showQRModal, setShowQRModal] = useState(false);
useEffect(() => {
const fetchAddress = async () => {
const userInfo = await getUserInfo();
if (userInfo) {
const username = userInfo.name || extractUsernameFromPubkey(userInfo.pubkey);
const address = `${username}@npubx.cash`;
setStaticAddress(address);
// Generate QR code for the address
const qrUrl = await generateQRCode(address);
setQrCodeUrl(qrUrl);
}
};
fetchAddress();
}, [getUserInfo, generateQRCode]);
if (isLoading) {
return <div>Loading address...</div>;
}
if (!staticAddress) {
return <div>No address available</div>;
}
return (
<div className="space-y-4">
<h3>Your Lightning Address</h3>
{/* Address display with copy button */}
<div className="flex items-center gap-2 p-3 bg-muted rounded-lg">
<code className="flex-1 text-sm break-all">{staticAddress}</code>
<Button
variant="ghost"
size="icon"
onClick={() => copy(staticAddress)}
className="h-8 w-8 shrink-0"
aria-label="Copy address"
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{/* QR code display */}
{qrCodeUrl && (
<div className="flex flex-col items-center gap-2">
<img
src={qrCodeUrl}
alt="QR Code"
className="w-48 h-48 cursor-pointer"
onClick={() => setShowQRModal(true)}
/>
<Button
variant="outline"
onClick={() => setShowQRModal(true)}
disabled={isGeneratingQR}
>
{isGeneratingQR ? 'Generating...' : 'View QR Code'}
</Button>
</div>
)}
{/* QR Modal */}
<QRModal
isOpen={showQRModal}
onClose={() => setShowQRModal(false)}
qrCodeUrl={qrCodeUrl}
content={staticAddress}
title="Lightning Address QR Code"
description="Scan this QR code to send Lightning payments"
/>
</div>
);
}
Key Features:
qr-code-generator skill to create QR codes for the Lightning addressbitcoin-wallet skill for easy address copyingExtract username from Nostr pubkey:
// lib/npubCashUtils.ts
export function extractUsernameFromPubkey(pubkey: string): string {
// npub.cash uses a deterministic username based on pubkey
// This is typically the first 8-12 characters of the npub
// You may need to check npub.cash API documentation for exact format
// Example: Use first part of pubkey as username
// In practice, npub.cash may provide this in the user info
return pubkey.substring(0, 8);
}
// Or use the name from userInfo if available
const username = userInfo.name || extractUsernameFromPubkey(userInfo.pubkey);
Configure user's preferred mint on npub.cash:
// In your component
const { setMint, isLoading } = useNpubCash();
// Set mint URL (links npub.cash to user's preferred Cashu mint)
const success = await setMint('https://mint.example.com');
if (success) {
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.log('Mint Updated: Your npub.cash mint has been updated');
// Option 2: Toast notification (if toast is available)
// toast({ title: 'Mint Updated', description: 'Your npub.cash mint has been updated' });
// Option 3: No notification (silent success)
}
Prevent quote modifications (optional security feature):
// In your component
const { setLockQuotes, isLoading } = useNpubCash();
// Lock quotes to prevent modifications
const success = await setLockQuotes(true);
if (success) {
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.log('Quotes Locked: Your quotes are now locked');
// Option 2: Toast notification (if toast is available)
// toast({ title: 'Quotes Locked', description: 'Your quotes are now locked' });
// Option 3: No notification (silent success)
}
Sync quotes from npub.cash to Cashu wallet:
// hooks/wallet/useNpubCash.ts (excerpt)
const refreshQuotes = useCallback(async (coco: Manager): Promise<void> => {
if (!npcClient || !coco) return;
try {
// Fetch all quotes from npub.cash
const quotes = await getAllQuotes();
if (quotes.length === 0) {
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.log('No Quotes Found: No pending quotes found on npub.cash');
// Option 2: Toast notification (if toast is available)
// toast({ title: "No Quotes Found", description: "No pending quotes found on npub.cash" });
// Option 3: No notification (silent)
return;
}
// Group quotes by mint URL
const quotesByMint = quotes.reduce((acc, quote) => {
if (!acc[quote.mintUrl]) {
acc[quote.mintUrl] = [];
}
acc[quote.mintUrl].push(quote);
return acc;
}, {} as Record<string, typeof quotes>);
let addedQuotes = 0;
// Process each mint's quotes
for (const [mintUrl, mintQuotes] of Object.entries(quotesByMint)) {
try {
// Check if mint exists (known) by checking all mints
const allMints = await coco.mint.getAllMints();
const isKnown = allMints.some(m => m.mintUrl.toLowerCase() === mintUrl.toLowerCase());
if (!isKnown) {
await coco.mint.addMint(mintUrl, { trusted: true });
} else {
// Mint exists, ensure it's trusted
const isTrusted = await coco.mint.isTrustedMint(mintUrl);
if (!isTrusted) {
await coco.mint.trustMint(mintUrl);
}
}
// Add quotes to the wallet
coco.quotes.addMintQuote(
mintUrl,
mintQuotes.map((q) => ({
...q,
expiry: q.expiresAt,
quote: q.quoteId,
state: "PAID",
unit: "sat",
})),
);
addedQuotes += mintQuotes.length;
} catch (err) {
console.warn(`Failed to process quotes for mint ${mintUrl}:`, err);
}
}
if (addedQuotes > 0) {
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.log(`Quotes Refreshed: Successfully added ${addedQuotes} quotes from npub.cash`);
// Option 2: Toast notification (if toast is available)
// toast({ title: "Quotes Refreshed", description: `Successfully added ${addedQuotes} quotes from npub.cash` });
// Option 3: No notification (silent success)
}
} catch (err) {
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('Quote Refresh Failed:', err instanceof Error ? err.message : 'Unknown error');
// Option 2: Toast notification (if toast is available)
// toast({ variant: "destructive", title: "Refresh Failed", description: err instanceof Error ? err.message : 'Failed to refresh quotes' });
// Option 3: No notification (silent failure)
}
}, [npcClient, getAllQuotes]);
Subscribe to real-time quote updates:
// hooks/wallet/useNpubCash.ts (excerpt)
const syncQuotesWithWallet = useCallback(async (coco: Manager): Promise<() => void> => {
if (!npcClient || !coco) return () => {};
try {
// Fetch and sync all quotes initially
const quotes = await getAllQuotes();
if (quotes.length > 0) {
// ... sync initial quotes (see refreshQuotes implementation)
}
// Subscribe to real-time updates
const unsubscribe = subscribeToQuotes(
async (quoteId: string) => {
try {
// When a quote is updated, fetch newer quotes and sync
const recentQuotes = await getQuotesSince(lastSyncRef.current);
if (recentQuotes.length > 0) {
// ... sync recent quotes (see refreshQuotes implementation)
lastSyncRef.current = Math.floor(Date.now() / 1000);
}
} catch (err) {
console.error('Failed to sync quote update:', err);
}
},
(err: string) => {
console.error('Quote subscription error:', err);
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('Sync Error: Failed to sync quotes from npub.cash');
// Option 2: Toast notification (if toast is available)
// toast({ variant: "destructive", title: "Sync Error", description: "Failed to sync quotes from npub.cash" });
// Option 3: No notification (silent failure)
}
);
return unsubscribe;
} catch (err) {
console.error('Failed to set up quote sync:', err);
return () => {};
}
}, [npcClient, getAllQuotes, getQuotesSince, subscribeToQuotes]);
Usage in component:
// In your component
const { syncQuotesWithWallet } = useNpubCash();
const { coco } = useCashu(); // From lightning-wallet skill
useEffect(() => {
if (!coco || !isLoggedIn) return;
// Set up real-time quote syncing
const unsubscribe = await syncQuotesWithWallet(coco);
return () => {
if (unsubscribe) unsubscribe();
};
}, [coco, isLoggedIn, syncQuotesWithWallet]);
This skill is optional for lightning-wallet implementations:
// components/wallet/WalletHeader.tsx (excerpt)
import { useNpubCash } from '@/hooks/wallet/useNpubCash';
export function WalletHeader({ userPubkey, mode }: WalletHeaderProps) {
const { getUserInfo, isLoading } = useNpubCash();
const [npubCashUsername, setNpubCashUsername] = useState<string | null>(null);
useEffect(() => {
if (userPubkey && mode === 'lightning') {
// Optionally fetch npub.cash address
const fetchAddress = async () => {
const userInfo = await getUserInfo();
if (userInfo) {
const username = userInfo.name || extractUsernameFromPubkey(userInfo.pubkey);
setNpubCashUsername(username);
}
};
fetchAddress();
}
}, [userPubkey, mode, getUserInfo]);
return (
<div>
{/* ... other wallet header content ... */}
{/* Optionally show static address if available */}
{userPubkey && mode === 'lightning' && npubCashUsername && (
<div className="mt-3 px-4 w-full max-w-[400px] mx-auto">
<LightningAddressDisplay
lightningAddress={`${npubCashUsername}@npubx.cash`}
/>
</div>
)}
</div>
);
}
Sync mint URL between wallet and npub.cash:
// When user changes active mint in wallet
const { setMint } = useNpubCash();
const { activeMintUrl, setActiveMintUrl } = useMintManager({ /* ... */ });
const handleMintChange = async (mintUrl: string) => {
// Update wallet mint
await setActiveMintUrl(mintUrl);
// Optionally sync to npub.cash
if (isNpubCashLoggedIn) {
await setMint(mintUrl);
}
};
Handle NIP-98 authentication failures:
const { getUserInfo, error, isLoggedIn } = useNpubCash();
if (!isLoggedIn) {
return (
<div>
<p>Please log in with Nostr to use npub.cash</p>
<LoginArea />
</div>
);
}
if (error) {
if (error.includes('401') || error.includes('Unauthorized')) {
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('Authentication Failed: Please log in again');
// Option 2: Toast notification (if toast is available)
// toast({ variant: 'destructive', title: 'Authentication Failed', description: 'Please log in again' });
// Option 3: No notification (silent failure)
}
}
Handle npub.cash API errors:
try {
const userInfo = await getUserInfo();
if (!userInfo) {
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('Failed to Get Address: Could not retrieve your npub.cash address');
// Option 2: Toast notification (if toast is available)
// toast({ variant: 'destructive', title: 'Failed to Get Address', description: 'Could not retrieve your npub.cash address' });
// Option 3: No notification (silent failure)
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
if (errorMessage.includes('404')) {
// User may not have npub.cash account yet
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.log('No Account: You may need to create an npub.cash account first');
// Option 2: Toast notification (if toast is available)
// toast({ title: 'No Account', description: 'You may need to create an npub.cash account first' });
// Option 3: No notification (silent)
} else {
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('API Error:', errorMessage);
// Option 2: Toast notification (if toast is available)
// toast({ variant: 'destructive', title: 'Error', description: errorMessage });
// Option 3: No notification (silent failure)
}
}
Only fetch npub.cash data when needed:
// Don't fetch on every render
const [userInfo, setUserInfo] = useState<NpubCashUserInfo | null>(null);
const [hasFetched, setHasFetched] = useState(false);
const fetchUserInfo = useCallback(async () => {
if (hasFetched) return; // Already fetched
const info = await getUserInfo();
setUserInfo(info);
setHasFetched(true);
}, [getUserInfo, hasFetched]);
// Only fetch when user explicitly requests it or when component mounts
useEffect(() => {
if (shouldShowAddress) {
fetchUserInfo();
}
}, [shouldShowAddress, fetchUserInfo]);
Cache user info to avoid repeated API calls:
// Use React Query or similar for caching
import { useQuery } from '@tanstack/react-query';
const { data: userInfo } = useQuery({
queryKey: ['npubcash-user-info', pubkey],
queryFn: () => getUserInfo(),
enabled: !!pubkey && isLoggedIn,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
Always cleanup WebSocket subscriptions:
useEffect(() => {
if (!coco || !isLoggedIn) return;
const unsubscribe = await syncQuotesWithWallet(coco);
return () => {
if (unsubscribe) unsubscribe();
};
}, [coco, isLoggedIn, syncQuotesWithWallet]);
Problem: Attempting to use npub.cash without Nostr login causes errors.
Solution: Always check authentication:
if (!isLoggedIn) {
return <LoginPrompt />;
}
Problem: Assuming user always has npub.cash account.
Solution: Handle null user info gracefully:
const userInfo = await getUserInfo();
if (!userInfo) {
// User may not have npub.cash account
return <CreateAccountPrompt />;
}
Problem: Memory leaks from uncleaned WebSocket subscriptions.
Solution: Always return cleanup function:
useEffect(() => {
const unsubscribe = await syncQuotesWithWallet(coco);
return () => unsubscribe();
}, [coco, syncQuotesWithWallet]);
Problem: npub.cash and wallet use different mints.
Solution: Sync mint URL when user changes it:
const handleMintChange = async (mintUrl: string) => {
await setActiveMintUrl(mintUrl);
if (isNpubCashLoggedIn) {
await setMint(mintUrl); // Sync to npub.cash
}
};
To implement npub.cash static Lightning address functionality:
[email protected])Key principle: This skill is for obtaining static Lightning addresses from npub.cash. Use the lightning-address skill to resolve any Lightning address (including npub.cash addresses) to invoices for sending payments.