- Add 'bunker' to SignerType with isRemote getter and displayName - Parse CAT token from bunker URL (?cat= parameter) - Pass CAT token to BunkerSigner constructor - Store bunkerCatToken in account for reconnection - Add deploy command documentation Files modified: - src/domain/identity/SignerType.ts: Add bunker signer type - src/providers/NostrProvider/bunker.signer.ts: Parse and use CAT tokens - src/providers/NostrProvider/index.tsx: Pass CAT to login/reconnect - src/types/index.d.ts: Add bunkerCatToken to TAccount 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
809 lines
23 KiB
TypeScript
809 lines
23 KiB
TypeScript
/**
|
|
* 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>&cat=<token>).
|
|
*/
|
|
export function parseBunkerUrl(url: string): {
|
|
pubkey: string
|
|
relays: string[]
|
|
secret?: string
|
|
catToken?: 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
|
|
const catToken = params.get('cat') || undefined
|
|
|
|
if (relays.length === 0) {
|
|
throw new Error('Invalid bunker URL: no relay specified')
|
|
}
|
|
|
|
return {
|
|
pubkey: pubkeyPart,
|
|
relays,
|
|
secret,
|
|
catToken
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a nostr+connect URL (nostr+connect://<relay-url>?pubkey=<client-pubkey>&secret=<secret>).
|
|
* This is the format that signers (like Amber) scan to connect to a client.
|
|
*/
|
|
export function parseNostrConnectUrl(url: string): {
|
|
relay: string
|
|
pubkey?: string
|
|
secret?: string
|
|
} {
|
|
if (!url.startsWith('nostr+connect://')) {
|
|
throw new Error('Invalid nostr+connect URL: must start with nostr+connect://')
|
|
}
|
|
|
|
const withoutPrefix = url.slice('nostr+connect://'.length)
|
|
const [relayPart, queryPart] = withoutPrefix.split('?')
|
|
|
|
if (!relayPart) {
|
|
throw new Error('Invalid nostr+connect URL: missing relay')
|
|
}
|
|
|
|
const params = new URLSearchParams(queryPart || '')
|
|
const pubkey = params.get('pubkey') || undefined
|
|
const secret = params.get('secret') || undefined
|
|
|
|
return {
|
|
relay: relayPart,
|
|
pubkey,
|
|
secret
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a nostr+connect URL for signers to scan.
|
|
* @param relay - The relay URL (without ws:// prefix, will be added)
|
|
* @param pubkey - The client's ephemeral pubkey for this session
|
|
* @param secret - Optional secret for the handshake
|
|
*/
|
|
export function buildNostrConnectUrl(relay: string, pubkey: string, secret?: string): string {
|
|
// Ensure relay URL uses the relay host without protocol
|
|
let relayHost = relay
|
|
.replace('wss://', '')
|
|
.replace('ws://', '')
|
|
.replace('https://', '')
|
|
.replace('http://', '')
|
|
.replace(/\/$/, '')
|
|
|
|
const params = new URLSearchParams()
|
|
params.set('pubkey', pubkey)
|
|
if (secret) {
|
|
params.set('secret', secret)
|
|
}
|
|
return `nostr+connect://${relayHost}?${params.toString()}`
|
|
}
|
|
|
|
/**
|
|
* Build a bunker URL from components.
|
|
*/
|
|
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
|
|
|
|
// Whether we're waiting for signer to connect (reverse flow)
|
|
private awaitingConnection = false
|
|
private connectionResolve: ((pubkey: string) => void) | null = null
|
|
|
|
/**
|
|
* 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
|
|
* @param catToken - Optional CAT token (encoded string) for authorization
|
|
*/
|
|
constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string, catToken?: string) {
|
|
this.bunkerPubkey = bunkerPubkey
|
|
this.relayUrls = relayUrls
|
|
this.connectionSecret = connectionSecret
|
|
|
|
// Decode CAT token if provided
|
|
if (catToken) {
|
|
try {
|
|
this.token = cashuTokenService.decodeToken(catToken)
|
|
} catch (err) {
|
|
console.warn('Failed to decode CAT token from URL:', err)
|
|
}
|
|
}
|
|
|
|
// Generate local ephemeral keypair for NIP-46 communication
|
|
this.localPrivkey = secp256k1.utils.randomPrivateKey()
|
|
this.localPubkey = nGetPublicKey(this.localPrivkey)
|
|
}
|
|
|
|
/**
|
|
* Create a BunkerSigner that waits for a signer (like Amber) to connect.
|
|
* Returns the nostr+connect URL to display as QR code and a promise for the connected signer.
|
|
*
|
|
* @param relayUrl - The relay URL for the connection
|
|
* @param secret - Optional secret for the handshake
|
|
* @param timeout - Connection timeout in ms (default 120000 = 2 minutes)
|
|
*/
|
|
static async awaitSignerConnection(
|
|
relayUrl: string,
|
|
secret?: string,
|
|
timeout = 120000
|
|
): Promise<{ connectUrl: string; signer: Promise<BunkerSigner> }> {
|
|
// Generate ephemeral keypair for this session
|
|
const localPrivkey = secp256k1.utils.randomPrivateKey()
|
|
const localPubkey = nGetPublicKey(localPrivkey)
|
|
|
|
// Generate secret if not provided
|
|
const connectionSecret = secret || generateRequestId()
|
|
|
|
// Build the nostr+connect URL for signer to scan
|
|
const connectUrl = buildNostrConnectUrl(relayUrl, localPubkey, connectionSecret)
|
|
|
|
// Create signer instance (bunkerPubkey will be set when signer connects)
|
|
const signer = new BunkerSigner('', [relayUrl], connectionSecret)
|
|
signer.localPrivkey = localPrivkey
|
|
signer.localPubkey = localPubkey
|
|
signer.awaitingConnection = true
|
|
|
|
// Return URL immediately, signer promise resolves when connected
|
|
const signerPromise = new Promise<BunkerSigner>((resolve, reject) => {
|
|
signer.connectionResolve = (signerPubkey: string) => {
|
|
signer.bunkerPubkey = signerPubkey
|
|
signer.remotePubkey = signerPubkey
|
|
signer.awaitingConnection = false
|
|
resolve(signer)
|
|
}
|
|
// Set timeout
|
|
setTimeout(() => {
|
|
if (signer.awaitingConnection) {
|
|
signer.disconnect()
|
|
reject(new Error('Connection timeout waiting for signer'))
|
|
}
|
|
}, timeout)
|
|
|
|
// Connect to relay and wait
|
|
signer.connectAndWait(relayUrl).catch(reject)
|
|
})
|
|
|
|
return { connectUrl, signer: signerPromise }
|
|
}
|
|
|
|
/**
|
|
* Connect to relay and wait for signer to initiate connection.
|
|
*/
|
|
private async connectAndWait(relayUrl: string): Promise<void> {
|
|
await this.acquireTokenIfNeeded(relayUrl)
|
|
await this.connectToRelayAndListen(relayUrl)
|
|
}
|
|
|
|
/**
|
|
* Connect to relay and listen for incoming connect requests.
|
|
*/
|
|
private async connectToRelayAndListen(relayUrl: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
let wsUrl = relayUrl
|
|
if (relayUrl.startsWith('http://')) {
|
|
wsUrl = 'ws://' + relayUrl.slice(7)
|
|
} else if (relayUrl.startsWith('https://')) {
|
|
wsUrl = 'wss://' + relayUrl.slice(8)
|
|
} else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
|
|
wsUrl = 'wss://' + relayUrl
|
|
}
|
|
|
|
// Add token if available
|
|
if (this.token) {
|
|
const tokenEncoded = cashuTokenService.encodeToken(this.token)
|
|
const url = new URL(wsUrl)
|
|
url.searchParams.set('token', tokenEncoded)
|
|
wsUrl = url.toString()
|
|
}
|
|
|
|
const ws = new WebSocket(wsUrl)
|
|
|
|
const timeout = setTimeout(() => {
|
|
ws.close()
|
|
reject(new Error('Connection timeout'))
|
|
}, 10000)
|
|
|
|
ws.onopen = () => {
|
|
clearTimeout(timeout)
|
|
this.ws = ws
|
|
this.connected = true
|
|
|
|
// Subscribe to events for our local pubkey
|
|
const subId = generateRequestId()
|
|
ws.send(
|
|
JSON.stringify([
|
|
'REQ',
|
|
subId,
|
|
{
|
|
kinds: [24133],
|
|
'#p': [this.localPubkey],
|
|
since: Math.floor(Date.now() / 1000) - 60
|
|
}
|
|
])
|
|
)
|
|
|
|
resolve()
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error('WebSocket error'))
|
|
}
|
|
|
|
ws.onclose = () => {
|
|
this.connected = false
|
|
this.ws = null
|
|
}
|
|
|
|
ws.onmessage = (event) => {
|
|
this.handleMessage(event.data)
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Get the local public key (for displaying in nostr+connect URL).
|
|
*/
|
|
getLocalPubkey(): string {
|
|
return this.localPubkey
|
|
}
|
|
|
|
/**
|
|
* Set the Cashu token for authentication.
|
|
*/
|
|
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
|
|
}
|
|
|
|
await infoResponse.json() // Validate JSON response
|
|
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 parsed = JSON.parse(decrypted)
|
|
|
|
// Check if this is an incoming connect request (signer initiating connection)
|
|
if (this.awaitingConnection && parsed.method === 'connect') {
|
|
const request = parsed as NIP46Request
|
|
console.log('Received connect request from signer:', event.pubkey)
|
|
|
|
// Verify secret if we have one
|
|
if (this.connectionSecret) {
|
|
const providedSecret = request.params[1] // Second param is the secret
|
|
if (providedSecret !== this.connectionSecret) {
|
|
console.warn('Connect request has wrong secret, ignoring')
|
|
return
|
|
}
|
|
}
|
|
|
|
// Send ack response
|
|
const response: NIP46Response = {
|
|
id: request.id,
|
|
result: 'ack'
|
|
}
|
|
const encrypted = await nip04.encrypt(this.localPrivkey, event.pubkey, JSON.stringify(response))
|
|
const responseEvent: TDraftEvent = {
|
|
kind: 24133,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
content: encrypted,
|
|
tags: [['p', event.pubkey]]
|
|
}
|
|
const signedResponse = finalizeEvent(responseEvent, this.localPrivkey)
|
|
this.ws?.send(JSON.stringify(['EVENT', signedResponse]))
|
|
|
|
// Resolve the connection promise
|
|
if (this.connectionResolve) {
|
|
this.connectionResolve(event.pubkey)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Handle as normal response
|
|
const response = parsed as NIP46Response
|
|
const pending = this.pendingRequests.get(response.id)
|
|
|
|
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)
|
|
}
|
|
}
|