/** * 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 = 'tracked_wallets'; /* Accepted chain identifiers */ const VALID_CHAINS = new Set([ 'base', 'ethereum', 'bitcoin', 'arbitrum', 'optimism', 'polygon', 'solana', 'avalanche', 'bsc', 'fantom', 'btc', 'hyperevm', 'import', ]); /* 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, hyperevm: /^(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}$/, /* Import — accepts any address format (EVM, BTC, Solana, etc.) */ import: /^(0x[0-9a-fA-F]{40}|bc1[a-z0-9]{25,62}|[13][a-km-zA-HJ-NP-Z1-9]{25,34}|[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. Filters out any * unverified wallets — they never survive a page reload. * Only verified wallets (or non-verification-chain wallets that * are always implicitly verified) are retained. * * @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 []; } /* Restore only verified wallets. Unverified wallets are discarded immediately — they were added but the user didn't complete the signature flow in time. */ 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) : '', lendingPlatform: w.lendingPlatform ? String(w.lendingPlatform).toLowerCase() : 'aave', isVerified: !!(w.isVerified && w.messageData), signature: w.signature ? String(w.signature) : null, messageData: w.messageData ? w.messageData : null, })) .filter((w) => { /* Non-verification chains (btc, bitcoin, solana) are always kept */ if (['btc', 'bitcoin', 'solana'].includes(w.chain)) return true; /* EVM chains: only keep if verified */ return w.isVerified; }); /* Persist the trimmed list so discarded unverified wallets don't reappear on subsequent reloads. */ this._persist(); return this._wallets; } catch (_ignored) { this._wallets = []; return []; } } /* ---------------------------------------------------------------- */ /* CRUD */ /* ---------------------------------------------------------------- */ /** * @returns {TrackedWallet[]} deep copy of state */ getWallets() { return this._wallets.map((w) => ({ ...w })); } /** * Return wallets that are either verified, or on a non-verification-required chain. * Non-verification chains (btc, solana, etc.) are always included. * EVM-only chains require isVerified === true to be returned. * * @returns {TrackedWallet[]} */ getVerifiedWallets() { return this._wallets.filter((w) => { if (['btc', 'bitcoin', 'solana'].includes(w.chain)) return true; return w.isVerified; }).map((w) => ({ ...w })); } /** * Add a new wallet. Rejects duplicates (same address+chain). * * @param {string} address * @param {ChainId} chain * @param {string} [nickname=''] * @param {string} [lendingPlatform='aave'] * @returns {{ success: true, wallet: TrackedWallet } | { success: false, error: string }} */ addWallet(address, chain, nickname = '', lendingPlatform = 'aave') { 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, lendingPlatform: String(lendingPlatform).toLowerCase(), 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 ); } /** * Rename a wallet's nickname. * * @param {string} address * @param {ChainId} chain * @param {string} nickname * @returns {{ success: true } | { success: false, error: string }} */ renameWallet(address, chain, nickname) { const w = this.findWallet(address, chain); if (!w) { return { success: false, error: 'Wallet not found' }; } w.nickname = String(nickname).trim(); this._persist(); return { success: true }; } /* ---------------------------------------------------------------- */ /* 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 }; } /** * Reorder wallets to match the given compound-key sequence ("addr:chain"). * Keys listed first are moved to front; remaining keep their original relative order. * Also accepts plain addresses for backward compatibility. * @param {string[]} keys */ setWalletOrder(keys) { const keySet = this._wallets.map(w => `${w.address}:${w.chain}`); const ordered = []; for (const k of keys) { if (keySet.includes(k)) { const [addr, chain] = k.split(':'); const w = this._wallets.find(x => x.address === addr && x.chain === chain); if (w) ordered.push(w); } } const remaining = this._wallets.filter(w => !ordered.includes(w)); this._wallets = [...ordered, ...remaining]; this._persist(); } } export default WalletManager;