22 KiB
OpChan Core SDK (packages/core) — Building Decentralized Forums
This guide shows how to build a decentralized forum application using the Core SDK directly (without React). It covers project setup, client initialization, key delegation, network connectivity, content management, identity resolution, and persistence.
The examples assume you install and use the @opchan/core package.
1) Install and basic setup
npm i @opchan/core
Create a client instance and open the database:
import { OpChanClient } from '@opchan/core';
const client = new OpChanClient({
wakuConfig: {
contentTopic: '/opchan/1/messages/proto',
reliableChannelId: 'opchan-messages'
},
reownProjectId: 'your-reown-project-id' // Optional
});
// IMPORTANT: Open database before use
await client.database.open();
2) Message synchronization
Set up listeners for incoming messages and network health:
// Listen for all incoming messages
const unsubscribeMessages = client.messageManager.onMessageReceived(
async (message) => {
// Apply to local database
const wasNew = await client.database.applyMessage(message);
if (wasNew) {
console.log('New message received:', message.type);
updateUI();
}
}
);
// Monitor network health
const unsubscribeHealth = client.messageManager.onHealthChange(
(isHealthy) => {
console.log('Network status:', isHealthy ? 'Connected' : 'Disconnected');
updateNetworkIndicator(isHealthy);
}
);
// Monitor sync status
const unsubscribeSync = client.messageManager.onSyncStatus(
(status) => {
console.log('Sync status:', status);
}
);
// Clean up when done
function cleanup() {
unsubscribeMessages();
unsubscribeHealth();
unsubscribeSync();
}
3) Key delegation — wallet users
For wallet-connected users, create a delegation to reduce signature prompts:
import { signMessage } from 'viem/accounts';
async function setupWalletDelegation(walletAddress: `0x${string}`) {
// Create delegation for 7 or 30 days
const success = await client.delegation.delegate(
walletAddress,
'7days', // or '30days'
async (message: string) => {
// Sign with wallet
return await signMessage({
message,
account: walletAddress
});
}
);
if (success) {
console.log('Delegation created successfully');
// Check delegation status
const status = await client.delegation.getStatus(walletAddress);
console.log('Delegation valid:', status.isValid);
console.log('Time remaining:', status.timeRemaining);
console.log('Expires at:', new Date(Date.now() + status.timeRemaining!));
}
}
4) Key delegation — anonymous users
For anonymous users (no wallet), create an anonymous delegation:
async function setupAnonymousSession() {
// Create anonymous delegation (returns session ID)
const sessionId = await client.delegation.delegateAnonymous('7days');
console.log('Anonymous session ID:', sessionId);
// Create user object
const anonymousUser = {
address: sessionId,
displayName: 'Anonymous',
displayPreference: EDisplayPreference.WALLET_ADDRESS,
verificationStatus: EVerificationStatus.ANONYMOUS
};
// Store user in database
await client.database.storeUser(anonymousUser);
return anonymousUser;
}
5) Creating content — cells
Create a cell (requires ENS-verified wallet):
async function createCell(
currentUser: User,
name: string,
description: string,
icon?: string
) {
const result = await client.forumActions.createCell(
{
name,
description,
icon,
currentUser,
isAuthenticated: true
},
() => {
// Callback when cache is updated
updateUI();
}
);
if (result.success) {
console.log('Cell created:', result.data);
return result.data;
} else {
console.error('Failed to create cell:', result.error);
return null;
}
}
6) Creating content — posts
Create a post in a cell:
async function createPost(
currentUser: User,
cellId: string,
title: string,
content: string
) {
const result = await client.forumActions.createPost(
{
cellId,
title,
content,
currentUser,
isAuthenticated: true
},
() => updateUI()
);
if (result.success) {
console.log('Post created:', result.data);
// Mark as pending until network confirms
client.database.markPending(result.data!.id);
return result.data;
} else {
console.error('Failed to create post:', result.error);
return null;
}
}
7) Creating content — comments
Add a comment to a post:
async function createComment(
currentUser: User,
postId: string,
content: string
) {
const result = await client.forumActions.createComment(
{
postId,
content,
currentUser,
isAuthenticated: true
},
() => updateUI()
);
if (result.success) {
console.log('Comment created:', result.data);
client.database.markPending(result.data!.id);
return result.data;
} else {
console.error('Failed to create comment:', result.error);
return null;
}
}
8) Voting
Vote on posts or comments:
async function voteOnContent(
currentUser: User,
targetId: string,
isUpvote: boolean
) {
const result = await client.forumActions.vote(
{
targetId,
isUpvote,
currentUser,
isAuthenticated: true
},
() => updateUI()
);
if (result.success) {
console.log('Vote registered:', isUpvote ? 'upvote' : 'downvote');
} else {
console.error('Failed to vote:', result.error);
}
}
9) Moderation (cell owner only)
Moderate posts, comments, or users within a cell:
async function moderatePost(
currentUser: User,
cellId: string,
postId: string,
reason: string
) {
const cell = client.database.cache.cells[cellId];
if (!cell || currentUser.address !== cell.author) {
console.error('Not authorized: Only cell owner can moderate');
return;
}
const result = await client.forumActions.moderatePost(
{
cellId,
postId,
reason,
currentUser,
isAuthenticated: true,
cellOwner: cell.author
},
() => updateUI()
);
if (result.success) {
console.log('Post moderated');
}
}
async function unmoderatePost(
currentUser: User,
cellId: string,
postId: string
) {
const cell = client.database.cache.cells[cellId];
const result = await client.forumActions.unmoderatePost(
{
cellId,
postId,
currentUser,
isAuthenticated: true,
cellOwner: cell.author
},
() => updateUI()
);
if (result.success) {
console.log('Post unmoderated');
}
}
// Similar methods exist for comments and users:
// - client.forumActions.moderateComment()
// - client.forumActions.unmoderateComment()
// - client.forumActions.moderateUser()
// - client.forumActions.unmoderateUser()
10) Reading cached data
Access cached content from the in-memory database:
// Get all cells
const cells = Object.values(client.database.cache.cells);
console.log('Cells:', cells.length);
// Get all posts
const posts = Object.values(client.database.cache.posts);
// Filter posts by cell
const cellPosts = posts.filter(p => p.cellId === 'specific-cell-id');
// Get all comments
const comments = Object.values(client.database.cache.comments);
// Filter comments by post
const postComments = comments.filter(c => c.postId === 'specific-post-id');
// Get votes
const votes = Object.values(client.database.cache.votes);
// Get votes for specific content
const postVotes = votes.filter(v => v.targetId === 'post-id');
const upvotes = postVotes.filter(v => v.value === 1);
const downvotes = postVotes.filter(v => v.value === -1);
// Get moderations
const moderations = Object.values(client.database.cache.moderations);
// Check if post is moderated
const postModeration = moderations.find(
m => m.targetType === 'post' && m.targetId === 'post-id'
);
const isModerated = postModeration?.action === 'moderate';
11) Identity resolution
Resolve user identities (ENS names, call signs, etc.):
// Get identity for a wallet address
const identity = await client.userIdentityService.getIdentity(
'0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'
);
if (identity) {
console.log('Display name:', identity.displayName);
console.log('ENS name:', identity.ensName);
console.log('ENS avatar:', identity.ensAvatar);
console.log('Call sign:', identity.callSign);
console.log('Verification:', identity.verificationStatus);
}
// Force fresh resolution (bypass cache)
const freshIdentity = await client.userIdentityService.getIdentity(
address,
{ fresh: true }
);
// Get display name only
const displayName = client.userIdentityService.getDisplayName({
address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
ensName: 'alice.eth',
displayPreference: EDisplayPreference.CALL_SIGN
});
12) User profiles
Update user profiles (call sign and display preference):
async function updateUserProfile(
userAddress: string,
callSign?: string,
displayPreference?: EDisplayPreference
) {
const result = await client.userIdentityService.updateProfile(
userAddress,
{
callSign,
displayPreference
}
);
if (result.ok) {
console.log('Profile updated:', result.identity);
return result.identity;
} else {
console.error('Failed to update profile:', result.error);
return null;
}
}
// Example: Set call sign
await updateUserProfile(
userAddress,
'alice',
EDisplayPreference.CALL_SIGN
);
13) Subscribe to identity changes
React to identity updates in real-time:
const unsubscribe = client.userIdentityService.subscribe(
(address, identity) => {
console.log('Identity updated:', address);
if (identity) {
console.log('New display name:', identity.displayName);
console.log('New call sign:', identity.callSign);
// Update UI
updateUserDisplay(address, identity);
}
}
);
// Clean up
unsubscribe();
14) Relevance scoring
Calculate relevance scores for posts:
import { transformPost } from '@opchan/core';
// Transform raw post message to enhanced post
const post = await transformPost(postMessage);
// Get votes and comments for scoring
const postVotes = Object.values(client.database.cache.votes)
.filter(v => v.targetId === post.id);
const postComments = Object.values(client.database.cache.comments)
.filter(c => c.postId === post.id);
// Get user verification status
const userVerificationStatus = {};
for (const [address, identity] of Object.entries(
client.database.cache.userIdentities
)) {
userVerificationStatus[address] = {
isVerified: identity.verificationStatus === 'ens-verified',
hasENS: !!identity.ensName,
ensName: identity.ensName
};
}
// Calculate score
const scoreDetails = client.relevance.calculatePostScore(
postMessage,
postVotes,
postComments,
userVerificationStatus,
client.database.cache.moderations
);
console.log('Relevance score:', scoreDetails.finalScore);
console.log('Score breakdown:', {
base: scoreDetails.baseScore,
engagement: scoreDetails.engagementScore,
authorBonus: scoreDetails.authorVerificationBonus,
upvoteBonus: scoreDetails.verifiedUpvoteBonus,
commenterBonus: scoreDetails.verifiedCommenterBonus,
timeDecay: scoreDetails.timeDecayMultiplier,
moderation: scoreDetails.moderationPenalty
});
15) Bookmarks
Manage user 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 all user bookmarks
const bookmarks = await client.database.getUserBookmarks(userId);
console.log('Total bookmarks:', bookmarks.length);
// Get bookmarks by type
const postBookmarks = bookmarks.filter(b => b.type === 'post');
const commentBookmarks = bookmarks.filter(b => b.type === 'comment');
// Check if content is bookmarked
const isBookmarked = client.database.isBookmarked(userId, 'post', postId);
// Remove bookmark
if (isBookmarked) {
const bookmarkId = `post:${postId}`;
await bookmarkService.removeBookmark(bookmarkId);
}
// Clear all bookmarks
const allBookmarks = await client.database.getUserBookmarks(userId);
for (const bookmark of allBookmarks) {
await bookmarkService.removeBookmark(bookmark.id);
}
16) Pending state management
Track pending operations for optimistic UI:
// Mark content as pending
client.database.markPending(postId);
// Check if pending
const isPending = client.database.isPending(postId);
if (isPending) {
showSyncingIndicator(postId);
}
// Listen for pending changes
const unsubscribe = client.database.onPendingChange(() => {
// Update UI when pending state changes
updateAllPendingIndicators();
});
// Clear pending when confirmed
client.database.clearPending(postId);
17) Persistence and hydration
Load persisted data on app start:
async function initializeApp() {
// Open database (hydrates from IndexedDB)
await client.database.open();
// Load stored user
const storedUser = await client.database.loadUser();
if (storedUser) {
console.log('Restored user session:', storedUser.displayName);
// Check if delegation is still valid
const delegationStatus = await client.delegation.getStatus(
storedUser.address
);
if (!delegationStatus.isValid) {
console.log('Delegation expired, need to re-authorize');
await client.database.clearUser();
await client.database.clearDelegation();
}
}
// Content is already hydrated from IndexedDB
console.log('Loaded from cache:', {
cells: Object.keys(client.database.cache.cells).length,
posts: Object.keys(client.database.cache.posts).length,
comments: Object.keys(client.database.cache.comments).length,
votes: Object.keys(client.database.cache.votes).length
});
}
18) Message validation
Validate messages before processing:
import { MessageValidator } from '@opchan/core';
const validator = new MessageValidator();
// Validate a message
const isValid = await validator.isValidMessage(message);
if (!isValid) {
// Get detailed validation report
const report = await validator.getValidationReport(message);
console.error('Invalid message:', {
missingFields: report.missingFields,
invalidFields: report.invalidFields,
hasValidSignature: report.hasValidSignature,
errors: report.errors,
warnings: report.warnings
});
}
19) Network state management
Monitor and manage network connectivity:
// Get current network status
const isReady = client.messageManager.isReady;
const health = client.messageManager.currentHealth;
console.log('Network ready:', isReady);
console.log('Network health:', health);
// Get sync state
const syncState = client.database.getSyncState();
console.log('Last sync:', new Date(syncState.lastSync || 0));
console.log('Is syncing:', syncState.isSyncing);
// Listen for health changes
client.messageManager.onHealthChange((isHealthy) => {
if (isHealthy) {
console.log('Network connected - messages will sync');
} else {
console.log('Network disconnected - working offline');
}
});
20) Complete application skeleton
import {
OpChanClient,
EVerificationStatus,
EDisplayPreference,
transformPost,
transformComment,
type User
} from '@opchan/core';
class ForumApp {
private client: OpChanClient;
private currentUser: User | null = null;
private unsubscribers: (() => void)[] = [];
async initialize() {
// 1. Create client
this.client = new OpChanClient({
wakuConfig: {
contentTopic: '/opchan/1/messages/proto',
reliableChannelId: 'opchan-messages'
}
});
// 2. Open database (hydrates from IndexedDB)
await this.client.database.open();
// 3. Try to restore user session
this.currentUser = await this.client.database.loadUser();
// 4. If no session, start anonymous
if (!this.currentUser) {
await this.startAnonymousSession();
}
// 5. Set up listeners
this.setupListeners();
// 6. Initial render
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
};
await this.client.database.storeUser(this.currentUser);
}
private setupListeners() {
// Message listener
const unsubMsg = this.client.messageManager.onMessageReceived(
async (message) => {
await this.client.database.applyMessage(message);
this.render();
}
);
this.unsubscribers.push(unsubMsg);
// Health listener
const unsubHealth = this.client.messageManager.onHealthChange(
(isHealthy) => {
console.log('Network:', isHealthy ? 'Connected' : 'Disconnected');
this.updateNetworkStatus(isHealthy);
}
);
this.unsubscribers.push(unsubHealth);
// Identity listener
const unsubIdentity = this.client.userIdentityService.subscribe(
(address, identity) => {
console.log('Identity updated:', address, identity);
this.render();
}
);
this.unsubscribers.push(unsubIdentity);
// Pending listener
const unsubPending = this.client.database.onPendingChange(() => {
this.updatePendingIndicators();
});
this.unsubscribers.push(unsubPending);
}
async createPost(cellId: string, title: string, content: string) {
if (!this.currentUser) return;
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);
this.client.database.markPending(result.data!.id);
} else {
console.error('Failed:', result.error);
}
}
async vote(targetId: string, isUpvote: boolean) {
if (!this.currentUser) return;
await this.client.forumActions.vote(
{
targetId,
isUpvote,
currentUser: this.currentUser,
isAuthenticated: true
},
() => this.render()
);
}
private render() {
// Get data from cache
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);
// Transform and sort by relevance
Promise.all(posts.map(p => transformPost(p))).then(transformedPosts => {
const sorted = transformedPosts
.filter(p => p !== null)
.sort((a, b) => (b!.relevanceScore || 0) - (a!.relevanceScore || 0));
// Update DOM
this.renderCells(cells);
this.renderPosts(sorted);
this.renderComments(comments);
});
}
private renderCells(cells: any[]) {
console.log('Rendering', cells.length, 'cells');
// Update DOM here...
}
private renderPosts(posts: any[]) {
console.log('Rendering', posts.length, 'posts');
// Update DOM here...
}
private renderComments(comments: any[]) {
console.log('Rendering', comments.length, 'comments');
// Update DOM here...
}
private updateNetworkStatus(isHealthy: boolean) {
// Update network indicator in UI
const indicator = document.getElementById('network-status');
if (indicator) {
indicator.textContent = isHealthy ? '🟢 Connected' : '🔴 Offline';
}
}
private updatePendingIndicators() {
// Update all pending indicators
document.querySelectorAll('[data-pending]').forEach(el => {
const id = el.getAttribute('data-id');
if (id && this.client.database.isPending(id)) {
el.classList.add('syncing');
} else {
el.classList.remove('syncing');
}
});
}
cleanup() {
this.unsubscribers.forEach(unsub => unsub());
}
}
// Initialize app
const app = new ForumApp();
app.initialize().then(() => {
console.log('App initialized');
});
21) Best practices
- Always open database: Call
client.database.open()before using the client - Set up message listener: Subscribe to
onMessageReceivedearly to stay synchronized - Monitor network health: Use
onHealthChangeto show connection status - Use optimistic UI: Mark items as pending during network operations
- Cache identity lookups:
UserIdentityServiceautomatically caches ENS resolution - Transform messages: Use
transformPost/transformComment/transformCellfor enhanced data - Validate before storage:
LocalDatabase.applyMessagevalidates all messages - Handle delegation expiry: Check
delegation.getStatus()and re-authorize when needed - Persist user session: Use
database.storeUser/loadUserfor session continuity - Clean up listeners: Call unsubscribe functions when components unmount
22) Error handling
// Wrap operations in try-catch
try {
const result = await client.forumActions.createPost(params, callback);
if (!result.success) {
// Handle business logic errors
showError(result.error);
}
} catch (error) {
// Handle unexpected errors
console.error('Unexpected error:', error);
showError('An unexpected error occurred');
}
// Check delegation before operations
const status = await client.delegation.getStatus(currentUser.address);
if (!status.isValid) {
showError('Delegation expired. Please re-authorize.');
await reauthorizeUser();
}
// Handle network errors
client.messageManager.onHealthChange((isHealthy) => {
if (!isHealthy) {
showWarning('Network disconnected. Working offline.');
}
});
23) Notes
- The core package is framework-agnostic - use with React, Vue, Svelte, or vanilla JS
- All content is stored locally and synchronized via Waku network
- Delegation lasts 7 or 30 days - users need to re-authorize after expiry
- Anonymous users can post/comment/vote but cannot create cells
- Cell creation requires ENS-verified wallet
- Moderation is cell-owner only
- All messages are cryptographically signed and verified
- IndexedDB persists data across sessions
See the React package for a higher-level React integration layer built on top of this core SDK.