From 789a3d7a609c1853387610f05e4316f904ae14b4 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 19 Jun 2026 00:28:46 +0700 Subject: [PATCH] fix: use nostr-tools SimplePool+nip04 for NIP-46 relay subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace NRelay1 async iterator (unreliable) with nostr-tools SimplePool.subscribeMany() using onevent callback — same pattern as Ditto/Coracle/Nostrudel. - Switch to relay.nsec.app, relay.damus.io, relay.primal.net (support ephemeral events) - Use nip04.decrypt() from nostr-tools for Amber's connect response decryption - Proper cleanup of pool and subscription on abort/close - Fall back to signerPubkey as userPubkey if NConnectSigner.getPublicKey() fails Co-authored-by: shakespeare.diy --- src/components/auth/LoginDialog.tsx | 285 +++++++++++++--------------- 1 file changed, 128 insertions(+), 157 deletions(-) diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index ba5ba12..dcece04 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -13,7 +13,7 @@ import { cn } from '@/lib/utils'; import { NLogin } from '@nostrify/react/login'; import { useNostrLogin } from '@nostrify/react/login'; import { NConnectSigner, NSecSigner, NRelay1 } from '@nostrify/nostrify'; -import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; +import { SimplePool, generateSecretKey, getPublicKey, nip19, nip04 } from 'nostr-tools'; interface LoginDialogProps { isOpen: boolean; @@ -23,10 +23,11 @@ interface LoginDialogProps { } // Relays used for NIP-46 nostrconnect:// handshake +// Use relays known to support ephemeral events (kind 24133) const NOSTRCONNECT_RELAYS = [ - 'wss://relay.ditto.pub', - 'wss://relay.nostr.band', - 'wss://nostr.mom', + 'wss://relay.nsec.app', + 'wss://relay.damus.io', + 'wss://relay.primal.net', ]; /** Build a nostrconnect:// URI per NIP-46 spec */ @@ -66,7 +67,8 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog const [amberStatus, setAmberStatus] = useState(''); const [nostrConnectUri, setNostrConnectUri] = useState(''); const abortRef = useRef(null); - const relayRefs = useRef([]); + const poolRef = useRef(null); + const subRef = useRef<{ close: () => void } | null>(null); const login = useLoginActions(); const { addLogin, setLogin } = useNostrLogin(); @@ -74,20 +76,20 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog const isAndroid = /Android/i.test(navigator.userAgent); - // Cleanup relay connections - const cleanupRelays = useCallback(() => { - for (const relay of relayRefs.current) { - try { relay.close(); } catch { /* ignore */ } - } - relayRefs.current = []; + // Cleanup pool/subscriptions + const cleanup = useCallback(() => { + try { subRef.current?.close(); } catch { /* ignore */ } + try { poolRef.current?.close(NOSTRCONNECT_RELAYS); } catch { /* ignore */ } + subRef.current = null; + poolRef.current = null; }, []); - // Reset state when dialog opens/closes + // Reset state when dialog closes useEffect(() => { if (!isOpen) { abortRef.current?.abort(); abortRef.current = null; - cleanupRelays(); + cleanup(); setAmberStep('idle'); setAmberStatus(''); setNostrConnectUri(''); @@ -95,36 +97,29 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog setBunkerError(''); setIsLoading(false); } - }, [isOpen, cleanupRelays]); + }, [isOpen, cleanup]); /** * Amber login via NIP-46 nostrconnect:// flow. * - * 1. Generate a client keypair + random secret - * 2. Build nostrconnect:// URI - * 3. Open Amber (or allow manual copy) - * 4. Listen on relays for Amber's connect response (kind 24133) - * 5. Decrypt, verify secret, extract remote signer pubkey - * 6. Use NConnectSigner to call get_public_key - * 7. Store as NLogin bunker type + * Uses nostr-tools SimplePool + nip04 for reliable WebSocket subscriptions. + * This is the same approach used by Ditto, Coracle, Nostrudel. */ const startAmberConnect = useCallback(async () => { - // Cancel any previous attempt + // Cancel previous attempt abortRef.current?.abort(); - cleanupRelays(); + cleanup(); const controller = new AbortController(); abortRef.current = controller; - // Step 1: Generate ephemeral client keypair + // Generate ephemeral client keypair const clientSk = generateSecretKey(); - const clientNsec = nip19.nsecEncode(clientSk); const clientPubkey = getPublicKey(clientSk); - - // Step 2: Random secret to prevent spoofing + const clientNsec = nip19.nsecEncode(clientSk); const secret = randomSecret(); - // Step 3: Build the nostrconnect URI + // Build the nostrconnect URI const uri = buildNostrConnectURI({ clientPubkey, relays: NOSTRCONNECT_RELAYS, @@ -138,115 +133,108 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog setAmberStep('connecting'); setAmberStatus('Opening Amber...'); - // Step 4: Open Amber. window.open with '_blank' goes through the browser - // intent path on Android, which makes Amber read URL params correctly. - // For nostrconnect:// Amber always handles this as a standard URI scheme. + // Open Amber with the nostrconnect:// URI try { window.open(uri, '_blank'); } catch { - // Some environments block window.open; user can copy manually + // fallback: user copies manually } setAmberStatus('Waiting for Amber to approve...'); - // Step 5: Listen on relays for Amber's response - // Amber sends a kind 24133 event encrypted with NIP-04 to our client pubkey - const clientSigner = new NSecSigner(clientSk); + // Set up SimplePool subscription to listen for Amber's connect response + const pool = new SimplePool(); + poolRef.current = pool; - let resolved = false; + const since = Math.floor(Date.now() / 1000) - 5; - const connectPromise = new Promise<{ signerPubkey: string; userPubkey: string }>( - async (resolve, reject) => { - // Listen on abort signal - controller.signal.addEventListener('abort', () => { - if (!resolved) reject(new DOMException('Aborted', 'AbortError')); - }); + const connectPromise = new Promise<{ signerPubkey: string; userPubkey: string }>((resolve, reject) => { + // Handle abort + controller.signal.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); - // Connect to all relays in parallel and listen for responses - const relays = NOSTRCONNECT_RELAYS.map(url => new NRelay1(url)); - relayRefs.current = relays; + let resolved = false; - const since = Math.floor(Date.now() / 1000) - 10; + // Subscribe using nostr-tools SimplePool — proven reliable + const sub = pool.subscribeMany( + NOSTRCONNECT_RELAYS, + [{ + kinds: [24133], + '#p': [clientPubkey], + since, + }], + { + onevent: async (event) => { + if (resolved) return; - for (const relay of relays) { - (async () => { try { - // Subscribe to kind 24133 events tagged to our client pubkey - for await (const msg of relay.req([{ - kinds: [24133], - '#p': [clientPubkey], - since, - limit: 0, - }], { signal: controller.signal })) { - if (msg[0] !== 'EVENT') continue; - const event = msg[2]; + // Amber encrypts the connect response with NIP-04 + const decrypted = await nip04.decrypt(clientSk, event.pubkey, event.content); + const parsed = JSON.parse(decrypted) as { + id: string; + result: string; + error?: string; + }; - if (resolved) break; - - try { - // Decrypt with NIP-04 (Amber uses NIP-04 for the connect response) - const decrypted = await clientSigner.nip04.decrypt( - event.pubkey, - event.content, - ); - const parsed = JSON.parse(decrypted) as { - id: string; - result: string; - error?: string; - }; - - // Verify this is a connect response with our secret - if (parsed.result !== secret && parsed.result !== 'ack') continue; - if (parsed.error) { - reject(new Error(parsed.error)); - return; - } - - // We found a valid connect response! - const signerPubkey = event.pubkey; - - // Step 6: Use NConnectSigner to get the user's actual pubkey - setAmberStatus('Connected! Getting your pubkey...'); - - const relayForSigning = relay; - const signer = new NConnectSigner({ - pubkey: signerPubkey, - signer: clientSigner, - relay: relayForSigning, - timeout: 30000, - }); - - const userPubkey = await signer.getPublicKey(); - resolved = true; - resolve({ signerPubkey, userPubkey }); - } catch { - // Decryption failed or wrong format — not our event, skip - } + if (parsed.error && parsed.error !== '') { + resolved = true; + reject(new Error(parsed.error)); + return; } - } catch (err) { - if (!resolved && (err as Error)?.name !== 'AbortError') { - // Relay connection failed, try others - console.warn('[Amber] Relay failed:', relay, err); + + // Accept either our secret or 'ack' as valid connect response + if (parsed.result !== secret && parsed.result !== 'ack') return; + + resolved = true; + const signerPubkey = event.pubkey; + + setAmberStatus('Connected! Getting your pubkey...'); + + // Use NConnectSigner to get the actual user pubkey + try { + const clientSigner = new NSecSigner(clientSk); + const relayForSigning = new NRelay1(NOSTRCONNECT_RELAYS[0]); + const signer = new NConnectSigner({ + pubkey: signerPubkey, + signer: clientSigner, + relay: relayForSigning, + timeout: 30000, + }); + const userPubkey = await signer.getPublicKey(); + try { relayForSigning.close(); } catch { /* ignore */ } + resolve({ signerPubkey, userPubkey }); + } catch { + // If NConnectSigner fails, use signerPubkey as userPubkey + // (works for Amber since it signs as the user's key directly) + resolve({ signerPubkey, userPubkey: signerPubkey }); } + } catch { + // Decryption failed — not our event or wrong encryption, skip } - })(); + }, + oneose: () => { + // EOSE received — we're caught up, still waiting for new events + }, } + ); - // Timeout after 3 minutes - setTimeout(() => { - if (!resolved) { - reject(new Error('Timeout waiting for Amber')); - } - }, 3 * 60 * 1000); - } - ); + subRef.current = sub; + + // Timeout after 5 minutes + setTimeout(() => { + if (!resolved) { + reject(new Error('Timeout — Amber did not respond in 5 minutes')); + } + }, 5 * 60 * 1000); + }); try { const { signerPubkey, userPubkey } = await connectPromise; - resolved = true; - // Step 7: Create NLogin bunker object and add it - // NLogin stores: type="bunker", pubkey=userPubkey, data={ bunkerPubkey, clientNsec, relays } + cleanup(); + + // Create NLogin bunker object — same structure NLogin.fromBunker() creates const nlogin = new NLogin('bunker', userPubkey, { bunkerPubkey: signerPubkey, clientNsec, @@ -256,12 +244,11 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog addLogin(nlogin); setLogin(nlogin.id); - cleanupRelays(); setAmberStep('success'); toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' }); setTimeout(() => { onLogin(); onClose(); }, 800); } catch (e: unknown) { - cleanupRelays(); + cleanup(); if ((e as Error)?.name === 'AbortError') return; setAmberStep('idle'); setAmberStatus(''); @@ -269,12 +256,12 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog toast({ title: 'Connection failed', description: msg.includes('Timeout') - ? 'Amber did not respond. Make sure you approved in Amber, then try again.' - : `Amber did not connect: ${msg}`, + ? 'Amber did not respond. Make sure you approved in Amber and try again.' + : `Failed: ${msg}`, variant: 'destructive', }); } - }, [addLogin, setLogin, cleanupRelays, onLogin, onClose, toast]); + }, [addLogin, setLogin, cleanup, onLogin, onClose, toast]); const handleBunkerLogin = async () => { const uri = bunkerUri.trim(); @@ -310,16 +297,12 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog const copyUri = () => { navigator.clipboard.writeText(nostrConnectUri).catch(() => {}); - toast({ title: 'Copied!', description: 'Paste this in Amber → Scan/Paste → Paste connection string' }); + toast({ title: 'Copied!', description: 'Paste in Amber → Scan/Paste link' }); }; const openAmberWithUri = () => { if (nostrConnectUri) { - try { - window.open(nostrConnectUri, '_blank'); - } catch { - copyUri(); - } + try { window.open(nostrConnectUri, '_blank'); } catch { copyUri(); } } }; @@ -386,7 +369,7 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog

Login with Amber

- Uses NIP-46 — no passwords, no clipboard hacks. Tap below, approve in Amber, done. + NIP-46 remote signing. Tap below, approve in Amber, done.

Need Amber?{' '} - - Get it on Zapstore - + Download →

)} @@ -427,33 +403,29 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog
- {nostrConnectUri && ( - <> - - - - )} + +
@@ -518,7 +490,6 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog
-

Or try:

nsec.app → Nostrum → @@ -533,7 +504,7 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog

Browser Extension (NIP-07)

-

Best for desktop. Works with Alby, nos2x, Flamingo, and other NIP-07 extensions.

+

Best for desktop. Works with Alby, nos2x, Flamingo.