Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9343a76bb | ||
|
|
28b8720dbf |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "smesh",
|
"name": "smesh",
|
||||||
"version": "0.4.1",
|
"version": "0.5.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",
|
||||||
|
|||||||
16
resources/open-sats-logo.svg
Normal file
16
resources/open-sats-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 10 KiB |
@@ -51,8 +51,7 @@ import {
|
|||||||
Smartphone,
|
Smartphone,
|
||||||
Download,
|
Download,
|
||||||
Camera,
|
Camera,
|
||||||
Zap,
|
Zap
|
||||||
Coins
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { NRCConnection, RemoteConnection } from '@/services/nrc'
|
import { NRCConnection, RemoteConnection } from '@/services/nrc'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
@@ -68,7 +67,6 @@ export default function NRCSettings() {
|
|||||||
connections,
|
connections,
|
||||||
activeSessions,
|
activeSessions,
|
||||||
rendezvousUrl,
|
rendezvousUrl,
|
||||||
relaySupportsCat,
|
|
||||||
enable,
|
enable,
|
||||||
disable,
|
disable,
|
||||||
addConnection,
|
addConnection,
|
||||||
@@ -415,23 +413,6 @@ export default function NRCSettings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CAT (Cashu Access Token) Status */}
|
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Coins className="w-4 h-4" />
|
|
||||||
<span className="text-sm">{t('CAT Authentication')}</span>
|
|
||||||
</div>
|
|
||||||
{relaySupportsCat ? (
|
|
||||||
<span className="px-2 py-1 bg-primary/10 text-primary rounded text-xs">
|
|
||||||
{t('Available')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="px-2 py-1 bg-muted text-muted-foreground rounded text-xs">
|
|
||||||
{t('Not Available')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connections List */}
|
{/* Connections List */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -465,11 +446,6 @@ export default function NRCSettings() {
|
|||||||
<div className="font-medium truncate">{connection.label}</div>
|
<div className="font-medium truncate">{connection.label}</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{new Date(connection.createdAt).toLocaleDateString()}
|
{new Date(connection.createdAt).toLocaleDateString()}
|
||||||
{connection.useCat && (
|
|
||||||
<span className="ml-2 px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px]">
|
|
||||||
CAT
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import cashuTokenService, { TCashuToken, TokenScope } from '@/services/cashu-token.service'
|
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
|
||||||
import { Clock, Copy, Key, RefreshCw, Shield } from 'lucide-react'
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import QrCode from '../QrCode'
|
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
|
||||||
import * as utils from '@noble/curves/abstract/utils'
|
|
||||||
|
|
||||||
dayjs.extend(relativeTime)
|
|
||||||
|
|
||||||
interface TokenDisplayProps {
|
|
||||||
bunkerPubkey: string
|
|
||||||
mintUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TokenDisplay({ bunkerPubkey, mintUrl }: TokenDisplayProps) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { signHttpAuth, pubkey } = useNostr()
|
|
||||||
const [currentToken, setCurrentToken] = useState<TCashuToken | null>(null)
|
|
||||||
const [nextToken, setNextToken] = useState<TCashuToken | null>(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
|
||||||
|
|
||||||
// Load tokens on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = cashuTokenService.loadTokens(bunkerPubkey)
|
|
||||||
if (stored) {
|
|
||||||
setCurrentToken(stored.current || null)
|
|
||||||
setNextToken(stored.next || null)
|
|
||||||
}
|
|
||||||
}, [bunkerPubkey])
|
|
||||||
|
|
||||||
// Request a new token
|
|
||||||
const requestToken = useCallback(async () => {
|
|
||||||
if (!pubkey) {
|
|
||||||
toast.error(t('You must be logged in to request a token'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
cashuTokenService.setMint(mintUrl)
|
|
||||||
await cashuTokenService.fetchMintInfo()
|
|
||||||
|
|
||||||
const userPubkey = utils.hexToBytes(pubkey)
|
|
||||||
const token = await cashuTokenService.requestToken(
|
|
||||||
TokenScope.NIP46,
|
|
||||||
userPubkey,
|
|
||||||
signHttpAuth,
|
|
||||||
[24133] // NIP-46 kind
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store the token
|
|
||||||
if (currentToken && cashuTokenService.verifyToken(currentToken)) {
|
|
||||||
// Current still valid, store new as next
|
|
||||||
cashuTokenService.storeTokens(bunkerPubkey, currentToken, token)
|
|
||||||
setNextToken(token)
|
|
||||||
} else {
|
|
||||||
// Current expired or missing, use new as current
|
|
||||||
cashuTokenService.storeTokens(bunkerPubkey, token)
|
|
||||||
setCurrentToken(token)
|
|
||||||
setNextToken(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(t('Token obtained successfully'))
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(t('Failed to get token') + ': ' + (err as Error).message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [bunkerPubkey, mintUrl, pubkey, signHttpAuth, currentToken, t])
|
|
||||||
|
|
||||||
// Refresh tokens (promote next to current if needed)
|
|
||||||
const refreshTokens = useCallback(async () => {
|
|
||||||
if (!pubkey) return
|
|
||||||
|
|
||||||
setRefreshing(true)
|
|
||||||
try {
|
|
||||||
// Check if current needs refresh
|
|
||||||
if (currentToken && cashuTokenService.needsRefresh(currentToken)) {
|
|
||||||
// Request a new token as next
|
|
||||||
if (!nextToken) {
|
|
||||||
await requestToken()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Promote next to current if current expired
|
|
||||||
const now = Date.now() / 1000
|
|
||||||
if (currentToken && currentToken.expiry <= now && nextToken) {
|
|
||||||
cashuTokenService.storeTokens(bunkerPubkey, nextToken)
|
|
||||||
setCurrentToken(nextToken)
|
|
||||||
setNextToken(null)
|
|
||||||
toast.info(t('Token rotated'))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setRefreshing(false)
|
|
||||||
}
|
|
||||||
}, [bunkerPubkey, currentToken, nextToken, pubkey, requestToken, t])
|
|
||||||
|
|
||||||
// Copy token to clipboard
|
|
||||||
const copyToken = useCallback(
|
|
||||||
(token: TCashuToken) => {
|
|
||||||
const encoded = cashuTokenService.encodeToken(token)
|
|
||||||
navigator.clipboard.writeText(encoded)
|
|
||||||
toast.success(t('Token copied to clipboard'))
|
|
||||||
},
|
|
||||||
[t]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Format expiry time
|
|
||||||
const formatExpiry = (expiry: number) => {
|
|
||||||
const date = dayjs.unix(expiry)
|
|
||||||
const now = dayjs()
|
|
||||||
if (date.isBefore(now)) {
|
|
||||||
return t('Expired')
|
|
||||||
}
|
|
||||||
return date.fromNow()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if token is expired
|
|
||||||
const isExpired = (token: TCashuToken) => {
|
|
||||||
return token.expiry < Date.now() / 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Shield className="h-5 w-5" />
|
|
||||||
{t('Access Tokens')}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t('Cashu tokens for authenticated bunker access')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
{!currentToken && !nextToken ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Key className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
|
||||||
<p className="text-muted-foreground mb-4">
|
|
||||||
{t('No tokens available. Request one to enable bunker access.')}
|
|
||||||
</p>
|
|
||||||
<Button onClick={requestToken} disabled={loading}>
|
|
||||||
{loading ? t('Requesting...') : t('Request Token')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Tabs defaultValue="current" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="current" className="relative">
|
|
||||||
{t('Current')}
|
|
||||||
{currentToken && isExpired(currentToken) && (
|
|
||||||
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-destructive" />
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="next">
|
|
||||||
{t('Next')}
|
|
||||||
{nextToken && (
|
|
||||||
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-green-500" />
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="current" className="space-y-4">
|
|
||||||
{currentToken ? (
|
|
||||||
<TokenCard
|
|
||||||
token={currentToken}
|
|
||||||
formatExpiry={formatExpiry}
|
|
||||||
isExpired={isExpired(currentToken)}
|
|
||||||
onCopy={() => copyToken(currentToken)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-4 text-muted-foreground">
|
|
||||||
{t('No current token')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="next" className="space-y-4">
|
|
||||||
{nextToken ? (
|
|
||||||
<TokenCard
|
|
||||||
token={nextToken}
|
|
||||||
formatExpiry={formatExpiry}
|
|
||||||
isExpired={isExpired(nextToken)}
|
|
||||||
onCopy={() => copyToken(nextToken)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-4 text-muted-foreground">
|
|
||||||
{t('No pending token. One will be requested before current expires.')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={refreshTokens} disabled={refreshing} className="flex-1">
|
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
|
||||||
{t('Refresh')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={requestToken} disabled={loading} className="flex-1">
|
|
||||||
{loading ? t('Requesting...') : t('Request New Token')}
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Individual token display card
|
|
||||||
function TokenCard({
|
|
||||||
token,
|
|
||||||
formatExpiry,
|
|
||||||
isExpired,
|
|
||||||
onCopy
|
|
||||||
}: {
|
|
||||||
token: TCashuToken
|
|
||||||
formatExpiry: (expiry: number) => string
|
|
||||||
isExpired: boolean
|
|
||||||
onCopy: () => void
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const encoded = cashuTokenService.encodeToken(token)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<QrCode value={encoded} size={200} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground">{t('Scope')}</span>
|
|
||||||
<span className="font-mono">{token.scope}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground">{t('Keyset')}</span>
|
|
||||||
<span className="font-mono text-xs">{token.keysetId}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{t('Expires')}
|
|
||||||
</span>
|
|
||||||
<span className={isExpired ? 'text-destructive' : 'text-green-600'}>
|
|
||||||
{formatExpiry(token.expiry)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{token.kinds && token.kinds.length > 0 && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground">{t('Kinds')}</span>
|
|
||||||
<span className="font-mono text-xs">{token.kinds.join(', ')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="outline" onClick={onCopy} className="w-full">
|
|
||||||
<Copy className="h-4 w-4 mr-2" />
|
|
||||||
{t('Copy Token')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -8,23 +8,17 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
|
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
|
||||||
import { Filter, Event } from 'nostr-tools'
|
import { Filter, Event } from 'nostr-tools'
|
||||||
import * as utils from '@noble/curves/abstract/utils'
|
|
||||||
import { useNostr } from './NostrProvider'
|
import { useNostr } from './NostrProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import indexedDb from '@/services/indexed-db.service'
|
import indexedDb from '@/services/indexed-db.service'
|
||||||
import cashuTokenService, { TokenScope } from '@/services/cashu-token.service'
|
|
||||||
import {
|
import {
|
||||||
NRCConnection,
|
NRCConnection,
|
||||||
CATConfig,
|
|
||||||
NRCListenerConfig,
|
NRCListenerConfig,
|
||||||
generateConnectionURI,
|
generateConnectionURI,
|
||||||
generateCATConnectionURI,
|
|
||||||
getNRCListenerService,
|
getNRCListenerService,
|
||||||
syncFromRemote,
|
syncFromRemote,
|
||||||
testConnection,
|
testConnection,
|
||||||
parseConnectionURI,
|
parseConnectionURI,
|
||||||
relaySupportsCat,
|
|
||||||
deriveMintUrlFromRelay,
|
|
||||||
requestRemoteIDs,
|
requestRemoteIDs,
|
||||||
sendEventsToRemote,
|
sendEventsToRemote,
|
||||||
EventManifestEntry
|
EventManifestEntry
|
||||||
@@ -50,7 +44,6 @@ interface NRCContextType {
|
|||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
connections: NRCConnection[] // Devices authorized to connect to us
|
connections: NRCConnection[] // Devices authorized to connect to us
|
||||||
activeSessions: number
|
activeSessions: number
|
||||||
relaySupportsCat: boolean // Auto-detected CAT support
|
|
||||||
rendezvousUrl: string
|
rendezvousUrl: string
|
||||||
|
|
||||||
// Client State (this device connects to others)
|
// Client State (this device connects to others)
|
||||||
@@ -61,7 +54,7 @@ interface NRCContextType {
|
|||||||
// Listener Actions
|
// Listener Actions
|
||||||
enable: () => Promise<void>
|
enable: () => Promise<void>
|
||||||
disable: () => void
|
disable: () => void
|
||||||
addConnection: (label: string, useCat?: boolean) => Promise<{ uri: string; connection: NRCConnection }>
|
addConnection: (label: string) => Promise<{ uri: string; connection: NRCConnection }>
|
||||||
removeConnection: (id: string) => Promise<void>
|
removeConnection: (id: string) => Promise<void>
|
||||||
getConnectionURI: (connection: NRCConnection) => string
|
getConnectionURI: (connection: NRCConnection) => string
|
||||||
setRendezvousUrl: (url: string) => void
|
setRendezvousUrl: (url: string) => void
|
||||||
@@ -113,9 +106,6 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
return localStorage.getItem(STORAGE_KEY_RENDEZVOUS_URL) || DEFAULT_RENDEZVOUS_URL
|
return localStorage.getItem(STORAGE_KEY_RENDEZVOUS_URL) || DEFAULT_RENDEZVOUS_URL
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-detected CAT support for the rendezvous relay
|
|
||||||
const [relaySupportsCatState, setRelaySupportsCatState] = useState(false)
|
|
||||||
|
|
||||||
const [isListening, setIsListening] = useState(false)
|
const [isListening, setIsListening] = useState(false)
|
||||||
const [isConnected, setIsConnected] = useState(false)
|
const [isConnected, setIsConnected] = useState(false)
|
||||||
const [activeSessions, setActiveSessions] = useState(0)
|
const [activeSessions, setActiveSessions] = useState(0)
|
||||||
@@ -155,32 +145,6 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
localStorage.setItem(STORAGE_KEY_RENDEZVOUS_URL, rendezvousUrl)
|
localStorage.setItem(STORAGE_KEY_RENDEZVOUS_URL, rendezvousUrl)
|
||||||
}, [rendezvousUrl])
|
}, [rendezvousUrl])
|
||||||
|
|
||||||
// Auto-detect CAT support when rendezvous URL changes
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
const checkCatSupport = async () => {
|
|
||||||
try {
|
|
||||||
const supported = await relaySupportsCat(rendezvousUrl)
|
|
||||||
if (!cancelled) {
|
|
||||||
setRelaySupportsCatState(supported)
|
|
||||||
console.log(`[NRC] Relay ${rendezvousUrl} CAT support:`, supported)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setRelaySupportsCatState(false)
|
|
||||||
console.log(`[NRC] Failed to check CAT support for ${rendezvousUrl}:`, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkCatSupport()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [rendezvousUrl])
|
|
||||||
|
|
||||||
// ===== Listener Logic =====
|
// ===== Listener Logic =====
|
||||||
const buildAuthorizedSecrets = useCallback((): Map<string, string> => {
|
const buildAuthorizedSecrets = useCallback((): Map<string, string> => {
|
||||||
const map = new Map<string, string>()
|
const map = new Map<string, string>()
|
||||||
@@ -212,16 +176,10 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
|
|
||||||
const startListener = async () => {
|
const startListener = async () => {
|
||||||
try {
|
try {
|
||||||
// Build CAT config if relay supports it
|
|
||||||
const catConfig: CATConfig | undefined = relaySupportsCatState
|
|
||||||
? { mintUrl: deriveMintUrlFromRelay(rendezvousUrl), scope: 'nrc' }
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const config: NRCListenerConfig = {
|
const config: NRCListenerConfig = {
|
||||||
rendezvousUrl,
|
rendezvousUrl,
|
||||||
signer: client.signer!,
|
signer: client.signer!,
|
||||||
authorizedSecrets: buildAuthorizedSecrets(),
|
authorizedSecrets: buildAuthorizedSecrets()
|
||||||
catConfig
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[NRC] Starting listener with', config.authorizedSecrets.size, 'authorized clients')
|
console.log('[NRC] Starting listener with', config.authorizedSecrets.size, 'authorized clients')
|
||||||
@@ -256,7 +214,7 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
setIsConnected(false)
|
setIsConnected(false)
|
||||||
setActiveSessions(0)
|
setActiveSessions(0)
|
||||||
}
|
}
|
||||||
}, [isEnabled, pubkey, rendezvousUrl, buildAuthorizedSecrets, relaySupportsCatState])
|
}, [isEnabled, pubkey, rendezvousUrl, buildAuthorizedSecrets])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEnabled || !client.signer || !pubkey) return
|
if (!isEnabled || !client.signer || !pubkey) return
|
||||||
@@ -268,40 +226,6 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
// Minimum time between syncs for the same connection: 5 minutes
|
// Minimum time between syncs for the same connection: 5 minutes
|
||||||
const MIN_SYNC_INTERVAL = 5 * 60 * 1000
|
const MIN_SYNC_INTERVAL = 5 * 60 * 1000
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a CAT token for authentication
|
|
||||||
*/
|
|
||||||
const getCATToken = async (mintUrl: string, userPubkey: string): Promise<string | undefined> => {
|
|
||||||
if (!client.signer) return undefined
|
|
||||||
|
|
||||||
try {
|
|
||||||
cashuTokenService.setMint(mintUrl)
|
|
||||||
await cashuTokenService.fetchMintInfo()
|
|
||||||
|
|
||||||
const userPubkeyBytes = utils.hexToBytes(userPubkey)
|
|
||||||
const token = await cashuTokenService.requestToken(
|
|
||||||
TokenScope.NRC,
|
|
||||||
userPubkeyBytes,
|
|
||||||
async (url: string, method: string) => {
|
|
||||||
const authEvent = await client.signer!.signEvent({
|
|
||||||
kind: 27235,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [
|
|
||||||
['u', url],
|
|
||||||
['method', method]
|
|
||||||
],
|
|
||||||
content: ''
|
|
||||||
})
|
|
||||||
return `Nostr ${btoa(JSON.stringify(authEvent))}`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return cashuTokenService.encodeToken(token)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[NRC] Failed to get CAT token:', err)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get local events for sync kinds and build manifest
|
* Get local events for sync kinds and build manifest
|
||||||
*/
|
*/
|
||||||
@@ -410,26 +334,11 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
setIsSyncing(true)
|
setIsSyncing(true)
|
||||||
setSyncProgress({ phase: 'connecting', eventsReceived: 0 })
|
setSyncProgress({ phase: 'connecting', eventsReceived: 0 })
|
||||||
|
|
||||||
// Get CAT token if needed
|
|
||||||
let catToken: string | undefined
|
|
||||||
if (remote.authMode === 'cat' && remote.mintUrl) {
|
|
||||||
catToken = await getCATToken(remote.mintUrl, pubkey)
|
|
||||||
if (!catToken) {
|
|
||||||
console.error(`[NRC] Failed to get CAT token for ${remote.label}, skipping`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const signer = remote.authMode === 'cat' ? client.signer : undefined
|
|
||||||
|
|
||||||
// Step 1: Get remote's event IDs
|
// Step 1: Get remote's event IDs
|
||||||
setSyncProgress({ phase: 'requesting', eventsReceived: 0, message: 'Getting remote event list...' })
|
setSyncProgress({ phase: 'requesting', eventsReceived: 0, message: 'Getting remote event list...' })
|
||||||
const remoteManifest = await requestRemoteIDs(
|
const remoteManifest = await requestRemoteIDs(
|
||||||
remote.uri,
|
remote.uri,
|
||||||
[{ kinds: SYNC_KINDS, limit: 1000 }],
|
[{ kinds: SYNC_KINDS, limit: 1000 }]
|
||||||
undefined,
|
|
||||||
signer,
|
|
||||||
catToken
|
|
||||||
)
|
)
|
||||||
console.log(`[NRC] Remote has ${remoteManifest.length} events`)
|
console.log(`[NRC] Remote has ${remoteManifest.length} events`)
|
||||||
|
|
||||||
@@ -444,22 +353,14 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
let eventsSent = 0
|
let eventsSent = 0
|
||||||
let eventsReceived = 0
|
let eventsReceived = 0
|
||||||
|
|
||||||
// Step 4: Send events remote needs (need new CAT token for new connection)
|
// Step 4: Send events remote needs
|
||||||
if (toSend.length > 0) {
|
if (toSend.length > 0) {
|
||||||
setSyncProgress({ phase: 'sending', eventsReceived: 0, eventsSent: 0, message: `Sending ${toSend.length} events...` })
|
setSyncProgress({ phase: 'sending', eventsReceived: 0, eventsSent: 0, message: `Sending ${toSend.length} events...` })
|
||||||
|
|
||||||
// Get fresh CAT token for sending
|
|
||||||
let sendCatToken = catToken
|
|
||||||
if (remote.authMode === 'cat' && remote.mintUrl) {
|
|
||||||
sendCatToken = await getCATToken(remote.mintUrl, pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
eventsSent = await sendEventsToRemote(
|
eventsSent = await sendEventsToRemote(
|
||||||
remote.uri,
|
remote.uri,
|
||||||
toSend,
|
toSend,
|
||||||
(progress) => setSyncProgress({ ...progress, message: `Sending events... (${progress.eventsSent || 0}/${toSend.length})` }),
|
(progress) => setSyncProgress({ ...progress, message: `Sending events... (${progress.eventsSent || 0}/${toSend.length})` })
|
||||||
signer,
|
|
||||||
sendCatToken
|
|
||||||
)
|
)
|
||||||
console.log(`[NRC] Sent ${eventsSent} events to ${remote.label}`)
|
console.log(`[NRC] Sent ${eventsSent} events to ${remote.label}`)
|
||||||
}
|
}
|
||||||
@@ -468,12 +369,6 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
if (toFetch.length > 0) {
|
if (toFetch.length > 0) {
|
||||||
setSyncProgress({ phase: 'receiving', eventsReceived: 0, eventsSent, message: `Fetching ${toFetch.length} events...` })
|
setSyncProgress({ phase: 'receiving', eventsReceived: 0, eventsSent, message: `Fetching ${toFetch.length} events...` })
|
||||||
|
|
||||||
// Get fresh CAT token for fetching
|
|
||||||
let fetchCatToken = catToken
|
|
||||||
if (remote.authMode === 'cat' && remote.mintUrl) {
|
|
||||||
fetchCatToken = await getCATToken(remote.mintUrl, pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch by ID in batches (relay may limit number of IDs per filter)
|
// Fetch by ID in batches (relay may limit number of IDs per filter)
|
||||||
const BATCH_SIZE = 50
|
const BATCH_SIZE = 50
|
||||||
const fetchedEvents: Event[] = []
|
const fetchedEvents: Event[] = []
|
||||||
@@ -487,16 +382,9 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
...progress,
|
...progress,
|
||||||
eventsSent,
|
eventsSent,
|
||||||
message: `Fetching events... (${fetchedEvents.length + progress.eventsReceived}/${toFetch.length})`
|
message: `Fetching events... (${fetchedEvents.length + progress.eventsReceived}/${toFetch.length})`
|
||||||
}),
|
})
|
||||||
signer,
|
|
||||||
fetchCatToken
|
|
||||||
)
|
)
|
||||||
fetchedEvents.push(...events)
|
fetchedEvents.push(...events)
|
||||||
|
|
||||||
// Get new CAT token for next batch if needed
|
|
||||||
if (remote.authMode === 'cat' && remote.mintUrl && i + BATCH_SIZE < toFetch.length) {
|
|
||||||
fetchCatToken = await getCATToken(remote.mintUrl, pubkey)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store fetched events
|
// Store fetched events
|
||||||
@@ -560,7 +448,7 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const addConnection = useCallback(
|
const addConnection = useCallback(
|
||||||
async (label: string, useCat = false): Promise<{ uri: string; connection: NRCConnection }> => {
|
async (label: string): Promise<{ uri: string; connection: NRCConnection }> => {
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
throw new Error('Not logged in')
|
throw new Error('Not logged in')
|
||||||
}
|
}
|
||||||
@@ -568,36 +456,21 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const createdAt = Date.now()
|
const createdAt = Date.now()
|
||||||
|
|
||||||
let connection: NRCConnection
|
const result = generateConnectionURI(pubkey, rendezvousUrl, undefined, label)
|
||||||
let uri: string
|
const uri = result.uri
|
||||||
|
const connection: NRCConnection = {
|
||||||
// Use CAT if requested AND relay supports it, otherwise fall back to secret-based
|
id,
|
||||||
if (useCat && relaySupportsCatState) {
|
label,
|
||||||
uri = generateCATConnectionURI(pubkey, rendezvousUrl)
|
secret: result.secret,
|
||||||
connection = {
|
clientPubkey: result.clientPubkey,
|
||||||
id,
|
createdAt
|
||||||
label,
|
|
||||||
useCat: true,
|
|
||||||
createdAt
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const result = generateConnectionURI(pubkey, rendezvousUrl, undefined, label)
|
|
||||||
uri = result.uri
|
|
||||||
connection = {
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
secret: result.secret,
|
|
||||||
clientPubkey: result.clientPubkey,
|
|
||||||
useCat: false,
|
|
||||||
createdAt
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setConnections((prev) => [...prev, connection])
|
setConnections((prev) => [...prev, connection])
|
||||||
|
|
||||||
return { uri, connection }
|
return { uri, connection }
|
||||||
},
|
},
|
||||||
[pubkey, rendezvousUrl, relaySupportsCatState]
|
[pubkey, rendezvousUrl]
|
||||||
)
|
)
|
||||||
|
|
||||||
const removeConnection = useCallback(async (id: string) => {
|
const removeConnection = useCallback(async (id: string) => {
|
||||||
@@ -610,23 +483,19 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
throw new Error('Not logged in')
|
throw new Error('Not logged in')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connection.useCat && relaySupportsCatState) {
|
if (!connection.secret) {
|
||||||
return generateCATConnectionURI(pubkey, rendezvousUrl)
|
throw new Error('Connection has no secret')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connection.secret) {
|
const result = generateConnectionURI(
|
||||||
const result = generateConnectionURI(
|
pubkey,
|
||||||
pubkey,
|
rendezvousUrl,
|
||||||
rendezvousUrl,
|
connection.secret,
|
||||||
connection.secret,
|
connection.label
|
||||||
connection.label
|
)
|
||||||
)
|
return result.uri
|
||||||
return result.uri
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Connection has no secret and relay does not support CAT')
|
|
||||||
},
|
},
|
||||||
[pubkey, rendezvousUrl, relaySupportsCatState]
|
[pubkey, rendezvousUrl]
|
||||||
)
|
)
|
||||||
|
|
||||||
const setRendezvousUrl = useCallback((url: string) => {
|
const setRendezvousUrl = useCallback((url: string) => {
|
||||||
@@ -644,9 +513,7 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
uri,
|
uri,
|
||||||
label,
|
label,
|
||||||
relayPubkey: parsed.relayPubkey,
|
relayPubkey: parsed.relayPubkey,
|
||||||
rendezvousUrl: parsed.rendezvousUrl,
|
rendezvousUrl: parsed.rendezvousUrl
|
||||||
authMode: parsed.authMode,
|
|
||||||
mintUrl: parsed.mintUrl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setRemoteConnections((prev) => [...prev, remoteConnection])
|
setRemoteConnections((prev) => [...prev, remoteConnection])
|
||||||
@@ -676,47 +543,10 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
{ kinds: [0, 3, 10000, 10001, 10002, 10003, 10012, 30002], limit: 1000 }
|
{ kinds: [0, 3, 10000, 10001, 10002, 10003, 10012, 30002], limit: 1000 }
|
||||||
]
|
]
|
||||||
|
|
||||||
let catToken: string | undefined
|
|
||||||
|
|
||||||
// For CAT mode, obtain a token from the mint
|
|
||||||
if (remote.authMode === 'cat' && remote.mintUrl && client.signer && pubkey) {
|
|
||||||
console.log('[NRC] CAT mode: obtaining token from mint', remote.mintUrl)
|
|
||||||
try {
|
|
||||||
cashuTokenService.setMint(remote.mintUrl)
|
|
||||||
await cashuTokenService.fetchMintInfo()
|
|
||||||
|
|
||||||
const userPubkeyBytes = utils.hexToBytes(pubkey)
|
|
||||||
const token = await cashuTokenService.requestToken(
|
|
||||||
TokenScope.NRC,
|
|
||||||
userPubkeyBytes,
|
|
||||||
async (url: string, method: string) => {
|
|
||||||
// NIP-98 HTTP auth signing
|
|
||||||
const authEvent = await client.signer!.signEvent({
|
|
||||||
kind: 27235,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [
|
|
||||||
['u', url],
|
|
||||||
['method', method]
|
|
||||||
],
|
|
||||||
content: ''
|
|
||||||
})
|
|
||||||
return `Nostr ${btoa(JSON.stringify(authEvent))}`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
catToken = cashuTokenService.encodeToken(token)
|
|
||||||
console.log('[NRC] CAT token obtained successfully')
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[NRC] Failed to obtain CAT token:', err)
|
|
||||||
throw new Error(`Failed to obtain CAT token: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const events = await syncFromRemote(
|
const events = await syncFromRemote(
|
||||||
remote.uri,
|
remote.uri,
|
||||||
syncFilters,
|
syncFilters,
|
||||||
(progress) => setSyncProgress(progress),
|
(progress) => setSyncProgress(progress)
|
||||||
remote.authMode === 'cat' ? client.signer : undefined,
|
|
||||||
catToken
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store synced events in IndexedDB
|
// Store synced events in IndexedDB
|
||||||
@@ -741,7 +571,7 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
setSyncProgress(null)
|
setSyncProgress(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[remoteConnections, pubkey]
|
[remoteConnections]
|
||||||
)
|
)
|
||||||
|
|
||||||
const syncAllRemotes = useCallback(
|
const syncAllRemotes = useCallback(
|
||||||
@@ -773,44 +603,9 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
setSyncProgress({ phase: 'connecting', eventsReceived: 0, message: 'Testing connection...' })
|
setSyncProgress({ phase: 'connecting', eventsReceived: 0, message: 'Testing connection...' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let catToken: string | undefined
|
|
||||||
|
|
||||||
// For CAT mode, obtain a token from the mint
|
|
||||||
if (remote.authMode === 'cat' && remote.mintUrl && client.signer && pubkey) {
|
|
||||||
console.log('[NRC] CAT mode: obtaining token for test from mint', remote.mintUrl)
|
|
||||||
try {
|
|
||||||
cashuTokenService.setMint(remote.mintUrl)
|
|
||||||
await cashuTokenService.fetchMintInfo()
|
|
||||||
|
|
||||||
const userPubkeyBytes = utils.hexToBytes(pubkey)
|
|
||||||
const token = await cashuTokenService.requestToken(
|
|
||||||
TokenScope.NRC,
|
|
||||||
userPubkeyBytes,
|
|
||||||
async (url: string, method: string) => {
|
|
||||||
const authEvent = await client.signer!.signEvent({
|
|
||||||
kind: 27235,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [
|
|
||||||
['u', url],
|
|
||||||
['method', method]
|
|
||||||
],
|
|
||||||
content: ''
|
|
||||||
})
|
|
||||||
return `Nostr ${btoa(JSON.stringify(authEvent))}`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
catToken = cashuTokenService.encodeToken(token)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[NRC] Failed to obtain CAT token for test:', err)
|
|
||||||
throw new Error(`Failed to obtain CAT token: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await testConnection(
|
const result = await testConnection(
|
||||||
remote.uri,
|
remote.uri,
|
||||||
(progress) => setSyncProgress(progress),
|
(progress) => setSyncProgress(progress)
|
||||||
remote.authMode === 'cat' ? client.signer : undefined,
|
|
||||||
catToken
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update connection to mark it as tested
|
// Update connection to mark it as tested
|
||||||
@@ -826,7 +621,7 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
setSyncProgress(null)
|
setSyncProgress(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[remoteConnections, pubkey]
|
[remoteConnections]
|
||||||
)
|
)
|
||||||
|
|
||||||
const value: NRCContextType = {
|
const value: NRCContextType = {
|
||||||
@@ -836,7 +631,6 @@ export function NRCProvider({ children }: NRCProviderProps) {
|
|||||||
isConnected,
|
isConnected,
|
||||||
connections,
|
connections,
|
||||||
activeSessions,
|
activeSessions,
|
||||||
relaySupportsCat: relaySupportsCatState,
|
|
||||||
rendezvousUrl,
|
rendezvousUrl,
|
||||||
enable,
|
enable,
|
||||||
disable,
|
disable,
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* NIP-46 Bunker Signer with Cashu Token Authentication
|
* NIP-46 Bunker Signer
|
||||||
*
|
*
|
||||||
* Implements remote signing via NIP-46 protocol with Cashu access tokens
|
* Implements remote signing via NIP-46 protocol.
|
||||||
* for authorization. The signer connects to a bunker WebSocket and
|
* The signer connects to a bunker WebSocket and
|
||||||
* requests signing operations.
|
* requests signing operations.
|
||||||
*
|
|
||||||
* Token flow:
|
|
||||||
* 1. Connect to bunker with X-Cashu-Token header
|
|
||||||
* 2. Send NIP-46 requests encrypted with NIP-04
|
|
||||||
* 3. Receive signed events from bunker
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ISigner, TDraftEvent } from '@/types'
|
import { ISigner, TDraftEvent } from '@/types'
|
||||||
import cashuTokenService, { TCashuToken, TokenScope } from '@/services/cashu-token.service'
|
|
||||||
import { relaySupportsCat, deriveMintUrlFromRelay } from '@/services/nrc'
|
|
||||||
import * as utils from '@noble/curves/abstract/utils'
|
import * as utils from '@noble/curves/abstract/utils'
|
||||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||||
import { Event, VerifiedEvent, getPublicKey as nGetPublicKey, nip04, finalizeEvent } from 'nostr-tools'
|
import { Event, VerifiedEvent, getPublicKey as nGetPublicKey, nip04, finalizeEvent } from 'nostr-tools'
|
||||||
@@ -62,13 +55,12 @@ function generateRequestId(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a bunker URL (bunker://<pubkey>?relay=<url>&secret=<secret>&cat=<token>).
|
* Parse a bunker URL (bunker://<pubkey>?relay=<url>&secret=<secret>).
|
||||||
*/
|
*/
|
||||||
export function parseBunkerUrl(url: string): {
|
export function parseBunkerUrl(url: string): {
|
||||||
pubkey: string
|
pubkey: string
|
||||||
relays: string[]
|
relays: string[]
|
||||||
secret?: string
|
secret?: string
|
||||||
catToken?: string
|
|
||||||
} {
|
} {
|
||||||
if (!url.startsWith('bunker://')) {
|
if (!url.startsWith('bunker://')) {
|
||||||
throw new Error('Invalid bunker URL: must start with bunker://')
|
throw new Error('Invalid bunker URL: must start with bunker://')
|
||||||
@@ -84,7 +76,6 @@ export function parseBunkerUrl(url: string): {
|
|||||||
const params = new URLSearchParams(queryPart || '')
|
const params = new URLSearchParams(queryPart || '')
|
||||||
const relays = params.getAll('relay')
|
const relays = params.getAll('relay')
|
||||||
const secret = params.get('secret') || undefined
|
const secret = params.get('secret') || undefined
|
||||||
const catToken = params.get('cat') || undefined
|
|
||||||
|
|
||||||
if (relays.length === 0) {
|
if (relays.length === 0) {
|
||||||
throw new Error('Invalid bunker URL: no relay specified')
|
throw new Error('Invalid bunker URL: no relay specified')
|
||||||
@@ -93,8 +84,7 @@ export function parseBunkerUrl(url: string): {
|
|||||||
return {
|
return {
|
||||||
pubkey: pubkeyPart,
|
pubkey: pubkeyPart,
|
||||||
relays,
|
relays,
|
||||||
secret,
|
secret
|
||||||
catToken
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,8 +164,6 @@ export class BunkerSigner implements ISigner {
|
|||||||
private ws: WebSocket | null = null
|
private ws: WebSocket | null = null
|
||||||
private pendingRequests = new Map<string, PendingRequest>()
|
private pendingRequests = new Map<string, PendingRequest>()
|
||||||
private connected = false
|
private connected = false
|
||||||
private token: TCashuToken | 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)
|
// Whether we're waiting for signer to connect (reverse flow)
|
||||||
@@ -187,22 +175,12 @@ export class BunkerSigner implements ISigner {
|
|||||||
* @param bunkerPubkey - The bunker's public key (hex)
|
* @param bunkerPubkey - The bunker's public key (hex)
|
||||||
* @param relayUrls - Relay URLs to connect to
|
* @param relayUrls - Relay URLs to connect to
|
||||||
* @param connectionSecret - Optional connection secret for initial handshake
|
* @param connectionSecret - Optional connection secret for initial handshake
|
||||||
* @param catToken - Optional CAT token (encoded string) for authorization
|
|
||||||
*/
|
*/
|
||||||
constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string, catToken?: string) {
|
constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string) {
|
||||||
this.bunkerPubkey = bunkerPubkey
|
this.bunkerPubkey = bunkerPubkey
|
||||||
this.relayUrls = relayUrls
|
this.relayUrls = relayUrls
|
||||||
this.connectionSecret = connectionSecret
|
this.connectionSecret = connectionSecret
|
||||||
|
|
||||||
// Decode CAT token if provided
|
|
||||||
if (catToken) {
|
|
||||||
try {
|
|
||||||
this.token = cashuTokenService.decodeToken(catToken)
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to decode CAT token from URL:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate local ephemeral keypair for NIP-46 communication
|
// Generate local ephemeral keypair for NIP-46 communication
|
||||||
this.localPrivkey = secp256k1.utils.randomPrivateKey()
|
this.localPrivkey = secp256k1.utils.randomPrivateKey()
|
||||||
this.localPubkey = nGetPublicKey(this.localPrivkey)
|
this.localPubkey = nGetPublicKey(this.localPrivkey)
|
||||||
@@ -264,7 +242,6 @@ export class BunkerSigner implements ISigner {
|
|||||||
* Connect to relay and wait for signer to initiate connection.
|
* Connect to relay and wait for signer to initiate connection.
|
||||||
*/
|
*/
|
||||||
private async connectAndWait(relayUrl: string): Promise<void> {
|
private async connectAndWait(relayUrl: string): Promise<void> {
|
||||||
await this.acquireTokenIfNeeded(relayUrl)
|
|
||||||
await this.connectToRelayAndListen(relayUrl)
|
await this.connectToRelayAndListen(relayUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,14 +259,6 @@ export class BunkerSigner implements ISigner {
|
|||||||
wsUrl = 'wss://' + relayUrl
|
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 ws = new WebSocket(wsUrl)
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -342,43 +311,10 @@ export class BunkerSigner implements ISigner {
|
|||||||
return this.localPubkey
|
return this.localPubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the Cashu token for authentication.
|
|
||||||
*/
|
|
||||||
setToken(token: TCashuToken) {
|
|
||||||
this.token = token
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the mint URL for token refresh.
|
|
||||||
*/
|
|
||||||
setMintUrl(url: string) {
|
|
||||||
this.mintUrl = url
|
|
||||||
cashuTokenService.setMint(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize connection to the bunker.
|
* Initialize connection to the bunker.
|
||||||
*/
|
*/
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
// Check for stored token
|
|
||||||
const stored = cashuTokenService.loadTokens(this.bunkerPubkey)
|
|
||||||
if (stored?.current && !cashuTokenService.needsRefresh(stored.current)) {
|
|
||||||
this.token = stored.current
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to acquire token for each relay if we don't have one
|
|
||||||
if (!this.token) {
|
|
||||||
for (const relayUrl of this.relayUrls) {
|
|
||||||
try {
|
|
||||||
await this.acquireTokenIfNeeded(relayUrl)
|
|
||||||
if (this.token) break
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Failed to acquire token for ${relayUrl}:`, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to first available relay
|
// Connect to first available relay
|
||||||
for (const relayUrl of this.relayUrls) {
|
for (const relayUrl of this.relayUrls) {
|
||||||
try {
|
try {
|
||||||
@@ -397,67 +333,6 @@ export class BunkerSigner implements ISigner {
|
|||||||
await this.connect()
|
await this.connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if relay supports Cashu tokens and acquire one if so.
|
|
||||||
* Falls back gracefully to regular connection if CAT is not supported.
|
|
||||||
*/
|
|
||||||
private async acquireTokenIfNeeded(relayUrl: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
// First check if relay supports CAT using the NRC helper
|
|
||||||
const supportsCat = await relaySupportsCat(relayUrl)
|
|
||||||
if (!supportsCat) {
|
|
||||||
console.log(`[Bunker] Relay ${relayUrl} does not support CAT, using regular connection`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Bunker] Relay ${relayUrl} supports CAT, acquiring token...`)
|
|
||||||
|
|
||||||
// Derive mint URL from relay URL
|
|
||||||
const mintUrl = deriveMintUrlFromRelay(relayUrl)
|
|
||||||
this.mintUrl = mintUrl
|
|
||||||
cashuTokenService.setMint(mintUrl)
|
|
||||||
|
|
||||||
// Fetch mint info to initialize the service
|
|
||||||
await cashuTokenService.fetchMintInfo()
|
|
||||||
|
|
||||||
// Create NIP-98 auth signer using our local ephemeral key
|
|
||||||
const signHttpAuth = async (url: string, method: string): Promise<string> => {
|
|
||||||
const authEvent: TDraftEvent = {
|
|
||||||
kind: 27235,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
content: '',
|
|
||||||
tags: [
|
|
||||||
['u', url],
|
|
||||||
['method', method]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
const signedAuth = finalizeEvent(authEvent, this.localPrivkey)
|
|
||||||
// Encode as base64url for NIP-98 header
|
|
||||||
const eventJson = JSON.stringify(signedAuth)
|
|
||||||
const base64 = btoa(eventJson)
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=+$/, '')
|
|
||||||
return `Nostr ${base64}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request token with NIP-46 scope
|
|
||||||
const token = await cashuTokenService.requestToken(
|
|
||||||
TokenScope.NIP46,
|
|
||||||
utils.hexToBytes(this.localPubkey),
|
|
||||||
signHttpAuth,
|
|
||||||
[24133] // NIP-46 kind
|
|
||||||
)
|
|
||||||
|
|
||||||
this.token = token
|
|
||||||
cashuTokenService.storeTokens(this.bunkerPubkey, token)
|
|
||||||
console.log(`[Bunker] Acquired CAT token for ${relayUrl}, expires: ${new Date(token.expiry * 1000).toISOString()}`)
|
|
||||||
} catch (err) {
|
|
||||||
// Token acquisition failed - continue without token
|
|
||||||
console.log(`[Bunker] CAT token acquisition failed for ${relayUrl}, using regular connection:`, err instanceof Error ? err.message : err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to a relay WebSocket.
|
* Connect to a relay WebSocket.
|
||||||
*/
|
*/
|
||||||
@@ -473,16 +348,6 @@ export class BunkerSigner implements ISigner {
|
|||||||
wsUrl = 'wss://' + relayUrl
|
wsUrl = 'wss://' + relayUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Cashu token header if available
|
|
||||||
// Note: WebSocket API doesn't support custom headers directly,
|
|
||||||
// so we'll need to pass token as a subprotocol or query param
|
|
||||||
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 ws = new WebSocket(wsUrl)
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -636,16 +501,12 @@ export class BunkerSigner implements ISigner {
|
|||||||
// Encrypt with NIP-04 to the bunker's pubkey
|
// Encrypt with NIP-04 to the bunker's pubkey
|
||||||
const encrypted = await nip04.encrypt(this.localPrivkey, this.bunkerPubkey, JSON.stringify(request))
|
const encrypted = await nip04.encrypt(this.localPrivkey, this.bunkerPubkey, JSON.stringify(request))
|
||||||
|
|
||||||
// Create NIP-46 request event with optional CAT tag
|
// Create NIP-46 request event
|
||||||
const tags: string[][] = [['p', this.bunkerPubkey]]
|
|
||||||
if (this.token) {
|
|
||||||
tags.push(['cat', cashuTokenService.encodeToken(this.token)])
|
|
||||||
}
|
|
||||||
const draftEvent: TDraftEvent = {
|
const draftEvent: TDraftEvent = {
|
||||||
kind: 24133,
|
kind: 24133,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
content: encrypted,
|
content: encrypted,
|
||||||
tags
|
tags: [['p', this.bunkerPubkey]]
|
||||||
}
|
}
|
||||||
|
|
||||||
const signedEvent = finalizeEvent(draftEvent, this.localPrivkey)
|
const signedEvent = finalizeEvent(draftEvent, this.localPrivkey)
|
||||||
@@ -742,47 +603,6 @@ export class BunkerSigner implements ISigner {
|
|||||||
return this.connected
|
return this.connected
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current token.
|
|
||||||
*/
|
|
||||||
getToken(): TCashuToken | null {
|
|
||||||
return this.token
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request a new token from the mint.
|
|
||||||
* Requires a signing function for NIP-98 auth.
|
|
||||||
*/
|
|
||||||
async refreshToken(
|
|
||||||
signHttpAuth: (url: string, method: string) => Promise<string>,
|
|
||||||
userPubkey: Uint8Array
|
|
||||||
): Promise<TCashuToken> {
|
|
||||||
if (!this.mintUrl) {
|
|
||||||
throw new Error('Mint URL not configured')
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await cashuTokenService.requestToken(
|
|
||||||
TokenScope.NIP46,
|
|
||||||
userPubkey,
|
|
||||||
signHttpAuth,
|
|
||||||
[24133] // NIP-46 kind
|
|
||||||
)
|
|
||||||
|
|
||||||
this.token = token
|
|
||||||
|
|
||||||
// Store the new token
|
|
||||||
const existing = cashuTokenService.loadTokens(this.bunkerPubkey)
|
|
||||||
if (existing?.current && cashuTokenService.verifyToken(existing.current)) {
|
|
||||||
// Current still valid, store new as next
|
|
||||||
cashuTokenService.storeTokens(this.bunkerPubkey, existing.current, token)
|
|
||||||
} else {
|
|
||||||
// Current expired or invalid, use new as current
|
|
||||||
cashuTokenService.storeTokens(this.bunkerPubkey, token)
|
|
||||||
}
|
|
||||||
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disconnect from the bunker.
|
* Disconnect from the bunker.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -491,8 +491,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const bunkerLogin = async (bunkerUrl: string) => {
|
const bunkerLogin = async (bunkerUrl: string) => {
|
||||||
try {
|
try {
|
||||||
const { pubkey: bunkerPubkey, relays, secret, catToken } = parseBunkerUrl(bunkerUrl)
|
const { pubkey: bunkerPubkey, relays, secret } = parseBunkerUrl(bunkerUrl)
|
||||||
const bunkerSigner = new BunkerSigner(bunkerPubkey, relays, secret, catToken)
|
const bunkerSigner = new BunkerSigner(bunkerPubkey, relays, secret)
|
||||||
await bunkerSigner.init()
|
await bunkerSigner.init()
|
||||||
const pubkey = await bunkerSigner.getPublicKey()
|
const pubkey = await bunkerSigner.getPublicKey()
|
||||||
return login(bunkerSigner, {
|
return login(bunkerSigner, {
|
||||||
@@ -500,8 +500,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
signerType: 'bunker',
|
signerType: 'bunker',
|
||||||
bunkerPubkey,
|
bunkerPubkey,
|
||||||
bunkerRelays: relays,
|
bunkerRelays: relays,
|
||||||
bunkerSecret: secret,
|
bunkerSecret: secret
|
||||||
bunkerCatToken: catToken
|
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(t('Bunker login failed') + ': ' + (err as Error).message)
|
toast.error(t('Bunker login failed') + ': ' + (err as Error).message)
|
||||||
@@ -578,8 +577,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const bunkerSigner = new BunkerSigner(
|
const bunkerSigner = new BunkerSigner(
|
||||||
account.bunkerPubkey,
|
account.bunkerPubkey,
|
||||||
account.bunkerRelays,
|
account.bunkerRelays,
|
||||||
account.bunkerSecret,
|
account.bunkerSecret
|
||||||
account.bunkerCatToken
|
|
||||||
)
|
)
|
||||||
await bunkerSigner.init()
|
await bunkerSigner.init()
|
||||||
return login(bunkerSigner, account)
|
return login(bunkerSigner, account)
|
||||||
|
|||||||
@@ -1,459 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cashu Token Service
|
|
||||||
*
|
|
||||||
* Manages Cashu access tokens for bunker authentication.
|
|
||||||
* Handles token issuance, storage, and two-token rotation (current + next).
|
|
||||||
*
|
|
||||||
* Token flow:
|
|
||||||
* 1. Generate random secret and blinding factor
|
|
||||||
* 2. Compute blinded message B_ = hash_to_curve(secret) + r*G
|
|
||||||
* 3. Submit B_ to mint with NIP-98 auth
|
|
||||||
* 4. Receive blinded signature C_
|
|
||||||
* 5. Unblind: C = C_ - r*K (where K is mint's pubkey)
|
|
||||||
* 6. Store token {secret, C, keysetId, expiry, ...}
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as utils from '@noble/curves/abstract/utils'
|
|
||||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
|
||||||
import { sha256 } from '@noble/hashes/sha256'
|
|
||||||
|
|
||||||
// Token scopes
|
|
||||||
export const TokenScope = {
|
|
||||||
RELAY: 'relay',
|
|
||||||
NIP46: 'nip46',
|
|
||||||
BLOSSOM: 'blossom',
|
|
||||||
API: 'api',
|
|
||||||
NRC: 'nrc'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type TTokenScope = (typeof TokenScope)[keyof typeof TokenScope]
|
|
||||||
|
|
||||||
// Token format matching ORLY's token.Token
|
|
||||||
export type TCashuToken = {
|
|
||||||
keysetId: string // k - keyset identifier
|
|
||||||
secret: Uint8Array // s - 32-byte random secret
|
|
||||||
signature: Uint8Array // c - 33-byte signature point (compressed)
|
|
||||||
pubkey: Uint8Array // p - 32-byte user pubkey
|
|
||||||
expiry: number // e - Unix timestamp
|
|
||||||
scope: TTokenScope // sc - token scope
|
|
||||||
kinds?: number[] // kinds - permitted event kinds
|
|
||||||
kindRanges?: [number, number][] // kind_ranges - permitted ranges
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keyset info from mint
|
|
||||||
export type TKeysetInfo = {
|
|
||||||
id: string
|
|
||||||
publicKey: string // hex-encoded mint public key
|
|
||||||
active: boolean
|
|
||||||
expiresAt?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mint info
|
|
||||||
export type TMintInfo = {
|
|
||||||
name: string
|
|
||||||
version: string
|
|
||||||
pubkey: string
|
|
||||||
keysets: TKeysetInfo[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blinding result
|
|
||||||
type BlindResult = {
|
|
||||||
B_: Uint8Array // Blinded point (33 bytes compressed)
|
|
||||||
secret: Uint8Array // Original secret
|
|
||||||
r: Uint8Array // Blinding factor scalar
|
|
||||||
}
|
|
||||||
|
|
||||||
// Storage key prefix
|
|
||||||
const STORAGE_PREFIX = 'cashu_token_'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hash a message to a point on secp256k1 using try-and-increment.
|
|
||||||
* Algorithm matches ORLY's Go implementation exactly:
|
|
||||||
* 1. msgHash = SHA256(domain_separator || message)
|
|
||||||
* 2. For counter in 0..65535:
|
|
||||||
* a. counterBytes = counter as 4-byte little-endian
|
|
||||||
* b. hash = SHA256(msgHash || counterBytes)
|
|
||||||
* c. compressed = 0x02 || hash
|
|
||||||
* d. If valid secp256k1 point, return compressed
|
|
||||||
*/
|
|
||||||
function hashToCurve(message: Uint8Array): Uint8Array {
|
|
||||||
const domainSeparator = new TextEncoder().encode('Secp256k1_HashToCurve_Cashu_')
|
|
||||||
const msgHash = sha256(new Uint8Array([...domainSeparator, ...message]))
|
|
||||||
|
|
||||||
// Try incrementing counter until we get a valid point
|
|
||||||
for (let counter = 0; counter < 65536; counter++) {
|
|
||||||
// 4-byte little-endian counter (matching ORLY's binary.LittleEndian.PutUint32)
|
|
||||||
const counterBytes = new Uint8Array(4)
|
|
||||||
new DataView(counterBytes.buffer).setUint32(0, counter, true) // true = little-endian
|
|
||||||
|
|
||||||
// msgHash THEN counterBytes (matching ORLY's append order)
|
|
||||||
const toHash = new Uint8Array([...msgHash, ...counterBytes])
|
|
||||||
const hash = sha256(toHash)
|
|
||||||
|
|
||||||
// Only try 0x02 prefix (even Y coordinate)
|
|
||||||
const compressed = new Uint8Array([0x02, ...hash])
|
|
||||||
try {
|
|
||||||
// Validate this is a valid point
|
|
||||||
const point = secp256k1.ProjectivePoint.fromHex(compressed)
|
|
||||||
if (!point.equals(secp256k1.ProjectivePoint.ZERO)) {
|
|
||||||
return compressed
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Not a valid point, continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Failed to hash to curve after 65536 attempts')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a blinded message from a secret.
|
|
||||||
* B_ = Y + r*G where Y = hash_to_curve(secret)
|
|
||||||
*/
|
|
||||||
function blind(secret: Uint8Array): BlindResult {
|
|
||||||
// Generate random blinding factor r
|
|
||||||
const r = secp256k1.utils.randomPrivateKey()
|
|
||||||
|
|
||||||
// Y = hash_to_curve(secret)
|
|
||||||
const Y = secp256k1.ProjectivePoint.fromHex(hashToCurve(secret))
|
|
||||||
|
|
||||||
// r*G
|
|
||||||
const rG = secp256k1.ProjectivePoint.BASE.multiply(utils.bytesToNumberBE(r))
|
|
||||||
|
|
||||||
// B_ = Y + r*G
|
|
||||||
const B_ = Y.add(rG)
|
|
||||||
|
|
||||||
return {
|
|
||||||
B_: B_.toRawBytes(true), // Compressed format
|
|
||||||
secret,
|
|
||||||
r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unblind the signature to get the final signature.
|
|
||||||
* C = C_ - r*K where K is the mint's public key
|
|
||||||
*/
|
|
||||||
function unblind(C_: Uint8Array, r: Uint8Array, K: Uint8Array): Uint8Array {
|
|
||||||
const C_point = secp256k1.ProjectivePoint.fromHex(C_)
|
|
||||||
const K_point = secp256k1.ProjectivePoint.fromHex(K)
|
|
||||||
|
|
||||||
// r*K
|
|
||||||
const rK = K_point.multiply(utils.bytesToNumberBE(r))
|
|
||||||
|
|
||||||
// C = C_ - r*K
|
|
||||||
const C = C_point.subtract(rK)
|
|
||||||
|
|
||||||
return C.toRawBytes(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify a token signature locally.
|
|
||||||
* Checks that C = k*Y where Y = hash_to_curve(secret) and k is unknown.
|
|
||||||
* We verify using DLEQ proof or by checking C matches our expectations.
|
|
||||||
*/
|
|
||||||
function verifyToken(token: TCashuToken, _mintPubkey: Uint8Array): boolean {
|
|
||||||
try {
|
|
||||||
// Basic validation
|
|
||||||
if (token.expiry < Date.now() / 1000) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify signature is a valid point
|
|
||||||
secp256k1.ProjectivePoint.fromHex(token.signature)
|
|
||||||
|
|
||||||
// Could implement full DLEQ verification here if needed
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode a token to the Cashu format (cashuA prefix + base64url).
|
|
||||||
*/
|
|
||||||
function encodeToken(token: TCashuToken): string {
|
|
||||||
const tokenData = {
|
|
||||||
k: token.keysetId,
|
|
||||||
s: utils.bytesToHex(token.secret),
|
|
||||||
c: utils.bytesToHex(token.signature),
|
|
||||||
p: utils.bytesToHex(token.pubkey),
|
|
||||||
e: token.expiry,
|
|
||||||
sc: token.scope,
|
|
||||||
kinds: token.kinds,
|
|
||||||
kind_ranges: token.kindRanges
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = JSON.stringify(tokenData)
|
|
||||||
// Use base64url encoding
|
|
||||||
const base64 = btoa(json)
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=+$/, '')
|
|
||||||
|
|
||||||
return 'cashuA' + base64
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode a token from the Cashu format.
|
|
||||||
*/
|
|
||||||
function decodeToken(encoded: string): TCashuToken {
|
|
||||||
if (!encoded.startsWith('cashuA')) {
|
|
||||||
throw new Error('Invalid token prefix, expected cashuA')
|
|
||||||
}
|
|
||||||
|
|
||||||
const base64url = encoded.slice(6)
|
|
||||||
// Convert base64url to base64
|
|
||||||
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
|
||||||
// Add padding if needed
|
|
||||||
while (base64.length % 4 !== 0) {
|
|
||||||
base64 += '='
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = atob(base64)
|
|
||||||
const data = JSON.parse(json)
|
|
||||||
|
|
||||||
return {
|
|
||||||
keysetId: data.k,
|
|
||||||
secret: utils.hexToBytes(data.s),
|
|
||||||
signature: utils.hexToBytes(data.c),
|
|
||||||
pubkey: utils.hexToBytes(data.p),
|
|
||||||
expiry: data.e,
|
|
||||||
scope: data.sc,
|
|
||||||
kinds: data.kinds,
|
|
||||||
kindRanges: data.kind_ranges
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cashu Token Service - manages token lifecycle for bunker auth.
|
|
||||||
*/
|
|
||||||
class CashuTokenService {
|
|
||||||
private mintUrl: string | null = null
|
|
||||||
private mintPubkey: Uint8Array | null = null
|
|
||||||
private activeKeysetId: string | null = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure the mint endpoint.
|
|
||||||
*/
|
|
||||||
setMint(url: string) {
|
|
||||||
this.mintUrl = url.replace(/\/$/, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch mint info and keysets.
|
|
||||||
*/
|
|
||||||
async fetchMintInfo(): Promise<TMintInfo> {
|
|
||||||
if (!this.mintUrl) {
|
|
||||||
throw new Error('Mint URL not configured')
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${this.mintUrl}/cashu/info`)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch mint info: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = await response.json()
|
|
||||||
this.mintPubkey = utils.hexToBytes(info.pubkey)
|
|
||||||
|
|
||||||
// Also fetch keysets
|
|
||||||
const keysetsResponse = await fetch(`${this.mintUrl}/cashu/keysets`)
|
|
||||||
if (keysetsResponse.ok) {
|
|
||||||
const keysetsData = await keysetsResponse.json()
|
|
||||||
info.keysets = keysetsData.keysets
|
|
||||||
|
|
||||||
// Find active keyset
|
|
||||||
const active = keysetsData.keysets.find((k: TKeysetInfo) => k.active)
|
|
||||||
if (active) {
|
|
||||||
this.activeKeysetId = active.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request a new token from the mint.
|
|
||||||
* Requires NIP-98 auth via the signHttpAuth function.
|
|
||||||
*/
|
|
||||||
async requestToken(
|
|
||||||
scope: TTokenScope,
|
|
||||||
userPubkey: Uint8Array,
|
|
||||||
signHttpAuth: (url: string, method: string) => Promise<string>,
|
|
||||||
kinds?: number[],
|
|
||||||
kindRanges?: [number, number][]
|
|
||||||
): Promise<TCashuToken> {
|
|
||||||
if (!this.mintUrl) {
|
|
||||||
throw new Error('Mint URL not configured')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate secret and blind it
|
|
||||||
const secret = crypto.getRandomValues(new Uint8Array(32))
|
|
||||||
const blindResult = blind(secret)
|
|
||||||
|
|
||||||
// Create request
|
|
||||||
const requestBody = {
|
|
||||||
blinded_message: utils.bytesToHex(blindResult.B_),
|
|
||||||
scope,
|
|
||||||
kinds,
|
|
||||||
kind_ranges: kindRanges
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get NIP-98 auth header
|
|
||||||
const authUrl = `${this.mintUrl}/cashu/mint`
|
|
||||||
const authHeader = await signHttpAuth(authUrl, 'POST')
|
|
||||||
|
|
||||||
// Submit to mint
|
|
||||||
const response = await fetch(authUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: authHeader
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text()
|
|
||||||
throw new Error(`Mint request failed: ${error}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
// Unblind the signature
|
|
||||||
const C_ = utils.hexToBytes(result.blinded_signature)
|
|
||||||
const K = utils.hexToBytes(result.mint_pubkey)
|
|
||||||
const signature = unblind(C_, blindResult.r, K)
|
|
||||||
|
|
||||||
const token: TCashuToken = {
|
|
||||||
keysetId: result.keyset_id,
|
|
||||||
secret: blindResult.secret,
|
|
||||||
signature,
|
|
||||||
pubkey: userPubkey,
|
|
||||||
expiry: result.expiry,
|
|
||||||
scope,
|
|
||||||
kinds,
|
|
||||||
kindRanges
|
|
||||||
}
|
|
||||||
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store tokens for a specific bunker.
|
|
||||||
* Maintains current and next token for rotation.
|
|
||||||
*/
|
|
||||||
storeTokens(bunkerPubkey: string, current: TCashuToken, next?: TCashuToken) {
|
|
||||||
const key = `${STORAGE_PREFIX}${bunkerPubkey}`
|
|
||||||
const data = {
|
|
||||||
current: encodeToken(current),
|
|
||||||
next: next ? encodeToken(next) : undefined,
|
|
||||||
storedAt: Date.now()
|
|
||||||
}
|
|
||||||
localStorage.setItem(key, JSON.stringify(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load tokens for a specific bunker.
|
|
||||||
*/
|
|
||||||
loadTokens(bunkerPubkey: string): { current?: TCashuToken; next?: TCashuToken } | null {
|
|
||||||
const key = `${STORAGE_PREFIX}${bunkerPubkey}`
|
|
||||||
const stored = localStorage.getItem(key)
|
|
||||||
if (!stored) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(stored)
|
|
||||||
return {
|
|
||||||
current: data.current ? decodeToken(data.current) : undefined,
|
|
||||||
next: data.next ? decodeToken(data.next) : undefined
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the active token for a bunker, handling rotation if needed.
|
|
||||||
*/
|
|
||||||
getActiveToken(bunkerPubkey: string): TCashuToken | null {
|
|
||||||
const tokens = this.loadTokens(bunkerPubkey)
|
|
||||||
if (!tokens) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now() / 1000
|
|
||||||
|
|
||||||
// If current is valid, use it
|
|
||||||
if (tokens.current && tokens.current.expiry > now) {
|
|
||||||
return tokens.current
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current expired, try to promote next
|
|
||||||
if (tokens.next && tokens.next.expiry > now) {
|
|
||||||
// Promote next to current
|
|
||||||
this.storeTokens(bunkerPubkey, tokens.next)
|
|
||||||
return tokens.next
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both expired
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if token needs refresh (< 1 day until expiry).
|
|
||||||
*/
|
|
||||||
needsRefresh(token: TCashuToken): boolean {
|
|
||||||
const now = Date.now() / 1000
|
|
||||||
const oneDaySeconds = 24 * 60 * 60
|
|
||||||
return token.expiry - now < oneDaySeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear tokens for a bunker.
|
|
||||||
*/
|
|
||||||
clearTokens(bunkerPubkey: string) {
|
|
||||||
const key = `${STORAGE_PREFIX}${bunkerPubkey}`
|
|
||||||
localStorage.removeItem(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode a token for transmission (e.g., in headers).
|
|
||||||
*/
|
|
||||||
encodeToken(token: TCashuToken): string {
|
|
||||||
return encodeToken(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode a token string.
|
|
||||||
*/
|
|
||||||
decodeToken(encoded: string): TCashuToken {
|
|
||||||
return decodeToken(encoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify a token is valid.
|
|
||||||
*/
|
|
||||||
verifyToken(token: TCashuToken): boolean {
|
|
||||||
if (!this.mintPubkey) {
|
|
||||||
// Can't verify without mint pubkey, assume valid if not expired
|
|
||||||
return token.expiry > Date.now() / 1000
|
|
||||||
}
|
|
||||||
return verifyToken(token, this.mintPubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the active keyset ID.
|
|
||||||
*/
|
|
||||||
getActiveKeysetId(): string | null {
|
|
||||||
return this.activeKeysetId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
const cashuTokenService = new CashuTokenService()
|
|
||||||
export default cashuTokenService
|
|
||||||
|
|
||||||
// Export utilities
|
|
||||||
export { encodeToken, decodeToken, hashToCurve, blind, unblind }
|
|
||||||
@@ -10,7 +10,6 @@ import { Event, Filter } from 'nostr-tools'
|
|||||||
import * as nip44 from 'nostr-tools/nip44'
|
import * as nip44 from 'nostr-tools/nip44'
|
||||||
import * as utils from '@noble/curves/abstract/utils'
|
import * as utils from '@noble/curves/abstract/utils'
|
||||||
import { finalizeEvent } from 'nostr-tools'
|
import { finalizeEvent } from 'nostr-tools'
|
||||||
import { ISigner } from '@/types'
|
|
||||||
import {
|
import {
|
||||||
KIND_NRC_REQUEST,
|
KIND_NRC_REQUEST,
|
||||||
KIND_NRC_RESPONSE,
|
KIND_NRC_RESPONSE,
|
||||||
@@ -55,8 +54,6 @@ export interface RemoteConnection {
|
|||||||
label: string
|
label: string
|
||||||
relayPubkey: string
|
relayPubkey: string
|
||||||
rendezvousUrl: string
|
rendezvousUrl: string
|
||||||
authMode: 'secret' | 'cat'
|
|
||||||
mintUrl?: string // For CAT mode
|
|
||||||
lastSync?: number
|
lastSync?: number
|
||||||
eventCount?: number
|
eventCount?: number
|
||||||
}
|
}
|
||||||
@@ -87,16 +84,10 @@ export class NRCClient {
|
|||||||
private chunkBuffers: Map<string, ChunkBuffer> = new Map()
|
private chunkBuffers: Map<string, ChunkBuffer> = new Map()
|
||||||
private syncTimeout: ReturnType<typeof setTimeout> | null = null
|
private syncTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
private lastActivityTime: number = 0
|
private lastActivityTime: number = 0
|
||||||
// CAT mode fields
|
|
||||||
private signer?: ISigner
|
|
||||||
private catToken?: string
|
|
||||||
private clientPubkey?: string
|
|
||||||
|
|
||||||
constructor(connectionUri: string, signer?: ISigner, catToken?: string) {
|
constructor(connectionUri: string) {
|
||||||
this.uri = parseConnectionURI(connectionUri)
|
this.uri = parseConnectionURI(connectionUri)
|
||||||
this.sessionId = generateSessionId()
|
this.sessionId = generateSessionId()
|
||||||
this.signer = signer
|
|
||||||
this.catToken = catToken
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,12 +117,6 @@ export class NRCClient {
|
|||||||
this.chunkBuffers.clear()
|
this.chunkBuffers.clear()
|
||||||
this.lastActivityTime = Date.now()
|
this.lastActivityTime = Date.now()
|
||||||
|
|
||||||
// For CAT mode, get our pubkey from the signer
|
|
||||||
if (this.uri.authMode === 'cat' && this.signer) {
|
|
||||||
this.clientPubkey = await this.signer.getPublicKey()
|
|
||||||
console.log(`[NRC Client] CAT mode, our pubkey: ${this.clientPubkey?.slice(0, 8)}...`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<Event[]>((resolve, reject) => {
|
return new Promise<Event[]>((resolve, reject) => {
|
||||||
this.resolveSync = resolve
|
this.resolveSync = resolve
|
||||||
this.rejectSync = reject
|
this.rejectSync = reject
|
||||||
@@ -189,11 +174,6 @@ export class NRCClient {
|
|||||||
this.lastActivityTime = Date.now()
|
this.lastActivityTime = Date.now()
|
||||||
this.idsMode = true
|
this.idsMode = true
|
||||||
|
|
||||||
// For CAT mode, get our pubkey from the signer
|
|
||||||
if (this.uri.authMode === 'cat' && this.signer) {
|
|
||||||
this.clientPubkey = await this.signer.getPublicKey()
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<EventManifestEntry[]>((resolve, reject) => {
|
return new Promise<EventManifestEntry[]>((resolve, reject) => {
|
||||||
this.resolveIDs = resolve
|
this.resolveIDs = resolve
|
||||||
this.rejectIDs = reject
|
this.rejectIDs = reject
|
||||||
@@ -259,11 +239,6 @@ export class NRCClient {
|
|||||||
this.eventsSentCount = 0
|
this.eventsSentCount = 0
|
||||||
this.eventsToSend = [...events]
|
this.eventsToSend = [...events]
|
||||||
|
|
||||||
// For CAT mode, get our pubkey from the signer
|
|
||||||
if (this.uri.authMode === 'cat' && this.signer) {
|
|
||||||
this.clientPubkey = await this.signer.getPublicKey()
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<number>((resolve, reject) => {
|
return new Promise<number>((resolve, reject) => {
|
||||||
this.resolveSend = resolve
|
this.resolveSend = resolve
|
||||||
|
|
||||||
@@ -379,8 +354,7 @@ export class NRCClient {
|
|||||||
|
|
||||||
// Subscribe to responses for our client pubkey
|
// Subscribe to responses for our client pubkey
|
||||||
const responseSubId = generateSubId()
|
const responseSubId = generateSubId()
|
||||||
// Use CAT-mode pubkey if available, otherwise use secret-derived pubkey
|
const clientPubkey = this.uri.clientPubkey
|
||||||
const clientPubkey = this.clientPubkey || this.uri.clientPubkey
|
|
||||||
|
|
||||||
if (!clientPubkey) {
|
if (!clientPubkey) {
|
||||||
reject(new Error('Client pubkey not available'))
|
reject(new Error('Client pubkey not available'))
|
||||||
@@ -461,69 +435,35 @@ export class NRCClient {
|
|||||||
throw new Error('Not connected')
|
throw new Error('Not connected')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.uri.clientPrivkey || !this.uri.clientPubkey) {
|
||||||
|
throw new Error('Missing keys')
|
||||||
|
}
|
||||||
|
|
||||||
const plaintext = JSON.stringify(request)
|
const plaintext = JSON.stringify(request)
|
||||||
let encrypted: string
|
|
||||||
let signedEvent: Event
|
|
||||||
|
|
||||||
if (this.uri.authMode === 'cat' && this.signer && this.clientPubkey) {
|
// Derive conversation key
|
||||||
// CAT mode: use signer for encryption and signing
|
const conversationKey = deriveConversationKey(
|
||||||
if (!this.signer.nip44Encrypt) {
|
this.uri.clientPrivkey,
|
||||||
throw new Error('Signer does not support NIP-44 encryption')
|
this.uri.relayPubkey
|
||||||
}
|
)
|
||||||
|
|
||||||
encrypted = await this.signer.nip44Encrypt(this.uri.relayPubkey, plaintext)
|
const encrypted = nip44.v2.encrypt(plaintext, conversationKey)
|
||||||
|
|
||||||
// Build the request event with CAT token
|
// Build the request event
|
||||||
const tags: string[][] = [
|
const unsignedEvent = {
|
||||||
|
kind: KIND_NRC_REQUEST,
|
||||||
|
content: encrypted,
|
||||||
|
tags: [
|
||||||
['p', this.uri.relayPubkey],
|
['p', this.uri.relayPubkey],
|
||||||
['encryption', 'nip44_v2'],
|
['encryption', 'nip44_v2'],
|
||||||
['session', this.sessionId]
|
['session', this.sessionId]
|
||||||
]
|
],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
// Add CAT token if available
|
pubkey: this.uri.clientPubkey
|
||||||
if (this.catToken) {
|
|
||||||
tags.push(['cashu', this.catToken])
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsignedEvent = {
|
|
||||||
kind: KIND_NRC_REQUEST,
|
|
||||||
content: encrypted,
|
|
||||||
tags,
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
signedEvent = await this.signer.signEvent(unsignedEvent)
|
|
||||||
console.log(`[NRC Client] CAT mode: Sent encrypted REQ with CAT token`)
|
|
||||||
} else {
|
|
||||||
// Secret mode: use derived keys
|
|
||||||
if (!this.uri.clientPrivkey || !this.uri.clientPubkey) {
|
|
||||||
throw new Error('Missing keys for secret mode')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive conversation key
|
|
||||||
const conversationKey = deriveConversationKey(
|
|
||||||
this.uri.clientPrivkey,
|
|
||||||
this.uri.relayPubkey
|
|
||||||
)
|
|
||||||
|
|
||||||
encrypted = nip44.v2.encrypt(plaintext, conversationKey)
|
|
||||||
|
|
||||||
// Build the request event
|
|
||||||
const unsignedEvent = {
|
|
||||||
kind: KIND_NRC_REQUEST,
|
|
||||||
content: encrypted,
|
|
||||||
tags: [
|
|
||||||
['p', this.uri.relayPubkey],
|
|
||||||
['encryption', 'nip44_v2'],
|
|
||||||
['session', this.sessionId]
|
|
||||||
],
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
pubkey: this.uri.clientPubkey
|
|
||||||
}
|
|
||||||
|
|
||||||
signedEvent = finalizeEvent(unsignedEvent, this.uri.clientPrivkey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const signedEvent = finalizeEvent(unsignedEvent, this.uri.clientPrivkey)
|
||||||
|
|
||||||
// Send to rendezvous relay
|
// Send to rendezvous relay
|
||||||
this.ws.send(JSON.stringify(['EVENT', signedEvent]))
|
this.ws.send(JSON.stringify(['EVENT', signedEvent]))
|
||||||
console.log(`[NRC Client] Sent encrypted REQ, event id: ${signedEvent.id?.slice(0, 8)}..., p-tag: ${this.uri.relayPubkey?.slice(0, 8)}...`)
|
console.log(`[NRC Client] Sent encrypted REQ, event id: ${signedEvent.id?.slice(0, 8)}..., p-tag: ${this.uri.relayPubkey?.slice(0, 8)}...`)
|
||||||
@@ -578,27 +518,16 @@ export class NRCClient {
|
|||||||
* Decrypt and process a response event
|
* Decrypt and process a response event
|
||||||
*/
|
*/
|
||||||
private async decryptAndProcessResponse(event: Event): Promise<void> {
|
private async decryptAndProcessResponse(event: Event): Promise<void> {
|
||||||
let plaintext: string
|
if (!this.uri.clientPrivkey) {
|
||||||
|
throw new Error('Missing private key for decryption')
|
||||||
if (this.uri.authMode === 'cat' && this.signer) {
|
|
||||||
// CAT mode: use signer for decryption
|
|
||||||
if (!this.signer.nip44Decrypt) {
|
|
||||||
throw new Error('Signer does not support NIP-44 decryption')
|
|
||||||
}
|
|
||||||
plaintext = await this.signer.nip44Decrypt(event.pubkey, event.content)
|
|
||||||
} else {
|
|
||||||
// Secret mode: use derived key
|
|
||||||
if (!this.uri.clientPrivkey) {
|
|
||||||
throw new Error('Missing private key for decryption')
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversationKey = deriveConversationKey(
|
|
||||||
this.uri.clientPrivkey,
|
|
||||||
this.uri.relayPubkey
|
|
||||||
)
|
|
||||||
plaintext = nip44.v2.decrypt(event.content, conversationKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conversationKey = deriveConversationKey(
|
||||||
|
this.uri.clientPrivkey,
|
||||||
|
this.uri.relayPubkey
|
||||||
|
)
|
||||||
|
const plaintext = nip44.v2.decrypt(event.content, conversationKey)
|
||||||
|
|
||||||
const response: ResponseMessage = JSON.parse(plaintext)
|
const response: ResponseMessage = JSON.parse(plaintext)
|
||||||
console.log(`[NRC Client] Received response: ${response.type}`)
|
console.log(`[NRC Client] Received response: ${response.type}`)
|
||||||
|
|
||||||
@@ -787,18 +716,14 @@ export class NRCClient {
|
|||||||
* @param connectionUri - The nostr+relayconnect:// URI
|
* @param connectionUri - The nostr+relayconnect:// URI
|
||||||
* @param filters - Nostr filters for events to sync
|
* @param filters - Nostr filters for events to sync
|
||||||
* @param onProgress - Optional progress callback
|
* @param onProgress - Optional progress callback
|
||||||
* @param signer - Optional signer for CAT mode
|
|
||||||
* @param catToken - Optional CAT token for CAT mode
|
|
||||||
* @returns Array of synced events
|
* @returns Array of synced events
|
||||||
*/
|
*/
|
||||||
export async function syncFromRemote(
|
export async function syncFromRemote(
|
||||||
connectionUri: string,
|
connectionUri: string,
|
||||||
filters: Filter[],
|
filters: Filter[],
|
||||||
onProgress?: (progress: SyncProgress) => void,
|
onProgress?: (progress: SyncProgress) => void
|
||||||
signer?: ISigner,
|
|
||||||
catToken?: string
|
|
||||||
): Promise<Event[]> {
|
): Promise<Event[]> {
|
||||||
const client = new NRCClient(connectionUri, signer, catToken)
|
const client = new NRCClient(connectionUri)
|
||||||
return client.sync(filters, onProgress)
|
return client.sync(filters, onProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,17 +733,13 @@ export async function syncFromRemote(
|
|||||||
*
|
*
|
||||||
* @param connectionUri - The nostr+relayconnect:// URI
|
* @param connectionUri - The nostr+relayconnect:// URI
|
||||||
* @param onProgress - Optional progress callback
|
* @param onProgress - Optional progress callback
|
||||||
* @param signer - Optional signer for CAT mode
|
|
||||||
* @param catToken - Optional CAT token for CAT mode
|
|
||||||
* @returns true if connection successful
|
* @returns true if connection successful
|
||||||
*/
|
*/
|
||||||
export async function testConnection(
|
export async function testConnection(
|
||||||
connectionUri: string,
|
connectionUri: string,
|
||||||
onProgress?: (progress: SyncProgress) => void,
|
onProgress?: (progress: SyncProgress) => void
|
||||||
signer?: ISigner,
|
|
||||||
catToken?: string
|
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const client = new NRCClient(connectionUri, signer, catToken)
|
const client = new NRCClient(connectionUri)
|
||||||
try {
|
try {
|
||||||
// Request just one profile event to test the full round-trip
|
// Request just one profile event to test the full round-trip
|
||||||
const events = await client.sync(
|
const events = await client.sync(
|
||||||
@@ -840,18 +761,14 @@ export async function testConnection(
|
|||||||
* @param connectionUri - The nostr+relayconnect:// URI
|
* @param connectionUri - The nostr+relayconnect:// URI
|
||||||
* @param filters - Filters to match events
|
* @param filters - Filters to match events
|
||||||
* @param onProgress - Optional progress callback
|
* @param onProgress - Optional progress callback
|
||||||
* @param signer - Optional signer for CAT mode
|
|
||||||
* @param catToken - Optional CAT token for CAT mode
|
|
||||||
* @returns Array of event manifest entries (id, kind, created_at, d)
|
* @returns Array of event manifest entries (id, kind, created_at, d)
|
||||||
*/
|
*/
|
||||||
export async function requestRemoteIDs(
|
export async function requestRemoteIDs(
|
||||||
connectionUri: string,
|
connectionUri: string,
|
||||||
filters: Filter[],
|
filters: Filter[],
|
||||||
onProgress?: (progress: SyncProgress) => void,
|
onProgress?: (progress: SyncProgress) => void
|
||||||
signer?: ISigner,
|
|
||||||
catToken?: string
|
|
||||||
): Promise<EventManifestEntry[]> {
|
): Promise<EventManifestEntry[]> {
|
||||||
const client = new NRCClient(connectionUri, signer, catToken)
|
const client = new NRCClient(connectionUri)
|
||||||
return client.requestIDs(filters, onProgress)
|
return client.requestIDs(filters, onProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -861,17 +778,13 @@ export async function requestRemoteIDs(
|
|||||||
* @param connectionUri - The nostr+relayconnect:// URI
|
* @param connectionUri - The nostr+relayconnect:// URI
|
||||||
* @param events - Events to send
|
* @param events - Events to send
|
||||||
* @param onProgress - Optional progress callback
|
* @param onProgress - Optional progress callback
|
||||||
* @param signer - Optional signer for CAT mode
|
|
||||||
* @param catToken - Optional CAT token for CAT mode
|
|
||||||
* @returns Number of events successfully stored
|
* @returns Number of events successfully stored
|
||||||
*/
|
*/
|
||||||
export async function sendEventsToRemote(
|
export async function sendEventsToRemote(
|
||||||
connectionUri: string,
|
connectionUri: string,
|
||||||
events: Event[],
|
events: Event[],
|
||||||
onProgress?: (progress: SyncProgress) => void,
|
onProgress?: (progress: SyncProgress) => void
|
||||||
signer?: ISigner,
|
|
||||||
catToken?: string
|
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const client = new NRCClient(connectionUri, signer, catToken)
|
const client = new NRCClient(connectionUri)
|
||||||
return client.sendEvents(events, onProgress)
|
return client.sendEvents(events, onProgress)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
import { Event, Filter } from 'nostr-tools'
|
import { Event, Filter } from 'nostr-tools'
|
||||||
import * as utils from '@noble/curves/abstract/utils'
|
import * as utils from '@noble/curves/abstract/utils'
|
||||||
import indexedDb from '@/services/indexed-db.service'
|
import indexedDb from '@/services/indexed-db.service'
|
||||||
import cashuTokenService, { decodeToken, TCashuToken } from '@/services/cashu-token.service'
|
|
||||||
import {
|
import {
|
||||||
KIND_NRC_REQUEST,
|
KIND_NRC_REQUEST,
|
||||||
KIND_NRC_RESPONSE,
|
KIND_NRC_RESPONSE,
|
||||||
@@ -291,7 +290,6 @@ export class NRCListenerService {
|
|||||||
const session = this.sessions.getOrCreateSession(
|
const session = this.sessions.getOrCreateSession(
|
||||||
event.pubkey,
|
event.pubkey,
|
||||||
undefined, // We use signer's nip44 methods instead of conversationKey
|
undefined, // We use signer's nip44 methods instead of conversationKey
|
||||||
authResult.mode,
|
|
||||||
authResult.deviceName
|
authResult.deviceName
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -344,17 +342,6 @@ export class NRCListenerService {
|
|||||||
throw new Error('Listener not configured')
|
throw new Error('Listener not configured')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for CAT token in cashu tag
|
|
||||||
const cashuTag = event.tags.find((t) => t[0] === 'cashu')
|
|
||||||
if (cashuTag && cashuTag[1] && this.config.catConfig) {
|
|
||||||
const catResult = await this.verifyCATToken(cashuTag[1], event.pubkey)
|
|
||||||
if (catResult) {
|
|
||||||
return catResult
|
|
||||||
}
|
|
||||||
// CAT verification failed, fall through to check other auth methods
|
|
||||||
console.log('[NRC] CAT verification failed, checking other auth methods')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Secret-based auth: check if pubkey is authorized
|
// Secret-based auth: check if pubkey is authorized
|
||||||
const deviceName = this.config.authorizedSecrets.get(event.pubkey)
|
const deviceName = this.config.authorizedSecrets.get(event.pubkey)
|
||||||
if (!deviceName) {
|
if (!deviceName) {
|
||||||
@@ -365,72 +352,10 @@ export class NRCListenerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode: 'secret',
|
|
||||||
deviceName
|
deviceName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify a CAT (Cashu Access Token) for NRC authentication
|
|
||||||
*/
|
|
||||||
private async verifyCATToken(encodedToken: string, clientPubkey: string): Promise<AuthResult | null> {
|
|
||||||
if (!this.config?.catConfig) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Decode the token
|
|
||||||
const token: TCashuToken = decodeToken(encodedToken)
|
|
||||||
console.log('[NRC] Verifying CAT token, scope:', token.scope, 'expiry:', new Date(token.expiry * 1000))
|
|
||||||
|
|
||||||
// Check expiry
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
|
||||||
if (token.expiry < now) {
|
|
||||||
console.log('[NRC] CAT token expired')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check scope - must be 'nrc' or 'relay' for NRC auth
|
|
||||||
if (token.scope !== 'nrc' && token.scope !== 'relay') {
|
|
||||||
console.log('[NRC] CAT token has wrong scope:', token.scope)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the token pubkey matches the event pubkey
|
|
||||||
const tokenPubkeyHex = utils.bytesToHex(token.pubkey)
|
|
||||||
if (tokenPubkeyHex !== clientPubkey) {
|
|
||||||
console.log('[NRC] CAT token pubkey mismatch:', tokenPubkeyHex, '!=', clientPubkey)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the cashu service with the mint URL if not already done
|
|
||||||
cashuTokenService.setMint(this.config.catConfig.mintUrl)
|
|
||||||
|
|
||||||
// Verify token signature with mint
|
|
||||||
// Note: This requires the mint info to be fetched first
|
|
||||||
try {
|
|
||||||
await cashuTokenService.fetchMintInfo()
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[NRC] Could not fetch mint info for CAT verification:', err)
|
|
||||||
// Continue anyway - we've done basic validation
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cashuTokenService.verifyToken(token)) {
|
|
||||||
console.log('[NRC] CAT token signature verification failed')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[NRC] CAT token verified successfully')
|
|
||||||
return {
|
|
||||||
mode: 'cat',
|
|
||||||
deviceName: `cat:${clientPubkey.slice(0, 8)}`
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[NRC] CAT token verification error:', err)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt content using the signer's NIP-44 implementation
|
* Decrypt content using the signer's NIP-44 implementation
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Filter } from 'nostr-tools'
|
import { Filter } from 'nostr-tools'
|
||||||
import { NRCSession, NRCSubscription, AuthMode } from './nrc-types'
|
import { NRCSession, NRCSubscription } from './nrc-types'
|
||||||
|
|
||||||
// Default session timeout: 30 minutes
|
// Default session timeout: 30 minutes
|
||||||
const DEFAULT_SESSION_TIMEOUT = 30 * 60 * 1000
|
const DEFAULT_SESSION_TIMEOUT = 30 * 60 * 1000
|
||||||
@@ -60,7 +60,6 @@ export class NRCSessionManager {
|
|||||||
getOrCreateSession(
|
getOrCreateSession(
|
||||||
clientPubkey: string,
|
clientPubkey: string,
|
||||||
conversationKey: Uint8Array | undefined,
|
conversationKey: Uint8Array | undefined,
|
||||||
authMode: AuthMode,
|
|
||||||
deviceName?: string
|
deviceName?: string
|
||||||
): NRCSession {
|
): NRCSession {
|
||||||
// Check if session exists for this client
|
// Check if session exists for this client
|
||||||
@@ -78,7 +77,6 @@ export class NRCSessionManager {
|
|||||||
clientPubkey,
|
clientPubkey,
|
||||||
conversationKey,
|
conversationKey,
|
||||||
deviceName,
|
deviceName,
|
||||||
authMode,
|
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
lastActivity: Date.now(),
|
lastActivity: Date.now(),
|
||||||
subscriptions: new Map()
|
subscriptions: new Map()
|
||||||
|
|||||||
@@ -5,16 +5,12 @@ import { ISigner } from '@/types'
|
|||||||
export const KIND_NRC_REQUEST = 24891
|
export const KIND_NRC_REQUEST = 24891
|
||||||
export const KIND_NRC_RESPONSE = 24892
|
export const KIND_NRC_RESPONSE = 24892
|
||||||
|
|
||||||
// Authentication modes
|
|
||||||
export type AuthMode = 'secret' | 'cat'
|
|
||||||
|
|
||||||
// Session types
|
// Session types
|
||||||
export interface NRCSession {
|
export interface NRCSession {
|
||||||
id: string
|
id: string
|
||||||
clientPubkey: string
|
clientPubkey: string
|
||||||
conversationKey?: Uint8Array // Optional - only set when using direct key access
|
conversationKey?: Uint8Array // Optional - only set when using direct key access
|
||||||
deviceName?: string
|
deviceName?: string
|
||||||
authMode: AuthMode
|
|
||||||
createdAt: number
|
createdAt: number
|
||||||
lastActivity: number
|
lastActivity: number
|
||||||
subscriptions: Map<string, NRCSubscription>
|
subscriptions: Map<string, NRCSubscription>
|
||||||
@@ -72,30 +68,21 @@ export interface NRCConnection {
|
|||||||
label: string
|
label: string
|
||||||
secret?: string // For secret-based auth
|
secret?: string // For secret-based auth
|
||||||
clientPubkey?: string // Derived from secret
|
clientPubkey?: string // Derived from secret
|
||||||
useCat: boolean // Whether to use CAT auth
|
|
||||||
createdAt: number
|
createdAt: number
|
||||||
lastUsed?: number
|
lastUsed?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// CAT (Cashu Access Token) configuration
|
|
||||||
export interface CATConfig {
|
|
||||||
mintUrl: string // Cashu mint URL
|
|
||||||
scope: string // Token scope (e.g., "nrc")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listener configuration
|
// Listener configuration
|
||||||
export interface NRCListenerConfig {
|
export interface NRCListenerConfig {
|
||||||
rendezvousUrl: string
|
rendezvousUrl: string
|
||||||
signer: ISigner
|
signer: ISigner
|
||||||
authorizedSecrets: Map<string, string> // clientPubkey → deviceName
|
authorizedSecrets: Map<string, string> // clientPubkey → deviceName
|
||||||
catConfig?: CATConfig // For CAT verification
|
|
||||||
sessionTimeout?: number // Session inactivity timeout in ms (default 30 min)
|
sessionTimeout?: number // Session inactivity timeout in ms (default 30 min)
|
||||||
maxSubscriptionsPerSession?: number // Max subscriptions per session (default 100)
|
maxSubscriptionsPerSession?: number // Max subscriptions per session (default 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorization result
|
// Authorization result
|
||||||
export interface AuthResult {
|
export interface AuthResult {
|
||||||
mode: AuthMode
|
|
||||||
conversationKey?: Uint8Array // Optional - only set when using direct key access
|
conversationKey?: Uint8Array // Optional - only set when using direct key access
|
||||||
deviceName: string
|
deviceName: string
|
||||||
}
|
}
|
||||||
@@ -104,13 +91,10 @@ export interface AuthResult {
|
|||||||
export interface ParsedConnectionURI {
|
export interface ParsedConnectionURI {
|
||||||
relayPubkey: string // Hex pubkey of the listening relay/client
|
relayPubkey: string // Hex pubkey of the listening relay/client
|
||||||
rendezvousUrl: string // URL of the rendezvous relay
|
rendezvousUrl: string // URL of the rendezvous relay
|
||||||
authMode: AuthMode
|
|
||||||
// For secret-based auth
|
// For secret-based auth
|
||||||
secret?: string // 32-byte hex secret
|
secret?: string // 32-byte hex secret
|
||||||
clientPubkey?: string // Derived pubkey from secret
|
clientPubkey?: string // Derived pubkey from secret
|
||||||
clientPrivkey?: Uint8Array // Derived private key from secret
|
clientPrivkey?: Uint8Array // Derived private key from secret
|
||||||
// For CAT auth
|
|
||||||
mintUrl?: string
|
|
||||||
// Optional
|
// Optional
|
||||||
deviceName?: string
|
deviceName?: string
|
||||||
}
|
}
|
||||||
@@ -121,7 +105,6 @@ export interface NRCListenerState {
|
|||||||
isListening: boolean
|
isListening: boolean
|
||||||
connections: NRCConnection[]
|
connections: NRCConnection[]
|
||||||
activeSessions: number
|
activeSessions: number
|
||||||
catConfig: CATConfig | null
|
|
||||||
rendezvousUrl: string
|
rendezvousUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,7 @@
|
|||||||
import * as utils from '@noble/curves/abstract/utils'
|
import * as utils from '@noble/curves/abstract/utils'
|
||||||
import { getPublicKey } from 'nostr-tools'
|
import { getPublicKey } from 'nostr-tools'
|
||||||
import * as nip44 from 'nostr-tools/nip44'
|
import * as nip44 from 'nostr-tools/nip44'
|
||||||
import { ParsedConnectionURI, AuthMode } from './nrc-types'
|
import { ParsedConnectionURI } from './nrc-types'
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive the Cashu mint URL from a relay URL.
|
|
||||||
* The mint is always at {relay-root}/cashu
|
|
||||||
*
|
|
||||||
* @param relayUrl - WebSocket relay URL (ws:// or wss://)
|
|
||||||
* @returns HTTP(S) mint URL
|
|
||||||
*/
|
|
||||||
export function deriveMintUrlFromRelay(relayUrl: string): string {
|
|
||||||
let mintUrl = relayUrl
|
|
||||||
|
|
||||||
// Convert WebSocket URL to HTTP URL
|
|
||||||
if (relayUrl.startsWith('ws://')) {
|
|
||||||
mintUrl = 'http://' + relayUrl.slice(5)
|
|
||||||
} else if (relayUrl.startsWith('wss://')) {
|
|
||||||
mintUrl = 'https://' + relayUrl.slice(6)
|
|
||||||
} else if (!relayUrl.startsWith('http://') && !relayUrl.startsWith('https://')) {
|
|
||||||
mintUrl = 'https://' + relayUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove trailing slash and append /cashu
|
|
||||||
mintUrl = mintUrl.replace(/\/$/, '')
|
|
||||||
|
|
||||||
return mintUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a random 32-byte secret as hex string
|
* Generate a random 32-byte secret as hex string
|
||||||
@@ -91,28 +66,6 @@ export function generateConnectionURI(
|
|||||||
return { uri, secret: secretHex, clientPubkey }
|
return { uri, secret: secretHex, clientPubkey }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a CAT-based NRC connection URI
|
|
||||||
*
|
|
||||||
* The mint URL is derived automatically from the rendezvous relay URL
|
|
||||||
* (mint is always at {relay-root}/cashu)
|
|
||||||
*
|
|
||||||
* @param relayPubkey - The public key of the listening client/relay
|
|
||||||
* @param rendezvousUrl - The URL of the rendezvous relay
|
|
||||||
* @returns The connection URI
|
|
||||||
*/
|
|
||||||
export function generateCATConnectionURI(
|
|
||||||
relayPubkey: string,
|
|
||||||
rendezvousUrl: string
|
|
||||||
): string {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
params.set('relay', rendezvousUrl)
|
|
||||||
params.set('auth', 'cat')
|
|
||||||
// Note: mint URL is derived from relay URL, not stored in URI
|
|
||||||
|
|
||||||
return `nostr+relayconnect://${relayPubkey}?${params.toString()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an NRC connection URI
|
* Parse an NRC connection URI
|
||||||
*
|
*
|
||||||
@@ -153,49 +106,30 @@ export function parseConnectionURI(uri: string): ParsedConnectionURI {
|
|||||||
throw new Error('Invalid rendezvous relay URL')
|
throw new Error('Invalid rendezvous relay URL')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine auth mode
|
|
||||||
const authParam = url.searchParams.get('auth')
|
|
||||||
const authMode: AuthMode = authParam === 'cat' ? 'cat' : 'secret'
|
|
||||||
|
|
||||||
// Extract device name (optional)
|
// Extract device name (optional)
|
||||||
const deviceName = url.searchParams.get('name') || undefined
|
const deviceName = url.searchParams.get('name') || undefined
|
||||||
|
|
||||||
if (authMode === 'cat') {
|
// Secret-based auth
|
||||||
// CAT-based auth - mint URL is derived from relay URL
|
const secret = url.searchParams.get('secret')
|
||||||
// (mint is always at {relay-root}/cashu)
|
if (!secret) {
|
||||||
const mintUrl = deriveMintUrlFromRelay(rendezvousUrl)
|
throw new Error('Missing secret parameter in URI')
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// Validate secret format (64 hex chars = 32 bytes)
|
||||||
relayPubkey,
|
if (!/^[0-9a-fA-F]{64}$/.test(secret)) {
|
||||||
rendezvousUrl,
|
throw new Error('Invalid secret format, expected 64 hex characters')
|
||||||
authMode,
|
}
|
||||||
mintUrl,
|
|
||||||
deviceName
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Secret-based auth
|
|
||||||
const secret = url.searchParams.get('secret')
|
|
||||||
if (!secret) {
|
|
||||||
throw new Error('Secret auth requires secret parameter')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate secret format (64 hex chars = 32 bytes)
|
// Derive keypair from secret
|
||||||
if (!/^[0-9a-fA-F]{64}$/.test(secret)) {
|
const { privkey, pubkey } = deriveKeypairFromSecret(secret)
|
||||||
throw new Error('Invalid secret format, expected 64 hex characters')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive keypair from secret
|
return {
|
||||||
const { privkey, pubkey } = deriveKeypairFromSecret(secret)
|
relayPubkey,
|
||||||
|
rendezvousUrl,
|
||||||
return {
|
secret,
|
||||||
relayPubkey,
|
clientPubkey: pubkey,
|
||||||
rendezvousUrl,
|
clientPrivkey: privkey,
|
||||||
authMode,
|
deviceName
|
||||||
secret,
|
|
||||||
clientPubkey: pubkey,
|
|
||||||
clientPrivkey: privkey,
|
|
||||||
deviceName
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,31 +145,3 @@ export function isValidConnectionURI(uri: string): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a relay supports CAT (Cashu Access Tokens)
|
|
||||||
* by probing the /cashu/info endpoint
|
|
||||||
*
|
|
||||||
* @param relayUrl - WebSocket relay URL
|
|
||||||
* @returns true if the relay has a Cashu mint
|
|
||||||
*/
|
|
||||||
export async function relaySupportsCat(relayUrl: string): Promise<boolean> {
|
|
||||||
const mintUrl = deriveMintUrlFromRelay(relayUrl)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${mintUrl}/cashu/info`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { Accept: 'application/json' }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if response is valid mint info
|
|
||||||
const info = await response.json()
|
|
||||||
return info && typeof info === 'object' && 'name' in info
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
1
src/types/index.d.ts
vendored
1
src/types/index.d.ts
vendored
@@ -123,7 +123,6 @@ export type TAccount = {
|
|||||||
bunkerPubkey?: string
|
bunkerPubkey?: string
|
||||||
bunkerRelays?: string[]
|
bunkerRelays?: string[]
|
||||||
bunkerSecret?: string
|
bunkerSecret?: string
|
||||||
bunkerCatToken?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
||||||
|
|||||||
Reference in New Issue
Block a user