From c0ae0e9a534cac57abb4e8617f39c43755249987 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 15 Mar 2026 17:39:41 -0700 Subject: [PATCH] feat(rwallet): add multiplayer shared watchlist via Automerge CRDT Shared wallet address watchlist syncs across space members. Click watched address to load it in the visualizer. Transaction annotations and dashboard config schema ready for future use. Co-Authored-By: Claude Opus 4.6 --- .../rwallet/components/folk-wallet-viewer.ts | 100 +++++++++++++++ modules/rwallet/local-first-client.ts | 121 ++++++++++++++++++ modules/rwallet/schemas.ts | 83 ++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 modules/rwallet/local-first-client.ts create mode 100644 modules/rwallet/schemas.ts diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index 9338742..884c168 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -11,6 +11,8 @@ import type { TimelineEntry, SankeyData, MultichainData } from "../lib/data-tran import { loadD3, renderTimeline, renderFlowChart, renderSankey } from "../lib/wallet-viz"; import { DEMO_TIMELINE_DATA, DEMO_SANKEY_DATA, DEMO_MULTICHAIN_DATA } from "../lib/wallet-demo-data"; import { TourEngine } from "../../../shared/tour-engine"; +import { WalletLocalFirstClient } from "../local-first-client"; +import type { WalletDoc, WatchedAddress } from "../schemas"; interface ChainInfo { chainId: string; @@ -190,6 +192,11 @@ class FolkWalletViewer extends HTMLElement { { target: '.view-tab', title: "Dashboard Views", message: "Switch between balance view, transfer timeline, and flow visualisations for deeper analysis.", advanceOnClick: false }, ]; + // Multiplayer state + private lfClient: WalletLocalFirstClient | null = null; + private _lfcUnsub: (() => void) | null = null; + private watchedAddresses: WatchedAddress[] = []; + constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); @@ -215,6 +222,7 @@ class FolkWalletViewer extends HTMLElement { const params = new URLSearchParams(window.location.search); this.address = params.get("address") || ""; this.checkAuthState(); + this.initWalletSync(space); // Yield view is standalone — always force visualizer tab if (this.activeView === "yield") { @@ -262,6 +270,41 @@ class FolkWalletViewer extends HTMLElement { } catch {} } + private async initWalletSync(space: string) { + try { + this.lfClient = new WalletLocalFirstClient(space); + await this.lfClient.init(); + await this.lfClient.subscribe(); + + this._lfcUnsub = this.lfClient.onChange((doc) => { + this.extractWatchlist(doc); + this.render(); + }); + + const doc = this.lfClient.getDoc(); + if (doc) this.extractWatchlist(doc); + } catch (err) { + console.warn('[rWallet] Local-first init failed:', err); + } + } + + private extractWatchlist(doc: WalletDoc) { + this.watchedAddresses = doc.watchedAddresses + ? Object.values(doc.watchedAddresses).sort((a, b) => b.addedAt - a.addedAt) + : []; + } + + private addToWatchlist(address: string, chain: string, label: string) { + if (!this.lfClient) return; + const did = this.userDID || null; + this.lfClient.addWatchAddress(address, chain, label, did); + } + + private removeFromWatchlist(key: string) { + if (!this.lfClient) return; + this.lfClient.removeWatchAddress(key); + } + private getAuthToken(): string | null { try { const session = localStorage.getItem("encryptid_session"); @@ -1691,6 +1734,29 @@ class FolkWalletViewer extends HTMLElement { `; } + private renderWatchlist(): string { + if (this.watchedAddresses.length === 0 && !this.lfClient) return ''; + const isLive = this.lfClient?.isConnected ?? false; + return ` +
+
+ Shared Watchlist + ${isLive ? 'LIVE' : ''} + +
+ ${this.watchedAddresses.length === 0 + ? '
No watched addresses. Click "Watch" to add one.
' + : `
${this.watchedAddresses.map(w => { + const key = `${w.chain}:${w.address.toLowerCase()}`; + return `
+ ${this.esc(w.label || w.address.slice(0, 8) + '...')} + ${w.address.slice(0, 6)}...${w.address.slice(-4)} + +
`; + }).join('')}
`} +
`; + } + private renderTopTabBar(): string { return `
@@ -2253,6 +2319,7 @@ class FolkWalletViewer extends HTMLElement { ${this.renderFeatures()} ${this.renderExamples()} + ${this.renderWatchlist()} ${this.renderDashboard()} `; } @@ -2313,6 +2380,39 @@ class FolkWalletViewer extends HTMLElement { }); }); + // Watchlist: add + this.shadow.querySelector('[data-action="add-watch"]')?.addEventListener('click', () => { + const addr = prompt('Wallet address to watch:'); + if (!addr) return; + const label = prompt('Label (optional):') || ''; + const chain = this.selectedChain || '1'; + this.addToWatchlist(addr, chain, label); + }); + + // Watchlist: click to load + this.shadow.querySelectorAll('.watch-item').forEach(el => { + el.addEventListener('click', () => { + const addr = el.dataset.watchAddress; + if (!addr) return; + const input = this.shadow.querySelector('#address-input') as HTMLInputElement; + if (input) input.value = addr; + this.address = addr; + const url = new URL(window.location.href); + url.searchParams.set('address', addr); + window.history.replaceState({}, '', url.toString()); + this.detectChains(); + }); + }); + + // Watchlist: remove + this.shadow.querySelectorAll('.watch-remove').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const key = btn.dataset.watchKey; + if (key) this.removeFromWatchlist(key); + }); + }); + // Draw visualization if active if (this.activeView !== "balances" && this.activeView !== "yield" && this.hasData()) { requestAnimationFrame(() => this.drawActiveVisualization()); diff --git a/modules/rwallet/local-first-client.ts b/modules/rwallet/local-first-client.ts new file mode 100644 index 0000000..2d5b1f2 --- /dev/null +++ b/modules/rwallet/local-first-client.ts @@ -0,0 +1,121 @@ +/** + * rWallet Local-First Client + * + * Wraps the shared local-first stack for shared treasury management. + */ + +import { DocumentManager } from '../../shared/local-first/document'; +import type { DocumentId } from '../../shared/local-first/document'; +import { EncryptedDocStore } from '../../shared/local-first/storage'; +import { DocSyncManager } from '../../shared/local-first/sync'; +import { DocCrypto } from '../../shared/local-first/crypto'; +import { walletSchema, walletDocId } from './schemas'; +import type { WalletDoc, WatchedAddress, TxAnnotation } from './schemas'; + +export class WalletLocalFirstClient { + #space: string; + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #initialized = false; + + constructor(space: string, docCrypto?: DocCrypto) { + this.#space = space; + this.#documents = new DocumentManager(); + this.#store = new EncryptedDocStore(space, docCrypto); + this.#sync = new DocSyncManager({ + documents: this.#documents, + store: this.#store, + }); + this.#documents.registerSchema(walletSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('wallet', 'treasury'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, walletSchema, binary); + } + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[WalletClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = walletDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, walletSchema, binary) + : this.#documents.open(docId, walletSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getDoc(): WalletDoc | undefined { + return this.#documents.get(walletDocId(this.#space) as DocumentId); + } + + onChange(cb: (doc: WalletDoc) => void): () => void { + return this.#sync.onChange(walletDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + + // ── Watchlist CRUD ── + + addWatchAddress(address: string, chain: string, label: string, addedBy: string | null): void { + const docId = walletDocId(this.#space) as DocumentId; + const key = `${chain}:${address.toLowerCase()}`; + this.#sync.change(docId, `Watch ${label || address}`, (d) => { + d.watchedAddresses[key] = { address, chain, label, addedBy, addedAt: Date.now() }; + }); + } + + removeWatchAddress(key: string): void { + const docId = walletDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Unwatch address`, (d) => { + delete d.watchedAddresses[key]; + }); + } + + // ── Annotations ── + + setAnnotation(txHash: string, note: string, authorDid: string): void { + const docId = walletDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Annotate tx`, (d) => { + d.annotations[txHash] = { txHash, note, authorDid, createdAt: Date.now() }; + }); + } + + removeAnnotation(txHash: string): void { + const docId = walletDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Remove annotation`, (d) => { + delete d.annotations[txHash]; + }); + } + + // ── Dashboard config ── + + updateConfig(config: Partial<{ defaultChain: string; displayCurrency: string; layout: string }>): void { + const docId = walletDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Update config`, (d) => { + if (config.defaultChain) d.dashboardConfig.defaultChain = config.defaultChain; + if (config.displayCurrency) d.dashboardConfig.displayCurrency = config.displayCurrency; + if (config.layout) d.dashboardConfig.layout = config.layout; + }); + } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rwallet/schemas.ts b/modules/rwallet/schemas.ts new file mode 100644 index 0000000..9a00948 --- /dev/null +++ b/modules/rwallet/schemas.ts @@ -0,0 +1,83 @@ +/** + * rWallet Automerge document schemas. + * + * Stores shared watchlist addresses, transaction annotations, + * and dashboard configuration synced across space members. + * + * DocId format: {space}:wallet:treasury + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface WatchedAddress { + address: string; + chain: string; + label: string; + addedBy: string | null; + addedAt: number; +} + +export interface TxAnnotation { + txHash: string; + note: string; + authorDid: string; + createdAt: number; +} + +export interface DashboardConfig { + defaultChain: string; + displayCurrency: string; + layout: string; +} + +export interface WalletDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + watchedAddresses: Record; + annotations: Record; + dashboardConfig: DashboardConfig; +} + +// ── Schema registration ── + +export const walletSchema: DocSchema = { + module: 'wallet', + collection: 'treasury', + version: 1, + init: (): WalletDoc => ({ + meta: { + module: 'wallet', + collection: 'treasury', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + watchedAddresses: {}, + annotations: {}, + dashboardConfig: { + defaultChain: '1', + displayCurrency: 'USD', + layout: 'grid', + }, + }), + migrate: (doc: any, _fromVersion: number) => { + if (!doc.watchedAddresses) doc.watchedAddresses = {}; + if (!doc.annotations) doc.annotations = {}; + if (!doc.dashboardConfig) doc.dashboardConfig = { defaultChain: '1', displayCurrency: 'USD', layout: 'grid' }; + doc.meta.version = 1; + return doc; + }, +}; + +// ── Helpers ── + +export function walletDocId(space: string) { + return `${space}:wallet:treasury` as const; +}