Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/satsigner/satsigner/llms.txt

Use this file to discover all available pages before exploring further.

Nostr Label Synchronization

SatSigner enables privacy-preserving label synchronization across multiple devices using the Nostr protocol. Share address labels securely with your other devices through encrypted direct messages.

Overview

Nostr sync allows you to:
  • Sync address labels across unlimited devices
  • Use encrypted messages (NIP-04 and NIP-17) for privacy
  • Select trusted Nostr relays for message routing
  • Maintain device-specific identities with unique keypairs
  • Share labels with trusted co-signers in multisig setups
  • Auto-sync labels in real-time when devices are online

Architecture

Key Hierarchy

Each account uses two sets of Nostr keys: Common Keys (Account-level)
  • Shared across all devices for the same account
  • Derived deterministically from Bitcoin account descriptor
  • Used as the “group chat” recipient for label broadcasts
// Generated once per account
const commonKeys = {
  commonNsec: 'nsec1...',  // Secret key
  commonNpub: 'npub1...'   // Public key
}
Device Keys (Device-specific)
  • Unique to each device
  • Randomly generated on first sync setup
  • Identifies which device sent which labels
// Generated per device
const deviceKeys = {
  deviceNsec: 'nsec1...',  // Device secret key
  deviceNpub: 'npub1...'   // Device public key
}

Message Flow

Device A                  Nostr Relays                Device B
   |                           |                           |
   |-- Encrypt & Publish -->  |                           |
   |     Kind 1059             |  <-- Subscribe Kind 1059 --|
   |                           |-- Deliver Message -->     |
   |                           |                           |
   |                           |  <-- Device Announcement --|
   |<-- New Device Found ------|                           |

Implementation

Nostr API Client

SatSigner uses a custom Nostr client built on NDK (Nostr Development Kit):
// apps/mobile/api/nostr.ts:10
export class NostrAPI {
  private ndk: NDK | null = null
  private activeSubscriptions: Set<NDKSubscription> = new Set()
  private processedMessageIds: Set<string> = new Set()
  private eventQueue: NostrMessage[] = []
  
  constructor(private relays: string[]) {
    if (!relays || relays.length === 0) {
      // Default relays
      this.relays = [
        'wss://relay.damus.io',
        'wss://nostr.bitcoiner.social',
        'wss://relay.nostr.band',
        'wss://nostr.mom'
      ]
    }
  }
}

Connecting to Relays

// apps/mobile/api/nostr.ts:43
async connect() {
  if (!this.ndk) {
    this.ndk = new NDK({
      explicitRelayUrls: this.relays
    })
  }
  
  await this.ndk.connect()
  await this.ndk.pool.connect()
  
  const connectedRelays = Array.from(this.ndk.pool.relays.keys())
  
  if (connectedRelays.length === 0) {
    throw new Error('No relays could be connected')
  }
  
  // Test each relay with retry logic
  const relayStatus = await Promise.all(
    connectedRelays.map(async (url) => {
      for (let attempt = 0; attempt < 3; attempt++) {
        try {
          const relay = this.ndk.pool.relays.get(url)
          const testEvent = await this.ndk.fetchEvent(
            { kinds: [1], limit: 1 },
            { relayUrl: url }
          )
          return { url, status: 'connected', testEvent: testEvent !== null }
        } catch (error) {
          if (attempt === 2) return { url, status: 'error' }
          await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)))
        }
      }
    })
  )
  
  const workingRelays = relayStatus.filter(r => r.status === 'connected')
  if (workingRelays.length === 0) {
    throw new Error('No relays are responding')
  }
  
  return true
}

Generating Keys

Common Keys (Deterministic) Derived from account descriptor using cryptographic hash:
const generateCommonNostrKeys = async (account: Account) => {
  // Use account descriptor as seed
  const seed = await hashDescriptor(account.descriptor)
  const keyBytes = new Uint8Array(seed)
  
  const signer = new NDKPrivateKeySigner(keyBytes)
  const user = await signer.user()
  const nsec = nip19.nsecEncode(keyBytes)
  const npub = user.npub
  
  return { commonNsec: nsec, commonNpub: npub }
}
Device Keys (Random)
// apps/mobile/api/nostr.ts:105
static async generateNostrKeys(): Promise<NostrKeys> {
  const randomHex = await randomKey(32)
  const randomBytesArray = new Uint8Array(Buffer.from(randomHex, 'hex'))
  
  const signer = new NDKPrivateKeySigner(randomBytesArray)
  const user = await signer.user()
  const nsec = nip19.nsecEncode(randomBytesArray)
  const npub = user.npub
  
  return {
    nsec,
    npub,
    secretNostrKey: randomBytesArray
  }
}

Message Protocol

Event Types

SatSigner uses Kind 1059 (Gift Wrapped Sealed Direct Message) for all sync messages:
// Subscribe to encrypted messages
const subscriptionQuery = {
  kinds: [1059 as NDKKind],
  '#p': [recipientPubKey],  // Recipient public key
  since: bufferedSince       // Start time (with 48h buffer)
}

Message Formats

Device Announcement
{
  "created_at": 1234567890,
  "public_key_bech32": "npub1...",
  "protocol_version": "1.0",
  "type": "device_announcement"
}
Label Sync
{
  "address": "bc1q...",
  "label": "Exchange deposit",
  "timestamp": 1234567890,
  "device_npub": "npub1...",
  "type": "label_update"
}
Label Deletion
{
  "address": "bc1q...",
  "type": "label_delete",
  "timestamp": 1234567890,
  "device_npub": "npub1..."
}

Encryption (NIP-04)

Messages are encrypted before sending:
  1. Compress: Use compressMessage() to minimize size
  2. Wrap: Create NIP-17 gift wrap with recipient key
  3. Seal: Additional NIP-59 sealing layer
  4. Publish: Broadcast as Kind 1059 event
// apps/mobile/api/nostr.ts:220
async createKind1059(
  nsec: string,              // Sender secret key
  recipientNpub: string,     // Recipient public key
  content: string            // Message content
): Promise<NDKEvent> {
  const { data: secretNostrKey } = nip19.decode(nsec)
  const recipientPubkey = nip19.decode(recipientNpub) as { data: string }
  const encodedContent = unescape(encodeURIComponent(content))
  
  const wrap = nip17.wrapEvent(
    secretNostrKey as Uint8Array,
    { publicKey: recipientPubkey.data },
    encodedContent
  )
  
  const tempNdk = new NDK()
  const event = new NDKEvent(tempNdk, wrap)
  return event
}

Decryption

// apps/mobile/api/nostr.ts:180
subscription.on('event', async (event) => {
  const rawEvent = await event.toNostrEvent()
  
  // Unwrap NIP-59 sealed event
  const unwrappedEvent = nip59.unwrapEvent(
    rawEvent as unknown as Event,
    recipientSecretNostrKey as Uint8Array
  )
  
  const message = {
    id: unwrappedEvent.id,
    content: unwrappedEvent,
    created_at: unwrappedEvent.created_at,
    pubkey: event.pubkey
  }
  
  // Add to processing queue
  this.eventQueue.push(message)
  this.processQueue()
})

Subscription Management

Real-time Subscriptions

// apps/mobile/api/nostr.ts:147
async subscribeToKind1059(
  recipientNsec: string,
  recipientNpub: string,
  callback: (message: NostrMessage) => void,
  limit?: number,
  since?: number,
  onEOSE?: (nsec: string) => void
): Promise<void> {
  await this.connect()
  
  const { data: recipientSecretNostrKey } = nip19.decode(recipientNsec)
  const { data: recipientPubKey } = nip19.decode(recipientNpub)
  
  // Buffer since by 2 days to catch delayed messages
  const TWO_DAYS = 48 * 60 * 60
  const bufferedSince = since ? since - TWO_DAYS : undefined
  
  const subscriptionQuery = {
    kinds: [1059 as NDKKind],
    '#p': [recipientPubKey.toString()],
    ...(limit && { limit }),
    since: bufferedSince
  }
  
  const subscription = this.ndk.subscribe(subscriptionQuery)
  this.activeSubscriptions.add(subscription)
  
  subscription.on('event', async (event) => {
    // Decrypt and process event
  })
  
  subscription.on('eose', () => {
    onEOSE?.(recipientNsec)
    this.setLoading(false)
  })
  
  subscription.on('close', () => {
    this.activeSubscriptions.delete(subscription)
  })
}

Queue Processing

Messages are queued and processed in batches to prevent UI blocking:
// apps/mobile/api/nostr.ts:122
private async processQueue() {
  if (this.isProcessingQueue || this.eventQueue.length === 0) return
  
  this.isProcessingQueue = true
  const batch = this.eventQueue.splice(0, this.BATCH_SIZE)  // Process 10 at a time
  
  for (const message of batch) {
    if (!this.processedMessageIds.has(message.id)) {
      this.processedMessageIds.add(message.id)
      try {
        this._callback?.(message)
      } catch {
        toast.error('Failed to process message')
      }
    }
  }
  
  this.isProcessingQueue = false
  if (this.eventQueue.length > 0) {
    setTimeout(() => this.processQueue(), this.PROCESSING_INTERVAL)  // Wait 200ms
  }
}

Publishing Messages

Broadcast to Relays

// apps/mobile/api/nostr.ts:239
async publishEvent(event: NDKEvent): Promise<void> {
  if (!this.ndk) await this.connect()
  
  const connectedRelays = Array.from(this.ndk.pool.relays.keys())
  
  if (event.ndk !== this.ndk) {
    event.ndk = this.ndk
  }
  
  if (!event.sig) {
    const signer = this.ndk.signer
    if (!signer) throw new Error('No signer available')
    await event.sign(signer)
  }
  
  let published = false
  const publishPromises = connectedRelays.map(async (url) => {
    const relay = this.ndk.pool.relays.get(url)
    if (!relay) return { url, success: false, error: 'Relay not found' }
    
    try {
      await relay.publish(event)
      return { url, success: true }
    } catch (error) {
      return { url, success: false, error }
    }
  })
  
  const results = await Promise.all(publishPromises)
  const successfulPublishes = results.filter(r => r.success)
  
  if (successfulPublishes.length > 0) {
    published = true
  }
  
  if (!published) {
    throw new Error('Failed to publish after 3 attempts')
  }
}

Sync Configuration

Setup Process

From the account settings:
  1. Generate Keys: Automatic on first access
  2. Select Relays: Choose trusted Nostr relays
  3. Enable Auto-Sync: Toggle real-time synchronization
  4. Announce Device: Broadcast device presence
  5. Trust Devices: Mark other devices as trusted

Relay Selection

Choose relays based on:
  • Reliability: Uptime and performance
  • Privacy: No logging policies
  • Decentralization: Geographic distribution
  • Speed: Low latency for your region
Default Relays:
  • wss://relay.damus.io
  • wss://nostr.bitcoiner.social
  • wss://relay.nostr.band
  • wss://nostr.mom

Auto-Sync Toggle

// apps/mobile/app/.../nostr/index.tsx:265
const handleToggleAutoSync = async () => {
  if (!accountId || !account) return
  
  if (account.nostr.autoSync) {
    // Turn sync OFF
    await cleanupSubscriptions()
    updateAccountNostrCallback(accountId, {
      ...account.nostr,
      autoSync: false,
      relayStatuses: allRelaysDisconnected
    })
  } else {
    // Turn sync ON
    updateAccountNostrCallback(accountId, {
      ...account.nostr,
      autoSync: true
    })
    
    // Test relay connections
    await testRelaySync(account.nostr.relays)
    
    // Start subscriptions
    deviceAnnouncement(account)
    await nostrSyncSubscriptions(account, setIsSyncing)
  }
}

Device Management

Device Discovery

Devices announce themselves periodically:
const deviceAnnouncement = async (account: Account) => {
  if (!account.nostr?.deviceNpub || !account.nostr?.relays) return
  
  const message = {
    created_at: Math.floor(Date.now() / 1000),
    public_key_bech32: account.nostr.deviceNpub,
    protocol_version: '1.0',
    type: 'device_announcement'
  }
  
  const compressedMessage = compressMessage(message)
  const event = await nostrApi.createKind1059(
    account.nostr.commonNsec,
    account.nostr.commonNpub,
    compressedMessage
  )
  
  await nostrApi.publishEvent(event)
}

Trusting Devices

Only trusted devices’ labels are imported:
// apps/mobile/app/.../nostr/index.tsx:381
const toggleMember = (npub: string) => {
  const isCurrentlyTrusted = selectedMembers.has(npub)
  
  if (isCurrentlyTrusted) {
    // Distrust device
    updateAccountNostrCallback(accountId, {
      trustedMemberDevices: account.nostr.trustedMemberDevices.filter(
        m => m !== npub
      )
    })
  } else {
    // Trust device
    updateAccountNostrCallback(accountId, {
      trustedMemberDevices: [...account.nostr.trustedMemberDevices, npub]
    })
  }
}

Device Aliases

Assign friendly names to device public keys:
{
  "npubAliases": {
    "npub1abc...": "iPhone 15",
    "npub1xyz...": "MacBook Pro",
    "npub1def...": "Co-signer Alice"
  }
}

Privacy & Security

Encryption Guarantees

  • NIP-04: Base encryption standard
  • NIP-17: Gift-wrapped sealed sender
  • NIP-59: Additional sealing layer
Relays cannot:
  • Read message contents (end-to-end encrypted)
  • Link senders to messages (sealed sender)
  • Determine message recipients (gift wrap)

Key Security

Common Keys
  • Derived from descriptor (never transmitted)
  • Same across all your devices
  • Never share with untrusted parties
Device Keys
  • Randomly generated per device
  • Stored in secure storage
  • Backup separately from Bitcoin keys

Trust Model

  • Zero-trust relays: Assume relays are adversarial
  • Explicit device trust: Manually approve each device
  • Message authenticity: Verify sender signatures
  • Forward secrecy: Compromise of one device doesn’t expose others

BIP-329 Compatibility

SatSigner’s Nostr sync is compatible with BIP-329 label format:
{
  "type": "tx",
  "ref": "<txid>",
  "label": "<label text>",
  "origin": "nostr"
}
Labels synced via Nostr can be:
  • Exported as BIP-329 JSON
  • Imported from BIP-329 compliant tools
  • Merged with locally created labels

Troubleshooting

”No relays could be connected”

  • Check internet connection
  • Try different relays
  • Verify relay URLs are correct
  • Check firewall/proxy settings

”Failed to publish device announcement”

  • Relays may be temporarily down
  • Check relay connection status
  • Try re-enabling sync
  • Verify keys are correctly generated

Labels not syncing

  • Ensure auto-sync is enabled on both devices
  • Check that devices are trusted
  • Verify both devices use same common keys
  • Check relay connection status
  • Clear caches and resync

Implementation Reference

Nostr API: apps/mobile/api/nostr.ts:1 Sync UI: apps/mobile/app/(authenticated)/(tabs)/(signer,explorer,converter)/signer/bitcoin/account/[id]/settings/nostr/index.tsx:1 Sync Hook: apps/mobile/hooks/useNostrSync.ts:1 (referenced) Type Definitions: apps/mobile/types/models/Nostr.ts:1

Resources