// lib/wallet-context-autobase.cjs - Multi-device storage with Autobase
require('bare-node-runtime/global')
const path = require('bare-path')
const os = require('bare-os')
const fs = require('bare-fs')
const Corestore = require('corestore')
const Hyperbee = require('hyperbee')
const Autobase = require('autobase')
const Hyperswarm = require('hyperswarm')
const crypto = require('hypercore-crypto')
const b4a = require('b4a')
const IdentityKey = require('keet-identity-key')
const { createPairingServer, createPairingClient } = require('./pairing-protocol.cjs')
const { loadWalletMnemonic, initializeWDK } = require('./wdk-integration.cjs')
const { fetchTransactions, fetchTokenTransactions } = require('./blockchain-providers.cjs')
const { getConfig } = require('./config.cjs')
// Storage configuration
const STORAGE_DIR = path.join(os.homedir(), '.hyper-wallet', 'storage')
const DEVICES_FILE = path.join(os.homedir(), '.hyper-wallet', '.devices')
const PENDING_FILE = path.join(os.homedir(), '.hyper-wallet', '.pending')
// Singleton instances
let store = null
let autobase = null
let txHistoryDb = null
let swarm = null
let identityKeyPair = null
let connections = new Set() // Track active connections
// Initialization lock to prevent concurrent initialization
let initializationPromise = null
// WDK singleton instances (Phase 6.1)
let wdkInstance = null
let walletManagers = {} // { bitcoin, ethereum }
/**
* Apply function for Autobase
* Processes operations from all input cores and builds Hyperbee view
*
* Pattern based on holepunchto/autobase examples:
* - nodes: array of node objects from all input cores
* - view: already a Hyperbee instance (created by open())
* - base: the Autobase instance (for operations like addWriter)
* - IMPORTANT: Must parse JSON manually (no valueEncoding in Autobase)
*/
async function apply(nodes, view, base) {
// Use batch for efficiency when processing multiple operations
const b = view.batch({ update: false })
for (const node of nodes) {
// Parse JSON manually (autobase doesn't use valueEncoding)
const value = JSON.parse(node.value)
// Handle writer management operations
if (value.addWriter) {
// Add writer to Autobase inputs using the host.addWriter() API
const writerKey = b4a.from(value.addWriter, 'hex')
await base.addWriter(writerKey, { indexer: true })
continue
}
if (value.removeWriter) {
// Writer removal marker
// Note: Autobase doesn't actually support removing inputs
// This is just a marker that the device has been revoked
// The device list manages the actual removal
continue
}
// Handle transaction operations
if (value.type === 'put-tx') {
// Store value as JSON string
await b.put(value.key, JSON.stringify(value.value))
} else if (value.type === 'del-tx') {
await b.del(value.key)
}
// Handle address operations (Phase 6.2)
if (value.type === 'put-address') {
await b.put(value.key, JSON.stringify(value.value))
} else if (value.type === 'del-address') {
await b.del(value.key)
}
// Handle UTXO operations (Phase 6.3)
if (value.type === 'put-utxo') {
await b.put(value.key, JSON.stringify(value.value))
} else if (value.type === 'del-utxo') {
await b.del(value.key)
}
// Handle transaction storage (Phase 6.6)
if (value.type === 'store-transaction') {
await b.put(value.key, JSON.stringify(value.value))
} else if (value.type === 'delete-transaction') {
await b.del(value.key)
}
// Handle contact storage
if (value.type === 'put-contact') {
await b.put(value.key, JSON.stringify(value.value))
} else if (value.type === 'del-contact') {
await b.del(value.key)
}
}
await b.flush()
}
/**
* Initialize storage with Autobase multi-writer support
* @param {Object} identity - keet-identity-key instance
*/
async function initializeStorage(identity) {
// Return existing instance if already initialized
if (store && autobase && txHistoryDb) {
return { store, autobase, txHistoryDb, swarm }
}
// If initialization is in progress, wait for it
if (initializationPromise) {
console.log('⏳ Storage initialization in progress, waiting...')
return await initializationPromise
}
// Start new initialization
console.log('🔧 Initializing storage (singleton)')
initializationPromise = _initializeStorage(identity)
try {
const result = await initializationPromise
return result
} finally {
// Clear the promise after initialization completes (success or failure)
initializationPromise = null
}
}
/**
* Internal storage initialization (wrapped by singleton lock)
*/
async function _initializeStorage(identity) {
// Store identity keypair for attestation
identityKeyPair = identity.identityKeyPair
// Initialize Corestore
store = new Corestore(STORAGE_DIR)
await store.ready()
// View class for Autobase
// Pattern from autobase-example
class WalletView {
open(store) {
const core = store.get('view')
// Use binary encoding - we'll handle JSON manually
return new Hyperbee(core, {
keyEncoding: 'utf-8',
valueEncoding: 'binary'
})
}
async apply(nodes, view, base) {
return apply(nodes, view, base)
}
close(view) {
return view.close()
}
}
// Get or create namespace for this autobase
const namespace = await Autobase.getLocalKey(store)
// Initialize Autobase with Corestore
// First param: Namespaced corestore
// Second param: bootstrap key (null for new, or existing autobase key)
// Third param: View instance with open/apply/close methods
// Note: No valueEncoding - handle JSON manually
autobase = new Autobase(store.namespace(namespace), null, new WalletView())
await autobase.ready()
// Add existing devices as writers by appending operations
// Autobase will process these operations and add the writers automatically
const devices = loadDevices()
for (const device of devices) {
// Skip if this is the local writer (already added automatically)
if (!b4a.equals(device.key, autobase.local.key)) {
// Append addWriter operation - Autobase processes this internally
const op = { addWriter: b4a.toString(device.key, 'hex') }
await autobase.append(JSON.stringify(op))
}
}
// Wait for operations to be processed
if (devices.length > 1) {
await autobase.update()
}
// Get view as Hyperbee (already created by open())
txHistoryDb = autobase.view
// Initialize Hyperswarm for P2P
swarm = new Hyperswarm()
// Track connections (Phase 6.7: Auto-replication enabled)
swarm.on('connection', (conn, info) => {
connections.add(conn)
console.log(`📡 Connected to peer (${connections.size} connection${connections.size !== 1 ? 's' : ''})`)
// Auto-replicate on all connections (Phase 6.7)
// This enables real-time multi-device sync
store.replicate(conn)
// Handle disconnection
conn.on('close', () => {
connections.delete(conn)
console.log(`📡 Peer disconnected (${connections.size} connection${connections.size !== 1 ? 's' : ''})`)
})
})
// Join discovery key for this wallet
const discoveryKey = crypto.discoveryKey(identityKeyPair.publicKey)
const discovery = swarm.join(discoveryKey, { server: true, client: true })
// Flush in background - don't block initialization on DHT announcement
discovery.flushed().then(() => {
console.log('📡 DHT discovery announced')
}).catch(err => {
console.warn('⚠️ DHT discovery flush failed:', err.message)
})
console.log('✅ Storage initialized successfully')
return { store, autobase, txHistoryDb, swarm }
}
/**
* Store a transaction in Autobase
* Operations are appended to local input core and merged by Autobase
*/
async function storeTx(tx) {
if (!autobase) {
throw new Error('Storage not initialized')
}
// Append operation to Autobase
// Note: No valueEncoding in Autobase - must JSON.stringify manually
const op = {
type: 'put-tx',
key: `tx:${tx.timestamp}:${tx.hash}`,
value: {
hash: tx.hash,
chain: tx.chain,
to: tx.to,
amount: tx.amount,
fee: tx.fee,
timestamp: tx.timestamp,
status: tx.status || 'pending'
}
}
await autobase.append(JSON.stringify(op))
// Wait for view to update
await new Promise(resolve => setTimeout(resolve, 100))
}
/**
* Get transaction history from Hyperbee view
*/
async function getTxHistory(chain = null) {
if (!txHistoryDb) {
throw new Error('Storage not initialized')
}
const transactions = []
for await (const entry of txHistoryDb.createReadStream()) {
// Parse JSON manually (binary encoding)
const tx = JSON.parse(entry.value)
if (chain && tx.chain !== chain) {
continue
}
transactions.push(tx)
}
// Sort by timestamp (newest first)
transactions.sort((a, b) => b.timestamp - a.timestamp)
return transactions
}
/**
* Get specific transaction by hash
*/
async function getTx(hash) {
if (!txHistoryDb) {
throw new Error('Storage not initialized')
}
for await (const entry of txHistoryDb.createReadStream()) {
// Parse JSON manually (binary encoding)
const tx = JSON.parse(entry.value)
if (tx.hash === hash) {
return tx
}
}
return null
}
/**
* Update transaction status
*/
async function updateTxStatus(hash, status) {
const tx = await getTx(hash)
if (!tx) return
tx.status = status
await storeTx(tx)
}
// ============================================================================
// WDK WALLET MANAGEMENT (Phase 6.2)
// ============================================================================
/**
* Initialize WDK wallets singleton
* @param {Object} config - Configuration { testnet: boolean }
* @returns {Object} - { wdk } - WDK instance with registered wallets
*/
async function initializeWallets(config = { testnet: true }) {
if (wdkInstance) {
return { wdk: wdkInstance }
}
const walletMnemonic = loadWalletMnemonic()
if (!walletMnemonic) {
throw new Error('Wallet mnemonic not found. Run: bare hyper-wallet-autobase.cjs init')
}
const { wdk } = await initializeWDK(walletMnemonic, config)
wdkInstance = wdk
return { wdk: wdkInstance }
}
/**
* Store address in Autobase
* @param {Object} addressData - { chain, network, address, path, index }
*/
async function storeAddress(addressData) {
if (!autobase) {
throw new Error('Storage not initialized')
}
const op = {
type: 'put-address',
key: `address:${addressData.chain}:${addressData.index}`,
value: {
chain: addressData.chain,
network: addressData.network,
address: addressData.address,
path: addressData.path,
index: addressData.index,
label: addressData.label || '', // Pet-name/label for address
createdAt: addressData.createdAt || Date.now(),
device: getCurrentDevice()?.name || 'unknown',
isArchived: addressData.isArchived || false, // For hiding old addresses
isCurrent: addressData.isCurrent || false // Mark current receiving address
}
}
await autobase.append(JSON.stringify(op))
// Wait for view to update
await new Promise(resolve => setTimeout(resolve, 100))
}
/**
* Get all addresses for a chain
* @param {string} chain - 'bitcoin' or 'ethereum' or 'all'
* @returns {Array} - Array of address objects sorted by index
*/
async function getAddresses(chain) {
if (!txHistoryDb) {
throw new Error('Storage not initialized')
}
const addresses = []
const prefix = chain === 'all' ? 'address:' : `address:${chain}:`
for await (const entry of txHistoryDb.createReadStream()) {
if (entry.key.startsWith(prefix)) {
addresses.push(JSON.parse(entry.value))
}
}
// For Ethereum, also include ERC-20 token addresses (they share the same address space)
// When querying 'testnet-ethereum', also return 'testnet-ethereum-usdt' addresses
if (!chain.includes('all') && (chain.includes('ethereum') && !chain.includes('-usdt'))) {
const tokenPrefix = `${prefix}usdt:`
for await (const entry of txHistoryDb.createReadStream()) {
if (entry.key.startsWith(tokenPrefix)) {
const addr = JSON.parse(entry.value)
// Check if we already have this index (don't duplicate)
if (!addresses.find(a => a.index === addr.index)) {
addresses.push(addr)
}
}
}
}
return addresses.sort((a, b) => a.index - b.index)
}
/**
* Get next address index for a chain
* @param {string} chain - 'bitcoin' or 'ethereum'
* @returns {number} - Next index to use (0 if no addresses exist)
*/
async function getNextAddressIndex(chain) {
const addresses = await getAddresses(chain)
if (addresses.length === 0) return 0
return Math.max(...addresses.map(a => a.index)) + 1
}
/**
* Get current (latest) address for a chain
* @param {string} chain - 'bitcoin' or 'ethereum'
* @returns {Object|null} - Latest address or null if none exist
*/
async function getCurrentAddress(chain) {
// Strip network prefix (testnet-ethereum-usdt -> ethereum-usdt)
// Addresses are stored without network prefix
const chainWithoutNetwork = chain.replace(/^(testnet|mainnet)-/, '')
let addresses = await getAddresses(chainWithoutNetwork)
// For ERC-20 tokens (like USDT), fall back to base Ethereum addresses
// since they share the same address space
if (addresses.length === 0 && chainWithoutNetwork.includes('-usdt')) {
const baseChain = chainWithoutNetwork.replace('-usdt', '')
addresses = await getAddresses(baseChain)
}
if (addresses.length === 0) return null
// Find address marked as current, or return latest
const current = addresses.find(a => a.isCurrent)
return current || addresses[addresses.length - 1]
}
/**
* Update address label
* @param {string} chain - 'bitcoin' or 'ethereum'
* @param {number} index - Address index
* @param {string} label - New label/pet-name
*/
async function updateAddressLabel(chain, index, label) {
if (!autobase) {
throw new Error('Storage not initialized')
}
// Get existing address
const addresses = await getAddresses(chain)
const existing = addresses.find(a => a.index === index)
if (!existing) {
throw new Error(`Address not found: ${chain}:${index}`)
}
// Update with new label
await storeAddress({
...existing,
label
})
return { ...existing, label }
}
/**
* Set address as current receiving address
* @param {string} chain - 'bitcoin' or 'ethereum'
* @param {number} index - Address index to set as current
*/
async function setCurrentAddress(chain, index) {
if (!autobase) {
throw new Error('Storage not initialized')
}
let addresses = await getAddresses(chain)
// For ERC-20 tokens (like USDT), fall back to base Ethereum addresses
// since they share the same address space
let actualChain = chain
if (addresses.length === 0 && chain.includes('-usdt')) {
const baseChain = chain.replace('-usdt', '')
addresses = await getAddresses(baseChain)
actualChain = baseChain
}
// Find the address to set as current
const targetAddr = addresses.find(a => a.index === index)
if (!targetAddr) {
throw new Error(`Address not found: ${actualChain}:${index}`)
}
// Update all addresses: unset current on others, set on target
for (const addr of addresses) {
if (addr.index === index) {
await storeAddress({ ...addr, chain: actualChain, isCurrent: true })
} else if (addr.isCurrent) {
await storeAddress({ ...addr, chain: actualChain, isCurrent: false })
}
}
return { ...targetAddr, chain: actualChain, isCurrent: true }
}
/**
* Archive address (hide from UI)
* @param {string} chain - 'bitcoin' or 'ethereum'
* @param {number} index - Address index
*/
async function archiveAddress(chain, index) {
if (!autobase) {
throw new Error('Storage not initialized')
}
const addresses = await getAddresses(chain)
const existing = addresses.find(a => a.index === index)
if (!existing) {
throw new Error(`Address not found: ${chain}:${index}`)
}
await storeAddress({
...existing,
isArchived: true
})
return { ...existing, isArchived: true }
}
// ============================================================================
// UTXO MANAGEMENT (Phase 6.3)
// ============================================================================
/**
* Store UTXO in Autobase
* @param {Object} utxoData - { chain, txid, vout, address, amount, ... }
*/
async function storeUTXO(utxoData) {
if (!autobase) {
throw new Error('Storage not initialized')
}
const op = {
type: 'put-utxo',
key: `utxo:${utxoData.chain}:${utxoData.txid}:${utxoData.vout}`,
value: utxoData
}
await autobase.append(JSON.stringify(op))
// Wait for view to update
await new Promise(resolve => setTimeout(resolve, 100))
}
/**
* Get all UTXOs for a chain
* @param {string} chain - 'bitcoin' or 'ethereum' or 'all'
* @returns {Array} - Array of UTXO objects
*/
async function getUTXOs(chain) {
if (!txHistoryDb) {
throw new Error('Storage not initialized')
}
const utxos = []
const prefix = chain === 'all' ? 'utxo:' : `utxo:${chain}:`
for await (const entry of txHistoryDb.createReadStream()) {
if (entry.key.startsWith(prefix)) {
utxos.push(JSON.parse(entry.value))
}
}
return utxos
}
/**
* Update UTXO (e.g., mark as spent)
* @param {string} chain - Chain identifier
* @param {string} txid - Transaction ID
* @param {number} vout - Output index
* @param {Object} updates - Fields to update
*/
async function updateUTXO(chain, txid, vout, updates) {
if (!autobase) {
throw new Error('Storage not initialized')
}
// Get existing UTXO
const key = `utxo:${chain}:${txid}:${vout}`
const existing = await txHistoryDb.get(key)
if (!existing) {
throw new Error(`UTXO not found: ${key}`)
}
const utxoData = JSON.parse(existing.value)
// Merge updates
const updated = {
...utxoData,
...updates
}
const op = {
type: 'put-utxo',
key,
value: updated
}
await autobase.append(JSON.stringify(op))
// Wait for view to update
await new Promise(resolve => setTimeout(resolve, 100))
}
/**
* Delete UTXO from storage
* @param {string} chain - Chain identifier
* @param {string} txid - Transaction ID
* @param {number} vout - Output index
*/
async function deleteUTXO(chain, txid, vout) {
if (!autobase) {
throw new Error('Storage not initialized')
}
const op = {
type: 'del-utxo',
key: `utxo:${chain}:${txid}:${vout}`
}
await autobase.append(JSON.stringify(op))
// Wait for view to update
await new Promise(resolve => setTimeout(resolve, 100))
}
// ============================================================================
// TRANSACTION STORAGE (Phase 6.6)
// ============================================================================
/**
* Store a transaction in Autobase
* @param {Object} tx - Transaction details
* @param {string} tx.hash - Transaction ID
* @param {string} tx.chain - Chain (bitcoin, ethereum, etc)
* @param {string} tx.direction - Direction (sent, received)
* @param {string} tx.from - From address
* @param {string} tx.to - To address
* @param {bigint|number} tx.amount - Amount in smallest unit
* @param {bigint|number} tx.fee - Fee in smallest unit
* @param {string} tx.status - Status (pending, confirmed, failed)
* @param {number} tx.timestamp - Unix timestamp
* @param {number} tx.blockHeight - Block height (if confirmed)
*/
async function storeTransaction(tx) {
if (!autobase) {
throw new Error('Storage not initialized')
}
const transaction = {
hash: tx.hash,
chain: tx.chain,
network: tx.network || 'testnet', // Add network field (testnet/mainnet)
direction: tx.direction,
from: tx.from,
to: tx.to,
amount: typeof tx.amount === 'string' ? tx.amount : String(tx.amount), // Ensure string
fee: typeof tx.fee === 'string' ? tx.fee : String(tx.fee || 0),
status: tx.status || 'pending',
timestamp: tx.timestamp || Date.now(),
blockHeight: tx.blockHeight || null
}
const op = {
type: 'store-transaction',
key: `tx:${tx.chain}:${tx.hash}`,
value: transaction
}
await autobase.append(JSON.stringify(op))
// Wait for view to update
await new Promise(resolve => setTimeout(resolve, 100))
}
/**
* Get all transactions for a chain
* @param {string} chain - Chain name (bitcoin, ethereum, all)
* @returns {Promise<Array>} - Array of transactions sorted by timestamp (newest first)
*/
async function getTransactions(chain = 'all') {
if (!txHistoryDb) {
throw new Error('Storage not initialized')
}
const transactions = []
const prefix = chain === 'all' ? 'tx:' : `tx:${chain}:`
for await (const data of txHistoryDb.createReadStream({ gte: prefix, lt: prefix + '\xff' })) {
try {
const tx = JSON.parse(data.value)
// Convert string amounts back to numbers for display
tx.amount = BigInt(tx.amount)
tx.fee = tx.fee ? BigInt(tx.fee) : 0n
transactions.push(tx)
} catch (err) {
console.error('Error parsing transaction:', err.message)
}
}
// Sort by timestamp, newest first
transactions.sort((a, b) => b.timestamp - a.timestamp)
return transactions
}
/**
* Get a specific transaction by hash
* @param {string} chain - Chain name
* @param {string} hash - Transaction hash
* @returns {Promise<Object|null>} - Transaction object or null
*/
async function getTransaction(chain, hash) {
if (!txHistoryDb) {
throw new Error('Storage not initialized')
}
try {
const data = await txHistoryDb.get(`tx:${chain}:${hash}`)
if (!data) return null
const tx = JSON.parse(data.value)
// Handle case where tx or its fields might be null/undefined
if (!tx) {
return null
}
// Safely convert amount and fee to BigInt
tx.amount = tx.amount ? BigInt(tx.amount) : 0n
tx.fee = tx.fee ? BigInt(tx.fee) : 0n
return tx
} catch (err) {
if (err.code === 'LEVEL_NOT_FOUND') {
return null
}
// Handle corrupted/malformed transaction data - treat as not found
// The corrupted entry will be overwritten by the new correct entry
if (err instanceof SyntaxError) {
return null
}
throw err
}
}
/**
* Delete transaction (for repair/cleanup)
* @param {string} chain - Chain name
* @param {string} hash - Transaction hash
*/
async function deleteTransaction(chain, hash) {
if (!autobase) {
throw new Error('Storage not initialized')
}
const op = {
type: 'delete-transaction',
key: `tx:${chain}:${hash}`
}
await autobase.append(JSON.stringify(op))
// Wait longer for view to update and propagate delete
await new Promise(resolve => setTimeout(resolve, 500))
}
/**
* Update transaction status (Phase 6.7)
* @param {string} chain - Chain name
* @param {string} hash - Transaction hash
* @param {Object} updates - Fields to update (status, blockHeight, confirmations)
*/
async function updateTransactionStatus(chain, hash, updates) {
if (!autobase) {
throw new Error('Storage not initialized')
}
// Get existing transaction
const existing = await getTransaction(chain, hash)
if (!existing) {
throw new Error('Transaction not found')
}
// Merge updates, ensuring BigInt values are converted to strings
const updated = {
hash: existing.hash,
chain: existing.chain,
direction: existing.direction,
from: existing.from,
to: existing.to,
amount: existing.amount.toString(),
fee: existing.fee ? existing.fee.toString() : '0',
status: updates.status !== undefined ? updates.status : existing.status,
timestamp: existing.timestamp,
blockHeight: updates.blockHeight !== undefined ? updates.blockHeight : existing.blockHeight
}
const op = {
type: 'store-transaction',
key: `tx:${chain}:${hash}`,
value: updated
}
await autobase.append(JSON.stringify(op))
// Wait for view to update
await new Promise(resolve => setTimeout(resolve, 100))
}
/**
* Sync transactions from WDK to local Hyperbee storage
* Fetches transaction history using Electrum for Bitcoin and provider for Ethereum
* @param {Object} wdk - WDK instance
* @param {Object} config - Configuration with chains to sync
* @returns {Promise<Object>} - Sync results { synced, errors }
*/
async function syncTransactionsFromWDK(wdk, config = {}) {
if (!wdk || !txHistoryDb) {
throw new Error('WDK or storage not initialized')
}
const chains = config.chains || ['bitcoin', 'ethereum']
const results = {
synced: 0,
errors: [],
chains: {}
}
for (const chain of chains) {
try {
// Determine base chain for WDK (ethereum-usdt -> ethereum)
const baseChain = chain.replace(/-usdt$/, '')
// Use explicit account index if provided, otherwise default to 0
// WDK design: Application must explicitly specify which account to query
const accountIndex = config.accountIndex !== undefined ? config.accountIndex : 0
// Get WDK account for this chain at the correct index
const account = await wdk.getAccount(baseChain, accountIndex)
if (!account) {
results.errors.push(`No account found for chain: ${chain}`)
continue
}
// Get current address
const currentAddress = await account.getAddress()
let chainSynced = 0
let chainSkipped = 0
let transactions = []
// Chain-specific transaction fetching
if (chain === 'bitcoin') {
// Bitcoin: Use public WDK getTransfers() API (documented in wdk-wallet-btc)
try {
const transfers = await account.getTransfers({
direction: 'all',
limit: 100
})
for (const transfer of transfers) {
const existing = await getTransaction(chain, transfer.txid)
if (existing) {
chainSkipped++
continue
}
const tx = {
hash: transfer.txid,
chain,
network: config.network || 'testnet',
direction: transfer.direction, // 'incoming' or 'outgoing'
from: transfer.direction === 'incoming' ? '(external)' : currentAddress,
to: transfer.direction === 'incoming' ? currentAddress : (transfer.recipient || '(unknown)'),
amount: String(transfer.value),
fee: transfer.fee ? String(transfer.fee) : '0',
status: transfer.height > 0 ? 'confirmed' : 'pending',
timestamp: Date.now(),
blockHeight: transfer.height || null
}
await storeTransaction(tx)
chainSynced++
results.synced++
}
} catch (err) {
console.error(`Bitcoin sync error: ${err.message}`)
results.errors.push(`${chain}: ${err.message}`)
}
} else if (baseChain === 'ethereum') {
// Ethereum (including USDT): Fetch transactions from blockchain provider
try {
const config = getConfig()
const network = config.network || 'testnet'
// Determine if this is a token (USDT or USDC) or native ETH
const isUSDT = chain.includes('-usdt')
const isUSDC = chain.includes('-usdc')
const isToken = isUSDT || isUSDC
let providerTxs = []
let tokenSymbol = null
if (isToken) {
// Fetch ERC-20 token transactions
console.log(` 📡 Fetching token transactions for ${chain}...`)
console.log(` 📍 Address: ${currentAddress} (index ${accountIndex})`)
// Token contract addresses
let tokenContract
if (isUSDT) {
tokenSymbol = 'USDT'
tokenContract = network === 'testnet'
? '0x7169D38820dfd117C3FA1f22a697dBA58d90BA06' // Sepolia testnet USDT
: '0xdAC17F958D2ee523a2206206994597C13D831ec7' // Ethereum mainnet USDT
} else if (isUSDC) {
tokenSymbol = 'USDC'
tokenContract = network === 'testnet'
? '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' // Sepolia testnet USDC (Circle official)
: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' // Ethereum mainnet USDC
}
console.log(` 🏦 ${tokenSymbol} Contract: ${tokenContract}`)
if (!tokenContract) {
throw new Error(`Token contract address not configured for ${chain} on ${network}`)
}
providerTxs = await fetchTokenTransactions(currentAddress, network, config, tokenContract)
console.log(` 📊 API returned ${providerTxs.length} transactions`)
} else {
// Fetch native ETH transactions
console.log(` 📡 Fetching ETH transactions for ${chain}...`)
providerTxs = await fetchTransactions(baseChain, currentAddress, network, config)
}
for (const tx of providerTxs) {
console.log(` 🔍 Checking tx: ${tx.hash.substring(0, 16)}...`)
console.log(` From: ${tx.from?.substring(0, 10)}...`)
console.log(` To: ${tx.to?.substring(0, 10)}...`)
console.log(` Value: ${tx.value}`)
if (tx.tokenSymbol) {
console.log(` Token: ${tx.tokenSymbol}`)
}
// Check if already stored
const existing = await getTransaction(chain, tx.hash)
if (existing) {
// Check if transaction data is corrupted (amount = 0 for token transfers)
const isCorrupted = existing.amount === 0n && tx.value !== '0'
if (isCorrupted) {
console.log(` 🔧 REPAIRING corrupted tx: ${tx.hash.substring(0, 16)}... (amount was 0, should be ${tx.value})`)
// Don't skip - will overwrite with correct data
} else {
console.log(` ⏭️ SKIPPED (already exists): ${tx.hash.substring(0, 16)}...`)
chainSkipped++
continue
}
}
// Determine direction
const direction = tx.to.toLowerCase() === currentAddress.toLowerCase() ? 'received' : 'sent'
console.log(` 💾 STORING (${direction}): ${tx.hash.substring(0, 16)}...`)
// Store transaction
const storedTx = {
hash: tx.hash,
chain,
network,
direction,
from: tx.from,
to: tx.to,
amount: tx.value,
fee: tx.fee,
status: tx.status,
timestamp: tx.timestamp * 1000, // Convert to milliseconds
blockHeight: tx.blockNumber,
// Add token info if this is a token transfer
token: tokenSymbol || tx.tokenSymbol || null,
decimals: isToken ? 6 : 18 // USDT/USDC have 6 decimals, ETH has 18
}
await storeTransaction(storedTx)
chainSynced++
results.synced++
console.log(` ✅ Stored successfully`)
}
} catch (err) {
console.error(`Error fetching ${chain} transactions:`, err.message)
results.errors.push(`${chain}: ${err.message}`)
}
}
results.chains[chain] = {
synced: chainSynced,
skipped: chainSkipped
}
} catch (err) {
console.error(`Error syncing ${chain} transactions:`, err.message)
results.errors.push(`${chain}: ${err.message}`)
}
}
return results
}
// ============================================================================
// DEVICE MANAGEMENT
// ============================================================================
/**
* Load device list from file
*/
function loadDevices() {
if (!fs.existsSync(DEVICES_FILE)) {
return []
}
const data = fs.readFileSync(DEVICES_FILE, 'utf8')
const devices = JSON.parse(data)
// Convert hex strings back to Buffers
return devices.map(d => ({
...d,
key: b4a.from(d.key, 'hex'),
proof: d.proof ? b4a.from(d.proof, 'hex') : null
}))
}
/**
* Save device list to file
*/
function saveDevices(devices) {
const dir = path.dirname(DEVICES_FILE)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
}
// Convert Buffers to hex strings for JSON
const serializable = devices.map(d => ({
...d,
key: b4a.toString(d.key, 'hex'),
proof: d.proof ? b4a.toString(d.proof, 'hex') : null
}))
fs.writeFileSync(DEVICES_FILE, JSON.stringify(serializable, null, 2), { mode: 0o600 })
}
// ============================================================================
// CONTACT MANAGEMENT
// ============================================================================
/**
* Add a contact to the address book
* @param {Object} contact - Contact details
* @param {string} contact.address - Blockchain address
* @param {string} contact.chain - Chain name (bitcoin, ethereum)
* @param {string} contact.name - Display name
* @param {string} [contact.notes] - Optional notes
* @returns {Promise<Object>} - Created contact with ID
*/
async function addContact({ address, chain, name, notes = '' }) {
if (!autobase) {
throw new Error('Storage not initialized')
}
// Generate unique ID for contact
const id = crypto.randomBytes(16).toString('hex')
const contact = {
id,
address,
chain,
name,
notes,
addedAt: Date.now(),
addedBy: getCurrentDevice()?.key ? b4a.toString(getCurrentDevice().key, 'hex').slice(0, 16) : 'unknown',
lastUsedAt: null,
metadata: {
favorite: false,
category: null
}
}
const op = {
type: 'put-contact',
key: `contact:${id}`,
value: contact
}
await autobase.append(JSON.stringify(op))
// Wait for view to update
await new Promise(resolve => setTimeout(resolve, 100))
return contact
}
/**
* Get all contacts, optionally filtered by chain
* @param {string} [chain] - Chain name to filter by (optional)
* @returns {Promise<Array>} - Array of contacts sorted by name
*/
/**
* Migrate a contact to new multi-address format with IDs
* @param {Object} contact - Contact to migrate
* @returns {Object} - Migrated contact
*/
function migrateContactFormat(contact) {
// Already in new format with proper IDs - check if ALL addresses have IDs
if (contact.addresses && Array.isArray(contact.addresses)) {
// Check if any address is missing an ID
const needsMigration = contact.addresses.some(addr => !addr.id)
if (!needsMigration) {
// All addresses have IDs, no migration needed
return contact
}
// Some addresses missing IDs - add them
const migratedAddresses = contact.addresses.map(addr => {
if (!addr.id) {
return {
...addr,
id: crypto.randomBytes(8).toString('hex'),
network: addr.network || getConfig().network || 'testnet',
label: addr.label || '',
addedAt: addr.addedAt || Date.now()
}
}
return addr
})
return {
...contact,
addresses: migratedAddresses
}
}
// Old single-address format - migrate to multi-address
if (contact.address && contact.chain) {
const config = getConfig()
const defaultNetwork = config.network || 'testnet'
return {
...contact,
addresses: [{
id: crypto.randomBytes(8).toString('hex'),
chain: contact.chain,
address: contact.address,
network: contact.network || defaultNetwork,
label: '',
addedAt: contact.addedAt || Date.now()
}],
// Remove old fields
address: undefined,
chain: undefined
}
}
// Contact with no addresses - return as is
return contact
}
async function getContacts(chain = null) {
if (!txHistoryDb) {
throw new Error('Storage not initialized')
}
const contacts = []
const prefix = 'contact:'
for await (const data of txHistoryDb.createReadStream({ gte: prefix, lt: prefix + '\xff' })) {
try {
let contact = JSON.parse(data.value)
// Migrate contact format if needed
contact = migrateContactFormat(contact)
// Filter by chain if specified (check both old and new format)
if (chain) {
// New format: check if any address matches chain
if (contact.addresses && Array.isArray(contact.addresses)) {
const hasChain = contact.addresses.some(addr => addr.chain === chain)
if (!hasChain) continue
} else if (contact.chain && contact.chain !== chain) {
// Old format fallback
continue
}
}
contacts.push(contact)
} catch (err) {
console.error('Error parsing contact:', err.message)
}
}
// Sort by name (case-insensitive)
contacts.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
return contacts
}
/**
* Find a contact by name or address
* @param {string} nameOrAddress - Contact name or blockchain address
* @param {string} [chain] - Chain to search (optional, makes search faster)
* @returns {Promise<Object|null>} - Contact object or null
*/
async function findContact(nameOrAddress, chain = null) {
const contacts = await getContacts(chain)
// Try exact name match first (case-insensitive)
const byName = contacts.find(c =>
c.name.toLowerCase() === nameOrAddress.toLowerCase()
)
if (byName) return byName
// Try exact address match (check in addresses array)
const byAddress = contacts.find(c => {
if (c.addresses && Array.isArray(c.addresses)) {
return c.addresses.some(addr =>
addr.address.toLowerCase() === nameOrAddress.toLowerCase()
)
}
return false
})
if (byAddress) return byAddress
// Try partial name match
const byPartial = contacts.find(c =>
c.name.toLowerCase().includes(nameOrAddress.toLowerCase())
)
return byPartial || null
}
/**
* Get a contact by ID
* @param {string} id - Contact ID
* @returns {Promise<Object|null>} - Contact object or null
*/
async function getContactById(id) {
if (!txHistoryDb) {
throw new Error('Storage not initialized')
}
try {
const data = await txHistoryDb.get(`contact:${id}`)
if (!data) return null
let contact = JSON.parse(data.value)
// Migrate contact format if needed
contact = migrateContactFormat(contact)
return contact
} catch (err) {
if (err.code === 'LEVEL_NOT_FOUND') {
return null
}
throw err
}
}
/**
* Update a contact
* @param {string} id - Contact ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} - Updated contact
*/
async function updateContact(id, updates) {
if (!autobase) {
throw new Error('Storage not initialized')
}
const existing = await getContactById(id)
if (!existing) {
throw new Error('Contact not found')
}
const updated = {
...existing,
...updates,
id: existing.id, // Preserve ID
addedAt: existing.addedAt, // Preserve creation time
modifiedAt: Date.now()
}
const op = {
type: 'put-contact',
key: `contact:${id}`,
value: updated
}
await autobase.append(JSON.stringify(op))
// Wait for view to update
await new Promise(resolve => setTimeout(resolve, 100))
return updated
}
/**
* Delete a contact
* @param {string} id - Contact ID
*/
async function deleteContact(id) {
if (!autobase) {
throw new Error('Storage not initialized')
}
const op = {
type: 'del-contact',
key: `contact:${id}`
}
await autobase.append(JSON.stringify(op))
// Wait for view to update
await new Promise(resolve => setTimeout(resolve, 100))
}
/**
* Find contact by address (helper for transaction display)
* UPDATED: Supports both old (single address) and new (multiple addresses) format
* @param {string} address - Blockchain address
* @param {string} chain - Chain name
* @returns {Promise<Object|null>} - Contact or null
*/
async function findContactByAddress(address, chain) {
const contacts = await getContacts()
return contacts.find(c => {
// Old format: contact.address
if (c.address && c.address.toLowerCase() === address.toLowerCase() && c.chain === chain) {
return true
}
// New format: contact.addresses[]
if (c.addresses && Array.isArray(c.addresses)) {
return c.addresses.some(addr =>
addr.chain === chain && addr.address.toLowerCase() === address.toLowerCase()
)
}
return false
}) || null
}
/**
* Add an address to an existing contact (supports multiple addresses per chain)
* @param {string} contactId - Contact ID
* @param {Object} addressInfo - Address details
* @param {string} addressInfo.chain - Chain name (bitcoin, ethereum, polygon, etc.)
* @param {string} addressInfo.address - Blockchain address
* @param {string} addressInfo.network - Network (testnet, mainnet, sepolia, etc.)
* @param {string} [addressInfo.label] - Optional label (e.g., "Hardware wallet", "Hot wallet")
* @returns {Promise<Object>} - Updated contact
*/
async function addAddressToContact(contactId, { chain, address, network, label = '' }) {
if (!autobase) {
throw new Error('Storage not initialized')
}
const existing = await getContactById(contactId)
if (!existing) {
throw new Error('Contact not found')
}
// Migrate old format to new format if needed
let addresses = []
if (existing.addresses && Array.isArray(existing.addresses)) {
addresses = existing.addresses
} else if (existing.address && existing.chain) {
// Convert old single-address format (add ID to migrated address)
addresses = [{
id: crypto.randomBytes(8).toString('hex'),
chain: existing.chain,
address: existing.address,
network: existing.network || network,
label: ''
}]
}
// Check if EXACT same address already exists (same address + same chain)
const exactMatch = addresses.find(a =>
a.chain === chain && a.address.toLowerCase() === address.toLowerCase()
)
if (exactMatch) {
throw new Error(`This exact address already exists for ${chain}`)
}
// Generate unique ID for this address
const addressId = crypto.randomBytes(8).toString('hex')
// Add new address with unique ID
addresses.push({
id: addressId,
chain,
address,
network,
label: label || '',
addedAt: Date.now()
})
// Update contact with new addresses array
const updated = {
...existing,
addresses,
// Remove old format fields
address: undefined,
chain: undefined,
modifiedAt: Date.now()
}
const op = {
type: 'put-contact',
key: `contact:${contactId}`,
value: updated
}
await autobase.append(JSON.stringify(op))
// Wait for view to update
await new Promise(resolve => setTimeout(resolve, 100))
return updated
}
/**
* Remove an address from a contact by address ID
* @param {string} contactId - Contact ID
* @param {string} addressId - Address ID to remove
* @returns {Promise<Object>} - Updated contact
*/
async function removeAddressFromContact(contactId, addressId) {
if (!autobase) {
throw new Error('Storage not initialized')
}
const existing = await getContactById(contactId)
if (!existing) {
throw new Error('Contact not found')
}
// Migrate old format to new format if needed
let addresses = []
if (!existing.addresses || !Array.isArray(existing.addresses)) {
// Old format: single address
if (existing.address && existing.chain) {
// Get default network from config for migration
const config = getConfig()
const defaultNetwork = config.network || 'testnet'
addresses = [{
id: crypto.randomBytes(8).toString('hex'),
chain: existing.chain,
address: existing.address,
network: existing.network || defaultNetwork,
label: '',
addedAt: existing.addedAt || Date.now()
}]
} else {
throw new Error('Contact has no addresses')
}
} else {
addresses = existing.addresses
}
// Filter out the address with this ID
const newAddresses = addresses.filter(a => a.id !== addressId)
if (newAddresses.length === addresses.length) {
throw new Error(`No address found with ID: ${addressId}`)
}
if (newAddresses.length === 0) {
throw new Error('Cannot remove last address. Delete contact instead.')
}
const updated = {
...existing,
addresses: newAddresses,
// Remove old format fields
address: undefined,
chain: undefined,
modifiedAt: Date.now()
}
const op = {
type: 'put-contact',
key: `contact:${contactId}`,
value: updated
}
await autobase.append(JSON.stringify(op))
// Wait for view to update
await new Promise(resolve => setTimeout(resolve, 100))
return updated
}
/**
* Bootstrap initial device (this device)
* Creates attestation proof for the first device
*/
async function bootstrapDevice(identity, deviceName) {
if (!autobase) {
throw new Error('Storage not initialized')
}
// With Autobase, the local writer is autobase.local
const localWriter = autobase.local
await localWriter.ready()
// Create bootstrap proof (self-attestation)
const proof = await identity.bootstrap(localWriter.key)
const device = {
name: deviceName,
key: localWriter.key,
identityKey: identityKeyPair.publicKey, // Shared identity key
proof: proof,
addedAt: Date.now(),
isLocal: true
}
const devices = loadDevices()
// Check if already bootstrapped
const existing = devices.find(d => b4a.equals(d.key, localWriter.key))
if (existing) {
return existing
}
devices.push(device)
saveDevices(devices)
return device
}
/**
* Create invite for another device to join
* Returns invite code that includes discovery key and autobase bootstrap key
*/
async function createInvite(identity) {
if (!identityKeyPair) {
throw new Error('Identity not loaded')
}
if (!autobase) {
throw new Error('Storage not initialized')
}
const now = Date.now()
const INVITE_EXPIRATION = 15 * 60 * 1000 // 15 minutes
// Create invite data (without signature first)
const inviteData = {
discoveryKey: b4a.toString(crypto.discoveryKey(identityKeyPair.publicKey), 'hex'),
identityKey: b4a.toString(identityKeyPair.publicKey, 'hex'),
autobaseKey: b4a.toString(autobase.key, 'hex'),
timestamp: now,
expiresAt: now + INVITE_EXPIRATION
}
// Sign the invite data with identity secret key
const message = b4a.from(JSON.stringify(inviteData))
const signature = crypto.sign(message, identityKeyPair.secretKey)
// Add signature to invite
const signedInvite = {
...inviteData,
signature: b4a.toString(signature, 'hex')
}
// Encode as base64
const inviteStr = b4a.toString(b4a.from(JSON.stringify(signedInvite)), 'base64')
console.log(`✅ Created signed invite (expires in 15 minutes)`)
return inviteStr
}
/**
* Join wallet using invite code
* Receives mnemonic from primary device over secure Protomux channel
* Initializes Autobase with bootstrap key from invite
*
* NOTE: This reinitializes storage! Close existing storage first.
* @param {string} inviteCode - Invite code from primary device
* @param {string} deviceName - Name for this device
* @returns {Object} { device, mnemonic } - Device info and received mnemonic
*/
async function joinWallet(inviteCode, deviceName) {
// Decode invite
let invite
try {
const inviteJson = b4a.toString(b4a.from(inviteCode, 'base64'))
invite = JSON.parse(inviteJson)
} catch (err) {
throw new Error('Invalid invite code format')
}
// Verify invite has required fields
if (!invite.discoveryKey || !invite.identityKey || !invite.autobaseKey ||
!invite.timestamp || !invite.expiresAt || !invite.signature) {
throw new Error('Invalid invite: missing required fields')
}
// Check expiration (15 minutes)
const now = Date.now()
if (now > invite.expiresAt) {
const expirationAge = Math.floor((now - invite.expiresAt) / 1000 / 60) // minutes ago
throw new Error(`Invite code expired ${expirationAge} minutes ago. Request a new invite.`)
}
// Verify signature
const { signature, ...inviteData } = invite
const message = b4a.from(JSON.stringify(inviteData))
const sig = b4a.from(signature, 'hex')
const pubKey = b4a.from(invite.identityKey, 'hex')
if (!crypto.verify(message, sig, pubKey)) {
throw new Error('Invalid invite signature - invite may be forged or corrupted')
}
console.log('✅ Invite signature verified')
console.log(`⏰ Invite expires in ${Math.floor((invite.expiresAt - now) / 1000 / 60)} minutes`)
// Initialize Corestore for temporary writer key
if (!store) {
store = new Corestore(STORAGE_DIR)
await store.ready()
}
// Get namespace
const namespace = await Autobase.getLocalKey(store)
// View class for Autobase
class WalletView {
open(store) {
const core = store.get('view')
return new Hyperbee(core, {
keyEncoding: 'utf-8',
valueEncoding: 'binary'
})
}
async apply(nodes, view, base) {
return apply(nodes, view, base)
}
close(view) {
return view.close()
}
}
// Initialize Autobase with BOOTSTRAP KEY from invite
const bootstrapKey = b4a.from(invite.autobaseKey, 'hex')
autobase = new Autobase(store.namespace(namespace), bootstrapKey, new WalletView())
await autobase.ready()
// Get our local writer key (to send to primary device)
const localWriterKey = autobase.local.key
// Get view
txHistoryDb = autobase.view
// Initialize Hyperswarm for pairing
if (!swarm) {
swarm = new Hyperswarm()
}
console.log('📡 Connecting to primary device...')
// Set up connection handler BEFORE joining swarm (avoid race condition)
const { conn, identityMnemonic, walletMnemonic } = await new Promise((resolve, reject) => {
let timeout = null
const handler = async (conn) => {
try {
if (timeout) clearTimeout(timeout)
swarm.off('connection', handler)
console.log('✅ Connected to primary device')
console.log('🔐 Starting pairing protocol...')
// Receive mnemonics via pairing protocol
const { identityMnemonic, walletMnemonic } = await createPairingClient(conn, localWriterKey, deviceName)
// Start replication AFTER pairing completes
store.replicate(conn)
resolve({ conn, identityMnemonic, walletMnemonic })
} catch (err) {
if (timeout) clearTimeout(timeout)
reject(err)
}
}
swarm.on('connection', handler)
// Timeout after 60 seconds
timeout = setTimeout(() => {
swarm.off('connection', handler)
reject(new Error('Connection timeout'))
}, 60000)
// Join discovery key AFTER setting up listener (avoid race condition)
const discoveryKey = b4a.from(invite.discoveryKey, 'hex')
swarm.join(discoveryKey, { server: true, client: true }).flushed().catch(reject)
})
console.log('✅ Received wallet mnemonics')
console.log('')
// Create identity from received identity mnemonic
const identity = await IdentityKey.from({ mnemonic: identityMnemonic })
identityKeyPair = identity.identityKeyPair
// Create bootstrap proof for this device
const proof = await identity.bootstrap(localWriterKey)
// Save local device info
const device = {
name: deviceName,
key: localWriterKey,
publicKey: b4a.toString(identityKeyPair.publicKey, 'hex'),
proof: proof,
addedAt: Date.now(),
isLocal: true
}
const devices = [device]
saveDevices(devices)
// NOTE: Don't try to add ourselves as a writer here - we can't append until
// we're already a writer (chicken-egg problem). The primary device handles
// adding our writer via the addDevice function during pairing.
return { device, identityMnemonic, walletMnemonic }
}
/**
* Add another device's writer to Autobase
* Called after attestation is verified
*/
async function addDevice(deviceKey, deviceName, proof) {
const remoteWriter = store.get(deviceKey)
await remoteWriter.ready()
// Add to Autobase by appending addWriter operation
const op = { addWriter: b4a.toString(deviceKey, 'hex') }
await autobase.append(JSON.stringify(op))
// Save to device list
const devices = loadDevices()
devices.push({
name: deviceName,
key: deviceKey,
proof: proof,
addedAt: Date.now(),
isLocal: false
})
saveDevices(devices)
}
/**
* Get current device info
*/
function getCurrentDevice() {
if (!autobase) return null
const devices = loadDevices()
return devices.find(d => d.isLocal && b4a.equals(d.key, autobase.local.key))
}
/**
* List all devices
*/
function listDevices() {
return loadDevices()
}
/**
* Remove device (revoke access)
* Note: Autobase doesn't have removeInput, so we append a removeWriter operation
*/
async function removeDevice(deviceKey) {
if (!autobase) {
throw new Error('Storage not initialized')
}
let devices = loadDevices()
const device = devices.find(d => b4a.equals(d.key, deviceKey))
if (!device) {
throw new Error('Device not found')
}
// Prevent removing local device
if (device.isLocal) {
throw new Error('Cannot remove local device')
}
// Append removeWriter operation - Autobase will process this
// Note: Autobase doesn't have built-in removeInput, so we use custom operation
const op = {
removeWriter: b4a.toString(deviceKey, 'hex'),
timestamp: Date.now()
}
await autobase.append(JSON.stringify(op))
// Wait for operation to be processed
await autobase.update()
await new Promise(resolve => setTimeout(resolve, 500))
// Remove from device list
devices = devices.filter(d => !b4a.equals(d.key, deviceKey))
saveDevices(devices)
}
/**
* Deduplicate devices in the device list
* Removes duplicate entries keeping only the most recent one per writer key
*/
function deduplicateDevices() {
let devices = loadDevices()
const seen = new Map()
const deduplicated = []
// Sort by addedAt (newest first)
devices.sort((a, b) => b.addedAt - a.addedAt)
for (const device of devices) {
const writerKeyHex = b4a.toString(device.key, 'hex')
if (!seen.has(writerKeyHex)) {
seen.set(writerKeyHex, true)
deduplicated.push(device)
}
}
// Sort back by addedAt (oldest first) for consistent ordering
deduplicated.sort((a, b) => a.addedAt - b.addedAt)
const removed = devices.length - deduplicated.length
if (removed > 0) {
saveDevices(deduplicated)
}
return { removed, total: deduplicated.length }
}
/**
* Get connection status
*/
function getConnectionStatus() {
return {
connected: connections.size > 0,
count: connections.size,
connections: Array.from(connections).map(conn => ({
remotePublicKey: conn.remotePublicKey ? b4a.toString(conn.remotePublicKey, 'hex').slice(0, 16) + '...' : 'unknown'
}))
}
}
/**
* Wait for incoming connections (for accepting join requests)
* Returns when a new device connects
* @param {number} timeout - Timeout in ms (Infinity = wait forever)
*/
async function waitForConnection(timeout = 30000) {
return new Promise((resolve, reject) => {
let timer = null
// Only set timeout if not infinite
if (timeout !== Infinity && timeout > 0) {
timer = setTimeout(() => {
swarm.off('connection', handler)
reject(new Error('Timeout waiting for connection'))
}, timeout)
}
const handler = (conn) => {
if (timer) clearTimeout(timer)
swarm.off('connection', handler)
resolve(conn)
}
swarm.on('connection', handler)
})
}
/**
* Wait for a new device to connect and auto-approve it
* Sends mnemonics to joining device over secure Protomux channel
* @param {number} timeout - Timeout in ms (0 = infinite)
* @param {string} identityMnemonic - Identity mnemonic to send to joining device
* @param {string} walletMnemonic - Wallet mnemonic to send to joining device
* @returns {Object} { writerKey, deviceName }
*/
async function waitForNewDevice(timeout = 120000, identityMnemonic = null, walletMnemonic = null) {
if (!identityMnemonic || !walletMnemonic) {
throw new Error('Both identity and wallet mnemonics required to pair new device')
}
// Get current device info
const currentDevice = getCurrentDevice()
const myDeviceName = currentDevice ? currentDevice.name : 'Primary Device'
// Wait for connection (infinite if timeout=0)
const actualTimeout = timeout === 0 ? Infinity : timeout
const conn = await waitForConnection(actualTimeout)
console.log('✅ Device connected, exchanging pairing information...')
// Start pairing server protocol - this will send BOTH mnemonics
const { remoteDeviceName, remoteWriterKey } = await createPairingServer(
conn,
identityMnemonic,
walletMnemonic,
myDeviceName
)
console.log(`🔍 Received device info: ${remoteDeviceName}`)
console.log(` Writer: ${b4a.toString(remoteWriterKey, 'hex').slice(0, 16)}...`)
console.log('')
// Start replication AFTER pairing protocol completes
store.replicate(conn)
// Auto-approve the writer
console.log('🔐 Auto-approving writer...')
// Append addWriter operation
const op = {
addWriter: b4a.toString(remoteWriterKey, 'hex'),
timestamp: Date.now()
}
await autobase.append(JSON.stringify(op))
await autobase.update()
// Wait for operation to be processed
await new Promise(resolve => setTimeout(resolve, 1000))
// Check for duplicate writer key (prevent re-adding same device)
const devices = loadDevices()
const writerKeyHex = b4a.toString(remoteWriterKey, 'hex')
const existing = devices.find(d => b4a.toString(d.key, 'hex') === writerKeyHex)
if (existing) {
console.log(`⚠️ Device already exists: ${existing.name} (writer: ${writerKeyHex.slice(0, 16)}...)`)
console.log(' Skipping duplicate entry')
return { writerKey: remoteWriterKey, deviceName: remoteDeviceName, isDuplicate: true }
}
// Get identity key (shared across all devices using same identity mnemonic)
const identity = await IdentityKey.from({ mnemonic: identityMnemonic })
const identityKey = identity.identityKeyPair.publicKey
// Save to device list with both keys
devices.push({
name: remoteDeviceName,
key: remoteWriterKey,
identityKey: identityKey, // Shared identity key
proof: null,
addedAt: Date.now(),
isLocal: false
})
saveDevices(devices)
return { writerKey: remoteWriterKey, deviceName: remoteDeviceName, isDuplicate: false }
}
/**
* Save pending writer to file with attestation proof
* @param {Buffer} writerKey - The writer key
* @param {Buffer} proof - Optional attestation proof
*/
function savePendingWriter(writerKey, proof = null) {
const dir = path.dirname(PENDING_FILE)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
}
const pending = loadPendingWriters()
const keyHex = b4a.toString(writerKey, 'hex')
// Check if already exists
const existing = pending.find(p => p.key === keyHex)
if (!existing) {
pending.push({
key: keyHex,
proof: proof ? b4a.toString(proof, 'hex') : null,
addedAt: Date.now()
})
}
fs.writeFileSync(PENDING_FILE, JSON.stringify(pending, null, 2), { mode: 0o600 })
}
/**
* Load pending writers from file
* Returns array of {key, proof, addedAt} objects
*/
function loadPendingWriters() {
if (!fs.existsSync(PENDING_FILE)) {
return []
}
const data = fs.readFileSync(PENDING_FILE, 'utf8')
return JSON.parse(data)
}
/**
* Remove a pending writer from the file
*/
function removePendingWriter(writerKey) {
const pending = loadPendingWriters()
const keyHex = b4a.toString(writerKey, 'hex')
const filtered = pending.filter(p => p.key !== keyHex)
if (filtered.length === 0) {
// Remove file if empty
if (fs.existsSync(PENDING_FILE)) {
fs.unlinkSync(PENDING_FILE)
}
} else {
fs.writeFileSync(PENDING_FILE, JSON.stringify(filtered, null, 2), { mode: 0o600 })
}
}
/**
* Get list of pending writers (connected but not approved)
* Returns array of {key, proof} objects with Buffers
*/
function getPendingWriters() {
const pendingData = loadPendingWriters()
// Convert hex strings back to Buffers
return pendingData.map(p => ({
key: b4a.from(p.key, 'hex'),
proof: p.proof ? b4a.from(p.proof, 'hex') : null,
addedAt: p.addedAt
}))
}
/**
* Approve a pending writer (add to Autobase)
* @param {Buffer} writerKey - The writer key to approve
* @param {string} deviceName - Optional name for the device
* @param {boolean} skipVerification - Skip attestation verification (for testing)
*/
async function approveWriter(writerKey, deviceName = 'Unknown Device', skipVerification = false) {
if (!autobase) {
throw new Error('Storage not initialized')
}
// Check if already added
const devices = loadDevices()
const existing = devices.find(d => b4a.equals(d.key, writerKey))
if (existing) {
throw new Error('Writer already approved')
}
console.log('🔍 Approving writer:', b4a.toString(writerKey, 'hex').slice(0, 16) + '...')
// Get attestation proof from pending writers
const pending = getPendingWriters()
const pendingWriter = pending.find(p => b4a.equals(p.key, writerKey))
let proof = null
if (pendingWriter && pendingWriter.proof) {
proof = pendingWriter.proof
// Verify attestation proof
if (!skipVerification) {
console.log('🔐 Verifying attestation proof...')
try {
const verified = IdentityKey.verify(proof)
if (!verified) {
throw new Error('Attestation verification failed')
}
console.log('✅ Attestation verified')
} catch (err) {
console.log('❌ Attestation verification failed:', err.message)
console.log('')
console.log('⚠️ WARNING: Proof verification failed!')
console.log(' This device may not be authorized.')
console.log('')
console.log('To approve anyway (UNSAFE), set skipVerification=true')
console.log('')
throw new Error('Attestation verification failed')
}
} else {
console.log('⚠️ Skipping attestation verification (unsafe mode)')
}
} else {
console.log('⚠️ No attestation proof found')
console.log(' Continuing without verification (Phase 5.4 limitation)')
}
// Append addWriter operation - Autobase processes this internally
const op = {
addWriter: b4a.toString(writerKey, 'hex'),
timestamp: Date.now()
}
await autobase.append(JSON.stringify(op))
// Wait for operation to be processed
await autobase.update()
console.log('✅ Writer added to Autobase')
// Wait for view to update
await new Promise(resolve => setTimeout(resolve, 1000))
// Save to device list
devices.push({
name: deviceName,
key: writerKey,
proof: proof,
addedAt: Date.now(),
isLocal: false
})
saveDevices(devices)
// Remove from pending list
removePendingWriter(writerKey)
console.log('✅ Device approved:', deviceName)
return writerKey
}
/**
* Enable replication for device sync mode
* Call this to start replicating on new connections
*/
function enableReplication() {
if (!swarm || !store) {
throw new Error('Storage not initialized')
}
// Add replication handler for new connections
swarm.on('connection', (conn) => {
store.replicate(conn)
})
// Replicate on existing connections
for (const conn of connections) {
store.replicate(conn)
}
}
/**
* Close storage and network
*/
async function closeStorage() {
if (swarm) {
await swarm.destroy()
swarm = null
}
if (txHistoryDb) {
await txHistoryDb.close()
txHistoryDb = null
}
if (autobase) {
await autobase.close()
autobase = null
}
if (store) {
await store.close()
store = null
}
connections.clear()
}
module.exports = {
initializeStorage,
storeTx,
getTxHistory,
getTx,
updateTxStatus,
// WDK wallet functions (Phase 6.2)
initializeWallets,
storeAddress,
getAddresses,
getNextAddressIndex,
getCurrentAddress,
updateAddressLabel,
setCurrentAddress,
archiveAddress,
// UTXO functions (Phase 6.3)
storeUTXO,
getUTXOs,
updateUTXO,
deleteUTXO,
// Transaction storage (Phase 6.6)
storeTransaction,
getTransactions,
getTransaction,
deleteTransaction,
// Transaction status updates (Phase 6.7)
updateTransactionStatus,
// Transaction sync
syncTransactionsFromWDK,
// Device management
bootstrapDevice,
createInvite,
joinWallet,
addDevice,
getCurrentDevice,
listDevices,
removeDevice,
deduplicateDevices,
getConnectionStatus,
waitForNewDevice,
getPendingWriters,
approveWriter,
enableReplication,
// Contact management
addContact,
getContacts,
findContact,
getContactById,
updateContact,
deleteContact,
findContactByAddress,
// NEW: Multi-address contact support
addAddressToContact,
removeAddressFromContact,
closeStorage
}