fix: use nostr-tools SimplePool+nip04 for NIP-46 relay subscription

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-19 00:28:46 +07:00
parent 61795db997
commit 789a3d7a60

View file

@ -13,7 +13,7 @@ import { cn } from '@/lib/utils';
import { NLogin } from '@nostrify/react/login'; import { NLogin } from '@nostrify/react/login';
import { useNostrLogin } from '@nostrify/react/login'; import { useNostrLogin } from '@nostrify/react/login';
import { NConnectSigner, NSecSigner, NRelay1 } from '@nostrify/nostrify'; 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 { interface LoginDialogProps {
isOpen: boolean; isOpen: boolean;
@ -23,10 +23,11 @@ interface LoginDialogProps {
} }
// Relays used for NIP-46 nostrconnect:// handshake // Relays used for NIP-46 nostrconnect:// handshake
// Use relays known to support ephemeral events (kind 24133)
const NOSTRCONNECT_RELAYS = [ const NOSTRCONNECT_RELAYS = [
'wss://relay.ditto.pub', 'wss://relay.nsec.app',
'wss://relay.nostr.band', 'wss://relay.damus.io',
'wss://nostr.mom', 'wss://relay.primal.net',
]; ];
/** Build a nostrconnect:// URI per NIP-46 spec */ /** Build a nostrconnect:// URI per NIP-46 spec */
@ -66,7 +67,8 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
const [amberStatus, setAmberStatus] = useState(''); const [amberStatus, setAmberStatus] = useState('');
const [nostrConnectUri, setNostrConnectUri] = useState(''); const [nostrConnectUri, setNostrConnectUri] = useState('');
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const relayRefs = useRef<NRelay1[]>([]); const poolRef = useRef<SimplePool | null>(null);
const subRef = useRef<{ close: () => void } | null>(null);
const login = useLoginActions(); const login = useLoginActions();
const { addLogin, setLogin } = useNostrLogin(); const { addLogin, setLogin } = useNostrLogin();
@ -74,20 +76,20 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
const isAndroid = /Android/i.test(navigator.userAgent); const isAndroid = /Android/i.test(navigator.userAgent);
// Cleanup relay connections // Cleanup pool/subscriptions
const cleanupRelays = useCallback(() => { const cleanup = useCallback(() => {
for (const relay of relayRefs.current) { try { subRef.current?.close(); } catch { /* ignore */ }
try { relay.close(); } catch { /* ignore */ } try { poolRef.current?.close(NOSTRCONNECT_RELAYS); } catch { /* ignore */ }
} subRef.current = null;
relayRefs.current = []; poolRef.current = null;
}, []); }, []);
// Reset state when dialog opens/closes // Reset state when dialog closes
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
abortRef.current?.abort(); abortRef.current?.abort();
abortRef.current = null; abortRef.current = null;
cleanupRelays(); cleanup();
setAmberStep('idle'); setAmberStep('idle');
setAmberStatus(''); setAmberStatus('');
setNostrConnectUri(''); setNostrConnectUri('');
@ -95,36 +97,29 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
setBunkerError(''); setBunkerError('');
setIsLoading(false); setIsLoading(false);
} }
}, [isOpen, cleanupRelays]); }, [isOpen, cleanup]);
/** /**
* Amber login via NIP-46 nostrconnect:// flow. * Amber login via NIP-46 nostrconnect:// flow.
* *
* 1. Generate a client keypair + random secret * Uses nostr-tools SimplePool + nip04 for reliable WebSocket subscriptions.
* 2. Build nostrconnect:// URI * This is the same approach used by Ditto, Coracle, Nostrudel.
* 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
*/ */
const startAmberConnect = useCallback(async () => { const startAmberConnect = useCallback(async () => {
// Cancel any previous attempt // Cancel previous attempt
abortRef.current?.abort(); abortRef.current?.abort();
cleanupRelays(); cleanup();
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
// Step 1: Generate ephemeral client keypair // Generate ephemeral client keypair
const clientSk = generateSecretKey(); const clientSk = generateSecretKey();
const clientNsec = nip19.nsecEncode(clientSk);
const clientPubkey = getPublicKey(clientSk); const clientPubkey = getPublicKey(clientSk);
const clientNsec = nip19.nsecEncode(clientSk);
// Step 2: Random secret to prevent spoofing
const secret = randomSecret(); const secret = randomSecret();
// Step 3: Build the nostrconnect URI // Build the nostrconnect URI
const uri = buildNostrConnectURI({ const uri = buildNostrConnectURI({
clientPubkey, clientPubkey,
relays: NOSTRCONNECT_RELAYS, relays: NOSTRCONNECT_RELAYS,
@ -138,115 +133,108 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
setAmberStep('connecting'); setAmberStep('connecting');
setAmberStatus('Opening Amber...'); setAmberStatus('Opening Amber...');
// Step 4: Open Amber. window.open with '_blank' goes through the browser // Open Amber with the nostrconnect:// URI
// intent path on Android, which makes Amber read URL params correctly.
// For nostrconnect:// Amber always handles this as a standard URI scheme.
try { try {
window.open(uri, '_blank'); window.open(uri, '_blank');
} catch { } catch {
// Some environments block window.open; user can copy manually // fallback: user copies manually
} }
setAmberStatus('Waiting for Amber to approve...'); setAmberStatus('Waiting for Amber to approve...');
// Step 5: Listen on relays for Amber's response // Set up SimplePool subscription to listen for Amber's connect response
// Amber sends a kind 24133 event encrypted with NIP-04 to our client pubkey const pool = new SimplePool();
const clientSigner = new NSecSigner(clientSk); poolRef.current = pool;
let resolved = false; const since = Math.floor(Date.now() / 1000) - 5;
const connectPromise = new Promise<{ signerPubkey: string; userPubkey: string }>( const connectPromise = new Promise<{ signerPubkey: string; userPubkey: string }>((resolve, reject) => {
async (resolve, reject) => { // Handle abort
// Listen on abort signal controller.signal.addEventListener('abort', () => {
controller.signal.addEventListener('abort', () => { reject(new DOMException('Aborted', 'AbortError'));
if (!resolved) reject(new DOMException('Aborted', 'AbortError')); });
});
// Connect to all relays in parallel and listen for responses let resolved = false;
const relays = NOSTRCONNECT_RELAYS.map(url => new NRelay1(url));
relayRefs.current = relays;
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 { try {
// Subscribe to kind 24133 events tagged to our client pubkey // Amber encrypts the connect response with NIP-04
for await (const msg of relay.req([{ const decrypted = await nip04.decrypt(clientSk, event.pubkey, event.content);
kinds: [24133], const parsed = JSON.parse(decrypted) as {
'#p': [clientPubkey], id: string;
since, result: string;
limit: 0, error?: string;
}], { signal: controller.signal })) { };
if (msg[0] !== 'EVENT') continue;
const event = msg[2];
if (resolved) break; if (parsed.error && parsed.error !== '') {
resolved = true;
try { reject(new Error(parsed.error));
// Decrypt with NIP-04 (Amber uses NIP-04 for the connect response) return;
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
}
} }
} catch (err) {
if (!resolved && (err as Error)?.name !== 'AbortError') { // Accept either our secret or 'ack' as valid connect response
// Relay connection failed, try others if (parsed.result !== secret && parsed.result !== 'ack') return;
console.warn('[Amber] Relay failed:', relay, err);
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 subRef.current = sub;
setTimeout(() => {
if (!resolved) { // Timeout after 5 minutes
reject(new Error('Timeout waiting for Amber')); setTimeout(() => {
} if (!resolved) {
}, 3 * 60 * 1000); reject(new Error('Timeout — Amber did not respond in 5 minutes'));
} }
); }, 5 * 60 * 1000);
});
try { try {
const { signerPubkey, userPubkey } = await connectPromise; const { signerPubkey, userPubkey } = await connectPromise;
resolved = true;
// Step 7: Create NLogin bunker object and add it cleanup();
// NLogin stores: type="bunker", pubkey=userPubkey, data={ bunkerPubkey, clientNsec, relays }
// Create NLogin bunker object — same structure NLogin.fromBunker() creates
const nlogin = new NLogin('bunker', userPubkey, { const nlogin = new NLogin('bunker', userPubkey, {
bunkerPubkey: signerPubkey, bunkerPubkey: signerPubkey,
clientNsec, clientNsec,
@ -256,12 +244,11 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
addLogin(nlogin); addLogin(nlogin);
setLogin(nlogin.id); setLogin(nlogin.id);
cleanupRelays();
setAmberStep('success'); setAmberStep('success');
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' }); toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
setTimeout(() => { onLogin(); onClose(); }, 800); setTimeout(() => { onLogin(); onClose(); }, 800);
} catch (e: unknown) { } catch (e: unknown) {
cleanupRelays(); cleanup();
if ((e as Error)?.name === 'AbortError') return; if ((e as Error)?.name === 'AbortError') return;
setAmberStep('idle'); setAmberStep('idle');
setAmberStatus(''); setAmberStatus('');
@ -269,12 +256,12 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
toast({ toast({
title: 'Connection failed', title: 'Connection failed',
description: msg.includes('Timeout') description: msg.includes('Timeout')
? 'Amber did not respond. Make sure you approved in Amber, then try again.' ? 'Amber did not respond. Make sure you approved in Amber and try again.'
: `Amber did not connect: ${msg}`, : `Failed: ${msg}`,
variant: 'destructive', variant: 'destructive',
}); });
} }
}, [addLogin, setLogin, cleanupRelays, onLogin, onClose, toast]); }, [addLogin, setLogin, cleanup, onLogin, onClose, toast]);
const handleBunkerLogin = async () => { const handleBunkerLogin = async () => {
const uri = bunkerUri.trim(); const uri = bunkerUri.trim();
@ -310,16 +297,12 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
const copyUri = () => { const copyUri = () => {
navigator.clipboard.writeText(nostrConnectUri).catch(() => {}); 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 = () => { const openAmberWithUri = () => {
if (nostrConnectUri) { if (nostrConnectUri) {
try { try { window.open(nostrConnectUri, '_blank'); } catch { copyUri(); }
window.open(nostrConnectUri, '_blank');
} catch {
copyUri();
}
} }
}; };
@ -386,7 +369,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<div> <div>
<p className='text-sm text-white font-bold'>Login with Amber</p> <p className='text-sm text-white font-bold'>Login with Amber</p>
<p className='text-xs text-gray-400 mt-1'> <p className='text-xs text-gray-400 mt-1'>
Uses NIP-46 — no passwords, no clipboard hacks. Tap below, approve in Amber, done. NIP-46 remote signing. Tap below, approve in Amber, done.
</p> </p>
</div> </div>
<Button <Button
@ -398,14 +381,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
</Button> </Button>
<p className='text-xs text-gray-500'> <p className='text-xs text-gray-500'>
Need Amber?{' '} Need Amber?{' '}
<a <a href="https://github.com/greenart7c3/Amber/releases" target="_blank" rel="noopener noreferrer" className="text-[#ccff00] hover:underline">Download →</a>
href="https://zapstore.dev/apps/naddr1qvzqqqr7pvpzqateqake4lc2fn77lflzq30jfpk8uhvtccalc66989er8cdmljceqqdkxmmd9enhyet9deshyaphvvejumn0wd68yumfvahx2usx8zmj2"
target="_blank"
rel="noopener noreferrer"
className="text-[#ccff00] hover:underline"
>
Get it on Zapstore
</a>
</p> </p>
</div> </div>
)} )}
@ -427,33 +403,29 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2'>
{nostrConnectUri && ( <Button
<> variant="outline"
<Button className='flex-1 rounded-full border-[#ccff00]/30 text-[#ccff00] hover:bg-[#ccff00]/10 text-xs h-auto py-2'
variant="outline" onClick={openAmberWithUri}
className='flex-1 rounded-full border-[#ccff00]/30 text-[#ccff00] hover:bg-[#ccff00]/10 text-xs h-auto py-2' >
onClick={openAmberWithUri} <Smartphone className='w-3 h-3 mr-1' />
> Open Amber
<Smartphone className='w-3 h-3 mr-1' /> </Button>
Open Amber <Button
</Button> variant="outline"
<Button className='flex-1 rounded-full border-gray-700 text-gray-300 hover:bg-gray-800 text-xs h-auto py-2'
variant="outline" onClick={copyUri}
className='flex-1 rounded-full border-gray-700 text-gray-300 hover:bg-gray-800 text-xs h-auto py-2' >
onClick={copyUri} <Copy className='w-3 h-3 mr-1' />
> Copy URI
<Copy className='w-3 h-3 mr-1' /> </Button>
Copy URI
</Button>
</>
)}
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2'>
<Button <Button
variant="ghost" variant="ghost"
className='flex-1 text-xs text-gray-500 hover:text-gray-300 h-auto py-2' className='flex-1 text-xs text-gray-500 hover:text-gray-300 h-auto py-2'
onClick={() => { abortRef.current?.abort(); cleanupRelays(); setAmberStep('idle'); setAmberStatus(''); }} onClick={() => { abortRef.current?.abort(); cleanup(); setAmberStep('idle'); setAmberStatus(''); }}
> >
Cancel Cancel
</Button> </Button>
@ -518,7 +490,6 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
</Button> </Button>
<div className='text-center pt-1'> <div className='text-center pt-1'>
<p className='text-xs text-gray-500 mb-2'>Or try:</p>
<div className='flex gap-2 justify-center flex-wrap'> <div className='flex gap-2 justify-center flex-wrap'>
<a href="https://nsec.app" target="_blank" rel="noopener noreferrer" className="text-xs px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full hover:bg-blue-500/30">nsec.app →</a> <a href="https://nsec.app" target="_blank" rel="noopener noreferrer" className="text-xs px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full hover:bg-blue-500/30">nsec.app →</a>
<a href="https://nostrum.social" target="_blank" rel="noopener noreferrer" className="text-xs px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full hover:bg-blue-500/30">Nostrum →</a> <a href="https://nostrum.social" target="_blank" rel="noopener noreferrer" className="text-xs px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full hover:bg-blue-500/30">Nostrum →</a>
@ -533,7 +504,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<Shield className='w-10 h-10 mx-auto text-[#ff0040]' /> <Shield className='w-10 h-10 mx-auto text-[#ff0040]' />
<div> <div>
<p className='text-sm text-white font-bold'>Browser Extension (NIP-07)</p> <p className='text-sm text-white font-bold'>Browser Extension (NIP-07)</p>
<p className='text-xs text-gray-400 mt-1'>Best for desktop. Works with Alby, nos2x, Flamingo, and other NIP-07 extensions.</p> <p className='text-xs text-gray-400 mt-1'>Best for desktop. Works with Alby, nos2x, Flamingo.</p>
</div> </div>
<Button <Button
className='w-full rounded-full py-3 bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold uppercase' className='w-full rounded-full py-3 bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold uppercase'