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;
+}