// wallet.js - Hyper Wallet Desktop Frontend
// Uses pear-pipe IPC to communicate with main process
// Wallet initialization state
let currentIdentityMnemonic = null
let currentWalletMnemonic = null
// Authentication token management
function getToken() {
return localStorage.getItem('hyper-wallet-token')
}
function saveToken(token) {
localStorage.setItem('hyper-wallet-token', token)
}
function clearToken() {
localStorage.removeItem('hyper-wallet-token')
}
// Authenticated fetch wrapper
async function authFetch(url, options = {}) {
const token = getToken()
// Add Authorization header if token exists
if (token) {
options.headers = {
...options.headers,
'Authorization': `Bearer ${token}`
}
}
try {
// Use IPC to communicate with main process (avoids CSP cross-origin issues)
const response = await window.ipcFetch(url, options)
// Handle 401 Unauthorized
if (response.status === 401) {
console.warn('⚠️ Unauthorized - clearing token')
clearToken()
// Don't show setup wizard here - let the init() flow handle password prompt
// Only throw error so the calling code knows authentication is needed
throw new Error('Authentication required')
}
return response
} catch (err) {
console.error('Fetch request failed:', err)
throw err
}
}
// Setup Wizard Functions
function showSetupWizard() {
document.getElementById('setup-wizard').style.display = 'flex'
document.getElementById('main-app').style.display = 'none'
showSetupWelcome()
}
function hideSetupWizard() {
document.getElementById('setup-wizard').style.display = 'none'
document.getElementById('main-app').style.display = 'block'
}
function showSetupWelcome() {
document.getElementById('setup-welcome').style.display = 'block'
document.getElementById('setup-create').style.display = 'none'
document.getElementById('setup-restore').style.display = 'none'
document.getElementById('setup-join').style.display = 'none'
}
function showCreateWallet() {
document.getElementById('setup-welcome').style.display = 'none'
document.getElementById('setup-create').style.display = 'block'
document.getElementById('setup-restore').style.display = 'none'
document.getElementById('setup-join').style.display = 'none'
// Reset create wallet screen
document.getElementById('mnemonic-display').style.display = 'none'
document.getElementById('btn-generate-mnemonic').style.display = 'inline-block'
document.getElementById('btn-confirm-mnemonic').style.display = 'none'
document.getElementById('mnemonic-confirm-check').checked = false
currentIdentityMnemonic = null
currentWalletMnemonic = null
}
function showRestoreWallet() {
document.getElementById('setup-welcome').style.display = 'none'
document.getElementById('setup-create').style.display = 'none'
document.getElementById('setup-restore').style.display = 'block'
document.getElementById('setup-join').style.display = 'none'
// Reset restore wallet screen
document.getElementById('restore-identity-mnemonic').value = ''
document.getElementById('restore-wallet-mnemonic').value = ''
document.getElementById('restore-error').textContent = ''
}
async function generateNewWallet() {
try {
// Validate password
const password = document.getElementById('create-password').value
const passwordConfirm = document.getElementById('create-password-confirm').value
const errorDiv = document.getElementById('create-password-error')
errorDiv.style.display = 'none'
if (!password || password.length < 8) {
errorDiv.textContent = 'Password must be at least 8 characters'
errorDiv.style.display = 'block'
return
}
if (password !== passwordConfirm) {
errorDiv.textContent = 'Passwords do not match'
errorDiv.style.display = 'block'
return
}
const btn = document.getElementById('btn-generate-mnemonic')
btn.disabled = true
btn.textContent = 'Generating...'
const res = await authFetch(`/api/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
})
const data = await res.json()
if (data.error) {
showError(data.error)
btn.disabled = false
btn.textContent = 'Generate Recovery Phrases'
return
}
// Save API token for authentication
if (data.token) {
saveToken(data.token)
console.log('✅ API token saved')
}
// Store mnemonics temporarily (already saved on server)
currentIdentityMnemonic = data.identityMnemonic
currentWalletMnemonic = data.walletMnemonic
// Display identity mnemonic (24 words)
const identityWords = data.identityMnemonic.split(' ')
const identityMnemonicWords = document.getElementById('identity-mnemonic-words')
identityMnemonicWords.innerHTML = identityWords.map((word, index) => `
<div class="mnemonic-word">
<span class="mnemonic-word-number">${index + 1}</span>
<span>${word}</span>
</div>
`).join('')
// Display wallet mnemonic (12 words)
const walletWords = data.walletMnemonic.split(' ')
const walletMnemonicWords = document.getElementById('wallet-mnemonic-words')
walletMnemonicWords.innerHTML = walletWords.map((word, index) => `
<div class="mnemonic-word">
<span class="mnemonic-word-number">${index + 1}</span>
<span>${word}</span>
</div>
`).join('')
// Show mnemonic display
document.getElementById('mnemonic-display').style.display = 'block'
document.getElementById('btn-generate-mnemonic').style.display = 'none'
document.getElementById('btn-confirm-mnemonic').style.display = 'inline-block'
console.log('Public key:', data.publicKey)
} catch (err) {
console.error('Generate wallet error:', err)
showError('Failed to generate wallet')
}
}
// Enable confirm button when checkbox is checked
document.addEventListener('DOMContentLoaded', () => {
const checkbox = document.getElementById('mnemonic-confirm-check')
const confirmBtn = document.getElementById('btn-confirm-mnemonic')
if (checkbox && confirmBtn) {
checkbox.addEventListener('change', (e) => {
confirmBtn.disabled = !e.target.checked
})
}
// Event delegation for copying contact addresses
document.addEventListener('click', (e) => {
if (e.target.classList.contains('copyable-address')) {
const address = e.target.getAttribute('data-address')
if (address) {
copyToClipboard(address, 'Address copied!')
}
}
})
})
function confirmMnemonicBackup() {
if (!currentIdentityMnemonic || !currentWalletMnemonic) {
showError('No mnemonics generated')
return
}
if (!document.getElementById('mnemonic-confirm-check').checked) {
showError('Please confirm you have backed up BOTH recovery phrases')
return
}
// System is already initialized on server, just transition to main app
hideSetupWizard()
init()
}
async function restoreWallet() {
const identityInput = document.getElementById('restore-identity-mnemonic')
const walletInput = document.getElementById('restore-wallet-mnemonic')
const password = document.getElementById('restore-password').value
const passwordConfirm = document.getElementById('restore-password-confirm').value
const errorDiv = document.getElementById('restore-error')
const identityMnemonic = identityInput.value.trim()
const walletMnemonic = walletInput.value.trim()
errorDiv.textContent = ''
if (!identityMnemonic || !walletMnemonic) {
errorDiv.textContent = 'Please enter both recovery phrases'
return
}
if (!password || password.length < 8) {
errorDiv.textContent = 'Password must be at least 8 characters'
return
}
if (password !== passwordConfirm) {
errorDiv.textContent = 'Passwords do not match'
return
}
try {
const res = await authFetch(`/api/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identityMnemonic,
walletMnemonic,
password
})
})
const data = await res.json()
if (data.error) {
errorDiv.textContent = data.error
return
}
// Save API token for authentication
if (data.token) {
saveToken(data.token)
console.log('✅ API token saved')
}
showSuccess('System restored successfully!')
setTimeout(() => {
hideSetupWizard()
init()
}, 1000)
} catch (err) {
console.error('Restore wallet error:', err)
errorDiv.textContent = 'Failed to restore system'
}
}
function showJoinWallet() {
document.getElementById('setup-welcome').style.display = 'none'
document.getElementById('setup-create').style.display = 'none'
document.getElementById('setup-restore').style.display = 'none'
document.getElementById('setup-join').style.display = 'block'
// Reset join wallet screen
document.getElementById('join-invite-code').value = ''
document.getElementById('join-device-name').value = ''
document.getElementById('join-error').textContent = ''
document.getElementById('join-status').style.display = 'none'
document.getElementById('btn-join-wallet').disabled = false
}
async function joinExistingWallet() {
const inviteCodeInput = document.getElementById('join-invite-code')
const deviceNameInput = document.getElementById('join-device-name')
const errorDiv = document.getElementById('join-error')
const statusDiv = document.getElementById('join-status')
const statusText = document.getElementById('join-status-text')
const joinBtn = document.getElementById('btn-join-wallet')
const inviteCode = inviteCodeInput.value.trim()
const deviceName = deviceNameInput.value.trim()
errorDiv.textContent = ''
if (!inviteCode) {
errorDiv.textContent = 'Please enter an invite code'
return
}
if (!deviceName) {
errorDiv.textContent = 'Please enter a device name'
return
}
try {
joinBtn.disabled = true
statusDiv.style.display = 'block'
statusText.textContent = 'Connecting to primary device...'
const res = await authFetch(`/api/device/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inviteCode,
deviceName
})
})
const data = await res.json()
if (data.error) {
errorDiv.textContent = data.error
statusDiv.style.display = 'none'
joinBtn.disabled = false
return
}
statusText.textContent = '✅ Successfully joined wallet!'
showSuccess('Wallet joined! Both identity and wallet keys synced.')
setTimeout(() => {
hideSetupWizard()
init()
}, 1500)
} catch (err) {
console.error('Join wallet error:', err)
errorDiv.textContent = 'Failed to join wallet: ' + err.message
statusDiv.style.display = 'none'
joinBtn.disabled = false
}
}
// Network state management
let selectedNetwork = localStorage.getItem('network') || 'testnet'
// Settings Functions
async function showSettings() {
const modal = document.getElementById('settings-modal')
modal.showModal()
// Load settings data
loadIdentityInfo()
loadSyncStatus()
await loadProviderSettings()
// Set current network
document.getElementById('default-network-select').value = selectedNetwork
}
async function loadProviderSettings() {
try {
const res = await authFetch(`/api/settings`)
const data = await res.json()
if (data.error) {
console.error('Error loading settings:', data.error)
return
}
const settings = data.settings
// Set Bitcoin provider
if (settings.blockchain.bitcoin.provider) {
document.getElementById('bitcoin-provider-select').value = settings.blockchain.bitcoin.provider
}
// Set Ethereum provider
if (settings.blockchain.ethereum.provider) {
document.getElementById('ethereum-provider-select').value = settings.blockchain.ethereum.provider
// Show/hide API key field based on provider
const needsApiKey = settings.blockchain.ethereum.provider !== 'blockscout'
const apiKeyRow = document.getElementById('ethereum-api-key-row')
if (needsApiKey) {
apiKeyRow.style.display = 'flex'
if (settings.blockchain.ethereum.apiKey) {
const masked = '***' + settings.blockchain.ethereum.apiKey.slice(-4)
document.getElementById('ethereum-api-key-input').placeholder = masked
}
} else {
apiKeyRow.style.display = 'none'
}
}
} catch (err) {
console.error('Error loading provider settings:', err)
}
}
async function changeBitcoinProvider() {
const provider = document.getElementById('bitcoin-provider-select').value
try {
const res = await authFetch(`/api/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: 'blockchain.bitcoin.provider',
value: provider
})
})
const data = await res.json()
if (data.error) {
showError(`Error: ${data.error}`)
return
}
showError(`Bitcoin provider set to ${provider}`)
} catch (err) {
showError(`Error: ${err.message}`)
}
}
async function changeEthereumProvider() {
const provider = document.getElementById('ethereum-provider-select').value
try {
const res = await authFetch(`/api/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: 'blockchain.ethereum.provider',
value: provider
})
})
const data = await res.json()
if (data.error) {
showError(`Error: ${data.error}`)
return
}
// Show/hide API key field based on provider
const apiKeyRow = document.getElementById('ethereum-api-key-row')
if (provider !== 'blockscout') {
apiKeyRow.style.display = 'flex'
} else {
apiKeyRow.style.display = 'none'
}
showError(`Ethereum provider set to ${provider}`)
} catch (err) {
showError(`Error: ${err.message}`)
}
}
async function saveEthereumApiKey() {
const apiKey = document.getElementById('ethereum-api-key-input').value.trim()
if (!apiKey) {
showError('Please enter an API key')
return
}
try {
const res = await authFetch(`/api/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: 'blockchain.ethereum.apiKey',
value: apiKey
})
})
const data = await res.json()
if (data.error) {
showError(`Error: ${data.error}`)
return
}
showError('Ethereum API key saved')
document.getElementById('ethereum-api-key-input').value = ''
document.getElementById('ethereum-api-key-input').placeholder = '***' + apiKey.slice(-4)
} catch (err) {
showError(`Error: ${err.message}`)
}
}
function closeSettings() {
document.getElementById('settings-modal').close()
}
async function loadIdentityInfo() {
try {
const res = await authFetch(`/api/whoami`)
const data = await res.json()
if (data.error) {
document.getElementById('identity-public-key').textContent = 'Not available'
document.getElementById('identity-device-name').textContent = 'Not available'
return
}
// Display public key (truncated)
const pubKey = data.publicKey || 'Unknown'
document.getElementById('identity-public-key').textContent =
pubKey.length > 20 ? pubKey.substring(0, 16) + '...' : pubKey
// Display device name (hostname)
document.getElementById('identity-device-name').textContent = data.hostname || 'Unknown'
// Update status badge
const statusBadge = document.getElementById('identity-status-badge')
statusBadge.textContent = 'Active'
statusBadge.className = 'status-badge'
} catch (err) {
console.error('Failed to load identity info:', err)
document.getElementById('identity-public-key').textContent = 'Error loading'
}
}
async function loadSyncStatus() {
try {
const res = await authFetch(`/api/sync-status`)
const data = await res.json()
if (data.error) {
document.getElementById('sync-status-badge').textContent = 'Offline'
document.getElementById('sync-status-badge').className = 'status-badge status-error'
document.getElementById('sync-device-count').textContent = '0'
document.getElementById('sync-last-time').textContent = 'Never'
return
}
// Update sync status
const statusBadge = document.getElementById('sync-status-badge')
if (data.syncing) {
statusBadge.textContent = 'Syncing'
statusBadge.className = 'status-badge status-syncing'
} else {
statusBadge.textContent = 'Synced'
statusBadge.className = 'status-badge'
}
// Get real device count from device list
const devicesRes = await authFetch(`/api/device/list`)
const devicesData = await devicesRes.json()
const deviceCount = devicesData.devices ? devicesData.devices.length : 1
document.getElementById('sync-device-count').textContent = deviceCount
// Update status based on device count
if (deviceCount > 1) {
statusBadge.textContent = 'Active'
statusBadge.className = 'status-badge'
} else {
statusBadge.textContent = 'Single Device'
statusBadge.className = 'status-badge status-warning'
}
document.getElementById('sync-last-time').textContent =
deviceCount > 1 ? new Date().toLocaleString() : 'Never'
} catch (err) {
console.error('Failed to load sync status:', err)
}
}
function changeDefaultNetwork() {
const select = document.getElementById('default-network-select')
setNetwork(select.value)
showSuccess('Default network changed to ' + select.value)
}
async function exportIdentity() {
if (!confirm('⚠️ WARNING: Your identity mnemonic will be displayed. Make sure no one can see your screen!\n\nContinue?')) {
return
}
// Use modal instead of prompt() which is blocked in Pear/Electron
const password = await promptForPasswordModal('Enter your encryption password to export identity mnemonic:')
if (!password) {
showError('Password required to export mnemonic')
return
}
try {
console.log('[Export] Requesting identity mnemonic...')
const res = await authFetch(`/api/export-identity`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
})
console.log('[Export] Response status:', res.status)
const data = await res.json()
console.log('[Export] Response data:', data.error ? `Error: ${data.error}` : 'Success')
if (data.error) {
showError(data.error)
return
}
// Show mnemonic in modal (better than alert)
if (data.mnemonic) {
showMnemonicDisplay(data.mnemonic, 'Identity Mnemonic (24 words)')
} else {
showError('No mnemonic returned from server')
console.error('[Export] No mnemonic in response:', data)
}
} catch (err) {
console.error('[Export] Error:', err)
showError('Failed to export identity mnemonic: ' + err.message)
}
}
async function exportWalletMnemonic() {
if (!confirm('⚠️ WARNING: Your wallet mnemonic controls your funds! Make sure no one can see your screen!\n\nContinue?')) {
return
}
// Use modal instead of prompt() which is blocked in Pear/Electron
const password = await promptForPasswordModal('Enter your encryption password to export wallet mnemonic:')
if (!password) {
showError('Password required to export mnemonic')
return
}
try {
console.log('[Export] Requesting wallet mnemonic...')
const res = await authFetch(`/api/export-wallet`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
})
console.log('[Export] Response status:', res.status)
const data = await res.json()
console.log('[Export] Response data:', data.error ? `Error: ${data.error}` : 'Success')
if (data.error) {
showError(data.error)
return
}
// Show mnemonic in modal (better than alert)
if (data.mnemonic) {
showMnemonicDisplay(data.mnemonic, 'Wallet Mnemonic (12 words)')
} else {
showError('No mnemonic returned from server')
console.error('[Export] No mnemonic in response:', data)
}
} catch (err) {
console.error('[Export] Error:', err)
showError('Failed to export wallet mnemonic: ' + err.message)
}
}
function showWhoAmI() {
// Run whoami and show in dialog
showError('Feature coming soon: Detailed identity information')
}
async function addDevice() {
try {
// Start device add process
const res = await authFetch(`/api/device/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
const data = await res.json()
if (data.error) {
showError(data.error)
return
}
// Show invite modal
const modal = document.getElementById('device-add-modal')
document.getElementById('device-invite-code').value = data.inviteCode
document.getElementById('device-pairing-status').textContent = '⏳ Waiting for device to connect...'
document.getElementById('device-pairing-status').className = 'pairing-status waiting'
document.getElementById('device-pairing-result').style.display = 'none'
modal.showModal()
// Poll for status
const sessionId = data.sessionId
const pollInterval = setInterval(async () => {
const statusRes = await authFetch(`/api/device/add/${sessionId}`)
const statusData = await statusRes.json()
if (statusData.status === 'completed') {
clearInterval(pollInterval)
document.getElementById('device-pairing-status').textContent = `✅ Device added: ${statusData.deviceName}`
document.getElementById('device-pairing-status').className = 'pairing-status success'
document.getElementById('device-pairing-result').style.display = 'block'
document.getElementById('device-pairing-result').innerHTML = `
<p><strong>Device Name:</strong> ${statusData.deviceName}</p>
<p><strong>Writer Key:</strong> ${statusData.writerKey.slice(0, 16)}...</p>
<p>The new device is now part of your wallet and can participate in P2P sync.</p>
`
// Refresh devices list if open
const devicesModal = document.getElementById('devices-modal')
if (devicesModal.open) {
loadDevicesList()
}
} else if (statusData.status === 'error') {
clearInterval(pollInterval)
document.getElementById('device-pairing-status').textContent = `❌ Error: ${statusData.error}`
document.getElementById('device-pairing-status').className = 'pairing-status error'
}
}, 2000) // Poll every 2 seconds
// Store interval ID for cleanup
modal.dataset.pollInterval = pollInterval
} catch (err) {
showError(err.message)
}
}
function closeDeviceAdd() {
const modal = document.getElementById('device-add-modal')
// Clear polling interval
if (modal.dataset.pollInterval) {
clearInterval(parseInt(modal.dataset.pollInterval))
delete modal.dataset.pollInterval
}
modal.close()
}
function copyInviteCode() {
const input = document.getElementById('device-invite-code')
input.select()
document.execCommand('copy')
showError('Invite code copied to clipboard!')
}
async function showDevices() {
try {
const modal = document.getElementById('devices-modal')
modal.showModal()
await loadDevicesList()
} catch (err) {
showError(err.message)
}
}
async function loadDevicesList() {
try {
const res = await authFetch(`/api/device/list`)
const data = await res.json()
if (data.error) {
showError(data.error)
return
}
const container = document.getElementById('devices-list')
if (data.devices.length === 0) {
container.innerHTML = '<p class="empty-state">No devices found. Add a device to enable multi-device sync.</p>'
return
}
container.innerHTML = data.devices.map(device => `
<div class="device-item ${device.isCurrentDevice ? 'current-device' : ''}">
<div class="device-info">
<div class="device-name">
${device.name}
${device.isCurrentDevice ? '<span class="badge">Current Device</span>' : ''}
</div>
<div class="device-keys">
<div class="key-row">
<span class="key-label">Identity Key:</span>
<span class="key-value" onclick="copyToClipboard('${device.identityKey}', 'Identity key copied!')" title="Click to copy full key">
${device.identityKeyShort || 'N/A'}
</span>
</div>
<div class="key-row">
<span class="key-label">Writer Key:</span>
<span class="key-value" onclick="copyToClipboard('${device.writerKey}', 'Writer key copied!')" title="Click to copy full key">
${device.writerKeyShort}
</span>
</div>
</div>
<div class="device-added">Added: ${new Date(device.addedAt).toLocaleString()}</div>
</div>
<div class="device-actions">
${!device.isCurrentDevice ? `
<button class="btn-danger btn-small" onclick="removeDeviceConfirm('${device.writerKey}', '${device.name}')">
Remove
</button>
` : ''}
</div>
</div>
`).join('')
} catch (err) {
showError(err.message)
}
}
async function removeDeviceConfirm(deviceKey, deviceName) {
const confirmed = confirm(
`⚠️ Remove Device: ${deviceName}?\n\n` +
`This will revoke this device's access to the wallet.\n` +
`The device will no longer be able to:\n` +
`- Create new transactions\n` +
`- Participate in multi-device sync\n\n` +
`Continue?`
)
if (!confirmed) return
try {
const res = await authFetch(`/api/device/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceKey })
})
const data = await res.json()
if (data.error) {
showError(data.error)
return
}
showError('Device removed successfully')
await loadDevicesList()
} catch (err) {
showError(err.message)
}
}
function closeDevices() {
document.getElementById('devices-modal').close()
}
function copyToClipboard(text, message = 'Copied!') {
// Create temporary textarea
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
showError(message)
} catch (err) {
showError('Failed to copy to clipboard')
}
document.body.removeChild(textarea)
}
async function deduplicateDevices() {
const confirmed = confirm(
'Remove duplicate device entries?\n\n' +
'This will keep only the most recent entry for each unique device.\n\n' +
'Continue?'
)
if (!confirmed) return
try {
const res = await authFetch(`/api/device/deduplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
const data = await res.json()
if (data.error) {
showError(data.error)
return
}
showError(`Removed ${data.removed} duplicate(s)`)
await loadDevicesList()
} catch (err) {
showError(err.message)
}
}
// ============================================================================
// CONTACT MANAGEMENT
// ============================================================================
let currentContacts = []
let currentEditContactId = null
async function showContacts() {
const modal = document.getElementById('contacts-modal')
modal.showModal()
await loadContactsList()
}
function closeContacts() {
document.getElementById('contacts-modal').close()
}
async function loadContactsList() {
const contactsList = document.getElementById('contacts-list')
contactsList.innerHTML = '<div class="spinner"></div>'
try {
const res = await authFetch(`/api/contacts`)
const data = await res.json()
if (data.error) {
contactsList.innerHTML = `<div class="error-message">${data.error}</div>`
return
}
currentContacts = data.contacts || []
if (currentContacts.length === 0) {
contactsList.innerHTML = `
<div class="empty-state">
<p>📇 No contacts yet</p>
<p class="empty-hint">Add contacts to send transactions by name instead of typing addresses</p>
<button class="btn-primary" onclick="showAddContact()">Add Your First Contact</button>
</div>
`
return
}
// Sort contacts alphabetically
currentContacts.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
let html = '<div class="contacts-grid">'
for (const contact of currentContacts) {
const favIcon = contact.metadata?.favorite ? '⭐' : ''
// Support both old format (single address) and new format (multiple addresses)
let addresses = []
if (contact.addresses && Array.isArray(contact.addresses)) {
// NEW FORMAT: Multiple addresses
addresses = contact.addresses
} else if (contact.chain && contact.address) {
// OLD FORMAT: Single address - convert to array
addresses = [{
chain: contact.chain,
address: contact.address,
network: contact.network || selectedNetwork
}]
}
html += `
<div class="contact-card">
<div class="contact-header">
<span class="contact-name">${favIcon} ${escapeHtml(contact.name)}</span>
<div class="contact-actions-inline">
<button class="btn-icon-small" onclick="editContact('${contact.id}')" title="Edit">✏️</button>
<button class="btn-icon-small" onclick="deleteContactConfirm('${contact.id}', '${escapeHtml(contact.name)}')" title="Delete">🗑️</button>
</div>
</div>
`
// Show all addresses
if (addresses.length > 0) {
html += '<div class="contact-addresses">'
for (const addr of addresses) {
const shortAddr = addr.address.substring(0, 16) + '...'
// Get chain info from SUPPORTED_CHAINS
const chainInfo = SUPPORTED_CHAINS[addr.chain] || { icon: '🔗', name: addr.chain }
const chainIcon = chainInfo.icon
const chainName = chainInfo.name
// Build display label: "Chain Name" or "Chain Name - Label"
const displayLabel = addr.label ? `${chainName} - ${addr.label}` : chainName
html += `
<div class="contact-address-row">
<div class="contact-address-info">
<span class="chain-icon" title="${displayLabel}">${chainIcon}</span>
<code class="contact-address copyable-address" data-address="${escapeHtml(addr.address)}" title="Click to copy full address">${shortAddr}</code>
<span class="chain-badge chain-badge-${addr.chain}">${displayLabel}</span>
</div>
<div class="contact-address-actions">
<button class="btn-icon-small" onclick="sendToContactAddress('${contact.id}', '${addr.id}')" title="Send to ${displayLabel}">💸</button>
<button class="btn-icon-small btn-danger" onclick="removeContactAddress('${contact.id}', '${addr.id}', '${escapeHtml(contact.name)}', '${escapeHtml(displayLabel)}')" title="Remove ${displayLabel} address">🗑️</button>
</div>
</div>
`
}
html += '</div>'
} else {
html += '<p class="contact-error">⚠️ No addresses configured</p>'
}
// Add Wallet button
html += `
<div class="contact-footer">
<button class="btn-link" onclick="showAddWallet('${contact.id}', '${escapeHtml(contact.name)}')">+ Add Wallet</button>
</div>
`
if (contact.notes) {
html += `<p class="contact-notes">${escapeHtml(contact.notes)}</p>`
}
html += '</div>'
}
html += '</div>'
contactsList.innerHTML = html
} catch (err) {
console.error('Load contacts error:', err)
contactsList.innerHTML = `<div class="error-message">Error loading contacts: ${err.message}</div>`
}
}
function showAddContact() {
const modal = document.getElementById('add-contact-modal')
modal.showModal()
// Reset form
document.getElementById('add-contact-form').reset()
document.getElementById('add-contact-error').style.display = 'none'
}
function closeAddContact() {
document.getElementById('add-contact-modal').close()
}
async function submitAddContact(event) {
event.preventDefault()
const name = document.getElementById('contact-name').value
const chain = document.getElementById('contact-chain').value
const address = document.getElementById('contact-address').value.trim()
const notes = document.getElementById('contact-notes').value.trim()
const errorDiv = document.getElementById('add-contact-error')
errorDiv.style.display = 'none'
try {
const res = await authFetch(`/api/contacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, chain, address, notes })
})
const data = await res.json()
if (data.error) {
errorDiv.textContent = data.error
errorDiv.style.display = 'block'
return
}
showError(`Contact "${name}" added successfully!`)
closeAddContact()
await loadContactsList()
} catch (err) {
errorDiv.textContent = `Error: ${err.message}`
errorDiv.style.display = 'block'
}
}
async function editContact(contactId) {
const contact = currentContacts.find(c => c.id === contactId)
if (!contact) {
showError('Contact not found')
return
}
currentEditContactId = contactId
// Populate form
document.getElementById('edit-contact-id').value = contact.id
document.getElementById('edit-contact-name').value = contact.name
document.getElementById('edit-contact-chain').value = contact.chain
document.getElementById('edit-contact-address').value = contact.address
document.getElementById('edit-contact-notes').value = contact.notes || ''
const modal = document.getElementById('edit-contact-modal')
modal.showModal()
document.getElementById('edit-contact-error').style.display = 'none'
}
function closeEditContact() {
document.getElementById('edit-contact-modal').close()
currentEditContactId = null
}
async function submitEditContact(event) {
event.preventDefault()
const id = currentEditContactId
const name = document.getElementById('edit-contact-name').value
const notes = document.getElementById('edit-contact-notes').value.trim()
const errorDiv = document.getElementById('edit-contact-error')
errorDiv.style.display = 'none'
try {
const res = await authFetch(`/api/contacts/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, notes })
})
const data = await res.json()
if (data.error) {
errorDiv.textContent = data.error
errorDiv.style.display = 'block'
return
}
showError(`Contact "${name}" updated successfully!`)
closeEditContact()
await loadContactsList()
} catch (err) {
errorDiv.textContent = `Error: ${err.message}`
errorDiv.style.display = 'block'
}
}
async function deleteContactConfirm(contactId, contactName) {
const confirmed = confirm(
`Delete contact "${contactName}"?\n\n` +
`This will remove the contact from your address book.\n\n` +
`Continue?`
)
if (!confirmed) return
try {
const res = await authFetch(`/api/contacts/${contactId}`, {
method: 'DELETE'
})
const data = await res.json()
if (data.error) {
showError(data.error)
return
}
showError(`Contact "${contactName}" deleted`)
await loadContactsList()
} catch (err) {
showError(`Error: ${err.message}`)
}
}
// Show add wallet modal for contact
function showAddWallet(contactId, contactName) {
currentEditContactId = contactId
document.getElementById('add-wallet-contact-name').textContent = contactName
document.getElementById('add-wallet-chain').value = 'bitcoin'
document.getElementById('add-wallet-address').value = ''
document.getElementById('add-wallet-error').style.display = 'none'
const modal = document.getElementById('add-wallet-modal')
modal.showModal()
}
// Close add wallet modal
function closeAddWallet() {
document.getElementById('add-wallet-modal').close()
currentEditContactId = null
}
// Submit add wallet
async function submitAddWallet(event) {
event.preventDefault()
const contactId = currentEditContactId
const chain = document.getElementById('add-wallet-chain').value
const address = document.getElementById('add-wallet-address').value.trim()
const label = document.getElementById('add-wallet-label').value.trim()
const errorDiv = document.getElementById('add-wallet-error')
errorDiv.style.display = 'none'
try {
const res = await authFetch(`/api/contacts/${contactId}/addresses`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chain, address, network: selectedNetwork, label })
})
const data = await res.json()
if (data.error) {
errorDiv.textContent = data.error
errorDiv.style.display = 'block'
return
}
const chainName = SUPPORTED_CHAINS[chain]?.name || chain.toUpperCase()
showSuccess(`${chainName} wallet added!`)
closeAddWallet()
await loadContactsList()
} catch (err) {
errorDiv.textContent = `Error: ${err.message}`
errorDiv.style.display = 'block'
}
}
// Remove address from contact
async function removeContactAddress(contactId, addressId, contactName, chainLabel) {
const confirmed = confirm(
`Remove ${chainLabel} wallet from "${contactName}"?\n\n` +
`This will remove this wallet address from the contact.\n\n` +
`Continue?`
)
if (!confirmed) return
try {
const res = await authFetch(`/api/contacts/${contactId}/addresses/${addressId}`, {
method: 'DELETE'
})
const data = await res.json()
if (data.error) {
showError(data.error)
return
}
showSuccess(`${chainLabel} wallet removed`)
await loadContactsList()
} catch (err) {
showError(`Error: ${err.message}`)
}
}
// Send to specific address of a contact
async function sendToContactAddress(contactId, addressId) {
const contact = currentContacts.find(c => c.id === contactId)
if (!contact) {
showError('Contact not found')
return
}
// Find the specific address by ID
let addrInfo = null
let address = null
let chain = null
if (contact.addresses && Array.isArray(contact.addresses)) {
// NEW FORMAT: Multiple addresses with IDs
addrInfo = contact.addresses.find(a => a.id === addressId)
if (addrInfo) {
address = addrInfo.address
chain = addrInfo.chain
}
}
// Fallback for old format (single address, no ID)
if (!address && contact.address && contact.chain) {
address = contact.address
chain = contact.chain
}
if (!address || !chain) {
showError(`Address not found for ${contact.name}`)
return
}
// Close contacts modal
closeContacts()
// Set chain and asset to match contact address
// Handle token chains like 'ethereum-usdt' -> chain='ethereum', asset='usdt'
if (chain.includes('-')) {
const parts = chain.split('-')
selectedChain = parts[0]
selectedAsset = parts[1]
} else {
selectedChain = chain
selectedAsset = null
}
localStorage.setItem('chain', selectedChain)
localStorage.setItem('asset', selectedAsset || '')
selectedCurrency = getSelectedCurrency()
document.getElementById('currency-selector').value = selectedChain
// Update wallet view
updateWalletView()
// Show send form
await showSend(selectedCurrency)
// Pre-fill address
setTimeout(() => {
const addressInput = document.getElementById('send-to')
if (addressInput) {
addressInput.value = address
}
}, 100)
}
// Legacy function - send to first available address
async function sendToContact(contactId) {
const contact = currentContacts.find(c => c.id === contactId)
if (!contact) {
showError('Contact not found')
return
}
// Get first available address ID
let addressId = null
if (contact.addresses && Array.isArray(contact.addresses) && contact.addresses.length > 0) {
addressId = contact.addresses[0].id
} else {
// Old format: no ID, pass null and let sendToContactAddress handle it
addressId = null
}
if (!addressId && !contact.address) {
showError('Contact has no addresses')
return
}
// Use the address-specific function
await sendToContactAddress(contactId, addressId)
}
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
function joinDevice() {
const modal = document.getElementById('device-join-modal')
document.getElementById('join-invite-code').value = ''
document.getElementById('join-device-name').value = os.hostname() || 'New Device'
document.getElementById('join-error').textContent = ''
modal.showModal()
}
function closeDeviceJoin() {
document.getElementById('device-join-modal').close()
}
async function executeDeviceJoin() {
const inviteCode = document.getElementById('join-invite-code').value.trim()
const deviceName = document.getElementById('join-device-name').value.trim()
const errorDiv = document.getElementById('join-error')
if (!inviteCode) {
errorDiv.textContent = 'Please enter an invite code'
return
}
if (!deviceName) {
errorDiv.textContent = 'Please enter a device name'
return
}
try {
errorDiv.textContent = '⏳ Connecting to primary device...'
const res = await authFetch(`/api/device/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode, deviceName })
})
const data = await res.json()
if (data.error) {
errorDiv.textContent = `❌ Error: ${data.error}`
return
}
// Success! Close modal and reload page
alert(
'✅ Successfully joined wallet!\n\n' +
`Device: ${data.device.name}\n` +
`Writer Key: ${data.device.writerKey}\n\n` +
'Both mnemonics have been saved securely.\n' +
'The page will now reload.'
)
closeDeviceJoin()
window.location.reload()
} catch (err) {
errorDiv.textContent = `❌ Error: ${err.message}`
}
}
async function forceSyncNow() {
// Trigger the sync button's functionality
await syncTransactions()
}
function verifyBackup() {
showError('Feature coming soon: Backup verification quiz')
}
async function resetWallet() {
const confirmed = confirm(
'⚠️ DANGER: This will DELETE ALL local wallet data!\n\n' +
'Make sure you have backed up both:\n' +
'- Identity mnemonic (24 words)\n' +
'- Wallet mnemonic (12 words)\n\n' +
'This action CANNOT be undone!\n\n' +
'Type "DELETE" to confirm:'
)
if (!confirmed) return
const verification = prompt('Type DELETE to confirm:')
if (verification !== 'DELETE') {
showError('Reset cancelled')
return
}
try {
const res = await authFetch(`/api/reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
const data = await res.json()
if (data.error) {
showError(data.error)
return
}
showSuccess('Wallet reset complete. Reloading...')
setTimeout(() => {
window.location.reload()
}, 2000)
} catch (err) {
showError('Failed to reset wallet')
}
}
// Supported chains configuration (extensible for future chains)
// Only chains with registered WDK wallets are included
const SUPPORTED_CHAINS = {
bitcoin: {
name: 'Bitcoin',
icon: '₿',
testnetName: 'Testnet',
mainnetName: 'Mainnet',
formatBalance: (balance) => {
const btc = Number(balance) / 100000000
return `${btc.toFixed(8)} BTC`
},
formatSmallUnit: (balance) => {
return `${Number(balance).toLocaleString()} sats`
},
priceId: 'bitcoin', // CoinGecko ID
addressPattern: {
testnet: /^(tb1|[mn])[a-zA-HJ-NP-Z0-9]{25,62}$/,
mainnet: /^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,62}$/
},
feeReserve: 500,
tokens: null
},
ethereum: {
name: 'Ethereum',
icon: 'Ξ',
testnetName: 'Sepolia',
mainnetName: 'Mainnet',
formatBalance: (balance) => {
const eth = Number(balance) / 1e18
return `${eth.toFixed(6)} ETH`
},
formatSmallUnit: (balance) => {
return `${Number(balance).toLocaleString()} wei`
},
priceId: 'ethereum', // CoinGecko ID
addressPattern: {
testnet: /^0x[a-fA-F0-9]{40}$/,
mainnet: /^0x[a-fA-F0-9]{40}$/
},
feeReserve: 100000,
tokens: {
usdt: {
symbol: 'USDT',
name: 'Tether USD',
icon: '₮',
decimals: 6,
priceId: 'tether',
formatBalance: (balance) => `${(Number(balance) / 1e6).toFixed(2)} USDT`
},
usdc: {
symbol: 'USDC',
name: 'USD Coin',
icon: '$',
decimals: 6,
priceId: 'usd-coin',
formatBalance: (balance) => `${(Number(balance) / 1e6).toFixed(2)} USDC`
}
}
},
// Backwards compatibility: Keep flat entries for existing code paths
'ethereum-usdt': {
name: 'Ethereum - USDT',
icon: '₮',
testnetName: 'Sepolia',
mainnetName: 'Mainnet',
decimals: 6, // USDT has 6 decimals
formatBalance: (balance) => {
const usdt = Number(balance) / 1e6 // USDT has 6 decimals
return `${usdt.toFixed(2)} USDT`
},
formatSmallUnit: (balance) => {
return `${Number(balance).toLocaleString()} units`
},
priceId: 'tether', // CoinGecko ID for USDT
addressPattern: {
testnet: /^0x[a-fA-F0-9]{40}$/, // Same as Ethereum (uses ETH address)
mainnet: /^0x[a-fA-F0-9]{40}$/
},
feeReserve: 0, // No fee reserve needed (fees paid in ETH)
isToken: true,
baseChain: 'ethereum'
},
'ethereum-usdc': {
name: 'Ethereum - USDC',
icon: '$',
testnetName: 'Sepolia',
mainnetName: 'Mainnet',
formatBalance: (balance) => {
const usdc = Number(balance) / 1e6 // USDC has 6 decimals
return `${usdc.toFixed(2)} USDC`
},
formatSmallUnit: (balance) => {
return `${Number(balance).toLocaleString()} units`
},
priceId: 'usd-coin', // CoinGecko ID for USDC
addressPattern: {
testnet: /^0x[a-fA-F0-9]{40}$/, // Same as Ethereum (uses ETH address)
mainnet: /^0x[a-fA-F0-9]{40}$/
},
feeReserve: 0, // No fee reserve needed (fees paid in ETH)
isToken: true,
baseChain: 'ethereum'
}
}
// Chain and asset state management
let selectedChain = localStorage.getItem('chain') || 'bitcoin'
let selectedAsset = localStorage.getItem('asset') || null // null = native, 'usdt' = token
// Backwards compatibility: selectedCurrency derived from selectedChain + selectedAsset
function getSelectedCurrency() {
if (selectedAsset && selectedChain === 'ethereum') {
return `ethereum-${selectedAsset}`
}
return selectedChain
}
// Legacy support: set selectedCurrency getter
let selectedCurrency = getSelectedCurrency()
// Price cache (keyed by CoinGecko ID)
let prices = {}
let pricesLoaded = false
// Transaction cache for details modal
let allTransactions = []
// Send transaction state
let currentSendChain = ''
let currentBalance = 0
let estimatedFee = null
let usedMaxAmount = false // Track if user clicked Max button
// Set network and refresh data
function setNetwork(network) {
selectedNetwork = network
localStorage.setItem('network', network)
// Update UI buttons
document.querySelectorAll('.network-btn').forEach(btn => {
btn.classList.remove('active')
if (btn.dataset.network === network) {
btn.classList.add('active')
}
})
// Update network labels
updateNetworkLabels()
// Refresh all data
refreshAll()
}
// Update network labels
function updateNetworkLabels() {
document.getElementById('network-name').textContent = selectedNetwork.toUpperCase()
updateWalletView()
}
// Switch currency (chain selector)
function switchCurrency() {
const selector = document.getElementById('currency-selector')
selectedChain = selector.value
selectedAsset = null // Reset asset when switching chains
localStorage.setItem('chain', selectedChain)
localStorage.setItem('asset', '')
// Update selectedCurrency for backwards compatibility
selectedCurrency = getSelectedCurrency()
updateWalletView()
// Use unified balance display for Ethereum, standard for others
if (selectedChain === 'ethereum') {
loadEthereumBalances()
} else {
loadCurrentBalance()
}
loadCurrentQRCode()
loadTransactions()
}
// Update wallet view for selected chain
function updateWalletView() {
const chain = SUPPORTED_CHAINS[selectedChain]
if (chain) {
const networkName = selectedNetwork === 'testnet' ? chain.testnetName : chain.mainnetName
document.getElementById('wallet-icon').textContent = chain.icon
document.getElementById('wallet-name').textContent = chain.name
document.getElementById('wallet-network').textContent = networkName
}
// Load wallet addresses for selector
loadWalletAddressSelector()
}
// Load wallet addresses into the selector dropdown
// Counter to prevent onchange cascade when programmatically updating selector
// Using a counter instead of boolean to handle concurrent/nested calls
let addressSelectorUpdateCount = 0
async function loadWalletAddressSelector() {
const select = document.getElementById('wallet-address-select')
// Increment counter to prevent onchange from firing during programmatic update
addressSelectorUpdateCount++
const myUpdateId = addressSelectorUpdateCount
select.innerHTML = '<option value="">Loading...</option>'
try {
// For tokens (ethereum-usdt, ethereum-usdc), use base chain for addresses
const addressChain = selectedChain // Always use base chain, not token-specific
const res = await authFetch(`/api/addresses/${selectedNetwork}-${addressChain}`)
const data = await res.json()
// Check if we're still the latest update (another call may have started)
if (myUpdateId !== addressSelectorUpdateCount) return
if (data.error) {
select.innerHTML = '<option value="">Error loading addresses</option>'
return
}
if (data.addresses.length === 0) {
select.innerHTML = '<option value="">No addresses yet</option>'
return
}
// Build options
let html = ''
for (const addr of data.addresses) {
const shortAddr = addr.address.substring(0, 12) + '...'
const label = addr.label || `Address ${addr.index}`
const displayText = `${label} - ${shortAddr}`
const currentMarker = addr.isCurrent ? ' ⭐' : ''
html += `<option value="${addr.index}" ${addr.isCurrent ? 'selected' : ''}>${displayText}${currentMarker}</option>`
}
// Store current value before update to detect programmatic vs user changes
const previousValue = select.value
select.innerHTML = html
// If the selected value changed due to our update (not user), mark as programmatic
if (select.value !== previousValue) {
select.dataset.programmaticChange = 'true'
}
} catch (err) {
console.error('Load address selector error:', err)
select.innerHTML = '<option value="">Failed to load</option>'
}
}
// Handle wallet address selector change
async function onWalletAddressChange() {
const select = document.getElementById('wallet-address-select')
// Ignore programmatic updates to prevent cascade
if (select.dataset.programmaticChange === 'true') {
delete select.dataset.programmaticChange
return
}
const selectedIndex = parseInt(select.value)
if (isNaN(selectedIndex)) return
try {
// For tokens, use base chain for address management
const addressChain = selectedChain
const res = await authFetch(`/api/addresses/${selectedNetwork}-${addressChain}/${selectedIndex}/current`, {
method: 'POST'
})
const data = await res.json()
if (data.error) {
showError(data.error)
return
}
showSuccess('Wallet address changed!')
// Update star markers in the existing options (no reload needed)
Array.from(select.options).forEach(option => {
const text = option.textContent.replace(' ⭐', '')
option.textContent = option.value === String(selectedIndex) ? text + ' ⭐' : text
})
// Reload balance - use unified view for Ethereum, standard for others
if (selectedChain === 'ethereum') {
await loadEthereumBalances()
} else {
await loadCurrentBalance()
}
await loadCurrentQRCode()
await loadTransactions()
// NOTE: Don't reload selector here - causes cascade. Star markers updated above.
} catch (err) {
showError(`Failed to change address: ${err.message}`)
}
}
// Load balance for current currency
async function loadCurrentBalance() {
const element = document.getElementById('wallet-balance-display')
element.innerHTML = '<div class="spinner"></div>'
try {
// Get currently selected address index
const select = document.getElementById('wallet-address-select')
const rawIndex = select ? parseInt(select.value) : 0
const accountIndex = isNaN(rawIndex) ? 0 : rawIndex
const res = await authFetch(`/api/balance/${selectedNetwork}-${selectedCurrency}?accountIndex=${accountIndex}`)
const data = await res.json()
if (data.error) {
element.innerHTML = '<span class="error">Error loading balance</span>'
return
}
const chain = SUPPORTED_CHAINS[selectedCurrency]
if (!chain) {
element.innerHTML = '<span class="error">Unsupported chain</span>'
return
}
const mainAmount = chain.formatBalance(data.balance)
const subAmount = chain.formatSmallUnit(data.balance)
let usdHTML = ''
// Calculate USD value (only for mainnet and if price is available)
if (selectedNetwork === 'mainnet' && pricesLoaded) {
const price = prices[chain.priceId]
if (price && price > 0) {
// Convert to base unit (varies by chain - respect token decimals)
let divisor
if (selectedCurrency === 'bitcoin') {
divisor = 100000000 // 8 decimals
} else if (chain.decimals) {
divisor = Math.pow(10, chain.decimals) // Use chain-specific decimals (e.g., 6 for USDT)
} else {
divisor = 1e18 // Default to 18 for ETH
}
const baseAmount = Number(data.balance) / divisor
const usdValue = baseAmount * price
usdHTML = `<div class="balance-usd">${formatUSD(usdValue)}</div>`
}
} else if (selectedNetwork === 'testnet') {
usdHTML = `<div class="balance-usd balance-usd-testnet">Testnet (no value)</div>`
}
element.innerHTML = `
<div class="balance-main">${mainAmount}</div>
<div class="balance-sub">${subAmount}</div>
${usdHTML}
`
} catch (err) {
console.error('Balance error:', err)
element.innerHTML = '<span class="error">Failed to load balance</span>'
}
}
// Load unified Ethereum balances (ETH + USDT + USDC)
async function loadEthereumBalances() {
console.log('loadEthereumBalances called, selectedChain:', selectedChain, 'selectedNetwork:', selectedNetwork)
const container = document.getElementById('wallet-balance-display')
// Show loading with asset placeholders
container.innerHTML = `
<div class="unified-balance">
<div class="total-value">
<span class="total-usd">Loading...</span>
<span class="total-label">Total Balance</span>
</div>
<div class="asset-list">
<div class="asset-row"><span class="asset-icon">Ξ</span><span class="asset-name">ETH</span><span class="asset-balance">...</span></div>
<div class="asset-row"><span class="asset-icon">₮</span><span class="asset-name">USDT</span><span class="asset-balance">...</span></div>
<div class="asset-row"><span class="asset-icon">$</span><span class="asset-name">USDC</span><span class="asset-balance">...</span></div>
</div>
</div>
`
try {
const res = await authFetch(`/api/balances/ethereum`)
const data = await res.json()
if (data.error) {
container.innerHTML = `<span class="error">${data.error}</span>`
return
}
const { balances } = data
// Handle missing balances gracefully
const ethBalance = balances?.eth?.balance || '0'
const usdtBalance = balances?.usdt?.balance || '0'
const usdcBalance = balances?.usdc?.balance || '0'
const ethAmount = Number(ethBalance) / 1e18
const usdtAmount = Number(usdtBalance) / 1e6
const usdcAmount = Number(usdcBalance) / 1e6
// Use testnet-appropriate USD values (show 0 for testnet)
const isTestnet = selectedNetwork === 'testnet'
const ethUsd = isTestnet ? 0 : ethAmount * (prices.ethereum || 0)
const usdtUsd = isTestnet ? 0 : usdtAmount * (prices.tether || 1)
const usdcUsd = isTestnet ? 0 : usdcAmount * (prices['usd-coin'] || 1)
const totalUsd = ethUsd + usdtUsd + usdcUsd
const totalDisplay = isTestnet ? 'Testnet' : formatUSD(totalUsd)
container.innerHTML = `
<div class="unified-balance">
<div class="total-value">
<span class="total-usd">${totalDisplay}</span>
<span class="total-label">${isTestnet ? 'Sepolia Testnet' : 'Total Balance'}</span>
</div>
<div class="asset-list">
<div class="asset-row ${selectedAsset === null ? 'selected' : ''}"
onclick="selectAsset(null)">
<span class="asset-icon">Ξ</span>
<span class="asset-name">ETH</span>
<span class="asset-balance">${ethAmount.toFixed(6)}</span>
<span class="asset-actions">
<button class="btn-small" onclick="event.stopPropagation(); showSend('ethereum')" ${ethAmount <= 0 ? 'disabled' : ''}>Send</button>
</span>
</div>
<div class="asset-row ${selectedAsset === 'usdt' ? 'selected' : ''}"
onclick="selectAsset('usdt')">
<span class="asset-icon">₮</span>
<span class="asset-name">USDT</span>
<span class="asset-balance">${usdtAmount.toFixed(2)}</span>
<span class="asset-actions">
<button class="btn-small" onclick="event.stopPropagation(); showSend('ethereum-usdt')" ${usdtAmount <= 0 ? 'disabled' : ''}>Send</button>
</span>
</div>
<div class="asset-row ${selectedAsset === 'usdc' ? 'selected' : ''}"
onclick="selectAsset('usdc')">
<span class="asset-icon">$</span>
<span class="asset-name">USDC</span>
<span class="asset-balance">${usdcAmount.toFixed(2)}</span>
<span class="asset-actions">
<button class="btn-small" onclick="event.stopPropagation(); showSend('ethereum-usdc')" ${usdcAmount <= 0 ? 'disabled' : ''}>Send</button>
</span>
</div>
</div>
${ethAmount < 0.001 ? `
<div class="gas-warning">
⚠️ Low ETH - needed for gas fees to send tokens
</div>
` : ''}
</div>
`
} catch (err) {
console.error('Load balances error:', err)
container.innerHTML = `
<div class="unified-balance">
<div class="error">Failed to load balances</div>
<button onclick="loadEthereumBalances()" class="btn-secondary">Retry</button>
</div>
`
}
}
// Select asset for Ethereum chain (null = ETH, 'usdt' = USDT, 'usdc' = USDC)
// NOTE: All Ethereum tokens share the same address - only visual selection changes
function selectAsset(asset, evt) {
selectedAsset = asset
localStorage.setItem('asset', asset || '')
// Update selectedCurrency for backwards compatibility
selectedCurrency = getSelectedCurrency()
// Update visual selection
document.querySelectorAll('.asset-row').forEach((row, index) => {
row.classList.remove('selected')
// ETH is index 0, USDT is index 1, USDC is index 2
const rowAsset = index === 0 ? null : index === 1 ? 'usdt' : 'usdc'
if (rowAsset === asset) {
row.classList.add('selected')
}
})
// For tokens (USDT/USDC), don't reload QR/address - they share the same ETH address
// Only reload transactions which may be filtered by token type
loadTransactions()
}
// Load QR code for current currency
async function loadCurrentQRCode() {
const qrContainer = document.getElementById('wallet-qr-container')
const addressDisplay = document.getElementById('wallet-address-display')
qrContainer.innerHTML = '<div class="spinner"></div>'
addressDisplay.textContent = 'Loading address...'
try {
const res = await authFetch(`/api/qrcode/${selectedNetwork}-${selectedCurrency}`)
const data = await res.json()
if (data.error) {
qrContainer.innerHTML = '<span class="error">Error</span>'
addressDisplay.textContent = 'Failed to load address'
return
}
// Display QR code image
qrContainer.innerHTML = `<img src="${data.qrcode}" alt="Receive Address QR Code">`
// Display address (clickable to copy)
addressDisplay.textContent = data.address
addressDisplay.title = 'Click to copy address'
addressDisplay.onclick = () => copyAddressToClipboard(data.address)
} catch (err) {
console.error('QR code error:', err)
qrContainer.innerHTML = '<span class="error">Failed to load</span>'
addressDisplay.textContent = 'Failed to load address'
}
}
// Copy address to clipboard
async function copyAddressToClipboard(address) {
try {
await navigator.clipboard.writeText(address)
showSuccess('Address copied to clipboard!')
} catch (err) {
// Fallback for older browsers
const temp = document.createElement('textarea')
temp.value = address
document.body.appendChild(temp)
temp.select()
document.execCommand('copy')
document.body.removeChild(temp)
showSuccess('Address copied!')
}
}
// Show receive for current currency
function showReceiveForCurrent() {
showReceive(selectedCurrency)
}
// Show send for current currency
function showSendForCurrent() {
showSend(selectedCurrency)
}
// Fetch current prices (CoinGecko API)
async function fetchPrices() {
try {
// Get all price IDs from supported chains
const priceIds = Object.values(SUPPORTED_CHAINS)
.map(chain => chain.priceId)
.filter(id => id) // Filter out chains without priceId
.join(',')
if (!priceIds) {
console.log('No price IDs configured')
return
}
const res = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${priceIds}&vs_currencies=usd`)
const data = await res.json()
// Store prices by CoinGecko ID
prices = {}
for (const [coinId, priceData] of Object.entries(data)) {
prices[coinId] = priceData.usd || 0
}
pricesLoaded = true
const priceLog = Object.entries(prices)
.map(([coin, price]) => `${coin}=$${price.toLocaleString()}`)
.join(', ')
console.log(`Prices loaded: ${priceLog}`)
} catch (err) {
console.error('Failed to fetch prices:', err)
pricesLoaded = false
}
}
// Format USD value
function formatUSD(amount) {
if (!amount) return '$0.00'
return `$${Number(amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
// Format balance helpers
function formatBTC(satoshis) {
const btc = Number(satoshis) / 100000000
return `${btc.toFixed(8)} BTC`
}
function formatETH(wei) {
const eth = Number(wei) / 1e18
return `${eth.toFixed(18).replace(/\.?0+$/, '')} ETH`
}
function formatSats(satoshis) {
return `${Number(satoshis).toLocaleString()} sats`
}
function formatGwei(wei) {
const gwei = Number(wei) / 1e9
return `${gwei.toFixed(2)} Gwei`
}
// Format fee in ETH (for Ethereum-based token transfers)
// Gas fees for token transfers are paid in ETH, not the token
function formatEthFee(wei) {
const eth = Number(wei) / 1e18
if (eth < 0.0001) {
return `${(eth * 1e6).toFixed(4)} µETH`
}
return `${eth.toFixed(6)} ETH`
}
// Convert human-readable amount to smallest units (satoshi/wei/token units)
// This is needed because users type "2" meaning 2 USDC, but backend expects 2000000
function toSmallestUnits(amount, chain) {
const num = parseFloat(amount)
if (isNaN(num) || num < 0) {
return '0'
}
if (chain === 'bitcoin') {
// BTC to satoshi (1e8)
return Math.floor(num * 1e8).toString()
} else if (chain.includes('usdt') || chain.includes('usdc')) {
// USDT/USDC have 6 decimals
return Math.floor(num * 1e6).toString()
} else {
// ETH to wei (1e18) - use BigInt to avoid precision loss
// For large numbers, we need to be careful with floating point
const wholePart = Math.floor(num)
const fractionalPart = num - wholePart
const wholeWei = BigInt(wholePart) * BigInt(1e18)
const fractionalWei = BigInt(Math.floor(fractionalPart * 1e18))
return (wholeWei + fractionalWei).toString()
}
}
// Convert smallest units to human-readable amount
// This is used when displaying amounts or setting input values
function fromSmallestUnits(amount, chain) {
const num = Number(amount)
if (isNaN(num) || num < 0) {
return '0'
}
if (chain === 'bitcoin') {
// Satoshi to BTC (1e8)
const btc = num / 1e8
return btc.toFixed(8).replace(/\.?0+$/, '')
} else if (chain.includes('usdt') || chain.includes('usdc')) {
// USDT/USDC have 6 decimals
const tokens = num / 1e6
return tokens.toFixed(6).replace(/\.?0+$/, '')
} else {
// Wei to ETH (1e18)
const eth = num / 1e18
return eth.toFixed(18).replace(/\.?0+$/, '')
}
}
// Show error toast
function showError(message) {
const toast = document.getElementById('error-toast')
toast.textContent = message
toast.classList.add('show')
// If a modal is open, append toast to it (so it appears above backdrop)
const openDialog = document.querySelector('dialog[open]')
if (openDialog && !openDialog.contains(toast)) {
openDialog.appendChild(toast)
} else if (!openDialog && toast.parentElement !== document.body) {
// If no dialog open, move back to body
document.body.appendChild(toast)
}
setTimeout(() => {
toast.classList.remove('show')
}, 5000)
}
// Check wallet status
async function checkStatus() {
try {
const res = await authFetch(`/api/status`)
const data = await res.json()
if (!data.initialized) {
// Show setup wizard instead of error
showSetupWizard()
return { initialized: false }
}
document.getElementById('wallet-status').innerHTML = '✅ Ready'
// Network name is set by updateNetworkLabels() based on selectedNetwork
return data
} catch (err) {
console.error('Status check failed:', err)
document.getElementById('wallet-status').innerHTML = '❌ Server Error'
showError('Cannot connect to wallet server')
return { initialized: false }
}
}
// Sync transactions from blockchain
async function syncTransactions() {
const syncBtn = document.getElementById('sync-btn')
const originalText = syncBtn.textContent
try {
syncBtn.disabled = true
syncBtn.textContent = '🔄 Syncing...'
// Get currently selected address index
const select = document.getElementById('wallet-address-select')
const rawIndex = select ? parseInt(select.value) : 0
const accountIndex = isNaN(rawIndex) ? 0 : rawIndex
// Sync current currency chain with selected account index
const chain = selectedCurrency
const res = await authFetch(`/api/transactions/sync`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
chain,
network: selectedNetwork, // Add network parameter
accountIndex
})
})
const data = await res.json()
if (data.error) {
showError(`Sync failed: ${data.error}`)
return
}
if (data.result.synced > 0) {
showSuccess(`✅ Synced ${data.result.synced} new transactions`)
} else {
showSuccess('✅ Transactions up to date')
}
// Always refresh balance and transactions after sync
// Balance comes fresh from blockchain via RPC (Alchemy)
if (selectedChain === 'ethereum') {
await loadEthereumBalances()
} else {
await loadCurrentBalance()
}
await loadTransactions()
if (data.result.errors && data.result.errors.length > 0) {
console.warn('Sync warnings:', data.result.errors)
}
} catch (err) {
console.error('Sync error:', err)
showError(`Sync failed: ${err.message}`)
} finally {
syncBtn.disabled = false
syncBtn.textContent = originalText
}
}
// Load transactions
async function loadTransactions() {
const list = document.getElementById('tx-list')
list.innerHTML = '<div class="spinner"></div>'
try {
// Load transactions, contacts, and current address in parallel
const [txRes, contactsRes, currentAddrRes] = await Promise.all([
authFetch(`/api/transactions/all`),
authFetch(`/api/contacts`),
authFetch(`/api/address/${selectedNetwork}-${selectedCurrency}`)
])
const data = await txRes.json()
const contactsData = await contactsRes.json()
const currentAddrData = await currentAddrRes.json()
if (data.error) {
list.innerHTML = '<div class="empty-state">Error loading transactions</div>'
return
}
if (data.transactions.length === 0) {
list.innerHTML = '<div class="empty-state">No transactions yet</div>'
return
}
// Get the currently selected address
const currentAddress = currentAddrData.address ? currentAddrData.address.toLowerCase() : null
// Build address-to-contact lookup
const contacts = contactsData.contacts || []
const addressToContact = {}
for (const contact of contacts) {
// Handle both old format (single address) and new format (addresses array)
if (contact.addresses && Array.isArray(contact.addresses)) {
// New format: multiple addresses
for (const addr of contact.addresses) {
addressToContact[addr.address.toLowerCase()] = contact
}
} else if (contact.address) {
// Old format: single address
addressToContact[contact.address.toLowerCase()] = contact
}
}
// Filter by selected network, selected currency, AND current wallet address
const filtered = data.transactions.filter(tx => {
// Must match network and chain
if (tx.network !== selectedNetwork || tx.chain !== selectedCurrency) {
return false
}
// Must involve the currently selected address (either as sender or receiver)
if (currentAddress) {
const fromAddr = tx.from.toLowerCase()
const toAddr = tx.to.toLowerCase()
return fromAddr === currentAddress || toAddr === currentAddress
}
// If no current address, show all for this chain (fallback)
return true
})
if (filtered.length === 0) {
list.innerHTML = '<div class="empty-state">No transactions for this currency on this network</div>'
return
}
// Sort by timestamp descending
const sorted = filtered.sort((a, b) => b.timestamp - a.timestamp)
// Store for details modal
allTransactions = sorted
// Show only last 10
const recent = sorted.slice(0, 10)
list.innerHTML = recent.map((tx, index) => {
const date = new Date(tx.timestamp).toLocaleDateString()
const time = new Date(tx.timestamp).toLocaleTimeString()
const icon = tx.direction === 'sent' ? '📤' : '📥'
const dirClass = tx.direction === 'sent' ? 'sent' : 'received'
const statusIcon = tx.status === 'confirmed' ? '✅' : '⏳'
// Format amount - handle tokens specially
const baseChain = tx.chain.replace(/-usdt$/, '').replace(/-usdc$/, '')
const chain = SUPPORTED_CHAINS[baseChain]
let amount
if (tx.token || tx.chain.includes('-usdt') || tx.chain.includes('-usdc')) {
const tokenSymbol = tx.token || (tx.chain.includes('-usdt') ? 'USDT' : 'USDC')
const decimals = tx.decimals || 6
const tokenAmount = Number(tx.amount) / Math.pow(10, decimals)
amount = `${tokenAmount.toFixed(2)} ${tokenSymbol}`
} else {
amount = chain ? chain.formatBalance(tx.amount) : tx.amount
}
// Look up contact for the to/from address
const relevantAddress = tx.direction === 'sent' ? tx.to : tx.from
const contact = addressToContact[relevantAddress.toLowerCase()]
const displayName = contact
? `<span class="contact-name-badge">📇 ${escapeHtml(contact.name)}</span> <small>${truncateAddress(relevantAddress)}</small>`
: truncateAddress(relevantAddress)
return `
<div class="tx-item" onclick="showTxDetails(${index})">
<div class="tx-icon">${icon}</div>
<div class="tx-details">
<div class="tx-main">
<span class="tx-direction ${dirClass}">${tx.direction.toUpperCase()}</span>
<span class="tx-chain">${tx.chain}</span>
</div>
<div class="tx-address">${displayName}</div>
<div class="tx-date">${date} ${time}</div>
</div>
<div class="tx-amount">
<div class="tx-value">${amount}</div>
<div class="tx-status">${statusIcon} ${tx.status}</div>
</div>
</div>
`
}).join('')
} catch (err) {
console.error('Transactions error:', err)
list.innerHTML = '<div class="empty-state">Failed to load transactions</div>'
}
}
// Helper to truncate address
function truncateAddress(addr) {
if (!addr || addr.length < 12) return addr
return `${addr.substring(0, 6)}...${addr.substring(addr.length - 4)}`
}
// Refresh all data
async function refreshAll() {
const btn = event.target
btn.disabled = true
btn.textContent = '⟳'
await Promise.all([
loadCurrentBalance(),
loadCurrentQRCode(),
loadTransactions()
])
btn.disabled = false
btn.textContent = '🔄 Refresh'
}
// Current chain for receive modal
let receiveModalChain = null
let manageAddressesChain = null
// Show manage addresses modal
async function showManageAddresses() {
manageAddressesChain = selectedCurrency
const modal = document.getElementById('manage-addresses-modal')
const chainInfo = SUPPORTED_CHAINS[selectedCurrency] || { icon: '🔗', name: selectedCurrency }
const chainName = chainInfo.name
const networkLabel = selectedNetwork === 'testnet'
? (selectedCurrency === 'ethereum' ? 'Sepolia' : 'Testnet')
: 'Mainnet'
document.getElementById('manage-addresses-chain').textContent = `- ${chainName} (${networkLabel})`
// Load addresses
await loadManageAddresses(selectedCurrency)
modal.showModal()
}
// Close manage addresses modal
function closeManageAddresses() {
document.getElementById('manage-addresses-modal').close()
manageAddressesChain = null
}
// Load addresses for manage modal
async function loadManageAddresses(chain) {
const container = document.getElementById('manage-addresses-list')
container.innerHTML = '<p class="loading-text">Loading addresses...</p>'
try {
const res = await authFetch(`/api/addresses/${selectedNetwork}-${chain}`)
const data = await res.json()
if (data.error) {
container.innerHTML = `<p class="error-text">${data.error}</p>`
return
}
if (data.addresses.length === 0) {
container.innerHTML = '<p class="info-text">No addresses yet. Click "Derive New Address" to create your first address!</p>'
return
}
// Build address list HTML
let html = ''
for (const addr of data.addresses) {
const isCurrent = addr.isCurrent
const currentBadge = isCurrent ? '<span class="badge badge-primary">Current</span>' : ''
const labelDisplay = addr.label || `Address ${addr.index}`
html += `
<div class="wallet-address-item ${isCurrent ? 'current' : ''}">
<div class="address-info-full">
<div class="address-label-row">
<input type="text"
class="address-label-input ${addr.label ? '' : 'placeholder'}"
value="${addr.label || `Address ${addr.index}`}"
onblur="updateWalletAddressLabel('${chain}', ${addr.index}, this.value)"
placeholder="Add label (e.g., Hardware wallet, Hot wallet)..."
/>
${currentBadge}
</div>
<div class="address-full-row">
<code class="address-code-full" title="Click to copy" onclick="copyWalletAddress('${addr.address}')">${addr.address}</code>
</div>
<div class="address-meta">
<span class="meta-item">Index: ${addr.index}</span>
<span class="meta-item">Path: ${addr.path}</span>
<span class="meta-item">Created: ${new Date(addr.createdAt).toLocaleDateString()}</span>
</div>
</div>
<div class="address-actions-vertical">
${!isCurrent ? `<button class="btn-secondary btn-sm" onclick="setWalletAddressCurrent('${chain}', ${addr.index})">Set as Current</button>` : ''}
<button class="btn-secondary btn-sm" onclick="copyWalletAddress('${addr.address}')">Copy Address</button>
${!isCurrent ? `<button class="btn-danger btn-sm" onclick="archiveWalletAddress('${chain}', ${addr.index})">Archive</button>` : ''}
</div>
</div>
`
}
container.innerHTML = html
} catch (err) {
console.error('Load addresses error:', err)
container.innerHTML = `<p class="error-text">Failed to load addresses</p>`
}
}
// Label input modal state
let labelInputCallback = null
// Show label input modal
function showLabelInput(callback) {
labelInputCallback = callback
document.getElementById('label-input-field').value = ''
document.getElementById('label-input-modal').showModal()
}
// Close label input modal
function closeLabelInput() {
document.getElementById('label-input-modal').close()
labelInputCallback = null
}
// Confirm label input
function confirmLabelInput() {
const label = document.getElementById('label-input-field').value.trim()
document.getElementById('label-input-modal').close()
if (labelInputCallback) {
labelInputCallback(label)
}
labelInputCallback = null
}
// Derive new address from manage modal
async function deriveNewAddressFromManage() {
if (!manageAddressesChain) return
showLabelInput(async (label) => {
try {
const res = await authFetch(`/api/addresses/${selectedNetwork}-${manageAddressesChain}/derive`, {
method: 'POST',
body: JSON.stringify({ label: label || '' })
})
const data = await res.json()
if (data.error) {
showError(data.error)
return
}
showSuccess('New address derived!')
await loadManageAddresses(manageAddressesChain)
} catch (err) {
showError(`Failed to derive address: ${err.message}`)
}
})
}
// Show receive modal
async function showReceive(chain) {
receiveModalChain = chain
const modal = document.getElementById('receive-modal')
const addressInput = document.getElementById('receive-address')
const chainName = chain.charAt(0).toUpperCase() + chain.slice(1)
const networkLabel = selectedNetwork === 'testnet'
? (chain === 'ethereum' ? 'Sepolia' : 'Testnet')
: 'Mainnet'
document.getElementById('receive-chain').textContent = `${chainName} (${networkLabel})`
// Load current address
addressInput.value = 'Loading...'
document.getElementById('receive-address-label').textContent = ''
try {
const res = await authFetch(`/api/address/${selectedNetwork}-${chain}`)
const data = await res.json()
if (data.error) {
addressInput.value = 'Error loading address'
return
}
addressInput.value = data.address
// Show label if present
if (data.label) {
document.getElementById('receive-address-label').textContent = `Label: ${data.label}`
} else {
document.getElementById('receive-address-label').textContent = 'Current receiving address'
}
// Display address in large format (no QR code - not available in holepunchto)
document.getElementById('qr-code').innerHTML = `
<div class="address-large">${data.address}</div>
`
// Load wallet addresses list
await loadWalletAddresses(chain)
} catch (err) {
console.error('Address error:', err)
addressInput.value = 'Failed to load address'
}
modal.showModal()
}
// Load wallet addresses for chain
async function loadWalletAddresses(chain) {
const container = document.getElementById('wallet-addresses-list')
container.innerHTML = '<p class="loading-text">Loading addresses...</p>'
try {
const res = await authFetch(`/api/addresses/${selectedNetwork}-${chain}`)
const data = await res.json()
if (data.error) {
container.innerHTML = `<p class="error-text">${data.error}</p>`
return
}
if (data.addresses.length === 0) {
container.innerHTML = '<p class="info-text">No addresses yet. Derive one to get started!</p>'
return
}
// Build address list HTML
let html = ''
for (const addr of data.addresses) {
const shortAddr = addr.address.substring(0, 20) + '...'
const isCurrent = addr.isCurrent
const currentBadge = isCurrent ? '<span class="badge badge-primary">Current</span>' : ''
const labelDisplay = addr.label || `Address ${addr.index}`
html += `
<div class="wallet-address-item ${isCurrent ? 'current' : ''}">
<div class="address-info">
<div class="address-label-row">
<input type="text"
class="address-label-input ${addr.label ? '' : 'placeholder'}"
value="${addr.label || `Address ${addr.index}`}"
onblur="updateWalletAddressLabel('${chain}', ${addr.index}, this.value)"
placeholder="Add label..."
/>
${currentBadge}
</div>
<code class="address-code" title="${addr.address}">${shortAddr}</code>
</div>
<div class="address-actions">
${!isCurrent ? `<button class="btn-icon-small" onclick="setWalletAddressCurrent('${chain}', ${addr.index})" title="Set as current">⭐</button>` : ''}
<button class="btn-icon-small" onclick="copyWalletAddress('${addr.address}')" title="Copy">📋</button>
${!isCurrent ? `<button class="btn-icon-small btn-danger" onclick="archiveWalletAddress('${chain}', ${addr.index})" title="Archive">🗑️</button>` : ''}
</div>
</div>
`
}
container.innerHTML = html
} catch (err) {
console.error('Load addresses error:', err)
container.innerHTML = `<p class="error-text">Failed to load addresses</p>`
}
}
// Derive new address
async function deriveNewAddress() {
if (!receiveModalChain) return
showLabelInput(async (label) => {
try {
const res = await authFetch(`/api/addresses/${selectedNetwork}-${receiveModalChain}/derive`, {
method: 'POST',
body: JSON.stringify({ label: label || '' })
})
const data = await res.json()
if (data.error) {
showError(data.error)
return
}
showSuccess('New address derived!')
await loadWalletAddresses(receiveModalChain)
} catch (err) {
showError(`Failed to derive address: ${err.message}`)
}
})
}
// Update wallet address label
async function updateWalletAddressLabel(chain, index, label) {
try {
const res = await authFetch(`/api/addresses/${selectedNetwork}-${chain}/${index}`, {
method: 'PUT',
body: JSON.stringify({ label })
})
const data = await res.json()
if (data.error) {
showError(data.error)
return
}
showSuccess('Label updated!')
} catch (err) {
console.error('Update label error:', err)
}
}
// Set wallet address as current
async function setWalletAddressCurrent(chain, index) {
try {
const res = await authFetch(`/api/addresses/${selectedNetwork}-${chain}/${index}/current`, {
method: 'POST'
})
const data = await res.json()
if (data.error) {
showError(data.error)
return
}
showSuccess('Address set as current!')
// Reload both modals if open
if (receiveModalChain) {
await showReceive(chain)
}
if (manageAddressesChain) {
await loadManageAddresses(chain)
}
// Update main wallet view
await updateWalletView()
} catch (err) {
showError(`Failed to set current: ${err.message}`)
}
}
// Copy wallet address to clipboard
async function copyWalletAddress(address) {
try {
await navigator.clipboard.writeText(address)
showSuccess('Address copied!')
} catch (err) {
showError('Failed to copy address')
}
}
// Archive wallet address
async function archiveWalletAddress(chain, index) {
const confirmed = confirm('Archive this address? It will be hidden from the list.')
if (!confirmed) return
try {
const res = await authFetch(`/api/addresses/${selectedNetwork}-${chain}/${index}`, {
method: 'DELETE'
})
const data = await res.json()
if (data.error) {
showError(data.error)
return
}
showSuccess('Address archived!')
// Reload both modals if open
if (receiveModalChain) {
await loadWalletAddresses(chain)
}
if (manageAddressesChain) {
await loadManageAddresses(chain)
}
} catch (err) {
showError(`Failed to archive: ${err.message}`)
}
}
// Close receive modal
function closeReceive() {
document.getElementById('receive-modal').close()
}
// Copy address to clipboard
async function copyAddress() {
const input = document.getElementById('receive-address')
input.select()
try {
await navigator.clipboard.writeText(input.value)
showSuccess('Address copied to clipboard!')
} catch (err) {
// Fallback for older browsers
document.execCommand('copy')
showSuccess('Address copied!')
}
}
// Show success message
function showSuccess(message) {
const toast = document.getElementById('error-toast')
toast.textContent = message
toast.classList.add('show', 'success')
// If a modal is open, append toast to it (so it appears above backdrop)
const openDialog = document.querySelector('dialog[open]')
if (openDialog && !openDialog.contains(toast)) {
openDialog.appendChild(toast)
} else if (!openDialog && toast.parentElement !== document.body) {
// If no dialog open, move back to body
document.body.appendChild(toast)
}
setTimeout(() => {
toast.classList.remove('show', 'success')
}, 3000)
}
// Show transaction details modal
function showTxDetails(index) {
const tx = allTransactions[index]
if (!tx) return
const modal = document.getElementById('tx-details-modal')
const content = document.getElementById('tx-details-content')
const date = new Date(tx.timestamp).toLocaleString()
const directionIcon = tx.direction === 'sent' ? '📤' : '📥'
const statusIcon = tx.status === 'confirmed' ? '✅' : '⏳'
// Format amount based on chain/token
let amount, fee
const baseChain = tx.chain.replace(/-usdt$/, '').replace(/-usdc$/, '')
const chain = SUPPORTED_CHAINS[baseChain]
// Check if this is a token transaction
if (tx.token || tx.chain.includes('-usdt') || tx.chain.includes('-usdc')) {
const tokenSymbol = tx.token || (tx.chain.includes('-usdt') ? 'USDT' : 'USDC')
const decimals = tx.decimals || 6 // USDT/USDC have 6 decimals
const tokenAmount = Number(tx.amount) / Math.pow(10, decimals)
amount = `${tokenAmount.toFixed(decimals > 6 ? 6 : decimals)} ${tokenSymbol}`
// Fee is still in ETH (wei)
fee = chain ? chain.formatSmallUnit(tx.fee) : `${tx.fee} wei`
} else {
amount = chain ? chain.formatBalance(tx.amount) : tx.amount
fee = chain ? chain.formatSmallUnit(tx.fee) : tx.fee
}
// Explorer links (chain-specific URLs)
let explorerLink = ''
if (baseChain === 'bitcoin') {
const network = tx.network === 'testnet' ? 'testnet/' : ''
explorerLink = `https://blockstream.info/${network}tx/${tx.hash}`
} else if (baseChain === 'ethereum') {
const network = tx.network === 'testnet' ? 'sepolia.' : ''
explorerLink = `https://${network}etherscan.io/tx/${tx.hash}`
}
content.innerHTML = `
<div class="tx-detail-row">
<span class="tx-detail-label">Status:</span>
<span class="tx-detail-value">${statusIcon} ${tx.status.toUpperCase()}</span>
</div>
<div class="tx-detail-row">
<span class="tx-detail-label">Direction:</span>
<span class="tx-detail-value">${directionIcon} ${tx.direction.toUpperCase()}</span>
</div>
<div class="tx-detail-row">
<span class="tx-detail-label">Chain:</span>
<span class="tx-detail-value">${tx.chain.toUpperCase()} (${tx.network})</span>
</div>
<div class="tx-detail-row">
<span class="tx-detail-label">Amount:</span>
<span class="tx-detail-value">${amount}</span>
</div>
<div class="tx-detail-row">
<span class="tx-detail-label">Fee:</span>
<span class="tx-detail-value">${fee}</span>
</div>
<div class="tx-detail-row">
<span class="tx-detail-label">From:</span>
<span class="tx-detail-value tx-detail-address">${tx.from}</span>
</div>
<div class="tx-detail-row">
<span class="tx-detail-label">To:</span>
<span class="tx-detail-value tx-detail-address">${tx.to}</span>
</div>
<div class="tx-detail-row">
<span class="tx-detail-label">Hash:</span>
<span class="tx-detail-value tx-detail-hash">${tx.hash}</span>
</div>
${tx.blockHeight ? `
<div class="tx-detail-row">
<span class="tx-detail-label">Block:</span>
<span class="tx-detail-value">${tx.blockHeight.toLocaleString()}</span>
</div>
` : ''}
<div class="tx-detail-row">
<span class="tx-detail-label">Date:</span>
<span class="tx-detail-value">${date}</span>
</div>
<div class="tx-detail-actions">
<a href="${explorerLink}" target="_blank" class="btn-primary">🔍 View on Explorer</a>
</div>
`
modal.showModal()
}
// Close transaction details modal
function closeTxDetails() {
document.getElementById('tx-details-modal').close()
}
// Show send modal
async function showSend(chain) {
currentSendChain = chain
const modal = document.getElementById('send-modal')
// Get proper display name for chain/token
let displayName, unit, decimals
if (chain === 'ethereum-usdt') {
displayName = 'USDT'
unit = 'USDT'
decimals = 6
} else if (chain === 'ethereum-usdc') {
displayName = 'USDC'
unit = 'USDC'
decimals = 6
} else if (chain === 'ethereum') {
displayName = 'Ethereum'
unit = 'ETH'
decimals = 18
} else if (chain === 'bitcoin') {
displayName = 'Bitcoin'
unit = 'sats'
decimals = 0
} else {
displayName = chain.charAt(0).toUpperCase() + chain.slice(1)
unit = chain
decimals = 18
}
const networkLabel = selectedNetwork === 'testnet'
? (chain.startsWith('ethereum') ? 'Sepolia' : 'Testnet')
: 'Mainnet'
document.getElementById('send-chain').textContent = `${displayName} (${networkLabel})`
// Reset form
document.getElementById('send-to').value = ''
document.getElementById('send-amount').value = ''
document.getElementById('send-to-error').textContent = ''
document.getElementById('send-amount-error').textContent = ''
document.getElementById('fee-display').innerHTML = '<div class="spinner"></div>'
usedMaxAmount = false // Reset max flag
// Reset max flag if user manually changes amount
document.getElementById('send-amount').addEventListener('input', function handleAmountChange() {
usedMaxAmount = false // User manually changed amount, so not max anymore
}, { once: true }) // Only listen once per modal open
// Load current balance
try {
const res = await authFetch(`/api/balance/${selectedNetwork}-${chain}`)
const data = await res.json()
currentBalance = Number(data.balance)
// Format balance display: prefer the chain's own formatter (SUPPORTED_CHAINS
// carries per-token ones, e.g. USDT/USDC at 6 decimals), else fall back to
// decimals-aware formatting rather than showing raw wei.
const chainConfig = SUPPORTED_CHAINS[chain]
let balanceDisplay
if (chainConfig && chainConfig.formatBalance) {
balanceDisplay = chainConfig.formatBalance(currentBalance)
} else if (chain === 'bitcoin') {
balanceDisplay = `${currentBalance.toLocaleString()} sats`
} else if (decimals === 6) {
balanceDisplay = `${(currentBalance / 1e6).toFixed(2)} ${unit}`
} else {
balanceDisplay = `${(currentBalance / 1e18).toFixed(6)} ${unit}`
}
document.getElementById('send-amount-helper').textContent = `Balance: ${balanceDisplay}`
} catch (err) {
console.error('Failed to load balance:', err)
document.getElementById('send-amount-helper').textContent = 'Balance: unavailable'
}
modal.showModal()
}
// Close send modal
function closeSend() {
document.getElementById('send-modal').close()
}
// Set amount to maximum balance
async function setMaxAmount() {
console.log('setMaxAmount called', { currentBalance, currentSendChain })
if (!currentSendChain) {
showError('Chain not selected')
return
}
if (!currentBalance || currentBalance <= 0) {
showError('No balance available')
return
}
const chain = SUPPORTED_CHAINS[currentSendChain]
if (!chain) {
console.error('Chain config not found:', currentSendChain)
showError('Invalid chain configuration')
return
}
const toAddress = document.getElementById('send-to').value.trim()
// If no recipient address, use static fee reserve (original behavior)
if (!toAddress) {
const reserved = chain.feeReserve || 0
const maxAmount = Math.max(0, currentBalance - reserved)
console.log('Setting max amount (static reserve):', { maxAmount, reserved, currentBalance })
// Convert to human-readable for input display
const maxAmountDisplay = fromSmallestUnits(maxAmount, currentSendChain)
document.getElementById('send-amount').value = maxAmountDisplay
document.getElementById('fee-display').innerHTML = `
<p class="help-text" style="color: #f59e0b;">
ℹ️ Estimated max amount (enter recipient address for exact fee calculation)
</p>
`
usedMaxAmount = true // Mark that max was used
showSuccess(`Max amount set (estimated, enter address for exact fees)`)
return
}
// With recipient address - calculate exact fees via API
document.getElementById('fee-display').innerHTML = '<div class="spinner"></div>'
document.getElementById('send-amount').value = '...'
try {
// Estimate fee for max transaction
// Use (balance - reserve) to avoid "insufficient balance" error during estimation
const reserved = chain.feeReserve || 0
const estimateAmount = Math.max(1, currentBalance - reserved)
const res = await authFetch(`/api/estimate-fee`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chain: currentSendChain,
network: selectedNetwork,
to: toAddress,
amount: estimateAmount // Estimate fee for (balance - reserve)
})
})
const data = await res.json()
if (data.error) {
showError(`Fee estimation failed: ${data.error}`)
document.getElementById('send-amount').value = ''
document.getElementById('fee-display').innerHTML = `<p class="form-error">${data.error}</p>`
return
}
estimatedFee = data.fee
const maxAmount = Math.max(0, currentBalance - estimatedFee)
if (maxAmount <= 0) {
showError('Insufficient balance to cover transaction fees')
document.getElementById('send-amount').value = ''
return
}
console.log('Setting max amount (exact fee):', { maxAmount, fee: estimatedFee, currentBalance })
// Convert to human-readable for input display
const maxAmountDisplay = fromSmallestUnits(maxAmount, currentSendChain)
// Set the max amount
document.getElementById('send-amount').value = maxAmountDisplay
usedMaxAmount = true // Mark that max was used
// Display fee info
const feeFormatted = currentSendChain === 'bitcoin'
? formatSats(estimatedFee)
: formatEthFee(estimatedFee)
// For tokens, fee is in ETH (different currency), so show separately
const isToken = currentSendChain.includes('usdt') || currentSendChain.includes('usdc')
let totalDisplay
if (currentSendChain === 'bitcoin') {
totalDisplay = `${currentBalance.toLocaleString()} sats`
} else if (isToken) {
const tokenSymbol = currentSendChain.includes('usdt') ? 'USDT' : 'USDC'
totalDisplay = `${maxAmountDisplay} ${tokenSymbol} + ${feeFormatted} gas`
} else {
// Native ETH
const totalEth = Number(currentBalance) / 1e18
totalDisplay = `${totalEth.toFixed(6)} ETH`
}
document.getElementById('fee-display').innerHTML = `
<div class="fee-option">
<span class="fee-label">Estimated Fee</span>
<span class="fee-value">${feeFormatted}</span>
</div>
<div class="fee-option">
<span class="fee-label">Total</span>
<span class="fee-value">${totalDisplay}</span>
</div>
<p class="help-text" style="margin-top: 0.5rem; color: #f59e0b;">
ℹ️ Max amount is your balance minus transaction fees
</p>
`
// Show info message
showSuccess(`Max amount set (${chain.formatSmallUnit(maxAmount)} after fees)`)
} catch (err) {
console.error('Fee estimation error:', err)
showError(`Failed to calculate max amount: ${err.message}`)
document.getElementById('send-amount').value = ''
document.getElementById('fee-display').innerHTML = '<p class="form-error">Failed to estimate fee</p>'
}
}
// Estimate transaction fee
async function estimateFee() {
const toAddress = document.getElementById('send-to').value
const amountInput = document.getElementById('send-amount').value
if (!toAddress || !amountInput) {
document.getElementById('fee-display').innerHTML =
'<p class="help-text">Enter address and amount to estimate fee</p>'
return
}
document.getElementById('fee-display').innerHTML = '<div class="spinner"></div>'
// Convert human-readable amount to smallest units for backend
const amount = toSmallestUnits(amountInput, currentSendChain)
try {
const res = await authFetch(`/api/estimate-fee`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chain: currentSendChain,
network: selectedNetwork,
to: toAddress,
amount: amount
})
})
const data = await res.json()
if (data.error) {
document.getElementById('fee-display').innerHTML =
`<p class="form-error">${data.error}</p>`
estimatedFee = null
return
}
estimatedFee = data.fee
const feeFormatted = currentSendChain === 'bitcoin'
? formatSats(data.fee)
: formatEthFee(data.fee)
// For tokens, fee is in ETH (different currency), so show separately
const isToken = currentSendChain.includes('usdt') || currentSendChain.includes('usdc')
let totalDisplay
if (currentSendChain === 'bitcoin') {
// Amount is now in sats (smallest units)
const totalSats = Number(amount) + Number(data.fee)
totalDisplay = `${totalSats.toLocaleString()} sats`
} else if (isToken) {
const tokenSymbol = currentSendChain.includes('usdt') ? 'USDT' : 'USDC'
// Display human-readable amount from input
totalDisplay = `${amountInput} ${tokenSymbol} + ${feeFormatted} gas`
} else {
// Native ETH - convert both to ETH for total
const totalWei = BigInt(amount) + BigInt(data.fee)
const totalEth = Number(totalWei) / 1e18
totalDisplay = `${totalEth.toFixed(6)} ETH`
}
document.getElementById('fee-display').innerHTML = `
<div class="fee-option">
<span class="fee-label">Estimated Fee</span>
<span class="fee-value">${feeFormatted}</span>
</div>
<div class="fee-option">
<span class="fee-label">Total</span>
<span class="fee-value">${totalDisplay}</span>
</div>
`
} catch (err) {
console.error('Fee estimation error:', err)
document.getElementById('fee-display').innerHTML =
'<p class="form-error">Failed to estimate fee</p>'
estimatedFee = null
}
}
// Validate and show confirmation
function confirmSend() {
const toAddress = document.getElementById('send-to').value.trim()
const amountInput = document.getElementById('send-amount').value.trim()
// Clear previous errors
document.getElementById('send-to-error').textContent = ''
document.getElementById('send-amount-error').textContent = ''
let hasError = false
// Validate address using chain config
if (!toAddress) {
document.getElementById('send-to-error').textContent = 'Address is required'
hasError = true
} else {
const chain = SUPPORTED_CHAINS[currentSendChain]
if (chain && chain.addressPattern) {
const pattern = chain.addressPattern[selectedNetwork]
if (pattern && !toAddress.match(pattern)) {
document.getElementById('send-to-error').textContent = `Invalid ${chain.name} address`
hasError = true
}
}
}
// Validate amount - convert to smallest units for balance comparison
const amountInSmallestUnits = Number(toSmallestUnits(amountInput, currentSendChain))
if (!amountInput || isNaN(parseFloat(amountInput)) || parseFloat(amountInput) <= 0) {
document.getElementById('send-amount-error').textContent = 'Invalid amount'
hasError = true
} else if (amountInSmallestUnits > currentBalance) {
document.getElementById('send-amount-error').textContent = 'Insufficient balance'
hasError = true
}
if (hasError) return
// Estimate fee if not done
if (!estimatedFee) {
estimateFee()
setTimeout(() => {
if (estimatedFee) confirmSend()
}, 2000)
return
}
// Show confirmation modal
const confirmModal = document.getElementById('confirm-modal')
const confirmContent = document.getElementById('confirm-content')
// Convert to smallest units for calculations
const amountSmallest = toSmallestUnits(amountInput, currentSendChain)
// Format for display
const isToken = currentSendChain.includes('usdt') || currentSendChain.includes('usdc')
let amountFormatted
if (currentSendChain === 'bitcoin') {
amountFormatted = formatBTC(amountSmallest)
} else if (isToken) {
const tokenSymbol = currentSendChain.includes('usdt') ? 'USDT' : 'USDC'
amountFormatted = `${amountInput} ${tokenSymbol}`
} else {
amountFormatted = formatETH(amountSmallest)
}
const feeFormatted = currentSendChain === 'bitcoin'
? formatSats(estimatedFee)
: formatEthFee(estimatedFee)
// Calculate total display
let totalDisplay
if (currentSendChain === 'bitcoin') {
const totalSats = Number(amountSmallest) + Number(estimatedFee)
totalDisplay = `${totalSats.toLocaleString()} sats`
} else if (isToken) {
const tokenSymbol = currentSendChain.includes('usdt') ? 'USDT' : 'USDC'
totalDisplay = `${amountInput} ${tokenSymbol} + ${feeFormatted} gas`
} else {
const totalWei = BigInt(amountSmallest) + BigInt(estimatedFee)
const totalEth = Number(totalWei) / 1e18
totalDisplay = `${totalEth.toFixed(6)} ETH`
}
const networkWarning = selectedNetwork === 'mainnet'
? '<div class="confirm-warning">⚠️ WARNING: You are sending on MAINNET. This will use real funds!</div>'
: ''
confirmContent.innerHTML = `
${networkWarning}
<div class="confirm-row">
<span class="confirm-label">Chain:</span>
<span class="confirm-value">${chain ? chain.name : currentSendChain.toUpperCase()} (${selectedNetwork})</span>
</div>
<div class="confirm-row">
<span class="confirm-label">To:</span>
<span class="confirm-value">${toAddress}</span>
</div>
<div class="confirm-row">
<span class="confirm-label">Amount:</span>
<span class="confirm-value">${amountFormatted}</span>
</div>
<div class="confirm-row">
<span class="confirm-label">Fee:</span>
<span class="confirm-value">${feeFormatted}</span>
</div>
<div class="confirm-row">
<span class="confirm-label">Total:</span>
<span class="confirm-value">${totalDisplay}</span>
</div>
`
confirmModal.showModal()
}
// Close confirmation modal
function closeConfirm() {
document.getElementById('confirm-modal').close()
}
// Execute send transaction
async function executeSend() {
const toAddress = document.getElementById('send-to').value.trim()
const amountInput = document.getElementById('send-amount').value.trim()
// Convert human-readable amount to smallest units for backend
const amount = toSmallestUnits(amountInput, currentSendChain)
// Close confirmation modal
closeConfirm()
closeSend()
// Show loading
showError('Sending transaction...')
try {
const res = await authFetch(`/api/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chain: currentSendChain,
network: selectedNetwork,
to: toAddress,
amount: amount,
sendMax: usedMaxAmount // Tell backend this is a max send
})
})
const data = await res.json()
if (data.error) {
showError(data.error)
return
}
showSuccess(`Transaction sent! Hash: ${data.hash.substring(0, 16)}...`)
usedMaxAmount = false // Reset flag after successful send
// Show native notification (don't let this crash the app)
try {
const isToken = currentSendChain.includes('usdt') || currentSendChain.includes('usdc')
const displayUnit = isToken
? (currentSendChain.includes('usdt') ? 'USDT' : 'USDC')
: currentSendChain.toUpperCase()
showNotification(
'Transaction Sent',
`${amountInput} ${displayUnit} sent successfully`,
'success'
)
} catch (notifErr) {
console.log('Notification failed (non-critical):', notifErr.message)
}
// Sync transaction from blockchain, then refresh balances and display
setTimeout(async () => {
// First sync from blockchain to fetch the new transaction
try {
// Get current account index for sync
const select = document.getElementById('wallet-address-select')
const rawIndex = select ? parseInt(select.value) : 0
const accountIndex = isNaN(rawIndex) ? 0 : rawIndex
const syncRes = await authFetch(`/api/transactions/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chain: currentSendChain,
network: selectedNetwork, // Add network parameter
accountIndex
})
})
const syncData = await syncRes.json()
console.log('Auto-sync after send:', syncData)
} catch (syncErr) {
console.warn('Auto-sync failed (non-critical):', syncErr.message)
}
// Then refresh UI
loadCurrentBalance()
loadTransactions()
}, 2000) // Wait 2 seconds for transaction to propagate
} catch (err) {
console.error('Send error:', err)
showError('Failed to send transaction')
usedMaxAmount = false // Reset flag on error
}
}
// Export transactions as CSV
function exportTransactions() {
if (allTransactions.length === 0) {
showError('No transactions to export')
return
}
// CSV headers
const headers = ['Date', 'Time', 'Chain', 'Network', 'Direction', 'From', 'To', 'Amount', 'Fee', 'Status', 'Hash', 'Block Height']
// CSV rows
const rows = allTransactions.map(tx => {
const date = new Date(tx.timestamp)
return [
date.toLocaleDateString(),
date.toLocaleTimeString(),
tx.chain,
tx.network,
tx.direction,
tx.from,
tx.to,
tx.amount,
tx.fee,
tx.status,
tx.hash,
tx.blockHeight || ''
].map(field => `"${field}"`).join(',')
})
// Combine headers and rows
const csv = [headers.join(','), ...rows].join('\n')
// Create download link
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `hyper-wallet-transactions-${selectedNetwork}-${Date.now()}.csv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showSuccess(`Exported ${allTransactions.length} transactions`)
}
// Check if wallet needs password setup (plaintext mnemonics)
async function checkEncryptionStatus() {
try {
const res = await authFetch(`/api/wallet/encryption-status`)
if (res.ok) {
const data = await res.json()
return data.encrypted
}
} catch (err) {
console.error('Failed to check encryption status:', err)
}
return null // Unknown status
}
// Prompt for password to unlock wallet using dialog
async function promptForPassword() {
const maxAttempts = 3
let attempts = 0
console.log('🔐 promptForPassword() called')
// Check if this is first-time password setup or regular unlock
const isEncrypted = await checkEncryptionStatus()
const isFirstTimeSetup = isEncrypted === false
console.log('📊 Encryption status:', { isEncrypted, isFirstTimeSetup })
return new Promise((resolve) => {
const dialog = document.getElementById('password-dialog')
const titleEl = document.getElementById('password-dialog-title')
const warningEl = document.getElementById('password-dialog-warning')
const passwordInput = document.getElementById('password-input')
const confirmGroup = document.getElementById('password-confirm-group')
const confirmInput = document.getElementById('password-confirm-input')
const matchError = document.getElementById('password-match-error')
const strengthDiv = document.getElementById('password-strength')
const strengthText = document.getElementById('password-strength-text')
const strengthBar = document.getElementById('password-strength-bar')
const submitBtn = document.getElementById('password-submit-btn')
const cancelBtn = document.getElementById('password-cancel-btn')
const attemptsEl = document.getElementById('password-attempts')
// Check if all elements exist
if (!dialog || !passwordInput || !submitBtn) {
console.error('❌ Password dialog elements not found!')
console.error({ dialog, passwordInput, submitBtn })
resolve(false)
return
}
console.log('✅ Password dialog elements found')
// Configure dialog for first-time setup vs unlock
if (isFirstTimeSetup) {
titleEl.textContent = '⚠️ SET WALLET PASSWORD'
warningEl.style.display = 'block'
confirmGroup.style.display = 'block'
strengthDiv.style.display = 'block'
} else {
titleEl.textContent = '🔐 Unlock Wallet'
warningEl.style.display = 'none'
confirmGroup.style.display = 'none'
strengthDiv.style.display = 'none'
}
// Show attempts counter
if (attempts > 0) {
attemptsEl.textContent = `Attempt ${attempts + 1} of ${maxAttempts}`
attemptsEl.style.display = 'block'
} else {
attemptsEl.style.display = 'none'
}
// Password strength calculator
function checkPasswordStrength(password) {
if (!password) return { strength: 0, text: 'Too short', color: '#d32f2f' }
let strength = 0
if (password.length >= 8) strength += 25
if (password.length >= 12) strength += 25
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength += 20
if (/\d/.test(password)) strength += 15
if (/[^a-zA-Z0-9]/.test(password)) strength += 15
if (strength < 40) return { strength, text: 'Weak', color: '#d32f2f' }
if (strength < 60) return { strength, text: 'Fair', color: '#ff9800' }
if (strength < 80) return { strength, text: 'Good', color: '#2196f3' }
return { strength, text: 'Strong', color: '#4caf50' }
}
// Validation function
function validateInputs() {
const password = passwordInput.value
const confirm = confirmInput.value
// Update strength indicator (only for first-time setup)
if (isFirstTimeSetup) {
const { strength, text, color} = checkPasswordStrength(password)
strengthText.textContent = text
strengthText.style.color = color
strengthBar.style.width = `${strength}%`
strengthBar.style.background = color
}
// Check password length
if (password.length < 8) {
submitBtn.disabled = true
return
}
// Check password match (only for first-time setup)
if (isFirstTimeSetup) {
if (password !== confirm) {
matchError.style.display = 'block'
submitBtn.disabled = true
return
} else {
matchError.style.display = 'none'
}
}
// All validations passed
submitBtn.disabled = false
}
// Add input listeners
passwordInput.addEventListener('input', validateInputs)
if (isFirstTimeSetup) {
confirmInput.addEventListener('input', validateInputs)
}
// Handle form submission
async function handleSubmit() {
const password = passwordInput.value
try {
submitBtn.disabled = true
submitBtn.textContent = 'Submitting...'
// Call appropriate endpoint based on whether this is first-time setup
const endpoint = isFirstTimeSetup ? '/api/set-password' : '/api/unlock'
const res = await authFetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
})
if (!res.ok) {
const error = await res.json()
if (res.status === 401) {
attempts++
if (attempts >= maxAttempts) {
alert('❌ Maximum unlock attempts reached. Please refresh the page to try again.')
dialog.close()
resolve(false)
return
}
// Show error and reset
if (isFirstTimeSetup) {
alert('❌ Failed to set password. Please try again.')
} else {
alert('❌ Invalid password. Please try again.')
}
passwordInput.value = ''
confirmInput.value = ''
submitBtn.textContent = 'Submit'
validateInputs()
// Update attempts counter
attemptsEl.textContent = `Attempt ${attempts + 1} of ${maxAttempts}`
attemptsEl.style.display = 'block'
return
}
throw new Error(error.error || 'Unlock failed')
}
const data = await res.json()
// Store API token
saveToken(data.token)
// Close dialog
dialog.close()
// Show success message
if (data.migrated) {
alert('✅ SUCCESS!\n\nYour wallet mnemonics have been encrypted.\n\nREMEMBER YOUR PASSWORD - you will need it every time you access the wallet!')
showSuccess('✅ Wallet encrypted and unlocked!')
} else {
showSuccess('✅ Wallet unlocked successfully')
}
console.log('🔓 Wallet unlocked, public key:', data.publicKey)
resolve(true)
} catch (err) {
console.error('Unlock error:', err)
alert(`❌ Error: ${err.message}`)
attempts++
if (attempts >= maxAttempts) {
dialog.close()
resolve(false)
} else {
submitBtn.textContent = 'Submit'
submitBtn.disabled = false
}
}
}
// Button handlers
submitBtn.onclick = handleSubmit
cancelBtn.onclick = () => {
dialog.close()
resolve(false)
}
// Enter key submits
passwordInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !submitBtn.disabled) {
handleSubmit()
}
})
confirmInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !submitBtn.disabled) {
handleSubmit()
}
})
// Reset and show dialog
passwordInput.value = ''
confirmInput.value = ''
matchError.style.display = 'none'
submitBtn.textContent = 'Submit'
submitBtn.disabled = true
dialog.showModal()
passwordInput.focus()
})
}
// Show change password dialog
function showChangePassword() {
const dialog = document.getElementById('change-password-dialog')
const currentInput = document.getElementById('current-password-input')
const newInput = document.getElementById('new-password-input')
const confirmInput = document.getElementById('new-password-confirm-input')
const matchError = document.getElementById('change-password-match-error')
const strengthDiv = document.getElementById('change-password-strength')
const strengthText = document.getElementById('change-password-strength-text')
const strengthBar = document.getElementById('change-password-strength-bar')
const submitBtn = document.getElementById('change-password-submit-btn')
const cancelBtn = document.getElementById('change-password-cancel-btn')
const errorDiv = document.getElementById('change-password-error')
// Password strength calculator (reuse from unlock dialog)
function checkPasswordStrength(password) {
if (!password) return { strength: 0, text: 'Too short', color: '#d32f2f' }
let strength = 0
if (password.length >= 8) strength += 25
if (password.length >= 12) strength += 25
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength += 20
if (/\d/.test(password)) strength += 15
if (/[^a-zA-Z0-9]/.test(password)) strength += 15
if (strength < 40) return { strength, text: 'Weak', color: '#d32f2f' }
if (strength < 60) return { strength, text: 'Fair', color: '#ff9800' }
if (strength < 80) return { strength, text: 'Good', color: '#2196f3' }
return { strength, text: 'Strong', color: '#4caf50' }
}
// Validation function
function validateInputs() {
const current = currentInput.value
const newPassword = newInput.value
const confirm = confirmInput.value
// Update strength indicator
const { strength, text, color } = checkPasswordStrength(newPassword)
strengthText.textContent = text
strengthText.style.color = color
strengthBar.style.width = `${strength}%`
strengthBar.style.background = color
// Check all fields filled
if (!current || newPassword.length < 8 || !confirm) {
submitBtn.disabled = true
return
}
// Check password match
if (newPassword !== confirm) {
matchError.style.display = 'block'
submitBtn.disabled = true
return
} else {
matchError.style.display = 'none'
}
// Check new password different from current
if (newPassword === current) {
matchError.textContent = '❌ New password must be different from current'
matchError.style.display = 'block'
submitBtn.disabled = true
return
}
matchError.textContent = '❌ New passwords do not match'
matchError.style.display = 'none'
// All validations passed
submitBtn.disabled = false
}
// Add input listeners
currentInput.addEventListener('input', validateInputs)
newInput.addEventListener('input', validateInputs)
confirmInput.addEventListener('input', validateInputs)
// Handle form submission
async function handleSubmit() {
const currentPassword = currentInput.value
const newPassword = newInput.value
try {
submitBtn.disabled = true
submitBtn.textContent = 'Changing Password...'
errorDiv.style.display = 'none'
// Call change password endpoint
const res = await authFetch(`/api/change-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPassword,
newPassword
})
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Password change failed')
}
const data = await res.json()
// Close dialog
dialog.close()
// Show success
alert('✅ Password changed successfully!\n\nYour mnemonics have been re-encrypted with the new password.\n\nYou\'ll need the new password on next login.')
showSuccess('Password changed successfully')
} catch (err) {
console.error('Change password error:', err)
errorDiv.textContent = err.message
errorDiv.style.display = 'block'
submitBtn.textContent = 'Change Password'
submitBtn.disabled = false
}
}
// Button handlers
submitBtn.onclick = handleSubmit
cancelBtn.onclick = () => {
dialog.close()
}
// Enter key submits
currentInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !submitBtn.disabled) {
handleSubmit()
}
})
newInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !submitBtn.disabled) {
handleSubmit()
}
})
confirmInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !submitBtn.disabled) {
handleSubmit()
}
})
// Reset and show dialog
currentInput.value = ''
newInput.value = ''
confirmInput.value = ''
matchError.style.display = 'none'
errorDiv.style.display = 'none'
submitBtn.textContent = 'Change Password'
submitBtn.disabled = true
dialog.showModal()
currentInput.focus()
}
// Initialize on page load
async function init() {
console.log('🚀 Hyper Wallet UI initialized')
// Set up network selector UI
document.querySelectorAll('.network-btn').forEach(btn => {
btn.classList.remove('active')
if (btn.dataset.network === selectedNetwork) {
btn.classList.add('active')
}
})
// Update network labels
updateNetworkLabels()
const status = await checkStatus()
// If initialized, check if we need to prompt for password
if (status.initialized) {
const token = getToken()
// If password not cached on server but we have a token, it's stale
if (status.passwordCached === false && token) {
console.log('🔐 Stale token detected - clearing and prompting for password')
clearToken()
}
// If no token (or cleared stale token), prompt for password
if (!getToken()) {
console.log('🔐 Wallet initialized but password needed')
const success = await promptForPassword()
if (!success) {
showError('Failed to unlock wallet. Please refresh and try again.')
return
}
// Hide setup wizard and show main app after successful unlock
console.log('✅ Unlock successful - showing main app')
hideSetupWizard()
}
}
// Populate currency selector from SUPPORTED_CHAINS
const currencySelector = document.getElementById('currency-selector')
if (currencySelector) {
// Clear existing options
currencySelector.innerHTML = ''
// Add option for each base chain (not tokens)
for (const [chainId, chain] of Object.entries(SUPPORTED_CHAINS)) {
// Skip token entries - they're accessed via the unified Ethereum view
if (chain.isToken) continue
const option = document.createElement('option')
option.value = chainId
option.textContent = `${chain.icon} ${chain.name}`
currencySelector.appendChild(option)
}
// Set selected chain
currencySelector.value = selectedChain
}
// Fetch prices first (don't wait)
fetchPrices()
// Only load wallet data if initialized AND authenticated
if (status.initialized && getToken()) {
// Update wallet view for selected chain (icon, name, network, address selector)
updateWalletView()
// Use unified balance display for Ethereum
const loadBalancePromise = selectedChain === 'ethereum'
? loadEthereumBalances()
: loadCurrentBalance()
await Promise.all([
loadBalancePromise,
loadCurrentQRCode(),
loadTransactions()
])
} else if (status.initialized && !getToken()) {
console.warn('⚠️ Wallet initialized but not authenticated - waiting for password')
}
// Auto-refresh every 30 seconds
setInterval(() => {
if (document.visibilityState === 'visible') {
// Use unified balance display for Ethereum
if (selectedChain === 'ethereum') {
loadEthereumBalances()
} else {
loadCurrentBalance()
}
loadCurrentQRCode()
loadTransactions()
}
}, 30000)
// Refresh prices every 5 minutes
setInterval(() => {
fetchPrices()
}, 300000)
// Add event listeners for send form (once DOM is ready)
const sendAmountInput = document.getElementById('send-amount')
const sendToInput = document.getElementById('send-to')
if (sendAmountInput) {
sendAmountInput.addEventListener('input', () => {
// Debounce fee estimation
clearTimeout(window.feeEstimateTimeout)
window.feeEstimateTimeout = setTimeout(estimateFee, 500)
})
}
if (sendToInput) {
sendToInput.addEventListener('blur', estimateFee)
}
// Setup native feature event listeners
setupNativeFeatureListeners()
}
// Native Feature Event Listeners
function setupNativeFeatureListeners() {
// Listen for IPC events from main process (via pear-electron)
if (typeof ui !== 'undefined' && ui.on) {
// Refresh balances (triggered by tray menu or keyboard shortcut)
ui.on('refresh-balances', async () => {
console.log('🔄 Refresh balances triggered')
await loadCurrentBalance()
await loadTransactions()
try {
showNotification('Balances refreshed', 'Your wallet balances have been updated')
} catch (err) {
console.log('Notification failed (non-critical):', err.message)
}
})
// Show send tab (triggered by keyboard shortcut Cmd+N)
ui.on('show-send-tab', () => {
console.log('💸 Show send tab triggered')
showTab('send')
})
// Show settings (triggered by tray menu or keyboard shortcut Cmd+,)
ui.on('show-settings', () => {
console.log('⚙️ Show settings triggered')
showTab('settings')
})
console.log('✓ Native feature event listeners registered')
} else {
console.log('ℹ️ Native feature events not available')
}
}
// Native Notification Helper
// NOTE: Native notifications disabled in Pear runtime due to libnotify blocking issues
// Using in-app toasts only (showSuccess/showError)
function showNotification(title, body, type = 'info') {
// Log to console only - native notifications cause UI freeze in Pear runtime
console.log(`📢 Notification: ${title} - ${body}`)
// Show in-app notification via toast (non-blocking)
if (type === 'success') {
showSuccess(`${title}: ${body}`)
} else if (type === 'error') {
showError(`${title}: ${body}`)
}
// Native Notification API disabled - causes libnotify blocking in pear-runtime
}
// Expose functions to global window object for onclick/onchange handlers
// (Required because wallet.js is loaded as type="module")
window.addDevice = addDevice
window.closeAddContact = closeAddContact
window.submitAddContact = submitAddContact
window.submitEditContact = submitEditContact
window.submitAddWallet = submitAddWallet
window.sendToContact = sendToContact
window.sendToContactAddress = sendToContactAddress
window.editContact = editContact
window.closeAddWallet = closeAddWallet
window.closeConfirm = closeConfirm
window.closeContacts = closeContacts
window.closeDeviceAdd = closeDeviceAdd
window.closeDeviceJoin = closeDeviceJoin
window.closeDevices = closeDevices
window.closeEditContact = closeEditContact
window.closeLabelInput = closeLabelInput
window.closeManageAddresses = closeManageAddresses
window.closeReceive = closeReceive
window.closeSend = closeSend
window.closeSettings = closeSettings
window.closeTxDetails = closeTxDetails
window.confirmLabelInput = confirmLabelInput
window.confirmMnemonicBackup = confirmMnemonicBackup
window.confirmSend = confirmSend
window.copyAddress = copyAddress
window.copyInviteCode = copyInviteCode
window.deduplicateDevices = deduplicateDevices
window.deriveNewAddress = deriveNewAddress
window.deriveNewAddressFromManage = deriveNewAddressFromManage
window.executeDeviceJoin = executeDeviceJoin
window.executeSend = executeSend
window.exportIdentity = exportIdentity
window.exportTransactions = exportTransactions
window.exportWalletMnemonic = exportWalletMnemonic
window.forceSyncNow = forceSyncNow
window.generateNewWallet = generateNewWallet
window.joinDevice = joinDevice
window.joinExistingWallet = joinExistingWallet
window.loadTransactions = loadTransactions
window.refreshAll = refreshAll
window.resetWallet = resetWallet
window.restoreWallet = restoreWallet
window.saveEthereumApiKey = saveEthereumApiKey
window.setMaxAmount = setMaxAmount
window.setNetwork = setNetwork
window.showAddContact = showAddContact
window.showChangePassword = showChangePassword
window.showContacts = showContacts
window.showCreateWallet = showCreateWallet
window.showDevices = showDevices
window.showJoinWallet = showJoinWallet
window.showManageAddresses = showManageAddresses
window.showReceiveForCurrent = showReceiveForCurrent
window.showRestoreWallet = showRestoreWallet
window.showSend = showSend
window.showSendForCurrent = showSendForCurrent
window.showSettings = showSettings
window.selectAsset = selectAsset
window.loadEthereumBalances = loadEthereumBalances
window.showSetupWelcome = showSetupWelcome
window.showWhoAmI = showWhoAmI
window.syncTransactions = syncTransactions
window.verifyBackup = verifyBackup
window.changeBitcoinProvider = changeBitcoinProvider
window.changeDefaultNetwork = changeDefaultNetwork
window.changeEthereumProvider = changeEthereumProvider
window.onWalletAddressChange = onWalletAddressChange
window.switchCurrency = switchCurrency
window.archiveWalletAddress = archiveWalletAddress
window.copyToClipboard = copyToClipboard
window.copyWalletAddress = copyWalletAddress
window.deleteContactConfirm = deleteContactConfirm
window.removeContactAddress = removeContactAddress
window.removeDeviceConfirm = removeDeviceConfirm
window.setWalletAddressCurrent = setWalletAddressCurrent
window.showAddWallet = showAddWallet
window.showTxDetails = showTxDetails
// Password prompt modal functions (replaces blocked prompt())
let passwordPromptResolver = null
function promptForPasswordModal(message = 'Enter your password:') {
return new Promise((resolve) => {
const modal = document.getElementById('password-prompt-modal')
const messageEl = document.getElementById('password-prompt-message')
const inputEl = document.getElementById('password-prompt-input')
messageEl.textContent = message
inputEl.value = ''
passwordPromptResolver = resolve
modal.showModal()
inputEl.focus()
// Allow Enter key to submit
inputEl.onkeypress = (e) => {
if (e.key === 'Enter') {
submitPasswordPrompt()
}
}
})
}
function submitPasswordPrompt() {
const modal = document.getElementById('password-prompt-modal')
const inputEl = document.getElementById('password-prompt-input')
const password = inputEl.value
if (passwordPromptResolver) {
passwordPromptResolver(password)
passwordPromptResolver = null
}
inputEl.value = ''
modal.close()
}
function closePasswordPrompt() {
const modal = document.getElementById('password-prompt-modal')
const inputEl = document.getElementById('password-prompt-input')
if (passwordPromptResolver) {
passwordPromptResolver(null)
passwordPromptResolver = null
}
inputEl.value = ''
modal.close()
}
window.promptForPasswordModal = promptForPasswordModal
window.submitPasswordPrompt = submitPasswordPrompt
window.closePasswordPrompt = closePasswordPrompt
// Mnemonic display modal functions
let currentMnemonic = ''
function showMnemonicDisplay(mnemonic, title = 'Mnemonic') {
const modal = document.getElementById('mnemonic-display-modal')
const textEl = document.getElementById('mnemonic-display-text')
const titleEl = modal.querySelector('h3')
currentMnemonic = mnemonic
textEl.textContent = mnemonic
titleEl.textContent = `🔐 ${title}`
modal.showModal()
}
function closeMnemonicDisplay() {
const modal = document.getElementById('mnemonic-display-modal')
currentMnemonic = ''
modal.close()
}
async function copyMnemonicToClipboard() {
try {
await navigator.clipboard.writeText(currentMnemonic)
showSuccess('Mnemonic copied to clipboard!')
} catch (err) {
showError('Failed to copy to clipboard')
}
}
window.showMnemonicDisplay = showMnemonicDisplay
window.closeMnemonicDisplay = closeMnemonicDisplay
window.copyMnemonicToClipboard = copyMnemonicToClipboard
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init)
} else {
init()
}