Fix EIP-712 chainId dynamic resolution; improve add-wallet modal

- 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
This commit is contained in:
Dione
2026-06-10 11:31:10 +00:00
parent 5ab9cb4b5c
commit 6473d3cedf
3 changed files with 302 additions and 103 deletions

View File

@ -136,12 +136,21 @@ window.WalletManager = WalletManager;
<form id="add-wallet-form" onsubmit="return handleAddWallet(event)">
<div class="mb-4">
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Wallet Address</label>
<input id="input-address" type="text" placeholder="0x..." class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-white text-sm placeholder-gray-600 focus:outline-none focus:border-[#FF7A00]/50 transition font-mono" required>
<input id="input-address" type="text" placeholder="Connect wallet..." class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-white text-sm placeholder-gray-600 transition font-mono" readonly>
</div>
<div class="mb-6">
<div class="mb-4">
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Chain</label>
<input id="input-chain-display" type="text" placeholder="—" class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-white text-sm placeholder-gray-600 transition" readonly>
<input id="input-chain-hidden" type="hidden" value="base">
</div>
<div class="mb-4">
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Nickname</label>
<input id="input-nickname" type="text" placeholder="Whale Wallet A" class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-white text-sm placeholder-gray-600 focus:outline-none focus:border-[#FF7A00]/50 transition" required>
</div>
<div class="mb-6">
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Color</label>
<div id="add-wallet-color-picker" class="flex flex-wrap gap-2"></div>
</div>
<div id="add-wallet-error" class="hidden mb-3 text-red-400 text-xs font-medium"></div>
<button type="submit" class="w-full bg-[#FF7A00] hover:bg-[#FF7A00]/90 text-white font-bold px-4 py-3 rounded-lg transition">Monitor Wallet</button>
</form>
@ -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();

View File

@ -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' },

View File

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