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:
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
402
index.html
402
index.html
@ -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));
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -26,7 +26,7 @@ const CHAIN_IDS = {
|
||||
avalanche: 43114,
|
||||
bsc: 56,
|
||||
fantom: 250,
|
||||
hyperliquide: 998,
|
||||
hyperevm: 999,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -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})$/,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
Reference in New Issue
Block a user