30 KiB
@opchan/core Architecture Guide
Deep dive into the architecture, design patterns, and implementation details of the OpChan Core SDK.
Table of Contents
- System Overview
- Core Architecture
- Data Flow
- Key Subsystems
- Cryptographic Design
- Storage Strategy
- Network Layer
- Design Patterns
- Performance Considerations
- Security Model
System Overview
OpChan Core is a decentralized forum infrastructure built on three pillars:
- Cryptographic Identity - Ed25519 key delegation with wallet authorization
- P2P Messaging - Waku protocol for decentralized communication
- Local-First Storage - IndexedDB with in-memory caching
Design Philosophy
- Local-First: All data persisted locally, network as synchronization mechanism
- Optimistic UI: Immediate feedback, eventual consistency
- Privacy-Preserving: No centralized servers, peer-to-peer architecture
- Framework-Agnostic: Pure TypeScript library, no UI framework dependencies
Core Architecture
┌───────────────────────────────────────────────────────────────────┐
│ OpChanClient │
│ Entry point orchestrating all subsystems │
└───────────────────────────────────────────────────────────────────┘
│
┌────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ MessageManager │ │ LocalDatabase │ │ DelegationManager│
│ │ │ │ │ │
│ - WakuNode │ │ - IndexedDB │ │ - Key Generation │
│ - Reliable │ │ - In-memory │ │ - Signing │
│ Messaging │ │ Cache │ │ - Verification │
│ - Health Monitor │ │ - Validation │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ Service Layer │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ ForumActions │ │ Identity │ │ Relevance │ │
│ │ │ │ Service │ │ Calculator │ │
│ │ - Create │ │ │ │ │ │
│ │ - Moderate │ │ - ENS Lookup │ │ - Scoring │ │
│ │ - Vote │ │ - Profiles │ │ - Time Decay │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────────────┘
Component Responsibilities
OpChanClient
- Role: Facade pattern - single entry point for all operations
- Responsibilities:
- Instantiate and wire all services
- Configure environment
- Initialize message manager with Waku config
- Provide unified API surface
MessageManager
- Role: Network abstraction layer
- Responsibilities:
- Manage Waku node lifecycle
- Handle message sending/receiving
- Monitor network health
- Implement reliable messaging (retries, acknowledgments)
- Manage subscriptions to content topics
LocalDatabase
- Role: Persistence and caching layer
- Responsibilities:
- IndexedDB operations (async)
- In-memory cache (sync)
- Message validation
- Deduplication
- State management (pending, syncing)
DelegationManager
- Role: Cryptographic signing system
- Responsibilities:
- Generate Ed25519 keypairs
- Create wallet-authorized delegations
- Sign messages with browser keys
- Verify message signatures
- Verify delegation proofs
Data Flow
Outbound Message Flow (Creating Content)
1. User Action
└─> ForumActions.createPost()
│
├─> Validate permissions (wallet or anonymous)
│
├─> Create unsigned message with UUID
│
├─> DelegationManager.signMessage()
│ ├─> Load cached delegation
│ ├─> Check expiry
│ ├─> Sign with browser private key (Ed25519)
│ └─> Attach signature + browserPubKey + delegationProof
│
├─> LocalDatabase.applyMessage()
│ ├─> Validate signature
│ ├─> Check for duplicates
│ ├─> Store in cache
│ └─> Persist to IndexedDB
│
├─> LocalDatabase.markPending()
│
├─> Call updateCallback() [UI refresh]
│
└─> MessageManager.sendMessage()
├─> Encode with protobuf
├─> Send via Waku
└─> Wait for acknowledgment
└─> LocalDatabase.clearPending()
Inbound Message Flow (Receiving Content)
1. Waku Network
└─> WakuNodeManager receives message
│
├─> Decode protobuf
│
├─> ReliableMessaging handles deduplication
│
└─> MessageService.onMessageReceived() callback
│
└─> LocalDatabase.applyMessage()
│
├─> MessageValidator.isValidMessage()
│ ├─> Check required fields
│ ├─> Verify signature (Ed25519)
│ ├─> If delegationProof:
│ │ ├─> Verify auth message format
│ │ ├─> Verify wallet signature (viem)
│ │ └─> Check expiry (optional)
│ └─> If anonymous:
│ └─> Verify session ID format (UUID)
│
├─> Check duplicate (type:id:timestamp key)
│
├─> Store in appropriate cache collection
│ ├─> cells[id]
│ ├─> posts[id]
│ ├─> comments[id]
│ ├─> votes[targetId:author]
│ ├─> moderations[key]
│ └─> userIdentities[address]
│
├─> Persist to IndexedDB
│
├─> Update lastSync timestamp
│
└─> Return true (new message)
Key Subsystems
1. Delegation Subsystem
Purpose: Reduce wallet signature prompts while maintaining cryptographic security.
Components:
- DelegationManager - Core delegation logic
- DelegationStorage - IndexedDB persistence
- DelegationCrypto - Ed25519 signing/verification
Delegation Process (Wallet):
// 1. Generate browser keypair
const keypair = DelegationCrypto.generateKeypair();
// { publicKey: string, privateKey: string }
// 2. Create authorization message
const authMessage = `
Authorize browser key:
Public Key: ${keypair.publicKey}
Wallet: ${walletAddress}
Expires: ${new Date(expiryTimestamp).toISOString()}
Nonce: ${nonce}
`;
// 3. Sign with wallet (happens once)
const walletSignature = await signFunction(authMessage);
// 4. Store delegation
const delegation: WalletDelegationInfo = {
authMessage,
walletSignature,
expiryTimestamp,
walletAddress,
browserPublicKey: keypair.publicKey,
browserPrivateKey: keypair.privateKey,
nonce
};
await DelegationStorage.store(delegation);
// 5. Subsequent messages signed with browser key
const signature = DelegationCrypto.signRaw(messageJson, privateKey);
Delegation Process (Anonymous):
// 1. Generate browser keypair
const keypair = DelegationCrypto.generateKeypair();
// 2. Generate session ID (UUID)
const sessionId = crypto.randomUUID();
// 3. Store anonymous delegation
const delegation: AnonymousDelegationInfo = {
sessionId,
browserPublicKey: keypair.publicKey,
browserPrivateKey: keypair.privateKey,
expiryTimestamp,
nonce
};
// 4. No wallet signature required
// User address = sessionId
Verification Process:
// 1. Verify message signature
const messagePayload = JSON.stringify({
...message,
signature: undefined,
browserPubKey: undefined,
delegationProof: undefined
});
const signatureValid = DelegationCrypto.verifyRaw(
messagePayload,
message.signature,
message.browserPubKey
);
if (!signatureValid) return false;
// 2. Verify delegation authorization
if (message.delegationProof) {
// Wallet user - verify delegation proof
const proofValid = await DelegationCrypto.verifyWalletSignature(
message.delegationProof.authMessage,
message.delegationProof.walletSignature,
message.delegationProof.walletAddress
);
// Check auth message contains browser key, wallet address, expiry
const authMessageValid =
message.delegationProof.authMessage.includes(message.browserPubKey) &&
message.delegationProof.authMessage.includes(message.author) &&
message.delegationProof.authMessage.includes(
message.delegationProof.expiryTimestamp.toString()
);
return proofValid && authMessageValid;
} else {
// Anonymous user - verify session ID format
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
message.author
);
}
2. Storage Subsystem
Purpose: Fast local access with persistent storage.
Two-Tier Architecture:
-
In-Memory Cache (Tier 1)
- Synchronous access
- Fast reads for UI rendering
- Volatile (resets on page reload)
-
IndexedDB (Tier 2)
- Asynchronous access
- Persistent across sessions
- Hydrates cache on startup
IndexedDB Schema:
const schema = {
// Content stores (keyPath = 'id')
cells: { keyPath: 'id' },
posts: { keyPath: 'id' },
comments: { keyPath: 'id' },
// Votes (keyPath = 'key', composite: targetId:author)
votes: { keyPath: 'key' },
// Moderations (keyPath = 'key', composite varies by type)
moderations: { keyPath: 'key' },
// User identities (keyPath = 'address')
userIdentities: {
keyPath: 'address',
indexes: []
},
// Bookmarks (keyPath = 'id')
bookmarks: {
keyPath: 'id',
indexes: [{ name: 'by_userId', keyPath: 'userId' }]
},
// Auth/state stores (keyPath = 'key')
userAuth: { keyPath: 'key' },
delegation: { keyPath: 'key' },
uiState: { keyPath: 'key' },
meta: { keyPath: 'key' }
};
Cache Synchronization:
┌─────────────────────────────────────────────────────┐
│ Write Operation │
│ │
│ 1. Update in-memory cache (immediate) │
│ 2. Write to IndexedDB (async, fire-and-forget) │
│ 3. Notify listeners (for UI update) │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Read Operation │
│ │
│ 1. Check in-memory cache (fast path) │
│ 2. If not found, return null/empty │
│ (IndexedDB only used for hydration on startup) │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Startup Hydration │
│ │
│ 1. Open IndexedDB connection │
│ 2. Load all stores in parallel │
│ 3. Populate in-memory cache │
│ 4. Ready for use │
└─────────────────────────────────────────────────────┘
Deduplication:
Messages are deduplicated using a composite key:
const messageKey = `${message.type}:${message.id}:${message.timestamp}`;
This allows the same logical message to be received multiple times without creating duplicates.
3. Identity Subsystem
Purpose: Resolve and cache user identities with ENS integration.
Resolution Strategy:
┌────────────────────────────────────────────────────┐
│ Identity Resolution Flow │
│ │
│ 1. Check LocalDatabase cache │
│ └─> If found and fresh (< 5 min): return │
│ │
│ 2. Check if Ethereum address │
│ └─> If not (anonymous/UUID): return null │
│ │
│ 3. Resolve ENS via PublicClient │
│ ├─> Get ENS name │
│ ├─> Get ENS avatar │
│ └─> Cache result for 5 minutes │
│ │
│ 4. Build UserIdentity object │
│ ├─> address │
│ ├─> ensName │
│ ├─> ensAvatar │
│ ├─> callSign (from profile messages) │
│ ├─> displayPreference │
│ ├─> displayName (computed) │
│ ├─> verificationStatus (computed) │
│ └─> lastUpdated │
│ │
│ 5. Store in LocalDatabase │
│ │
│ 6. Return identity │
└────────────────────────────────────────────────────┘
Display Name Resolution:
function getDisplayName(identity: UserIdentity): string {
// Priority 1: Call sign (if preference is CALL_SIGN)
if (
identity.callSign &&
identity.displayPreference === EDisplayPreference.CALL_SIGN
) {
return identity.callSign;
}
// Priority 2: ENS name
if (identity.ensName) {
return identity.ensName;
}
// Priority 3: Shortened address
return `${identity.address.slice(0, 6)}...${identity.address.slice(-4)}`;
}
Profile Updates:
User profile updates are broadcast as USER_PROFILE_UPDATE messages:
{
type: MessageType.USER_PROFILE_UPDATE,
id: uuid(),
timestamp: Date.now(),
author: userAddress,
callSign: 'alice', // optional
displayPreference: EDisplayPreference.CALL_SIGN,
signature: '...',
browserPubKey: '...',
delegationProof: { ... }
}
These messages update the userIdentities cache, propagating changes across the network.
4. Relevance Subsystem
Purpose: Score content based on engagement, verification, time, and moderation.
Scoring Algorithm:
interface RelevanceFactors {
base: 100;
engagement: {
upvoteWeight: 10;
commentWeight: 3;
};
verification: {
authorBonus: 20; // ENS verified author
upvoteBonus: 5; // Per ENS verified upvoter
commenterBonus: 10; // Per ENS verified commenter
};
timeDecay: {
halfLifeDays: 7;
formula: 'exponential'; // exp(-0.693 * days / halfLife)
};
moderation: {
penalty: 0.5; // 50% reduction if moderated
};
}
function calculateScore(
post: Post,
votes: Vote[],
comments: Comment[],
verifications: Map<address, boolean>,
moderations: Map<postId, Moderation>
): number {
// Base score
let score = 100;
// Engagement
const upvotes = votes.filter(v => v.value === 1).length;
const downvotes = votes.filter(v => v.value === -1).length;
score += (upvotes * 10) + (comments.length * 3);
// Verification bonuses
if (verifications.get(post.author)) {
score += 20; // Author ENS verified
}
const verifiedUpvoters = votes
.filter(v => v.value === 1 && verifications.get(v.author))
.length;
score += verifiedUpvoters * 5;
const verifiedCommenters = new Set(
comments
.map(c => c.author)
.filter(author => verifications.get(author))
).size;
score += verifiedCommenters * 10;
// Time decay (exponential)
const daysOld = (Date.now() - post.timestamp) / (1000 * 60 * 60 * 24);
const decay = Math.exp(-0.693 * daysOld / 7); // Half-life 7 days
score *= decay;
// Moderation penalty
if (moderations.has(post.id)) {
score *= 0.5;
}
return Math.max(0, score);
}
Score Components Breakdown:
interface RelevanceScoreDetails {
baseScore: 100,
engagementScore: (upvotes * 10) + (comments * 3),
authorVerificationBonus: isENS ? 20 : 0,
verifiedUpvoteBonus: verifiedUpvoters * 5,
verifiedCommenterBonus: verifiedCommenters * 10,
timeDecayMultiplier: exp(-0.693 * daysOld / 7),
moderationPenalty: isModerated ? 0.5 : 1.0,
finalScore: (base + engagement + bonuses) * decay * modPenalty
}
Cryptographic Design
Key Hierarchy
Wallet Private Key (User's wallet, never exposed)
│
├─> Signs authorization message
│ └─> Stored in delegationProof.walletSignature
│
└─> Authorizes ─────────────────────────┐
│
Browser Private Key (Generated, stored locally)
│ │
├─> Signs all messages │
│ └─> Stored in message.signature │
│ │
└─> Public key: message.browserPubKey ───┘
Cryptographic Primitives
Ed25519 (via @noble/ed25519):
- Browser key generation
- Message signing
- Signature verification
ECDSA (via viem):
- Wallet signature verification
- ENS resolution
Message Signature Structure
// Unsigned message
const unsignedMessage = {
type: 'post',
id: 'abc123',
cellId: 'xyz789',
title: 'Hello',
content: 'World',
timestamp: 1234567890,
author: '0x...'
};
// Message to sign (excludes signature fields)
const messageToSign = JSON.stringify({
...unsignedMessage,
signature: undefined,
browserPubKey: undefined,
delegationProof: undefined
});
// Sign with browser private key
const signature = await ed25519.sign(
sha512(messageToSign),
browserPrivateKey
);
// Signed message
const signedMessage = {
...unsignedMessage,
signature: bytesToHex(signature),
browserPubKey: bytesToHex(browserPublicKey),
delegationProof: {
authMessage: '...',
walletSignature: '...',
expiryTimestamp: 1234567890,
walletAddress: '0x...'
}
};
Verification Logic
async function verifyMessage(message: OpchanMessage): Promise<boolean> {
// 1. Verify message signature with browser public key
const messagePayload = JSON.stringify({
...message,
signature: undefined,
browserPubKey: undefined,
delegationProof: undefined
});
const signatureValid = await ed25519.verify(
hexToBytes(message.signature),
sha512(messagePayload),
hexToBytes(message.browserPubKey)
);
if (!signatureValid) return false;
// 2. Verify delegation authorization
if (message.delegationProof) {
// Wallet user - verify wallet signature on auth message
const { authMessage, walletSignature, walletAddress } = message.delegationProof;
const walletSigValid = await verifyMessage({
account: walletAddress,
message: authMessage,
signature: walletSignature
});
if (!walletSigValid) return false;
// Verify auth message contains browser key and expiry
if (!authMessage.includes(message.browserPubKey)) return false;
if (!authMessage.includes(walletAddress)) return false;
if (!authMessage.includes(message.delegationProof.expiryTimestamp.toString())) {
return false;
}
return true;
} else {
// Anonymous user - verify session ID is valid UUID
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
message.author
);
}
}
Network Layer
Waku Protocol Integration
Architecture:
┌─────────────────────────────────────────────────────┐
│ MessageManager │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ WakuNodeManager │ │
│ │ - Create Waku node │ │
│ │ - Connect to bootstrap peers │ │
│ │ - Monitor health │ │
│ │ - Emit health events │ │
│ └────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────┐ │
│ │ ReliableMessaging │ │
│ │ - Store & Forward protocol │ │
│ │ - Message deduplication │ │
│ │ - Acknowledgments │ │
│ │ - Retries │ │
│ └────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────┐ │
│ │ MessageService │ │
│ │ - Send messages │ │
│ │ - Receive subscriptions │ │
│ │ - Codec management (protobuf) │ │
│ └────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Content Topics:
Messages are published/subscribed via content topics:
const contentTopic = '/opchan/1/messages/proto';
// All message types share the same content topic
// Filtering happens at application layer based on message.type
Message Encoding:
Messages are encoded with protobuf before transmission (handled by Waku SDK).
Reliable Messaging:
The ReliableMessaging layer provides:
- Store-and-Forward: Messages cached for offline peers
- Deduplication: Filter out duplicate receives
- Acknowledgments: Confirm message delivery
- Retries: Resend on failure
Design Patterns
1. Singleton Pattern
DelegationManager, LocalDatabase, MessageManager are exported as singletons:
// Singleton instance
export const delegationManager = new DelegationManager();
export const localDatabase = new LocalDatabase();
export default new DefaultMessageManager();
2. Facade Pattern
OpChanClient acts as a facade, providing a simplified interface:
class OpChanClient {
// Aggregates all services
readonly delegation: DelegationManager;
readonly database: LocalDatabase;
readonly messageManager: DefaultMessageManager;
readonly forumActions: ForumActions;
// ...
}
3. Observer Pattern
Event subscriptions throughout:
// MessageManager
messageManager.onMessageReceived(callback);
messageManager.onHealthChange(callback);
messageManager.onSyncStatus(callback);
// UserIdentityService
userIdentityService.subscribe(callback);
// LocalDatabase
database.onPendingChange(callback);
4. Strategy Pattern
MessageValidator validates different message types:
class MessageValidator {
async isValidMessage(message: unknown): Promise<boolean> {
// Different validation logic per message type
switch (message.type) {
case MessageType.CELL:
return this.validateCell(message);
case MessageType.POST:
return this.validatePost(message);
// ...
}
}
}
5. Repository Pattern
LocalDatabase acts as a repository abstracting storage:
interface Repository<T> {
getById(id: string): T | null;
getAll(): T[];
save(item: T): Promise<void>;
delete(id: string): Promise<void>;
}
// LocalDatabase implements repository pattern for each entity type
Performance Considerations
1. In-Memory Caching
All reads are synchronous from in-memory cache:
// Fast - synchronous cache access
const posts = Object.values(client.database.cache.posts);
// No IndexedDB reads during normal operation
2. Lazy Identity Resolution
Identities resolved on-demand with caching:
// First call: async ENS lookup
const identity1 = await getIdentity(address);
// Subsequent calls: cache hit (fast)
const identity2 = await getIdentity(address); // same address
3. Debouncing
Identity lookups are debounced to avoid redundant calls:
// Multiple rapid calls
getIdentity(address); // Starts timer
getIdentity(address); // Resets timer
getIdentity(address); // Resets timer
// Only executes once after 100ms
4. Batch Operations
IndexedDB writes are batched:
// Single transaction for multiple writes
const tx = db.transaction(['cells', 'posts'], 'readwrite');
tx.objectStore('cells').put(cell);
tx.objectStore('posts').put(post);
await tx.complete;
5. Optimistic UI
Immediate feedback with pending states:
// 1. Write to cache immediately
cache.posts[postId] = post;
// 2. Mark as pending
markPending(postId);
// 3. Update UI (shows post with "syncing" badge)
updateUI();
// 4. Send to network (async)
sendMessage(post);
// 5. Clear pending when confirmed
clearPending(postId);
Security Model
Threat Model
Trusted:
- User's device and browser
- User's wallet private key
Untrusted:
- Network peers
- Network infrastructure
- Message content
Security Guarantees
-
Message Authenticity
- All messages signed with browser key
- Browser key authorized by wallet (for wallet users)
- Anonymous users sign with session key (no wallet)
-
Message Integrity
- Signatures cover entire message payload
- Any modification invalidates signature
-
Non-Repudiation
- Messages cryptographically tied to author
- Cannot deny authorship of signed messages
-
Replay Protection
- Deduplication prevents replayed messages
- Timestamps in message payloads
Attack Resistance
Impersonation:
- ❌ Prevented: Cannot forge signature without private key
Message Tampering:
- ❌ Prevented: Modified messages fail signature verification
Replay Attacks:
- ✅ Mitigated: Deduplication based on (type🆔timestamp)
- ⚠️ Limited: Same message can be replayed with different timestamp
Sybil Attacks:
- ⚠️ Possible: Anonymous users can create multiple sessions
- ✅ Mitigated: ENS-verified users have higher trust/scoring
DoS Attacks:
- ⚠️ Possible: Can flood network with messages
- ✅ Mitigated: Client-side validation rejects malformed messages
End of Architecture Guide