Add nostr+connect QR code flow for bunker login (v0.2.1)
- Add buildNostrConnectUrl() and parseNostrConnectUrl() for nostr+connect:// URLs - Add BunkerSigner.awaitSignerConnection() for reverse flow (client waits for signer) - Update BunkerLogin to show QR code that signers like Amber can scan - Add bunkerLoginWithSigner() to NostrProvider for completing login after scan - Support both QR scan mode and paste bunker URL mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "smesh",
|
"name": "smesh",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"description": "A user-friendly Nostr client for exploring relay feeds",
|
"description": "A user-friendly Nostr client for exploring relay feeds",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { ArrowLeft, Loader2, Server } from 'lucide-react'
|
import { BunkerSigner } from '@/providers/NostrProvider/bunker.signer'
|
||||||
import { useState } from 'react'
|
import { ArrowLeft, Loader2, QrCode, Server, Copy, Check } from 'lucide-react'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
|
||||||
|
// Default relay for bunker connections - can be configured
|
||||||
|
const DEFAULT_BUNKER_RELAY = 'wss://relay.nsec.app'
|
||||||
|
|
||||||
export default function BunkerLogin({
|
export default function BunkerLogin({
|
||||||
back,
|
back,
|
||||||
@@ -14,19 +19,82 @@ export default function BunkerLogin({
|
|||||||
onLoginSuccess: () => void
|
onLoginSuccess: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { bunkerLogin } = useNostr()
|
const { bunkerLoginWithSigner } = useNostr()
|
||||||
|
const [mode, setMode] = useState<'choose' | 'scan' | 'paste'>('choose')
|
||||||
const [bunkerUrl, setBunkerUrl] = useState('')
|
const [bunkerUrl, setBunkerUrl] = useState('')
|
||||||
|
const [relayUrl, setRelayUrl] = useState(DEFAULT_BUNKER_RELAY)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [connectUrl, setConnectUrl] = useState<string | null>(null)
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
// Generate QR code when in scan mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'scan') return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const startConnection = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { connectUrl, signer: signerPromise } = await BunkerSigner.awaitSignerConnection(
|
||||||
|
relayUrl,
|
||||||
|
undefined,
|
||||||
|
120000 // 2 minute timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
setConnectUrl(connectUrl)
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
const qr = await QRCode.toDataURL(connectUrl, {
|
||||||
|
width: 256,
|
||||||
|
margin: 2,
|
||||||
|
color: { dark: '#000000', light: '#ffffff' }
|
||||||
|
})
|
||||||
|
setQrDataUrl(qr)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
// Wait for signer to connect
|
||||||
|
const signer = await signerPromise
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
signer.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the user's pubkey from the signer
|
||||||
|
const pubkey = await signer.getPublicKey()
|
||||||
|
|
||||||
|
// Complete login
|
||||||
|
await bunkerLoginWithSigner(signer, pubkey)
|
||||||
|
onLoginSuccess()
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError((err as Error).message)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startConnection()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [mode, relayUrl, bunkerLoginWithSigner, onLoginSuccess])
|
||||||
|
|
||||||
|
const handlePasteSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!bunkerUrl.trim()) {
|
if (!bunkerUrl.trim()) {
|
||||||
setError(t('Please enter a bunker URL'))
|
setError(t('Please enter a bunker URL'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate bunker URL format
|
|
||||||
if (!bunkerUrl.startsWith('bunker://')) {
|
if (!bunkerUrl.startsWith('bunker://')) {
|
||||||
setError(t('Invalid bunker URL format. Must start with bunker://'))
|
setError(t('Invalid bunker URL format. Must start with bunker://'))
|
||||||
return
|
return
|
||||||
@@ -36,6 +104,8 @@ export default function BunkerLogin({
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Use the existing bunkerLogin flow for bunker:// URLs
|
||||||
|
const { bunkerLogin } = useNostr()
|
||||||
await bunkerLogin(bunkerUrl.trim())
|
await bunkerLogin(bunkerUrl.trim())
|
||||||
onLoginSuccess()
|
onLoginSuccess()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -45,19 +115,167 @@ export default function BunkerLogin({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
if (connectUrl) {
|
||||||
|
await navigator.clipboard.writeText(connectUrl)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'choose') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="icon" variant="ghost" className="rounded-full" onClick={back}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Server className="size-5" />
|
||||||
|
<span className="font-semibold">{t('Login with Bunker')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start gap-3 h-auto py-4"
|
||||||
|
onClick={() => setMode('scan')}
|
||||||
|
>
|
||||||
|
<QrCode className="size-6" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium">{t('Show QR Code')}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t('Scan with Amber or another NIP-46 signer')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start gap-3 h-auto py-4"
|
||||||
|
onClick={() => setMode('paste')}
|
||||||
|
>
|
||||||
|
<Server className="size-6" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium">{t('Paste Bunker URL')}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t('Enter a bunker:// URL from your signer')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground space-y-2 pt-2">
|
||||||
|
<p>
|
||||||
|
<strong>{t('What is a bunker?')}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
'A bunker (NIP-46) is a remote signing service that keeps your private key secure while allowing you to sign Nostr events. Your key never leaves the bunker.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'scan') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="icon" variant="ghost" className="rounded-full" onClick={() => setMode('choose')}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<QrCode className="size-5" />
|
||||||
|
<span className="font-semibold">{t('Scan with Signer')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="relayUrl">{t('Relay URL')}</Label>
|
||||||
|
<Input
|
||||||
|
id="relayUrl"
|
||||||
|
type="text"
|
||||||
|
value={relayUrl}
|
||||||
|
onChange={(e) => setRelayUrl(e.target.value)}
|
||||||
|
disabled={loading || !!qrDataUrl}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !qrDataUrl && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrDataUrl && (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div
|
||||||
|
className="relative cursor-pointer rounded-lg overflow-hidden"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
title={t('Click to copy URL')}
|
||||||
|
>
|
||||||
|
<img src={qrDataUrl} alt="Bunker QR Code" className="w-64 h-64" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 hover:opacity-100 transition-opacity">
|
||||||
|
{copied ? (
|
||||||
|
<Check className="size-8 text-white" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-8 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{t('Scan this QR code with Amber or your NIP-46 signer')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-sm text-muted-foreground">{t('Waiting for connection...')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connectUrl && (
|
||||||
|
<div className="w-full">
|
||||||
|
<Label className="text-xs text-muted-foreground">{t('Connection URL')}</Label>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<Input
|
||||||
|
value={connectUrl}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button size="icon" variant="outline" onClick={copyToClipboard}>
|
||||||
|
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="text-sm text-destructive text-center">{error}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste mode
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
|
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button size="icon" variant="ghost" className="rounded-full" onClick={back}>
|
<Button size="icon" variant="ghost" className="rounded-full" onClick={() => setMode('choose')}>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Server className="size-5" />
|
<Server className="size-5" />
|
||||||
<span className="font-semibold">{t('Login with Bunker')}</span>
|
<span className="font-semibold">{t('Paste Bunker URL')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handlePasteSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="bunkerUrl">{t('Bunker URL')}</Label>
|
<Label htmlFor="bunkerUrl">{t('Bunker URL')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -89,17 +307,6 @@ export default function BunkerLogin({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground space-y-2">
|
|
||||||
<p>
|
|
||||||
<strong>{t('What is a bunker?')}</strong>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{t(
|
|
||||||
'A bunker (NIP-46) is a remote signing service that keeps your private key secure while allowing you to sign Nostr events. Your key never leaves the bunker.'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,60 @@ export function parseBunkerUrl(url: string): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a nostr+connect URL (nostr+connect://<relay-url>?pubkey=<client-pubkey>&secret=<secret>).
|
||||||
|
* This is the format that signers (like Amber) scan to connect to a client.
|
||||||
|
*/
|
||||||
|
export function parseNostrConnectUrl(url: string): {
|
||||||
|
relay: string
|
||||||
|
pubkey?: string
|
||||||
|
secret?: string
|
||||||
|
} {
|
||||||
|
if (!url.startsWith('nostr+connect://')) {
|
||||||
|
throw new Error('Invalid nostr+connect URL: must start with nostr+connect://')
|
||||||
|
}
|
||||||
|
|
||||||
|
const withoutPrefix = url.slice('nostr+connect://'.length)
|
||||||
|
const [relayPart, queryPart] = withoutPrefix.split('?')
|
||||||
|
|
||||||
|
if (!relayPart) {
|
||||||
|
throw new Error('Invalid nostr+connect URL: missing relay')
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(queryPart || '')
|
||||||
|
const pubkey = params.get('pubkey') || undefined
|
||||||
|
const secret = params.get('secret') || undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
relay: relayPart,
|
||||||
|
pubkey,
|
||||||
|
secret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a nostr+connect URL for signers to scan.
|
||||||
|
* @param relay - The relay URL (without ws:// prefix, will be added)
|
||||||
|
* @param pubkey - The client's ephemeral pubkey for this session
|
||||||
|
* @param secret - Optional secret for the handshake
|
||||||
|
*/
|
||||||
|
export function buildNostrConnectUrl(relay: string, pubkey: string, secret?: string): string {
|
||||||
|
// Ensure relay URL uses the relay host without protocol
|
||||||
|
let relayHost = relay
|
||||||
|
.replace('wss://', '')
|
||||||
|
.replace('ws://', '')
|
||||||
|
.replace('https://', '')
|
||||||
|
.replace('http://', '')
|
||||||
|
.replace(/\/$/, '')
|
||||||
|
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('pubkey', pubkey)
|
||||||
|
if (secret) {
|
||||||
|
params.set('secret', secret)
|
||||||
|
}
|
||||||
|
return `nostr+connect://${relayHost}?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a bunker URL from components.
|
* Build a bunker URL from components.
|
||||||
*/
|
*/
|
||||||
@@ -118,6 +172,11 @@ export class BunkerSigner implements ISigner {
|
|||||||
private mintUrl: string | null = null
|
private mintUrl: string | null = null
|
||||||
private requestTimeout = 30000 // 30 seconds
|
private requestTimeout = 30000 // 30 seconds
|
||||||
|
|
||||||
|
// Whether we're waiting for signer to connect (reverse flow)
|
||||||
|
private awaitingConnection = false
|
||||||
|
private connectionResolve: ((pubkey: string) => void) | null = null
|
||||||
|
private connectionReject: ((error: Error) => void) | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a BunkerSigner.
|
* Create a BunkerSigner.
|
||||||
* @param bunkerPubkey - The bunker's public key (hex)
|
* @param bunkerPubkey - The bunker's public key (hex)
|
||||||
@@ -134,6 +193,142 @@ export class BunkerSigner implements ISigner {
|
|||||||
this.localPubkey = nGetPublicKey(this.localPrivkey)
|
this.localPubkey = nGetPublicKey(this.localPrivkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a BunkerSigner that waits for a signer (like Amber) to connect.
|
||||||
|
* Returns the nostr+connect URL to display as QR code and a promise for the connected signer.
|
||||||
|
*
|
||||||
|
* @param relayUrl - The relay URL for the connection
|
||||||
|
* @param secret - Optional secret for the handshake
|
||||||
|
* @param timeout - Connection timeout in ms (default 120000 = 2 minutes)
|
||||||
|
*/
|
||||||
|
static async awaitSignerConnection(
|
||||||
|
relayUrl: string,
|
||||||
|
secret?: string,
|
||||||
|
timeout = 120000
|
||||||
|
): Promise<{ connectUrl: string; signer: Promise<BunkerSigner> }> {
|
||||||
|
// Generate ephemeral keypair for this session
|
||||||
|
const localPrivkey = secp256k1.utils.randomPrivateKey()
|
||||||
|
const localPubkey = nGetPublicKey(localPrivkey)
|
||||||
|
|
||||||
|
// Generate secret if not provided
|
||||||
|
const connectionSecret = secret || generateRequestId()
|
||||||
|
|
||||||
|
// Build the nostr+connect URL for signer to scan
|
||||||
|
const connectUrl = buildNostrConnectUrl(relayUrl, localPubkey, connectionSecret)
|
||||||
|
|
||||||
|
// Create signer instance (bunkerPubkey will be set when signer connects)
|
||||||
|
const signer = new BunkerSigner('', [relayUrl], connectionSecret)
|
||||||
|
signer.localPrivkey = localPrivkey
|
||||||
|
signer.localPubkey = localPubkey
|
||||||
|
signer.awaitingConnection = true
|
||||||
|
|
||||||
|
// Return URL immediately, signer promise resolves when connected
|
||||||
|
const signerPromise = new Promise<BunkerSigner>((resolve, reject) => {
|
||||||
|
signer.connectionResolve = (signerPubkey: string) => {
|
||||||
|
signer.bunkerPubkey = signerPubkey
|
||||||
|
signer.remotePubkey = signerPubkey
|
||||||
|
signer.awaitingConnection = false
|
||||||
|
resolve(signer)
|
||||||
|
}
|
||||||
|
signer.connectionReject = reject
|
||||||
|
|
||||||
|
// Set timeout
|
||||||
|
setTimeout(() => {
|
||||||
|
if (signer.awaitingConnection) {
|
||||||
|
signer.disconnect()
|
||||||
|
reject(new Error('Connection timeout waiting for signer'))
|
||||||
|
}
|
||||||
|
}, timeout)
|
||||||
|
|
||||||
|
// Connect to relay and wait
|
||||||
|
signer.connectAndWait(relayUrl).catch(reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { connectUrl, signer: signerPromise }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to relay and wait for signer to initiate connection.
|
||||||
|
*/
|
||||||
|
private async connectAndWait(relayUrl: string): Promise<void> {
|
||||||
|
await this.acquireTokenIfNeeded(relayUrl)
|
||||||
|
await this.connectToRelayAndListen(relayUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to relay and listen for incoming connect requests.
|
||||||
|
*/
|
||||||
|
private async connectToRelayAndListen(relayUrl: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let wsUrl = relayUrl
|
||||||
|
if (relayUrl.startsWith('http://')) {
|
||||||
|
wsUrl = 'ws://' + relayUrl.slice(7)
|
||||||
|
} else if (relayUrl.startsWith('https://')) {
|
||||||
|
wsUrl = 'wss://' + relayUrl.slice(8)
|
||||||
|
} else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
|
||||||
|
wsUrl = 'wss://' + relayUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add token if available
|
||||||
|
if (this.token) {
|
||||||
|
const tokenEncoded = cashuTokenService.encodeToken(this.token)
|
||||||
|
const url = new URL(wsUrl)
|
||||||
|
url.searchParams.set('token', tokenEncoded)
|
||||||
|
wsUrl = url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl)
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
ws.close()
|
||||||
|
reject(new Error('Connection timeout'))
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
this.ws = ws
|
||||||
|
this.connected = true
|
||||||
|
|
||||||
|
// Subscribe to events for our local pubkey
|
||||||
|
const subId = generateRequestId()
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify([
|
||||||
|
'REQ',
|
||||||
|
subId,
|
||||||
|
{
|
||||||
|
kinds: [24133],
|
||||||
|
'#p': [this.localPubkey],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 60
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
reject(new Error('WebSocket error'))
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
this.connected = false
|
||||||
|
this.ws = null
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
this.handleMessage(event.data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the local public key (for displaying in nostr+connect URL).
|
||||||
|
*/
|
||||||
|
getLocalPubkey(): string {
|
||||||
|
return this.localPubkey
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the Cashu token for authentication.
|
* Set the Cashu token for authentication.
|
||||||
*/
|
*/
|
||||||
@@ -359,8 +554,46 @@ export class BunkerSigner implements ISigner {
|
|||||||
try {
|
try {
|
||||||
// Decrypt the content with NIP-04
|
// Decrypt the content with NIP-04
|
||||||
const decrypted = await nip04.decrypt(this.localPrivkey, event.pubkey, event.content)
|
const decrypted = await nip04.decrypt(this.localPrivkey, event.pubkey, event.content)
|
||||||
|
const parsed = JSON.parse(decrypted)
|
||||||
|
|
||||||
const response: NIP46Response = JSON.parse(decrypted)
|
// Check if this is an incoming connect request (signer initiating connection)
|
||||||
|
if (this.awaitingConnection && parsed.method === 'connect') {
|
||||||
|
const request = parsed as NIP46Request
|
||||||
|
console.log('Received connect request from signer:', event.pubkey)
|
||||||
|
|
||||||
|
// Verify secret if we have one
|
||||||
|
if (this.connectionSecret) {
|
||||||
|
const providedSecret = request.params[1] // Second param is the secret
|
||||||
|
if (providedSecret !== this.connectionSecret) {
|
||||||
|
console.warn('Connect request has wrong secret, ignoring')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send ack response
|
||||||
|
const response: NIP46Response = {
|
||||||
|
id: request.id,
|
||||||
|
result: 'ack'
|
||||||
|
}
|
||||||
|
const encrypted = await nip04.encrypt(this.localPrivkey, event.pubkey, JSON.stringify(response))
|
||||||
|
const responseEvent: TDraftEvent = {
|
||||||
|
kind: 24133,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
content: encrypted,
|
||||||
|
tags: [['p', event.pubkey]]
|
||||||
|
}
|
||||||
|
const signedResponse = finalizeEvent(responseEvent, this.localPrivkey)
|
||||||
|
this.ws?.send(JSON.stringify(['EVENT', signedResponse]))
|
||||||
|
|
||||||
|
// Resolve the connection promise
|
||||||
|
if (this.connectionResolve) {
|
||||||
|
this.connectionResolve(event.pubkey)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle as normal response
|
||||||
|
const response = parsed as NIP46Response
|
||||||
const pending = this.pendingRequests.get(response.id)
|
const pending = this.pendingRequests.get(response.id)
|
||||||
|
|
||||||
if (pending) {
|
if (pending) {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ type TNostrContext = {
|
|||||||
nip07Login: () => Promise<string>
|
nip07Login: () => Promise<string>
|
||||||
npubLogin(npub: string): Promise<string>
|
npubLogin(npub: string): Promise<string>
|
||||||
bunkerLogin: (bunkerUrl: string) => Promise<string>
|
bunkerLogin: (bunkerUrl: string) => Promise<string>
|
||||||
|
bunkerLoginWithSigner: (signer: BunkerSigner, pubkey: string) => Promise<string>
|
||||||
removeAccount: (account: TAccountPointer) => void
|
removeAccount: (account: TAccountPointer) => void
|
||||||
/**
|
/**
|
||||||
* Default publish the event to current relays, user's write relays and additional relays
|
* Default publish the event to current relays, user's write relays and additional relays
|
||||||
@@ -544,6 +545,25 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with an already-connected BunkerSigner instance.
|
||||||
|
* Used for the nostr+connect flow where we wait for signer to connect.
|
||||||
|
*/
|
||||||
|
const bunkerLoginWithSigner = async (signer: BunkerSigner, pubkey: string) => {
|
||||||
|
try {
|
||||||
|
return login(signer, {
|
||||||
|
pubkey,
|
||||||
|
signerType: 'bunker',
|
||||||
|
bunkerPubkey: signer.getBunkerPubkey(),
|
||||||
|
bunkerRelays: signer.getRelayUrls(),
|
||||||
|
bunkerSecret: undefined
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('Bunker login failed') + ': ' + (err as Error).message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loginWithAccountPointer = async (act: TAccountPointer): Promise<string | null> => {
|
const loginWithAccountPointer = async (act: TAccountPointer): Promise<string | null> => {
|
||||||
let account = storage.findAccount(act)
|
let account = storage.findAccount(act)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -828,6 +848,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
nip07Login,
|
nip07Login,
|
||||||
npubLogin,
|
npubLogin,
|
||||||
bunkerLogin,
|
bunkerLogin,
|
||||||
|
bunkerLoginWithSigner,
|
||||||
removeAccount,
|
removeAccount,
|
||||||
publish,
|
publish,
|
||||||
attemptDelete,
|
attemptDelete,
|
||||||
|
|||||||
Reference in New Issue
Block a user