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)
}
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:
- Compress: Use
compressMessage() to minimize size
- Wrap: Create NIP-17 gift wrap with recipient key
- Seal: Additional NIP-59 sealing layer
- 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:
- Generate Keys: Automatic on first access
- Select Relays: Choose trusted Nostr relays
- Enable Auto-Sync: Toggle real-time synchronization
- Announce Device: Broadcast device presence
- 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