#!/usr/bin/env bare
// wallet-server.cjs - HTTP Server for Hyper Wallet Web UI
// Runs in Bare 1.26.0 (no Pear version conflict!)
require('bare-node-runtime/global')
// WORKAROUND: Pear doesn't support import attributes with ESM yet
// Manually load imports.json into bundle for Bare 1.24/1.26 compatibility
require('bare-node-runtime/imports')
const http = require('bare-http1')
const fs = require('bare-fs')
const path = require('bare-path')
const os = require('bare-os')
// Check if running under Pear
const isPear = typeof Pear !== 'undefined'
let appDrive = null
const {
initializeStorage,
getTransactions,
getTransaction,
storeTransaction,
closeStorage,
createInvite,
joinWallet,
listDevices,
removeDevice,
deduplicateDevices,
waitForNewDevice,
enableReplication,
// Contact management
addContact,
getContacts,
findContact,
getContactById,
updateContact,
deleteContact,
findContactByAddress,
addAddressToContact,
removeAddressFromContact,
// Transaction syncing
syncTransactionsFromWDK,
// Wallet address management
getAddresses,
getNextAddressIndex,
getCurrentAddress,
storeAddress,
updateAddressLabel,
setCurrentAddress,
archiveAddress
} = require('./lib/hyper-wallet-lib/wallet-storage.cjs')
const IdentityKey = require('keet-identity-key')
const b4a = require('b4a')
const {
loadWalletMnemonic,
generateWalletMnemonic,
saveWalletMnemonic,
initializeWDK,
isTokenChain,
getTokenInfo,
getBaseChain,
TOKEN_CONTRACTS
} = require('./lib/hyper-wallet-lib/wdk-integration.cjs')
const { loadConfig } = require('./lib/hyper-wallet-lib/config-loader.cjs')
const { getConfig, updateConfig, saveConfig, DEFAULT_CONFIG } = require('./lib/hyper-wallet-lib/config.cjs')
const { getAvailableProviders, getProviderInfo } = require('./lib/hyper-wallet-lib/blockchain-providers.cjs')
const {
generateToken,
saveToken,
loadToken,
verifyToken,
tokenExists
} = require('./lib/hyper-wallet-lib/auth.cjs')
const {
encrypt,
decrypt,
isValidFormat
} = require('./lib/hyper-wallet-lib/encryption.cjs')
const {
checkRateLimit,
getRateLimitStatus
} = require('./lib/hyper-wallet-lib/rate-limiter.cjs')
const {
validateAddress
} = require('./lib/hyper-wallet-lib/address-validator.cjs')
const {
validateAmount,
validateSufficientBalance,
formatAmount
} = require('./lib/hyper-wallet-lib/amount-validator.cjs')
const {
sanitizeContactName,
sanitizeDeviceName,
sanitizeNotes,
validateChain,
validateNetwork,
validateInviteCode
} = require('./lib/hyper-wallet-lib/input-sanitizer.cjs')
// Load configuration from file
const config = loadConfig()
// Polyfill TextEncoder for QR code generation
if (typeof global.TextEncoder === 'undefined') {
const { TextEncoder, TextDecoder } = require('bare-encoding')
global.TextEncoder = TextEncoder
global.TextDecoder = TextDecoder
}
const QRCodeCore = require('qrcode/lib/core/qrcode')
const SvgRenderer = require('qrcode/lib/renderer/svg')
// Storage configuration
const STORAGE_DIR = path.join(os.homedir(), '.hyper-wallet')
const MNEMONIC_FILE = path.join(STORAGE_DIR, '.mnemonic')
// Password caching (in-memory only, cleared on server restart)
// Security: Passwords never persisted to disk
let cachedPassword = null
function setCachedPassword(password) {
cachedPassword = password
console.log('✅ Password cached in memory (cleared on server restart)')
}
function getCachedPassword() {
return cachedPassword
}
function clearCachedPassword() {
cachedPassword = null
console.log('🔒 Password cleared from memory')
}
// Helper: Load identity mnemonic using cached password (for internal operations)
function loadIdentityMnemonicCached() {
const password = getCachedPassword()
if (!password) {
// Check if file exists and is plaintext (legacy) - can load without password
if (fs.existsSync(MNEMONIC_FILE)) {
const content = fs.readFileSync(MNEMONIC_FILE, 'utf8').trim()
if (content.includes(' ') && content.split(' ').length === 24) {
console.warn('⚠️ Loading plaintext identity mnemonic (legacy format)')
return content
}
}
throw new Error('Password not cached. Please restart wallet or re-initialize.')
}
return loadIdentityMnemonic(password)
}
// Helper: Load wallet mnemonic using cached password (for internal operations)
function loadWalletMnemonicCached() {
const password = getCachedPassword()
if (!password) {
// Check if file exists and is plaintext (legacy) - can load without password
const WALLET_FILE = path.join(STORAGE_DIR, '.wallet-mnemonic')
if (fs.existsSync(WALLET_FILE)) {
const content = fs.readFileSync(WALLET_FILE, 'utf8').trim()
if (content.includes(' ') && content.split(' ').length === 12) {
console.warn('⚠️ Loading plaintext wallet mnemonic (legacy format)')
return content
}
}
throw new Error('Password not cached. Please restart wallet or re-initialize.')
}
return loadWalletMnemonic(password)
}
// Load identity mnemonic (with password-based decryption)
function loadIdentityMnemonic(password) {
if (!fs.existsSync(MNEMONIC_FILE)) {
return null
}
const encrypted = fs.readFileSync(MNEMONIC_FILE, 'utf8').trim()
// Check if this is plaintext (legacy format) - contains spaces and 24 words
if (encrypted.includes(' ') && encrypted.split(' ').length === 24) {
console.warn('⚠️ WARNING: Identity mnemonic is stored in plaintext (legacy format)')
console.warn('⚠️ Please set a password to encrypt your mnemonics')
return encrypted
}
// Decrypt with password
if (!password) {
throw new Error('Password required to decrypt identity mnemonic')
}
try {
return decrypt(encrypted, password)
} catch (err) {
throw new Error('Invalid password or corrupted mnemonic data')
}
}
// Save identity mnemonic (with password-based encryption)
function saveIdentityMnemonic(mnemonic, password) {
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true, mode: 0o700 })
}
// Require password for new mnemonics
if (!password || password.length < 8) {
throw new Error('Password must be at least 8 characters')
}
// Encrypt mnemonic before saving
const encrypted = encrypt(mnemonic, password)
fs.writeFileSync(MNEMONIC_FILE, encrypted, { mode: 0o600 })
}
// Generate identity mnemonic (24 words)
function generateIdentityMnemonic() {
return IdentityKey.generateMnemonic()
}
// Load identity from mnemonic
async function loadIdentity() {
const mnemonic = loadIdentityMnemonicCached()
if (!mnemonic) {
throw new Error('Identity not initialized')
}
return await IdentityKey.from({ mnemonic })
}
const PORT = parseInt(process.env.PORT || config.server?.port || 4004)
const HOST = process.env.WALLET_HOST || config.server?.host || 'localhost'
// Use process.cwd() instead of __dirname for Pear compatibility
// Pear sets cwd to the app bundle directory, but __dirname is empty
const UI_DIR = path.join(process.cwd(), 'desktop')
// Track pending device add sessions
// Map<sessionId, { inviteCode, status, deviceName, error }>
const pendingDeviceAdds = new Map()
// Helper to parse JSON body
async function parseBody(req) {
return new Promise((resolve, reject) => {
let body = ''
req.on('data', chunk => { body += chunk })
req.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {})
} catch (err) {
reject(err)
}
})
req.on('error', reject)
})
}
// Helper to send JSON response
function sendJSON(res, data, status = 200) {
res.statusCode = status
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(data))
}
// Helper to send error
function sendError(res, message, status = 500) {
sendJSON(res, { error: message }, status)
}
/**
* Authentication middleware
* Wraps route handlers to require Bearer token authentication
*
* @param {function} handler - Route handler function
* @returns {function} - Wrapped handler with auth check
*/
function requireAuth(handler) {
return async (req, res, ...params) => {
// Verify Bearer token
if (!verifyToken(req)) {
console.warn(`⚠️ Unauthorized access attempt: ${req.method} ${req.url}`)
return sendError(res, 'Unauthorized - Valid Bearer token required', 401)
}
// Token valid - proceed to handler
return handler(req, res, ...params)
}
}
/**
* Rate limiting middleware
* Wraps route handlers to enforce rate limits per IP address
*
* @param {function} handler - Route handler function
* @returns {function} - Wrapped handler with rate limit check
*/
function withRateLimit(handler) {
return async (req, res, ...params) => {
const result = checkRateLimit(req)
// Set rate limit headers
res.setHeader('X-RateLimit-Limit', String(result.limit))
res.setHeader('X-RateLimit-Remaining', String(result.remaining))
res.setHeader('X-RateLimit-Reset', String(result.resetIn))
if (!result.allowed) {
console.warn(`⚠️ Rate limit exceeded: ${req.method} ${req.url} (IP: ${req.socket.remoteAddress})`)
return sendError(res, `Rate limit exceeded. Try again in ${result.resetIn} seconds.`, 429)
}
// Rate limit OK - proceed to handler
return handler(req, res, ...params)
}
}
// CORS Configuration
// Define allowed origins (whitelist)
const ALLOWED_ORIGINS = [
'http://localhost:4004',
'http://127.0.0.1:4004',
'http://0.0.0.0:4004',
'http://ashatron.local:4004', // Network testing
'http://tabertron.local:4004', // Network testing
'pear://dev' // Pear development mode
// Add production origins here when deploying:
// 'https://your-production-domain.com'
]
// If running under Pear, add the Pear protocol origin
if (isPear && typeof Pear !== 'undefined' && Pear.config?.key) {
ALLOWED_ORIGINS.push(`pear://${Pear.config.key}`)
}
/**
* Set CORS headers with origin whitelist
* @param {ServerResponse} res - HTTP response
* @param {IncomingMessage} req - HTTP request
*/
function setCORS(res, req) {
const origin = req.headers.origin || req.headers.referer
// Check if origin is in whitelist
if (origin && ALLOWED_ORIGINS.some(allowed => origin.startsWith(allowed))) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Credentials', 'true')
} else if (!origin) {
// No origin header (e.g., same-origin request or API client)
// Allow localhost by default for development
res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGINS[0])
} else {
// Origin not in whitelist - log warning but still set a default
console.warn(`⚠️ CORS: Blocked origin: ${origin}`)
res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGINS[0])
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE, PUT')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
}
// API Routes
const routes = {
// GET /api/status (public - health check)
'GET /api/status': async (req, res) => {
// Check if wallet files exist (don't try to decrypt - no password needed for status check)
const identityFileExists = fs.existsSync(MNEMONIC_FILE)
const walletFileExists = fs.existsSync(path.join(STORAGE_DIR, '.wallet-mnemonic'))
const walletInitialized = identityFileExists && walletFileExists
const tokenConfigured = tokenExists()
sendJSON(res, {
status: 'ok',
version: '1.0.0-beta',
initialized: walletInitialized,
authEnabled: tokenConfigured,
passwordCached: getCachedPassword() !== null
})
},
// GET /api/balance/:chain?accountIndex=N (protected)
// Supports both native tokens (bitcoin, ethereum) and ERC-20 tokens (ethereum-usdt)
// WDK design: Application must explicitly specify which account to query
'GET /api/balance/': requireAuth(async (req, res, chain) => {
try {
const walletMnemonic = loadWalletMnemonicCached()
if (!walletMnemonic) {
return sendError(res, 'Wallet not initialized', 400)
}
// Strip query string from chain parameter (route parsing includes it)
const chainPath = chain.split('?')[0]
const network = chainPath.startsWith('testnet') ? 'testnet' : 'mainnet'
const chainParam = chainPath.replace(/^(testnet|mainnet)-/, '')
// Parse accountIndex from query string (e.g., ?accountIndex=2)
const url = new URL(req.url, `http://${req.headers.host}`)
const accountIndexParam = url.searchParams.get('accountIndex')
const accountIndex = accountIndexParam !== null ? parseInt(accountIndexParam) : 0
console.log(`[BALANCE] chain=${chainParam}, accountIndex=${accountIndex}`)
// Check if this is a token balance request (e.g., 'ethereum-usdt')
if (isTokenChain(chainParam)) {
console.log(`[BALANCE] Token balance request:`)
console.log(` Chain: ${chainParam}`)
console.log(` Network: ${network}`)
console.log(` Account Index: ${accountIndex}`)
const tokenInfo = getTokenInfo(chainParam, network === 'testnet')
console.log(` Token: ${tokenInfo.symbol}`)
console.log(` Contract: ${tokenInfo.contractAddress}`)
console.log(` Decimals: ${tokenInfo.decimals}`)
const baseChain = getBaseChain(chainParam)
console.log(` Base Chain: ${baseChain}`)
const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
console.log(` ✅ WDK initialized`)
const account = await wdk.getAccount(baseChain, accountIndex)
console.log(` ✅ Account retrieved`)
const address = await account.getAddress()
console.log(` Address: ${address}`)
// Get token balance using contract address
console.log(` Calling getTokenBalance...`)
const startTime = Date.now()
const balance = await account.getTokenBalance(tokenInfo.contractAddress)
const duration = Date.now() - startTime
console.log(` ✅ Balance retrieved in ${duration}ms: ${balance.toString()}`)
const balanceFormatted = (Number(balance) / Math.pow(10, tokenInfo.decimals)).toFixed(2)
console.log(` Formatted: ${balanceFormatted} ${tokenInfo.symbol}`)
sendJSON(res, {
chain: chainParam,
network,
balance: balance.toString(),
decimals: tokenInfo.decimals,
symbol: tokenInfo.symbol,
isToken: true
})
} else {
// Native token balance
const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
const account = await wdk.getAccount(chainParam, accountIndex)
const balance = await account.getBalance()
sendJSON(res, {
chain: chainParam,
network,
balance: balance.toString()
})
}
} catch (err) {
console.error('Balance error:', err)
sendError(res, err.message)
}
}),
// GET /api/balances/ethereum (protected) - Unified Ethereum view with all token balances
'GET /api/balances/ethereum': requireAuth(async (req, res) => {
try {
const walletMnemonic = loadWalletMnemonicCached()
if (!walletMnemonic) {
return sendError(res, 'Wallet not initialized', 400)
}
// Get current address index from storage
const identity = await loadIdentity()
await initializeStorage(identity)
const currentAddr = await getCurrentAddress('ethereum')
const accountIndex = currentAddr ? currentAddr.index : 0
// Initialize WDK for testnet (can be made configurable later)
const { wdk } = await initializeWDK(walletMnemonic, { testnet: true })
const account = await wdk.getAccount('ethereum', accountIndex)
const address = await account.getAddress()
// Fetch balances with error handling for each
const fetchBalance = async (fn, label) => {
try {
return await fn()
} catch (err) {
console.error(`Failed to fetch ${label} balance:`, err.message)
return BigInt(0)
}
}
const [ethBalance, usdtBalance, usdcBalance] = await Promise.all([
fetchBalance(() => account.getBalance(), 'ETH'),
fetchBalance(() => account.getTokenBalance(TOKEN_CONTRACTS['ethereum-usdt'].testnet), 'USDT'),
fetchBalance(() => account.getTokenBalance(TOKEN_CONTRACTS['ethereum-usdc'].testnet), 'USDC')
])
sendJSON(res, {
address,
network: 'testnet',
balances: {
eth: {
balance: ethBalance.toString(),
symbol: 'ETH',
decimals: 18,
name: 'Ethereum'
},
usdt: {
balance: usdtBalance.toString(),
symbol: 'USDT',
decimals: 6,
name: 'Tether USD',
contract: TOKEN_CONTRACTS['ethereum-usdt'].testnet
},
usdc: {
balance: usdcBalance.toString(),
symbol: 'USDC',
decimals: 6,
name: 'USD Coin',
contract: TOKEN_CONTRACTS['ethereum-usdc'].testnet
}
}
})
} catch (err) {
console.error('Get Ethereum balances error:', err)
sendError(res, err.message)
}
}),
// GET /api/address/:chain - Get current receiving address
// For tokens (e.g., ethereum-usdt), returns the base chain address (ethereum)
'GET /api/address/': requireAuth(async (req, res, chain) => {
try {
const walletMnemonic = loadWalletMnemonicCached()
if (!walletMnemonic) {
return sendError(res, 'Wallet not initialized', 400)
}
const network = chain.startsWith('testnet') ? 'testnet' : 'mainnet'
const chainParam = chain.replace(/^(testnet|mainnet)-/, '')
// For tokens, get the base chain address (ethereum-usdt -> ethereum)
const chainName = getBaseChain(chainParam)
// Check storage for current address first
const identity = await loadIdentity()
await initializeStorage(identity)
// FIX: Use chainParam (ethereum-usdt) not chainName (ethereum) for storage lookup
const currentAddr = await getCurrentAddress(chainParam)
if (currentAddr) {
return sendJSON(res, {
chain: chainName,
network,
address: currentAddr.address,
index: currentAddr.index,
label: currentAddr.label || ''
})
}
// Fallback: derive from WDK if no addresses stored
const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
const account = await wdk.getAccount(chainName, 0)
const address = await account.getAddress()
// Store the first address automatically
const addressData = {
chain: chainParam, // FIX: Store with original chain name (ethereum-usdt)
network,
address,
path: `m/44'/${chainName === 'bitcoin' ? 0 : 60}'/0'/0/0`,
index: 0,
label: '',
isCurrent: true // First address is current by default
}
try {
await storeAddress(addressData)
} catch (storeErr) {
console.warn('Failed to store initial address:', storeErr.message)
// Continue anyway - return the address even if storage fails
}
sendJSON(res, {
chain: chainName,
network,
address,
index: 0,
label: ''
})
} catch (err) {
console.error('Address error:', err)
sendError(res, err.message)
}
}),
// GET /api/addresses/:chain - List all wallet addresses
'GET /api/addresses/': requireAuth(async (req, res, chain) => {
try {
const identity = await loadIdentity()
await initializeStorage(identity)
const chainName = chain.replace(/^(testnet|mainnet)-/, '')
const addresses = await getAddresses(chainName)
// Filter out archived unless requested
const filtered = addresses.filter(a => !a.isArchived)
sendJSON(res, {
chain: chainName,
count: filtered.length,
addresses: filtered
})
} catch (err) {
console.error('List addresses error:', err)
sendError(res, err.message)
}
}),
// POST /api/addresses/ - Derive new address OR set as current
'POST /api/addresses/': requireAuth(async (req, res, ...params) => {
try {
// params: ['testnet-bitcoin', 'derive'] OR ['testnet-bitcoin', '0', 'current']
const [chain, ...rest] = params
if (!chain) {
return sendError(res, 'Missing chain parameter', 400)
}
// Check if this is a "set current" operation
if (rest.length === 2 && rest[1] === 'current') {
// POST /api/addresses/testnet-bitcoin/0/current
const indexStr = rest[0]
const index = parseInt(indexStr)
const identity = await loadIdentity()
await initializeStorage(identity)
const chainName = chain.replace(/^(testnet|mainnet)-/, '')
const updated = await setCurrentAddress(chainName, index)
return sendJSON(res, {
success: true,
address: updated
})
}
// Otherwise, it's a derive operation
// POST /api/addresses/testnet-bitcoin/derive
if (rest[0] !== 'derive') {
return sendError(res, 'Invalid operation. Use /derive to create new address', 400)
}
const body = await parseBody(req)
const { label } = body
const walletMnemonic = loadWalletMnemonicCached()
if (!walletMnemonic) {
return sendError(res, 'Wallet not initialized', 400)
}
const identity = await loadIdentity()
await initializeStorage(identity)
const network = chain.startsWith('testnet') ? 'testnet' : 'mainnet'
const chainParam = chain.replace(/^(testnet|mainnet)-/, '')
// For tokens, get the base chain (ethereum-usdt -> ethereum)
const chainName = getBaseChain(chainParam)
// Get next index
const nextIndex = await getNextAddressIndex(chainName)
// Derive address from WDK using full BIP44 path (use base chain)
const coinType = chainName === 'bitcoin' ? 0 : 60
const addressPath = `${coinType}'/0'/0/${nextIndex}` // account'/change/address
const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
const account = await wdk.getAccountByPath(chainName, addressPath)
const address = await account.getAddress()
// Store in database (use original chainParam for storage key)
const addressData = {
chain: chainParam, // Store with original name (ethereum-usdt)
network,
address,
path: `m/44'/${addressPath}`,
index: nextIndex,
label: label || ''
}
await storeAddress(addressData)
sendJSON(res, {
success: true,
address: addressData
})
} catch (err) {
console.error('POST /api/addresses/ error:', err)
sendError(res, err.message)
}
}),
// PUT /api/addresses/ - Update address label
'PUT /api/addresses/': requireAuth(async (req, res, ...params) => {
try {
// params: ['testnet-bitcoin', '0']
const [chain, indexStr, ...rest] = params
const index = parseInt(indexStr)
const body = await parseBody(req)
const { label } = body
const identity = await loadIdentity()
await initializeStorage(identity)
const chainName = chain.replace(/^(testnet|mainnet)-/, '')
const updated = await updateAddressLabel(chainName, index, label || '')
sendJSON(res, {
success: true,
address: updated
})
} catch (err) {
console.error('Update address error:', err)
sendError(res, err.message)
}
}),
// DELETE /api/addresses/ - Archive address
'DELETE /api/addresses/': requireAuth(async (req, res, ...params) => {
try {
// params: ['testnet-bitcoin', '0']
const [chain, indexStr, ...rest] = params
const index = parseInt(indexStr)
const identity = await loadIdentity()
await initializeStorage(identity)
const chainName = chain.replace(/^(testnet|mainnet)-/, '')
const archived = await archiveAddress(chainName, index)
sendJSON(res, {
success: true,
address: archived
})
} catch (err) {
console.error('Archive address error:', err)
sendError(res, err.message)
}
}),
// GET /api/qrcode/:chain
// For tokens (ethereum-usdt), returns QR code with base chain address (ethereum)
'GET /api/qrcode/': requireAuth(async (req, res, chain) => {
try {
const walletMnemonic = loadWalletMnemonicCached()
if (!walletMnemonic) {
return sendError(res, 'Wallet not initialized', 400)
}
const network = chain.startsWith('testnet') ? 'testnet' : 'mainnet'
const chainParam = chain.replace(/^(testnet|mainnet)-/, '')
// For tokens, get the base chain address (ethereum-usdt -> ethereum)
const chainName = getBaseChain(chainParam)
// Get current address index from storage (defaults to 0)
const identity = await loadIdentity()
await initializeStorage(identity)
// FIX: Use chainParam (ethereum-usdt) not chainName (ethereum) for storage lookup
const currentAddr = await getCurrentAddress(chainParam)
const accountIndex = currentAddr ? currentAddr.index : 0
const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
const account = await wdk.getAccount(chainName, accountIndex)
const address = await account.getAddress()
// Generate QR code as SVG data URL (no fs dependency)
const qrData = QRCodeCore.create(address, { errorCorrectionLevel: 'M' })
const svg = await SvgRenderer.render(qrData, { width: 256, margin: 2 })
const qrcode = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
sendJSON(res, {
chain: chainParam, // Return original chain name
network,
address,
qrcode
})
} catch (err) {
console.error('QR code error:', err)
sendError(res, err.message)
}
}),
// GET /api/transactions/:chain
'GET /api/transactions/': requireAuth(async (req, res, chain) => {
try {
const identity = await loadIdentity()
await initializeStorage(identity)
const chainFilter = chain === 'all' ? 'all' : chain.replace(/^(testnet|mainnet)-/, '')
const transactions = await getTransactions(chainFilter)
// Debug: Log ethereum-usdt transactions
const usdtTxs = transactions.filter(tx => tx.chain === 'ethereum-usdt')
if (usdtTxs.length > 0) {
console.log(` 💰 Found ${usdtTxs.length} USDT transactions:`)
usdtTxs.forEach(tx => {
console.log(` ${tx.hash.substring(0, 16)}... - ${tx.direction} - ${tx.amount}`)
})
} else {
console.log(` ⚠️ No ethereum-usdt transactions found in database`)
}
// Note: Storage remains open (persistent daemon mode)
sendJSON(res, {
chain: chainFilter,
count: transactions.length,
transactions: transactions.map(tx => ({
hash: tx.hash,
chain: tx.chain,
network: tx.network || 'testnet', // Add network field
direction: tx.direction,
from: tx.from,
to: tx.to,
amount: tx.amount.toString(),
fee: tx.fee.toString(),
status: tx.status,
timestamp: tx.timestamp,
blockHeight: tx.blockHeight
}))
})
} catch (err) {
console.error('Transactions error:', err)
sendError(res, err.message)
}
}),
// POST /api/transactions/sync - Sync transactions from blockchain
'POST /api/transactions/sync': withRateLimit(requireAuth(async (req, res) => {
try {
const body = await parseBody(req)
const { chain, network, accountIndex } = body
if (!chain) {
return sendError(res, 'Missing required field: chain', 400)
}
const identity = await loadIdentity()
await initializeStorage(identity)
// Initialize WDK
const walletMnemonic = loadWalletMnemonicCached()
if (!walletMnemonic) {
return sendError(res, 'Wallet not initialized', 400)
}
// Determine network - use explicit parameter if provided, otherwise try to extract from chain
let finalNetwork = network
if (!finalNetwork) {
const networkMatch = chain.match(/^(testnet|mainnet)-/)
finalNetwork = networkMatch ? networkMatch[1] : 'testnet'
}
// Strip network prefix from chain if present (testnet-ethereum-usdt -> ethereum-usdt)
const chainName = chain.replace(/^(testnet|mainnet)-/, '')
// Use explicit account index (WDK design pattern)
const finalAccountIndex = accountIndex !== undefined ? accountIndex : 0
console.log(`[SYNC] chain=${chainName}, network=${finalNetwork}, accountIndex=${finalAccountIndex}`)
const { initializeWDK } = require('./lib/hyper-wallet-lib/wdk-integration.cjs')
const { wdk } = await initializeWDK(walletMnemonic, { testnet: finalNetwork === 'testnet' })
const result = await syncTransactionsFromWDK(wdk, {
chains: [chainName],
network: finalNetwork,
accountIndex: finalAccountIndex // Pass explicit account index
})
console.log(`[SYNC] Result: synced=${result.synced}, errors=${result.errors.length}`)
sendJSON(res, {
success: true,
message: `Synced ${result.synced} new transactions`,
result
})
} catch (err) {
console.error('Sync transactions error:', err)
sendError(res, err.message)
}
})),
// GET /api/status
// GET /api/whoami
'GET /api/whoami': requireAuth(async (req, res) => {
try {
const identityMnemonic = loadIdentityMnemonicCached()
if (!identityMnemonic) {
return sendError(res, 'Identity not initialized', 400)
}
const identity = await loadIdentity()
const publicKey = b4a.toString(identity.identityKeyPair.publicKey, 'hex')
sendJSON(res, {
publicKey,
hostname: os.hostname(),
platform: os.platform()
})
} catch (err) {
console.error('WhoAmI error:', err)
sendError(res, err.message)
}
}),
// GET /api/sync-status
'GET /api/sync-status': requireAuth(async (req, res) => {
try {
// For now, return mock data
// TODO: Integrate with actual Autobase sync status
sendJSON(res, {
syncing: false,
deviceCount: 1,
lastSync: Date.now()
})
} catch (err) {
sendError(res, err.message)
}
}),
// POST /api/export-identity (changed to POST to accept password in body)
'POST /api/export-identity': withRateLimit(requireAuth(async (req, res) => {
try {
const body = await parseBody(req)
const { password } = body
if (!password) {
return sendError(res, 'Password required to decrypt identity mnemonic', 400)
}
const mnemonic = loadIdentityMnemonic(password)
if (!mnemonic) {
return sendError(res, 'Identity not initialized', 400)
}
sendJSON(res, {
mnemonic,
warning: 'CRITICAL: Keep this safe! This is for device attestation.'
})
} catch (err) {
console.error('Export identity error:', err)
// Provide user-friendly error for wrong password
if (err.message.includes('Invalid password')) {
return sendError(res, 'Invalid password', 401)
}
sendError(res, err.message)
}
})),
// POST /api/export-wallet (changed to POST to accept password in body)
'POST /api/export-wallet': withRateLimit(requireAuth(async (req, res) => {
try {
const body = await parseBody(req)
const { password } = body
if (!password) {
return sendError(res, 'Password required to decrypt wallet mnemonic', 400)
}
const mnemonic = loadWalletMnemonic(password)
if (!mnemonic) {
return sendError(res, 'Wallet not initialized', 400)
}
sendJSON(res, {
mnemonic,
warning: 'CRITICAL: Keep this safe! This controls your funds.'
})
} catch (err) {
console.error('Export wallet error:', err)
// Provide user-friendly error for wrong password
if (err.message.includes('Invalid password')) {
return sendError(res, 'Invalid password', 401)
}
sendError(res, err.message)
}
})),
// POST /api/reset
'POST /api/reset': requireAuth(async (req, res) => {
try {
// Delete identity mnemonic
if (fs.existsSync(MNEMONIC_FILE)) {
fs.unlinkSync(MNEMONIC_FILE)
}
// Delete wallet mnemonic
const walletMnemonicFile = path.join(STORAGE_DIR, '.wallet-mnemonic')
if (fs.existsSync(walletMnemonicFile)) {
fs.unlinkSync(walletMnemonicFile)
}
// Note: This doesn't delete Autobase/Hyperbee data
// Users would need to manually delete ~/.hyper-wallet for full reset
sendJSON(res, {
success: true,
message: 'Wallet reset complete. Please refresh the page.'
})
} catch (err) {
console.error('Reset error:', err)
sendError(res, err.message)
}
}),
// POST /api/device/add - Create invite and start pairing
'POST /api/device/add': withRateLimit(requireAuth(async (req, res) => {
try {
const identity = await loadIdentity()
const identityMnemonic = loadIdentityMnemonicCached()
const walletMnemonic = loadWalletMnemonicCached()
if (!walletMnemonic) {
return sendError(res, 'Wallet not initialized', 400)
}
await initializeStorage(identity)
// Create invite code
const inviteCode = await createInvite(identity)
// Generate session ID
const sessionId = b4a.toString(b4a.from(Math.random().toString()), 'hex').slice(0, 16)
// Store pending session
pendingDeviceAdds.set(sessionId, {
inviteCode,
status: 'waiting',
deviceName: null,
error: null
})
// Start waiting for device in background
;(async () => {
try {
const { writerKey, deviceName } = await waitForNewDevice(0, identityMnemonic, walletMnemonic)
pendingDeviceAdds.set(sessionId, {
inviteCode,
status: 'completed',
deviceName,
writerKey: b4a.toString(writerKey, 'hex'),
error: null
})
console.log(`✅ Device added: ${deviceName}`)
} catch (err) {
pendingDeviceAdds.set(sessionId, {
inviteCode,
status: 'error',
deviceName: null,
error: err.message
})
console.error('Device add error:', err)
}
})()
// Return immediately with invite and session ID
sendJSON(res, {
sessionId,
inviteCode,
status: 'waiting',
message: 'Share this invite code with your other device'
})
} catch (err) {
console.error('Create invite error:', err)
sendError(res, err.message)
}
})),
// GET /api/device/add/:sessionId - Check pairing status
'GET /api/device/add/': requireAuth(async (req, res, sessionId) => {
const session = pendingDeviceAdds.get(sessionId)
if (!session) {
return sendError(res, 'Session not found', 404)
}
sendJSON(res, session)
}),
// POST /api/device/join - Join wallet using invite code
'POST /api/device/join': withRateLimit(requireAuth(async (req, res) => {
try {
const body = await parseBody(req)
let { inviteCode, deviceName } = body
if (!inviteCode) {
return sendError(res, 'Invite code required', 400)
}
if (!deviceName) {
return sendError(res, 'Device name required', 400)
}
// Validate and sanitize inputs
try {
inviteCode = validateInviteCode(inviteCode)
deviceName = sanitizeDeviceName(deviceName)
} catch (err) {
return sendError(res, err.message, 400)
}
// Join wallet (receives BOTH mnemonics from primary device)
// This will also verify invite signature and expiration
const { device, identityMnemonic, walletMnemonic } = await joinWallet(inviteCode, deviceName)
// Save both mnemonics
saveIdentityMnemonic(identityMnemonic)
saveWalletMnemonic(walletMnemonic)
sendJSON(res, {
success: true,
message: 'Joined wallet successfully',
device: {
name: device.name,
writerKey: b4a.toString(device.key, 'hex').slice(0, 16) + '...'
}
})
} catch (err) {
console.error('Join wallet error:', err)
sendError(res, err.message)
}
})),
// GET /api/device/list - List all devices
'GET /api/device/list': requireAuth(async (req, res) => {
try {
const identity = await loadIdentity()
await initializeStorage(identity)
const devices = await listDevices()
sendJSON(res, {
devices: devices.map(d => {
const writerKey = b4a.toString(d.key, 'hex')
const identityKey = d.identityKey ? b4a.toString(d.identityKey, 'hex') : null
return {
name: d.name,
writerKey: writerKey,
writerKeyShort: writerKey.slice(0, 16) + '...',
identityKey: identityKey,
identityKeyShort: identityKey ? identityKey.slice(0, 16) + '...' : null,
addedAt: d.addedAt,
isCurrentDevice: d.isCurrentDevice || d.isLocal || false
}
})
})
// Note: Storage remains open (persistent daemon mode)
} catch (err) {
console.error('List devices error:', err)
sendError(res, err.message)
}
}),
// POST /api/device/remove - Remove device
'POST /api/device/remove': requireAuth(async (req, res) => {
try {
const body = await parseBody(req)
const { deviceKey } = body
if (!deviceKey) {
return sendError(res, 'Device key required', 400)
}
const identity = await loadIdentity()
await initializeStorage(identity)
const keyBuffer = b4a.from(deviceKey, 'hex')
await removeDevice(keyBuffer)
sendJSON(res, {
success: true,
message: 'Device removed successfully'
})
// Note: Storage remains open (persistent daemon mode)
} catch (err) {
console.error('Remove device error:', err)
sendError(res, err.message)
}
}),
// POST /api/device/deduplicate - Remove duplicate device entries
'POST /api/device/deduplicate': requireAuth(async (req, res) => {
try {
const result = deduplicateDevices()
sendJSON(res, {
success: true,
removed: result.removed,
total: result.total,
message: `Removed ${result.removed} duplicate(s), ${result.total} device(s) remaining`
})
} catch (err) {
console.error('Deduplicate error:', err)
sendError(res, err.message)
}
}),
// GET /api/contacts - Get all contacts
'GET /api/contacts': requireAuth(async (req, res) => {
try {
const identity = await loadIdentity()
await initializeStorage(identity)
const contacts = await getContacts()
sendJSON(res, {
success: true,
count: contacts.length,
contacts
})
} catch (err) {
console.error('Get contacts error:', err)
sendError(res, err.message)
}
}),
// GET /api/contacts/:chain - Get contacts for specific chain
'GET /api/contacts/': requireAuth(async (req, res, chain) => {
try {
const identity = await loadIdentity()
await initializeStorage(identity)
const contacts = await getContacts(chain)
sendJSON(res, {
success: true,
count: contacts.length,
chain,
contacts
})
} catch (err) {
console.error('Get contacts by chain error:', err)
sendError(res, err.message)
}
}),
// POST /api/contacts - Add new contact
'POST /api/contacts': requireAuth(async (req, res) => {
try {
const body = await parseBody(req)
let { address, chain, name, notes } = body
if (!address || !chain || !name) {
return sendError(res, 'Missing required fields: address, chain, name', 400)
}
// Validate and sanitize chain
try {
chain = validateChain(chain)
} catch (err) {
return sendError(res, err.message, 400)
}
// Validate address with checksum
const config = getConfig()
const network = config.network || 'testnet'
const addressValidation = validateAddress(address, chain, network)
if (!addressValidation.valid) {
return sendError(res, `Invalid address: ${addressValidation.reason}`, 400)
}
if (addressValidation.warning) {
console.warn(`⚠️ Address validation warning: ${addressValidation.warning}`)
}
// Sanitize name and notes
try {
name = sanitizeContactName(name)
notes = notes ? sanitizeNotes(notes) : ''
} catch (err) {
return sendError(res, err.message, 400)
}
const identity = await loadIdentity()
await initializeStorage(identity)
// Check if contact already exists for this address
const existing = await findContact(address, chain)
if (existing) {
return sendError(res, `Contact already exists: ${existing.name}`, 400)
}
const contact = await addContact({ address, chain, name, notes })
sendJSON(res, {
success: true,
message: 'Contact added successfully',
contact
})
} catch (err) {
console.error('Add contact error:', err)
sendError(res, err.message)
}
}),
// PUT /api/contacts/:id - Update contact
'PUT /api/contacts/': requireAuth(async (req, res, id) => {
try {
const body = await parseBody(req)
let { name, notes, metadata } = body
if (!id) {
return sendError(res, 'Contact ID required', 400)
}
// Sanitize name and notes if provided
try {
if (name !== undefined) {
name = sanitizeContactName(name)
}
if (notes !== undefined) {
notes = sanitizeNotes(notes)
}
} catch (err) {
return sendError(res, err.message, 400)
}
const identity = await loadIdentity()
await initializeStorage(identity)
const updates = {}
if (name !== undefined) updates.name = name
if (notes !== undefined) updates.notes = notes
if (metadata !== undefined) updates.metadata = metadata
const contact = await updateContact(id, updates)
sendJSON(res, {
success: true,
message: 'Contact updated successfully',
contact
})
} catch (err) {
console.error('Update contact error:', err)
sendError(res, err.message)
}
}),
// NOTE: DELETE /api/contacts/ handler is below (handles both contact deletion and address removal)
// POST /api/contacts/:id/addresses - Add address to contact
'POST /api/contacts/': requireAuth(async (req, res, id, ...rest) => {
console.log('📝 POST /api/contacts/ route hit:', { id, rest })
// Check if this is the /addresses sub-route
if (rest[0] === 'addresses') {
console.log('✅ /addresses sub-route detected')
try {
if (!id) {
console.log('❌ No contact ID provided')
return sendError(res, 'Contact ID required', 400)
}
const body = await parseBody(req)
console.log('📦 Request body:', { chain: body.chain, address: body.address, network: body.network, label: body.label })
let { chain, address, network, label } = body
if (!chain || !address) {
console.log('❌ Missing required fields:', { chain, address })
return sendError(res, 'Missing required fields: chain, address', 400)
}
// Validate chain
try {
chain = validateChain(chain)
console.log('✅ Chain validated:', chain)
} catch (err) {
console.log('❌ Chain validation error:', err.message)
return sendError(res, err.message, 400)
}
// Validate network
if (!network) {
const config = getConfig()
network = config.network || 'testnet'
}
console.log('🌐 Network:', network)
// Validate address
console.log('🔍 Validating address:', { address, chain, network })
const addressValidation = validateAddress(address, chain, network)
console.log('📋 Address validation result:', addressValidation)
if (!addressValidation.valid) {
console.log('❌ Address validation failed:', addressValidation.reason)
return sendError(res, `Invalid address: ${addressValidation.reason}`, 400)
}
if (addressValidation.warning) {
console.warn(`⚠️ Address validation warning: ${addressValidation.warning}`)
}
console.log('💾 Loading identity and initializing storage...')
const identity = await loadIdentity()
await initializeStorage(identity)
console.log('➕ Adding address to contact...')
const contact = await addAddressToContact(id, { chain, address, network, label })
console.log('✅ Address added successfully:', contact.id)
sendJSON(res, {
success: true,
message: 'Address added to contact successfully',
contact
})
} catch (err) {
console.error('❌ Add address to contact error:', err)
console.error('Stack trace:', err.stack)
sendError(res, err.message)
}
return
}
// If not /addresses, return 404
console.log('❌ Not /addresses sub-route, returning 404')
sendError(res, 'Not found', 404)
}),
// DELETE /api/contacts/:id/addresses/:addressId - Remove address from contact by ID
'DELETE /api/contacts/': requireAuth(async (req, res, id, ...rest) => {
console.log('🗑️ DELETE /api/contacts/ route hit')
console.log(' Full URL:', req.url)
console.log(' id parameter:', id)
console.log(' rest array:', rest)
console.log(' rest[0]:', rest[0])
console.log(' rest[1]:', rest[1])
// Check if this is the /addresses/:addressId sub-route
if (rest[0] === 'addresses' && rest[1]) {
console.log('✅ Matched addresses sub-route')
console.log(' Contact ID:', id)
console.log(' Address ID:', rest[1])
try {
if (!id) {
console.log('❌ No contact ID provided')
return sendError(res, 'Contact ID required', 400)
}
const addressId = rest[1]
console.log('📞 Calling removeAddressFromContact with:', { id, addressId })
const identity = await loadIdentity()
await initializeStorage(identity)
const contact = await removeAddressFromContact(id, addressId)
console.log('✅ Successfully removed address, updated contact:', contact)
sendJSON(res, {
success: true,
message: 'Address removed from contact successfully',
contact
})
} catch (err) {
console.error('❌ Remove address from contact error:', err)
console.error(' Error stack:', err.stack)
sendError(res, err.message)
}
return
}
// If rest is empty, this is DELETE /api/contacts/:id (delete entire contact)
if (rest.length === 0) {
console.log('✅ Matched delete contact (no sub-route)')
try {
if (!id) {
console.log('❌ No contact ID provided')
return sendError(res, 'Contact ID required', 400)
}
console.log('📞 Calling deleteContact with ID:', id)
const identity = await loadIdentity()
await initializeStorage(identity)
await deleteContact(id)
console.log('✅ Successfully deleted contact')
sendJSON(res, {
success: true,
message: 'Contact deleted successfully'
})
} catch (err) {
console.error('❌ Delete contact error:', err)
console.error(' Error stack:', err.stack)
sendError(res, err.message)
}
return
}
// Unknown sub-route
console.log('❌ Unknown sub-route')
console.log(' rest[0]:', rest[0])
console.log(' Expected: empty for delete contact, or "addresses" for delete address')
sendError(res, 'Not found', 404)
}),
// GET /api/contacts/search?q=name - Search contacts
'GET /api/contacts/search': requireAuth(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`)
const query = url.searchParams.get('q')
if (!query) {
return sendError(res, 'Search query required', 400)
}
const identity = await loadIdentity()
await initializeStorage(identity)
const contact = await findContact(query)
if (!contact) {
sendJSON(res, {
success: true,
found: false,
contact: null
})
} else {
sendJSON(res, {
success: true,
found: true,
contact
})
}
} catch (err) {
console.error('Search contact error:', err)
sendError(res, err.message)
}
}),
// GET /api/settings - Get current settings
'GET /api/settings': requireAuth(async (req, res) => {
try {
const settings = getConfig()
sendJSON(res, {
success: true,
settings
})
} catch (err) {
console.error('Get settings error:', err)
sendError(res, err.message)
}
}),
// PUT /api/settings - Update a setting
'PUT /api/settings': requireAuth(async (req, res) => {
try {
const body = await parseBody(req)
const { key, value } = body
if (!key || value === undefined) {
return sendError(res, 'Missing required fields: key, value', 400)
}
updateConfig(key, value)
sendJSON(res, {
success: true,
message: 'Setting updated',
key,
value
})
} catch (err) {
console.error('Update setting error:', err)
sendError(res, err.message)
}
}),
// GET /api/settings/providers/:chain - Get available providers for chain
'GET /api/settings/providers/': requireAuth(async (req, res, chain) => {
try {
if (!chain || (chain !== 'bitcoin' && chain !== 'ethereum')) {
return sendError(res, 'Invalid chain. Must be bitcoin or ethereum', 400)
}
const providers = getAvailableProviders(chain)
const providerDetails = providers.map(name => {
const info = getProviderInfo(chain, name)
return {
name,
displayName: info.name,
needsApiKey: info.needsApiKey,
usesNativeWDK: info.usesNativeWDK || false
}
})
sendJSON(res, {
success: true,
chain,
providers: providerDetails
})
} catch (err) {
console.error('Get providers error:', err)
sendError(res, err.message)
}
}),
// POST /api/settings/reset - Reset settings to defaults
'POST /api/settings/reset': requireAuth(async (req, res) => {
try {
saveConfig(DEFAULT_CONFIG)
sendJSON(res, {
success: true,
message: 'Settings reset to defaults'
})
} catch (err) {
console.error('Reset settings error:', err)
sendError(res, err.message)
}
}),
// POST /api/estimate-fee
// Supports both native tokens and ERC-20 tokens (ethereum-usdt)
'POST /api/estimate-fee': withRateLimit(requireAuth(async (req, res) => {
try {
const body = await parseBody(req)
let { chain, network, to, amount } = body
if (!chain || !network || !to || !amount) {
return sendError(res, 'Missing required fields', 400)
}
// Validate network
try {
network = validateNetwork(network)
} catch (err) {
return sendError(res, err.message, 400)
}
const walletMnemonic = loadWalletMnemonicCached()
if (!walletMnemonic) {
return sendError(res, 'Wallet not initialized', 400)
}
// Check if this is a token transfer
if (isTokenChain(chain)) {
const tokenInfo = getTokenInfo(chain, network === 'testnet')
const baseChain = getBaseChain(chain)
// Validate recipient address (use base chain)
const addressValidation = validateAddress(to, baseChain, network)
if (!addressValidation.valid) {
return sendError(res, `Invalid recipient address: ${addressValidation.reason}`, 400)
}
// Validate amount
const amountValidation = validateAmount(amount, chain)
if (!amountValidation.valid) {
return sendError(res, `Invalid amount: ${amountValidation.reason}`, 400)
}
const amountBigInt = amountValidation.amount
// Get current address index from storage
const identity = await loadIdentity()
await initializeStorage(identity)
const currentAddr = await getCurrentAddress(chain) // Use full chain name (ethereum-usdt)
const accountIndex = currentAddr ? currentAddr.index : 0
const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
const account = await wdk.getAccount(baseChain, accountIndex) // FIX: Use stored index, not hardcoded 0
let fee
try {
// Quote token transfer (fee in ETH, not USDT)
const quote = await account.quoteTransfer({
token: tokenInfo.contractAddress,
recipient: to,
amount: amountBigInt
})
fee = quote.fee
} catch (quoteErr) {
// On testnet, the token contract may not exist or be valid
// Fall back to estimated gas: ~65000 gas for ERC-20 transfer
if (network === 'testnet') {
console.warn(`Token fee estimation failed on testnet, using estimate: ${quoteErr.message}`)
// Get current gas price and estimate fee (65000 gas * gasPrice)
try {
const provider = account.provider || wdk.provider
const feeData = await provider.getFeeData()
const gasPrice = feeData.gasPrice || BigInt(20000000000) // 20 gwei fallback
fee = BigInt(65000) * gasPrice
} catch (priceErr) {
// Complete fallback: assume 20 gwei gas price
fee = BigInt(65000) * BigInt(20000000000) // ~0.0013 ETH
}
} else {
throw quoteErr // Re-throw on mainnet - need accurate fee
}
}
sendJSON(res, {
fee: fee.toString(),
total: amountBigInt.toString(), // For tokens, total = amount (fee is separate in ETH)
formatted: {
fee: formatAmount(fee, baseChain), // Fee in ETH
amount: `${Number(amountBigInt) / Math.pow(10, tokenInfo.decimals)} ${tokenInfo.symbol}`,
total: `${Number(amountBigInt) / Math.pow(10, tokenInfo.decimals)} ${tokenInfo.symbol} + ${formatAmount(fee, baseChain)} gas`
},
estimated: network === 'testnet' // Flag if this is an estimate
})
} else {
// Native token fee estimation (existing code)
try {
chain = validateChain(chain)
} catch (err) {
return sendError(res, err.message, 400)
}
const addressValidation = validateAddress(to, chain, network)
if (!addressValidation.valid) {
return sendError(res, `Invalid recipient address: ${addressValidation.reason}`, 400)
}
const amountValidation = validateAmount(amount, chain)
if (!amountValidation.valid) {
return sendError(res, `Invalid amount: ${amountValidation.reason}`, 400)
}
const amountBigInt = amountValidation.amount
// Get current address index from storage
const identity = await loadIdentity()
await initializeStorage(identity)
const currentAddr = await getCurrentAddress(chain)
const accountIndex = currentAddr ? currentAddr.index : 0
const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
const account = await wdk.getAccount(chain, accountIndex) // FIX: Use stored index, not hardcoded 0
const quote = await account.quoteSendTransaction({
to,
value: amountBigInt
})
sendJSON(res, {
fee: quote.fee.toString(),
total: (amountBigInt + quote.fee).toString(),
formatted: {
fee: formatAmount(quote.fee, chain),
total: formatAmount(amountBigInt + quote.fee, chain)
}
})
}
} catch (err) {
console.error('Fee estimation error:', err)
sendError(res, err.message)
}
})),
// POST /api/send
// Supports both native tokens and ERC-20 tokens (ethereum-usdt)
// Supports sendMax flag for sending maximum available balance minus fees
'POST /api/send': withRateLimit(requireAuth(async (req, res) => {
try {
const body = await parseBody(req)
let { chain, network, to, amount, sendMax } = body
if (!chain || !network || !to) {
return sendError(res, 'Missing required fields', 400)
}
// For sendMax, amount is optional (will be calculated)
if (!sendMax && !amount) {
return sendError(res, 'Amount required when sendMax is not set', 400)
}
// Validate and sanitize network
try {
network = validateNetwork(network)
} catch (err) {
return sendError(res, err.message, 400)
}
const walletMnemonic = loadWalletMnemonicCached()
if (!walletMnemonic) {
return sendError(res, 'Wallet not initialized', 400)
}
// Check if this is a token transfer (e.g., ethereum-usdt)
if (isTokenChain(chain)) {
const tokenInfo = getTokenInfo(chain, network === 'testnet')
const baseChain = getBaseChain(chain)
// Validate recipient address (use base chain for validation)
const addressValidation = validateAddress(to, baseChain, network)
if (!addressValidation.valid) {
return sendError(res, `Invalid recipient address: ${addressValidation.reason}`, 400)
}
// Validate amount
const amountValidation = validateAmount(amount, chain)
if (!amountValidation.valid) {
return sendError(res, `Invalid amount: ${amountValidation.reason}`, 400)
}
const amountBigInt = amountValidation.amount
// Get current address index (not hardcoded 0)
const currentAddr = await getCurrentAddress(baseChain)
const accountIndex = currentAddr ? currentAddr.index : 0
const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
const account = await wdk.getAccount(baseChain, accountIndex)
// Check token balance (with testnet fallback)
let tokenBalance
try {
tokenBalance = await account.getTokenBalance(tokenInfo.contractAddress)
} catch (balanceErr) {
if (network === 'testnet') {
console.warn(`Token balance check failed on testnet (contract may not exist): ${balanceErr.message}`)
// On testnet, skip balance check if contract doesn't exist
// User is responsible for ensuring they have tokens
tokenBalance = amountBigInt + BigInt(1) // Allow the transfer to proceed
} else {
return sendError(res, `Cannot check ${tokenInfo.symbol} balance: ${balanceErr.message}`, 400)
}
}
if (amountBigInt > tokenBalance) {
return sendError(res, `Insufficient ${tokenInfo.symbol} balance`, 400)
}
// Check native balance for gas (ETH needed for gas fees)
const ethBalance = await account.getBalance()
// Get fee estimate for token transfer (with testnet fallback)
let fee
try {
const quote = await account.quoteTransfer({
token: tokenInfo.contractAddress,
recipient: to,
amount: amountBigInt
})
fee = quote.fee
} catch (quoteErr) {
if (network === 'testnet') {
// Testnet contracts may not exist - use estimate
console.warn(`Token fee estimation failed on testnet, using estimate: ${quoteErr.message}`)
const provider = account.provider || (await wdk.getProvider(baseChain))
const feeData = await provider.getFeeData()
const gasPrice = feeData.gasPrice || BigInt(20000000000) // 20 gwei fallback
fee = BigInt(65000) * gasPrice // ERC-20 transfer ~65k gas
} else {
console.error('Fee estimation failed:', quoteErr)
return sendError(res, `Cannot estimate fee: ${quoteErr.message}`, 400)
}
}
// Check sufficient ETH for gas
if (fee > ethBalance) {
return sendError(res, `Insufficient ETH for gas (need ${formatAmount(fee, baseChain)})`, 400)
}
// Transfer token
let result
try {
result = await account.transfer({
token: tokenInfo.contractAddress,
recipient: to,
amount: amountBigInt
})
} catch (transferErr) {
console.error('Token transfer failed:', transferErr)
if (network === 'testnet') {
return sendError(res, `Testnet ${tokenInfo.symbol} transfer failed - the testnet token contract may not exist. Error: ${transferErr.message}`, 400)
}
return sendError(res, `Token transfer failed: ${transferErr.message}`, 500)
}
console.log(`✅ Token transfer sent: ${result.hash}`)
console.log(` Token: ${tokenInfo.symbol}`)
console.log(` Amount: ${Number(amountBigInt) / Math.pow(10, tokenInfo.decimals)}`)
console.log(` Fee: ${formatAmount(fee, baseChain)}`)
console.log(` To: ${to}`)
// Store transaction locally so it appears immediately
try {
const fromAddress = await account.getAddress()
await storeTransaction({
hash: result.hash,
chain: chain, // e.g., 'ethereum-usdc'
network,
direction: 'sent',
from: fromAddress,
to,
amount: amountBigInt.toString(),
fee: (result.fee || fee).toString(),
timestamp: Date.now(),
status: 'pending',
token: tokenInfo.symbol
})
console.log(` 💾 Transaction stored locally`)
} catch (storeErr) {
console.warn(' ⚠️ Failed to store transaction locally:', storeErr.message)
// Non-critical - transaction still succeeded on-chain
}
sendJSON(res, {
hash: result.hash,
fee: result.fee.toString(),
amount: amountBigInt.toString()
})
} else {
// Native token transfer (existing code)
try {
chain = validateChain(chain)
} catch (err) {
return sendError(res, err.message, 400)
}
const addressValidation = validateAddress(to, chain, network)
if (!addressValidation.valid) {
return sendError(res, `Invalid recipient address: ${addressValidation.reason}`, 400)
}
if (addressValidation.warning) {
console.warn(`⚠️ Address validation warning: ${addressValidation.warning}`)
}
const amountValidation = validateAmount(amount, chain)
if (!amountValidation.valid) {
return sendError(res, `Invalid amount: ${amountValidation.reason}`, 400)
}
const amountBigInt = amountValidation.amount
// Get current address index from storage
const identity = await loadIdentity()
await initializeStorage(identity)
const currentAddr = await getCurrentAddress(chain)
const accountIndex = currentAddr ? currentAddr.index : 0
const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
const account = await wdk.getAccount(chain, accountIndex) // FIX: Use stored index, not hardcoded 0
const balance = await account.getBalance()
let fee
let actualAmount = amountBigInt
// Handle sendMax - calculate exact amount that can be sent
if (sendMax) {
// Estimate fee for sending maximum (use balance as initial estimate)
try {
const quote = await account.quoteSendTransaction({
to,
value: balance
})
fee = quote.fee
} catch (err) {
// If full balance fails, try balance - 1000 (for Bitcoin dust/minimum)
try {
const fallbackAmount = balance - BigInt(1000)
if (fallbackAmount <= 0n) {
return sendError(res, 'Insufficient balance to send any amount', 400)
}
const quote = await account.quoteSendTransaction({
to,
value: fallbackAmount
})
fee = quote.fee
} catch (err2) {
console.error('Fee estimation failed:', err2)
return sendError(res, `Cannot estimate fee: ${err2.message}`, 400)
}
}
// Calculate actual sendable amount
actualAmount = balance - fee
if (actualAmount <= 0n) {
return sendError(res, 'Insufficient balance to cover transaction fees', 400)
}
console.log(`📊 Send Max: balance=${balance}, fee=${fee}, actualAmount=${actualAmount}`)
} else {
// Normal send - estimate fee for the requested amount
try {
const quote = await account.quoteSendTransaction({
to,
value: amountBigInt
})
fee = quote.fee
} catch (err) {
console.error('Fee estimation failed:', err)
return sendError(res, `Cannot estimate fee: ${err.message}`, 400)
}
// Check sufficient balance for requested amount + fee
const balanceCheck = validateSufficientBalance(amountBigInt, balance, fee, chain)
if (!balanceCheck.valid) {
return sendError(res, balanceCheck.reason, 400)
}
}
const result = await account.sendTransaction({
to,
value: actualAmount
})
console.log(`✅ Transaction sent: ${result.hash}`)
console.log(` Amount: ${formatAmount(actualAmount, chain)}`)
console.log(` Fee: ${formatAmount(fee, chain)}`)
console.log(` SendMax: ${sendMax || false}`)
console.log(` To: ${to}`)
sendJSON(res, {
hash: result.hash,
fee: result.fee.toString(),
amount: actualAmount.toString()
})
}
} catch (err) {
console.error('Send transaction error:', err)
sendError(res, err.message)
}
})),
// POST /api/sync-transactions - Manual transaction sync from blockchain
'POST /api/sync-transactions': requireAuth(async (req, res) => {
try {
const walletMnemonic = loadWalletMnemonicCached()
if (!walletMnemonic) {
return sendError(res, 'Wallet not initialized', 400)
}
const identity = await loadIdentity()
await initializeStorage(identity)
const { wdk } = await initializeWDK(walletMnemonic, { testnet: true })
const results = {
synced: 0,
errors: [],
chains: {}
}
// Sync Bitcoin transactions using Blockstream API
try {
// Get current Bitcoin address index from storage
const currentBtcAddr = await getCurrentAddress('bitcoin')
const btcAccountIndex = currentBtcAddr ? currentBtcAddr.index : 0
const btcAccount = await wdk.getAccount('bitcoin', btcAccountIndex) // FIX: Use stored index
const btcAddress = await btcAccount.getAddress()
const response = await fetch(`https://blockstream.info/testnet/api/address/${btcAddress}/txs`)
const txs = await response.json()
let btcSynced = 0
let btcSkipped = 0
for (const tx of txs) {
const existing = await getTransaction('bitcoin', tx.txid)
if (existing) {
btcSkipped++
continue
}
// Determine direction and amount
let direction = 'sent'
let amount = 0n
let to = '(unknown)'
let from = btcAddress
// Check outputs to see if we received
const receivedOutput = tx.vout.find(out => out.scriptpubkey_address === btcAddress)
if (receivedOutput) {
direction = 'received'
amount = BigInt(receivedOutput.value)
to = btcAddress
from = '(external)'
} else {
// We sent - find the output that's NOT our change address
const sentOutput = tx.vout.find(out => out.scriptpubkey_address !== btcAddress)
if (sentOutput) {
amount = BigInt(sentOutput.value)
to = sentOutput.scriptpubkey_address || '(unknown)'
}
}
await storeTransaction({
hash: tx.txid,
chain: 'bitcoin',
network: 'testnet',
direction,
from,
to,
amount: amount.toString(),
fee: tx.fee ? tx.fee.toString() : '0',
status: tx.status.confirmed ? 'confirmed' : 'pending',
timestamp: tx.status.block_time ? tx.status.block_time * 1000 : Date.now(),
blockHeight: tx.status.block_height || null
})
btcSynced++
results.synced++
}
results.chains.bitcoin = {
synced: btcSynced,
skipped: btcSkipped,
total: txs.length
}
} catch (err) {
console.error('Bitcoin sync error:', err.message)
results.errors.push(`bitcoin: ${err.message}`)
}
// Ethereum - skip for now (would need Etherscan API)
results.chains.ethereum = {
synced: 0,
skipped: 0,
note: 'Ethereum sync requires Etherscan API - not implemented yet'
}
sendJSON(res, results)
} catch (err) {
console.error('Sync transactions error:', err)
sendError(res, err.message)
}
}),
// GET /api/wallet/encryption-status - Check if mnemonics are encrypted or plaintext
'GET /api/wallet/encryption-status': async (req, res) => {
try {
// Check if wallet files exist
if (!fs.existsSync(MNEMONIC_FILE)) {
return sendJSON(res, { encrypted: null, initialized: false })
}
// Read identity mnemonic file
const identityContent = fs.readFileSync(MNEMONIC_FILE, 'utf8').trim()
// Check if plaintext (24 words separated by spaces)
const isPlaintext = identityContent.includes(' ') && identityContent.split(' ').length === 24
sendJSON(res, {
encrypted: !isPlaintext,
initialized: true,
needsMigration: isPlaintext
})
} catch (err) {
console.error('Encryption status check error:', err)
sendError(res, err.message)
}
},
// POST /api/set-password - Set password for plaintext mnemonics (first-time encryption)
'POST /api/set-password': async (req, res) => {
try {
const body = await parseBody(req)
const { password } = body
if (!password || password.length < 8) {
return sendError(res, 'Password must be at least 8 characters', 400)
}
// Check if mnemonics are already encrypted
if (!fs.existsSync(MNEMONIC_FILE)) {
return sendError(res, 'Wallet not initialized', 400)
}
const identityContent = fs.readFileSync(MNEMONIC_FILE, 'utf8').trim()
const isPlaintext = identityContent.includes(' ') && identityContent.split(' ').length === 24
if (!isPlaintext) {
return sendError(res, 'Mnemonics are already encrypted. Use change-password instead.', 400)
}
// Read plaintext mnemonics
const identityMnemonic = identityContent
const walletMnemonic = loadWalletMnemonic(null) // null password for plaintext
if (!walletMnemonic) {
return sendError(res, 'Wallet mnemonic not found', 400)
}
// Encrypt with new password
saveIdentityMnemonic(identityMnemonic, password)
saveWalletMnemonic(walletMnemonic, password)
// Cache password and generate token
setCachedPassword(password)
// Derive identity from the plaintext mnemonic we already have
const identity = await IdentityKey.from({ mnemonic: identityMnemonic })
const token = generateToken(identity.publicKey)
console.log('🔒 Mnemonics encrypted with password for first time')
sendJSON(res, {
success: true,
token,
publicKey: identity.publicKey.toString('hex'),
migrated: true // Flag to show special success message
})
} catch (err) {
console.error('Set password error:', err)
sendError(res, err.message)
}
},
// POST /api/change-password - Change wallet encryption password
'POST /api/change-password': requireAuth(async (req, res) => {
try {
const body = await parseBody(req)
const { currentPassword, newPassword } = body
// Validate inputs
if (!currentPassword || !newPassword) {
return sendError(res, 'Both current and new passwords required', 400)
}
if (newPassword.length < 8) {
return sendError(res, 'New password must be at least 8 characters', 400)
}
if (currentPassword === newPassword) {
return sendError(res, 'New password must be different from current password', 400)
}
// Verify current password by trying to decrypt
let identityMnemonic, walletMnemonic
try {
identityMnemonic = loadIdentityMnemonic(currentPassword)
walletMnemonic = loadWalletMnemonic(currentPassword)
} catch (err) {
return sendError(res, 'Current password is incorrect', 401)
}
// Re-encrypt with new password
saveIdentityMnemonic(identityMnemonic, newPassword)
saveWalletMnemonic(walletMnemonic, newPassword)
// Update cached password
setCachedPassword(newPassword)
console.log('🔑 Password changed successfully')
sendJSON(res, {
success: true,
message: 'Password changed successfully. Mnemonics re-encrypted.'
})
} catch (err) {
console.error('Change password error:', err)
sendError(res, err.message)
}
}),
// POST /api/unlock - Unlock existing wallet with password (caches password in memory)
'POST /api/unlock': async (req, res) => {
try {
const body = await parseBody(req)
const { password } = body
// Validate password provided
if (!password || password.length < 8) {
return sendError(res, 'Password must be at least 8 characters', 400)
}
// Check if wallet exists
if (!fs.existsSync(MNEMONIC_FILE)) {
return sendError(res, 'Wallet not initialized. Use /api/init or /api/restore first.', 400)
}
// Try to load mnemonics with provided password
let identityMnemonic, walletMnemonic
try {
identityMnemonic = loadIdentityMnemonic(password)
walletMnemonic = loadWalletMnemonic(password)
} catch (err) {
// If decryption fails, check if it's plaintext (legacy)
const identityContent = fs.readFileSync(MNEMONIC_FILE, 'utf8').trim()
const walletContent = fs.readFileSync(path.join(STORAGE_DIR, '.wallet-mnemonic'), 'utf8').trim()
if (identityContent.includes(' ') && identityContent.split(' ').length === 24 &&
walletContent.includes(' ') && walletContent.split(' ').length === 12) {
// Legacy plaintext mnemonics - migrate to encrypted format
console.log('🔄 Migrating plaintext mnemonics to encrypted format...')
identityMnemonic = identityContent
walletMnemonic = walletContent
// Save encrypted versions
saveIdentityMnemonic(identityMnemonic, password)
saveWalletMnemonic(walletMnemonic, password)
console.log('✅ Mnemonics encrypted successfully')
} else {
return sendError(res, 'Invalid password', 401)
}
}
// Cache password in memory
setCachedPassword(password)
// Load or generate API token
let apiToken = loadToken()
if (!apiToken) {
apiToken = generateToken()
saveToken(apiToken)
}
// Get public key for confirmation
const identity = await IdentityKey.from({ mnemonic: identityMnemonic })
const publicKey = b4a.toString(identity.identityKeyPair.publicKey, 'hex').slice(0, 16) + '...'
sendJSON(res, {
success: true,
message: 'Wallet unlocked successfully',
publicKey,
token: apiToken,
migrated: identityMnemonic !== loadIdentityMnemonic(password) // True if we migrated from plaintext
})
} catch (err) {
console.error('Unlock error:', err)
sendError(res, err.message)
}
},
// POST /api/init (full initialization: identity + wallet)
'POST /api/init': async (req, res) => {
try {
const body = await readBody(req)
const { password } = body
// Validate password
if (!password || password.length < 8) {
return sendError(res, 'Password must be at least 8 characters', 400)
}
// Check if already initialized (use try/catch since loadIdentityMnemonic now requires password)
let existingIdentity = null
let existingWallet = null
try {
existingIdentity = loadIdentityMnemonic(password)
existingWallet = loadWalletMnemonic(password)
} catch (err) {
// Ignore errors - if decrypt fails, it means no existing wallet or wrong password
// We'll catch the "already initialized" case by checking file existence below
}
if (fs.existsSync(MNEMONIC_FILE) || fs.existsSync(path.join(STORAGE_DIR, '.wallet-mnemonic'))) {
return sendError(res, 'System already initialized', 400)
}
// Generate identity mnemonic (24 words for device attestation)
const identityMnemonic = generateIdentityMnemonic()
// Generate wallet mnemonic (12 words for crypto keys)
const walletMnemonic = generateWalletMnemonic()
// Save both (encrypted with password)
saveIdentityMnemonic(identityMnemonic, password)
saveWalletMnemonic(walletMnemonic, password)
// Cache password in memory for subsequent operations
setCachedPassword(password)
// Generate and save API token
const apiToken = generateToken()
saveToken(apiToken)
// Get public key for display
const identity = await IdentityKey.from({ mnemonic: identityMnemonic })
const publicKey = b4a.toString(identity.identityKeyPair.publicKey, 'hex').slice(0, 16) + '...'
// Return both mnemonics (PLAINTEXT for user backup) AND API token
sendJSON(res, {
identityMnemonic,
walletMnemonic,
publicKey,
token: apiToken,
warning: 'CRITICAL: Write down BOTH sets of words and store them safely!',
passwordWarning: 'IMPORTANT: Remember your password - you will need it to access your wallet. If you lose it, you can only restore using your mnemonics.',
tokenWarning: 'IMPORTANT: Save this API token - it\'s required for all API requests. Store it securely (e.g., in localStorage for web UI).'
})
} catch (err) {
console.error('Init error:', err)
sendError(res, err.message)
}
},
// POST /api/wallet/init (wallet only - for backward compatibility)
'POST /api/wallet/init': async (req, res) => {
try {
// Check if wallet already exists
const existing = loadWalletMnemonicCached()
if (existing) {
return sendError(res, 'Wallet already initialized', 400)
}
// Generate new mnemonic
const mnemonic = generateWalletMnemonic()
// Save to file
saveWalletMnemonic(mnemonic)
// Return mnemonic (user MUST backup!)
sendJSON(res, {
mnemonic,
warning: 'CRITICAL: Write down these 12 words and store them safely. This is the ONLY way to recover your wallet!'
})
} catch (err) {
console.error('Wallet init error:', err)
sendError(res, err.message)
}
},
// POST /api/restore (full restore: identity + wallet)
'POST /api/restore': async (req, res) => {
try {
const body = await parseBody(req)
const { identityMnemonic, walletMnemonic, password } = body
if (!identityMnemonic || !walletMnemonic) {
return sendError(res, 'Both identity and wallet mnemonics are required', 400)
}
// Validate password
if (!password || password.length < 8) {
return sendError(res, 'Password must be at least 8 characters', 400)
}
// Validate identity mnemonic format (24 words)
const identityWords = identityMnemonic.trim().split(/\s+/)
if (identityWords.length !== 24) {
return sendError(res, 'Invalid identity mnemonic: must be 24 words', 400)
}
// Validate wallet mnemonic format (12 words)
const walletWords = walletMnemonic.trim().split(/\s+/)
if (walletWords.length !== 12) {
return sendError(res, 'Invalid wallet mnemonic: must be 12 words', 400)
}
// Check if already initialized (use file existence check)
if (fs.existsSync(MNEMONIC_FILE) || fs.existsSync(path.join(STORAGE_DIR, '.wallet-mnemonic'))) {
return sendError(res, 'System already initialized. Delete ~/.hyper-wallet files to restore.', 400)
}
// Test identity mnemonic
try {
await IdentityKey.from({ mnemonic: identityMnemonic })
} catch (err) {
return sendError(res, 'Invalid identity mnemonic: ' + err.message, 400)
}
// Test wallet mnemonic
try {
await initializeWDK(walletMnemonic, { testnet: true })
} catch (err) {
return sendError(res, 'Invalid wallet mnemonic: ' + err.message, 400)
}
// Save both mnemonics (encrypted with password)
saveIdentityMnemonic(identityMnemonic, password)
saveWalletMnemonic(walletMnemonic, password)
// Cache password in memory for subsequent operations
setCachedPassword(password)
// Generate and save new API token (security: don't reuse old token)
const apiToken = generateToken()
saveToken(apiToken)
sendJSON(res, {
success: true,
message: 'System restored successfully',
token: apiToken,
tokenWarning: 'IMPORTANT: Save this API token - it\'s required for all API requests.',
passwordWarning: 'IMPORTANT: Remember your password - you will need it to access your wallet.'
})
} catch (err) {
console.error('Restore error:', err)
sendError(res, err.message)
}
},
// POST /api/wallet/restore (wallet only - for backward compatibility)
'POST /api/wallet/restore': async (req, res) => {
try {
const body = await parseBody(req)
const { mnemonic } = body
if (!mnemonic) {
return sendError(res, 'Mnemonic is required', 400)
}
// Validate mnemonic format (12 words)
const words = mnemonic.trim().split(/\s+/)
if (words.length !== 12) {
return sendError(res, 'Invalid mnemonic: must be 12 words', 400)
}
// Check if wallet already exists
const existing = loadWalletMnemonicCached()
if (existing) {
return sendError(res, 'Wallet already initialized. Delete ~/.hyper-wallet/.wallet-mnemonic to restore.', 400)
}
// Test mnemonic by trying to initialize WDK
try {
await initializeWDK(mnemonic, { testnet: true })
} catch (err) {
return sendError(res, 'Invalid mnemonic: ' + err.message, 400)
}
// Save mnemonic
saveWalletMnemonic(mnemonic)
sendJSON(res, {
success: true,
message: 'Wallet restored successfully'
})
} catch (err) {
console.error('Wallet restore error:', err)
sendError(res, err.message)
}
}
}
// Static file MIME types
const mimeTypes = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml'
}
// Serve static files
async function serveStatic(req, res) {
const fileName = req.url === '/' ? 'index.html' : req.url.substring(1)
try {
let content
if (appDrive) {
// Running from pear:// - read from app bundle using pear-appdrive
const bundlePath = '/ui/' + fileName
console.log(`[STATIC] Reading from Pear bundle: ${bundlePath}`)
content = await appDrive.get(bundlePath)
if (!content) {
console.error(`[STATIC] File not found in bundle: ${bundlePath}`)
res.statusCode = 404
res.end('Not found')
return
}
} else {
// Local development - read from filesystem
const filePath = path.join(UI_DIR, fileName)
// Security: prevent directory traversal
if (!filePath.startsWith(UI_DIR)) {
console.error(`Security error: ${filePath} is outside ${UI_DIR}`)
res.statusCode = 403
res.end('Forbidden')
return
}
// Check if file exists
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`)
console.error(` UI_DIR: ${UI_DIR}`)
console.error(` __dirname: ${__dirname}`)
console.error(` process.cwd(): ${process.cwd()}`)
console.error(` Requested URL: ${req.url}`)
res.statusCode = 404
res.end('Not found')
return
}
content = fs.readFileSync(filePath)
}
// Serve the file
const ext = path.extname(fileName)
const contentType = mimeTypes[ext] || 'application/octet-stream'
res.statusCode = 200
res.setHeader('Content-Type', contentType)
res.end(content)
} catch (err) {
console.error(`[STATIC] Error serving ${fileName}:`, err.message)
res.statusCode = 500
res.end('Internal server error')
}
}
// Server instance (created in startServer function)
let server = null
// Start server function (wrapping prevents Pear port remapping)
async function startServer() {
// Initialize Pear appDrive for reading bundled UI files
if (isPear && !appDrive) {
try {
// Use dynamic import for ESM module in CommonJS
const AppDriveModule = await import('pear-appdrive')
const AppDrive = AppDriveModule.default || AppDriveModule
appDrive = new AppDrive()
await appDrive.ready()
console.log('[DEBUG] pear-appdrive initialized - can read bundled UI files')
} catch (err) {
console.error('[DEBUG] pear-appdrive failed:', err.message)
console.error(' UI files will be served from filesystem (may fail in Pear)')
}
}
// Main HTTP server
server = http.createServer(async (req, res) => {
setCORS(res, req)
// Handle OPTIONS (CORS preflight)
if (req.method === 'OPTIONS') {
res.statusCode = 204
res.end()
return
}
console.log(`${req.method} ${req.url}`)
// API routes
if (req.url.startsWith('/api/')) {
// Find matching route
let handled = false
// Sort routes by path length (longest first) so specific routes match before generic
const sortedRoutes = Object.entries(routes).sort((a, b) => {
const pathA = a[0].split(' ')[1]
const pathB = b[0].split(' ')[1]
return pathB.length - pathA.length
})
for (const [routeKey, handler] of sortedRoutes) {
const [method, routePath] = routeKey.split(' ')
// Use startsWith for all routes to support path parameters
const isMatch = req.method === method && req.url.startsWith(routePath)
if (isMatch) {
// Extract parameters (e.g., :id/addresses from /api/contacts/:id/addresses)
const param = req.url.substring(routePath.length)
const params = param ? param.split('/').filter(p => p) : []
await handler(req, res, ...params)
handled = true
break
}
}
if (!handled) {
res.statusCode = 404
sendJSON(res, { error: 'Route not found' }, 404)
}
return
}
// Serve static files
await serveStatic(req, res)
})
// Add error handler BEFORE calling listen
server.on('error', (err) => {
console.error('')
console.error('❌ SERVER ERROR:', err.message)
console.error(' Code:', err.code)
console.error(' Stack:', err.stack)
console.error('')
process.exit(1)
})
// Start server with persistent storage
server.listen(PORT, HOST, async () => {
// Check for hidden error (bare-tcp bug - swallows errors)
if (server._error) {
console.error('')
console.error('❌ HIDDEN SERVER ERROR DETECTED!')
console.error(' This is a bare-tcp bug - error was swallowed')
console.error(' Error:', server._error.message)
console.error(' Code:', server._error.code)
console.error('')
process.exit(1)
}
console.log('')
console.log('🌐 Hyper Wallet Server (Daemon Mode)')
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
console.log('')
// Initialize persistent storage if wallet exists and password is cached
try {
const identityFileExists = fs.existsSync(MNEMONIC_FILE)
const walletFileExists = fs.existsSync(path.join(STORAGE_DIR, '.wallet-mnemonic'))
if (identityFileExists && walletFileExists) {
if (getCachedPassword()) {
// Password cached - can initialize storage
const identityMnemonic = loadIdentityMnemonicCached()
const walletMnemonic = loadWalletMnemonicCached()
const identity = await loadIdentity()
await initializeStorage(identity)
enableReplication()
console.log('✅ Persistent storage initialized')
console.log('✅ P2P sync enabled')
console.log('💡 Use POST /api/sync-transactions to sync transaction history')
console.log('')
} else {
console.log('⚠️ Wallet encrypted - password not cached')
console.log(' Storage will initialize after first authenticated request')
console.log(' Password cached in memory during this server session only')
console.log('')
}
} else {
console.log('⚠️ Wallet not initialized - storage will open per-request')
console.log(' Use web UI or CLI to initialize wallet')
console.log('')
}
} catch (err) {
console.error('⚠️ Warning: Could not initialize persistent storage:', err.message)
console.error(' Storage will open per-request')
console.log('')
}
// Get actual bound address from server
const actualAddress = server.address()
const actualPort = actualAddress ? actualAddress.port : PORT
const actualHost = actualAddress ? actualAddress.address : HOST
console.log(` Requested: ${HOST}:${PORT}`)
console.log(` Actually: ${actualHost}:${actualPort}`)
if (actualPort !== parseInt(PORT)) {
console.log(` ⚠️ PORT MISMATCH - Pear remapped ${PORT} → ${actualPort}`)
}
if (HOST === 'localhost' || HOST === '127.0.0.1') {
console.log(` Access: http://localhost:${actualPort} (local only)`)
console.log(` Security: ✅ Locked to localhost`)
} else {
console.log(` Access: http://localhost:${actualPort}`)
console.log(` Network: http://<your-ip>:${actualPort}`)
console.log(` Security: ⚠️ Accessible from network`)
}
console.log(` API: http://localhost:${actualPort}/api/status`)
console.log(` UI Dir: ${UI_DIR}`)
console.log('')
console.log('📡 API Endpoints:')
console.log(' GET /api/status')
console.log(' GET /api/balance/:chain')
console.log(' GET /api/address/:chain')
console.log(' GET /api/transactions/:chain')
console.log(' POST /api/device/add')
console.log(' POST /api/device/join')
console.log(' GET /api/device/list')
console.log('')
console.log('💡 Use hyper-wallet-client.cjs to access via CLI')
console.log('')
console.log('Press Ctrl+C to stop')
console.log('')
})
}
// Export startServer for hyper-wallet.cjs to call
module.exports = { startServer }
// Only start if run directly (not when required as module)
if (require.main === module) {
startServer().catch(err => {
console.error('Failed to start server:', err)
process.exit(1)
})
}
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('')
console.log('🛑 Shutting down server...')
try {
await closeStorage()
console.log('✅ Storage closed')
} catch (err) {
// Storage might not be open
}
if (server) {
server.close(() => {
console.log('✅ Server stopped')
process.exit(0)
})
} else {
process.exit(0)
}
})