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:
mleku
2025-12-28 19:33:19 +02:00
parent 2aa0a8c460
commit a268c63082
30 changed files with 1458 additions and 53 deletions

View File

@@ -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",

View 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>
)
}

View File

@@ -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)

View File

@@ -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) => {

View File

@@ -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} />

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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 = {

View File

@@ -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?': 'هل أنت متأكد تماماً؟',

View File

@@ -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?',

View File

@@ -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?',

View File

@@ -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?',

View File

@@ -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?': 'آیا کاملاً مطمئن هستید؟',

View File

@@ -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 ?',

View File

@@ -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?': 'क्या आप पूरी तरह से सुनिश्चित हैं?',

View File

@@ -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?',

View File

@@ -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?',

View File

@@ -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?': '本当に確かですか?',

View File

@@ -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?': '정말 확실합니까?',

View File

@@ -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?',

View File

@@ -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?',

View File

@@ -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?',

View File

@@ -434,7 +434,6 @@ export default {
'Ретрансляторы записи и {{count}} других ретрансляторов',
'{{count}} relays': '{{count}} ретрансляторов',
'Republishing...': 'Ретрансляция...',
'Trending Notes': 'Популярные заметки',
'Connected to': 'Подключено к',
'Disconnect Wallet': 'Отключить кошелёк',
'Are you absolutely sure?': 'Вы абсолютно уверены?',

View File

@@ -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?': 'คุณแน่ใจอย่างยิ่งหรือไม่?',

View File

@@ -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?': '您確定嗎?',

View File

@@ -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?': '您确定吗?',

View 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)
}
}

View File

@@ -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,

View 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 }

View File

@@ -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'>