This commit refines the NIP-17 encrypted messaging implementation with
better relay detection, cleaner UI, and comprehensive documentation.
## Core Fixes
### 1. Fix Missing User Inbox Relays (nip-17-adapter.ts)
- **Problem**: When creating conversations, user's own inbox relays were
only checked from cache, not actively fetched if cache was empty
- **Result**: Send failures with "missing relays" even when relays were
fetched and connected elsewhere
- **Solution**: Actively fetch user's inbox relays if cache is empty,
with fallback to cached value for performance
- **Impact**: Reliable relay detection for sending messages
### 2. Improve View-Only Detection (ChatViewer.tsx)
- **Problem**: Used stale `unreachableParticipants` metadata set at
conversation creation time, causing false warnings even after relay
lists loaded
- **Solution**: Added dynamic `canSendMessage` useMemo that checks
current state of `participantInboxRelays` in real-time
- **Impact**: Send button correctly enables/disables as relay lists load
### 3. Cleaner UI (ChatViewer.tsx)
- Removed large yellow warning banners about view-only mode
- Removed "Sending disabled - waiting for relay lists" text in composer
- Send button now simply disables when relay lists are missing
- **Impact**: Less intrusive, cleaner messaging interface
## Additional Improvements
### Cache Readiness Check (gift-wrap.ts)
- Added `waitForCacheReady()` to prevent race condition on page reload
- Waits up to 1s for encrypted content cache to be accessible before
processing conversations
- **Impact**: Fixes "inbox appears empty" issue on page reload
### Simplified Event Caching (nip-17-adapter.ts)
- Removed redundant `syntheticEventCache` WeakMap
- Uses eventStore as single source of truth with O(1) lookup
- **Impact**: Reduced complexity, eventStore already handles deduplication
### Removed Self-Chat Workaround (nip-17-adapter.ts)
- Deleted 70-line custom gift wrap construction for self-chat
- Applesauce's `SendWrappedMessage` works fine for self-chat
- **Impact**: Cleaner code, better maintainability
### Debug Logging System (dm-debug.ts - NEW)
- Added dedicated DM debug logging utilities
- Enable with: `localStorage.setItem('grimoire:debug:dms', 'true')`
- Levels: dmDebug (verbose), dmInfo (important), dmWarn (warnings)
- **Impact**: Better troubleshooting for NIP-17 issues
### Comprehensive Documentation (docs/gift-wrap-architecture.md - NEW)
- 450+ line architecture document
- Component diagrams, data flow, cache strategy
- Security considerations, performance optimizations
- Debugging guide and testing strategy
- **Impact**: Complete reference for gift wrap implementation
## Testing
- ✅ All tests pass (864 tests)
- ✅ Build succeeds with no errors
- ✅ Lint passes (only pre-existing warnings)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
16 KiB
Gift Wrap (NIP-17) Architecture
Overview
This document explains the architecture for encrypted private messaging using NIP-17/59 gift wrap protocol in Grimoire.
Component Diagram
┌─────────────────────────────────────────────────────────────────┐
│ UI Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ InboxViewer │ │ ChatViewer │ │ useAccountSync│ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
└─────────┼──────────────────┼──────────────────┼─────────────────┘
│ │ │
└──────────────────┴──────────────────┘
│
┌────────────────────────────┼─────────────────────────────────────┐
│ Service Layer (Singletons) │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ GiftWrapService │ │
│ │ (gift-wrap.ts) │ │
│ │ │ │
│ │ - Manages gift wrap │ │
│ │ subscriptions │ │
│ │ - Tracks decrypt state │ │
│ │ - Groups conversations │ │
│ │ - Loads inbox relays │ │
│ └─────┬─────────────────────┘ │
│ │ │
│ ┌───────────┼────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │EventStore│ │RelayPool │ │RelayListCache│ │
│ └──────────┘ └──────────┘ └──────────────┘ │
│ │ │ │ │
└────────┼───────────┼────────────┼────────────────────────────────┘
│ │ │
┌────────┼───────────┼────────────┼────────────────────────────────┐
│ Adapter Layer │
│ │ │
│ ┌──────────────▼──────────────┐ │
│ │ Nip17Adapter │ │
│ │ (nip-17-adapter.ts) │ │
│ │ │ │
│ │ - Parses identifiers │ │
│ │ - Resolves conversations │ │
│ │ - Fetches inbox relays │ │
│ │ - Sends messages │ │
│ └──────────┬──────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Applesauce Actions │ │
│ │ - SendWrappedMessage │ │
│ │ - ReplyToWrappedMessage │ │
│ └──────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
Singleton Dependencies
Critical Dependencies (Direct Imports)
GiftWrapService depends on:
eventStore- Singleton EventStore for reactive Nostr event storagepool- Singleton RelayPool for relay connectionsrelayListCache- Singleton cache for user relay lists (kind 10002/10050)encryptedContentStorage- Dexie storage for decrypted rumors
Nip17Adapter depends on:
giftWrapService- For accessing decrypted rumors and conversationsaccountManager- For active account and signereventStore- For creating synthetic events from rumorspool- For fetching inbox relay listsrelayListCache- For cached relay list lookupshub- For executing applesauce actions
Dependency Chain
UI Component
└─> GiftWrapService (singleton)
├─> EventStore (singleton)
├─> RelayPool (singleton)
├─> RelayListCache (singleton)
└─> EncryptedContentStorage (Dexie)
Chat Component
└─> Nip17Adapter
├─> GiftWrapService (singleton)
├─> EventStore (singleton)
├─> AccountManager (singleton)
├─> RelayListCache (singleton)
└─> Hub (action runner singleton)
Why Singletons?
EventStore: Single reactive database for all Nostr events
- Ensures event deduplication
- Provides consistent observables for UI reactivity
- Manages replaceable event logic globally
RelayPool: Single connection manager for all relay connections
- Prevents duplicate WebSocket connections
- Centralizes relay health monitoring
- Manages subscription lifecycle
RelayListCache: Single cache for all user relay lists
- Reduces redundant kind 10002/10050 fetches
- Provides fast relay lookups for any pubkey
- Automatically updates on event arrival
GiftWrapService: Single manager for all gift wrap operations
- Ensures consistent decrypt state across UI
- Prevents duplicate subscription to same gift wraps
- Centralizes inbox relay management
Data Flow
Receiving Messages (Inbox Flow)
- Account Login →
useAccountSynccallsgiftWrapService.init(pubkey, signer) - Fetch Inbox Relays → Load kind 10050 from user's outbox relays
- Subscribe to Gift Wraps → Open subscription to inbox relays for
kind 1059with#p= user pubkey - Gift Wrap Arrival → EventStore receives event → GiftWrapService detects new gift wrap
- Decrypt (if auto-decrypt enabled) → Call
unlockGiftWrap(event, signer) - Extract Rumor → Get kind 14 DM from gift wrap inner content
- Group into Conversations → Compute conversation ID from participants → Update
conversations$observable - UI Update → InboxViewer/ChatViewer re-renders with new messages
Sending Messages (Outbox Flow)
- User Types Message → ChatViewer captures content
- Resolve Recipients → Nip17Adapter resolves pubkeys from identifiers
- Fetch Inbox Relays → Get kind 10050 for each recipient (with 10s timeout)
- Validate Relays → Block if any recipient has no inbox relays
- Create Rumor → Build kind 14 unsigned event with content and tags
- Wrap for Each Recipient → Create kind 1059 gift wrap for each recipient
- Publish → Send to recipient's inbox relays via
hub.run(SendWrappedMessage) - Local Availability → EventStore adds sent gift wraps → GiftWrapService processes → Messages appear in UI
State Management
Observable Streams
GiftWrapService exposes these observables:
giftWraps$- All gift wrap events for current userdecryptStates$- Map of gift wrap ID → decrypt status (pending/success/error)decryptedRumors$- All decrypted rumors (kind 14 and other kinds)conversations$- Grouped conversations (NIP-17 kind 14 only)inboxRelays$- User's inbox relays from kind 10050settings$- Inbox settings (enabled, autoDecrypt)syncStatus$- Current sync state (idle/syncing/error/disabled)pendingCount$- Count of pending decryptions for UI badge
Lifecycle
Init (on account login):
giftWrapService.init(pubkey, signer)
1. Load persisted encrypted content IDs from Dexie
2. Wait for cache readiness (prevents race condition)
3. Subscribe to user's kind 10050 (inbox relays)
4. Load stored gift wraps from Dexie into EventStore
5. Subscribe to EventStore timeline for real-time updates
6. Open persistent relay subscription for new gift wraps
Cleanup (on account logout):
giftWrapService.cleanup()
1. Unsubscribe from all observables
2. Close relay subscription
3. Clear in-memory state
Cache Strategy
Encrypted Content Persistence
Problem: Decrypting gift wraps on every page load is slow and redundant.
Solution: Applesauce automatically persists decrypted rumors to Dexie:
encryptedContenttable stores gift wrap ID → plaintext rumor JSONpersistedIdsSet tracks which gift wraps have cached content- On reload, check
persistedIdsbefore marking as "pending"
Cache Readiness Check:
- Wait for Dexie to be accessible before processing conversations
- Prevents race condition where
persistedIdssays "unlocked" butgetGiftWrapRumor()returnsnull - Max 1 second wait with exponential backoff
Synthetic Events
Problem: Rumors are unsigned events (no sig field), need to be converted to NostrEvent for UI rendering.
Solution: EventStore as single source of truth:
- Convert rumors to synthetic
NostrEventwith emptysigfield - Check
eventStore.database.getEvent(rumor.id)before creating (O(1) lookup) - Add to EventStore which handles deduplication automatically by event ID
- No additional cache needed - EventStore provides fast lookups and deduplication
Security Considerations
Inbox Relay Validation
Design Philosophy: Separate viewing from sending.
Viewing Messages (Always Allowed):
- Conversations can be created even without recipient relay lists
- Received messages are already in your inbox - no relay list needed to view them
- This allows reading existing messages while relay lists are being fetched
Sending Messages (Requires Relay Lists):
- Fetch inbox relays with 10s timeout
- Flag unreachable participants in conversation metadata
- Block
sendMessage()if ANY recipient has no inbox relays - Show UI warnings:
- Yellow banner: "View-only: Cannot send messages until participants publish inbox relays"
- Disabled composer: "Sending disabled - waiting for relay lists"
Benefits:
- No "conversation failed to load" errors when relay lists are slow
- Users can immediately see existing message history
- Clear UI feedback about why sending is blocked
- Relay lists can be fetched in background without blocking UI
Auto-Enable Inbox Sync
Default: Inbox sync is auto-enabled on first login to ensure users receive DMs.
Rationale: Better UX to auto-enable with opt-out than require manual setup.
User Control: Settings UI allows disabling inbox sync and auto-decrypt.
Relay List Privacy
Inbox Relays (kind 10050): Published to user's outbox relays for discoverability.
- Anyone can query your inbox relays to send you DMs
- This is by design per NIP-17 spec
- Users control which relays are in their inbox list
Performance Optimizations
Parallel Relay Fetching
When starting conversation with multiple participants:
const results = await Promise.all(
others.map(async (pubkey) => ({
pubkey,
relays: await fetchInboxRelays(pubkey),
})),
);
Aggressive Relay Coverage
When fetching inbox relays for a pubkey:
- Check EventStore cache (100ms timeout)
- Check RelayListCache
- Query ALL participant's write relays + ALL aggregators
- 10s timeout with error handling
Rationale: Better to over-fetch than silently fail to reach someone.
Efficient Conversation Grouping
Conversations grouped by sorted participant pubkeys (stable ID):
function computeConversationId(participants: string[]): string {
const sorted = [...participants].sort();
return `nip17:${sorted.join(",")}`;
}
This ensures:
- 1-on-1 conversation with Alice always has same ID
- Group conversations identified by full participant set
- Self-chat has single-participant ID
Debugging
Enable Verbose Logging
// In browser console
localStorage.setItem('grimoire:debug:dms', 'true')
location.reload()
// To disable
localStorage.removeItem('grimoire:debug:dms')
location.reload()
What Gets Logged (Debug Mode)
- Gift wrap arrival and processing
- Decryption attempts and results
- Inbox relay fetching and caching
- Conversation grouping updates
- Relay subscription events
- Cache restoration
What's Always Logged
- Warnings (relay fetch failures, missing inbox relays)
- Errors (decryption failures, send failures)
- Info (loading stored gift wraps, important state changes)
Testing Strategy
Unit Tests
gift-wrap.ts:
- Cache readiness check logic
- Conversation grouping algorithm
- Decrypt state management
nip-17-adapter.ts:
- Identifier parsing (npub, nprofile, NIP-05, $me)
- Inbox relay fetching with timeout
- Conversation resolution with relay validation
- Synthetic event creation and caching
Integration Tests
E2E Message Flow:
- Alice sends message to Bob
- Verify gift wrap created and published
- Verify Bob's inbox subscription receives event
- Verify auto-decrypt (if enabled)
- Verify conversation appears in Bob's inbox
- Verify Bob can reply
Self-Chat Flow:
- Alice sends message to self
- Verify single gift wrap created
- Verify published to own inbox relays
- Verify appears in "Saved Messages"
- Verify cross-device sync (same message on mobile)
Future Improvements
Reliability
- Retry failed decryptions with exponential backoff
- Detect relay failures and switch to alternates
- Implement message queuing for offline sends
- Add delivery receipts (NIP-17 extension)
Performance
- Lazy load old messages (pagination)
- Virtual scrolling for large conversations
- Background sync for message fetching
- Optimize Dexie queries with indexes
Features
- Message editing/deletion (NIP-09)
- Rich media attachments (NIP-94/96)
- Group chat management (add/remove participants)
- Message search across conversations
- Export conversation history
Architecture
- Refactor to dependency injection pattern
- Split GiftWrapService into smaller services
- Create dedicated ConversationManager
- Implement proper event sourcing for state management