feat: multi-wallet architecture with localStorage state, EIP-712 verification, cross-tab WS leadership

- wallets.js: WalletManager state management with chain validation
- verifier.js: WalletVerifier EIP-712 signing via window.ethereum
- stream.js: WalletStreamManager with BroadcastChannel leader election, backoff + jitter
- AGENTS.md: updated with coding guidelines
This commit is contained in:
Dione
2026-06-06 12:16:41 +00:00
commit a61e0b0457
17 changed files with 4037 additions and 0 deletions

116
test/README.md Normal file
View File

@ -0,0 +1,116 @@
# cbBTC Dashboard Test Suite
The test suite is organized under the `/test` folder. All tests run in Node.js with ES modules and mock browser APIs (`localStorage`, `WebSocket`, `BroadcastChannel`, `window.ethereum`). The setup file `test/test_setup.mjs` provides the global `location` object that `stream.js` requires at import time.
## Files
| File | Targets | Requires | How to run |
|---|---|---|---|
| `test/test_wallets.js` | **WalletManager** — CRUD, persistence, security | None | `node --experimental-modules test/test_wallets.js` |
| `test/test_stream.js` | **WalletStreamManager** — WebSocket, backoff, routing | `test/test_setup.mjs` | `node --experimental-modules --import ./test/test_setup.mjs ./test/test_stream.js` |
| `test/test_tabs.js` | **WalletStreamManager** — multi-tab leader election, failover | `test/test_setup.mjs` | `node --experimental-modules --import ./test/test_setup.mjs ./test/test_tabs.js` |
| `test/test_verifier.js` | **WalletVerifier** — Web3 provider error handling | None | `node --experimental-modules test/test_verifier.js` |
| `test/test_setup.mjs` | Provides `globalThis.location` for `stream.js` | — | Imported via `--import` |
## What Can Be Tested — by Module
### 1. WalletManager (`test_wallets.js`)
The `WalletManager` class manages tracked wallets in `localStorage`. The test suite covers:
**Address Validation**
- Rejection of invalid address formats per chain (EVM, Bitcoin, Solana).
- Acceptance of valid addresses for EVM (`0x...`), Bitcoin legacy (`1...`), and Solana base58 patterns.
**CRUD (Create/Read/Update/Delete)**
- `addWallet()` — adds wallets, persists to storage, returns success/error.
- `getWallets()` — returns deep copies of tracked wallets.
- `findWallet()` — returns wallet object or `undefined`.
- `removeWallet()` — splices wallet from state and persists.
**Duplicate Guard**
- Same `address` + `chain` pair is rejected on second `addWallet()` call.
**Verification Lifecycle**
- Wallets start with `isVerified: false`.
- `verifyWallet()` sets the flag to `true` and stores the signature.
- `revokeVerification()` resets the flag and clears the signature.
**Security: localStorage Tampering**
- `loadWallets()` always resets `isVerified` to `false` on restart, regardless of what was written to storage. This prevents a user from forging verification by editing `localStorage` directly.
### 2. WalletVerifier (`test_verifier.js`)
The `WalletVerifier` class handles Web3 provider interactions. The test suite covers error-path cases:
**Missing Extension**
- When `window.ethereum` is undefined, verification fails with `"No Web3 wallet extension found"`.
**User Rejection (Account Request)**
- When the user denies `eth_requestAccounts` (EIP-1193 error 4001), verification cancels with `"Verification cancelled"`.
**Signature Rejection**
- When the user denies the typed-data signature request, the error is mapped to `"rejected"`.
### 3. WalletStreamManager — Core (`test_stream.js`)
The `WalletStreamManager` class manages the WebSocket connection, event routing, and reconnect logic with exponential backoff. The test suite covers:
**Multiplexed Subscription**
- On connect, the manager sends a single `subscribe` frame containing all tracked wallets (from `WalletManager`).
**Event Routing**
- Incoming server messages are parsed and dispatched to registered `on('balance_update', ...)` callbacks.
- Events route by `data.type` (or `data.event`, or fallback `'message'`).
**Callback Error Isolation**
- If one registered callback throws, remaining callbacks for the same event still execute. Exceptions are caught and logged but never propagate.
**Dynamic Subscription**
- `subscribeToNewWallet()` sends an immediate `subscribe_wallet` frame without cycling the connection.
**Exponential Backoff + Jitter**
- On network drop (`simulateNetworkDrop()`), the manager retries connecting. The delay follows `BASE_DELAY * 2^attempt` with ±25% jitter, capped at `MAX_DELAY` (30s).
- Tests speed up time by overriding `setTimeout` to fire instantly.
### 4. WalletStreamManager — Multi-Tab (`test_tabs.js`)
The leader/follower election and cross-tab synchronization system relies on `BroadcastChannel` and `localStorage` heartbeats. The test suite covers:
**Leader Election**
- First tab to connect finds no heartbeat in `localStorage`, claims leadership, opens the WebSocket, and starts heartbeats.
- Second tab finds the heartbeat, assigns itself as a follower, and avoids opening a second WebSocket.
**Cross-Tab Event Broadcasting**
- Leader receives server data on the WebSocket and posts it to `BroadcastChannel`.
- Followers receive the broadcast and dispatch it to their local listeners.
**Proxy Subscription Forwarding**
- When a follower calls `subscribeToNewWallet()`, the request is sent through `BroadcastChannel` to the leader, which opens the actual network subscription.
**Automatic Failover**
- When the leader tab closes (`disconnect()`), it broadcasts `LEADER_DEATH` before closing the channel.
- Followers detect the signal, claim leadership, remove the old heartbeat, write a new one, and open their own WebSocket connection.
## Mock Infrastructure
All tests replace browser APIs with in-memory equivalents:
- **localStorage** — plain object (`mockStorage`) with `getItem`, `setItem`, `removeItem`, `clear`.
- **setTimeout** — overridden to fire callbacks after 1ms instead of the specified delay, so backoff/retry logic runs instantly.
- **WebSocket** — `MockWebSocket` auto-opens after 2ms, exposes `simulateServerPush()` and `simulateNetworkDrop()` helpers.
- **BroadcastChannel** — `MockBroadcastChannel` delivers messages synchronously to all other registered channels, simulating cross-tab delivery.
## Extending the Test Suite
To add a new test:
1. Add a `describe`/`it` block or inline `assert` inside the relevant `test_<module>.js` file.
2. For stream-related tests, ensure the file is imported via `--import ./test/test_setup.mjs`.
3. Run the test from the project root with the appropriate command.
To add a new module test:
1. Create `test/test_<module>.js` with imports using `../<module>.js` (relative to the `/test` folder).
2. Add the required mock infrastructure at the top.
3. Register it in this README with its run command.

4
test/test_setup.mjs Normal file
View File

@ -0,0 +1,4 @@
globalThis.location = {
protocol: 'http:',
host: 'localhost:8080'
};

151
test/test_stream.js Normal file
View File

@ -0,0 +1,151 @@
import { WalletManager } from '../wallets.js';
import { WalletStreamManager } from '../stream.js';
// --- 1. Environment Mocks (Storage & Timers) ---
const mockStorage = {};
globalThis.localStorage = {
getItem: (key) => mockStorage[key] || null,
setItem: (key, value) => { mockStorage[key] = String(value); },
clear: () => { Object.keys(mockStorage).forEach(k => delete mockStorage[k]); }
};
// Speed up time so our backoff tests don't take 30 actual seconds to run
const originalSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = (callback, delay) => {
return originalSetTimeout(callback, 1); // Force all retries to happen instantly
};
// Mock location for stream.js WS_URL evaluation
globalThis.location = {
protocol: 'http:',
host: 'localhost:3000'
};
// --- 2. The Mock WebSocket Server Mirror ---
const mockInstances = [];
let lastSentPayload = null;
let connectionCount = 0;
class MockWebSocket {
constructor(url) {
this.url = url;
this.readyState = 0; // CONNECTING
connectionCount++;
mockInstances.push(this);
originalSetTimeout(() => {
this.readyState = 1; // OPEN
if (this.onopen) this.onopen();
}, 2);
}
send(data) {
lastSentPayload = JSON.parse(data);
}
close() {
this.readyState = 3; // CLOSED
if (this.onclose) this.onclose({ code: 1000, reason: "Normal closure" });
}
// Helper method for our test runner to mimic the server pushing data to client
simulateServerPush(msgObject) {
if (this.onmessage) {
this.onmessage({ data: JSON.stringify(msgObject) });
}
}
// Helper method to simulate a sudden dirty network disconnect
simulateNetworkDrop() {
this.readyState = 3;
if (this.onclose) this.onclose({ code: 1006, reason: "Abnormal Drop" });
}
}
globalThis.WebSocket = MockWebSocket;
// --- 3. Test Runner Execution ---
function assert(condition, message) {
if (!condition) throw new Error(`❌ FAIL: ${message}`);
console.log(`✅ PASS: ${message}`);
}
async function runStreamTests() {
console.log("🚀 Starting WalletStreamManager Real-Time Test Suite...\n");
localStorage.clear();
// Seed two dummy wallets to simulate an existing local state
const wm = new WalletManager();
wm.addWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base", "Base Acc");
wm.addWallet("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "bitcoin", "Satoshi Acc");
const stream = new WalletStreamManager(wm);
stream.connect();
// Wait a few milliseconds for our mocked async connection handshakes to settle
await new Promise(r => originalSetTimeout(r, 10));
// --- TEST 1: Multiplexed On-Open Subscription ---
assert(connectionCount === 1, "WebSocket established exactly one connection");
assert(lastSentPayload !== null, "Server received subscription frame on startup");
assert(lastSentPayload.action === "subscribe", "Payload correctly set action to 'subscribe'");
assert(lastSentPayload.subscriptions.length === 2, "Multiplexed frame packed all stored wallets successfully");
// --- TEST 2: Real-time Message Callback Routing ---
let balanceUpdatedCalled = false;
stream.on('balance_update', (msg) => {
if (msg.address === "0x04f728C520C438A000f7A5E9d904F0e725FFAEFE") {
balanceUpdatedCalled = true;
}
});
// Access the underlying mock instance to simulate the server pushing data
const activeSocketInstance = stream._ws;
activeSocketInstance.simulateServerPush({
type: "balance_update",
address: "0x04f728C520C438A000f7A5E9d904F0e725FFAEFE",
chain: "base",
balance: "50000000000"
});
assert(balanceUpdatedCalled === true, "Incoming server events route seamlessly to their respective listeners");
// --- TEST 3: Callback Error Isolation Safeguard ---
let secondaryListenerExecuted = false;
stream.on('transfer', () => {
throw new Error("CRASHING INTRUDER: I am a broken UI component callback!");
});
stream.on('transfer', () => {
secondaryListenerExecuted = true;
});
// Push a transfer event. The first listener throws an exception, but shouldn't halt execution.
try {
activeSocketInstance.simulateServerPush({ type: "transfer", tx_hash: "0xabc" });
assert(secondaryListenerExecuted === true, "ERROR ISOLATION: Second event handler fired successfully despite preceding listener throwing a hard error");
} catch (e) {
assert(false, "The StreamManager leaked a callback exception and crashed the event loop");
}
// --- TEST 4: Dynamic Delta Streaming Tracker Updates ---
const dynamicWallet = { address: "H7vjR5vP6dfYuxUzoZ76yvBpx6Z3zM7p9qE1vRkWk3xp", chain: "solana" };
stream.subscribeToNewWallet(dynamicWallet);
assert(lastSentPayload.action === "subscribe_wallet", "Dynamic frame used accurate 'subscribe_wallet' verb");
assert(lastSentPayload.wallet?.address === dynamicWallet.address, "Dynamic delta subscription dispatched for runtime updates without connection cycling");
// --- TEST 5: Resilient Exponential Backoff Retry Loop ---
console.log("\n🕵 Triggering a sudden network failure drop scenario...");
const preDropCount = connectionCount;
stream._ws.simulateNetworkDrop(); // Crash the current open pipe
await new Promise(r => originalSetTimeout(r, 15)); // Wait for fast-forward timers to run the retry loops
assert(connectionCount > preDropCount, "RESILIENCE: Stream Manager successfully caught connection drop and automatically invoked reconnection logic");
// Cleanup
stream.disconnect();
console.log("\n🎉 All core real-time streaming transport client tests passed!");
}
runStreamTests().catch(console.error);

122
test/test_tabs.js Normal file
View File

@ -0,0 +1,122 @@
import { WalletManager } from '../wallets.js';
import { WalletStreamManager } from '../stream.js';
// --- 1. Infrastructure Mocks ---
const mockStorage = {};
globalThis.localStorage = {
getItem: (key) => mockStorage[key] || null,
setItem: (key, value) => { mockStorage[key] = String(value); },
clear: () => { Object.keys(mockStorage).forEach(k => delete mockStorage[k]); }
};
// Accelerate timers so leader election (200ms) and backoff happen instantly
const originalSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = (callback, delay) => originalSetTimeout(callback, 1);
// Standard WebSocket Mock
class MockWebSocket {
constructor(url) {
this.readyState = 1; // Immediately OPEN
originalSetTimeout(() => { if (this.onopen) this.onopen(); }, 1);
}
send(data) { globalLastSentPayload = JSON.parse(data); }
close() { if (this.onclose) this.onclose({ code: 1000 }); }
}
globalThis.WebSocket = MockWebSocket;
// --- 2. The Cross-Tab Broadcast Router Mock ---
const activeChannels = new Set();
class MockBroadcastChannel {
constructor(name) {
this.name = name;
activeChannels.add(this);
}
postMessage(data) {
// Distribute the message to all OTHER open channel instances simulation offline cross-tab delivery
for (const channel of activeChannels) {
if (channel !== this && channel.onmessage) {
channel.onmessage({ data });
}
}
}
close() { activeChannels.delete(this); }
}
globalThis.BroadcastChannel = MockBroadcastChannel;
// Global tracking variables for assertions
let globalLastSentPayload = null;
function assert(condition, message) {
if (!condition) throw new Error(`❌ FAIL: ${message}`);
console.log(`✅ PASS: ${message}`);
}
// --- 3. The Multi-Tab Simulation Scenario ---
async function runMultiTabTest() {
console.log("🚀 Starting Multi-Tab Synchronization & Leader Election Test Suite...\n");
localStorage.clear();
// Seed initial wallet configuration
const wm = new WalletManager();
wm.addWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base", "Shared Wallet");
// --- STEP 1: Boot First Tab ---
console.log("▶️ Initializing Tab 1...");
const tab1 = new WalletStreamManager(wm);
tab1.connect();
await new Promise(r => originalSetTimeout(r, 15)); // Await leader election window
assert(tab1.isLeader === true, "Tab 1 detected no existing leaders and claimed leadership role.");
assert(tab1.ws !== null, "Tab 1 successfully initialized a physical WebSocket network connection.");
// --- STEP 2: Boot Second Tab ---
console.log("\n▶ Initializing Tab 2 (Simulating a new browser window/tab)...");
const tab2 = new WalletStreamManager(wm);
tab2.connect();
await new Promise(r => originalSetTimeout(r, 15));
assert(tab2.isLeader === false, "Tab 2 discovered active Leader and assigned itself as a Follower.");
assert(tab2.ws === undefined || tab2.ws === null, "Tab 2 kept its local WebSocket offline to save system resources.");
// --- STEP 3: Verify Event Cross-Broadcasting Flow ---
let tab2ReceivedData = false;
tab2.on('balance_update', (msg) => {
if (msg.balance === "9999") tab2ReceivedData = true;
});
// Simulate server pushing an update specifically to Tab 1's socket connection
tab1.ws.onmessage({
data: JSON.stringify({ type: "balance_update", balance: "9999" })
});
await new Promise(r => originalSetTimeout(r, 5));
assert(tab2ReceivedData === true, "Tab 1 received WebSocket data and pushed it locally across the BroadcastChannel to Tab 2.");
// --- STEP 4: Proxy Subscription Modification Check ---
console.log("\n▶ Simulating Follower (Tab 2) subscribing to a new asset dynamically...");
const newAsset = { address: "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", chain: "bitcoin" };
globalLastSentPayload = null;
tab2.subscribeToNewWallet(newAsset);
await new Promise(r => originalSetTimeout(r, 5));
assert(globalLastSentPayload !== null, "Tab 2's subscription was successfully intercepted and routed to the channel.");
assert(globalLastSentPayload.action === "subscribe_wallet", "Tab 1 successfully executed the proxy network subscription on behalf of Tab 2.");
// --- STEP 5: Graceful Failover Handover (Leader Death Scenario) ---
console.log("\n🕵 Simulating user abruptly closing Tab 1 (Leader)...");
// Simulate browser exit/unload routine for Tab 1
tab1.disconnect();
// Dispatch the exit event if your module binds directly to window event listeners
if (tab1._handleExit) tab1._handleExit();
await new Promise(r => originalSetTimeout(r, 15)); // Await emergency election frame
assert(tab2.isLeader === true, "Tab 2 caught the LEADER_DEATH signal and successfully promoted itself to Leader.");
assert(tab2.ws !== null, "Tab 2 gracefully opened a brand new physical WebSocket connection to pick up the stream.");
console.log("\n🎉 Massive Success! Cross-tab synchronization, proxy forwarding, and leader failovers function perfectly.");
}
runMultiTabTest().catch(console.error);

76
test/test_verifier.js Normal file
View File

@ -0,0 +1,76 @@
import { WalletManager } from '../wallets.js';
import { WalletVerifier } from '../verifier.js';
// Setup basic environment mocks
const mockStorage = {};
globalThis.localStorage = {
getItem: (key) => mockStorage[key] || null,
setItem: (key, value) => { mockStorage[key] = String(value); }
};
function assertError(result, expectedMsg, testName) {
if (result.success === false && result.error.includes(expectedMsg)) {
console.log(`✅ PASS: ${testName}`);
} else {
console.error(`❌ FAIL: ${testName} (Got: "${result.error ?? 'success'}", Expected: "${expectedMsg}")`);
}
}
async function runVerifierTests() {
console.log("🚀 Starting WalletVerifier Error State Test Suite...\n");
// --- TEST 1: Missing Extension ---
globalThis.window = {}; // No ethereum provider defined
const wm1 = new WalletManager();
let verifier = new WalletVerifier(wm1);
assertError(
await verifier.triggerWalletVerification('base', 'Test'),
"No Web3 wallet extension found",
"Missing Extension Detection"
);
// --- TEST 2: User Rejects Account Connection (EIP-1193 Error 4001) ---
globalThis.window = {
ethereum: {
request: async ({ method }) => {
if (method === 'eth_requestAccounts') {
const err = new Error("User denied connection");
err.code = 4001;
throw err;
}
}
}
};
const wm2 = new WalletManager();
verifier = new WalletVerifier(wm2);
assertError(
await verifier.triggerWalletVerification('base', 'Test'),
"Verification cancelled",
"Account Rejection (Code 4001)"
);
// --- TEST 3: User Rejects Signature ---
globalThis.window = {
ethereum: {
request: async ({ method }) => {
if (method === 'eth_requestAccounts') return ['0x04f728C520C438A000f7A5E9d904F0e725FFAEFE'];
if (method === 'eth_signTypedData_v4') {
const err = new Error("User rejected the signature request");
err.code = 4001;
throw err;
}
}
}
};
const wm3 = new WalletManager();
verifier = new WalletVerifier(wm3);
assertError(
await verifier.triggerWalletVerification('base', 'Test'),
"rejected",
"Signature Rejection Mapping"
);
console.log("\n🏁 Test Suite Complete.");
}
runVerifierTests();

80
test/test_wallets.js Normal file
View File

@ -0,0 +1,80 @@
import { WalletManager } from '../wallets.js';
// 1. Mock localStorage for the Node.js environment
const mockStorage = {};
globalThis.localStorage = {
getItem: (key) => mockStorage[key] || null,
setItem: (key, value) => { mockStorage[key] = String(value); },
removeItem: (key) => { delete mockStorage[key]; },
clear: () => { Object.keys(mockStorage).forEach(k => delete mockStorage[k]); }
};
// Simple test assertion helper
function assert(condition, message) {
if (!condition) throw new Error(`❌ FAIL: ${message}`);
console.log(`✅ PASS: ${message}`);
}
async function runTestSuite() {
console.log("🚀 Starting WalletManager Test Suite...\n");
localStorage.clear();
const manager = new WalletManager();
// --- TEST 1: Address Format Validations ---
const invalidResult = manager.addWallet("invalid-address", "base", "My EVM");
assert(!invalidResult.success, "Rejected invalid EVM address format properly");
// Add valid addresses across different patterns
assert(manager.addWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base", "Main Base").success, "Added EVM wallet");
assert(manager.addWallet("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "bitcoin", "Satoshi BTC").success, "Added Bitcoin wallet");
assert(manager.addWallet("H7vjR5vP6dfYuxUzoZ76yvBpx6Z3zM7p9qE1vRkWk3xp", "solana", "Solana Trading").success, "Added Solana wallet");
console.log("👉 ACTUAL WALLETS IN STATE:", manager.getWallets());
console.log("👉 CURRENT LENGTH:", manager.getWallets().length);
assert(manager.getWallets().length === 3, "Successfully added 3 distinct multichain wallets");
// --- TEST 2: Duplicate Rejection ---
const dupResult = manager.addWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base", "Duplicate Base");
assert(!dupResult.success, "Correctly prevented duplicate entry");
// --- TEST 3: Verification Flow ---
const initialWallet = manager.findWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base");
assert(initialWallet.isVerified === false, "Wallet starts completely unverified");
manager.verifyWallet(
"0x04f728C520C438A000f7A5E9d904F0e725FFAEFE",
"base",
"0xmockSignature123456",
{ action: "Verify Ownership" }
);
const verifiedWallet = manager.findWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base");
assert(verifiedWallet.isVerified === true, "verifyWallet() successfully flips flag to true");
assert(verifiedWallet.signature === "0xmockSignature123456", "Signature string is retained");
// --- TEST 4: The Core Security Hack Simulation ---
console.log("\n🕵 Simulating a user manually tampering with localStorage file...");
// Fetch raw storage payload, hack it to true, and write it back
const rawData = JSON.parse(localStorage.getItem("cbbtc_tracked_wallets"));
rawData.forEach(w => w.isVerified = true); // Force-inject "true" directly to file
localStorage.setItem("cbbtc_tracked_wallets", JSON.stringify(rawData));
// Instantiate a brand new manager to simulate page reload/reboot
const maliciousManager = new WalletManager();
maliciousManager.loadWallets(); // Boot up from hacked storage
const postBootWallet = maliciousManager.findWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base");
assert(postBootWallet.isVerified === false, "SECURITY VERIFIED: Tampered localStorage flags were successfully wiped back to false on boot.");
// --- TEST 5: Removal ---
maliciousManager.removeWallet("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "bitcoin");
assert(maliciousManager.findWallet("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "bitcoin") === undefined, "Successfully spliced and removed wallet from state");
console.log("\n🎉 All core client-side state tests passed successfully!");
}
runTestSuite().catch(err => {
console.error(err);
process.exit(1);
});