From 6473d3cedfe813a8d8dee1a07d27d2d0cdde7771 Mon Sep 17 00:00:00 2001 From: Dione Date: Wed, 10 Jun 2026 11:31:10 +0000 Subject: [PATCH] Fix EIP-712 chainId dynamic resolution; improve add-wallet modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hardcoded chainId:1 with per-chain EIP-712 domain (base→8453, etc.) - Add hyperliquide (chainId 998) as supported EVM chain - Auto-fill wallet address and chain from eth_requestAccounts + eth_chainId - Auto-pick unused color when adding wallet - Remove chain dropdown and address input; add readonly displays - Rename localStorage key to tracked_wallets --- index.html | 288 +++++++++++++++++++++++++++++++++++++++++----------- verifier.js | 37 +++++-- wallets.js | 80 ++++++++------- 3 files changed, 302 insertions(+), 103 deletions(-) diff --git a/index.html b/index.html index c2ea2df..849e350 100644 --- a/index.html +++ b/index.html @@ -136,12 +136,21 @@ window.WalletManager = WalletManager;
- +
-
+
+ + + +
+
+
+ +
+
@@ -332,6 +341,9 @@ window.WalletManager = WalletManager; this._wallets = raw ? JSON.parse(raw) : []; if (!Array.isArray(this._wallets)) this._wallets = []; this._wallets = this._wallets.filter(w => w && w.address && w.chain).map(w => ({ address: String(w.address), chain: String(w.chain).toLowerCase(), nickname: String(w.nickname||''), isVerified: !!(w.isVerified && w.messageData), signature: w.signature ? String(w.signature) : null, messageData: w.messageData ? w.messageData : null })); + /* Discard unverified wallets — keep only verified EVM or non-verification-chain wallets */ + this._wallets = this._wallets.filter(w => ['btc','bitcoin','solana'].includes(w.chain) ? true : w.isVerified); + this._persist(); this.getVerifiedWallets = function() { return this._wallets.filter(w => ['btc','bitcoin','solana'].includes(w.chain) ? true : w.isVerified).map(w => ({...w})); }; } catch(e) { this._wallets = []; } return this._wallets; @@ -372,15 +384,13 @@ window.WalletManager = WalletManager; this._persist(); return { success: true }; } - revokeVerification(address, chain) { - const w = this.findWallet(address, chain); - if (!w) return { success: false, error: 'Wallet not found' }; - w.isVerified = false; - w.signature = null; - w.messageData = null; - this._persist(); - return { success: true }; - } + revokeVerification(address, chain) { + const idx = this._wallets.findIndex(w => w.address === address && w.chain === chain); + if (idx === -1) return { success: false, error: 'Wallet not found' }; + this._wallets.splice(idx, 1); + this._persist(); + return { success: true }; + } }; } })(); @@ -623,11 +633,97 @@ function handleRenameNickname(input) { /* =================================================================== Add Wallet Modal =================================================================== */ +let _addWalletSelectedColor = null; + function openAddWalletModal() { - document.getElementById('add-wallet-modal').classList.add('open'); - document.getElementById('add-wallet-error').classList.add('hidden'); - document.getElementById('input-address').value = ''; - document.getElementById('input-nickname').value = ''; + if (!window.ethereum) { + alert('No web3 provider found. Install a wallet extension (MetaMask, Rabby, etc.).'); + return; + } + /* Request connection first, then show modal with prefilled data */ + window.ethereum.request({ method: 'eth_requestAccounts' }) + .then(accounts => { + if (!accounts || accounts.length === 0) return; + + document.getElementById('input-address').value = accounts[0]; + document.getElementById('input-nickname').value = ''; + document.getElementById('input-chain-display').value = ''; + document.getElementById('input-chain-hidden').value = 'base'; + _addWalletSelectedColor = autoPickColor(); + renderAddWalletColorPicker(); + + window.ethereum.request({ method: 'eth_chainId' }) + .then(chainIdHex => { + const chainName = CHAIN_IDS_REVERSED[chainIdHex] || 'base'; + document.getElementById('input-chain-display').value = chainName.charAt(0).toUpperCase() + chainName.slice(1); + document.getElementById('input-chain-hidden').value = chainName; + }); + + document.getElementById('add-wallet-error').classList.add('hidden'); + document.getElementById('add-wallet-modal').classList.add('open'); + }) + .catch(() => { + alert('Wallet connection rejected.'); + }); +} + +function autoPickColor() { + const usedColors = wm.getWallets().map(w => getColorForWallet(w.address)); + for (const c of WALLET_COLORS) { + if (!usedColors.includes(c)) return c; + } + return '#F7931A'; +} + +function renderAddWalletColorPicker() { + const container = document.getElementById('add-wallet-color-picker'); + container.innerHTML = ''; + if (!_addWalletSelectedColor) _addWalletSelectedColor = autoPickColor(); + WALLET_COLORS.forEach((c) => { + const swatch = document.createElement('div'); + swatch.className = 'color-picker-swatch' + (_addWalletSelectedColor.toLowerCase() === c.toLowerCase() ? ' selected' : ''); + swatch.style.setProperty('--swatch-color', c); + swatch.style.background = c; + swatch.addEventListener('click', () => { + _addWalletSelectedColor = c; + renderAddWalletColorPicker(); + }); + container.appendChild(swatch); + }); +} + +/* Reverse map: chainId → chain name */ +const CHAIN_IDS_REVERSED = { '1': 'ethereum', '8453': 'base', '42161': 'arbitrum', '10': 'optimism', '137': 'polygon', '43114': 'avalanche', '56': 'bsc', '250': 'fantom', '998': 'hyperliquide' }; + +function fetchWalletAddress() { + if (!window.ethereum) { + document.getElementById('add-wallet-error').textContent = 'No web3 provider found'; + document.getElementById('add-wallet-error').classList.remove('hidden'); + return; + } + window.ethereum.request({ method: 'eth_requestAccounts' }) + .then(accounts => { + if (accounts && accounts.length > 0) { + document.getElementById('input-address').value = accounts[0]; + document.getElementById('add-wallet-error').classList.add('hidden'); + + /* Auto-detect chain from wallet */ + window.ethereum.request({ method: 'eth_chainId' }) + .then(chainIdHex => { + const chainName = CHAIN_IDS_REVERSED[chainIdHex] || 'base'; + document.getElementById('input-chain-display').value = chainName.charAt(0).toUpperCase() + chainName.slice(1); + document.getElementById('input-chain-hidden').value = chainName; + }); + + /* Auto-assign a color not used by existing wallets */ + _addWalletSelectedColor = autoPickColor(); + renderAddWalletColorPicker(); + } + }) + .catch(() => { + document.getElementById('add-wallet-error').textContent = 'Connection rejected'; + document.getElementById('add-wallet-error').classList.remove('hidden'); + }); } function closeAddWalletModal() { @@ -636,74 +732,96 @@ function closeAddWalletModal() { async function handleAddWallet(e) { e.preventDefault(); - const address = document.getElementById('input-address').value.trim().toLowerCase(); + let address = document.getElementById('input-address').value.trim().toLowerCase(); + let chain = document.getElementById('input-chain-hidden').value || 'base'; const nickname = document.getElementById('input-nickname').value.trim(); const errEl = document.getElementById('add-wallet-error'); - if (!address) { errEl.textContent = 'Address is required'; errEl.classList.remove('hidden'); return false; } + /* If no address, connect wallet first */ + if (!address) { + if (!window.ethereum) { + errEl.textContent = 'No web3 provider found. Install a wallet extension.'; + errEl.classList.remove('hidden'); + return false; + } + try { + const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' }); + if (!accounts || accounts.length === 0) { + errEl.textContent = 'No account returned'; + errEl.classList.remove('hidden'); + return false; + } + address = accounts[0].toLowerCase(); + document.getElementById('input-address').value = accounts[0]; + + /* Auto-detect chain */ + const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' }); + chain = CHAIN_IDS_REVERSED[chainIdHex] || 'base'; + document.getElementById('input-chain-hidden').value = chain; + document.getElementById('input-chain-display').value = chain.charAt(0).toUpperCase() + chain.slice(1); + + _addWalletSelectedColor = autoPickColor(); + renderAddWalletColorPicker(); + } catch (err) { + errEl.textContent = 'Connection rejected'; + errEl.classList.remove('hidden'); + return false; + } + } + + /* Assign color */ + const color = _addWalletSelectedColor || autoPickColor(); + setColorForWallet(address, color); - /* Detect chain from address format */ - let chain = 'base'; const isEVM = /^(0x)?[0-9a-fA-F]{40}$/i.test(address); const isBTC = /^(bc1[a-z0-9]{25,39}|1[a-km-zA-HJ-NP-Z1-9]{25,34})$/i.test(address); - // if (!isEVM && !isBTC) { errEl.textContent = 'Invalid address format'; errEl.classList.remove('hidden'); return false; } + const isSolana = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(address); - if (!isEVM && !isBTC) chain = 'btc'; + if (!isEVM && !isBTC && !isSolana) { errEl.textContent = 'Invalid address format'; errEl.classList.remove('hidden'); return false; } /* Auto-verify non-EVM wallets */ - if (!isEVM && isBTC) { - const addResult = wm.addWallet(address, 'btc', nickname); + if (!isEVM) { + const autoChain = isSolana ? 'solana' : 'btc'; + const addResult = wm.addWallet(address, autoChain, nickname); if (!addResult.success) { errEl.textContent = addResult.error; errEl.classList.remove('hidden'); return false; } - wm.verifyWallet(address, 'btc', '', { action: 'Auto-verified (non-verification chain)', walletAddress: address }); + wm.verifyWallet(address, autoChain, '', { action: 'Auto-verified (non-verification chain)', walletAddress: address }); walletSyncState[address] = 'synced'; closeAddWalletModal(); + closeSidebar(); renderAll(); return false; } - /* Register EVM wallet */ - const addResult = wm.addWallet(address, 'base', nickname); + /* Register EVM wallet first so verifyOwnership can find it */ + const addResult = wm.addWallet(address, chain, nickname); if (!addResult.success) { errEl.textContent = addResult.error; errEl.classList.remove('hidden'); return false; } - /* Attempt EIP-712 verification if MetaMask is available */ - const verifyResult = await verifyOwnership(address, nickname); + /* One-shot: verify or remove */ + const verifyResult = await verifyOwnership(address, chain, nickname); if (!verifyResult.success) { - console.log("Verification not performed:", verifyResult.error); - /* Wallet saved but unverified — user can verify from sidebar */ + wm.removeWallet(address, chain); + errEl.textContent = verifyResult.error; + errEl.classList.remove('hidden'); + return false; } - /* POST to FastAPI backend to start monitoring */ - try { - const resp = await fetch('http://localhost:8000/api/v1/portfolio/monitor', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address: address, chain: 'base', chain_id: 8453 }) - }); - if (!resp.ok) { - console.log(`Backend returned ${resp.status}`); - } - } catch (err) { - console.error('Backend unavailable', err); - } - - /* Mark as syncing initially */ - walletSyncState[address] = 'syncing'; - closeAddWalletModal(); + closeSidebar(); + walletSyncState[address] = 'syncing'; + + await _pollUntilSynced(address); renderSidebar(); renderWalletPills(); renderLedgerFilterWallets(); - - /* Kick off a fetch for this new wallet */ - fetchWalletAaveData(address); - updateDashboard(); + await _fetchPricesAndRender(); return false; } /* =================================================================== EIP-712 Verification =================================================================== */ -const EIP712_DOMAIN = { name: "Anonymous Wallet Tracker", version: "1", chainId: 1 }; +/* 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, hyperliquide: 998 }; const VERIFY_TYPES = { VerifyTracking: [ { name: 'action', type: 'string' }, @@ -712,7 +830,7 @@ const VERIFY_TYPES = { ] }; -async function verifyOwnership(address, nickname) { +async function verifyOwnership(address, chain, nickname) { if (!window.ethereum) { return { success: false, error: 'No web3 provider' }; } @@ -721,8 +839,9 @@ async function verifyOwnership(address, nickname) { walletAddress: address, timestamp: Math.floor(Date.now() / 1000).toString() }; + const domain = { name: 'Anonymous Wallet Tracker', version: '1', chainId: CHAIN_IDS[chain] || 1 }; const typedData = { - domain: EIP712_DOMAIN, + domain, types: { EIP712Domain: [{ name: 'name', type: 'string' }, { name: 'version', type: 'string' }, { name: 'chainId', type: 'uint256' }], VerifyTracking: VERIFY_TYPES.VerifyTracking }, primaryType: 'VerifyTracking', message @@ -740,8 +859,8 @@ async function verifyOwnership(address, nickname) { method: 'eth_signTypedData_v4', params: [accounts[0], JSON.stringify(typedData)] }); - wm.verifyWallet(address, 'base', sig, typedData.message); - wm.renameWallet(address, 'base', nickname); + wm.verifyWallet(address, chain, sig, typedData.message); + wm.renameWallet(address, chain, nickname); return { success: true }; } catch (err) { return { success: false, error: err?.message || 'Signature failed' }; @@ -760,18 +879,22 @@ async function handleVerifyWallet(address, chain, nickname) { return; } - const result = await verifyOwnership(address, nickname || w.nickname); + const result = await verifyOwnership(address, chain, nickname || w.nickname); if (result.success) { /* Fetch data now that wallet is verified */ if (chain === 'base') fetchWalletAaveData(address); } else { + /* Remove unverified wallet on failure */ + wm.removeWallet(address, chain); alert('Verification failed: ' + result.error); } renderAll(); } function handleRevokeVerification(address, chain) { - wm.revokeVerification(address, chain); + wm.removeWallet(address, chain); + delete walletSyncState[address]; + delete addressSnapshots[address]; renderAll(); } @@ -870,7 +993,7 @@ function renderLedgerFilterWallets() { Dynamic Data Fetching (Promise.all aggregation) =================================================================== */ async function fetchWalletAaveData(address) { - const endpoint = `/api/v1/portfolio/${address}/avve`; + const endpoint = `/api/v1/portfolio/${address}/base/aave`; try { const resp = await fetch(`${API_BASE}${endpoint}`); if (resp.status === 400) { @@ -894,6 +1017,51 @@ async function fetchWalletAaveData(address) { } } +/* Poll until wallet data is synchronized, up to ~60s */ +async function _pollUntilSynced(address, timeoutMs = 60000, intervalMs = 2000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const resp = await fetch(`${API_BASE}/api/v1/portfolio/${address}/base/aave`); + if (resp.ok) { + const events = await resp.json(); + if (Array.isArray(events) && events.length > 0) { + walletSyncState[address] = 'synced'; + addressSnapshots[address] = snapshotsToDaily(events); + renderWalletPills(); + return true; + } + } + } catch (err) { /* ignore, will retry */ } + await new Promise(r => setTimeout(r, intervalMs)); + } + /* Timeout — mark as synced anyway so UI is not blocked */ + walletSyncState[address] = 'syncing'; + renderWalletPills(); + return false; +} + +/* Fetch price history for the new wallet's data range, then render */ +async function _fetchPricesAndRender() { + const selectedAddr = getSelectedAddresses(); + if (selectedAddr.length === 0) { + renderCombinedTable(); + return; + } + /* Find oldest transaction to determine price range */ + let oldestTs = Date.now(); + selectedAddr.forEach(addr => { + const snaps = addressSnapshots[addr] || []; + snaps.forEach(s => { + const t = new Date(s.block_timestamp).getTime(); + if (t < oldestTs) oldestTs = t; + }); + }); + await fetchPrices(Object.keys(TOKENS), oldestTs); + renderCombinedTable(); + updateDashboard(); +} + async function fetchSelectedWalletsData() { const selectedAddr = getSelectedAddresses(); if (selectedAddr.length === 0) return; @@ -1465,7 +1633,7 @@ async function pollSyncStatus() { if (syncing.length === 0) return; const promises = syncing.map(async (addr) => { try { - const resp = await fetch(`${API_BASE}/api/v1/portfolio/${addr}/avve`); + const resp = await fetch(`${API_BASE}/api/v1/portfolio/${addr}/base/aave`); if (resp.status === 400) { const body = await resp.json().catch(() => ({})); const details = (body?.detail || body?.details || '').toString().toUpperCase(); diff --git a/verifier.js b/verifier.js index b534f04..3589f15 100644 --- a/verifier.js +++ b/verifier.js @@ -16,12 +16,34 @@ import { WalletManager } from './wallets.js'; /* EIP-712 Domain */ /* ------------------------------------------------------------------ */ -const EIP712_DOMAIN = { - name: 'Anonymous Wallet Tracker', - version: '1', - chainId: 1, +/* 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, + hyperliquide: 998, }; +/** + * 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' }, @@ -126,7 +148,7 @@ export class WalletVerifier { address = await this._requestAccount(); /* ---- Step 2: Build EIP-712 payload ---- */ - const typedData = this._buildTypedData(address); + const typedData = this._buildTypedData(address, chain); /* ---- Step 3: Request signature ---- */ signature = await this._requestSignature(address, typedData); @@ -205,12 +227,13 @@ export class WalletVerifier { * 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) { + _buildTypedData(address, chain) { const message = this._buildMessageData(address); return { - domain: EIP712_DOMAIN, + domain: buildEIP712Domain(chain), types: { EIP712Domain: [ { name: 'name', type: 'string' }, diff --git a/wallets.js b/wallets.js index c2aff9d..f4a563d 100644 --- a/wallets.js +++ b/wallets.js @@ -33,12 +33,13 @@ /* Constants */ /* ------------------------------------------------------------------ */ -const LS_KEY = 'cbbtc_tracked_wallets'; +const LS_KEY = 'tracked_wallets'; /* Accepted chain identifiers */ const VALID_CHAINS = new Set([ 'base', 'ethereum', 'bitcoin', 'arbitrum', 'optimism', 'polygon', 'solana', 'avalanche', 'bsc', 'fantom', 'btc', + 'hyperliquide', ]); /* Regex patterns for address validation per chain */ @@ -52,6 +53,7 @@ const ADDRESS_PATTERNS = { avalanche: /^(0x)?[0-9a-fA-F]{40}$/i, bsc: /^(0x)?[0-9a-fA-F]{40}$/i, fantom: /^(0x)?[0-9a-fA-F]{40}$/i, + hyperliquide: /^(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})$/, @@ -113,37 +115,34 @@ export class WalletManager { } } - /** - * 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 []; - } + /** + * 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); + const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - this._wallets = []; - return []; - } + if (!Array.isArray(parsed)) { + this._wallets = []; + return []; + } - /* Restore persisted state, including verification. - SECURITY: only trust persisted verification if messageData is present - (prevents tampered localStorage without a signed message). */ + /* 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) => ({ @@ -153,15 +152,24 @@ export class WalletManager { 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; + }); - return this._wallets; + /* Persist the trimmed list so discarded unverified wallets don't + reappear on subsequent reloads. */ + this._persist(); + return this._wallets; - } catch (_ignored) { - this._wallets = []; - return []; - } - } + } catch (_ignored) { + this._wallets = []; + return []; + } + } /* ---------------------------------------------------------------- */ /* CRUD */