/** * Wallet Verifier — EIP-712 signature verification via window.ethereum. * * Orchestrates connection, typed-data signing, and state persistence. * Designed to work alongside WalletManager. * * Usage: * import { WalletVerifier } from './verifier.js'; * const verifier = new WalletVerifier(walletManagerInstance); * await verifier.triggerWalletVerification('base', 'My Wallet'); */ import { WalletManager } from './wallets.js'; /* ------------------------------------------------------------------ */ /* EIP-712 Domain */ /* ------------------------------------------------------------------ */ /* Chain name → numeric chainId for EIP-712 domain */ const CHAIN_IDS = { ethereum: 1, base: 8453, arbitrum: 42161, optimism: 10, polygon: 137, avalanche: 43114, bsc: 56, fantom: 250, hyperevm: 999, }; /** * Build the EIP-712 domain for a given chain. * @param {string} chain — chain name * @returns {object} Domain object */ function buildEIP712Domain(chain) { const normalChain = String(chain).toLowerCase(); const chainId = CHAIN_IDS[normalChain] ?? 1; return { name: 'Anonymous Wallet Tracker', version: '1', chainId, }; } const EIP712_TYPES = { VerifyTracking: [ { name: 'action', type: 'string' }, { name: 'walletAddress', type: 'address' }, { name: 'timestamp', type: 'uint256' }, ], }; /* ------------------------------------------------------------------ */ /* Error Codes (EIP-1193) */ /* ------------------------------------------------------------------ */ /* User rejected request */ const USER_REJECTED_CODE = 4001; /* User switched accounts mid-flow */ const CHAIN_DISCONNECTED_CODE = 4900; /* Human-friendly message map */ const ERROR_MESSAGES = { [USER_REJECTED_CODE]: 'Wallet request rejected. Verification cancelled.', [CHAIN_DISCONNECTED_CODE]: 'Wallet disconnected unexpectedly.', NO_EXTENSION: 'No Web3 wallet extension found. Install MetaMask or similar.', NO_ACCOUNT: 'Could not retrieve an active wallet address.', SIGN_FAILED: 'Signature request failed.', WALLET_EXISTS: 'This wallet address is already being tracked.', }; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ /** * Check if window.ethereum is available and callable. * * @returns {boolean} */ function hasEthereumProvider() { return ( typeof window !== 'undefined' && typeof window.ethereum !== 'undefined' && typeof window.ethereum.request === 'function' ); } /** * Parse an EIP-1193 provider error into a user-friendly string. * * @param {Error} err * @returns {string} */ function providerErrorMessage(err) { /* MetaMask and compatible providers attach a code property */ const code = err?.code ?? err?.cause?.code; if (code === USER_REJECTED_CODE || code === 4001) { return 'Verification cancelled — you rejected the wallet request.'; } if (code === CHAIN_DISCONNECTED_CODE || code === 4900) { return 'Wallet connection dropped unexpectedly.'; } return err?.message ?? 'An unexpected error occurred.'; } /* ------------------------------------------------------------------ */ /* WalletVerifier */ /* ------------------------------------------------------------------ */ export class WalletVerifier { /** * @param {WalletManager} walletManager — shared state manager instance */ constructor(walletManager) { if (!(walletManager instanceof WalletManager)) { throw new TypeError('WalletVerifier requires a WalletManager instance.'); } /** @type {WalletManager} */ this._wm = walletManager; } /* ---------------------------------------------------------------- */ /* Public API */ /* ---------------------------------------------------------------- */ /** * Full verification flow: request account → sign EIP-712 → persist. * * @param {string} chain — chain name for storage (e.g. 'base') * @param {string} [nickname=''] — display nickname * @returns {Promise<{ success: true, wallet: object } | { success: false, error: string }>} */ async triggerWalletVerification(chain, nickname = '') { /* ---- Step 0: Provider check ---- */ if (!hasEthereumProvider()) { return { success: false, error: ERROR_MESSAGES.NO_EXTENSION }; } let address; let signature; try { /* ---- Step 1: Request active account ---- */ address = await this._requestAccount(); /* ---- Step 2: Build EIP-712 payload ---- */ const typedData = this._buildTypedData(address, chain); /* ---- Step 3: Request signature ---- */ signature = await this._requestSignature(address, typedData); } catch (err) { return { success: false, error: providerErrorMessage(err) }; } /* ---- Step 4: Store in WalletManager ---- */ const normalChain = String(chain).toLowerCase(); const existing = this._wm.findWallet(address, normalChain); if (existing) { /* Already tracked — just verify if not yet verified */ if (!existing.isVerified) { const verifyResult = this._wm.verifyWallet( address, normalChain, signature, this._buildMessageData(address) ); if (!verifyResult.success) { return { success: false, error: verifyResult.error }; } return { success: true, wallet: this._wm.findWallet(address, normalChain) }; } return { success: false, error: 'This wallet is already verified and being tracked.', }; } /* New wallet — add then verify */ const addResult = this._wm.addWallet(address, normalChain, nickname); if (!addResult.success) { return { success: false, error: addResult.error }; } const verifyResult = this._wm.verifyWallet( address, normalChain, signature, this._buildMessageData(address) ); if (!verifyResult.success) { return { success: false, error: verifyResult.error }; } return { success: true, wallet: this._wm.findWallet(address, normalChain), }; } /* ---------------------------------------------------------------- */ /* Internal Steps */ /* ---------------------------------------------------------------- */ /** * Request `eth_requestAccounts`. Throws on rejection. * @returns {Promise} The connected address. */ async _requestAccount() { const accounts = await window.ethereum.request({ method: 'eth_requestAccounts', }); if (!accounts || accounts.length === 0) { throw new Error(ERROR_MESSAGES.NO_ACCOUNT); } return accounts[0].toLowerCase(); } /** * Build the EIP-712 typed data payload. * * @param {string} address * @param {string} chain — chain name for domain chainId * @returns {object} Full typedData object (domain + types + message) */ _buildTypedData(address, chain) { const message = this._buildMessageData(address); return { domain: buildEIP712Domain(chain), types: { EIP712Domain: [ { name: 'name', type: 'string' }, { name: 'version', type: 'string' }, { name: 'chainId', type: 'uint256' }, ], VerifyTracking: EIP712_TYPES.VerifyTracking, }, primaryType: 'VerifyTracking', message, }; } /** * Build the message portion only (for storage). * * @param {string} address * @returns {object} */ _buildMessageData(address) { return { action: 'Verify Ownership for Live Tracking', walletAddress: address, timestamp: Math.floor(Date.now() / 1000), }; } /** * Request `eth_signTypedData_v4`. Throws on rejection. * * @param {string} address * @param {object} typedData * @returns {Promise} The signature hex string. */ async _requestSignature(address, typedData) { try { const sig = await window.ethereum.request({ method: 'eth_signTypedData_v4', params: [address, JSON.stringify(typedData)], }); if (!sig || typeof sig !== 'string') { throw new Error(ERROR_MESSAGES.SIGN_FAILED); } return sig; } catch (err) { /* Re-throw EIP-1193 code errors for upstream handling */ if (err?.code === USER_REJECTED_CODE) { throw new Error('User rejected the signature request.'); } throw err; } } /* ---------------------------------------------------------------- */ /* Quick connect (no signature) */ /* ---------------------------------------------------------------- */ /** * Minimal connection check — only requests accounts, does NOT sign. * Useful for pre-flight checks or auto-connect on page load. * * @returns {Promise} Connected address, or null on rejection. */ async connect() { if (!hasEthereumProvider()) return null; try { const accounts = await window.ethereum.request({ method: 'eth_requestAccounts', }); return accounts?.[0]?.toLowerCase() ?? null; } catch { return null; } } } export default WalletVerifier;