OpChan/packages/core/README.md
2025-12-12 15:08:52 -05:00

27 KiB

@opchan/core

Core browser library for building decentralized forum applications with cryptographic identity management and peer-to-peer messaging.

Overview

@opchan/core provides the foundational infrastructure for the OpChan protocol, featuring:

  • 🔐 Cryptographic Identity - Ed25519 key delegation with wallet signatures
  • 📡 Waku Messaging - Peer-to-peer communication via Waku network
  • 💾 Local-First Storage - IndexedDB persistence with in-memory caching
  • ⚖️ Content Management - Cells, posts, comments, and voting system
  • 🎯 Relevance Scoring - Multi-factor content ranking algorithm
  • 🛡️ Message Validation - Cryptographic verification of all content
  • 👤 Identity Resolution - ENS integration and user profiles
  • 🔖 Bookmarks - Client-side bookmark management

Installation

npm install @opchan/core

Quick Start

1. Initialize Client

import { OpChanClient } from '@opchan/core';

const client = new OpChanClient({
  wakuConfig: {
    contentTopic: '/opchan/1/messages/proto',
    reliableChannelId: 'opchan-messages'
  },
  reownProjectId: 'your-reown-project-id' // Optional, for WalletConnect
});

// Open database
await client.database.open();

2. Set Up Message Listening

// Listen for incoming messages
client.messageManager.onMessageReceived(async (message) => {
  // Apply message to local database
  await client.database.applyMessage(message);
  console.log('Received message:', message.type);
});

// Monitor network health
client.messageManager.onHealthChange((isHealthy) => {
  console.log('Network health:', isHealthy ? 'Connected' : 'Disconnected');
});

3. Delegate Signing Keys (Wallet Users)

import { signMessage } from 'viem/accounts';

// Generate delegation for 7 days
const success = await client.delegation.delegate(
  walletAddress,
  '7days',
  async (message) => {
    return await signMessage({ message, account: walletAddress });
  }
);

if (success) {
  console.log('Delegation created successfully');
}

4. Create Content

// Create a cell (requires ENS verification)
const cellResult = await client.forumActions.createCell(
  {
    name: 'Tech Discussion',
    description: 'A place for tech talk',
    icon: '🚀',
    currentUser: user,
    isAuthenticated: true
  },
  () => refreshUI()
);

// Create a post
const postResult = await client.forumActions.createPost(
  {
    cellId: 'cell-id',
    title: 'Hello World',
    content: 'My first post!',
    currentUser: user,
    isAuthenticated: true
  },
  () => refreshUI()
);

// Add a comment
const commentResult = await client.forumActions.createComment(
  {
    postId: 'post-id',
    content: 'Great post!',
    currentUser: user,
    isAuthenticated: true
  },
  () => refreshUI()
);

// Vote on content
await client.forumActions.vote(
  {
    targetId: 'post-id',
    isUpvote: true,
    currentUser: user,
    isAuthenticated: true
  },
  () => refreshUI()
);

5. Access Cached Data

// Get all cells
const cells = Object.values(client.database.cache.cells);

// Get all posts
const posts = Object.values(client.database.cache.posts);

// Get posts for a specific cell
const cellPosts = posts.filter(post => post.cellId === 'cell-id');

// Get comments for a post
const comments = Object.values(client.database.cache.comments)
  .filter(comment => comment.postId === 'post-id');

// Get votes for a post
const votes = Object.values(client.database.cache.votes)
  .filter(vote => vote.targetId === 'post-id');

Core Concepts

Architecture

┌─────────────────────────────────────────────────────────┐
│                    OpChanClient                         │
│  ┌─────────────────────────────────────────────────┐   │
│  │ MessageManager (Waku Network)                   │   │
│  │  - WakuNodeManager: Connection handling         │   │
│  │  - ReliableMessaging: Message delivery          │   │
│  │  - MessageService: Send/receive orchestration   │   │
│  └─────────────────────────────────────────────────┘   │
│                         ↕                               │
│  ┌─────────────────────────────────────────────────┐   │
│  │ LocalDatabase (IndexedDB + Cache)               │   │
│  │  - In-memory cache for fast reads               │   │
│  │  - IndexedDB for persistence                    │   │
│  │  - Message validation and deduplication         │   │
│  └─────────────────────────────────────────────────┘   │
│                         ↕                               │
│  ┌─────────────────────────────────────────────────┐   │
│  │ Services Layer                                  │   │
│  │  - ForumActions: Content creation               │   │
│  │  - UserIdentityService: Identity resolution     │   │
│  │  - DelegationManager: Key management            │   │
│  │  - RelevanceCalculator: Content scoring         │   │
│  │  - BookmarkService: Bookmark management         │   │
│  └─────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

Message Flow

  1. Outbound: User action → Sign with delegated key → Store in LocalDatabase → Send via Waku
  2. Inbound: Waku receives → Validate signature → Validate delegation proof → Store in LocalDatabase → Notify listeners

Key Delegation

OpChan uses a two-tier signing system:

  • Wallet Key: Used once to authorize a browser key
  • Browser Key: Used for all subsequent messages

Benefits:

  • Reduces wallet prompts from dozens to one
  • Messages still cryptographically verifiable
  • Works with anonymous sessions (no wallet required)

API Reference

OpChanClient

Main client class that orchestrates all services.

class OpChanClient {
  readonly config: OpChanClientConfig;
  readonly messageManager: DefaultMessageManager;
  readonly database: LocalDatabase;
  readonly forumActions: ForumActions;
  readonly relevance: RelevanceCalculator;
  readonly messageService: MessageService;
  readonly userIdentityService: UserIdentityService;
  readonly delegation: DelegationManager;
}

Configuration:

interface OpChanClientConfig {
  wakuConfig: WakuConfig;
  reownProjectId?: string;
}

interface WakuConfig {
  contentTopic: string;
  reliableChannelId: string;
}

DelegationManager

Manages cryptographic key delegation and message signing.

Methods

delegate(address, duration, signFunction)

Create a wallet-signed delegation for browser keys.

async delegate(
  address: `0x${string}`,
  duration: '7days' | '30days',
  signFunction: (message: string) => Promise<string>
): Promise<boolean>

Example:

const success = await client.delegation.delegate(
  '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
  '7days',
  async (msg) => await wallet.signMessage(msg)
);

delegateAnonymous(duration)

Create an anonymous delegation (no wallet required).

async delegateAnonymous(
  duration: '7days' | '30days' = '7days'
): Promise<string>

Returns a session ID for the anonymous user.


signMessage(message)

Sign a message with the delegated browser key.

async signMessage(
  message: UnsignedMessage
): Promise<OpchanMessage | null>

verify(message)

Verify a signed message's authenticity.

async verify(message: OpchanMessage): Promise<boolean>

getStatus(currentAddress?)

Get current delegation status.

async getStatus(
  currentAddress?: string
): Promise<DelegationFullStatus>

Returns:

interface DelegationFullStatus {
  hasDelegation: boolean;
  isValid: boolean;
  timeRemaining?: number;
  publicKey?: string;
  address?: `0x${string}`;
  proof?: DelegationProof;
}

clear()

Clear stored delegation.

async clear(): Promise<void>

LocalDatabase

IndexedDB-backed local storage with in-memory caching.

Properties

cache - In-memory cache of all content

interface LocalDatabaseCache {
  cells: { [id: string]: CellMessage };
  posts: { [id: string]: PostMessage };
  comments: { [id: string]: CommentMessage };
  votes: { [key: string]: VoteMessage };
  moderations: { [key: string]: ModerateMessage };
  userIdentities: { [address: string]: UserIdentityCache };
  bookmarks: { [id: string]: Bookmark };
}

Methods

open()

Open database and hydrate cache from IndexedDB.

async open(): Promise<void>

applyMessage(message)

Apply an incoming message to the database.

async applyMessage(message: unknown): Promise<boolean>

Returns true if message was newly processed and stored.


storeUser(user) / loadUser() / clearUser()

Persist user authentication state.

async storeUser(user: User): Promise<void>
async loadUser(): Promise<User | null>
async clearUser(): Promise<void>

storeDelegation(delegation) / loadDelegation() / clearDelegation()

Persist delegation information.

async storeDelegation(delegation: DelegationInfo): Promise<void>
async loadDelegation(): Promise<DelegationInfo | null>
async clearDelegation(): Promise<void>

markPending(id) / clearPending(id) / isPending(id)

Track pending message synchronization.

markPending(id: string): void
clearPending(id: string): void
isPending(id: string): boolean
onPendingChange(listener: () => void): () => void

addBookmark(bookmark) / removeBookmark(id)

Manage bookmarks.

async addBookmark(bookmark: Bookmark): Promise<void>
async removeBookmark(bookmarkId: string): Promise<void>
async getUserBookmarks(userId: string): Promise<Bookmark[]>
isBookmarked(userId: string, type: 'post' | 'comment', targetId: string): boolean

ForumActions

High-level actions for content creation and moderation.

Content Creation

createCell(params, updateCallback)

Create a new cell (requires ENS verification).

async createCell(
  params: {
    name: string;
    description: string;
    icon?: string;
    currentUser: User | null;
    isAuthenticated: boolean;
  },
  updateCallback: () => void
): Promise<{ success: boolean; data?: Cell; error?: string }>

createPost(params, updateCallback)

Create a new post.

async createPost(
  params: {
    cellId: string;
    title: string;
    content: string;
    currentUser: User | null;
    isAuthenticated: boolean;
  },
  updateCallback: () => void
): Promise<{ success: boolean; data?: Post; error?: string }>

createComment(params, updateCallback)

Create a new comment.

async createComment(
  params: {
    postId: string;
    content: string;
    currentUser: User | null;
    isAuthenticated: boolean;
  },
  updateCallback: () => void
): Promise<{ success: boolean; data?: Comment; error?: string }>

Voting

vote(params, updateCallback)

Vote on a post or comment.

async vote(
  params: {
    targetId: string;
    isUpvote: boolean;
    currentUser: User | null;
    isAuthenticated: boolean;
  },
  updateCallback: () => void
): Promise<{ success: boolean; data?: boolean; error?: string }>

Moderation

moderatePost(params, updateCallback)

Moderate a post (cell owner only).

async moderatePost(
  params: {
    cellId: string;
    postId: string;
    reason?: string;
    currentUser: User | null;
    isAuthenticated: boolean;
    cellOwner: string;
  },
  updateCallback: () => void
): Promise<{ success: boolean; data?: boolean; error?: string }>

Similar methods exist for: unmoderatePost, moderateComment, unmoderateComment, moderateUser, unmoderateUser


UserIdentityService

Manages user identity resolution and profiles.

Methods

getIdentity(address, opts?)

Get user identity with optional fresh resolution.

async getIdentity(
  address: string,
  opts?: { fresh?: boolean }
): Promise<UserIdentity | null>

Returns:

interface UserIdentity {
  address: `0x${string}`;
  ensName?: string;
  ensAvatar?: string;
  callSign?: string;
  displayPreference: EDisplayPreference;
  displayName: string;
  lastUpdated: number;
  verificationStatus: EVerificationStatus;
}

updateProfile(address, updates)

Update user profile (call sign and display preference).

async updateProfile(
  address: string,
  updates: {
    callSign?: string;
    displayPreference?: EDisplayPreference;
  }
): Promise<{ ok: true; identity: UserIdentity } | { ok: false; error: Error }>

getDisplayName(params)

Get display name for a user based on their preferences.

getDisplayName({
  address: string,
  ensName?: string | null,
  displayPreference?: EDisplayPreference
}): string

subscribe(listener)

Subscribe to identity changes.

subscribe(
  listener: (address: string, identity: UserIdentity | null) => void
): () => void

RelevanceCalculator

Calculates content relevance scores based on multiple factors.

Method

calculatePostScore(post, votes, comments, userVerificationStatus, moderatedPosts)

Calculate relevance score for a post.

calculatePostScore(
  post: PostMessage,
  votes: VoteMessage[],
  comments: CommentMessage[],
  userVerificationStatus: UserVerificationStatus,
  moderatedPosts: { [postId: string]: ModerateMessage }
): RelevanceScoreDetails

Score Factors:

  • Base Score: 100 points for all content
  • Engagement: 10 points per upvote, 3 points per comment
  • Verification Bonuses:
    • Author ENS verified: +20 points
    • Each verified upvote: +5 points
    • Each verified commenter: +10 points
  • Time Decay: Exponential decay over 7 days (half-life)
  • Moderation Penalty: -50% for moderated content

MessageManager

Manages Waku network connectivity and message transmission.

Methods

sendMessage(message, statusCallback?)

Send a message via Waku network.

async sendMessage(
  message: OpchanMessage,
  statusCallback?: (status: MessageStatus) => void
): Promise<void>

onMessageReceived(callback)

Subscribe to incoming messages.

onMessageReceived(
  callback: (message: OpchanMessage) => void
): () => void

onHealthChange(callback)

Monitor network health.

onHealthChange(
  callback: (isHealthy: boolean) => void
): () => void

onSyncStatus(callback)

Monitor synchronization status.

onSyncStatus(
  callback: (status: SyncStatus) => void
): () => void

Type Definitions

Core Types

// User verification levels
enum EVerificationStatus {
  ANONYMOUS = 'anonymous',
  WALLET_UNCONNECTED = 'wallet-unconnected',
  WALLET_CONNECTED = 'wallet-connected',
  ENS_VERIFIED = 'ens-verified',
}

// User display preferences
enum EDisplayPreference {
  CALL_SIGN = 'call-sign',
  WALLET_ADDRESS = 'wallet-address',
}

// User object
interface User {
  address: string; // 0x${string} for wallet, UUID for anonymous
  ensName?: string;
  ensAvatar?: string;
  callSign?: string;
  displayPreference: EDisplayPreference;
  displayName: string;
  verificationStatus: EVerificationStatus;
  lastChecked?: number;
}

Message Types

// All messages include signature fields
interface SignedMessage {
  signature: string;
  browserPubKey: string;
  delegationProof?: DelegationProof; // Present for wallet users only
}

// Message types
enum MessageType {
  CELL = 'cell',
  POST = 'post',
  COMMENT = 'comment',
  VOTE = 'vote',
  MODERATE = 'moderate',
  USER_PROFILE_UPDATE = 'user_profile_update',
}

// Cell message
interface CellMessage {
  type: MessageType.CELL;
  id: string;
  name: string;
  description: string;
  icon?: string;
  timestamp: number;
  author: string;
}

// Post message
interface PostMessage {
  type: MessageType.POST;
  id: string;
  cellId: string;
  title: string;
  content: string;
  timestamp: number;
  author: string;
}

// Comment message
interface CommentMessage {
  type: MessageType.COMMENT;
  id: string;
  postId: string;
  content: string;
  timestamp: number;
  author: string;
}

// Vote message
interface VoteMessage {
  type: MessageType.VOTE;
  id: string;
  targetId: string; // Post or comment ID
  value: 1 | -1; // Upvote or downvote
  timestamp: number;
  author: string;
}

Extended Forum Types

// Extended cell with computed fields
interface Cell extends CellMessage {
  relevanceScore?: number;
  activeMemberCount?: number;
  recentActivity?: number;
  postCount?: number;
}

// Extended post with computed fields
interface Post extends PostMessage {
  authorAddress: string;
  upvotes: VoteMessage[];
  downvotes: VoteMessage[];
  moderated?: boolean;
  moderatedBy?: string;
  moderationReason?: string;
  relevanceScore?: number;
  verifiedUpvotes?: number;
  verifiedCommenters?: string[];
  voteScore?: number;
}

// Extended comment with computed fields
interface Comment extends CommentMessage {
  authorAddress: string;
  upvotes: VoteMessage[];
  downvotes: VoteMessage[];
  moderated?: boolean;
  moderatedBy?: string;
  moderationReason?: string;
  relevanceScore?: number;
  voteScore?: number;
}

Usage Patterns

Pattern 1: Complete Vanilla JS Application

import { OpChanClient, EVerificationStatus } from '@opchan/core';

// Initialize
const client = new OpChanClient({
  wakuConfig: {
    contentTopic: '/opchan/1/messages/proto',
    reliableChannelId: 'opchan-messages'
  }
});

await client.database.open();

// Set up message listener
client.messageManager.onMessageReceived(async (message) => {
  await client.database.applyMessage(message);
  renderUI();
});

// Create delegation
const sessionId = await client.delegation.delegateAnonymous('7days');
const user = {
  address: sessionId,
  displayName: 'Anonymous',
  displayPreference: EDisplayPreference.WALLET_ADDRESS,
  verificationStatus: EVerificationStatus.ANONYMOUS
};

// Create content
const result = await client.forumActions.createPost(
  {
    cellId: 'general',
    title: 'Hello',
    content: 'First post!',
    currentUser: user,
    isAuthenticated: true
  },
  () => renderUI()
);

// Render UI
function renderUI() {
  const posts = Object.values(client.database.cache.posts);
  document.getElementById('posts').innerHTML = posts
    .map(post => `<div>${post.title}</div>`)
    .join('');
}

Pattern 2: Identity Resolution

// Resolve user identity
const identity = await client.userIdentityService.getIdentity(
  '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
  { fresh: true }
);

console.log('Display name:', identity?.displayName);
console.log('ENS name:', identity?.ensName);
console.log('Verification:', identity?.verificationStatus);

// Update profile
const result = await client.userIdentityService.updateProfile(
  userAddress,
  {
    callSign: 'alice',
    displayPreference: EDisplayPreference.CALL_SIGN
  }
);

if (result.ok) {
  console.log('Profile updated:', result.identity);
}

// Subscribe to changes
const unsubscribe = client.userIdentityService.subscribe(
  (address, identity) => {
    console.log('Identity updated:', address, identity);
  }
);

Pattern 3: Content Scoring

import { transformPost } from '@opchan/core';

// Transform raw post message to enhanced post
const post = await transformPost(postMessage);

// Calculate relevance
const score = client.relevance.calculatePostScore(
  postMessage,
  votes,
  comments,
  userVerificationStatus,
  moderatedPosts
);

console.log('Relevance score:', score.finalScore);
console.log('Score breakdown:', {
  base: score.baseScore,
  engagement: score.engagementScore,
  verificationBonus: score.authorVerificationBonus + 
                     score.verifiedUpvoteBonus +
                     score.verifiedCommenterBonus,
  timeDecay: score.timeDecayMultiplier,
  moderation: score.moderationPenalty
});

Pattern 4: Moderation

// Check if user can moderate
const cellOwner = cell.author;
const canModerate = currentUser.address === cellOwner;

if (canModerate) {
  // Moderate a post
  await client.forumActions.moderatePost(
    {
      cellId: cell.id,
      postId: post.id,
      reason: 'Spam',
      currentUser,
      isAuthenticated: true,
      cellOwner
    },
    () => refreshUI()
  );

  // Unmoderate a post
  await client.forumActions.unmoderatePost(
    {
      cellId: cell.id,
      postId: post.id,
      currentUser,
      isAuthenticated: true,
      cellOwner
    },
    () => refreshUI()
  );
}

Pattern 5: Bookmarks

import { BookmarkService } from '@opchan/core';

const bookmarkService = new BookmarkService();

// Add post bookmark
await bookmarkService.addPostBookmark(
  post,
  userId,
  cellId
);

// Add comment bookmark
await bookmarkService.addCommentBookmark(
  comment,
  userId,
  postId
);

// Get user bookmarks
const bookmarks = await client.database.getUserBookmarks(userId);

// Check if bookmarked
const isBookmarked = client.database.isBookmarked(
  userId,
  'post',
  postId
);

// Remove bookmark
await bookmarkService.removeBookmark(bookmarkId);

Complete Example

Here's a complete vanilla JavaScript application using @opchan/core:

import {
  OpChanClient,
  EVerificationStatus,
  EDisplayPreference,
  transformPost,
  transformComment
} from '@opchan/core';

class OpChanApp {
  private client: OpChanClient;
  private currentUser: any = null;

  async initialize() {
    // Create client
    this.client = new OpChanClient({
      wakuConfig: {
        contentTopic: '/opchan/1/messages/proto',
        reliableChannelId: 'opchan-messages'
      }
    });

    // Open database
    await this.client.database.open();

    // Set up listeners
    this.setupListeners();

    // Create anonymous session
    await this.startAnonymousSession();

    // Initial render
    this.render();
  }

  private setupListeners() {
    // Message listener
    this.client.messageManager.onMessageReceived(async (message) => {
      await this.client.database.applyMessage(message);
      this.render();
    });

    // Health listener
    this.client.messageManager.onHealthChange((isHealthy) => {
      console.log('Network:', isHealthy ? 'Connected' : 'Disconnected');
      this.render();
    });
  }

  private async startAnonymousSession() {
    const sessionId = await this.client.delegation.delegateAnonymous('7days');
    this.currentUser = {
      address: sessionId,
      displayName: 'Anonymous',
      displayPreference: EDisplayPreference.WALLET_ADDRESS,
      verificationStatus: EVerificationStatus.ANONYMOUS
    };
  }

  private async createPost(cellId: string, title: string, content: string) {
    const result = await this.client.forumActions.createPost(
      {
        cellId,
        title,
        content,
        currentUser: this.currentUser,
        isAuthenticated: true
      },
      () => this.render()
    );

    if (result.success) {
      console.log('Post created:', result.data);
    } else {
      console.error('Failed to create post:', result.error);
    }
  }

  private async vote(targetId: string, isUpvote: boolean) {
    await this.client.forumActions.vote(
      {
        targetId,
        isUpvote,
        currentUser: this.currentUser,
        isAuthenticated: true
      },
      () => this.render()
    );
  }

  private render() {
    const cells = Object.values(this.client.database.cache.cells);
    const posts = Object.values(this.client.database.cache.posts);
    const comments = Object.values(this.client.database.cache.comments);

    console.log('Cells:', cells.length);
    console.log('Posts:', posts.length);
    console.log('Comments:', comments.length);

    // Update DOM here...
  }
}

// Start app
const app = new OpChanApp();
app.initialize();

Advanced Topics

Message Validation

All messages are validated before storage:

import { MessageValidator } from '@opchan/core';

const validator = new MessageValidator();

// Validate message
const isValid = await validator.isValidMessage(message);

// Get detailed validation report
const report = await validator.getValidationReport(message);
console.log('Validation report:', {
  isValid: report.isValid,
  missingFields: report.missingFields,
  invalidFields: report.invalidFields,
  hasValidSignature: report.hasValidSignature,
  errors: report.errors,
  warnings: report.warnings
});

Custom Transformers

Transform raw messages into enhanced domain objects:

import { transformPost, transformComment, transformCell } from '@opchan/core';

// Transform post with votes and comments
const enhancedPost = await transformPost(postMessage);

// Transform comment with votes
const enhancedComment = await transformComment(commentMessage);

// Transform cell with stats
const enhancedCell = await transformCell(cellMessage);

Network Configuration

Configure Waku network parameters:

const client = new OpChanClient({
  wakuConfig: {
    contentTopic: '/my-app/1/messages/proto',
    reliableChannelId: 'my-app-messages',
    // Additional Waku options...
  }
});

Best Practices

  1. Always open database before use: Call client.database.open() on initialization
  2. Listen for messages: Set up onMessageReceived listener to stay synchronized
  3. Handle delegation expiry: Check delegation status and refresh when needed
  4. Validate user permissions: Use ForumActions validation or implement custom checks
  5. Use transformers for UI: Transform raw messages to enhanced types for rendering
  6. Monitor network health: Subscribe to health changes for connection indicators
  7. Implement optimistic UI: Mark items as pending during network operations
  8. Cache identity lookups: Use UserIdentityService for efficient identity resolution

Troubleshooting

Messages not appearing

  • Check delegation: Ensure delegation is valid with delegation.getStatus()
  • Verify network: Monitor onHealthChange for connectivity issues
  • Check validation: Messages may be rejected if signature is invalid

Database not persisting

  • Call open(): Ensure database.open() was called before use
  • Check browser support: IndexedDB must be available
  • Clear cache: Try database.clearAll() to reset

Identity not resolving

  • Set public client: Call userIdentityService.setPublicClient(publicClient) for ENS
  • Force refresh: Use getIdentity(address, { fresh: true }) to bypass cache
  • Check network: ENS resolution requires network connectivity

License

MIT


Built for decentralized communities 🌐