chore: initial commit for v0.0.1
DChain single-node blockchain + React Native messenger client. Core: - PBFT consensus with multi-sig validator admission + equivocation slashing - BadgerDB + schema migration scaffold (CurrentSchemaVersion=0) - libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1) - Native Go contracts (username_registry) alongside WASM (wazero) - WebSocket gateway with topic-based fanout + Ed25519-nonce auth - Relay mailbox with NaCl envelope encryption (X25519 + Ed25519) - Prometheus /metrics, per-IP rate limit, body-size cap Deployment: - Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus - 3-node dev compose (docker-compose.yml) with mocked internet topology - 3-validator prod compose (deploy/prod/) for federation - Auto-update from Gitea via /api/update-check + systemd timer - Build-time version injection (ldflags → node --version) - UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER) Client (client-app/): - Expo / React Native / NativeWind - E2E NaCl encryption, typing indicator, contact requests - Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch Documentation: - README.md, CHANGELOG.md, CONTEXT.md - deploy/single/README.md with 6 operator scenarios - deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design - docs/contracts/*.md per contract
This commit is contained in:
401
client-app/lib/ws.ts
Normal file
401
client-app/lib/ws.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* DChain WebSocket client — replaces balance / inbox / contacts polling with
|
||||
* server-push. Matches `node/ws.go` exactly.
|
||||
*
|
||||
* Usage:
|
||||
* const ws = getWSClient();
|
||||
* ws.connect(); // idempotent
|
||||
* const off = ws.subscribe('addr:ab12…', ev => { ... });
|
||||
* // later:
|
||||
* off(); // unsubscribe + stop handler
|
||||
* ws.disconnect();
|
||||
*
|
||||
* Features:
|
||||
* - Auto-reconnect with exponential backoff (1s → 30s cap).
|
||||
* - Re-subscribes all topics after a reconnect.
|
||||
* - `hello` frame exposes chain_id + tip_height for connection state UI.
|
||||
* - Degrades silently if the endpoint returns 501 (old node without WS).
|
||||
*/
|
||||
|
||||
import { getNodeUrl, onNodeUrlChange } from './api';
|
||||
import { sign } from './crypto';
|
||||
|
||||
export type WSEventName =
|
||||
| 'hello'
|
||||
| 'block'
|
||||
| 'tx'
|
||||
| 'contract_log'
|
||||
| 'inbox'
|
||||
| 'typing'
|
||||
| 'pong'
|
||||
| 'error'
|
||||
| 'subscribed'
|
||||
| 'submit_ack'
|
||||
| 'lag';
|
||||
|
||||
export interface WSFrame {
|
||||
event: WSEventName;
|
||||
data?: unknown;
|
||||
topic?: string;
|
||||
msg?: string;
|
||||
chain_id?: string;
|
||||
tip_height?: number;
|
||||
/** Server-issued nonce in the hello frame; client signs it for auth. */
|
||||
auth_nonce?: string;
|
||||
// submit_ack fields
|
||||
id?: string;
|
||||
status?: 'accepted' | 'rejected';
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
type Handler = (frame: WSFrame) => void;
|
||||
|
||||
class WSClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string | null = null;
|
||||
private reconnectMs: number = 1000;
|
||||
private closing: boolean = false;
|
||||
|
||||
/** topic → set of handlers interested in frames for this topic */
|
||||
private handlers: Map<string, Set<Handler>> = new Map();
|
||||
/** topics we want the server to push — replayed on every reconnect */
|
||||
private wantedTopics: Set<string> = new Set();
|
||||
|
||||
private connectionListeners: Set<(ok: boolean, err?: string) => void> = new Set();
|
||||
private helloInfo: { chainId?: string; tipHeight?: number; authNonce?: string } = {};
|
||||
|
||||
/**
|
||||
* Credentials used for auto-auth on every (re)connect. The signer runs on
|
||||
* each hello frame so scoped subscriptions (addr:*, inbox:*) are accepted.
|
||||
* Without these, subscribe requests to scoped topics get rejected by the
|
||||
* server; global topics (blocks, tx, …) still work unauthenticated.
|
||||
*/
|
||||
private authCreds: { pubKey: string; privKey: string } | null = null;
|
||||
|
||||
/** Current connection state (read-only for UI). */
|
||||
isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
getHelloInfo(): { chainId?: string; tipHeight?: number } {
|
||||
return this.helloInfo;
|
||||
}
|
||||
|
||||
/** Subscribe to a connection-state listener — fires on connect/disconnect. */
|
||||
onConnectionChange(cb: (ok: boolean, err?: string) => void): () => void {
|
||||
this.connectionListeners.add(cb);
|
||||
return () => this.connectionListeners.delete(cb) as unknown as void;
|
||||
}
|
||||
private fireConnectionChange(ok: boolean, err?: string) {
|
||||
for (const cb of this.connectionListeners) {
|
||||
try { cb(ok, err); } catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Ed25519 keypair used for auto-auth. The signer runs on each
|
||||
* (re)connect against the server-issued nonce so the connection is bound
|
||||
* to this identity. Pass null to disable auth (only global topics will
|
||||
* work — useful for observers).
|
||||
*/
|
||||
setAuthCreds(creds: { pubKey: string; privKey: string } | null): void {
|
||||
this.authCreds = creds;
|
||||
// If we're already connected, kick off auth immediately.
|
||||
if (creds && this.isConnected() && this.helloInfo.authNonce) {
|
||||
this.sendAuth(this.helloInfo.authNonce);
|
||||
}
|
||||
}
|
||||
|
||||
/** Idempotent connect. Call once on app boot. */
|
||||
connect(): void {
|
||||
const base = getNodeUrl();
|
||||
const newURL = base.replace(/^http/, 'ws') + '/api/ws';
|
||||
if (this.ws) {
|
||||
const state = this.ws.readyState;
|
||||
// Already pointing at this URL and connected / connecting — nothing to do.
|
||||
if (this.url === newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
// URL changed (operator flipped nodes in settings) — tear down and
|
||||
// re-dial. Existing subscriptions live in wantedTopics and will be
|
||||
// replayed after the new onopen fires.
|
||||
if (this.url !== newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) {
|
||||
try { this.ws.close(); } catch { /* noop */ }
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
this.closing = false;
|
||||
this.url = newURL;
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
} catch (e: any) {
|
||||
this.fireConnectionChange(false, e?.message ?? 'ws construct failed');
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectMs = 1000; // reset backoff
|
||||
this.fireConnectionChange(true);
|
||||
// Replay all wanted subscriptions.
|
||||
for (const topic of this.wantedTopics) {
|
||||
this.sendRaw({ op: 'subscribe', topic });
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onmessage = (ev) => {
|
||||
let frame: WSFrame;
|
||||
try {
|
||||
frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (frame.event === 'hello') {
|
||||
this.helloInfo = {
|
||||
chainId: frame.chain_id,
|
||||
tipHeight: frame.tip_height,
|
||||
authNonce: frame.auth_nonce,
|
||||
};
|
||||
// Auto-authenticate if credentials are set. The server binds this
|
||||
// connection to the signed pubkey so scoped subscriptions (addr:*,
|
||||
// inbox:*) get through. On reconnect a new nonce is issued, so the
|
||||
// auth dance repeats transparently.
|
||||
if (this.authCreds && frame.auth_nonce) {
|
||||
this.sendAuth(frame.auth_nonce);
|
||||
}
|
||||
}
|
||||
// Dispatch to all handlers for any topic that could match this frame.
|
||||
// We use a simple predicate: look at the frame to decide which topics it
|
||||
// was fanned out to, then fire every matching handler.
|
||||
for (const topic of this.topicsForFrame(frame)) {
|
||||
const set = this.handlers.get(topic);
|
||||
if (!set) continue;
|
||||
for (const h of set) {
|
||||
try { h(frame); } catch (e) { console.warn('[ws] handler error', e); }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (e: any) => {
|
||||
this.fireConnectionChange(false, 'ws error');
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.ws = null;
|
||||
this.fireConnectionChange(false);
|
||||
if (!this.closing) this.scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.closing = true;
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch { /* noop */ }
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a topic. Returns an `off()` function that unsubscribes AND
|
||||
* removes the handler. If multiple callers subscribe to the same topic,
|
||||
* the server is only notified on the first and last caller.
|
||||
*/
|
||||
subscribe(topic: string, handler: Handler): () => void {
|
||||
let set = this.handlers.get(topic);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.handlers.set(topic, set);
|
||||
}
|
||||
set.add(handler);
|
||||
|
||||
// Notify server only on the first handler for this topic.
|
||||
if (!this.wantedTopics.has(topic)) {
|
||||
this.wantedTopics.add(topic);
|
||||
if (this.isConnected()) {
|
||||
this.sendRaw({ op: 'subscribe', topic });
|
||||
} else {
|
||||
this.connect(); // lazy-connect on first subscribe
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
const s = this.handlers.get(topic);
|
||||
if (!s) return;
|
||||
s.delete(handler);
|
||||
if (s.size === 0) {
|
||||
this.handlers.delete(topic);
|
||||
this.wantedTopics.delete(topic);
|
||||
if (this.isConnected()) {
|
||||
this.sendRaw({ op: 'unsubscribe', topic });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Force a keepalive ping. Useful for debugging. */
|
||||
ping(): void {
|
||||
this.sendRaw({ op: 'ping' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a typing indicator to another user. Recipient is their X25519 pubkey
|
||||
* (the one used for inbox encryption). Ephemeral — no ack, no retry; just
|
||||
* fire and forget. Call on each keystroke but throttle to once per 2-3s
|
||||
* at the caller side so we don't flood the WS with frames.
|
||||
*/
|
||||
sendTyping(recipientX25519: string): void {
|
||||
if (!this.isConnected()) return;
|
||||
try {
|
||||
this.ws!.send(JSON.stringify({ op: 'typing', to: recipientX25519 }));
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a signed transaction over the WebSocket and resolve once the
|
||||
* server returns a `submit_ack`. Saves the HTTP round-trip on every tx
|
||||
* and gives the UI immediate accept/reject feedback.
|
||||
*
|
||||
* Rejects if:
|
||||
* - WS is not connected (caller should fall back to HTTP)
|
||||
* - Server returns `status: "rejected"` — `reason` is surfaced as error msg
|
||||
* - No ack within `timeoutMs` (default 10 s)
|
||||
*/
|
||||
submitTx(tx: unknown, timeoutMs = 10_000): Promise<{ id: string }> {
|
||||
if (!this.isConnected()) {
|
||||
return Promise.reject(new Error('WS not connected'));
|
||||
}
|
||||
const reqId = 's_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const off = this.subscribe('$system', (frame) => {
|
||||
if (frame.event !== 'submit_ack' || frame.id !== reqId) return;
|
||||
off();
|
||||
clearTimeout(timer);
|
||||
if (frame.status === 'accepted') {
|
||||
// `msg` carries the server-confirmed tx id.
|
||||
resolve({ id: typeof frame.msg === 'string' ? frame.msg : '' });
|
||||
} else {
|
||||
reject(new Error(frame.reason || 'submit_tx rejected'));
|
||||
}
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
off();
|
||||
reject(new Error('submit_tx timeout (' + timeoutMs + 'ms)'));
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
this.ws!.send(JSON.stringify({ op: 'submit_tx', tx, id: reqId }));
|
||||
} catch (e: any) {
|
||||
off();
|
||||
clearTimeout(timer);
|
||||
reject(new Error('WS send failed: ' + (e?.message ?? 'unknown')));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── internals ───────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.closing) return;
|
||||
const delay = Math.min(this.reconnectMs, 30_000);
|
||||
this.reconnectMs = Math.min(this.reconnectMs * 2, 30_000);
|
||||
setTimeout(() => {
|
||||
if (!this.closing) this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private sendRaw(cmd: { op: string; topic?: string }): void {
|
||||
if (!this.isConnected()) return;
|
||||
try { this.ws!.send(JSON.stringify(cmd)); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the server nonce with our Ed25519 private key and send the `auth`
|
||||
* op. The server binds this connection to `authCreds.pubKey`; subsequent
|
||||
* subscribe requests to `addr:<pubKey>` / `inbox:<my_x25519>` are accepted.
|
||||
*/
|
||||
private sendAuth(nonce: string): void {
|
||||
if (!this.authCreds || !this.isConnected()) return;
|
||||
try {
|
||||
const bytes = new TextEncoder().encode(nonce);
|
||||
const sig = sign(bytes, this.authCreds.privKey);
|
||||
this.ws!.send(JSON.stringify({
|
||||
op: 'auth',
|
||||
pubkey: this.authCreds.pubKey,
|
||||
sig,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn('[ws] auth send failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an incoming frame, enumerate every topic that handlers could have
|
||||
* subscribed to and still be interested. This mirrors the fan-out logic in
|
||||
* node/ws.go:EmitBlock / EmitTx / EmitContractLog.
|
||||
*/
|
||||
private topicsForFrame(frame: WSFrame): string[] {
|
||||
switch (frame.event) {
|
||||
case 'block':
|
||||
return ['blocks'];
|
||||
case 'tx': {
|
||||
const d = frame.data as { from?: string; to?: string } | undefined;
|
||||
const topics = ['tx'];
|
||||
if (d?.from) topics.push('addr:' + d.from);
|
||||
if (d?.to && d.to !== d.from) topics.push('addr:' + d.to);
|
||||
return topics;
|
||||
}
|
||||
case 'contract_log': {
|
||||
const d = frame.data as { contract_id?: string } | undefined;
|
||||
const topics = ['contract_log'];
|
||||
if (d?.contract_id) topics.push('contract:' + d.contract_id);
|
||||
return topics;
|
||||
}
|
||||
case 'inbox': {
|
||||
// Node fans inbox events to `inbox` + `inbox:<recipient_x25519>`;
|
||||
// we mirror that here so both firehose listeners and address-scoped
|
||||
// subscribers see the event.
|
||||
const d = frame.data as { recipient_pub?: string } | undefined;
|
||||
const topics = ['inbox'];
|
||||
if (d?.recipient_pub) topics.push('inbox:' + d.recipient_pub);
|
||||
return topics;
|
||||
}
|
||||
case 'typing': {
|
||||
// Server fans to `typing:<to>` only (the recipient).
|
||||
const d = frame.data as { to?: string } | undefined;
|
||||
return d?.to ? ['typing:' + d.to] : [];
|
||||
}
|
||||
// Control-plane events — no topic fan-out; use a pseudo-topic so UI
|
||||
// can listen for them via subscribe('$system', ...).
|
||||
case 'hello':
|
||||
case 'pong':
|
||||
case 'error':
|
||||
case 'subscribed':
|
||||
case 'submit_ack':
|
||||
case 'lag':
|
||||
return ['$system'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _singleton: WSClient | null = null;
|
||||
|
||||
/**
|
||||
* Return the app-wide WebSocket client. Safe to call from any component;
|
||||
* `.connect()` is idempotent.
|
||||
*
|
||||
* On first creation we register a node-URL listener so flipping the node
|
||||
* in Settings tears down the existing socket and dials the new one — the
|
||||
* user's active subscriptions (addr:*, inbox:*) replay automatically.
|
||||
*/
|
||||
export function getWSClient(): WSClient {
|
||||
if (!_singleton) {
|
||||
_singleton = new WSClient();
|
||||
onNodeUrlChange(() => {
|
||||
// Fire and forget — connect() is idempotent and handles stale URLs.
|
||||
_singleton!.connect();
|
||||
});
|
||||
}
|
||||
return _singleton;
|
||||
}
|
||||
Reference in New Issue
Block a user