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:
323
wallets.js
Normal file
323
wallets.js
Normal file
@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Wallet Management Module — Anonymous localStorage-backed state.
|
||||
*
|
||||
* Handles lifecycle of tracked wallets: load, add, remove, verify.
|
||||
* Addresses are validated per-chain. Unverified wallets can never
|
||||
* slip through as verified unless a valid signature is explicitly set.
|
||||
*
|
||||
* Usage:
|
||||
* import { WalletManager } from './wallets.js';
|
||||
* const wm = new WalletManager();
|
||||
* wm.loadWallets();
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types (JSDoc for editor support; runtime is plain JS) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* @typedef {'base'|'ethereum'|'bitcoin'|'arbitrum'|'optimism'|'polygon'|'solana'|string} ChainId
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TrackedWallet
|
||||
* @property {string} address
|
||||
* @property {ChainId} chain
|
||||
* @property {string} nickname
|
||||
* @property {boolean} isVerified
|
||||
* @property {string|null} signature
|
||||
* @property {object|null} messageData
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LS_KEY = 'cbbtc_tracked_wallets';
|
||||
|
||||
/* Accepted chain identifiers */
|
||||
const VALID_CHAINS = new Set([
|
||||
'base', 'ethereum', 'bitcoin', 'arbitrum', 'optimism',
|
||||
'polygon', 'solana', 'avalanche', 'bsc', 'fantom', 'btc',
|
||||
]);
|
||||
|
||||
/* Regex patterns for address validation per chain */
|
||||
const ADDRESS_PATTERNS = {
|
||||
/* EVM-compatible — 0x-prefixed 40-hex */
|
||||
base: /^(0x)?[0-9a-fA-F]{40}$/i,
|
||||
ethereum: /^(0x)?[0-9a-fA-F]{40}$/i,
|
||||
arbitrum: /^(0x)?[0-9a-fA-F]{40}$/i,
|
||||
optimism: /^(0x)?[0-9a-fA-F]{40}$/i,
|
||||
polygon: /^(0x)?[0-9a-fA-F]{40}$/i,
|
||||
avalanche: /^(0x)?[0-9a-fA-F]{40}$/i,
|
||||
bsc: /^(0x)?[0-9a-fA-F]{40}$/i,
|
||||
fantom: /^(0x)?[0-9a-fA-F]{40}$/i,
|
||||
|
||||
/* Bitcoin — bech32 or legacy pubkey hash */
|
||||
bitcoin: /^(bc1[a-z0-9]{25,39}|1[a-km-zA-HJ-NP-Z1-9]{25,34})$/,
|
||||
btc: /^(bc1[a-z0-9]{25,39}|1[a-km-zA-HJ-NP-Z1-9]{25,34})$/,
|
||||
|
||||
/* Solana — base58, 32-44 chars */
|
||||
solana: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Validators */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Validate an address against the chain's known format.
|
||||
* Falls back to a looser check for chains without a pattern.
|
||||
*
|
||||
* @param {string} address
|
||||
* @param {ChainId} chain
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function validateAddress(address, chain) {
|
||||
if (!address || typeof address !== 'string') return false;
|
||||
|
||||
const pattern = ADDRESS_PATTERNS[chain];
|
||||
if (pattern) {
|
||||
return pattern.test(address.trim());
|
||||
}
|
||||
|
||||
/* Fallback: non-empty, printable chars, reasonable length */
|
||||
return /^[A-Za-z0-9_.+/=-]{6,100}$/.test(address.trim());
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* WalletManager */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export class WalletManager {
|
||||
constructor() {
|
||||
/** @type {TrackedWallet[]} */
|
||||
this._wallets = [];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Persist / Load */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Serialize state to localStorage.
|
||||
* Silently ignores write errors (private-browsing mode).
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_persist() {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(this._wallets));
|
||||
} catch (_ignored) {
|
||||
/* quotaExceeded or private-browsing — degrade gracefully */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load wallets from localStorage.
|
||||
*
|
||||
* On first run returns an empty array. Restores any stored
|
||||
* entries — even if the browser was closed between sessions.
|
||||
*
|
||||
* Invariant: every wallet read from storage has `isVerified: false`
|
||||
* because a signature cannot survive an empty localStorage round-trip
|
||||
* without an accompanying non-empty `signature` field, and we
|
||||
* deliberately do NOT trust persisted signatures.
|
||||
*
|
||||
* @returns {TrackedWallet[]} loaded wallets
|
||||
*/
|
||||
loadWallets() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) {
|
||||
this._wallets = [];
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
this._wallets = [];
|
||||
return [];
|
||||
}
|
||||
|
||||
/* Sanitize: enforce isVerified = false for all persisted entries.
|
||||
A wallet can only be verified by calling verifyWallet(), not
|
||||
by tampering with localStorage. */
|
||||
this._wallets = parsed
|
||||
.filter((w) => w && typeof w === 'object' && w.address && w.chain)
|
||||
.map((w) => ({
|
||||
address: String(w.address),
|
||||
chain: String(w.chain).toLowerCase(),
|
||||
nickname: w.nickname ? String(w.nickname) : '',
|
||||
isVerified: false, /* SECURITY: never trust persisted flag */
|
||||
signature: null, /* SECURITY: discard persisted signatures */
|
||||
messageData: null,
|
||||
}));
|
||||
|
||||
return this._wallets;
|
||||
|
||||
} catch (_ignored) {
|
||||
this._wallets = [];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* CRUD */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @returns {TrackedWallet[]} deep copy of state
|
||||
*/
|
||||
getWallets() {
|
||||
return this._wallets.map((w) => ({ ...w }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new wallet. Rejects duplicates (same address+chain).
|
||||
*
|
||||
* @param {string} address
|
||||
* @param {ChainId} chain
|
||||
* @param {string} [nickname='']
|
||||
* @returns {{ success: true, wallet: TrackedWallet } | { success: false, error: string }}
|
||||
*/
|
||||
addWallet(address, chain, nickname = '') {
|
||||
const normalAddress = String(address).trim();
|
||||
const normalChain = String(chain).toLowerCase();
|
||||
const normalNickname = String(nickname).trim();
|
||||
|
||||
/* Validate chain */
|
||||
if (!VALID_CHAINS.has(normalChain)) {
|
||||
return { success: false, error: `Unsupported chain: ${normalChain}` };
|
||||
}
|
||||
|
||||
/* Validate address format */
|
||||
if (!validateAddress(normalAddress, normalChain)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid ${normalChain} address: ${normalAddress}`,
|
||||
};
|
||||
}
|
||||
|
||||
/* Duplicate check */
|
||||
if (this._wallets.some((w) =>
|
||||
w.address === normalAddress && w.chain === normalChain
|
||||
)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Wallet already tracked: ${normalAddress} (${normalChain})`,
|
||||
};
|
||||
}
|
||||
|
||||
const wallet = {
|
||||
address: normalAddress,
|
||||
chain: normalChain,
|
||||
nickname: normalNickname,
|
||||
isVerified: false,
|
||||
signature: null,
|
||||
messageData: null,
|
||||
};
|
||||
|
||||
this._wallets.push(wallet);
|
||||
this._persist();
|
||||
return { success: true, wallet };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a wallet by address + chain.
|
||||
*
|
||||
* @param {string} address
|
||||
* @param {ChainId} chain
|
||||
* @returns {{ success: true, removedAddress: string } | { success: false, error: string }}
|
||||
*/
|
||||
removeWallet(address, chain) {
|
||||
const normalAddress = String(address).trim();
|
||||
const normalChain = String(chain).toLowerCase();
|
||||
|
||||
const idx = this._wallets.findIndex(
|
||||
(w) => w.address === normalAddress && w.chain === normalChain
|
||||
);
|
||||
|
||||
if (idx === -1) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Wallet not found: ${normalAddress} (${normalChain})`,
|
||||
};
|
||||
}
|
||||
|
||||
this._wallets.splice(idx, 1);
|
||||
this._persist();
|
||||
return { success: true, removedAddress: normalAddress };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a wallet by address + chain.
|
||||
*
|
||||
* @param {string} address
|
||||
* @param {ChainId} chain
|
||||
* @returns {TrackedWallet | undefined}
|
||||
*/
|
||||
findWallet(address, chain) {
|
||||
const normalAddress = String(address).trim();
|
||||
const normalChain = String(chain).toLowerCase();
|
||||
return this._wallets.find(
|
||||
(w) => w.address === normalAddress && w.chain === normalChain
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Verification */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Mark a wallet as verified after a successful signature check.
|
||||
* Only works if the wallet exists in state.
|
||||
*
|
||||
* @param {string} address
|
||||
* @param {ChainId} chain
|
||||
* @param {string} signature
|
||||
* @param {object} messageData
|
||||
* @returns {{ success: true } | { success: false, error: string }}
|
||||
*/
|
||||
verifyWallet(address, chain, signature, messageData) {
|
||||
const w = this.findWallet(address, chain);
|
||||
if (!w) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Wallet not found: ${address} (${chain})`,
|
||||
};
|
||||
}
|
||||
|
||||
w.isVerified = true;
|
||||
w.signature = String(signature);
|
||||
w.messageData = messageData || null;
|
||||
this._persist();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke verification — resets back to unverified.
|
||||
*
|
||||
* @param {string} address
|
||||
* @param {ChainId} chain
|
||||
* @returns {{ success: true } | { success: false, error: string }}
|
||||
*/
|
||||
revokeVerification(address, chain) {
|
||||
const w = this.findWallet(address, chain);
|
||||
if (!w) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Wallet not found: ${address} (${chain})`,
|
||||
};
|
||||
}
|
||||
|
||||
w.isVerified = false;
|
||||
w.signature = null;
|
||||
w.messageData = null;
|
||||
this._persist();
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export default WalletManager;
|
||||
Reference in New Issue
Block a user