From b585f70d31e9678dc608068042865e1f16c8e746 Mon Sep 17 00:00:00 2001 From: Dione Date: Wed, 17 Jun 2026 20:34:54 +0000 Subject: [PATCH] feat: import wallet support, HYPE tracking, bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix pollVerifiedWallets: route import wallets to /wallet endpoint and reconstruct running-balance ledger - Fix generateRandomAddress: append 'IMPORT' suffix to prevent fake EVM addresses - Add import chain: CSV upload via multipart, IMPORT_ONLY wallet registration - Add HYPE summary cards with holdings, PnL, avg buy price, total invested - Add BTC and SATS to TOKENS config - Add backend DELETE on wallet removal (skip for import wallets) - Fix chain name: hyperliquide → hyperevm (chainId 999) - Add HSTS and security headers to nginx config --- default.conf | 7 + index.html | 464 +++++++++++++++++++++++++++++++++++---------------- verifier.js | 2 +- wallets.js | 5 +- 4 files changed, 328 insertions(+), 150 deletions(-) diff --git a/default.conf b/default.conf index 71a16f1..7071472 100644 --- a/default.conf +++ b/default.conf @@ -1,12 +1,19 @@ server { listen 80; + + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options "nosniff" always; + location / { root /usr/share/nginx/html; index index.html; } + location /api/ { proxy_pass http://192.168.1.102:8000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } diff --git a/index.html b/index.html index 5d366a2..df6a79f 100644 --- a/index.html +++ b/index.html @@ -163,7 +163,7 @@ window.WalletManager = WalletManager; @@ -213,15 +213,18 @@ window.WalletManager = WalletManager;

Import Transaction History

+
+ Imports transaction history via CSV upload. The wallet is registered as IMPORT_ONLY and the CSV is sent directly to the backend for processing. +
+
+ + +
-
- - -
@@ -235,13 +238,13 @@ window.WalletManager = WalletManager;
@@ -300,6 +303,30 @@ window.WalletManager = WalletManager; + +
+
+
Current Holdings
+
0.00 HYPE
+
$Loading...
+
+
+
Total PnL
+
$--.--
+
--.--%
+
+
+
Avg Buy Price
+
$--.--
+
Cost Basis
+
+
+
Total Invested
+
$--.--
+
0 Transactions
+
+
+
@@ -502,7 +529,7 @@ const orangeBrandColor = '#FF7A00'; const blueBrandColor = '#3b82f6'; const API_BASE = window.location.origin; const WALLET_COLORS = ['#F7931A','#FF007F','#39FF14','#00FFFF','#CCFF00','#9D00FF','#FF0033','#00FFCC','#FF00FF','#007FFF','#DEFF0A','#FF5E00','#8A2BE2','#00FF66','#FF1493','#7B00FF']; -const TOKENS = {"cbBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WETH": {"decimals": 18, "priceSymbol": "WETH"}, "USDC": {"decimals": 6, "priceSymbol": "USDC"}, "USDCn": {"decimals": 6, "priceSymbol": "USDC"}, "wHYPE": {"decimals": 18, "priceSymbol": "HYPE"}}; +const TOKENS = {"cbBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WETH": {"decimals": 18, "priceSymbol": "WETH"}, "USDC": {"decimals": 6, "priceSymbol": "USDC"}, "USDCn": {"decimals": 6, "priceSymbol": "USDC"}, "wHYPE": {"decimals": 18, "priceSymbol": "HYPE"}, "BTC": {"decimals": 8, "priceSymbol": "BTC"}, "SATS": {"decimals": 0, "priceSymbol": "BTC"}}; function symbolDisplay(s) { return s === 'USDCn' ? 'USDC' : s; } /* Compound key for wallet identity: "{address}:{chain}" */ @@ -520,6 +547,10 @@ let currentWalletSeries = []; let currentNetHeld = 0; let currentBuyCost = 0; let currentBuyAmount = 0; +let currentHypeHeld = 0; +let currentHypeBuyCost = 0; +let currentHypeBuyAmount = 0; +let currentHypeTxCount = 0; let btcPriceData = []; let currentCutoffMs; let tickIntervalId; @@ -1053,8 +1084,8 @@ async function handleVerifyWallet(address, chain, nickname) { if (!w) return; if (w.isVerified) return; - /* Non-EVM auto-verify */ - if (['btc', 'bitcoin', 'solana'].includes(chain)) { + /* Non-EVM auto-verify (includes import chain) */ + if (['btc', 'bitcoin', 'solana', 'import'].includes(chain)) { wm.verifyWallet(address, chain, '', { action: 'Auto-verified (non-verification chain)', walletAddress: address }); renderAll(); return; @@ -1075,8 +1106,18 @@ async function handleVerifyWallet(address, chain, nickname) { /* =================================================================== Delete Wallet =================================================================== */ -function handleDeleteWallet(address, chain, idx) { +async function handleDeleteWallet(address, chain, idx) { const key = walletKey(address, chain); + + /* Remove from backend DB (CASCADE deletes all associated data) — skip for import wallets */ + if (chain !== 'import') { + try { + await fetch(`${API_BASE}/api/v1/portfolio/${address}/${chain}`, { method: 'DELETE' }); + } catch (err) { + console.error(`Failed to remove ${key} from backend:`, err); + } + } + wm.removeWallet(address, chain); delete walletSyncState[key]; delete addressSnapshots[key]; @@ -1288,7 +1329,10 @@ function renderLedgerFilterWallets() { async function fetchWalletAaveData(address, chain) { chain = chain || 'base'; const key = walletKey(address, chain); - const endpoint = `/api/v1/portfolio/${address}/${chain}/aave`; + /* Import wallets use the /wallet ledger endpoint, not /aave */ + const endpoint = chain === 'import' + ? `/api/v1/portfolio/${address}/${chain}/wallet` + : `/api/v1/portfolio/${address}/${chain}/aave`; try { const resp = await fetch(`${API_BASE}${endpoint}`); if (resp.status === 400) { @@ -1308,7 +1352,26 @@ async function fetchWalletAaveData(address, chain) { if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const events = await resp.json(); walletSyncState[key] = 'synced'; - addressSnapshots[key] = snapshotsToDaily(events); + let ledger = events; + /* Import wallets: reconstruct running-balance ledger from raw DB records */ + if (chain === 'import' && events.length && events[0].token_address !== undefined) { + const sorted = [...events].sort((a, b) => new Date(a.block_timestamp) - new Date(b.block_timestamp)); + const balances = {}; + ledger = sorted.map(r => { + const tk = r.token_address; + const decs = TOKENS[tk] ? TOKENS[tk].decimals : 0; + const amt = (parseFloat(r.amount) || 0) / Math.pow(10, decs); + balances[tk] = (balances[tk] || 0) + (r.direction === 'IN' ? amt : -amt); + return { + tx_hash: r.tx_hash || '0x0', + block_timestamp: r.block_timestamp, + wallet: { [tk]: balances[tk].toFixed(18) }, + collateral: {}, + debt: {} + }; + }); + } + addressSnapshots[key] = snapshotsToDaily(ledger); renderWalletPills(); } catch (err) { console.error(`Failed to fetch data for ${key}:`, err); @@ -1320,6 +1383,8 @@ async function fetchWalletAaveData(address, chain) { /* Poll until wallet data is synchronized, up to ~60s */ async function _pollUntilSynced(address, timeoutMs = 60000, intervalMs = 2000, chain) { chain = chain || 'base'; + /* IMPORT_ONLY wallets don't poll — they're synced immediately */ + if (chain === 'import') return true; const key = walletKey(address, chain); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -1383,16 +1448,26 @@ async function fetchSelectedWalletsData() { /* =================================================================== Chart Helpers =================================================================== */ -function getTokenAmount(raw, symbol) { +function getTokenAmount(raw, symbol, isImport) { + if (isImport) return parseFloat(raw || '0'); const dec = TOKENS[symbol] ? TOKENS[symbol].decimals : 18; return parseFloat(raw || '0') / Math.pow(10, dec); } -function getTotalBTC(obj) { +function getTotalBTC(obj, isImport) { let total = 0; if (!obj) return 0; for (const sym of Object.keys(TOKENS)) { - if (TOKENS[sym].priceSymbol === 'BTC') total += getTokenAmount(obj[sym], sym); + if (TOKENS[sym].priceSymbol === 'BTC') total += getTokenAmount(obj[sym], sym, isImport); + } + return total; +} + +function getTotalHYPE(obj, isImport) { + let total = 0; + if (!obj) return 0; + for (const sym of Object.keys(TOKENS)) { + if (TOKENS[sym].priceSymbol === 'HYPE') total += getTokenAmount(obj[sym], sym, isImport); } return total; } @@ -1496,10 +1571,12 @@ function buildCumulativeSeries(wallets) { /* Pre-compute per-wallet dated arrays (oldest-first) */ const walletDated = {}; keys.forEach(key => { + const chain = key.split(':')[1]; + const isImport = chain === 'import'; const snaps = addressSnapshots[key] || []; walletDated[key] = snaps.map(s => ({ dateStr: s.block_timestamp.slice(0, 10), - btcAmt: getTotalBTC(s?.wallet) + getTotalBTC(s?.collateral) + btcAmt: getTotalBTC(s?.wallet, isImport) + getTotalBTC(s?.collateral, isImport) })).sort((a, b) => a.dateStr.localeCompare(b.dateStr)); }); @@ -1548,28 +1625,34 @@ function calculateCurrentHoldings() { currentNetHeld = 0; currentBuyCost = 0; currentBuyAmount = 0; + currentHypeHeld = 0; + currentHypeBuyCost = 0; + currentHypeBuyAmount = 0; + currentHypeTxCount = 0; if (selectedWallets.length === 0) return; /* Sum latest cbBTC balance across selected wallets */ selectedWallets.forEach(w => { const key = walletKey(w.address, w.chain); + const isImport = w.chain === 'import'; const snaps = addressSnapshots[key] || []; if (snaps.length > 0) { const latest = snaps[0]; - currentNetHeld += getTotalBTC(latest?.wallet); - currentNetHeld += getTotalBTC(latest?.collateral); + currentNetHeld += getTotalBTC(latest?.wallet, isImport); + currentNetHeld += getTotalBTC(latest?.collateral, isImport); } }); /* Derive buy cost from snapshot deltas: positive BTC changes, oldest first */ selectedWallets.forEach(w => { const key = walletKey(w.address, w.chain); + const isImport = w.chain === 'import'; const snaps = addressSnapshots[key] || []; if (snaps.length === 0) return; let prevBtc = 0; snaps.slice().reverse().forEach(snap => { const currentDate = snap.block_timestamp.slice(0, 10); - const currentBtc = getTotalBTC(snap?.wallet) + getTotalBTC(snap?.collateral); + const currentBtc = getTotalBTC(snap?.wallet, isImport) + getTotalBTC(snap?.collateral, isImport); const delta = currentBtc - prevBtc; if (delta > 0) { const price = priceForToken('BTC', currentDate) || 0; @@ -1579,6 +1662,39 @@ function calculateCurrentHoldings() { prevBtc = currentBtc; }); }); + + /* HYPE holdings — current */ + selectedWallets.forEach(w => { + const key = walletKey(w.address, w.chain); + const isImport = w.chain === 'import'; + const snaps = addressSnapshots[key] || []; + if (snaps.length > 0) { + const latest = snaps[0]; + currentHypeHeld += getTotalHYPE(latest?.wallet, isImport); + currentHypeHeld += getTotalHYPE(latest?.collateral, isImport); + } + }); + + /* Derive HYPE buy cost from snapshot deltas: positive HYPE changes, oldest first */ + selectedWallets.forEach(w => { + const key = walletKey(w.address, w.chain); + const isImport = w.chain === 'import'; + const snaps = addressSnapshots[key] || []; + if (snaps.length === 0) return; + let prevHype = 0; + snaps.slice().reverse().forEach(snap => { + const currentDate = snap.block_timestamp.slice(0, 10); + const currentHype = getTotalHYPE(snap?.wallet, isImport) + getTotalHYPE(snap?.collateral, isImport); + const delta = currentHype - prevHype; + if (delta > 0) { + const price = priceForToken('HYPE', currentDate) || 0; + if (price > 0) currentHypeBuyCost += delta * price; + currentHypeBuyAmount += delta; + currentHypeTxCount++; + } + prevHype = currentHype; + }); + }); } function updateDashboard() { @@ -1603,6 +1719,8 @@ function updateDashboard() { updateBtcUI(btcPriceData[btcPriceData.length - 1][1], 0); } + updateHypeUI(); + /* Filter transaction table */ const selectedWallets = getSelectedAddresses(); const activeTypes = Array.from(document.querySelectorAll('.filter-checkbox:checked')).map(cb => cb.dataset.filter); @@ -1665,9 +1783,10 @@ function renderCombinedTable() { /* Build unified snapshot list from dynamic sources only */ const unified = []; for (const [key, snaps] of Object.entries(addressSnapshots)) { - const [addr] = key.split(':'); + const [addr, chain] = key.split(':'); + const isImport = chain === 'import'; (snaps || []).forEach(snap => { - unified.push({ ...snap, _walletAddress: key, _walletAddr: addr }); + unified.push({ ...snap, _walletAddress: key, _walletAddr: addr, _walletChain: chain, _isImport: isImport }); }); } @@ -1691,11 +1810,12 @@ function renderCombinedTable() { const color = walletColorMap[walletAddr] || '#f7931a'; const nick = walletNickMap[walletAddr] || walletAddr.slice(0, 8); + const isImport = snap._isImport; /* Cold storage */ const coldItems = []; let coldTotal = 0; Object.entries(snap.wallet || {}).forEach(([sym, raw]) => { - const amt = getTokenAmount(raw, sym); + const amt = getTokenAmount(raw, sym, isImport); if (amt <= 0) return; const price = priceForToken(sym, dateStr) || 0; const usd = amt * price; @@ -1710,7 +1830,7 @@ function renderCombinedTable() { const collateralItems = []; let collateralTotal = 0; Object.entries(snap.collateral || {}).forEach(([sym, raw]) => { - const amt = getTokenAmount(raw, sym); + const amt = getTokenAmount(raw, sym, isImport); if (amt <= 0) return; const price = priceForToken(sym, dateStr) || 0; const usd = amt * price; @@ -1725,7 +1845,7 @@ function renderCombinedTable() { const borrowItems = []; let borrowTotal = 0; Object.entries(snap.debt || {}).forEach(([sym, raw]) => { - const amt = getTokenAmount(raw, sym); + const amt = getTokenAmount(raw, sym, isImport); if (amt <= 0) return; const price = priceForToken(sym, dateStr) || 0; const usd = amt * price; @@ -1859,6 +1979,25 @@ function updateBtcUI(price, percent) { pnlPctEl.className = `text-xs font-bold mt-1 ${totalPnl >= 0 ? 'text-green-400' : 'text-red-400'}`; } +function updateHypeUI() { + const hypePrice = priceForToken('HYPE', new Date().toISOString().slice(0, 10)) || 0; + document.getElementById('hype-held-val').innerText = currentHypeHeld.toFixed(4); + const currentUsdVal = currentHypeHeld * hypePrice; + document.getElementById('hype-usd-val').innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(currentUsdVal); + const totalPnl = currentUsdVal - currentHypeBuyCost; + const pnlPercent = currentHypeBuyCost > 0 ? (totalPnl / currentHypeBuyCost) * 100 : 0; + const pnlEl = document.getElementById('hype-pnl-val'); + pnlEl.innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(totalPnl); + pnlEl.className = `text-xl font-bold ${totalPnl >= 0 ? 'text-green-400' : 'text-red-400'}`; + const pnlPctEl = document.getElementById('hype-pnl-percent-val'); + pnlPctEl.innerText = `${totalPnl >= 0 ? '+' : ''}${pnlPercent.toFixed(2)}%`; + pnlPctEl.className = `text-xs font-bold mt-1 ${totalPnl >= 0 ? 'text-green-400' : 'text-red-400'}`; + const avgBuyHype = currentHypeBuyAmount > 0 ? currentHypeBuyCost / currentHypeBuyAmount : 0; + document.getElementById('hype-avg-buy-val').innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(avgBuyHype); + document.getElementById('hype-total-invested-val').innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(currentHypeBuyCost); + document.getElementById('hype-tx-count-val').innerText = currentHypeTxCount; +} + function setupCumulCard(cutoff) { const selectedWallets = getSelectedAddresses(); const wallets = wm.getWallets(); @@ -1949,10 +2088,11 @@ function setupSatsCard(cutoff) { selectedWallets.forEach(w => { const key = walletKey(w.address, w.chain); walletDailyBalances[key] = {}; + const isImport = w.chain === 'import'; const snaps = addressSnapshots[key] || []; const dated = snaps.map(s => ({ dateStr: s.block_timestamp.slice(0, 10), - btcVal: getTotalBTC(s?.wallet) + getTotalBTC(s?.collateral) + btcVal: getTotalBTC(s?.wallet, isImport) + getTotalBTC(s?.collateral, isImport) })).sort((a, b) => a.dateStr.localeCompare(b.dateStr)); let balance = 0; for (const ds of sortedDates) { @@ -2082,11 +2222,12 @@ function setupBreakdownCard(cutoff) { const walletDated = {}; selectedWallets.forEach(w => { const key = walletKey(w.address, w.chain); + const isImport = w.chain === 'import'; const snaps = addressSnapshots[key] || []; walletDated[key] = snaps.map(s => ({ dateStr: s.block_timestamp.slice(0, 10), - cold: getTotalBTC(s?.wallet), - collateral: getTotalBTC(s?.collateral) + cold: getTotalBTC(s?.wallet, isImport), + collateral: getTotalBTC(s?.collateral, isImport) })).sort((a, b) => a.dateStr.localeCompare(b.dateStr)); }); @@ -2179,41 +2320,62 @@ document.querySelectorAll('.filter-checkbox').forEach(cb => { /* =================================================================== Polling for Verified Wallets =================================================================== */ -async function pollVerifiedWallets() { - const now = Date.now(); - if (now - lastFetchMs < 30000) return; - lastFetchMs = now; - const verified = wm.getVerifiedWallets() || []; - let anyUpdated = false; - for (const w of verified) { - try { - const key = walletKey(w.address, w.chain); - const resp = await fetch(`${API_BASE}/api/v1/portfolio/${w.address}/${w.chain}/aave`); - if (!resp.ok) continue; - const events = await resp.json(); - if (events.length === 0) continue; - const newestTs = events.reduce((m, e) => (e.block_timestamp > m ? e.block_timestamp : m), events[0].block_timestamp); - const existing = addressSnapshots[key]; - if (existing && existing.length > 0) { - if (newestTs > existing[0].block_timestamp) { - addressSnapshots[key] = snapshotsToDaily(events); - walletSyncState[key] = 'synced'; - anyUpdated = true; - } - } else { - addressSnapshots[key] = snapshotsToDaily(events); - walletSyncState[key] = 'synced'; - anyUpdated = true; - } - } catch (err) { - console.error("Poll update failed for " + key, err); - } - } - if (anyUpdated) { - await refreshPrices(); - renderCombinedTable(); - updateDashboard(); - } + async function pollVerifiedWallets() { + const now = Date.now(); + if (now - lastFetchMs < 30000) return; + lastFetchMs = now; + const verified = wm.getVerifiedWallets() || []; + let anyUpdated = false; + for (const w of verified) { + try { + const key = walletKey(w.address, w.chain); + const endpoint = w.chain === 'import' + ? `/api/v1/portfolio/${w.address}/${w.chain}/wallet` + : `/api/v1/portfolio/${w.address}/${w.chain}/aave`; + const resp = await fetch(`${API_BASE}${endpoint}`); + if (!resp.ok) continue; + const events = await resp.json(); + if (events.length === 0) continue; + let ledger = events; + if (w.chain === 'import' && events.length && events[0].token_address !== undefined) { + const sorted = [...events].sort((a, b) => new Date(a.block_timestamp) - new Date(b.block_timestamp)); + const balances = {}; + ledger = sorted.map(r => { + const tk = r.token_address; + const decs = TOKENS[tk] ? TOKENS[tk].decimals : 0; + const amt = (parseFloat(r.amount) || 0) / Math.pow(10, decs); + balances[tk] = (balances[tk] || 0) + (r.direction === 'IN' ? amt : -amt); + return { + tx_hash: r.tx_hash || '0x0', + block_timestamp: r.block_timestamp, + wallet: { [tk]: balances[tk].toFixed(18) }, + collateral: {}, + debt: {} + }; + }); + } + const newestTs = ledger.reduce((m, e) => (e.block_timestamp > m ? e.block_timestamp : m), ledger[0].block_timestamp); + const existing = addressSnapshots[key]; + if (existing && existing.length > 0) { + if (newestTs > existing[0].block_timestamp) { + addressSnapshots[key] = snapshotsToDaily(ledger); + walletSyncState[key] = 'synced'; + anyUpdated = true; + } + } else { + addressSnapshots[key] = snapshotsToDaily(ledger); + walletSyncState[key] = 'synced'; + anyUpdated = true; + } + } catch (err) { + console.error("Poll update failed for " + key, err); + } + } + if (anyUpdated) { + await refreshPrices(); + renderCombinedTable(); + updateDashboard(); + } } function startPolling() { @@ -2310,18 +2472,24 @@ function openImportCsvModal() { _csvImportRecords = []; _importSelectedColor = autoPickColor(); document.getElementById('import-nickname').value = ''; - document.getElementById('import-address').value = ''; + document.getElementById('import-address').value = generateRandomAddress(); document.getElementById('import-filename').textContent = 'Click to select a .csv file'; document.getElementById('import-file-display').classList.remove('border-green-500/50'); document.getElementById('import-file-display').classList.add('border-[#1A1F2C]'); document.getElementById('import-csv-error').classList.add('hidden'); document.getElementById('import-csv-progress').classList.add('hidden'); document.getElementById('import-csv-submit').disabled = false; - document.getElementById('import-submit-text').textContent = 'Import CSV'; + document.getElementById('import-submit-text').textContent = 'Import'; renderImportColorPicker(); document.getElementById('import-csv-modal').classList.add('open'); } +function generateRandomAddress() { + const buf = new Uint8Array(18); + crypto.getRandomValues(buf); + return '0x' + Array.from(buf).map(b => b.toString(16).padStart(2, '0')).join('') + '494d504f5254'; +} + function closeImportCsvModal() { document.getElementById('import-csv-modal').classList.remove('open'); _importCsvFile = null; @@ -2356,13 +2524,13 @@ function parseCsvOnSelect(file) { }); } -/* Submit handler */ +/* Submit handler — Step 1: register IMPORT_ONLY wallet, Step 2: upload CSV */ async function handleImportCsv(e) { e.preventDefault(); const nickname = document.getElementById('import-nickname').value.trim(); - const addressInput = document.getElementById('import-address').value.trim(); - const chain = 'base'; + let address = document.getElementById('import-address').value.trim().toLowerCase(); + const chain = 'import'; const color = _importSelectedColor || autoPickColor(); const errEl = document.getElementById('import-csv-error'); const progressEl = document.getElementById('import-csv-progress'); @@ -2375,106 +2543,106 @@ async function handleImportCsv(e) { errEl.classList.remove('hidden'); return false; } + /* Accept EVM (0x...), BTC Bech32 (bc1...), BTC legacy (1.../3...), or Solana */ + const evmRe = /^(0x)?[0-9a-f]{40}$/i; + const btcBech32Re = /^bc1[a-z0-9]{25,62}$/i; + const btcLegacyRe = /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/; + const solanaRe = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; + if (!address || !(evmRe.test(address) || btcBech32Re.test(address) || btcLegacyRe.test(address) || solanaRe.test(address))) { + errEl.textContent = 'Valid address required (EVM 0x-prefixed, BTC bc1/1/3-prefixed, or Solana base58).'; + errEl.classList.remove('hidden'); + return false; + } + /* Normalize EVM addresses — non-EVM addresses pass through as-is */ + if (evmRe.test(address) && !address.startsWith('0x')) address = '0x' + address; if (!_importCsvFile) { errEl.textContent = 'No CSV file selected.'; errEl.classList.remove('hidden'); return false; } + /* Check address not already tracked on import chain */ + const existing = wm.findWallet(address, chain); + if (existing) { + errEl.textContent = `Wallet ${address} already tracked on import chain.`; + errEl.classList.remove('hidden'); + return false; + } + errEl.classList.add('hidden'); progressEl.classList.remove('hidden'); submitBtn.disabled = true; - submitText.textContent = 'Processing...'; + submitText.textContent = 'Parsing CSV...'; + + /* Parse CSV into memory for client-side ledger calculation */ + const fileContent = _importCsvFile + ? await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target.result); + reader.onerror = reject; + reader.readAsText(_importCsvFile); + }) + : ''; + _csvImportRecords = parseCsvAndMap(fileContent); + let hasRecords = _csvImportRecords.length > 0; + + if (!hasRecords) { + errEl.textContent = 'No valid records found in the CSV.'; + errEl.classList.remove('hidden'); + submitBtn.disabled = false; + submitText.textContent = 'Import'; + return false; + } + + submitText.textContent = 'Registering wallet...'; try { - /* Parse CSV */ - const records = await parseCsvOnSelect(_importCsvFile); - _csvImportRecords = records; - - if (records.length === 0) { - errEl.textContent = 'No valid records found in the CSV file.'; - errEl.classList.remove('hidden'); - progressEl.classList.add('hidden'); - submitBtn.disabled = false; - submitText.textContent = 'Import CSV'; - return false; - } - - /* Resolve address: use input if valid, otherwise generate */ - let address = addressInput.toLowerCase(); - let addressWasAutoGenerated = false; - - if (address && /^(0x)?[0-9a-f]{40}$/i.test(address)) { - if (!address.startsWith('0x')) address = '0x' + address; - /* Check duplicate */ - const existing = wm.findWallet(address, chain); - if (existing) { - errEl.textContent = `Wallet ${address} already tracked on ${chain}.`; - errEl.classList.remove('hidden'); - progressEl.classList.add('hidden'); - submitBtn.disabled = false; - submitText.textContent = 'Import CSV'; - return false; - } - } else { - /* Generate a synthetic address from nickname hash */ - let hashArr; - if (crypto?.subtle) { - const hashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(nickname + Date.now())); - hashArr = new Uint8Array(hashBuf); - } else { - /* Fallback for non-secure contexts where crypto.subtle is unavailable */ - let s = (nickname + Date.now()).split('').reduce((a, c) => ((a << 5) - a + c.charCodeAt(0)) | 0, 0); - hashArr = new Uint8Array(32); - for (let i = 0; i < 32; i++) { s = ((s ^ s >> 15) * 2654435761) | 0; hashArr[i] = s % 256; } - } - address = '0x' + Array.from(hashArr.slice(0, 20)).map(b => b.toString(16).padStart(2, '0')).join(''); - document.getElementById('import-address').value = address; - addressWasAutoGenerated = true; - } - - submitText.textContent = 'Uploading...'; - - /* Register wallet in state without verification (non-EVM flow) */ - wm.addWallet(address, chain, nickname); - wm.verifyWallet(address, chain, '', { action: 'CSV Import (no-signature)', walletAddress: address }); - - /* POST to backend */ - const payload = { records: records.map(r => ({ ...r })) }; - - const resp = await fetch(`${API_BASE}/api/v1/portfolio/import/${chain}/${address}`, { + /* Step 1: Register IMPORT_ONLY wallet */ + const registerResp = await fetch(`${API_BASE}/api/v1/portfolio/monitor`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + body: JSON.stringify({ address, chain: 'import', chain_id: 0 }) }); - if (!resp.ok) { - const errBody = await resp.json().catch(() => ({})); - throw new Error(errBody?.detail || `HTTP ${resp.status}`); + if (!registerResp.ok && registerResp.status !== 409) { + const errBody = await registerResp.json().catch(() => ({})); + throw new Error(errBody?.detail || `Registration failed (HTTP ${registerResp.status})`); } - const result = await resp.json(); + submitText.textContent = 'Uploading CSV...'; + + /* Step 2: Upload CSV via multipart endpoint */ + const formData = new FormData(); + formData.append('file', _importCsvFile); + + const uploadResp = await fetch( + `${API_BASE}/api/v1/portfolio/import/csv/${chain}/${address}?skip_ledger=true`, + { + method: 'POST', + body: formData + } + ); + + if (!uploadResp.ok) { + const errBody = await uploadResp.json().catch(() => ({})); + const errMsg = errBody?.errors + ? errBody.errors.join('\n') + : errBody?.detail || `Upload failed (HTTP ${uploadResp.status})`; + throw new Error(errMsg); + } + + const result = await uploadResp.json(); const inserted = result.inserted || 0; const skipped = result.skipped || 0; - /* Update frontend state */ - const key = walletKey(address, chain); - if (result.ledger && Array.isArray(result.ledger)) { - addressSnapshots[key] = snapshotsToDaily(result.ledger); - } else { - addressSnapshots[key] = snapshotsToDaily(records.map(r => ({ - block_timestamp: r.block_timestamp, - wallet: { [r.token_address]: r.amount }, - collateral: {}, - debt: {}, - tx_hash: r.tx_hash || null, - }))); - } - walletSyncState[key] = 'synced'; + /* Register wallet in local state */ + wm.addWallet(address, 'import', nickname); + wm.verifyWallet(address, 'import', '', { action: 'CSV Import (IMPORT_ONLY)', walletAddress: address }); + setColorForWallet(address, 'import', color); - /* Assign color and mark synced */ - setColorForWallet(address, chain, color); - walletSyncState[key] = 'synced'; + /* Step 3: Fetch computed ledger from backend */ + submitText.textContent = 'Loading ledger...'; + await fetchWalletAaveData(address, 'import'); showToast( 'Import Complete', @@ -2494,7 +2662,7 @@ async function handleImportCsv(e) { showToast('Import Failed', err.message || 'Network error', 'error'); progressEl.classList.add('hidden'); submitBtn.disabled = false; - submitText.textContent = 'Import CSV'; + submitText.textContent = 'Import'; } return false; diff --git a/verifier.js b/verifier.js index 3589f15..e03b382 100644 --- a/verifier.js +++ b/verifier.js @@ -26,7 +26,7 @@ const CHAIN_IDS = { avalanche: 43114, bsc: 56, fantom: 250, - hyperliquide: 998, + hyperevm: 999, }; /** diff --git a/wallets.js b/wallets.js index 958285c..4a554da 100644 --- a/wallets.js +++ b/wallets.js @@ -39,7 +39,7 @@ const LS_KEY = 'tracked_wallets'; const VALID_CHAINS = new Set([ 'base', 'ethereum', 'bitcoin', 'arbitrum', 'optimism', 'polygon', 'solana', 'avalanche', 'bsc', 'fantom', 'btc', - 'hyperevm', + 'hyperevm', 'import', ]); /* Regex patterns for address validation per chain */ @@ -61,6 +61,9 @@ const ADDRESS_PATTERNS = { /* 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})$/, }; /* ------------------------------------------------------------------ */