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;
+ Imports transaction history via CSV upload. The wallet is registered as IMPORT_ONLY and the CSV is sent directly to the backend for processing.
+
@@ -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})$/,
};
/* ------------------------------------------------------------------ */