#!/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 (legacy path)
const isPear = typeof Pear !== 'undefined'
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
} = require('./lib/hyper-wallet-lib/wallet-storage.cjs')
const IdentityKey = require('keet-identity-key')
const b4a = require('b4a')
const {
loadWalletMnemonic,
generateWalletMnemonic,
saveWalletMnemonic,
initializeWDK
} = 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(__dirname, 'ui')
// 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
// Add production origins here when deploying:
// 'https://your-production-domain.com'
]
// pear:// CORS origin no longer needed — apps run locally
/**
* 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 (protected)
'GET /api/balance/': 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 chainName = chain.replace(/^(testnet|mainnet)-/, '')
const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
const account = await wdk.getAccount(chainName, 0)
const balance = await account.getBalance()
sendJSON(res, {
chain: chainName,
network,
balance: balance.toString()
})
} catch (err) {
console.error('Balance error:', err)
sendError(res, err.message)
}
}),
// GET /api/address/:chain
'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 chainName = chain.replace(/^(testnet|mainnet)-/, '')
const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
const account = await wdk.getAccount(chainName, 0)
const address = await account.getAddress()
sendJSON(res, {
chain: chainName,
network,
address
})
} catch (err) {
console.error('Address error:', err)
sendError(res, err.message)
}
}),
// GET /api/qrcode/:chain
'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 chainName = chain.replace(/^(testnet|mainnet)-/, '')
const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
const account = await wdk.getAccount(chainName, 0)
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: chainName,
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)
// 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 } = 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)
}
const { initializeWDK } = require('./lib/hyper-wallet-lib/wdk-integration.cjs')
const { wdk } = await initializeWDK(walletMnemonic, { testnet: true })
// Sync transactions
const chainName = chain.replace(/^(testnet|mainnet)-/, '')
const result = await syncTransactionsFromWDK(wdk, {
chains: [chainName],
network: 'testnet'
})
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)
}
}),
// DELETE /api/contacts/:id - Delete contact
'DELETE /api/contacts/': requireAuth(async (req, res, id) => {
try {
if (!id) {
return sendError(res, 'Contact ID required', 400)
}
const identity = await loadIdentity()
await initializeStorage(identity)
await deleteContact(id)
sendJSON(res, {
success: true,
message: 'Contact deleted successfully'
})
} catch (err) {
console.error('Delete contact error:', err)
sendError(res, err.message)
}
}),
// 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 })
let { chain, address, network } = 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 })
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/:chain - Remove address from contact
'DELETE /api/contacts/': requireAuth(async (req, res, id, ...rest) => {
// Check if this is the /addresses/:chain sub-route
if (rest[0] === 'addresses' && rest[1]) {
try {
if (!id) {
return sendError(res, 'Contact ID required', 400)
}
const chain = rest[1]
const identity = await loadIdentity()
await initializeStorage(identity)
const contact = await removeAddressFromContact(id, chain)
sendJSON(res, {
success: true,
message: 'Address removed from contact successfully',
contact
})
} catch (err) {
console.error('Remove address from contact error:', err)
sendError(res, err.message)
}
return
}
// If not matching, return 404
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
'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 and sanitize chain and network
try {
chain = validateChain(chain)
network = validateNetwork(network)
} catch (err) {
return sendError(res, err.message, 400)
}
// Validate recipient address with checksum
const addressValidation = validateAddress(to, chain, network)
if (!addressValidation.valid) {
return sendError(res, `Invalid recipient address: ${addressValidation.reason}`, 400)
}
// Validate amount format and range
const amountValidation = validateAmount(amount, chain)
if (!amountValidation.valid) {
return sendError(res, `Invalid amount: ${amountValidation.reason}`, 400)
}
const amountBigInt = amountValidation.amount
const walletMnemonic = loadWalletMnemonicCached()
if (!walletMnemonic) {
return sendError(res, 'Wallet not initialized', 400)
}
const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
const account = await wdk.getAccount(chain, 0)
// Quote the transaction to get fee estimate
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
'POST /api/send': 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 and sanitize chain and network
try {
chain = validateChain(chain)
network = validateNetwork(network)
} catch (err) {
return sendError(res, err.message, 400)
}
// Validate recipient address with checksum
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}`)
}
// Validate amount format and range
const amountValidation = validateAmount(amount, chain)
if (!amountValidation.valid) {
return sendError(res, `Invalid amount: ${amountValidation.reason}`, 400)
}
const amountBigInt = amountValidation.amount
const walletMnemonic = loadWalletMnemonicCached()
if (!walletMnemonic) {
return sendError(res, 'Wallet not initialized', 400)
}
const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
const account = await wdk.getAccount(chain, 0)
// Check balance
const balance = await account.getBalance()
// Get fee estimate
let fee
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 (amount + fee)
const balanceCheck = validateSufficientBalance(amountBigInt, balance, fee, chain)
if (!balanceCheck.valid) {
return sendError(res, balanceCheck.reason, 400)
}
// Send the transaction
const result = await account.sendTransaction({
to,
value: amountBigInt
})
console.log(`✅ Transaction sent: ${result.hash}`)
console.log(` Amount: ${formatAmount(amountBigInt, chain)}`)
console.log(` Fee: ${formatAmount(fee, chain)}`)
console.log(` To: ${to}`)
sendJSON(res, {
hash: result.hash,
fee: result.fee.toString(),
amount: amountBigInt.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 {
const btcAccount = await wdk.getAccount('bitcoin', 0)
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/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 from local filesystem
async function serveStatic(req, res) {
const fileName = req.url === '/' ? 'index.html' : req.url.substring(1)
try {
let content
const filePath = path.join(UI_DIR, fileName)
// Security: prevent directory traversal
if (!filePath.startsWith(UI_DIR)) {
res.statusCode = 403
res.end('Forbidden')
return
}
if (!fs.existsSync(filePath)) {
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
async function startServer() {
// 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)
}
})