+
+
+
+
+
+
+
@@ -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 */