POWR/docs/archive/NDK_Comprehensive_Guide.md

14 KiB

ARCHIVED: NDK Comprehensive Guide for POWR App

ARCHIVED: This document has been superseded by docs/technical/ndk/comprehensive_guide.md as part of the documentation reorganization.

UPDATED FOR MVP: This guide has been consolidated and aligned with our simplified social approach for the MVP release.

This guide combines key information from our various NDK analysis documents and the ndk-mobile package to provide a comprehensive reference for implementing Nostr features in POWR app. It's organized to support our MVP strategy with a focus on the core NDK capabilities we need.

Table of Contents

  1. NDK Core Concepts
  2. Proper State Management
  3. Subscription Lifecycle
  4. NIP-19 and Encoding/Decoding
  5. Context Provider Pattern
  6. Mobile-Specific Considerations
  7. Best Practices for POWR MVP

NDK Core Concepts

NDK (Nostr Development Kit) provides a set of tools for working with the Nostr protocol. Key components include:

  • NDK Instance: Central object for interacting with the Nostr network
  • NDKEvent: Represents a Nostr event
  • NDKUser: Represents a user (pubkey)
  • NDKFilter: Defines criteria for querying events
  • NDKSubscription: Manages subscriptions to event streams
  • NDKRelay: Represents a connection to a relay

Basic Setup

import NDK from '@nostr-dev-kit/ndk';

// Initialize NDK with relays
const ndk = new NDK({
  explicitRelayUrls: [
    'wss://relay.damus.io',
    'wss://relay.nostr.band'
  ]
});

// Connect to relays
await ndk.connect();

Proper State Management

The ndk-mobile library emphasizes proper state management using Zustand. This approach is critical for avoiding the issues we've been seeing in our application.

Zustand Store Pattern

// Create a singleton store using Zustand
import { create } from 'zustand';

// Private store instance - not exported
let store: ReturnType<typeof createNDKStore> | undefined;

// Store creator function
const createNDKStore = () => create<NDKState>((set) => ({
  ndk: undefined,
  initialized: false,
  connecting: false,
  connectionError: null,
  
  initialize: (config) => set((state) => {
    if (state.initialized) return state;
    
    const ndk = new NDK(config);
    return { ndk, initialized: true };
  }),
  
  connect: async () => {
    set({ connecting: true, connectionError: null });
    try {
      const ndk = store?.getState().ndk;
      if (!ndk) throw new Error("NDK not initialized");
      
      await ndk.connect();
      set({ connecting: false });
    } catch (error) {
      set({ connecting: false, connectionError: error });
    }
  }
}));

// Getter for the singleton store
export const getNDKStore = () => {
  if (!store) {
    store = createNDKStore();
  }
  return store;
};

// Hook for components to use
export const useNDKStore = <T>(selector: (state: NDKState) => T) => {
  // Ensure the store exists
  getNDKStore();
  // Return the selected state
  return useStore(store)(selector);
};

Critical State Management Points

  1. Single Store Instance: Always use a singleton pattern for NDK stores
  2. Lazy Initialization: Only create the store when first accessed
  3. Proper Selectors: Select only what you need from the store
  4. Clear State Transitions: Define explicit state transitions (connecting, connected, error)

Subscription Lifecycle

Proper subscription management is crucial for app stability and performance. The subscribe.ts file in ndk-mobile provides advanced subscription handling.

Basic Subscription Pattern

import { useEffect } from 'react';
import { useNDK } from './hooks/useNDK';

function EventComponent({ filter }) {
  const { ndk } = useNDK();
  const [events, setEvents] = useState([]);
  
  useEffect(() => {
    if (!ndk) return;
    
    // Create subscription
    const sub = ndk.subscribe(filter, {
      closeOnEose: false,  // Keep connection open
    });
    
    // Handle incoming events
    sub.on('event', (event) => {
      setEvents(prev => [...prev, event]);
    });
    
    // Start subscription
    sub.start();
    
    // Critical: Clean up subscription when component unmounts
    return () => {
      sub.stop();
    };
  }, [ndk, JSON.stringify(filter)]);
  
  return (/* render events */);
}

Enhanced Subscription Hook

The ndk-mobile package includes an enhanced useSubscribe hook with additional features:

// Example based on ndk-mobile implementation
function useEnhancedSubscribe(filter, options = {}) {
  const { ndk } = useNDK();
  const [events, setEvents] = useState([]);
  const [eose, setEose] = useState(false);
  const subRef = useRef(null);
  
  useEffect(() => {
    if (!ndk) return;
    
    // Create subscription
    const sub = ndk.subscribe(filter, {
      closeOnEose: options.closeOnEose || false,
      wrap: options.wrap || false
    });
    
    subRef.current = sub;
    
    // Handle incoming events
    sub.on('event', (event) => {
      // Process events (filtering, wrapping, etc.)
      
      // Add to state
      setEvents(prev => {
        // Check for duplicates using event.id
        if (prev.some(e => e.id === event.id)) return prev;
        return [...prev, event];
      });
    });
    
    // Handle end of stored events
    sub.on('eose', () => {
      setEose(true);
    });
    
    // Start subscription
    sub.start();
    
    // Clean up
    return () => {
      if (subRef.current) {
        subRef.current.stop();
      }
    };
  }, [ndk, JSON.stringify(filter), options]);
  
  return { events, eose };
}

NIP-19 and Encoding/Decoding

NIP-19 functions are essential for handling Nostr identifiers like npub, note, and naddr.

Decoding NIP-19 Entities

import { nip19 } from '@nostr-dev-kit/ndk';

// Decode any NIP-19 entity (naddr, npub, nsec, note, etc.)
function decodeNIP19(encoded: string) {
  try {
    const decoded = nip19.decode(encoded);
    // decoded.type will be 'npub', 'note', 'naddr', etc.
    // decoded.data will contain the data specific to that type
    return decoded;
  } catch (error) {
    console.error('Invalid NIP-19 format:', error);
    return null;
  }
}

// Convert npub to hex pubkey
function npubToHex(npub: string) {
  try {
    const decoded = nip19.decode(npub);
    if (decoded.type === 'npub') {
      return decoded.data as string; // This is the hex pubkey
    }
    return null;
  } catch (error) {
    console.error('Invalid npub format:', error);
    return null;
  }
}

Encoding to NIP-19 Formats

// Create an npub from a hex public key
function hexToNpub(hexPubkey: string) {
  return nip19.npubEncode(hexPubkey);
}

// Create a note (event reference) from event ID
function eventIdToNote(eventId: string) {
  return nip19.noteEncode(eventId);
}

// Create an naddr for addressable events
function createNaddr(pubkey: string, kind: number, identifier: string) {
  return nip19.naddrEncode({
    pubkey,  // Hex pubkey
    kind,    // Event kind (number)
    identifier // The 'd' tag value
  });
}

Context Provider Pattern

The ndk-mobile package emphasizes the Context Provider pattern for proper NDK integration:

import { NDKProvider } from '@nostr-dev-kit/ndk-mobile';
import App from './App';

// Root component
export default function Root() {
  return (
    <NDKProvider
      params={{
        explicitRelayUrls: [
          'wss://relay.damus.io',
          'wss://relay.nostr.band'
        ]
      }}
    >
      <App />
    </NDKProvider>
  );
}

This pattern ensures:

  1. Single NDK Instance: The entire app shares one NDK instance
  2. Consistent State: Auth state and relay connections are managed in one place
  3. Hooks Availability: All NDK hooks (useNDK, useSubscribe, etc.) work correctly
  4. Proper Cleanup: Connections and subscriptions are managed appropriately

Mobile-Specific Considerations

The ndk-mobile package includes several mobile-specific optimizations:

Caching Strategy

Mobile devices need efficient caching to reduce network usage and improve performance:

import { SQLiteAdapter } from '@nostr-dev-kit/ndk-mobile/cache-adapter/sqlite';

// Initialize NDK with SQLite caching
const ndk = new NDK({
  explicitRelayUrls: [...],
  cacheAdapter: new SQLiteAdapter({
    dbName: 'nostr-cache.db'
  })
});

Mobile Signers

For secure key management on mobile devices:

import { SecureStorageSigner } from '@nostr-dev-kit/ndk-mobile/signers/securestorage';

// Use secure storage for private keys
const signer = new SecureStorageSigner({
  storageKey: 'nostr-private-key'
});

// Add to NDK
ndk.signer = signer;

Best Practices for POWR MVP

Based on our analysis and the ndk-mobile implementation, here are key best practices for our MVP:

1. State Management

  • Use singleton stores for NDK and session state
  • Implement proper state machine for auth transitions with Zustand
  • Add event listeners/callbacks for components to respond to auth changes
// Authentication state using Zustand (aligned with ndk-mobile patterns)
export const useAuthStore = create((set) => ({
  state: 'unauthenticated', // 'unauthenticated', 'authenticating', 'authenticated', 'deauthenticating'
  user: null,
  error: null,
  
  startAuthentication: async (npub) => {
    set({ state: 'authenticating', error: null });
    try {
      // Authentication logic here
      const user = await authenticateUser(npub);
      set({ state: 'authenticated', user });
    } catch (error) {
      set({ state: 'unauthenticated', error });
    }
  },
  
  logout: async () => {
    set({ state: 'deauthenticating' });
    // Cleanup logic
    set({ state: 'unauthenticated', user: null });
  },
}));

2. Subscription Management

  • Always clean up subscriptions when components unmount
  • Keep subscription references in useRef to ensure proper cleanup
  • Use appropriate cache strategies to reduce relay load
  • Implement proper error handling for subscription failures
// Example component with proper subscription management
function WorkoutShareComponent({ workoutId }) {
  const { ndk } = useNDK();
  const [relatedPosts, setRelatedPosts] = useState([]);
  const subRef = useRef(null);
  
  useEffect(() => {
    if (!ndk) return;
    
    // Create subscription for posts mentioning this workout
    const sub = ndk.subscribe({
      kinds: [1],
      '#e': [workoutId]
    }, {
      closeOnEose: true,
      cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
    });
    
    subRef.current = sub;
    
    // Handle events
    sub.on('event', (event) => {
      setRelatedPosts(prev => [...prev, event]);
    });
    
    // Start subscription
    sub.start();
    
    // Clean up on unmount
    return () => {
      if (subRef.current) {
        subRef.current.stop();
      }
    };
  }, [ndk, workoutId]);
  
  // Component JSX
}

3. Simplified Social Features

For the MVP, we're focusing on:

  • Workout sharing: Publishing kind 1301 workout records and kind 1 notes quoting them
  • Official feed: Display only POWR official account posts in the social tab
  • Static content: Prefer static content over complex real-time feeds
// Example function to share a workout
async function shareWorkout(ndk, workout, caption) {
  // First, publish the workout record (kind 1301)
  const workoutEvent = new NDKEvent(ndk);
  workoutEvent.kind = 1301;
  workoutEvent.content = JSON.stringify(workout);
  // Add appropriate tags
  workoutEvent.tags = [
    ['d', workout.id],
    ['title', workout.title],
    ['duration', workout.duration.toString()]
  ];
  
  // Publish workout record
  const workoutPub = await workoutEvent.publish();
  
  // Then create a kind 1 note quoting the workout
  const noteEvent = new NDKEvent(ndk);
  noteEvent.kind = 1;
  noteEvent.content = caption || `Just completed ${workout.title}!`;
  // Add e tag to reference the workout event
  noteEvent.tags = [
    ['e', workoutEvent.id, '', 'quote']
  ];
  
  // Publish social note
  await noteEvent.publish();
  
  return { workoutEvent, noteEvent };
}

4. Error Handling & Offline Support

  • Implement graceful fallbacks for network errors
  • Store pending publish operations for later retry
  • Use SQLite caching for offline access to previously loaded data
// Publication queue service example (aligned with mobile patterns)
class PublicationQueue {
  async queueForPublication(event) {
    try {
      // Try immediate publication
      await event.publish();
      return true;
    } catch (error) {
      // Store for later retry
      await this.storeEventForLater(event);
      return false;
    }
  }
  
  async publishPendingEvents() {
    const pendingEvents = await this.getPendingEvents();
    for (const event of pendingEvents) {
      try {
        await event.publish();
        await this.markAsPublished(event);
      } catch (error) {
        // Keep in queue for next retry
        console.error("Failed to publish:", error);
      }
    }
  }
}

5. Provider Pattern

  • Use NDKProvider to wrap the application
  • Configure NDK once at the app root level
  • Access NDK via hooks rather than creating instances
// App.tsx
export default function App() {
  return (
    <NDKProvider
      params={{
        explicitRelayUrls: [
          'wss://relay.damus.io',
          'wss://relay.nostr.band'
        ]
      }}
    >
      <YourAppComponents />
    </NDKProvider>
  );
}

// Using NDK in components
function SomeComponent() {
  const { ndk } = useNDK();
  
  // Use ndk here
  
  return (/* component JSX */);
}

6. MVP Implementation Focus

For the MVP, focus on implementing:

  1. Core Authentication: Proper login/logout with state management
  2. Workout Sharing: Publication of workouts to Nostr
  3. Limited Social Features: Static feed of official POWR account
  4. User Profile: Basic user information display

Defer these for post-MVP:

  1. Full social feed implementation
  2. Real-time following/interaction
  3. Complex subscription patterns

By following these best practices, we can create a stable foundation for the POWR app's MVP release that addresses the current architectural issues while providing a simplified but functional social experience.