This document describes the persistent relay storage system implemented for Mutable using NIP-78 (Application-specific Data). This feature enables multi-device synchronization of important user settings and data.
-
relayStorage.ts - Low-level NIP-78 implementation
- Publishes kind:30078 events with app-specific d-tags
- Handles encryption/decryption using NIP-04
- Fetches and syncs data from relays
-
syncManager.ts - Coordination service
- Orchestrates syncing across all data types
- Provides status tracking and error reporting
- Exposes unified API for sync operations
-
Service Layer - Data-specific services
protectionService.ts- Protected users (Decimator immunity)blacklistService.ts- Blacklisted pubkeys (removed inactive profiles)preferencesService.ts- App preferences (theme, onboarding)importedPacksService.ts- Imported pack tracking
-
useRelaySync Hook - React integration
- Provides relay-aware methods for UI components
- Automatically publishes changes after modifications
- Simplifies integration with existing components
-
useAuth Hook Integration
- Triggers automatic sync on login
- Syncs on session restore
- Fire-and-forget approach (non-blocking)
User Action
↓
Component calls useRelaySync hook
↓
Service updates localStorage
↓
Service publishes to relay (async, non-blocking)
↓
Relay storage updated across devices
- D-tag:
mutable:protected-users - Encryption: Yes (NIP-04)
- Purpose: Users protected from Decimator feature
- Structure:
{ version: 1, timestamp: number, users: [{ pubkey: string, addedAt: number, reason?: string }] }
- D-tag:
mutable:blacklist - Encryption: Yes (NIP-04)
- Purpose: Prevent re-import of removed inactive profiles
- Structure:
{ version: 1, timestamp: number, pubkeys: string[] }
- D-tag:
mutable:preferences - Encryption: No (non-sensitive)
- Purpose: App settings (theme, onboarding status)
- Structure:
{ version: 1, timestamp: number, theme?: 'light' | 'dark', hasCompletedOnboarding?: boolean, [key: string]: unknown }
- D-tag:
mutable:imported-packs - Encryption: No (tracking data)
- Purpose: Track which community packs have been imported
- Structure:
{ version: 1, timestamp: number, packs: { [packId: string]: { importedAt: number, itemsImported: number } } }
- Triggered on login (after successful NIP-07 connection)
- Triggered on session restore (page reload with existing session)
- Non-blocking (doesn't prevent user from using the app)
- Available via "Sync Now" button in Settings page
- Shows real-time sync status
- Displays synced services and any errors
- Strategy: Timestamp-based (newest wins)
- Process:
- Fetch data from relay
- Compare timestamps
- Use newer version
- Publish if local is newer
- localStorage serves as offline cache
- Changes are saved locally immediately
- Synced to relay when connection is available
- Protected users and blacklist are encrypted using NIP-04
- Data is encrypted to user's own pubkey (self-encryption)
- Requires NIP-07 extension with nip04 support
- Preferences and imported packs are not encrypted
- These contain non-sensitive tracking information
- Allows for future public statistics or analytics
- Relay Storage Sync section displays:
- Online/offline status
- Last sync timestamp
- Synced services (with checkmarks)
- Error messages (if any)
- Manual sync button
- Polls every 2 seconds for status changes
- Shows spinner during active sync
- Auto-dismisses success/error messages after 5 seconds
import { useRelaySync } from '@/hooks/useRelaySync';
function MyComponent() {
const { addProtection, isOnline } = useRelaySync();
const handleProtect = async (pubkey: string) => {
// This automatically syncs to relay
await addProtection(pubkey, 'Important user');
};
return (
<div>
{isOnline && <span>✓ Synced to relays</span>}
<button onClick={() => handleProtect(somePubkey)}>
Protect User
</button>
</div>
);
}import { protectionService } from '@/lib/protectionService';
// Sync with relay
await protectionService.syncWithRelay(userPubkey, relays);
// Fetch latest from relay
const records = await protectionService.fetchFromRelay(userPubkey, relays);
// Publish current state
await protectionService.publishToRelay(userPubkey, relays);- Backup Storage: Enhanced imported packs format to include individual item values
- Version Migration: Handle schema version changes gracefully
- Selective Sync: Allow users to choose which data types to sync
- Sync Scheduling: Periodic background sync
- Conflict Resolution UI: Let users manually resolve conflicts
- Compression: Compress large data sets before publishing
- Delta Sync: Only sync changes instead of full state
- Imported Packs: Currently only tracks which packs are imported, not individual items
- No Conflict UI: Timestamp-based resolution is automatic (no user choice)
- Sync Errors: Errors are logged but not retried automatically
- Large Data: No compression or pagination for large datasets
- Login triggers sync
- Page reload restores and syncs data
- Adding protected user syncs to relay
- Removing protected user syncs to relay
- Manual sync button works
- Sync status updates correctly
- Multi-device sync works (same account, different browsers)
- Offline changes sync when back online
- Encrypted data is encrypted (check relay events)
- Non-encrypted data is readable (check relay events)
# Build succeeds without errors
npm run build
# Check for TypeScript errors
npx tsc --noEmit
# Check for console errors in browser
# Open DevTools console and watch for errors during syncImplementation by Claude (Anthropic) with guidance from the Mutable project maintainer.
Sources: