Auto-acquire Cashu tokens for NIP-46 bunker connections (v0.2.0)
- Add acquireTokenIfNeeded() in BunkerSigner to get CAT before connecting - Check /cashu/info to detect CAT-enabled relays - Request token via cashuTokenService with NIP-98 auth using ephemeral key - Store and reuse tokens across sessions - Pass token as query parameter on WebSocket connection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "smesh",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "A user-friendly Nostr client for exploring relay feeds",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
105
src/components/AccountManager/BunkerLogin.tsx
Normal file
105
src/components/AccountManager/BunkerLogin.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { ArrowLeft, Loader2, Server } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function BunkerLogin({
|
||||
back,
|
||||
onLoginSuccess
|
||||
}: {
|
||||
back: () => void
|
||||
onLoginSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { bunkerLogin } = useNostr()
|
||||
const [bunkerUrl, setBunkerUrl] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!bunkerUrl.trim()) {
|
||||
setError(t('Please enter a bunker URL'))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate bunker URL format
|
||||
if (!bunkerUrl.startsWith('bunker://')) {
|
||||
setError(t('Invalid bunker URL format. Must start with bunker://'))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await bunkerLogin(bunkerUrl.trim())
|
||||
onLoginSuccess()
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="icon" variant="ghost" className="rounded-full" onClick={back}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="size-5" />
|
||||
<span className="font-semibold">{t('Login with Bunker')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bunkerUrl">{t('Bunker URL')}</Label>
|
||||
<Input
|
||||
id="bunkerUrl"
|
||||
type="text"
|
||||
placeholder="bunker://pubkey?relay=wss://..."
|
||||
value={bunkerUrl}
|
||||
onChange={(e) => setBunkerUrl(e.target.value)}
|
||||
disabled={loading}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'Enter the bunker connection URL. This is typically provided by your signing device or service.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading || !bunkerUrl.trim()}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('Connecting...')}
|
||||
</>
|
||||
) : (
|
||||
t('Connect to Bunker')
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-2">
|
||||
<p>
|
||||
<strong>{t('What is a bunker?')}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'A bunker (NIP-46) is a remote signing service that keeps your private key secure while allowing you to sign Nostr events. Your key never leaves the bunker.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,11 +5,12 @@ import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AccountList from '../AccountList'
|
||||
import BunkerLogin from './BunkerLogin'
|
||||
import NpubLogin from './NpubLogin'
|
||||
import PrivateKeyLogin from './PrivateKeyLogin'
|
||||
import Signup from './Signup'
|
||||
|
||||
type TAccountManagerPage = 'nsec' | 'npub' | 'signup' | null
|
||||
type TAccountManagerPage = 'nsec' | 'npub' | 'signup' | 'bunker' | null
|
||||
|
||||
export default function AccountManager({ close }: { close?: () => void }) {
|
||||
const [page, setPage] = useState<TAccountManagerPage>(null)
|
||||
@@ -22,6 +23,8 @@ export default function AccountManager({ close }: { close?: () => void }) {
|
||||
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'signup' ? (
|
||||
<Signup back={() => setPage(null)} onSignupSuccess={() => close?.()} />
|
||||
) : page === 'bunker' ? (
|
||||
<BunkerLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : (
|
||||
<AccountManagerNav setPage={setPage} close={close} />
|
||||
)}
|
||||
@@ -54,6 +57,9 @@ function AccountManagerNav({
|
||||
<Button variant="secondary" onClick={() => setPage('nsec')} className="w-full">
|
||||
{t('Login with Private Key')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setPage('bunker')} className="w-full">
|
||||
{t('Login with Bunker')}
|
||||
</Button>
|
||||
{isDevEnv() && (
|
||||
<Button variant="secondary" onClick={() => setPage('npub')} className="w-full">
|
||||
Login with Public key (for development)
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function NormalFeed({
|
||||
<Tabs
|
||||
value={listMode === '24h' && disable24hMode ? 'postsAndReplies' : listMode}
|
||||
tabs={[
|
||||
{ value: 'postsAndReplies', label: 'Replies' },
|
||||
{ value: 'postsAndReplies', label: 'Feed' },
|
||||
...(!disable24hMode ? [{ value: '24h', label: '24h Pulse' }] : [])
|
||||
]}
|
||||
onTabChange={(listMode) => {
|
||||
|
||||
@@ -4,11 +4,10 @@ import NormalFeed from '../NormalFeed'
|
||||
import Profile from '../Profile'
|
||||
import { ProfileListBySearch } from '../ProfileListBySearch'
|
||||
import Relay from '../Relay'
|
||||
import TrendingNotes from '../TrendingNotes'
|
||||
|
||||
export default function SearchResult({ searchParams }: { searchParams: TSearchParams | null }) {
|
||||
if (!searchParams) {
|
||||
return <TrendingNotes />
|
||||
return null
|
||||
}
|
||||
if (searchParams.type === 'profile') {
|
||||
return <Profile id={searchParams.search} />
|
||||
|
||||
280
src/components/TokenDisplay/index.tsx
Normal file
280
src/components/TokenDisplay/index.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { TRENDING_NOTES_RELAY_URLS } from '@/constants'
|
||||
import { simplifyUrl } from '@/lib/url'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NormalFeed from '../NormalFeed'
|
||||
|
||||
const RESOURCE_DESCRIPTION = TRENDING_NOTES_RELAY_URLS.map((url) => simplifyUrl(url)).join(', ')
|
||||
|
||||
export default function TrendingNotes() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="top-12 h-12 px-4 flex flex-col justify-center text-lg font-bold bg-background z-30 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
{t('Trending Notes')}
|
||||
<span className="text-sm text-muted-foreground font-normal">
|
||||
({RESOURCE_DESCRIPTION})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<NormalFeed
|
||||
subRequests={[{ urls: TRENDING_NOTES_RELAY_URLS, filter: {} }]}
|
||||
showRelayCloseReason
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -77,8 +77,6 @@ export const SEARCHABLE_RELAY_URLS = [
|
||||
'wss://relay.orly.dev/'
|
||||
]
|
||||
|
||||
export const TRENDING_NOTES_RELAY_URLS = ['wss://trending.relays.land/']
|
||||
|
||||
export const GROUP_METADATA_EVENT_KIND = 39000
|
||||
|
||||
export const ExtendedKind = {
|
||||
|
||||
@@ -425,7 +425,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': 'مرحلات الكتابة و {{count}} مرحل آخر',
|
||||
'{{count}} relays': '{{count}} ريلايات',
|
||||
'Republishing...': 'جارٍ إعادة النشر...',
|
||||
'Trending Notes': 'الملاحظات الرائجة',
|
||||
'Connected to': 'متصل بـ',
|
||||
'Disconnect Wallet': 'قطع الاتصال بالمحفظة',
|
||||
'Are you absolutely sure?': 'هل أنت متأكد تماماً؟',
|
||||
|
||||
@@ -437,7 +437,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': 'Schreib-Relays und {{count}} andere Relays',
|
||||
'{{count}} relays': '{{count}} Relays',
|
||||
'Republishing...': 'Wird erneut veröffentlicht...',
|
||||
'Trending Notes': 'Trendende Notizen',
|
||||
'Connected to': 'Verbunden mit',
|
||||
'Disconnect Wallet': 'Wallet trennen',
|
||||
'Are you absolutely sure?': 'Bist du dir absolut sicher?',
|
||||
|
||||
@@ -409,7 +409,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': 'Write relays and {{count}} other relays',
|
||||
'{{count}} relays': '{{count}} relays',
|
||||
'Republishing...': 'Republishing...',
|
||||
'Trending Notes': 'Trending Notes',
|
||||
'Connected to': 'Connected to',
|
||||
'Disconnect Wallet': 'Disconnect Wallet',
|
||||
'Are you absolutely sure?': 'Are you absolutely sure?',
|
||||
|
||||
@@ -432,7 +432,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': 'Relés de escritura y {{count}} otros relés',
|
||||
'{{count}} relays': '{{count}} relés',
|
||||
'Republishing...': 'Republicando...',
|
||||
'Trending Notes': 'Notas de tendencia',
|
||||
'Connected to': 'Conectado a',
|
||||
'Disconnect Wallet': 'Desconectar billetera',
|
||||
'Are you absolutely sure?': '¿Estás absolutamente seguro?',
|
||||
|
||||
@@ -427,7 +427,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': 'رلههای نوشتن و {{count}} رله دیگر',
|
||||
'{{count}} relays': '{{count}} رله',
|
||||
'Republishing...': 'در حال بازنشر...',
|
||||
'Trending Notes': 'یادداشتهای محبوب',
|
||||
'Connected to': 'متصل به',
|
||||
'Disconnect Wallet': 'قطع اتصال کیف پول',
|
||||
'Are you absolutely sure?': 'آیا کاملاً مطمئن هستید؟',
|
||||
|
||||
@@ -436,7 +436,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': 'Relais d’écriture et {{count}} autres relais',
|
||||
'{{count}} relays': '{{count}} relais',
|
||||
'Republishing...': 'Republication en cours...',
|
||||
'Trending Notes': 'Notes tendance',
|
||||
'Connected to': 'Connecté à',
|
||||
'Disconnect Wallet': 'Déconnecter le portefeuille',
|
||||
'Are you absolutely sure?': 'Êtes-vous absolument sûr ?',
|
||||
|
||||
@@ -429,7 +429,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': 'राइट रिले और {{count}} अन्य रिले',
|
||||
'{{count}} relays': '{{count}} रिले',
|
||||
'Republishing...': 'पुनः प्रकाशित कर रहे हैं...',
|
||||
'Trending Notes': 'ट्रेंडिंग नोट्स',
|
||||
'Connected to': 'से कनेक्टेड',
|
||||
'Disconnect Wallet': 'वॉलेट डिस्कनेक्ट करें',
|
||||
'Are you absolutely sure?': 'क्या आप पूरी तरह से सुनिश्चित हैं?',
|
||||
|
||||
@@ -426,7 +426,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': 'írt csomópontok és {{count}} további csomópont',
|
||||
'{{count}} relays': '{{count}} csomópont',
|
||||
'Republishing...': 'Továbbküldés...',
|
||||
'Trending Notes': 'Népszerű Posztok',
|
||||
'Connected to': 'Csatlakozva',
|
||||
'Disconnect Wallet': 'Tárca eltávolítása',
|
||||
'Are you absolutely sure?': 'Teljesen biztos vagy benne?',
|
||||
|
||||
@@ -432,7 +432,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': 'Relay di scrittura e {{count}} altri relay',
|
||||
'{{count}} relays': '{{count}} relay',
|
||||
'Republishing...': 'Ricondivisione in corso...',
|
||||
'Trending Notes': 'Note di tendenza',
|
||||
'Connected to': 'Connesso a',
|
||||
'Disconnect Wallet': 'Disconnetti Wallet',
|
||||
'Are you absolutely sure?': 'Sei assolutamente sicuro?',
|
||||
|
||||
@@ -428,7 +428,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': '書き込みリレーと他の {{count}} 個のリレー',
|
||||
'{{count}} relays': '{{count}} 個のリレー',
|
||||
'Republishing...': '再公開中...',
|
||||
'Trending Notes': '注目のノート',
|
||||
'Connected to': '接続先',
|
||||
'Disconnect Wallet': 'ウォレットの接続を解除',
|
||||
'Are you absolutely sure?': '本当に確かですか?',
|
||||
|
||||
@@ -428,7 +428,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': '쓰기 릴레이 및 기타 {{count}}개 릴레이',
|
||||
'{{count}} relays': '{{count}}개 릴레이',
|
||||
'Republishing...': '다시 게시 중...',
|
||||
'Trending Notes': '트렌딩 노트',
|
||||
'Connected to': '연결됨',
|
||||
'Disconnect Wallet': '지갑 연결 해제',
|
||||
'Are you absolutely sure?': '정말 확실합니까?',
|
||||
|
||||
@@ -432,7 +432,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': 'Transmitery zapisu i {{count}} innych transmitrów',
|
||||
'{{count}} relays': '{{count}} transmiterów',
|
||||
'Republishing...': 'Ponowne publikowanie...',
|
||||
'Trending Notes': 'Popularne wpisy',
|
||||
'Connected to': 'Połączono z',
|
||||
'Disconnect Wallet': 'Odłącz portfel',
|
||||
'Are you absolutely sure?': 'Czy jesteś całkowicie pewien?',
|
||||
|
||||
@@ -430,7 +430,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': 'Relays de escrita e {{count}} outros relays',
|
||||
'{{count}} relays': '{{count}} relays',
|
||||
'Republishing...': 'Republicando...',
|
||||
'Trending Notes': 'Notas em tendência',
|
||||
'Connected to': 'Conectado a',
|
||||
'Disconnect Wallet': 'Desconectar carteira',
|
||||
'Are you absolutely sure?': 'Você tem certeza absoluta?',
|
||||
|
||||
@@ -432,7 +432,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': 'Relays de escrita e {{count}} outros relays',
|
||||
'{{count}} relays': '{{count}} relays',
|
||||
'Republishing...': 'Republicando...',
|
||||
'Trending Notes': 'Notas em Tendência',
|
||||
'Connected to': 'Conectado a',
|
||||
'Disconnect Wallet': 'Desconectar Carteira',
|
||||
'Are you absolutely sure?': 'Tem certeza absoluta?',
|
||||
|
||||
@@ -434,7 +434,6 @@ export default {
|
||||
'Ретрансляторы записи и {{count}} других ретрансляторов',
|
||||
'{{count}} relays': '{{count}} ретрансляторов',
|
||||
'Republishing...': 'Ретрансляция...',
|
||||
'Trending Notes': 'Популярные заметки',
|
||||
'Connected to': 'Подключено к',
|
||||
'Disconnect Wallet': 'Отключить кошелёк',
|
||||
'Are you absolutely sure?': 'Вы абсолютно уверены?',
|
||||
|
||||
@@ -423,7 +423,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': 'รีเลย์เขียนและรีเลย์อื่น ๆ {{count}} ตัว',
|
||||
'{{count}} relays': 'รีเลย์ {{count}} ตัว',
|
||||
'Republishing...': 'กำลังเผยแพร่ซ้ำ...',
|
||||
'Trending Notes': 'โน้ตยอดนิยม',
|
||||
'Connected to': 'เชื่อมต่อกับ',
|
||||
'Disconnect Wallet': 'ตัดการเชื่อมต่อกระเป๋าสตางค์',
|
||||
'Are you absolutely sure?': 'คุณแน่ใจอย่างยิ่งหรือไม่?',
|
||||
|
||||
@@ -421,7 +421,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': '寫入伺服器和其他 {{count}} 個伺服器',
|
||||
'{{count}} relays': '{{count}} 個伺服器',
|
||||
'Republishing...': '正在重新發布...',
|
||||
'Trending Notes': '熱門筆記',
|
||||
'Connected to': '已連接到',
|
||||
'Disconnect Wallet': '中斷錢包連接',
|
||||
'Are you absolutely sure?': '您確定嗎?',
|
||||
|
||||
@@ -421,7 +421,6 @@ export default {
|
||||
'Write relays and {{count}} other relays': '写服务器和其他 {{count}} 个服务器',
|
||||
'{{count}} relays': '{{count}} 个服务器',
|
||||
'Republishing...': '正在重新发布...',
|
||||
'Trending Notes': '热门笔记',
|
||||
'Connected to': '已连接到',
|
||||
'Disconnect Wallet': '断开钱包连接',
|
||||
'Are you absolutely sure?': '您确定吗?',
|
||||
|
||||
565
src/providers/NostrProvider/bunker.signer.ts
Normal file
565
src/providers/NostrProvider/bunker.signer.ts
Normal file
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* NIP-46 Bunker Signer with Cashu Token Authentication
|
||||
*
|
||||
* Implements remote signing via NIP-46 protocol with Cashu access tokens
|
||||
* for authorization. The signer connects to a bunker WebSocket and
|
||||
* 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 cashuTokenService, { TCashuToken, TokenScope } from '@/services/cashu-token.service'
|
||||
import * as utils from '@noble/curves/abstract/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||
import { Event, VerifiedEvent, getPublicKey as nGetPublicKey, nip04, finalizeEvent } from 'nostr-tools'
|
||||
|
||||
// NIP-46 methods
|
||||
const NIP46_METHOD = {
|
||||
CONNECT: 'connect',
|
||||
GET_PUBLIC_KEY: 'get_public_key',
|
||||
SIGN_EVENT: 'sign_event',
|
||||
NIP04_ENCRYPT: 'nip04_encrypt',
|
||||
NIP04_DECRYPT: 'nip04_decrypt',
|
||||
PING: 'ping'
|
||||
} as const
|
||||
|
||||
type NIP46Method = (typeof NIP46_METHOD)[keyof typeof NIP46_METHOD]
|
||||
|
||||
// NIP-46 request format
|
||||
interface NIP46Request {
|
||||
id: string
|
||||
method: NIP46Method
|
||||
params: string[]
|
||||
}
|
||||
|
||||
// NIP-46 response format
|
||||
interface NIP46Response {
|
||||
id: string
|
||||
result?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Pending request tracker
|
||||
interface PendingRequest {
|
||||
resolve: (value: string) => void
|
||||
reject: (error: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random request ID.
|
||||
*/
|
||||
function generateRequestId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
||||
return utils.bytesToHex(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a bunker URL (bunker://<pubkey>?relay=<url>&secret=<secret>).
|
||||
*/
|
||||
export function parseBunkerUrl(url: string): {
|
||||
pubkey: string
|
||||
relays: string[]
|
||||
secret?: string
|
||||
} {
|
||||
if (!url.startsWith('bunker://')) {
|
||||
throw new Error('Invalid bunker URL: must start with bunker://')
|
||||
}
|
||||
|
||||
const withoutPrefix = url.slice('bunker://'.length)
|
||||
const [pubkeyPart, queryPart] = withoutPrefix.split('?')
|
||||
|
||||
if (!pubkeyPart || pubkeyPart.length !== 64) {
|
||||
throw new Error('Invalid bunker URL: missing or invalid pubkey')
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(queryPart || '')
|
||||
const relays = params.getAll('relay')
|
||||
const secret = params.get('secret') || undefined
|
||||
|
||||
if (relays.length === 0) {
|
||||
throw new Error('Invalid bunker URL: no relay specified')
|
||||
}
|
||||
|
||||
return {
|
||||
pubkey: pubkeyPart,
|
||||
relays,
|
||||
secret
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bunker URL from components.
|
||||
*/
|
||||
export function buildBunkerUrl(pubkey: string, relays: string[], secret?: string): string {
|
||||
const params = new URLSearchParams()
|
||||
relays.forEach((relay) => params.append('relay', relay))
|
||||
if (secret) {
|
||||
params.set('secret', secret)
|
||||
}
|
||||
return `bunker://${pubkey}?${params.toString()}`
|
||||
}
|
||||
|
||||
export class BunkerSigner implements ISigner {
|
||||
private bunkerPubkey: string
|
||||
private relayUrls: string[]
|
||||
private connectionSecret?: string
|
||||
private localPrivkey: Uint8Array
|
||||
private localPubkey: string
|
||||
private remotePubkey: string | null = null
|
||||
private ws: WebSocket | null = null
|
||||
private pendingRequests = new Map<string, PendingRequest>()
|
||||
private connected = false
|
||||
private token: TCashuToken | null = null
|
||||
private mintUrl: string | null = null
|
||||
private requestTimeout = 30000 // 30 seconds
|
||||
|
||||
/**
|
||||
* Create a BunkerSigner.
|
||||
* @param bunkerPubkey - The bunker's public key (hex)
|
||||
* @param relayUrls - Relay URLs to connect to
|
||||
* @param connectionSecret - Optional connection secret for initial handshake
|
||||
*/
|
||||
constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string) {
|
||||
this.bunkerPubkey = bunkerPubkey
|
||||
this.relayUrls = relayUrls
|
||||
this.connectionSecret = connectionSecret
|
||||
|
||||
// Generate local ephemeral keypair for NIP-46 communication
|
||||
this.localPrivkey = secp256k1.utils.randomPrivateKey()
|
||||
this.localPubkey = nGetPublicKey(this.localPrivkey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
for (const relayUrl of this.relayUrls) {
|
||||
try {
|
||||
await this.connectToRelay(relayUrl)
|
||||
break
|
||||
} catch (err) {
|
||||
console.warn(`Failed to connect to ${relayUrl}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.connected) {
|
||||
throw new Error('Failed to connect to any bunker relay')
|
||||
}
|
||||
|
||||
// Perform NIP-46 connect handshake
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if relay requires Cashu token and acquire one if needed.
|
||||
*/
|
||||
private async acquireTokenIfNeeded(relayUrl: string): Promise<void> {
|
||||
// Convert to HTTP URL for mint endpoints
|
||||
let mintUrl = relayUrl
|
||||
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
|
||||
}
|
||||
mintUrl = mintUrl.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
// Check if relay has Cashu mint endpoints
|
||||
const infoResponse = await fetch(`${mintUrl}/cashu/info`)
|
||||
if (!infoResponse.ok) {
|
||||
console.log(`Relay ${relayUrl} does not support Cashu tokens`)
|
||||
return
|
||||
}
|
||||
|
||||
const mintInfo = await infoResponse.json()
|
||||
console.log(`Relay ${relayUrl} requires Cashu token, acquiring...`)
|
||||
|
||||
// Configure the mint
|
||||
this.mintUrl = mintUrl
|
||||
cashuTokenService.setMint(mintUrl)
|
||||
|
||||
// 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(`Acquired Cashu token for ${relayUrl}, expires: ${new Date(token.expiry * 1000).toISOString()}`)
|
||||
} catch (err) {
|
||||
// Relay doesn't support Cashu or request failed - continue without token
|
||||
console.warn(`Could not acquire Cashu token for ${relayUrl}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a relay WebSocket.
|
||||
*/
|
||||
private async connectToRelay(relayUrl: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Convert ws:// or wss:// URL
|
||||
let wsUrl = relayUrl
|
||||
if (relayUrl.startsWith('http://')) {
|
||||
wsUrl = 'ws://' + relayUrl.slice(7)
|
||||
} else if (relayUrl.startsWith('https://')) {
|
||||
wsUrl = 'wss://' + relayUrl.slice(8)
|
||||
} else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
|
||||
wsUrl = 'wss://' + relayUrl
|
||||
}
|
||||
|
||||
// Add 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 timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
reject(new Error('Connection timeout'))
|
||||
}, 10000)
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timeout)
|
||||
this.ws = ws
|
||||
this.connected = true
|
||||
|
||||
// Subscribe to responses for our local pubkey
|
||||
const subId = generateRequestId()
|
||||
ws.send(
|
||||
JSON.stringify([
|
||||
'REQ',
|
||||
subId,
|
||||
{
|
||||
kinds: [24133], // NIP-46 response kind
|
||||
'#p': [this.localPubkey],
|
||||
since: Math.floor(Date.now() / 1000) - 60
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
resolve()
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error('WebSocket error'))
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
this.connected = false
|
||||
this.ws = null
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
this.handleMessage(event.data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming WebSocket messages.
|
||||
*/
|
||||
private async handleMessage(data: string): Promise<void> {
|
||||
try {
|
||||
const msg = JSON.parse(data)
|
||||
if (!Array.isArray(msg)) return
|
||||
|
||||
const [type, ...rest] = msg
|
||||
|
||||
if (type === 'EVENT') {
|
||||
const [, event] = rest as [string, Event]
|
||||
if (event.kind === 24133) {
|
||||
await this.handleNIP46Response(event)
|
||||
}
|
||||
} else if (type === 'OK') {
|
||||
// Event published confirmation
|
||||
} else if (type === 'NOTICE') {
|
||||
console.warn('Relay notice:', rest[0])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse message:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle NIP-46 response event.
|
||||
*/
|
||||
private async handleNIP46Response(event: Event): Promise<void> {
|
||||
try {
|
||||
// Decrypt the content with NIP-04
|
||||
const decrypted = await nip04.decrypt(this.localPrivkey, event.pubkey, event.content)
|
||||
|
||||
const response: NIP46Response = JSON.parse(decrypted)
|
||||
const pending = this.pendingRequests.get(response.id)
|
||||
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout)
|
||||
this.pendingRequests.delete(response.id)
|
||||
|
||||
if (response.error) {
|
||||
pending.reject(new Error(response.error))
|
||||
} else if (response.result !== undefined) {
|
||||
pending.resolve(response.result)
|
||||
} else {
|
||||
pending.reject(new Error('Empty response'))
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to handle NIP-46 response:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a NIP-46 request and wait for response.
|
||||
*/
|
||||
private async sendRequest(method: NIP46Method, params: string[] = []): Promise<string> {
|
||||
if (!this.ws || !this.connected) {
|
||||
throw new Error('Not connected to bunker')
|
||||
}
|
||||
|
||||
const request: NIP46Request = {
|
||||
id: generateRequestId(),
|
||||
method,
|
||||
params
|
||||
}
|
||||
|
||||
// Encrypt with NIP-04 to the bunker's pubkey
|
||||
const encrypted = await nip04.encrypt(this.localPrivkey, this.bunkerPubkey, JSON.stringify(request))
|
||||
|
||||
// Create NIP-46 request event
|
||||
const draftEvent: TDraftEvent = {
|
||||
kind: 24133,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: encrypted,
|
||||
tags: [['p', this.bunkerPubkey]]
|
||||
}
|
||||
|
||||
const signedEvent = finalizeEvent(draftEvent, this.localPrivkey)
|
||||
|
||||
// Send to relay
|
||||
this.ws.send(JSON.stringify(['EVENT', signedEvent]))
|
||||
|
||||
// Wait for response
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(request.id)
|
||||
reject(new Error('Request timeout'))
|
||||
}, this.requestTimeout)
|
||||
|
||||
this.pendingRequests.set(request.id, { resolve, reject, timeout })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform NIP-46 connect handshake.
|
||||
*/
|
||||
private async connect(): Promise<void> {
|
||||
const params: string[] = [this.localPubkey]
|
||||
if (this.connectionSecret) {
|
||||
params.push(this.connectionSecret)
|
||||
}
|
||||
|
||||
const result = await this.sendRequest(NIP46_METHOD.CONNECT, params)
|
||||
if (result !== 'ack') {
|
||||
throw new Error(`Connect failed: ${result}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public key of the user (from the bunker).
|
||||
*/
|
||||
async getPublicKey(): Promise<string> {
|
||||
if (this.remotePubkey) {
|
||||
return this.remotePubkey
|
||||
}
|
||||
|
||||
const pubkey = await this.sendRequest(NIP46_METHOD.GET_PUBLIC_KEY)
|
||||
this.remotePubkey = pubkey
|
||||
return pubkey
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an event via the bunker.
|
||||
*/
|
||||
async signEvent(draftEvent: TDraftEvent): Promise<VerifiedEvent> {
|
||||
const eventJson = JSON.stringify({
|
||||
...draftEvent,
|
||||
pubkey: await this.getPublicKey()
|
||||
})
|
||||
|
||||
const signedEventJson = await this.sendRequest(NIP46_METHOD.SIGN_EVENT, [eventJson])
|
||||
const signedEvent = JSON.parse(signedEventJson) as VerifiedEvent
|
||||
|
||||
return signedEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a message with NIP-04 via the bunker.
|
||||
*/
|
||||
async nip04Encrypt(pubkey: string, plainText: string): Promise<string> {
|
||||
return this.sendRequest(NIP46_METHOD.NIP04_ENCRYPT, [pubkey, plainText])
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a message with NIP-04 via the bunker.
|
||||
*/
|
||||
async nip04Decrypt(pubkey: string, cipherText: string): Promise<string> {
|
||||
return this.sendRequest(NIP46_METHOD.NIP04_DECRYPT, [pubkey, cipherText])
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected to the bunker.
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
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(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
this.connected = false
|
||||
this.pendingRequests.forEach((pending) => {
|
||||
clearTimeout(pending.timeout)
|
||||
pending.reject(new Error('Disconnected'))
|
||||
})
|
||||
this.pendingRequests.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bunker's public key.
|
||||
*/
|
||||
getBunkerPubkey(): string {
|
||||
return this.bunkerPubkey
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relay URLs.
|
||||
*/
|
||||
getRelayUrls(): string[] {
|
||||
return this.relayUrls
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bunker URL for sharing.
|
||||
*/
|
||||
getBunkerUrl(): string {
|
||||
return buildBunkerUrl(this.bunkerPubkey, this.relayUrls)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useDeletedEvent } from '../DeletedEventProvider'
|
||||
import { usePasswordPrompt } from '../PasswordPromptProvider'
|
||||
import { BunkerSigner, parseBunkerUrl } from './bunker.signer'
|
||||
import { Nip07Signer } from './nip-07.signer'
|
||||
import { NpubSigner } from './npub.signer'
|
||||
import { NsecSigner } from './nsec.signer'
|
||||
@@ -66,6 +67,7 @@ type TNostrContext = {
|
||||
ncryptsecLogin: (ncryptsec: string) => Promise<string>
|
||||
nip07Login: () => Promise<string>
|
||||
npubLogin(npub: string): Promise<string>
|
||||
bunkerLogin: (bunkerUrl: string) => Promise<string>
|
||||
removeAccount: (account: TAccountPointer) => void
|
||||
/**
|
||||
* Default publish the event to current relays, user's write relays and additional relays
|
||||
@@ -523,6 +525,25 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
const bunkerLogin = async (bunkerUrl: string) => {
|
||||
try {
|
||||
const { pubkey: bunkerPubkey, relays, secret } = parseBunkerUrl(bunkerUrl)
|
||||
const bunkerSigner = new BunkerSigner(bunkerPubkey, relays, secret)
|
||||
await bunkerSigner.init()
|
||||
const pubkey = await bunkerSigner.getPublicKey()
|
||||
return login(bunkerSigner, {
|
||||
pubkey,
|
||||
signerType: 'bunker',
|
||||
bunkerPubkey,
|
||||
bunkerRelays: relays,
|
||||
bunkerSecret: secret
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error(t('Bunker login failed') + ': ' + (err as Error).message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const loginWithAccountPointer = async (act: TAccountPointer): Promise<string | null> => {
|
||||
let account = storage.findAccount(act)
|
||||
if (!account) {
|
||||
@@ -568,6 +589,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
storage.addAccount(account)
|
||||
}
|
||||
return login(npubSigner, account)
|
||||
} else if (account.signerType === 'bunker' && account.bunkerPubkey && account.bunkerRelays) {
|
||||
try {
|
||||
const bunkerSigner = new BunkerSigner(
|
||||
account.bunkerPubkey,
|
||||
account.bunkerRelays,
|
||||
account.bunkerSecret
|
||||
)
|
||||
await bunkerSigner.init()
|
||||
return login(bunkerSigner, account)
|
||||
} catch (err) {
|
||||
console.error('Failed to reconnect to bunker:', err)
|
||||
toast.error(t('Failed to reconnect to bunker'))
|
||||
return null
|
||||
}
|
||||
}
|
||||
storage.removeAccount(account)
|
||||
return null
|
||||
@@ -792,6 +827,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
ncryptsecLogin,
|
||||
nip07Login,
|
||||
npubLogin,
|
||||
bunkerLogin,
|
||||
removeAccount,
|
||||
publish,
|
||||
attemptDelete,
|
||||
|
||||
458
src/services/cashu-token.service.ts
Normal file
458
src/services/cashu-token.service.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* 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'
|
||||
} 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 }
|
||||
5
src/types/index.d.ts
vendored
5
src/types/index.d.ts
vendored
@@ -98,7 +98,7 @@ export interface ISigner {
|
||||
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
|
||||
}
|
||||
|
||||
export type TSignerType = 'nsec' | 'nip-07' | 'browser-nsec' | 'ncryptsec' | 'npub'
|
||||
export type TSignerType = 'nsec' | 'nip-07' | 'browser-nsec' | 'ncryptsec' | 'npub' | 'bunker'
|
||||
|
||||
export type TAccount = {
|
||||
pubkey: string
|
||||
@@ -106,6 +106,9 @@ export type TAccount = {
|
||||
ncryptsec?: string
|
||||
nsec?: string
|
||||
npub?: string
|
||||
bunkerPubkey?: string
|
||||
bunkerRelays?: string[]
|
||||
bunkerSecret?: string
|
||||
}
|
||||
|
||||
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
||||
|
||||
Reference in New Issue
Block a user