feat: import wallet support, HYPE tracking, bug fixes

- 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
This commit is contained in:
Dione
2026-06-17 20:34:54 +00:00
parent b7eb8ece97
commit b585f70d31
4 changed files with 328 additions and 150 deletions

View File

@ -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;
}
}

View File

@ -163,7 +163,7 @@ window.WalletManager = WalletManager;
</button>
<button onclick="openImportCsvModal()" class="w-full bg-[#3b82f6]/10 border border-[#3b82f6]/30 text-[#58a6ff] hover:bg-[#3b82f6]/20 px-4 py-3 rounded-lg font-bold text-sm transition flex items-center justify-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Import CSV
Import History
</button>
</div>
</div>
@ -213,15 +213,18 @@ window.WalletManager = WalletManager;
<h3 class="text-lg font-bold text-white">Import Transaction History</h3>
<button onclick="closeImportCsvModal()" class="text-gray-500 hover:text-white text-xl leading-none transition"></button>
</div>
<div class="text-[11px] text-gray-500 mb-4 leading-relaxed">
Imports transaction history via CSV upload. The wallet is registered as <span class="text-gray-400 font-mono">IMPORT_ONLY</span> and the CSV is sent directly to the backend for processing.
</div>
<form id="import-csv-form" onsubmit="return handleImportCsv(event)">
<div class="mb-4">
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Wallet Address <span class="text-red-400">*</span></label>
<input id="import-address" type="text" placeholder="0x000..." 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-[#58a6ff]/50 transition font-mono" required>
</div>
<div class="mb-4">
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Wallet Nickname <span class="text-red-400">*</span></label>
<input id="import-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-[#58a6ff]/50 transition" required>
</div>
<div class="mb-4">
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Wallet Address</label>
<input id="import-address" type="text" placeholder="0x000..." class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-white text-sm placeholder-gray-600 transition font-mono">
</div>
<div class="mb-4">
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Color</label>
<div id="import-color-picker" class="flex flex-wrap gap-2"></div>
@ -235,13 +238,13 @@ window.WalletManager = WalletManager;
</div>
<div id="import-csv-error" class="hidden mb-3 text-red-400 text-xs font-medium"></div>
<div id="import-csv-progress" class="hidden mb-3">
<div class="text-[10px] font-bold text-gray-500 uppercase mb-1">Uploading...</div>
<div class="text-[10px] font-bold text-gray-500 uppercase mb-1">Processing...</div>
<div class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-full h-2 overflow-hidden">
<div class="import-progress-bar h-full bg-[#58a6ff] rounded-full animate-pulse"></div>
</div>
</div>
<button type="submit" id="import-csv-submit" class="w-full bg-[#3b82f6] hover:bg-[#3b82f6]/90 text-white font-bold px-4 py-3 rounded-lg transition">
<span id="import-submit-text">Import CSV</span>
<span id="import-submit-text">Import</span>
</button>
</form>
</div>
@ -300,6 +303,30 @@ window.WalletManager = WalletManager;
</div>
</div>
<!-- HYPE Summary Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
<div class="text-[10px] font-bold text-[#caeae5]/60 uppercase tracking-wider mb-1">Current Holdings</div>
<div class="text-xl font-bold text-white"><span id="hype-held-val">0.00</span> <span class="text-xs font-medium text-gray-500">HYPE</span></div>
<div class="text-xs font-bold text-green-400 mt-1" id="hype-usd-val">$Loading...</div>
</div>
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
<div class="text-[10px] font-bold text-[#caeae5]/60 uppercase tracking-wider mb-1">Total PnL</div>
<div class="text-xl font-bold" id="hype-pnl-val">$--.--</div>
<div class="text-xs font-bold mt-1" id="hype-pnl-percent-val">--.--%</div>
</div>
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
<div class="text-[10px] font-bold text-[#caeae5]/60 uppercase tracking-wider mb-1">Avg Buy Price</div>
<div class="text-xl font-bold text-white" id="hype-avg-buy-val">$--.--</div>
<div class="text-xs font-medium text-gray-500 mt-1">Cost Basis</div>
</div>
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
<div class="text-[10px] font-bold text-[#caeae5]/60 uppercase tracking-wider mb-1">Total Invested</div>
<div class="text-xl font-bold text-white" id="hype-total-invested-val">$--.--</div>
<div class="text-xs font-medium text-gray-500 mt-1"><span id="hype-tx-count-val">0</span> Transactions</div>
</div>
</div>
<!-- Charts Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="bg-[#090D14] rounded-2xl border border-[#1A1F2C]/60 p-6 relative shadow-2xl transition-all duration-300 hover:scale-[1.03] hover:border-[#FF7A00] hover:shadow-[#FF7A00]/10 overflow-hidden">
@ -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,7 +2320,7 @@ document.querySelectorAll('.filter-checkbox').forEach(cb => {
/* ===================================================================
Polling for Verified Wallets
=================================================================== */
async function pollVerifiedWallets() {
async function pollVerifiedWallets() {
const now = Date.now();
if (now - lastFetchMs < 30000) return;
lastFetchMs = now;
@ -2188,20 +2329,41 @@ async function pollVerifiedWallets() {
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`);
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;
const newestTs = events.reduce((m, e) => (e.block_timestamp > m ? e.block_timestamp : m), events[0].block_timestamp);
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(events);
addressSnapshots[key] = snapshotsToDaily(ledger);
walletSyncState[key] = 'synced';
anyUpdated = true;
}
} else {
addressSnapshots[key] = snapshotsToDaily(events);
addressSnapshots[key] = snapshotsToDaily(ledger);
walletSyncState[key] = 'synced';
anyUpdated = true;
}
@ -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;

View File

@ -26,7 +26,7 @@ const CHAIN_IDS = {
avalanche: 43114,
bsc: 56,
fantom: 250,
hyperliquide: 998,
hyperevm: 999,
};
/**

View File

@ -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})$/,
};
/* ------------------------------------------------------------------ */