| … | … | @@ -0,0 +1,91 @@ |
| 1 | // lib/address-manager.cjs - Address Generation & Management (Phase 6.2) | |
| 2 | // CRITICAL: Load bare-node-runtime FIRST before any other requires | |
| 3 | require('bare-node-runtime/global') | |
| 4 | ||
| 5 | const { | |
| 6 | initializeWallets, | |
| 7 | storeAddress, | |
| 8 | getAddresses, | |
| 9 | getNextAddressIndex, | |
| 10 | getCurrentAddress: getLatestAddress | |
| 11 | } = require('./wallet-storage.cjs') | |
| 12 | ||
| 13 | /** | |
| 14 | * Generate new receiving address and store in Autobase | |
| 15 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 16 | * @param {Object} config - { testnet: boolean } | |
| 17 | * @returns {Object} - { address, path, index, chain } | |
| 18 | */ | |
| 19 | async function generateNewAddress(chain, config = { testnet: true }) { | |
| 20 | // Normalize chain name | |
| 21 | const chainNormalized = chain.toLowerCase() | |
| 22 | if (chainNormalized !== 'bitcoin' && chainNormalized !== 'ethereum') { | |
| 23 | throw new Error(`Unsupported chain: ${chain}. Use 'bitcoin' or 'ethereum'`) | |
| 24 | } | |
| 25 | ||
| 26 | // Initialize WDK | |
| 27 | const { wdk } = await initializeWallets(config) | |
| 28 | ||
| 29 | // Get next address index | |
| 30 | const index = await getNextAddressIndex(chainNormalized) | |
| 31 | ||
| 32 | // Determine derivation path fragment for WDK getAccountByPath | |
| 33 | // Bitcoin (BIP-84): m/84'/coinType'/0'/0/{index} | |
| 34 | // Ethereum (BIP-44): m/44'/60'/0'/0/{index} | |
| 35 | // WDK expects path fragment like "0'/0/{index}" | |
| 36 | const pathFragment = `0'/0/${index}` | |
| 37 | ||
| 38 | // Get account using custom derivation path | |
| 39 | const account = await wdk.getAccountByPath(chainNormalized, pathFragment) | |
| 40 | const address = await account.getAddress() | |
| 41 | ||
| 42 | // Build full path for display | |
| 43 | const coinType = chainNormalized === 'bitcoin' | |
| 44 | ? (config.testnet ? "1'" : "0'") | |
| 45 | : "60'" | |
| 46 | ||
| 47 | const path = chainNormalized === 'bitcoin' | |
| 48 | ? `m/84'/${coinType}/${pathFragment}` | |
| 49 | : `m/44'/${coinType}/${pathFragment}` | |
| 50 | ||
| 51 | // Store address in Autobase (syncs to all devices) | |
| 52 | await storeAddress({ | |
| 53 | chain: chainNormalized, | |
| 54 | network: config.testnet ? 'testnet' : 'mainnet', | |
| 55 | address, | |
| 56 | path, | |
| 57 | index | |
| 58 | }) | |
| 59 | ||
| 60 | return { address, path, index, chain: chainNormalized } | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * List all addresses for a chain | |
| 65 | * @param {string} chain - 'bitcoin', 'ethereum', or 'all' | |
| 66 | * @returns {Array} - Array of address objects sorted by index | |
| 67 | */ | |
| 68 | async function listAddresses(chain = 'all') { | |
| 69 | const chainNormalized = chain.toLowerCase() | |
| 70 | return await getAddresses(chainNormalized) | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * Get current (latest) address for a chain | |
| 75 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 76 | * @returns {Object|null} - Latest address or null if none exist | |
| 77 | */ | |
| 78 | async function getCurrentAddress(chain) { | |
| 79 | const chainNormalized = chain.toLowerCase() | |
| 80 | if (chainNormalized !== 'bitcoin' && chainNormalized !== 'ethereum') { | |
| 81 | throw new Error(`Unsupported chain: ${chain}. Use 'bitcoin' or 'ethereum'`) | |
| 82 | } | |
| 83 | ||
| 84 | return await getLatestAddress(chainNormalized) | |
| 85 | } | |
| 86 | ||
| 87 | module.exports = { | |
| 88 | generateNewAddress, | |
| 89 | listAddresses, | |
| 90 | getCurrentAddress | |
| 91 | } |
| … | … | @@ -0,0 +1,269 @@ |
| 1 | /** | |
| 2 | * FILE: lib/address-validator.cjs | |
| 3 | * STATUS: ACTIVE | |
| 4 | * RUNTIME: Bare | |
| 5 | * MODULE: CommonJS | |
| 6 | * | |
| 7 | * PURPOSE: | |
| 8 | * Address validation with basic format checking | |
| 9 | * | |
| 10 | * USED BY: | |
| 11 | * - wallet-server.cjs (transaction and contact validation) | |
| 12 | * | |
| 13 | * KEY EXPORTS: | |
| 14 | * - validateAddress(address, chain, network) - Generic validator | |
| 15 | * - validateBitcoinAddress(address, network) - Bitcoin with Base58Check/Bech32 | |
| 16 | * - validateEthereumAddress(address) - Ethereum format check only | |
| 17 | * | |
| 18 | * VALIDATION: | |
| 19 | * - Bitcoin: Full checksum validation (Base58Check and Bech32/Bech32m) | |
| 20 | * - Ethereum: Format check only (EIP-55 checksum disabled - Pear module resolution issues) | |
| 21 | * - Returns: { valid: boolean, reason?: string, warning?: string } | |
| 22 | * | |
| 23 | * NOTE: EIP-55 checksum validation disabled due to Pear module resolution bug with @noble/hashes | |
| 24 | */ | |
| 25 | ||
| 26 | const crypto = require('bare-crypto') | |
| 27 | ||
| 28 | /** | |
| 29 | * Bitcoin Base58 alphabet (no 0, O, I, l to avoid confusion) | |
| 30 | */ | |
| 31 | const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' | |
| 32 | ||
| 33 | /** | |
| 34 | * Decode Base58 string to buffer | |
| 35 | * @param {string} str - Base58 encoded string | |
| 36 | * @returns {Buffer} Decoded bytes | |
| 37 | */ | |
| 38 | function base58Decode(str) { | |
| 39 | const bytes = [] | |
| 40 | for (let i = 0; i < str.length; i++) { | |
| 41 | const char = str[i] | |
| 42 | let value = BASE58_ALPHABET.indexOf(char) | |
| 43 | if (value === -1) { | |
| 44 | throw new Error('Invalid Base58 character') | |
| 45 | } | |
| 46 | ||
| 47 | for (let j = 0; j < bytes.length; j++) { | |
| 48 | value += bytes[j] * 58 | |
| 49 | bytes[j] = value & 0xff | |
| 50 | value >>= 8 | |
| 51 | } | |
| 52 | ||
| 53 | while (value > 0) { | |
| 54 | bytes.push(value & 0xff) | |
| 55 | value >>= 8 | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | // Add leading zeros | |
| 60 | for (let i = 0; i < str.length && str[i] === '1'; i++) { | |
| 61 | bytes.push(0) | |
| 62 | } | |
| 63 | ||
| 64 | return Buffer.from(bytes.reverse()) | |
| 65 | } | |
| 66 | ||
| 67 | /** | |
| 68 | * Verify Base58Check checksum | |
| 69 | * @param {Buffer} payload - Decoded Base58 bytes | |
| 70 | * @returns {boolean} True if checksum valid | |
| 71 | */ | |
| 72 | function verifyBase58Check(payload) { | |
| 73 | if (payload.length < 5) return false | |
| 74 | ||
| 75 | const data = payload.slice(0, -4) | |
| 76 | const checksum = payload.slice(-4) | |
| 77 | ||
| 78 | // Double SHA-256 hash | |
| 79 | const hash1 = crypto.createHash('sha256').update(data).digest() | |
| 80 | const hash2 = crypto.createHash('sha256').update(hash1).digest() | |
| 81 | ||
| 82 | // Compare first 4 bytes | |
| 83 | return checksum.equals(hash2.slice(0, 4)) | |
| 84 | } | |
| 85 | ||
| 86 | /** | |
| 87 | * Bech32 character set | |
| 88 | */ | |
| 89 | const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' | |
| 90 | ||
| 91 | /** | |
| 92 | * Bech32 polymod for checksum | |
| 93 | */ | |
| 94 | function bech32Polymod(values) { | |
| 95 | const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] | |
| 96 | let chk = 1 | |
| 97 | ||
| 98 | for (const value of values) { | |
| 99 | const top = chk >> 25 | |
| 100 | chk = ((chk & 0x1ffffff) << 5) ^ value | |
| 101 | ||
| 102 | for (let i = 0; i < 5; i++) { | |
| 103 | if ((top >> i) & 1) { | |
| 104 | chk ^= GEN[i] | |
| 105 | } | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 109 | return chk | |
| 110 | } | |
| 111 | ||
| 112 | /** | |
| 113 | * Expand HRP (Human Readable Part) for Bech32 | |
| 114 | */ | |
| 115 | function bech32HrpExpand(hrp) { | |
| 116 | const result = [] | |
| 117 | for (let i = 0; i < hrp.length; i++) { | |
| 118 | result.push(hrp.charCodeAt(i) >> 5) | |
| 119 | } | |
| 120 | result.push(0) | |
| 121 | for (let i = 0; i < hrp.length; i++) { | |
| 122 | result.push(hrp.charCodeAt(i) & 31) | |
| 123 | } | |
| 124 | return result | |
| 125 | } | |
| 126 | ||
| 127 | /** | |
| 128 | * Verify Bech32 checksum (SegWit v0) | |
| 129 | */ | |
| 130 | function verifyBech32(hrp, data) { | |
| 131 | return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 1 | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * Verify Bech32m checksum (SegWit v1+) | |
| 136 | */ | |
| 137 | function verifyBech32m(hrp, data) { | |
| 138 | return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 0x2bc830a3 | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * Decode Bech32 address | |
| 143 | */ | |
| 144 | function bech32Decode(address) { | |
| 145 | address = address.toLowerCase() | |
| 146 | ||
| 147 | const pos = address.lastIndexOf('1') | |
| 148 | if (pos < 1 || pos + 7 > address.length || address.length > 90) { | |
| 149 | throw new Error('Invalid Bech32 address') | |
| 150 | } | |
| 151 | ||
| 152 | const hrp = address.slice(0, pos) | |
| 153 | const data = [] | |
| 154 | ||
| 155 | for (let i = pos + 1; i < address.length; i++) { | |
| 156 | const d = BECH32_CHARSET.indexOf(address[i]) | |
| 157 | if (d === -1) { | |
| 158 | throw new Error('Invalid Bech32 character') | |
| 159 | } | |
| 160 | data.push(d) | |
| 161 | } | |
| 162 | ||
| 163 | return { hrp, data } | |
| 164 | } | |
| 165 | ||
| 166 | /** | |
| 167 | * Validate Bitcoin address with checksum | |
| 168 | * @param {string} address - Bitcoin address | |
| 169 | * @param {string} network - 'testnet' or 'mainnet' | |
| 170 | * @returns {Object} { valid: boolean, reason?: string } | |
| 171 | */ | |
| 172 | function validateBitcoinAddress(address, network = 'testnet') { | |
| 173 | // P2PKH (1... or m...) and P2SH (3... or 2...) | |
| 174 | if (address.match(/^[123mn][a-zA-HJ-NP-Z0-9]{25,34}$/)) { | |
| 175 | try { | |
| 176 | const decoded = base58Decode(address) | |
| 177 | ||
| 178 | if (!verifyBase58Check(decoded)) { | |
| 179 | return { valid: false, reason: 'Invalid Base58Check checksum' } | |
| 180 | } | |
| 181 | ||
| 182 | const version = decoded[0] | |
| 183 | ||
| 184 | // Check version byte matches network | |
| 185 | const validVersions = network === 'testnet' ? [0x6f, 0xc4] : [0x00, 0x05] | |
| 186 | if (!validVersions.includes(version)) { | |
| 187 | return { valid: false, reason: `Invalid network for address format (expected ${network})` } | |
| 188 | } | |
| 189 | ||
| 190 | return { valid: true } | |
| 191 | } catch (err) { | |
| 192 | return { valid: false, reason: `Base58 decode error: ${err.message}` } | |
| 193 | } | |
| 194 | } | |
| 195 | ||
| 196 | // Bech32 (bc1... or tb1...) | |
| 197 | if (address.match(/^(bc1|tb1)[a-z0-9]{39,87}$/)) { | |
| 198 | try { | |
| 199 | const prefix = network === 'testnet' ? 'tb' : 'bc' | |
| 200 | if (!address.startsWith(prefix)) { | |
| 201 | return { valid: false, reason: `Invalid network prefix (expected ${prefix}1...)` } | |
| 202 | } | |
| 203 | ||
| 204 | const { hrp, data } = bech32Decode(address) | |
| 205 | ||
| 206 | if (hrp !== prefix) { | |
| 207 | return { valid: false, reason: 'Network mismatch in HRP' } | |
| 208 | } | |
| 209 | ||
| 210 | // Try Bech32 (v0) first, then Bech32m (v1+) | |
| 211 | const validBech32 = verifyBech32(hrp, data) | |
| 212 | const validBech32m = verifyBech32m(hrp, data) | |
| 213 | ||
| 214 | if (!validBech32 && !validBech32m) { | |
| 215 | return { valid: false, reason: 'Invalid Bech32/Bech32m checksum' } | |
| 216 | } | |
| 217 | ||
| 218 | return { valid: true } | |
| 219 | } catch (err) { | |
| 220 | return { valid: false, reason: `Bech32 decode error: ${err.message}` } | |
| 221 | } | |
| 222 | } | |
| 223 | ||
| 224 | return { valid: false, reason: 'Unrecognized Bitcoin address format' } | |
| 225 | } | |
| 226 | ||
| 227 | /** | |
| 228 | * Validate Ethereum address (format only, NO EIP-55 checksum) | |
| 229 | * @param {string} address - Ethereum address (0x...) | |
| 230 | * @returns {Object} { valid: boolean, reason?: string, warning?: string } | |
| 231 | */ | |
| 232 | function validateEthereumAddress(address) { | |
| 233 | // Must start with 0x and be 40 hex chars | |
| 234 | if (!address.match(/^0x[a-fA-F0-9]{40}$/)) { | |
| 235 | return { valid: false, reason: 'Invalid Ethereum address format (expected 0x + 40 hex chars)' } | |
| 236 | } | |
| 237 | ||
| 238 | // EIP-55 checksum validation DISABLED | |
| 239 | // bare-crypto doesn't support keccak256, and installing @noble/hashes breaks Pear module resolution | |
| 240 | // Accepting all properly formatted addresses regardless of checksum | |
| 241 | return { valid: true, warning: 'EIP-55 checksum validation disabled (Pear module resolution limitation)' } | |
| 242 | } | |
| 243 | ||
| 244 | /** | |
| 245 | * Generic address validator (dispatches to chain-specific validator) | |
| 246 | * @param {string} address - Blockchain address | |
| 247 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 248 | * @param {string} network - 'testnet' or 'mainnet' | |
| 249 | * @returns {Object} { valid: boolean, reason?: string, warning?: string } | |
| 250 | */ | |
| 251 | function validateAddress(address, chain, network = 'testnet') { | |
| 252 | if (!address || typeof address !== 'string') { | |
| 253 | return { valid: false, reason: 'Address is required' } | |
| 254 | } | |
| 255 | ||
| 256 | if (chain === 'bitcoin') { | |
| 257 | return validateBitcoinAddress(address, network) | |
| 258 | } else if (chain === 'ethereum') { | |
| 259 | return validateEthereumAddress(address) | |
| 260 | } else { | |
| 261 | return { valid: false, reason: `Unsupported chain: ${chain}` } | |
| 262 | } | |
| 263 | } | |
| 264 | ||
| 265 | module.exports = { | |
| 266 | validateAddress, | |
| 267 | validateBitcoinAddress, | |
| 268 | validateEthereumAddress | |
| 269 | } |
| … | … | @@ -0,0 +1,158 @@ |
| 1 | /** | |
| 2 | * FILE: lib/amount-validator.cjs | |
| 3 | * STATUS: ACTIVE | |
| 4 | * RUNTIME: Bare | |
| 5 | * MODULE: CommonJS | |
| 6 | * | |
| 7 | * PURPOSE: | |
| 8 | * Amount validation with range checks and format validation | |
| 9 | * | |
| 10 | * USED BY: | |
| 11 | * - wallet-server.cjs (transaction validation) | |
| 12 | * | |
| 13 | * KEY EXPORTS: | |
| 14 | * - validateAmount(amountStr, chain) - Format and range validation | |
| 15 | * - validateSufficientBalance(amount, balance, fee, chain) - Balance check | |
| 16 | * - formatAmount(amount, chain) - Human-readable formatting | |
| 17 | * | |
| 18 | * VALIDATION: | |
| 19 | * - Checks for positive amounts | |
| 20 | * - Validates BigInt format (no decimals, scientific notation) | |
| 21 | * - Enforces maximum limits (21M BTC, 1B ETH) | |
| 22 | * - Balance sufficiency checks | |
| 23 | */ | |
| 24 | ||
| 25 | /** | |
| 26 | * Maximum supply limits (prevent overflow attacks) | |
| 27 | */ | |
| 28 | const MAX_BTC = 21_000_000n * 100_000_000n // 21 million BTC in satoshi | |
| 29 | const MAX_ETH = 1_000_000_000n * 1_000_000_000_000_000_000n // 1 billion ETH in wei | |
| 30 | ||
| 31 | /** | |
| 32 | * Validate amount string format and range | |
| 33 | * @param {string|number} amountStr - Amount in smallest unit (satoshi/wei) | |
| 34 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 35 | * @returns {Object} { valid: boolean, amount?: bigint, reason?: string } | |
| 36 | */ | |
| 37 | function validateAmount(amountStr, chain) { | |
| 38 | // Check if amount is provided | |
| 39 | if (amountStr === undefined || amountStr === null || amountStr === '') { | |
| 40 | return { valid: false, reason: 'Amount is required' } | |
| 41 | } | |
| 42 | ||
| 43 | // Try to convert to BigInt | |
| 44 | let amount | |
| 45 | try { | |
| 46 | // Remove any spaces or underscores (common in large numbers) | |
| 47 | const cleaned = String(amountStr).replace(/[\s_]/g, '') | |
| 48 | ||
| 49 | // Check for scientific notation (not supported by BigInt) | |
| 50 | if (cleaned.includes('e') || cleaned.includes('E')) { | |
| 51 | return { valid: false, reason: 'Amount cannot be in scientific notation (use smallest unit: satoshi/wei)' } | |
| 52 | } | |
| 53 | ||
| 54 | // Check for decimal point (BigInt doesn't support decimals) | |
| 55 | if (cleaned.includes('.')) { | |
| 56 | return { valid: false, reason: 'Decimal values not supported (use smallest unit: satoshi/wei)' } | |
| 57 | } | |
| 58 | ||
| 59 | // Check for negative sign | |
| 60 | if (cleaned.includes('-')) { | |
| 61 | return { valid: false, reason: 'Amount cannot be negative' } | |
| 62 | } | |
| 63 | ||
| 64 | amount = BigInt(cleaned) | |
| 65 | } catch (err) { | |
| 66 | return { valid: false, reason: `Invalid amount format: ${err.message}` } | |
| 67 | } | |
| 68 | ||
| 69 | // Check if non-negative (allow zero for balance checks) | |
| 70 | if (amount < 0n) { | |
| 71 | return { valid: false, reason: 'Amount cannot be negative' } | |
| 72 | } | |
| 73 | ||
| 74 | // Check maximum limits (prevent overflow) | |
| 75 | if (chain === 'bitcoin' && amount > MAX_BTC) { | |
| 76 | return { valid: false, reason: 'Amount exceeds maximum (21M BTC = 2,100,000,000,000,000 satoshi)' } | |
| 77 | } | |
| 78 | if (chain === 'ethereum' && amount > MAX_ETH) { | |
| 79 | return { valid: false, reason: 'Amount exceeds maximum (1B ETH)' } | |
| 80 | } | |
| 81 | ||
| 82 | return { valid: true, amount } | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * Validate sufficient balance for transaction | |
| 87 | * @param {bigint} amount - Amount to send | |
| 88 | * @param {bigint} balance - Available balance | |
| 89 | * @param {bigint} fee - Transaction fee | |
| 90 | * @param {string} chain - Blockchain identifier | |
| 91 | * @returns {Object} { valid: boolean, reason?: string } | |
| 92 | */ | |
| 93 | function validateSufficientBalance(amount, balance, fee, chain) { | |
| 94 | const total = amount + fee | |
| 95 | ||
| 96 | if (total > balance) { | |
| 97 | return { | |
| 98 | valid: false, | |
| 99 | reason: `Insufficient balance: need ${formatAmount(total, chain)}, have ${formatAmount(balance, chain)}` | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | return { valid: true } | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * Format amount for display (human-readable) | |
| 108 | * @param {bigint|string} amount - Amount in smallest unit | |
| 109 | * @param {string} chain - Blockchain identifier | |
| 110 | * @returns {string} Formatted amount with unit | |
| 111 | */ | |
| 112 | function formatAmount(amount, chain) { | |
| 113 | const amountBigInt = typeof amount === 'string' ? BigInt(amount) : amount | |
| 114 | ||
| 115 | if (chain === 'bitcoin') { | |
| 116 | // 1 BTC = 100,000,000 satoshi (1e8) | |
| 117 | const btc = Number(amountBigInt) / 1e8 | |
| 118 | return `${btc.toFixed(8)} BTC` | |
| 119 | } else if (chain === 'ethereum') { | |
| 120 | // 1 ETH = 1,000,000,000,000,000,000 wei (1e18) | |
| 121 | const eth = Number(amountBigInt) / 1e18 | |
| 122 | return `${eth.toFixed(18)} ETH` | |
| 123 | } | |
| 124 | ||
| 125 | return String(amountBigInt) | |
| 126 | } | |
| 127 | ||
| 128 | /** | |
| 129 | * Parse human-readable amount to smallest unit | |
| 130 | * @param {string} amountStr - Amount in BTC/ETH | |
| 131 | * @param {string} chain - Blockchain identifier | |
| 132 | * @returns {bigint} Amount in smallest unit (satoshi/wei) | |
| 133 | * @throws {Error} If amount is invalid | |
| 134 | */ | |
| 135 | function parseAmount(amountStr, chain) { | |
| 136 | const amount = parseFloat(amountStr) | |
| 137 | ||
| 138 | if (isNaN(amount) || amount < 0) { | |
| 139 | throw new Error('Invalid amount') | |
| 140 | } | |
| 141 | ||
| 142 | if (chain === 'bitcoin') { | |
| 143 | // Convert BTC to satoshi | |
| 144 | return BigInt(Math.floor(amount * 1e8)) | |
| 145 | } else if (chain === 'ethereum') { | |
| 146 | // Convert ETH to wei | |
| 147 | return BigInt(Math.floor(amount * 1e18)) | |
| 148 | } | |
| 149 | ||
| 150 | throw new Error(`Unsupported chain: ${chain}`) | |
| 151 | } | |
| 152 | ||
| 153 | module.exports = { | |
| 154 | validateAmount, | |
| 155 | validateSufficientBalance, | |
| 156 | formatAmount, | |
| 157 | parseAmount | |
| 158 | } |
| … | … | @@ -0,0 +1,144 @@ |
| 1 | /** | |
| 2 | * FILE: lib/auth.cjs | |
| 3 | * STATUS: ACTIVE | |
| 4 | * RUNTIME: Bare | |
| 5 | * MODULE: CommonJS | |
| 6 | * | |
| 7 | * PURPOSE: | |
| 8 | * API authentication system for wallet-server.cjs | |
| 9 | * Generates and verifies Bearer tokens for HTTP API access | |
| 10 | * | |
| 11 | * USED BY: | |
| 12 | * - wallet-server.cjs (authentication middleware) | |
| 13 | * | |
| 14 | * KEY EXPORTS: | |
| 15 | * - generateToken() - Generate secure random token | |
| 16 | * - saveToken() - Save token to file | |
| 17 | * - loadToken() - Load token from file | |
| 18 | * - verifyToken() - Verify Bearer token from request | |
| 19 | * | |
| 20 | * SECURITY: | |
| 21 | * - 32-byte random tokens (256-bit security) | |
| 22 | * - Token file mode 0600 (owner read/write only) | |
| 23 | * - Bearer token authentication (RFC 6750) | |
| 24 | */ | |
| 25 | ||
| 26 | const crypto = require('hypercore-crypto') | |
| 27 | const fs = require('bare-fs') | |
| 28 | const path = require('bare-path') | |
| 29 | const os = require('bare-os') | |
| 30 | ||
| 31 | const STORAGE_DIR = path.join(os.homedir(), '.hyper-wallet') | |
| 32 | const TOKEN_FILE = path.join(STORAGE_DIR, '.api-token') | |
| 33 | ||
| 34 | /** | |
| 35 | * Generate secure random API token | |
| 36 | * Uses hypercore-crypto for cryptographically secure random bytes | |
| 37 | * | |
| 38 | * @returns {string} - Hex-encoded 32-byte token (64 hex characters) | |
| 39 | */ | |
| 40 | function generateToken() { | |
| 41 | const randomBytes = crypto.randomBytes(32) | |
| 42 | return randomBytes.toString('hex') | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * Save API token to file with secure permissions | |
| 47 | * | |
| 48 | * @param {string} token - API token to save | |
| 49 | */ | |
| 50 | function saveToken(token) { | |
| 51 | // Ensure storage directory exists | |
| 52 | if (!fs.existsSync(STORAGE_DIR)) { | |
| 53 | fs.mkdirSync(STORAGE_DIR, { recursive: true, mode: 0o700 }) | |
| 54 | } | |
| 55 | ||
| 56 | // Write token with mode 0600 (owner read/write only) | |
| 57 | fs.writeFileSync(TOKEN_FILE, token, { mode: 0o600 }) | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * Load API token from file | |
| 62 | * | |
| 63 | * @returns {string|null} - Token if exists, null otherwise | |
| 64 | */ | |
| 65 | function loadToken() { | |
| 66 | if (!fs.existsSync(TOKEN_FILE)) { | |
| 67 | return null | |
| 68 | } | |
| 69 | ||
| 70 | try { | |
| 71 | return fs.readFileSync(TOKEN_FILE, 'utf8').trim() | |
| 72 | } catch (err) { | |
| 73 | console.error('Error loading API token:', err.message) | |
| 74 | return null | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | /** | |
| 79 | * Verify Bearer token from HTTP request | |
| 80 | * | |
| 81 | * @param {object} req - HTTP request object | |
| 82 | * @returns {boolean} - True if token is valid, false otherwise | |
| 83 | */ | |
| 84 | function verifyToken(req) { | |
| 85 | // Get Authorization header | |
| 86 | const authHeader = req.headers.authorization | |
| 87 | ||
| 88 | // Check if header exists and has Bearer scheme | |
| 89 | if (!authHeader || !authHeader.startsWith('Bearer ')) { | |
| 90 | return false | |
| 91 | } | |
| 92 | ||
| 93 | // Extract token (remove "Bearer " prefix) | |
| 94 | const token = authHeader.slice(7) | |
| 95 | ||
| 96 | // Load stored token | |
| 97 | const storedToken = loadToken() | |
| 98 | ||
| 99 | // Verify token matches | |
| 100 | if (!storedToken) { | |
| 101 | return false | |
| 102 | } | |
| 103 | ||
| 104 | // Use constant-time comparison to prevent timing attacks | |
| 105 | // Note: crypto.timingSafeEqual doesn't exist in Bare runtime | |
| 106 | // Implement manual constant-time comparison | |
| 107 | if (token.length !== storedToken.length) { | |
| 108 | return false | |
| 109 | } | |
| 110 | ||
| 111 | let diff = 0 | |
| 112 | for (let i = 0; i < token.length; i++) { | |
| 113 | diff |= token.charCodeAt(i) ^ storedToken.charCodeAt(i) | |
| 114 | } | |
| 115 | ||
| 116 | return diff === 0 | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Delete API token (for security - regenerate on demand) | |
| 121 | */ | |
| 122 | function deleteToken() { | |
| 123 | if (fs.existsSync(TOKEN_FILE)) { | |
| 124 | fs.unlinkSync(TOKEN_FILE) | |
| 125 | } | |
| 126 | } | |
| 127 | ||
| 128 | /** | |
| 129 | * Check if API token exists | |
| 130 | * | |
| 131 | * @returns {boolean} - True if token file exists | |
| 132 | */ | |
| 133 | function tokenExists() { | |
| 134 | return fs.existsSync(TOKEN_FILE) | |
| 135 | } | |
| 136 | ||
| 137 | module.exports = { | |
| 138 | generateToken, | |
| 139 | saveToken, | |
| 140 | loadToken, | |
| 141 | verifyToken, | |
| 142 | deleteToken, | |
| 143 | tokenExists | |
| 144 | } |
| … | … | @@ -0,0 +1,166 @@ |
| 1 | /** | |
| 2 | * Blockchain API - Phase 6.3 | |
| 3 | * | |
| 4 | * Wrapper around WDK blockchain queries. | |
| 5 | * Provides unified interface for querying UTXOs, transactions, and fees. | |
| 6 | */ | |
| 7 | ||
| 8 | /** | |
| 9 | * Query UTXOs for a Bitcoin address | |
| 10 | * @param {Object} btcWallet - WDK Bitcoin wallet instance | |
| 11 | * @param {string} address - Bitcoin address (tb1q... for testnet) | |
| 12 | * @returns {Array} Array of UTXO objects | |
| 13 | */ | |
| 14 | async function getUTXOsForAddress(btcWallet, address) { | |
| 15 | try { | |
| 16 | const utxos = await btcWallet.getUTXOs(address) | |
| 17 | ||
| 18 | // Normalize UTXO format | |
| 19 | return utxos.map(utxo => ({ | |
| 20 | txid: utxo.txid, | |
| 21 | vout: utxo.vout, | |
| 22 | address: address, | |
| 23 | value: utxo.value, // satoshis | |
| 24 | scriptPubKey: utxo.scriptPubKey || null, | |
| 25 | confirmations: utxo.confirmations || 0, | |
| 26 | blockHeight: utxo.height || null | |
| 27 | })) | |
| 28 | } catch (err) { | |
| 29 | console.error(`Error fetching UTXOs for ${address}:`, err.message) | |
| 30 | return [] | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * Query UTXOs for multiple addresses | |
| 36 | * @param {Object} btcWallet - WDK Bitcoin wallet instance | |
| 37 | * @param {Array} addresses - Array of address strings | |
| 38 | * @returns {Array} Combined array of UTXOs from all addresses | |
| 39 | */ | |
| 40 | async function getUTXOsForAddresses(btcWallet, addresses) { | |
| 41 | const allUTXOs = [] | |
| 42 | ||
| 43 | for (const address of addresses) { | |
| 44 | const utxos = await getUTXOsForAddress(btcWallet, address) | |
| 45 | allUTXOs.push(...utxos) | |
| 46 | } | |
| 47 | ||
| 48 | return allUTXOs | |
| 49 | } | |
| 50 | ||
| 51 | /** | |
| 52 | * Get current fee rates from network | |
| 53 | * @param {Object} btcWallet - WDK Bitcoin wallet instance | |
| 54 | * @returns {Object} Fee rates in sat/vbyte | |
| 55 | */ | |
| 56 | async function getFeeRates(btcWallet) { | |
| 57 | try { | |
| 58 | const rates = await btcWallet.getFeeRates() | |
| 59 | ||
| 60 | return { | |
| 61 | low: rates.low || 1, | |
| 62 | medium: rates.medium || 5, | |
| 63 | high: rates.high || 10 | |
| 64 | } | |
| 65 | } catch (err) { | |
| 66 | console.error('Error fetching fee rates:', err.message) | |
| 67 | ||
| 68 | // Return default rates on error | |
| 69 | return { | |
| 70 | low: 1, | |
| 71 | medium: 5, | |
| 72 | high: 10 | |
| 73 | } | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Get transaction details from blockchain | |
| 79 | * @param {Object} btcWallet - WDK Bitcoin wallet instance | |
| 80 | * @param {string} txid - Transaction ID | |
| 81 | * @returns {Object|null} Transaction details or null if not found | |
| 82 | */ | |
| 83 | async function getTransaction(btcWallet, txid) { | |
| 84 | try { | |
| 85 | const tx = await btcWallet.getTransaction(txid) | |
| 86 | ||
| 87 | return { | |
| 88 | txid: tx.txid, | |
| 89 | confirmations: tx.confirmations || 0, | |
| 90 | blockHeight: tx.blockHeight || null, | |
| 91 | blockHash: tx.blockHash || null, | |
| 92 | timestamp: tx.timestamp || null, | |
| 93 | inputs: tx.inputs || [], | |
| 94 | outputs: tx.outputs || [], | |
| 95 | fee: tx.fee || null | |
| 96 | } | |
| 97 | } catch (err) { | |
| 98 | console.error(`Error fetching transaction ${txid}:`, err.message) | |
| 99 | return null | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Get balance for a single address (direct query) | |
| 105 | * @param {Object} btcWallet - WDK Bitcoin wallet instance | |
| 106 | * @param {string} address - Bitcoin address | |
| 107 | * @returns {number} Balance in satoshis | |
| 108 | */ | |
| 109 | async function getAddressBalance(btcWallet, address) { | |
| 110 | try { | |
| 111 | const balance = await btcWallet.getBalance(address) | |
| 112 | return balance || 0 | |
| 113 | } catch (err) { | |
| 114 | console.error(`Error fetching balance for ${address}:`, err.message) | |
| 115 | return 0 | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Broadcast transaction to network | |
| 121 | * @param {Object} btcWallet - WDK Bitcoin wallet instance | |
| 122 | * @param {string} txHex - Signed transaction hex | |
| 123 | * @returns {Object} Broadcast result | |
| 124 | */ | |
| 125 | async function broadcastTransaction(btcWallet, txHex) { | |
| 126 | try { | |
| 127 | const result = await btcWallet.broadcast(txHex) | |
| 128 | ||
| 129 | return { | |
| 130 | success: true, | |
| 131 | txid: result.txid, | |
| 132 | message: 'Transaction broadcasted successfully' | |
| 133 | } | |
| 134 | } catch (err) { | |
| 135 | return { | |
| 136 | success: false, | |
| 137 | error: err.message, | |
| 138 | message: 'Failed to broadcast transaction' | |
| 139 | } | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * Check if blockchain API is reachable | |
| 145 | * @param {Object} btcWallet - WDK Bitcoin wallet instance | |
| 146 | * @returns {boolean} True if API is reachable | |
| 147 | */ | |
| 148 | async function checkConnection(btcWallet) { | |
| 149 | try { | |
| 150 | // Try to get fee rates as a connectivity check | |
| 151 | await btcWallet.getFeeRates() | |
| 152 | return true | |
| 153 | } catch (err) { | |
| 154 | return false | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | module.exports = { | |
| 159 | getUTXOsForAddress, | |
| 160 | getUTXOsForAddresses, | |
| 161 | getFeeRates, | |
| 162 | getTransaction, | |
| 163 | getAddressBalance, | |
| 164 | broadcastTransaction, | |
| 165 | checkConnection | |
| 166 | } |
| … | … | @@ -0,0 +1,502 @@ |
| 1 | // lib/blockchain-providers.cjs - Blockchain API providers | |
| 2 | require('bare-node-runtime/global') | |
| 3 | ||
| 4 | /** | |
| 5 | * Ethereum providers configuration | |
| 6 | * Supports multiple providers with automatic fallback | |
| 7 | */ | |
| 8 | const ETHEREUM_PROVIDERS = { | |
| 9 | blockscout: { | |
| 10 | name: 'Blockscout', | |
| 11 | needsApiKey: false, | |
| 12 | testnetUrl: 'https://eth-sepolia.blockscout.com/api', | |
| 13 | mainnetUrl: 'https://eth.blockscout.com/api', | |
| 14 | testnet4Url: null, // Blockscout doesn't have testnet4 | |
| 15 | ||
| 16 | getTxListUrl (address, network, apiKey) { | |
| 17 | const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl | |
| 18 | return `${baseUrl}?module=account&action=txlist&address=${address}&startblock=0&endblock=latest&page=1&offset=100&sort=desc` | |
| 19 | }, | |
| 20 | ||
| 21 | // NEW: Get ERC-20 token transaction list | |
| 22 | getTokenTxListUrl (address, network, contractAddress = null) { | |
| 23 | const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl | |
| 24 | let url = `${baseUrl}?module=account&action=tokentx&address=${address}&startblock=0&endblock=latest&page=1&offset=100&sort=desc` | |
| 25 | if (contractAddress) { | |
| 26 | url += `&contractaddress=${contractAddress}` | |
| 27 | } | |
| 28 | return url | |
| 29 | }, | |
| 30 | ||
| 31 | parseResponse (data) { | |
| 32 | if (data.status === '1' && Array.isArray(data.result)) { | |
| 33 | return data.result.map(tx => ({ | |
| 34 | hash: tx.hash, | |
| 35 | from: tx.from, | |
| 36 | to: tx.to, | |
| 37 | value: tx.value, | |
| 38 | fee: tx.gasUsed && tx.gasPrice ? (BigInt(tx.gasUsed) * BigInt(tx.gasPrice)).toString() : '0', | |
| 39 | blockNumber: parseInt(tx.blockNumber) || 0, | |
| 40 | timestamp: parseInt(tx.timeStamp) || 0, | |
| 41 | status: tx.txreceipt_status === '1' ? 'confirmed' : 'failed' | |
| 42 | })) | |
| 43 | } | |
| 44 | throw new Error('Invalid response format') | |
| 45 | }, | |
| 46 | ||
| 47 | // NEW: Parse token transaction response | |
| 48 | parseTokenResponse (data) { | |
| 49 | if (data.status === '1' && Array.isArray(data.result)) { | |
| 50 | return data.result.map(tx => ({ | |
| 51 | hash: tx.hash, | |
| 52 | from: tx.from, | |
| 53 | to: tx.to, | |
| 54 | value: tx.value, // Token amount in smallest unit | |
| 55 | tokenSymbol: tx.tokenSymbol, | |
| 56 | tokenName: tx.tokenName, | |
| 57 | tokenDecimal: parseInt(tx.tokenDecimal) || 0, | |
| 58 | contractAddress: tx.contractAddress, | |
| 59 | fee: tx.gasUsed && tx.gasPrice ? (BigInt(tx.gasUsed) * BigInt(tx.gasPrice)).toString() : '0', | |
| 60 | blockNumber: parseInt(tx.blockNumber) || 0, | |
| 61 | timestamp: parseInt(tx.timeStamp) || 0, | |
| 62 | // Token transactions: Blockscout doesn't return txreceipt_status for tokentx API | |
| 63 | // If transaction is in the response, it was mined. Check for explicit failure indicators. | |
| 64 | status: tx.txreceipt_status === '0' || tx.isError === '1' ? 'failed' : 'confirmed' | |
| 65 | })) | |
| 66 | } | |
| 67 | throw new Error('Invalid response format') | |
| 68 | } | |
| 69 | }, | |
| 70 | ||
| 71 | etherscan: { | |
| 72 | name: 'Etherscan', | |
| 73 | needsApiKey: true, | |
| 74 | testnetUrl: 'https://api.etherscan.io/v2/api', | |
| 75 | mainnetUrl: 'https://api.etherscan.io/api', | |
| 76 | testnet4Url: null, | |
| 77 | ||
| 78 | getTxListUrl (address, network, apiKey) { | |
| 79 | if (!apiKey) throw new Error('Etherscan requires API key') | |
| 80 | ||
| 81 | const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl | |
| 82 | const chainId = network === 'testnet' ? '11155111' : '1' // Sepolia : Mainnet | |
| 83 | ||
| 84 | if (network === 'testnet') { | |
| 85 | // Etherscan v2 API for testnet | |
| 86 | return `${baseUrl}?chainid=${chainId}&module=account&action=txlist&address=${address}&startblock=0&endblock=latest&page=1&offset=100&sort=desc&apikey=${apiKey}` | |
| 87 | } else { | |
| 88 | // Regular API for mainnet | |
| 89 | return `${baseUrl}?module=account&action=txlist&address=${address}&startblock=0&endblock=latest&page=1&offset=100&sort=desc&apikey=${apiKey}` | |
| 90 | } | |
| 91 | }, | |
| 92 | ||
| 93 | getTokenTxListUrl (address, network, contractAddress = null) { | |
| 94 | // Note: Etherscan token tx endpoint requires API key (checked by caller) | |
| 95 | const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl | |
| 96 | const chainId = network === 'testnet' ? '11155111' : '1' // Sepolia : Mainnet | |
| 97 | ||
| 98 | let url | |
| 99 | if (network === 'testnet') { | |
| 100 | // Etherscan v2 API for testnet | |
| 101 | url = `${baseUrl}?chainid=${chainId}&module=account&action=tokentx&address=${address}&startblock=0&endblock=latest&page=1&offset=100&sort=desc` | |
| 102 | } else { | |
| 103 | // Regular API for mainnet | |
| 104 | url = `${baseUrl}?module=account&action=tokentx&address=${address}&startblock=0&endblock=latest&page=1&offset=100&sort=desc` | |
| 105 | } | |
| 106 | ||
| 107 | if (contractAddress) { | |
| 108 | url += `&contractaddress=${contractAddress}` | |
| 109 | } | |
| 110 | ||
| 111 | // Note: API key must be added by the caller since it's passed separately | |
| 112 | return url | |
| 113 | }, | |
| 114 | ||
| 115 | parseResponse (data) { | |
| 116 | // Same format as Blockscout | |
| 117 | return ETHEREUM_PROVIDERS.blockscout.parseResponse(data) | |
| 118 | }, | |
| 119 | ||
| 120 | parseTokenResponse (data) { | |
| 121 | // Same format as Blockscout for token transactions | |
| 122 | return ETHEREUM_PROVIDERS.blockscout.parseTokenResponse(data) | |
| 123 | } | |
| 124 | }, | |
| 125 | ||
| 126 | alchemy: { | |
| 127 | name: 'Alchemy', | |
| 128 | needsApiKey: true, | |
| 129 | testnetUrl: 'https://eth-sepolia.g.alchemy.com/v2', | |
| 130 | mainnetUrl: 'https://eth-mainnet.g.alchemy.com/v2', | |
| 131 | testnet4Url: null, | |
| 132 | ||
| 133 | getTxListUrl (address, network, apiKey) { | |
| 134 | if (!apiKey) throw new Error('Alchemy requires API key') | |
| 135 | ||
| 136 | const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl | |
| 137 | // Alchemy uses a different API format | |
| 138 | return `${baseUrl}/${apiKey}` | |
| 139 | }, | |
| 140 | ||
| 141 | // Signal to use fetchTokenTransactions instead of URL-based fetch | |
| 142 | getTokenTxListUrl (address, network, contractAddress = null) { | |
| 143 | return null | |
| 144 | }, | |
| 145 | ||
| 146 | async fetchTransactions (address, network, apiKey) { | |
| 147 | // Alchemy uses JSON-RPC, not REST | |
| 148 | const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl | |
| 149 | const url = `${baseUrl}/${apiKey}` | |
| 150 | ||
| 151 | const response = await fetch(url, { | |
| 152 | method: 'POST', | |
| 153 | headers: { 'Content-Type': 'application/json' }, | |
| 154 | body: JSON.stringify({ | |
| 155 | jsonrpc: '2.0', | |
| 156 | method: 'alchemy_getAssetTransfers', | |
| 157 | params: [{ | |
| 158 | fromBlock: '0x0', | |
| 159 | toBlock: 'latest', | |
| 160 | fromAddress: address, | |
| 161 | category: ['external', 'internal', 'erc20'], | |
| 162 | maxCount: '0x64' // 100 in hex | |
| 163 | }], | |
| 164 | id: 1 | |
| 165 | }) | |
| 166 | }) | |
| 167 | ||
| 168 | return response.json() | |
| 169 | }, | |
| 170 | ||
| 171 | async fetchTokenTransactions (address, network, apiKey, contractAddress = null) { | |
| 172 | const baseUrl = network === 'testnet' ? this.testnetUrl : this.mainnetUrl | |
| 173 | const url = `${baseUrl}/${apiKey}` | |
| 174 | ||
| 175 | const params = { | |
| 176 | fromBlock: '0x0', | |
| 177 | toBlock: 'latest', | |
| 178 | toAddress: address, | |
| 179 | category: ['erc20'], | |
| 180 | maxCount: '0x64' | |
| 181 | } | |
| 182 | ||
| 183 | if (contractAddress) { | |
| 184 | params.contractAddresses = [contractAddress] | |
| 185 | } | |
| 186 | ||
| 187 | const response = await fetch(url, { | |
| 188 | method: 'POST', | |
| 189 | headers: { 'Content-Type': 'application/json' }, | |
| 190 | body: JSON.stringify({ | |
| 191 | jsonrpc: '2.0', | |
| 192 | method: 'alchemy_getAssetTransfers', | |
| 193 | params: [params], | |
| 194 | id: 1 | |
| 195 | }) | |
| 196 | }) | |
| 197 | ||
| 198 | return response.json() | |
| 199 | }, | |
| 200 | ||
| 201 | parseResponse (data) { | |
| 202 | // Alchemy has different format | |
| 203 | if (data.result && Array.isArray(data.result.transfers)) { | |
| 204 | return data.result.transfers.map(tx => ({ | |
| 205 | hash: tx.hash, | |
| 206 | from: tx.from, | |
| 207 | to: tx.to, | |
| 208 | value: tx.value ? (parseFloat(tx.value) * 1e18).toString() : '0', | |
| 209 | fee: '0', // Not provided by Alchemy | |
| 210 | blockNumber: tx.blockNum ? parseInt(tx.blockNum, 16) : 0, | |
| 211 | timestamp: 0, // Not provided | |
| 212 | status: 'confirmed' | |
| 213 | })) | |
| 214 | } | |
| 215 | throw new Error('Invalid Alchemy response format') | |
| 216 | }, | |
| 217 | ||
| 218 | parseTokenResponse (data) { | |
| 219 | if (data.result && Array.isArray(data.result.transfers)) { | |
| 220 | return data.result.transfers.map(tx => ({ | |
| 221 | hash: tx.hash, | |
| 222 | from: tx.from, | |
| 223 | to: tx.to, | |
| 224 | value: tx.rawContract?.value || '0', | |
| 225 | tokenSymbol: tx.asset || 'TOKEN', | |
| 226 | contractAddress: tx.rawContract?.address, | |
| 227 | fee: '0', | |
| 228 | blockNumber: tx.blockNum ? parseInt(tx.blockNum, 16) : 0, | |
| 229 | timestamp: 0, | |
| 230 | status: 'confirmed' | |
| 231 | })) | |
| 232 | } | |
| 233 | throw new Error('Invalid Alchemy response format') | |
| 234 | } | |
| 235 | } | |
| 236 | } | |
| 237 | ||
| 238 | /** | |
| 239 | * Bitcoin providers configuration | |
| 240 | */ | |
| 241 | const BITCOIN_PROVIDERS = { | |
| 242 | electrum: { | |
| 243 | name: 'Electrum', | |
| 244 | needsApiKey: false, | |
| 245 | usesNativeWDK: true // Uses WDK's built-in getTransfers() method | |
| 246 | }, | |
| 247 | ||
| 248 | blockstream: { | |
| 249 | name: 'Blockstream', | |
| 250 | needsApiKey: false, | |
| 251 | testnetUrl: 'https://blockstream.info/testnet/api', | |
| 252 | mainnetUrl: 'https://blockstream.info/api', | |
| 253 | testnet4Url: 'https://blockstream.info/testnet4/api', | |
| 254 | ||
| 255 | getTxListUrl (address, network) { | |
| 256 | let baseUrl | |
| 257 | if (network === 'testnet4') { | |
| 258 | baseUrl = this.testnet4Url | |
| 259 | } else if (network === 'testnet') { | |
| 260 | baseUrl = this.testnetUrl | |
| 261 | } else { | |
| 262 | baseUrl = this.mainnetUrl | |
| 263 | } | |
| 264 | return `${baseUrl}/address/${address}/txs` | |
| 265 | }, | |
| 266 | ||
| 267 | parseResponse (data) { | |
| 268 | if (!Array.isArray(data)) throw new Error('Invalid response') | |
| 269 | ||
| 270 | return data.map(tx => ({ | |
| 271 | txid: tx.txid, | |
| 272 | hash: tx.txid, | |
| 273 | value: tx.vout.reduce((sum, out) => sum + out.value, 0), | |
| 274 | fee: tx.fee || 0, | |
| 275 | blockHeight: tx.status?.block_height || 0, | |
| 276 | timestamp: tx.status?.block_time || 0, | |
| 277 | status: tx.status?.confirmed ? 'confirmed' : 'pending' | |
| 278 | })) | |
| 279 | } | |
| 280 | }, | |
| 281 | ||
| 282 | mempool: { | |
| 283 | name: 'Mempool.space', | |
| 284 | needsApiKey: false, | |
| 285 | testnetUrl: 'https://mempool.space/testnet/api', | |
| 286 | mainnetUrl: 'https://mempool.space/api', | |
| 287 | testnet4Url: 'https://mempool.space/testnet4/api', | |
| 288 | ||
| 289 | getTxListUrl (address, network) { | |
| 290 | let baseUrl | |
| 291 | if (network === 'testnet4') { | |
| 292 | baseUrl = this.testnet4Url | |
| 293 | } else if (network === 'testnet') { | |
| 294 | baseUrl = this.testnetUrl | |
| 295 | } else { | |
| 296 | baseUrl = this.mainnetUrl | |
| 297 | } | |
| 298 | return `${baseUrl}/address/${address}/txs` | |
| 299 | }, | |
| 300 | ||
| 301 | parseResponse (data) { | |
| 302 | // Same format as Blockstream | |
| 303 | return BITCOIN_PROVIDERS.blockstream.parseResponse(data) | |
| 304 | } | |
| 305 | } | |
| 306 | } | |
| 307 | ||
| 308 | /** | |
| 309 | * Fetch transactions using configured provider with automatic fallback | |
| 310 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 311 | * @param {string} address - Blockchain address | |
| 312 | * @param {string} network - 'testnet', 'testnet4', or 'mainnet' | |
| 313 | * @param {Object} config - Configuration object from getConfig() | |
| 314 | * @returns {Promise<Array|Object>} - Array of transactions or { useNativeWDK: true } | |
| 315 | */ | |
| 316 | async function fetchTransactions (chain, address, network, config) { | |
| 317 | const providers = chain === 'ethereum' ? ETHEREUM_PROVIDERS : BITCOIN_PROVIDERS | |
| 318 | ||
| 319 | // Get primary provider from config | |
| 320 | const primaryProviderName = config.blockchain[chain].provider | |
| 321 | const apiKey = config.blockchain[chain].apiKey | |
| 322 | ||
| 323 | // Build provider list (primary first, then fallbacks) | |
| 324 | const providerList = [ | |
| 325 | primaryProviderName, | |
| 326 | ...Object.keys(providers).filter(p => p !== primaryProviderName) | |
| 327 | ] | |
| 328 | ||
| 329 | let lastError = null | |
| 330 | ||
| 331 | for (const providerName of providerList) { | |
| 332 | const provider = providers[providerName] | |
| 333 | if (!provider) { | |
| 334 | console.log(` ⚠️ Unknown provider: ${providerName}`) | |
| 335 | continue | |
| 336 | } | |
| 337 | ||
| 338 | try { | |
| 339 | // Skip if provider needs API key but we don't have one | |
| 340 | if (provider.needsApiKey && !apiKey) { | |
| 341 | console.log(` ⚠️ Skipping ${provider.name} (API key required)`) | |
| 342 | continue | |
| 343 | } | |
| 344 | ||
| 345 | // If provider uses native WDK method, return signal | |
| 346 | if (provider.usesNativeWDK) { | |
| 347 | return { useNativeWDK: true, provider: provider.name } | |
| 348 | } | |
| 349 | ||
| 350 | console.log(` 📡 Fetching from ${provider.name}...`) | |
| 351 | ||
| 352 | // Handle Alchemy's special API | |
| 353 | if (providerName === 'alchemy' && provider.fetchTransactions) { | |
| 354 | const data = await provider.fetchTransactions(address, network, apiKey) | |
| 355 | const transactions = provider.parseResponse(data) | |
| 356 | console.log(` ✅ ${provider.name}: ${transactions.length} transactions`) | |
| 357 | return transactions | |
| 358 | } | |
| 359 | ||
| 360 | // Standard HTTP API | |
| 361 | const url = provider.getTxListUrl(address, network, apiKey) | |
| 362 | const response = await fetch(url) | |
| 363 | ||
| 364 | if (!response.ok) { | |
| 365 | throw new Error(`HTTP ${response.status}: ${response.statusText}`) | |
| 366 | } | |
| 367 | ||
| 368 | const data = await response.json() | |
| 369 | const transactions = provider.parseResponse(data) | |
| 370 | ||
| 371 | console.log(` ✅ ${provider.name}: ${transactions.length} transactions`) | |
| 372 | return transactions | |
| 373 | } catch (err) { | |
| 374 | console.log(` ⚠️ ${provider.name} failed: ${err.message}`) | |
| 375 | lastError = err | |
| 376 | continue | |
| 377 | } | |
| 378 | } | |
| 379 | ||
| 380 | // All providers failed | |
| 381 | throw new Error(`All providers failed. Last error: ${lastError?.message}`) | |
| 382 | } | |
| 383 | ||
| 384 | /** | |
| 385 | * Fetch ERC-20 token transactions for an Ethereum address | |
| 386 | * @param {string} address - Ethereum address | |
| 387 | * @param {string} network - 'testnet' or 'mainnet' | |
| 388 | * @param {Object} config - Configuration object from getConfig() | |
| 389 | * @param {string} contractAddress - Optional: filter by token contract address | |
| 390 | * @returns {Promise<Array>} - Array of token transactions | |
| 391 | */ | |
| 392 | async function fetchTokenTransactions (address, network, config, contractAddress = null) { | |
| 393 | const providers = ETHEREUM_PROVIDERS | |
| 394 | ||
| 395 | // Get primary provider from config | |
| 396 | const primaryProviderName = config.blockchain.ethereum.provider | |
| 397 | const apiKey = config.blockchain.ethereum.apiKey | |
| 398 | ||
| 399 | // Build provider list (primary first, then fallbacks) | |
| 400 | const providerList = [ | |
| 401 | primaryProviderName, | |
| 402 | ...Object.keys(providers).filter(p => p !== primaryProviderName) | |
| 403 | ] | |
| 404 | ||
| 405 | let lastError = null | |
| 406 | ||
| 407 | for (const providerName of providerList) { | |
| 408 | const provider = providers[providerName] | |
| 409 | if (!provider) { | |
| 410 | console.log(` ⚠️ Unknown provider: ${providerName}`) | |
| 411 | continue | |
| 412 | } | |
| 413 | ||
| 414 | // Check if provider supports token transactions (via URL or custom method) | |
| 415 | if (!provider.getTokenTxListUrl && !provider.fetchTokenTransactions) { | |
| 416 | console.log(` ⚠️ ${providerName} doesn't support token transactions`) | |
| 417 | continue | |
| 418 | } | |
| 419 | ||
| 420 | try { | |
| 421 | // Skip if provider needs API key but we don't have one | |
| 422 | if (provider.needsApiKey && !apiKey) { | |
| 423 | console.log(` ⚠️ Skipping ${provider.name} (API key required)`) | |
| 424 | continue | |
| 425 | } | |
| 426 | ||
| 427 | console.log(` 📡 Fetching token txs from ${provider.name}...`) | |
| 428 | ||
| 429 | // Get token transaction list URL (null = use custom fetchTokenTransactions) | |
| 430 | let url = provider.getTokenTxListUrl ? provider.getTokenTxListUrl(address, network, contractAddress) : null | |
| 431 | ||
| 432 | // Handle providers with custom fetchTokenTransactions method (e.g., Alchemy) | |
| 433 | if (url === null && provider.fetchTokenTransactions) { | |
| 434 | const data = await provider.fetchTokenTransactions(address, network, apiKey, contractAddress) | |
| 435 | const transactions = provider.parseTokenResponse(data) | |
| 436 | console.log(` ✅ ${provider.name}: ${transactions.length} token transactions`) | |
| 437 | return transactions | |
| 438 | } | |
| 439 | ||
| 440 | // Standard URL-based fetch | |
| 441 | if (!url) { | |
| 442 | console.log(` ⚠️ ${provider.name} returned no URL for token transactions`) | |
| 443 | continue | |
| 444 | } | |
| 445 | ||
| 446 | // Add API key to URL if needed | |
| 447 | if (provider.needsApiKey && apiKey) { | |
| 448 | url += `&apikey=${apiKey}` | |
| 449 | } | |
| 450 | ||
| 451 | const response = await fetch(url) | |
| 452 | ||
| 453 | if (!response.ok) { | |
| 454 | throw new Error(`HTTP ${response.status}: ${response.statusText}`) | |
| 455 | } | |
| 456 | ||
| 457 | const data = await response.json() | |
| 458 | const transactions = provider.parseTokenResponse(data) | |
| 459 | ||
| 460 | console.log(` ✅ ${provider.name}: ${transactions.length} token transactions`) | |
| 461 | return transactions | |
| 462 | } catch (err) { | |
| 463 | console.log(` ⚠️ ${provider.name} failed: ${err.message}`) | |
| 464 | lastError = err | |
| 465 | continue | |
| 466 | } | |
| 467 | } | |
| 468 | ||
| 469 | // All providers failed - throw error instead of silently returning empty array | |
| 470 | console.error(` ❌ All token tx providers failed: ${lastError?.message}`) | |
| 471 | throw new Error(`Failed to fetch token transactions: ${lastError?.message || 'All providers failed'}`) | |
| 472 | } | |
| 473 | ||
| 474 | /** | |
| 475 | * Get list of available providers for a chain | |
| 476 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 477 | * @returns {Array} - Array of provider names | |
| 478 | */ | |
| 479 | function getAvailableProviders (chain) { | |
| 480 | const providers = chain === 'ethereum' ? ETHEREUM_PROVIDERS : BITCOIN_PROVIDERS | |
| 481 | return Object.keys(providers) | |
| 482 | } | |
| 483 | ||
| 484 | /** | |
| 485 | * Get provider info | |
| 486 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 487 | * @param {string} providerName - Provider name | |
| 488 | * @returns {Object} - Provider configuration | |
| 489 | */ | |
| 490 | function getProviderInfo (chain, providerName) { | |
| 491 | const providers = chain === 'ethereum' ? ETHEREUM_PROVIDERS : BITCOIN_PROVIDERS | |
| 492 | return providers[providerName] || null | |
| 493 | } | |
| 494 | ||
| 495 | module.exports = { | |
| 496 | ETHEREUM_PROVIDERS, | |
| 497 | BITCOIN_PROVIDERS, | |
| 498 | fetchTransactions, | |
| 499 | fetchTokenTransactions, | |
| 500 | getAvailableProviders, | |
| 501 | getProviderInfo | |
| 502 | } |
| … | … | @@ -0,0 +1,96 @@ |
| 1 | // lib/config-loader.cjs - Configuration Loader | |
| 2 | require('bare-node-runtime/global') | |
| 3 | ||
| 4 | const fs = require('bare-fs') | |
| 5 | const path = require('bare-path') | |
| 6 | ||
| 7 | /** | |
| 8 | * Load configuration from file | |
| 9 | * Looks for config/default.json relative to project root | |
| 10 | * @param {string} configPath - Optional path to config file | |
| 11 | * @returns {Object} - Configuration object | |
| 12 | */ | |
| 13 | function loadConfig(configPath) { | |
| 14 | // Default path: config/default.json in project root | |
| 15 | const defaultPath = path.join(__dirname, '..', 'config', 'default.json') | |
| 16 | const filePath = configPath || defaultPath | |
| 17 | ||
| 18 | try { | |
| 19 | if (!fs.existsSync(filePath)) { | |
| 20 | console.warn(`⚠️ Config file not found: ${filePath}`) | |
| 21 | console.warn('Using default configuration') | |
| 22 | return getDefaultConfig() | |
| 23 | } | |
| 24 | ||
| 25 | const content = fs.readFileSync(filePath, 'utf8') | |
| 26 | const config = JSON.parse(content) | |
| 27 | ||
| 28 | // Convert flat config to format expected by wdk-integration.cjs | |
| 29 | return normalizeConfig(config) | |
| 30 | } catch (err) { | |
| 31 | console.error(`Failed to load config from ${filePath}:`, err.message) | |
| 32 | console.warn('Falling back to default configuration') | |
| 33 | return getDefaultConfig() | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | /** | |
| 38 | * Normalize config to expected format | |
| 39 | * Converts config/default.json format to internal format used by wdk-integration | |
| 40 | * @param {Object} config - Raw config from file | |
| 41 | * @returns {Object} - Normalized config | |
| 42 | */ | |
| 43 | function normalizeConfig(config) { | |
| 44 | const normalized = { | |
| 45 | storagePath: config.storagePath || '~/.hyper-wallet/storage', | |
| 46 | network: config.network || 'testnet', | |
| 47 | protocols: config.protocols || ['wdk'], | |
| 48 | ||
| 49 | // Convert network flag to boolean flags expected by wdk-integration.cjs | |
| 50 | testnet: config.network === 'testnet', | |
| 51 | testnet4: config.network === 'testnet4', | |
| 52 | mainnet: config.network === 'mainnet', | |
| 53 | ||
| 54 | // WDK-specific config | |
| 55 | wdk: config.wdk || {} | |
| 56 | } | |
| 57 | ||
| 58 | return normalized | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * Get default configuration (fallback) | |
| 63 | * Used when config file is missing or invalid | |
| 64 | * @returns {Object} - Default configuration | |
| 65 | */ | |
| 66 | function getDefaultConfig() { | |
| 67 | return { | |
| 68 | storagePath: '~/.hyper-wallet/storage', | |
| 69 | network: 'testnet', | |
| 70 | protocols: ['wdk'], | |
| 71 | testnet: true, | |
| 72 | testnet4: false, | |
| 73 | mainnet: false, | |
| 74 | wdk: { | |
| 75 | chains: ['ethereum', 'bitcoin'], | |
| 76 | networks: { | |
| 77 | ethereum: { | |
| 78 | name: 'sepolia', | |
| 79 | provider: 'https://ethereum-sepolia-rpc.publicnode.com', | |
| 80 | chainId: 11155111 | |
| 81 | }, | |
| 82 | bitcoin: { | |
| 83 | network: 'testnet', | |
| 84 | host: 'electrum.blockstream.info', | |
| 85 | port: 60001 | |
| 86 | } | |
| 87 | } | |
| 88 | } | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | module.exports = { | |
| 93 | loadConfig, | |
| 94 | normalizeConfig, | |
| 95 | getDefaultConfig | |
| 96 | } |
| … | … | @@ -0,0 +1,233 @@ |
| 1 | // lib/config.cjs - Configuration management system | |
| 2 | // CRITICAL: Load bare-node-runtime FIRST before any other requires | |
| 3 | require('bare-node-runtime/global') | |
| 4 | ||
| 5 | const fs = require('bare-fs') | |
| 6 | const path = require('bare-path') | |
| 7 | const os = require('bare-os') | |
| 8 | ||
| 9 | const CONFIG_DIR = path.join(os.homedir(), '.hyper-wallet') | |
| 10 | const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json') | |
| 11 | ||
| 12 | // Default configuration | |
| 13 | const DEFAULT_CONFIG = { | |
| 14 | version: 1, | |
| 15 | network: 'testnet', | |
| 16 | ||
| 17 | blockchain: { | |
| 18 | ethereum: { | |
| 19 | provider: 'blockscout', // Default: no API key needed! | |
| 20 | apiKey: null, | |
| 21 | rpcUrl: null | |
| 22 | }, | |
| 23 | bitcoin: { | |
| 24 | provider: 'electrum', | |
| 25 | host: 'electrum.blockstream.info', | |
| 26 | port: 60001 | |
| 27 | } | |
| 28 | }, | |
| 29 | ||
| 30 | ui: { | |
| 31 | theme: 'dark', | |
| 32 | autoLock: 30, // 30 minutes | |
| 33 | currency: 'USD' | |
| 34 | }, | |
| 35 | ||
| 36 | sync: { | |
| 37 | autoSync: true, | |
| 38 | syncInterval: 15 // 15 minutes | |
| 39 | }, | |
| 40 | ||
| 41 | security: { | |
| 42 | requirePasswordOnSend: true, | |
| 43 | sessionTimeout: 30 // 30 minutes | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * Deep merge objects | |
| 49 | * Later objects override earlier ones | |
| 50 | */ | |
| 51 | function mergeDeep (...objects) { | |
| 52 | const result = {} | |
| 53 | ||
| 54 | for (const obj of objects) { | |
| 55 | if (!obj) continue | |
| 56 | ||
| 57 | for (const key in obj) { | |
| 58 | if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) { | |
| 59 | result[key] = mergeDeep(result[key] || {}, obj[key]) | |
| 60 | } else { | |
| 61 | result[key] = obj[key] | |
| 62 | } | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | return result | |
| 67 | } | |
| 68 | ||
| 69 | /** | |
| 70 | * Load user config file | |
| 71 | * Creates default if doesn't exist | |
| 72 | */ | |
| 73 | function loadUserConfig () { | |
| 74 | // Create directory if needed | |
| 75 | if (!fs.existsSync(CONFIG_DIR)) { | |
| 76 | fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }) | |
| 77 | } | |
| 78 | ||
| 79 | // Create default config if doesn't exist | |
| 80 | if (!fs.existsSync(CONFIG_FILE)) { | |
| 81 | saveConfig(DEFAULT_CONFIG) | |
| 82 | return DEFAULT_CONFIG | |
| 83 | } | |
| 84 | ||
| 85 | try { | |
| 86 | const data = fs.readFileSync(CONFIG_FILE, 'utf8') | |
| 87 | const userConfig = JSON.parse(data) | |
| 88 | ||
| 89 | // Merge with defaults (in case new fields added in updates) | |
| 90 | return mergeDeep(DEFAULT_CONFIG, userConfig) | |
| 91 | } catch (err) { | |
| 92 | console.error('⚠️ Error loading config, using defaults:', err.message) | |
| 93 | return DEFAULT_CONFIG | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Load config from environment variables | |
| 99 | * Overrides user config | |
| 100 | */ | |
| 101 | function loadEnvConfig () { | |
| 102 | const envConfig = {} | |
| 103 | ||
| 104 | if (process.env.WALLET_NETWORK) { | |
| 105 | envConfig.network = process.env.WALLET_NETWORK | |
| 106 | } | |
| 107 | ||
| 108 | if (process.env.ETHEREUM_PROVIDER) { | |
| 109 | envConfig.blockchain = envConfig.blockchain || {} | |
| 110 | envConfig.blockchain.ethereum = envConfig.blockchain.ethereum || {} | |
| 111 | envConfig.blockchain.ethereum.provider = process.env.ETHEREUM_PROVIDER | |
| 112 | } | |
| 113 | ||
| 114 | if (process.env.ETHERSCAN_API_KEY) { | |
| 115 | envConfig.blockchain = envConfig.blockchain || {} | |
| 116 | envConfig.blockchain.ethereum = envConfig.blockchain.ethereum || {} | |
| 117 | envConfig.blockchain.ethereum.apiKey = process.env.ETHERSCAN_API_KEY | |
| 118 | } | |
| 119 | ||
| 120 | if (process.env.ETHEREUM_RPC_URL) { | |
| 121 | envConfig.blockchain = envConfig.blockchain || {} | |
| 122 | envConfig.blockchain.ethereum = envConfig.blockchain.ethereum || {} | |
| 123 | envConfig.blockchain.ethereum.rpcUrl = process.env.ETHEREUM_RPC_URL | |
| 124 | } | |
| 125 | ||
| 126 | // Legacy support: Check .etherscan-api-key file | |
| 127 | const legacyKeyFile = path.join(CONFIG_DIR, '.etherscan-api-key') | |
| 128 | if (fs.existsSync(legacyKeyFile)) { | |
| 129 | try { | |
| 130 | const apiKey = fs.readFileSync(legacyKeyFile, 'utf8').trim() | |
| 131 | if (apiKey) { | |
| 132 | envConfig.blockchain = envConfig.blockchain || {} | |
| 133 | envConfig.blockchain.ethereum = envConfig.blockchain.ethereum || {} | |
| 134 | envConfig.blockchain.ethereum.apiKey = apiKey | |
| 135 | // Don't override provider if already set | |
| 136 | if (!envConfig.blockchain.ethereum.provider) { | |
| 137 | envConfig.blockchain.ethereum.provider = 'etherscan' | |
| 138 | } | |
| 139 | } | |
| 140 | } catch (err) { | |
| 141 | // Ignore error reading legacy file | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | return envConfig | |
| 146 | } | |
| 147 | ||
| 148 | /** | |
| 149 | * Get complete configuration | |
| 150 | * Merges defaults + user config + env vars | |
| 151 | */ | |
| 152 | function getConfig () { | |
| 153 | const defaults = DEFAULT_CONFIG | |
| 154 | const user = loadUserConfig() | |
| 155 | const env = loadEnvConfig() | |
| 156 | ||
| 157 | return mergeDeep(defaults, user, env) | |
| 158 | } | |
| 159 | ||
| 160 | /** | |
| 161 | * Save configuration to file | |
| 162 | */ | |
| 163 | function saveConfig (config) { | |
| 164 | // Ensure directory exists | |
| 165 | if (!fs.existsSync(CONFIG_DIR)) { | |
| 166 | fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }) | |
| 167 | } | |
| 168 | ||
| 169 | const data = JSON.stringify(config, null, 2) | |
| 170 | fs.writeFileSync(CONFIG_FILE, data, { mode: 0o600 }) | |
| 171 | } | |
| 172 | ||
| 173 | /** | |
| 174 | * Update specific config value | |
| 175 | * Example: updateConfig('blockchain.ethereum.provider', 'etherscan') | |
| 176 | */ | |
| 177 | function updateConfig (path, value) { | |
| 178 | const config = loadUserConfig() | |
| 179 | ||
| 180 | // Set nested value | |
| 181 | const parts = path.split('.') | |
| 182 | let current = config | |
| 183 | ||
| 184 | for (let i = 0; i < parts.length - 1; i++) { | |
| 185 | if (!current[parts[i]]) current[parts[i]] = {} | |
| 186 | current = current[parts[i]] | |
| 187 | } | |
| 188 | ||
| 189 | current[parts[parts.length - 1]] = value | |
| 190 | ||
| 191 | saveConfig(config) | |
| 192 | return config | |
| 193 | } | |
| 194 | ||
| 195 | /** | |
| 196 | * Migrate legacy .etherscan-api-key file to config.json | |
| 197 | * Called automatically on first config load | |
| 198 | */ | |
| 199 | function migrateLegacyConfig () { | |
| 200 | const legacyKeyFile = path.join(CONFIG_DIR, '.etherscan-api-key') | |
| 201 | ||
| 202 | if (fs.existsSync(legacyKeyFile)) { | |
| 203 | try { | |
| 204 | const apiKey = fs.readFileSync(legacyKeyFile, 'utf8').trim() | |
| 205 | ||
| 206 | if (apiKey) { | |
| 207 | // Update config | |
| 208 | const config = loadUserConfig() | |
| 209 | config.blockchain.ethereum.apiKey = apiKey | |
| 210 | config.blockchain.ethereum.provider = 'etherscan' // They were using Etherscan | |
| 211 | saveConfig(config) | |
| 212 | ||
| 213 | // Rename old file (don't delete, for safety) | |
| 214 | fs.renameSync(legacyKeyFile, legacyKeyFile + '.migrated') | |
| 215 | ||
| 216 | console.log('✅ Migrated Etherscan API key to config.json') | |
| 217 | console.log(` Old file renamed to ${path.basename(legacyKeyFile)}.migrated`) | |
| 218 | } | |
| 219 | } catch (err) { | |
| 220 | console.error('⚠️ Error migrating legacy config:', err.message) | |
| 221 | } | |
| 222 | } | |
| 223 | } | |
| 224 | ||
| 225 | module.exports = { | |
| 226 | getConfig, | |
| 227 | saveConfig, | |
| 228 | updateConfig, | |
| 229 | migrateLegacyConfig, | |
| 230 | DEFAULT_CONFIG, | |
| 231 | CONFIG_FILE, | |
| 232 | CONFIG_DIR | |
| 233 | } |
| … | … | @@ -0,0 +1,37 @@ |
| 1 | // Wrapper to create wallet HTTP server (reusing terminal version logic) | |
| 2 | // This allows desktop and terminal versions to share the same API implementation | |
| 3 | ||
| 4 | const http = require('bare-http1') | |
| 5 | const path = require('bare-path') | |
| 6 | ||
| 7 | // Import the terminal version's wallet server | |
| 8 | // Since lib/ is symlinked to ../hyper-wallet/lib/, we can require the parent's server | |
| 9 | const parentWalletServer = path.join(__dirname, '../../hyper-wallet/wallet-server.cjs') | |
| 10 | ||
| 11 | module.exports = async function createServer(port = 4004) { | |
| 12 | // For now, just start the HTTP server directly | |
| 13 | // TODO: Extract server creation logic from wallet-server.cjs into a reusable function | |
| 14 | ||
| 15 | const server = http.createServer((req, res) => { | |
| 16 | res.setHeader('Access-Control-Allow-Origin', '*') | |
| 17 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') | |
| 18 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') | |
| 19 | ||
| 20 | if (req.method === 'OPTIONS') { | |
| 21 | res.statusCode = 200 | |
| 22 | res.end() | |
| 23 | return | |
| 24 | } | |
| 25 | ||
| 26 | res.statusCode = 200 | |
| 27 | res.setHeader('Content-Type', 'application/json') | |
| 28 | res.end(JSON.stringify({ status: 'ok', message: 'API placeholder - use terminal version server logic' })) | |
| 29 | }) | |
| 30 | ||
| 31 | return new Promise((resolve, reject) => { | |
| 32 | server.listen(port, 'localhost', (err) => { | |
| 33 | if (err) reject(err) | |
| 34 | else resolve(server) | |
| 35 | }) | |
| 36 | }) | |
| 37 | } |
| … | … | @@ -0,0 +1,167 @@ |
| 1 | /** | |
| 2 | * FILE: lib/encryption.cjs | |
| 3 | * STATUS: ACTIVE | |
| 4 | * RUNTIME: Bare | |
| 5 | * MODULE: CommonJS | |
| 6 | * | |
| 7 | * PURPOSE: | |
| 8 | * Password-based encryption for mnemonics at rest | |
| 9 | * Uses PBKDF2 for key derivation and AES-256-GCM for encryption | |
| 10 | * | |
| 11 | * USED BY: | |
| 12 | * - wallet-server.cjs (mnemonic encryption/decryption) | |
| 13 | * - hyper-wallet-advanced-cli.cjs (mnemonic encryption/decryption) | |
| 14 | * | |
| 15 | * KEY EXPORTS: | |
| 16 | * - encrypt(plaintext, password) - Encrypt with password | |
| 17 | * - decrypt(encrypted, password) - Decrypt with password | |
| 18 | * | |
| 19 | * SECURITY: | |
| 20 | * - PBKDF2-SHA256 for key derivation (100k iterations, bare-crypto compatible) | |
| 21 | * - AES-256-GCM authenticated encryption | |
| 22 | * - Random salt and IV per encryption | |
| 23 | * - Auth tag prevents tampering | |
| 24 | * | |
| 25 | * NOTE: Using PBKDF2 instead of scrypt due to bare-crypto limitations | |
| 26 | */ | |
| 27 | ||
| 28 | const crypto = require('bare-crypto') | |
| 29 | const b4a = require('b4a') | |
| 30 | ||
| 31 | // PBKDF2 parameters (bare-crypto compatible) | |
| 32 | const PBKDF2_ITERATIONS = 100000 // NIST recommended minimum | |
| 33 | const PBKDF2_KEYLEN = 32 // 256-bit key | |
| 34 | const PBKDF2_DIGEST = 'sha256' // Hash algorithm | |
| 35 | ||
| 36 | // AES-GCM parameters | |
| 37 | const IV_LENGTH = 12 // 96-bit IV (recommended for GCM) | |
| 38 | const SALT_LENGTH = 32 // 256-bit salt | |
| 39 | const TAG_LENGTH = 16 // 128-bit auth tag | |
| 40 | ||
| 41 | /** | |
| 42 | * Derive encryption key from password using PBKDF2 | |
| 43 | * (bare-crypto compatible - scrypt not available) | |
| 44 | * | |
| 45 | * @param {string} password - User password | |
| 46 | * @param {Buffer} salt - Random salt | |
| 47 | * @returns {Buffer} - 256-bit derived key | |
| 48 | */ | |
| 49 | function deriveKey(password, salt) { | |
| 50 | // Derive key using PBKDF2 | |
| 51 | // Note: This is CPU-intensive (intentional security feature) | |
| 52 | const key = crypto.pbkdf2Sync( | |
| 53 | password, | |
| 54 | salt, | |
| 55 | PBKDF2_ITERATIONS, | |
| 56 | PBKDF2_KEYLEN, | |
| 57 | PBKDF2_DIGEST | |
| 58 | ) | |
| 59 | ||
| 60 | return key | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * Encrypt plaintext with password | |
| 65 | * | |
| 66 | * @param {string} plaintext - Text to encrypt (e.g., mnemonic) | |
| 67 | * @param {string} password - Encryption password | |
| 68 | * @returns {string} - Base64-encoded encrypted data with salt and IV | |
| 69 | * | |
| 70 | * Format: base64(salt + iv + ciphertext + tag) | |
| 71 | */ | |
| 72 | function encrypt(plaintext, password) { | |
| 73 | // Generate random salt and IV | |
| 74 | const salt = crypto.randomBytes(SALT_LENGTH) | |
| 75 | const iv = crypto.randomBytes(IV_LENGTH) | |
| 76 | ||
| 77 | // Derive encryption key | |
| 78 | const key = deriveKey(password, salt) | |
| 79 | ||
| 80 | // Create cipher | |
| 81 | const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) | |
| 82 | ||
| 83 | // Encrypt plaintext | |
| 84 | const plaintextBuffer = b4a.from(plaintext, 'utf8') | |
| 85 | const encrypted = b4a.concat([ | |
| 86 | cipher.update(plaintextBuffer), | |
| 87 | cipher.final() | |
| 88 | ]) | |
| 89 | ||
| 90 | // Get authentication tag | |
| 91 | const tag = cipher.getAuthTag() | |
| 92 | ||
| 93 | // Concatenate: salt + iv + ciphertext + tag | |
| 94 | const result = b4a.concat([salt, iv, encrypted, tag]) | |
| 95 | ||
| 96 | // Return as base64 | |
| 97 | return b4a.toString(result, 'base64') | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * Decrypt encrypted data with password | |
| 102 | * | |
| 103 | * @param {string} encrypted - Base64-encoded encrypted data | |
| 104 | * @param {string} password - Decryption password | |
| 105 | * @returns {string} - Decrypted plaintext | |
| 106 | * @throws {Error} - If password is wrong or data is corrupted | |
| 107 | */ | |
| 108 | function decrypt(encrypted, password) { | |
| 109 | // Decode from base64 | |
| 110 | const data = b4a.from(encrypted, 'base64') | |
| 111 | ||
| 112 | // Extract components | |
| 113 | const salt = data.slice(0, SALT_LENGTH) | |
| 114 | const iv = data.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH) | |
| 115 | const ciphertext = data.slice(SALT_LENGTH + IV_LENGTH, data.length - TAG_LENGTH) | |
| 116 | const tag = data.slice(data.length - TAG_LENGTH) | |
| 117 | ||
| 118 | // Derive decryption key | |
| 119 | const key = deriveKey(password, salt) | |
| 120 | ||
| 121 | // Create decipher | |
| 122 | const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) | |
| 123 | decipher.setAuthTag(tag) | |
| 124 | ||
| 125 | try { | |
| 126 | // Decrypt ciphertext | |
| 127 | const decrypted = b4a.concat([ | |
| 128 | decipher.update(ciphertext), | |
| 129 | decipher.final() | |
| 130 | ]) | |
| 131 | ||
| 132 | return b4a.toString(decrypted, 'utf8') | |
| 133 | } catch (err) { | |
| 134 | // Auth tag verification failed or wrong password | |
| 135 | throw new Error('Decryption failed - wrong password or corrupted data') | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | /** | |
| 140 | * Check if encrypted data format is valid | |
| 141 | * | |
| 142 | * @param {string} encrypted - Base64-encoded encrypted data | |
| 143 | * @returns {boolean} - True if format is valid | |
| 144 | */ | |
| 145 | function isValidFormat(encrypted) { | |
| 146 | try { | |
| 147 | const data = b4a.from(encrypted, 'base64') | |
| 148 | const minLength = SALT_LENGTH + IV_LENGTH + TAG_LENGTH | |
| 149 | return data.length >= minLength | |
| 150 | } catch { | |
| 151 | return false | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | module.exports = { | |
| 156 | encrypt, | |
| 157 | decrypt, | |
| 158 | isValidFormat, | |
| 159 | ||
| 160 | // Export constants for testing | |
| 161 | PBKDF2_ITERATIONS, | |
| 162 | PBKDF2_KEYLEN, | |
| 163 | PBKDF2_DIGEST, | |
| 164 | IV_LENGTH, | |
| 165 | SALT_LENGTH, | |
| 166 | TAG_LENGTH | |
| 167 | } |
| … | … | @@ -0,0 +1,227 @@ |
| 1 | /** | |
| 2 | * FILE: lib/input-sanitizer.cjs | |
| 3 | * STATUS: ACTIVE | |
| 4 | * RUNTIME: Bare | |
| 5 | * MODULE: CommonJS | |
| 6 | * | |
| 7 | * PURPOSE: | |
| 8 | * Input sanitization to prevent injection attacks and validate user inputs | |
| 9 | * | |
| 10 | * USED BY: | |
| 11 | * - wallet-server.cjs (all user input validation) | |
| 12 | * | |
| 13 | * KEY EXPORTS: | |
| 14 | * - sanitizeString(str, maxLength) - Generic string sanitization | |
| 15 | * - sanitizeContactName(name) - Contact name validation | |
| 16 | * - sanitizeDeviceName(name) - Device name validation | |
| 17 | * - sanitizeNotes(notes) - Notes/description validation | |
| 18 | * - validateChain(chain) - Chain identifier validation | |
| 19 | * - validateNetwork(network) - Network identifier validation | |
| 20 | * | |
| 21 | * PROTECTION: | |
| 22 | * - Removes null bytes (string termination attacks) | |
| 23 | * - Enforces length limits (DoS prevention) | |
| 24 | * - Validates character sets (injection prevention) | |
| 25 | * - Trims whitespace (normalization) | |
| 26 | */ | |
| 27 | ||
| 28 | /** | |
| 29 | * Generic string sanitization | |
| 30 | * @param {string} str - Input string | |
| 31 | * @param {number} maxLength - Maximum allowed length | |
| 32 | * @returns {string} Sanitized string | |
| 33 | * @throws {Error} If input is invalid | |
| 34 | */ | |
| 35 | function sanitizeString(str, maxLength = 1000) { | |
| 36 | if (typeof str !== 'string') { | |
| 37 | throw new Error('Input must be a string') | |
| 38 | } | |
| 39 | ||
| 40 | // Trim whitespace | |
| 41 | let sanitized = str.trim() | |
| 42 | ||
| 43 | // Truncate to max length (sanitize, don't throw) | |
| 44 | if (sanitized.length > maxLength) { | |
| 45 | sanitized = sanitized.slice(0, maxLength) | |
| 46 | } | |
| 47 | ||
| 48 | // Remove null bytes (can cause string termination attacks) | |
| 49 | sanitized = sanitized.replace(/\0/g, '') | |
| 50 | ||
| 51 | return sanitized | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Sanitize contact name | |
| 56 | * Allows: alphanumeric, spaces, and common punctuation | |
| 57 | * @param {string} name - Contact name | |
| 58 | * @returns {string} Sanitized name | |
| 59 | * @throws {Error} If name is invalid | |
| 60 | */ | |
| 61 | function sanitizeContactName(name) { | |
| 62 | const sanitized = sanitizeString(name, 100) | |
| 63 | ||
| 64 | if (sanitized.length === 0) { | |
| 65 | throw new Error('Name cannot be empty') | |
| 66 | } | |
| 67 | ||
| 68 | // Only allow safe characters (alphanumeric, spaces, common punctuation) | |
| 69 | if (!sanitized.match(/^[a-zA-Z0-9\s\-_.,'()]+$/)) { | |
| 70 | throw new Error('Name contains invalid characters (allowed: letters, numbers, spaces, -_.\'(),)') | |
| 71 | } | |
| 72 | ||
| 73 | return sanitized | |
| 74 | } | |
| 75 | ||
| 76 | /** | |
| 77 | * Sanitize device name | |
| 78 | * Allows: alphanumeric, spaces, hyphens, underscores | |
| 79 | * @param {string} name - Device name | |
| 80 | * @returns {string} Sanitized name | |
| 81 | * @throws {Error} If name is invalid | |
| 82 | */ | |
| 83 | function sanitizeDeviceName(name) { | |
| 84 | const sanitized = sanitizeString(name, 50) | |
| 85 | ||
| 86 | if (sanitized.length === 0) { | |
| 87 | throw new Error('Device name cannot be empty') | |
| 88 | } | |
| 89 | ||
| 90 | // Only allow alphanumeric, spaces, hyphens, underscores | |
| 91 | if (!sanitized.match(/^[a-zA-Z0-9\s\-_]+$/)) { | |
| 92 | throw new Error('Device name contains invalid characters (allowed: letters, numbers, spaces, -_)') | |
| 93 | } | |
| 94 | ||
| 95 | return sanitized | |
| 96 | } | |
| 97 | ||
| 98 | /** | |
| 99 | * Sanitize notes/description fields | |
| 100 | * Allows: alphanumeric, spaces, and extended punctuation | |
| 101 | * @param {string} notes - Notes text | |
| 102 | * @returns {string} Sanitized notes | |
| 103 | * @throws {Error} If notes are invalid | |
| 104 | */ | |
| 105 | function sanitizeNotes(notes) { | |
| 106 | // Check for non-string types | |
| 107 | if (typeof notes !== 'string') { | |
| 108 | throw new Error('Notes must be a string') | |
| 109 | } | |
| 110 | ||
| 111 | // Notes can be empty strings | |
| 112 | if (notes.trim().length === 0) { | |
| 113 | return '' | |
| 114 | } | |
| 115 | ||
| 116 | let sanitized = sanitizeString(notes, 500) | |
| 117 | ||
| 118 | // Remove dangerous characters (< and >) instead of throwing | |
| 119 | sanitized = sanitized.replace(/[<>]/g, '') | |
| 120 | ||
| 121 | return sanitized | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * Validate chain identifier | |
| 126 | * @param {string} chain - Chain name | |
| 127 | * @returns {string|boolean} Validated chain or false if invalid | |
| 128 | */ | |
| 129 | function validateChain(chain) { | |
| 130 | if (typeof chain !== 'string') { | |
| 131 | return false | |
| 132 | } | |
| 133 | ||
| 134 | const validChains = ['bitcoin', 'ethereum'] | |
| 135 | const lowerChain = chain.toLowerCase() | |
| 136 | ||
| 137 | if (!validChains.includes(lowerChain)) { | |
| 138 | return false | |
| 139 | } | |
| 140 | ||
| 141 | return lowerChain | |
| 142 | } | |
| 143 | ||
| 144 | /** | |
| 145 | * Validate network identifier | |
| 146 | * @param {string} network - Network name | |
| 147 | * @returns {string|boolean} Validated network or false if invalid | |
| 148 | */ | |
| 149 | function validateNetwork(network) { | |
| 150 | if (typeof network !== 'string') { | |
| 151 | return false | |
| 152 | } | |
| 153 | ||
| 154 | const validNetworks = ['testnet', 'mainnet', 'sepolia'] | |
| 155 | const lowerNetwork = network.toLowerCase() | |
| 156 | ||
| 157 | if (!validNetworks.includes(lowerNetwork)) { | |
| 158 | return false | |
| 159 | } | |
| 160 | ||
| 161 | return lowerNetwork | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * Sanitize hex string (for keys, hashes, etc.) | |
| 166 | * @param {string} hex - Hex string | |
| 167 | * @param {number} expectedLength - Expected length (0 = any length) | |
| 168 | * @returns {string} Sanitized hex string (lowercase) | |
| 169 | * @throws {Error} If hex is invalid | |
| 170 | */ | |
| 171 | function sanitizeHex(hex, expectedLength = 0) { | |
| 172 | if (typeof hex !== 'string') { | |
| 173 | throw new Error('Hex string must be a string') | |
| 174 | } | |
| 175 | ||
| 176 | // Remove 0x prefix if present | |
| 177 | let sanitized = hex.toLowerCase() | |
| 178 | if (sanitized.startsWith('0x')) { | |
| 179 | sanitized = sanitized.slice(2) | |
| 180 | } | |
| 181 | ||
| 182 | // Remove non-hex characters instead of throwing | |
| 183 | sanitized = sanitized.replace(/[^0-9a-f]/g, '') | |
| 184 | ||
| 185 | // Check length if specified | |
| 186 | if (expectedLength > 0 && sanitized.length !== expectedLength) { | |
| 187 | throw new Error(`Invalid hex length (expected ${expectedLength}, got ${sanitized.length})`) | |
| 188 | } | |
| 189 | ||
| 190 | return sanitized | |
| 191 | } | |
| 192 | ||
| 193 | /** | |
| 194 | * Validate invite code format | |
| 195 | * @param {string} inviteCode - Base64 invite code | |
| 196 | * @returns {string|boolean} Validated invite code or false if invalid | |
| 197 | */ | |
| 198 | function validateInviteCode(inviteCode) { | |
| 199 | if (typeof inviteCode !== 'string') { | |
| 200 | return false | |
| 201 | } | |
| 202 | ||
| 203 | const sanitized = inviteCode.trim() | |
| 204 | ||
| 205 | // Check for valid base64 characters | |
| 206 | if (!sanitized.match(/^[A-Za-z0-9+/]+=*$/)) { | |
| 207 | return false | |
| 208 | } | |
| 209 | ||
| 210 | // Check reasonable length (should be ~200-300 chars for signed invite) | |
| 211 | if (sanitized.length < 50 || sanitized.length > 1000) { | |
| 212 | return false | |
| 213 | } | |
| 214 | ||
| 215 | return sanitized | |
| 216 | } | |
| 217 | ||
| 218 | module.exports = { | |
| 219 | sanitizeString, | |
| 220 | sanitizeContactName, | |
| 221 | sanitizeDeviceName, | |
| 222 | sanitizeNotes, | |
| 223 | validateChain, | |
| 224 | validateNetwork, | |
| 225 | sanitizeHex, | |
| 226 | validateInviteCode | |
| 227 | } |
| … | … | @@ -1,13 +1,31 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-wallet-lib", |
| 3 | 3 | "version": "1.0.0", |
| 4 | "description": "Shared library for hyper-wallet - identity and secure storage", | |
| 4 | "description": "Shared library for hyper-wallet", | |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| 7 | 7 | "exports": { |
| 8 | 8 | ".": "./index.js", |
| 9 | 9 | "./identity": "./identity.js", |
| 10 | "./secure-storage": "./secure-storage.js" | |
| 10 | "./secure-storage": "./secure-storage.js", | |
| 11 | "./secure-storage.cjs": "./secure-storage.cjs", | |
| 12 | "./address-manager": "./address-manager.cjs", | |
| 13 | "./address-validator": "./address-validator.cjs", | |
| 14 | "./amount-validator": "./amount-validator.cjs", | |
| 15 | "./auth": "./auth.cjs", | |
| 16 | "./blockchain-api": "./blockchain-api.cjs", | |
| 17 | "./blockchain-providers": "./blockchain-providers.cjs", | |
| 18 | "./config": "./config.cjs", | |
| 19 | "./config-loader": "./config-loader.cjs", | |
| 20 | "./create-wallet-server": "./create-wallet-server.cjs", | |
| 21 | "./encryption": "./encryption.cjs", | |
| 22 | "./input-sanitizer": "./input-sanitizer.cjs", | |
| 23 | "./pairing-protocol": "./pairing-protocol.cjs", | |
| 24 | "./qr-svg": "./qr-svg.cjs", | |
| 25 | "./rate-limiter": "./rate-limiter.cjs", | |
| 26 | "./utxo-manager": "./utxo-manager.cjs", | |
| 27 | "./wallet-storage": "./wallet-storage.cjs", | |
| 28 | "./wdk-integration": "./wdk-integration.cjs" | |
| 11 | 29 | }, |
| 12 | 30 | "dependencies": { |
| 13 | 31 | "keet-identity-key": "^3.1.0", |
| … | … | @@ -0,0 +1,210 @@ |
| 1 | // lib/pairing-protocol.cjs - Device pairing protocol using Protomux | |
| 2 | ||
| 3 | const Protomux = require('protomux') | |
| 4 | const b4a = require('b4a') | |
| 5 | const c = require('compact-encoding') | |
| 6 | ||
| 7 | // Protocol identifier | |
| 8 | const PAIRING_PROTOCOL = 'hyper-wallet-pairing' | |
| 9 | ||
| 10 | /** | |
| 11 | * Create pairing channel for device that is adding another device (server side) | |
| 12 | * @param {Connection} conn - Hyperswarm connection | |
| 13 | * @param {string} identityMnemonic - Identity mnemonic (24 words) to send to joining device | |
| 14 | * @param {string} walletMnemonic - Wallet mnemonic (12 words) to send to joining device | |
| 15 | * @param {string} deviceName - Name of this device | |
| 16 | * @returns {Promise<Object>} { remoteDeviceName, remoteWriterKey } | |
| 17 | */ | |
| 18 | async function createPairingServer(conn, identityMnemonic, walletMnemonic, deviceName) { | |
| 19 | return new Promise((resolve, reject) => { | |
| 20 | const mux = Protomux.from(conn) | |
| 21 | const channel = mux.createChannel({ | |
| 22 | protocol: PAIRING_PROTOCOL, | |
| 23 | id: null | |
| 24 | }) | |
| 25 | ||
| 26 | // Message types | |
| 27 | const messages = { | |
| 28 | // 0: Request pairing (client → server) | |
| 29 | request: channel.addMessage({ | |
| 30 | encoding: { | |
| 31 | preencode(state, m) { | |
| 32 | c.string.preencode(state, m.deviceName) | |
| 33 | c.buffer.preencode(state, m.writerKey) | |
| 34 | }, | |
| 35 | encode(state, m) { | |
| 36 | c.string.encode(state, m.deviceName) | |
| 37 | c.buffer.encode(state, m.writerKey) | |
| 38 | }, | |
| 39 | decode(state) { | |
| 40 | return { | |
| 41 | deviceName: c.string.decode(state), | |
| 42 | writerKey: c.buffer.decode(state) | |
| 43 | } | |
| 44 | } | |
| 45 | }, | |
| 46 | onmessage(msg) { | |
| 47 | console.log(`📨 Received pairing request from: ${msg.deviceName}`) | |
| 48 | ||
| 49 | // Send BOTH mnemonics to joining device | |
| 50 | messages.response.send({ | |
| 51 | identityMnemonic, | |
| 52 | walletMnemonic, | |
| 53 | serverDeviceName: deviceName, | |
| 54 | approved: true | |
| 55 | }) | |
| 56 | ||
| 57 | // Resolve with remote device info | |
| 58 | resolve({ | |
| 59 | remoteDeviceName: msg.deviceName, | |
| 60 | remoteWriterKey: msg.writerKey | |
| 61 | }) | |
| 62 | } | |
| 63 | }), | |
| 64 | ||
| 65 | // 1: Send mnemonics (server → client) | |
| 66 | response: channel.addMessage({ | |
| 67 | encoding: { | |
| 68 | preencode(state, m) { | |
| 69 | c.string.preencode(state, m.identityMnemonic) | |
| 70 | c.string.preencode(state, m.walletMnemonic) | |
| 71 | c.string.preencode(state, m.serverDeviceName) | |
| 72 | c.bool.preencode(state, m.approved) | |
| 73 | }, | |
| 74 | encode(state, m) { | |
| 75 | c.string.encode(state, m.identityMnemonic) | |
| 76 | c.string.encode(state, m.walletMnemonic) | |
| 77 | c.string.encode(state, m.serverDeviceName) | |
| 78 | c.bool.encode(state, m.approved) | |
| 79 | }, | |
| 80 | decode(state) { | |
| 81 | return { | |
| 82 | identityMnemonic: c.string.decode(state), | |
| 83 | walletMnemonic: c.string.decode(state), | |
| 84 | serverDeviceName: c.string.decode(state), | |
| 85 | approved: c.bool.decode(state) | |
| 86 | } | |
| 87 | } | |
| 88 | } | |
| 89 | }) | |
| 90 | } | |
| 91 | ||
| 92 | channel.open() | |
| 93 | ||
| 94 | // Timeout after 60 seconds | |
| 95 | const timeout = setTimeout(() => { | |
| 96 | reject(new Error('Pairing timeout - no response from joining device')) | |
| 97 | }, 60000) | |
| 98 | ||
| 99 | // Clear timeout when resolved | |
| 100 | const originalResolve = resolve | |
| 101 | resolve = (value) => { | |
| 102 | clearTimeout(timeout) | |
| 103 | originalResolve(value) | |
| 104 | } | |
| 105 | }) | |
| 106 | } | |
| 107 | ||
| 108 | /** | |
| 109 | * Create pairing channel for device that is joining (client side) | |
| 110 | * @param {Connection} conn - Hyperswarm connection | |
| 111 | * @param {Buffer} writerKey - Writer key of this device | |
| 112 | * @param {string} deviceName - Name of this device | |
| 113 | * @returns {Promise<Object>} { identityMnemonic, walletMnemonic } received from server | |
| 114 | */ | |
| 115 | async function createPairingClient(conn, writerKey, deviceName) { | |
| 116 | return new Promise((resolve, reject) => { | |
| 117 | const mux = Protomux.from(conn) | |
| 118 | const channel = mux.createChannel({ | |
| 119 | protocol: PAIRING_PROTOCOL, | |
| 120 | id: null | |
| 121 | }) | |
| 122 | ||
| 123 | // Message types | |
| 124 | const messages = { | |
| 125 | // 0: Request pairing (client → server) | |
| 126 | request: channel.addMessage({ | |
| 127 | encoding: { | |
| 128 | preencode(state, m) { | |
| 129 | c.string.preencode(state, m.deviceName) | |
| 130 | c.buffer.preencode(state, m.writerKey) | |
| 131 | }, | |
| 132 | encode(state, m) { | |
| 133 | c.string.encode(state, m.deviceName) | |
| 134 | c.buffer.encode(state, m.writerKey) | |
| 135 | }, | |
| 136 | decode(state) { | |
| 137 | return { | |
| 138 | deviceName: c.string.decode(state), | |
| 139 | writerKey: c.buffer.decode(state) | |
| 140 | } | |
| 141 | } | |
| 142 | } | |
| 143 | }), | |
| 144 | ||
| 145 | // 1: Receive mnemonics (server → client) | |
| 146 | response: channel.addMessage({ | |
| 147 | encoding: { | |
| 148 | preencode(state, m) { | |
| 149 | c.string.preencode(state, m.identityMnemonic) | |
| 150 | c.string.preencode(state, m.walletMnemonic) | |
| 151 | c.string.preencode(state, m.serverDeviceName) | |
| 152 | c.bool.preencode(state, m.approved) | |
| 153 | }, | |
| 154 | encode(state, m) { | |
| 155 | c.string.encode(state, m.identityMnemonic) | |
| 156 | c.string.encode(state, m.walletMnemonic) | |
| 157 | c.string.encode(state, m.serverDeviceName) | |
| 158 | c.bool.encode(state, m.approved) | |
| 159 | }, | |
| 160 | decode(state) { | |
| 161 | return { | |
| 162 | identityMnemonic: c.string.decode(state), | |
| 163 | walletMnemonic: c.string.decode(state), | |
| 164 | serverDeviceName: c.string.decode(state), | |
| 165 | approved: c.bool.decode(state) | |
| 166 | } | |
| 167 | } | |
| 168 | }, | |
| 169 | onmessage(msg) { | |
| 170 | if (!msg.approved) { | |
| 171 | reject(new Error('Pairing rejected by server')) | |
| 172 | return | |
| 173 | } | |
| 174 | ||
| 175 | console.log(`✅ Received mnemonics from: ${msg.serverDeviceName}`) | |
| 176 | resolve({ | |
| 177 | identityMnemonic: msg.identityMnemonic, | |
| 178 | walletMnemonic: msg.walletMnemonic | |
| 179 | }) | |
| 180 | } | |
| 181 | }) | |
| 182 | } | |
| 183 | ||
| 184 | channel.open() | |
| 185 | ||
| 186 | // Send pairing request immediately (channel is ready after open()) | |
| 187 | console.log(`📤 Sending pairing request as: ${deviceName}`) | |
| 188 | messages.request.send({ | |
| 189 | deviceName, | |
| 190 | writerKey | |
| 191 | }) | |
| 192 | ||
| 193 | // Timeout after 60 seconds | |
| 194 | const timeout = setTimeout(() => { | |
| 195 | reject(new Error('Pairing timeout - no response from server')) | |
| 196 | }, 60000) | |
| 197 | ||
| 198 | // Clear timeout when resolved | |
| 199 | const originalResolve = resolve | |
| 200 | resolve = (value) => { | |
| 201 | clearTimeout(timeout) | |
| 202 | originalResolve(value) | |
| 203 | } | |
| 204 | }) | |
| 205 | } | |
| 206 | ||
| 207 | module.exports = { | |
| 208 | createPairingServer, | |
| 209 | createPairingClient | |
| 210 | } |
| … | … | @@ -0,0 +1,32 @@ |
| 1 | // QR Code generator using SVG (no fs dependency) | |
| 2 | ||
| 3 | // Polyfill TextEncoder for Bare runtime | |
| 4 | if (typeof global.TextEncoder === 'undefined') { | |
| 5 | const { TextEncoder, TextDecoder } = require('bare-encoding') | |
| 6 | global.TextEncoder = TextEncoder | |
| 7 | global.TextDecoder = TextDecoder | |
| 8 | } | |
| 9 | ||
| 10 | const QRCode = require('qrcode/lib/core/qrcode') | |
| 11 | const SvgRenderer = require('qrcode/lib/renderer/svg') | |
| 12 | ||
| 13 | async function generateQRCodeSVG(text, options = {}) { | |
| 14 | console.log('QRCode type:', typeof QRCode) | |
| 15 | console.log('QRCode.create type:', typeof QRCode?.create) | |
| 16 | console.log('QRCode keys:', Object.keys(QRCode || {})) | |
| 17 | ||
| 18 | // Create QR code data structure | |
| 19 | const qrData = QRCode.create(text, options) | |
| 20 | ||
| 21 | // Render to SVG | |
| 22 | const svg = await SvgRenderer.render(qrData, { | |
| 23 | ...options, | |
| 24 | type: 'svg' | |
| 25 | }) | |
| 26 | ||
| 27 | // Convert SVG string to data URL | |
| 28 | const svgDataURL = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` | |
| 29 | return svgDataURL | |
| 30 | } | |
| 31 | ||
| 32 | module.exports = { generateQRCodeSVG } |
| … | … | @@ -0,0 +1,265 @@ |
| 1 | /** | |
| 2 | * FILE: lib/rate-limiter.cjs | |
| 3 | * STATUS: ACTIVE | |
| 4 | * RUNTIME: Bare | |
| 5 | * MODULE: CommonJS | |
| 6 | * | |
| 7 | * PURPOSE: | |
| 8 | * Rate limiting middleware to prevent abuse and spam | |
| 9 | * | |
| 10 | * USED BY: | |
| 11 | * - wallet-server.cjs (rate limiting middleware) | |
| 12 | * | |
| 13 | * KEY EXPORTS: | |
| 14 | * - createRateLimiter() - Create rate limiter instance | |
| 15 | * - checkRateLimit(req, endpoint) - Check if request is within limits | |
| 16 | * | |
| 17 | * RATE LIMITS: | |
| 18 | * - /api/send: 10 tx per minute | |
| 19 | * - /api/export-identity: 3 per hour | |
| 20 | * - /api/export-wallet: 3 per hour | |
| 21 | * - /api/device/add: 5 per 5 minutes | |
| 22 | * - /api/device/join: 5 per 5 minutes | |
| 23 | * - default: 100 per minute | |
| 24 | */ | |
| 25 | ||
| 26 | // Rate limit configurations (per IP address) | |
| 27 | const RATE_LIMITS = { | |
| 28 | '/api/send': { window: 60000, max: 10 }, // 10 transactions per minute | |
| 29 | '/api/export-identity': { window: 3600000, max: 3 }, // 3 per hour | |
| 30 | '/api/export-wallet': { window: 3600000, max: 3 }, // 3 per hour | |
| 31 | '/api/device/add': { window: 300000, max: 5 }, // 5 per 5 minutes | |
| 32 | '/api/device/join': { window: 300000, max: 5 }, // 5 per 5 minutes | |
| 33 | '/api/estimate-fee': { window: 60000, max: 50 }, // 50 per minute | |
| 34 | '/api/transactions/sync': { window: 300000, max: 10 }, // 10 per 5 minutes | |
| 35 | 'default': { window: 60000, max: 100 } // 100 per minute (general) | |
| 36 | } | |
| 37 | ||
| 38 | /** | |
| 39 | * Rate limiter class | |
| 40 | * Tracks request counts per IP and endpoint | |
| 41 | */ | |
| 42 | class RateLimiter { | |
| 43 | constructor() { | |
| 44 | // Map: IP → Map: endpoint → [timestamps] | |
| 45 | this.requests = new Map() | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * Check if request is within rate limits | |
| 50 | * @param {string} ip - Client IP address | |
| 51 | * @param {string} endpoint - API endpoint path | |
| 52 | * @returns {Object} { allowed: boolean, remaining: number, resetIn: number, headers: Object } | |
| 53 | */ | |
| 54 | checkLimit(ip, endpoint) { | |
| 55 | // Get rate limit for this endpoint (or default) | |
| 56 | const limit = RATE_LIMITS[endpoint] || RATE_LIMITS.default | |
| 57 | const now = Date.now() | |
| 58 | ||
| 59 | // Initialize IP tracking if needed | |
| 60 | if (!this.requests.has(ip)) { | |
| 61 | this.requests.set(ip, new Map()) | |
| 62 | } | |
| 63 | ||
| 64 | const ipRequests = this.requests.get(ip) | |
| 65 | ||
| 66 | // Initialize endpoint tracking if needed | |
| 67 | if (!ipRequests.has(endpoint)) { | |
| 68 | ipRequests.set(endpoint, []) | |
| 69 | } | |
| 70 | ||
| 71 | const timestamps = ipRequests.get(endpoint) | |
| 72 | ||
| 73 | // Remove timestamps outside the time window | |
| 74 | const recentRequests = timestamps.filter(ts => now - ts < limit.window) | |
| 75 | ||
| 76 | // Check if limit exceeded | |
| 77 | if (recentRequests.length >= limit.max) { | |
| 78 | const oldestRequest = Math.min(...recentRequests) | |
| 79 | const resetIn = limit.window - (now - oldestRequest) | |
| 80 | const resetTime = Math.floor((now + resetIn) / 1000) | |
| 81 | ||
| 82 | return { | |
| 83 | allowed: false, | |
| 84 | remaining: 0, | |
| 85 | resetIn: Math.ceil(resetIn / 1000), // seconds | |
| 86 | limit: limit.max, | |
| 87 | window: Math.floor(limit.window / 1000), // seconds | |
| 88 | headers: { | |
| 89 | 'X-RateLimit-Limit': String(limit.max), | |
| 90 | 'X-RateLimit-Remaining': '0', | |
| 91 | 'X-RateLimit-Reset': String(resetTime) | |
| 92 | } | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | // Add current request timestamp | |
| 97 | recentRequests.push(now) | |
| 98 | ipRequests.set(endpoint, recentRequests) | |
| 99 | ||
| 100 | const resetTime = Math.floor((now + limit.window) / 1000) | |
| 101 | ||
| 102 | return { | |
| 103 | allowed: true, | |
| 104 | remaining: limit.max - recentRequests.length, | |
| 105 | resetIn: Math.floor(limit.window / 1000), // seconds | |
| 106 | limit: limit.max, | |
| 107 | window: Math.floor(limit.window / 1000), // seconds | |
| 108 | headers: { | |
| 109 | 'X-RateLimit-Limit': String(limit.max), | |
| 110 | 'X-RateLimit-Remaining': String(limit.max - recentRequests.length), | |
| 111 | 'X-RateLimit-Reset': String(resetTime) | |
| 112 | } | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * Get current status for an IP/endpoint combination | |
| 118 | * @param {string} ip - Client IP address | |
| 119 | * @param {string} endpoint - API endpoint path | |
| 120 | * @returns {Object} Current rate limit status | |
| 121 | */ | |
| 122 | getStatus(ip, endpoint) { | |
| 123 | const limit = RATE_LIMITS[endpoint] || RATE_LIMITS.default | |
| 124 | const now = Date.now() | |
| 125 | ||
| 126 | if (!this.requests.has(ip)) { | |
| 127 | return { | |
| 128 | remaining: limit.max, | |
| 129 | limit: limit.max, | |
| 130 | window: Math.floor(limit.window / 1000) | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | const ipRequests = this.requests.get(ip) | |
| 135 | if (!ipRequests.has(endpoint)) { | |
| 136 | return { | |
| 137 | remaining: limit.max, | |
| 138 | limit: limit.max, | |
| 139 | window: Math.floor(limit.window / 1000) | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | const timestamps = ipRequests.get(endpoint) | |
| 144 | const recentRequests = timestamps.filter(ts => now - ts < limit.window) | |
| 145 | ||
| 146 | return { | |
| 147 | remaining: Math.max(0, limit.max - recentRequests.length), | |
| 148 | limit: limit.max, | |
| 149 | window: Math.floor(limit.window / 1000) | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 153 | /** | |
| 154 | * Clean up old entries (garbage collection) | |
| 155 | * Call periodically to prevent memory buildup | |
| 156 | */ | |
| 157 | cleanup() { | |
| 158 | const now = Date.now() | |
| 159 | let cleaned = 0 | |
| 160 | ||
| 161 | for (const [ip, endpoints] of this.requests.entries()) { | |
| 162 | for (const [endpoint, timestamps] of endpoints.entries()) { | |
| 163 | const limit = RATE_LIMITS[endpoint] || RATE_LIMITS.default | |
| 164 | const recentRequests = timestamps.filter(ts => now - ts < limit.window) | |
| 165 | ||
| 166 | if (recentRequests.length === 0) { | |
| 167 | endpoints.delete(endpoint) | |
| 168 | cleaned++ | |
| 169 | } else { | |
| 170 | endpoints.set(endpoint, recentRequests) | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | if (endpoints.size === 0) { | |
| 175 | this.requests.delete(ip) | |
| 176 | } | |
| 177 | } | |
| 178 | ||
| 179 | if (cleaned > 0) { | |
| 180 | console.log(`🧹 Rate limiter cleanup: removed ${cleaned} stale entries`) | |
| 181 | } | |
| 182 | } | |
| 183 | } | |
| 184 | ||
| 185 | // Create global rate limiter instance | |
| 186 | const rateLimiter = new RateLimiter() | |
| 187 | ||
| 188 | // Cleanup every 5 minutes | |
| 189 | setInterval(() => rateLimiter.cleanup(), 5 * 60 * 1000) | |
| 190 | ||
| 191 | /** | |
| 192 | * Extract client IP from request | |
| 193 | * @param {Request} req - HTTP request | |
| 194 | * @returns {string} Client IP address | |
| 195 | */ | |
| 196 | function getClientIP(req) { | |
| 197 | // Check X-Forwarded-For header (if behind proxy) | |
| 198 | const forwardedFor = req.headers['x-forwarded-for'] | |
| 199 | if (forwardedFor) { | |
| 200 | // Take first IP if multiple | |
| 201 | return forwardedFor.split(',')[0].trim() | |
| 202 | } | |
| 203 | ||
| 204 | // Use socket remote address | |
| 205 | return req.socket.remoteAddress || '0.0.0.0' | |
| 206 | } | |
| 207 | ||
| 208 | /** | |
| 209 | * Normalize endpoint path for rate limiting | |
| 210 | * Removes query params and route params | |
| 211 | * @param {string} url - Request URL | |
| 212 | * @returns {string} Normalized endpoint | |
| 213 | */ | |
| 214 | function normalizeEndpoint(url) { | |
| 215 | // Remove query string | |
| 216 | const path = url.split('?')[0] | |
| 217 | ||
| 218 | // Normalize parameterized routes | |
| 219 | // /api/balance/testnet-bitcoin → /api/balance/:chain | |
| 220 | // /api/address/testnet-bitcoin → /api/address/:chain | |
| 221 | // /api/transactions/bitcoin → /api/transactions/:chain | |
| 222 | // /api/device/add/abc123 → /api/device/add/:sessionId | |
| 223 | ||
| 224 | if (path.match(/^\/api\/balance\//)) return '/api/balance/:chain' | |
| 225 | if (path.match(/^\/api\/address\//)) return '/api/address/:chain' | |
| 226 | if (path.match(/^\/api\/qrcode\//)) return '/api/qrcode/:chain' | |
| 227 | if (path.match(/^\/api\/transactions\/[^/]+$/)) return '/api/transactions/:chain' | |
| 228 | if (path.match(/^\/api\/transaction\//)) return '/api/transaction/:chain/:hash' | |
| 229 | if (path.match(/^\/api\/contacts\//)) return '/api/contacts/:id' | |
| 230 | if (path.match(/^\/api\/device\/add\/[^/]+$/)) return '/api/device/add/:sessionId' | |
| 231 | if (path.match(/^\/api\/settings\/providers\//)) return '/api/settings/providers/:chain' | |
| 232 | ||
| 233 | return path | |
| 234 | } | |
| 235 | ||
| 236 | /** | |
| 237 | * Check rate limit for request | |
| 238 | * @param {Request} req - HTTP request | |
| 239 | * @returns {Object} Rate limit result | |
| 240 | */ | |
| 241 | function checkRateLimit(req) { | |
| 242 | const ip = getClientIP(req) | |
| 243 | const endpoint = normalizeEndpoint(req.url) | |
| 244 | ||
| 245 | return rateLimiter.checkLimit(ip, endpoint) | |
| 246 | } | |
| 247 | ||
| 248 | /** | |
| 249 | * Get rate limit status for request | |
| 250 | * @param {Request} req - HTTP request | |
| 251 | * @returns {Object} Current status | |
| 252 | */ | |
| 253 | function getRateLimitStatus(req) { | |
| 254 | const ip = getClientIP(req) | |
| 255 | const endpoint = normalizeEndpoint(req.url) | |
| 256 | ||
| 257 | return rateLimiter.getStatus(ip, endpoint) | |
| 258 | } | |
| 259 | ||
| 260 | module.exports = { | |
| 261 | checkRateLimit, | |
| 262 | getRateLimitStatus, | |
| 263 | RateLimiter, | |
| 264 | RATE_LIMITS | |
| 265 | } |
| … | … | @@ -0,0 +1,70 @@ |
| 1 | // lib/secure-storage.cjs - File-based Mnemonic Storage (Bare Runtime) | |
| 2 | // | |
| 3 | // NOTE: This is a simplified version for Bare runtime. | |
| 4 | // Uses file-based storage instead of OS keychain. | |
| 5 | // In production, consider using encrypted storage. | |
| 6 | ||
| 7 | const fs = require('fs') | |
| 8 | const path = require('path') | |
| 9 | const os = require('os') | |
| 10 | ||
| 11 | const STORAGE_DIR = path.join(os.homedir(), '.hyper-wallet') | |
| 12 | const MNEMONIC_FILE = path.join(STORAGE_DIR, '.mnemonic') | |
| 13 | ||
| 14 | /** | |
| 15 | * Ensure storage directory exists | |
| 16 | */ | |
| 17 | function ensureStorageDir() { | |
| 18 | if (!fs.existsSync(STORAGE_DIR)) { | |
| 19 | fs.mkdirSync(STORAGE_DIR, { recursive: true, mode: 0o700 }) | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | /** | |
| 24 | * Store mnemonic in file | |
| 25 | * @param {String} mnemonic - BIP39 mnemonic phrase | |
| 26 | */ | |
| 27 | async function storeMnemonic(mnemonic) { | |
| 28 | ensureStorageDir() | |
| 29 | fs.writeFileSync(MNEMONIC_FILE, mnemonic, { mode: 0o600 }) | |
| 30 | } | |
| 31 | ||
| 32 | /** | |
| 33 | * Retrieve mnemonic from file | |
| 34 | * @returns {String|null} - Mnemonic or null if not found | |
| 35 | */ | |
| 36 | async function loadMnemonic() { | |
| 37 | try { | |
| 38 | if (!fs.existsSync(MNEMONIC_FILE)) { | |
| 39 | return null | |
| 40 | } | |
| 41 | return fs.readFileSync(MNEMONIC_FILE, 'utf8').trim() | |
| 42 | } catch (err) { | |
| 43 | return null | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * Delete mnemonic file | |
| 49 | */ | |
| 50 | async function deleteMnemonic() { | |
| 51 | if (fs.existsSync(MNEMONIC_FILE)) { | |
| 52 | fs.unlinkSync(MNEMONIC_FILE) | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Check if wallet is initialized (mnemonic file exists) | |
| 58 | * @returns {Boolean} | |
| 59 | */ | |
| 60 | async function isInitialized() { | |
| 61 | const mnemonic = await loadMnemonic() | |
| 62 | return mnemonic !== null | |
| 63 | } | |
| 64 | ||
| 65 | module.exports = { | |
| 66 | storeMnemonic, | |
| 67 | loadMnemonic, | |
| 68 | deleteMnemonic, | |
| 69 | isInitialized | |
| 70 | } |
| … | … | @@ -0,0 +1,157 @@ |
| 1 | /** | |
| 2 | * UTXO Manager - Phase 6.3 | |
| 3 | * | |
| 4 | * Manages Unspent Transaction Outputs (UTXOs) for Bitcoin wallet. | |
| 5 | * Tracks UTXOs in Autobase for multi-device synchronization. | |
| 6 | */ | |
| 7 | ||
| 8 | const b4a = require('b4a') | |
| 9 | ||
| 10 | /** | |
| 11 | * Create UTXO key for Autobase storage | |
| 12 | * Format: utxo:{chain}:{txid}:{vout} | |
| 13 | */ | |
| 14 | function createUTXOKey(chain, txid, vout) { | |
| 15 | return `utxo:${chain}:${txid}:${vout}` | |
| 16 | } | |
| 17 | ||
| 18 | /** | |
| 19 | * Store UTXO in Autobase | |
| 20 | * @param {Object} utxo - UTXO data from blockchain | |
| 21 | * @param {string} chain - Chain identifier (bitcoin, ethereum) | |
| 22 | * @param {Function} storeUTXOFn - Backend storeUTXO function | |
| 23 | */ | |
| 24 | async function storeUTXO(utxo, chain, storeUTXOFn) { | |
| 25 | const utxoData = { | |
| 26 | chain, | |
| 27 | txid: utxo.txid, | |
| 28 | vout: utxo.vout, | |
| 29 | address: utxo.address, | |
| 30 | amount: utxo.value, // satoshis | |
| 31 | scriptPubKey: utxo.scriptPubKey || null, | |
| 32 | confirmations: utxo.confirmations || 0, | |
| 33 | blockHeight: utxo.blockHeight || null, | |
| 34 | spendable: true, | |
| 35 | spent: false, | |
| 36 | spentTxid: null, | |
| 37 | timestamp: Date.now() | |
| 38 | } | |
| 39 | ||
| 40 | await storeUTXOFn(utxoData) | |
| 41 | return utxoData | |
| 42 | } | |
| 43 | ||
| 44 | /** | |
| 45 | * Mark UTXO as spent | |
| 46 | * @param {string} txid - Transaction ID | |
| 47 | * @param {number} vout - Output index | |
| 48 | * @param {string} spentTxid - Transaction that spent this UTXO | |
| 49 | * @param {string} chain - Chain identifier | |
| 50 | * @param {Function} updateUTXOFn - Backend updateUTXO function | |
| 51 | */ | |
| 52 | async function markUTXOSpent(txid, vout, spentTxid, chain, updateUTXOFn) { | |
| 53 | await updateUTXOFn(chain, txid, vout, { | |
| 54 | spent: true, | |
| 55 | spendable: false, | |
| 56 | spentTxid, | |
| 57 | spentAt: Date.now() | |
| 58 | }) | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * Calculate balance from UTXOs | |
| 63 | * @param {Array} utxos - Array of UTXO objects | |
| 64 | * @returns {Object} Balance breakdown | |
| 65 | */ | |
| 66 | function calculateBalance(utxos) { | |
| 67 | const confirmed = utxos | |
| 68 | .filter(u => u.confirmations >= 6 && !u.spent && u.spendable) | |
| 69 | .reduce((sum, u) => sum + u.amount, 0) | |
| 70 | ||
| 71 | const unconfirmed = utxos | |
| 72 | .filter(u => u.confirmations > 0 && u.confirmations < 6 && !u.spent && u.spendable) | |
| 73 | .reduce((sum, u) => sum + u.amount, 0) | |
| 74 | ||
| 75 | const pending = utxos | |
| 76 | .filter(u => u.confirmations === 0 && !u.spent && u.spendable) | |
| 77 | .reduce((sum, u) => sum + u.amount, 0) | |
| 78 | ||
| 79 | return { | |
| 80 | confirmed, // 6+ confirmations | |
| 81 | unconfirmed, // 1-5 confirmations | |
| 82 | pending, // 0 confirmations | |
| 83 | total: confirmed + unconfirmed + pending, | |
| 84 | spendable: confirmed + unconfirmed // Don't include pending in spendable | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * Get spendable UTXOs (confirmed + unconfirmed, not spent) | |
| 90 | * @param {Array} utxos - Array of UTXO objects | |
| 91 | * @param {boolean} confirmedOnly - Only return confirmed UTXOs (6+ confirmations) | |
| 92 | * @returns {Array} Spendable UTXOs sorted by amount (largest first) | |
| 93 | */ | |
| 94 | function getSpendableUTXOs(utxos, confirmedOnly = false) { | |
| 95 | const minConfirmations = confirmedOnly ? 6 : 1 | |
| 96 | ||
| 97 | return utxos | |
| 98 | .filter(u => | |
| 99 | u.confirmations >= minConfirmations && | |
| 100 | !u.spent && | |
| 101 | u.spendable | |
| 102 | ) | |
| 103 | .sort((a, b) => b.amount - a.amount) // Largest first for coin selection | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * Format satoshis to BTC display | |
| 108 | * @param {number} satoshis - Amount in satoshis | |
| 109 | * @returns {string} Formatted BTC amount | |
| 110 | */ | |
| 111 | function formatBTC(satoshis) { | |
| 112 | const btc = (satoshis / 100000000).toFixed(8) | |
| 113 | return btc.replace(/\.?0+$/, '') || '0' // Remove trailing zeros | |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * Format balance display | |
| 118 | * @param {Object} balance - Balance object from calculateBalance | |
| 119 | * @param {string} symbol - Currency symbol (BTC, ETH, etc.) | |
| 120 | * @returns {string} Formatted balance string | |
| 121 | */ | |
| 122 | function formatBalance(balance, symbol = 'BTC') { | |
| 123 | const lines = [] | |
| 124 | ||
| 125 | if (balance.confirmed > 0) { | |
| 126 | lines.push(`Confirmed: ${formatBTC(balance.confirmed)} ${symbol} (${balance.confirmed.toLocaleString()} sats)`) | |
| 127 | } | |
| 128 | ||
| 129 | if (balance.unconfirmed > 0) { | |
| 130 | lines.push(`Unconfirmed: ${formatBTC(balance.unconfirmed)} ${symbol} (${balance.unconfirmed.toLocaleString()} sats)`) | |
| 131 | } | |
| 132 | ||
| 133 | if (balance.pending > 0) { | |
| 134 | lines.push(`Pending: ${formatBTC(balance.pending)} ${symbol} (${balance.pending.toLocaleString()} sats)`) | |
| 135 | } | |
| 136 | ||
| 137 | if (lines.length === 0) { | |
| 138 | return `Balance: 0 ${symbol}` | |
| 139 | } | |
| 140 | ||
| 141 | if (balance.total !== balance.confirmed) { | |
| 142 | lines.push(`─────────────────────────────────────`) | |
| 143 | lines.push(`Total: ${formatBTC(balance.total)} ${symbol} (${balance.total.toLocaleString()} sats)`) | |
| 144 | } | |
| 145 | ||
| 146 | return lines.join('\n') | |
| 147 | } | |
| 148 | ||
| 149 | module.exports = { | |
| 150 | createUTXOKey, | |
| 151 | storeUTXO, | |
| 152 | markUTXOSpent, | |
| 153 | calculateBalance, | |
| 154 | getSpendableUTXOs, | |
| 155 | formatBTC, | |
| 156 | formatBalance | |
| 157 | } |
| … | … | @@ -0,0 +1,2270 @@ |
| 1 | // lib/wallet-context-autobase.cjs - Multi-device storage with Autobase | |
| 2 | ||
| 3 | require('bare-node-runtime/global') | |
| 4 | const path = require('bare-path') | |
| 5 | const os = require('bare-os') | |
| 6 | const fs = require('bare-fs') | |
| 7 | const Corestore = require('corestore') | |
| 8 | const Hyperbee = require('hyperbee') | |
| 9 | const Autobase = require('autobase') | |
| 10 | const Hyperswarm = require('hyperswarm') | |
| 11 | const crypto = require('hypercore-crypto') | |
| 12 | const b4a = require('b4a') | |
| 13 | const IdentityKey = require('keet-identity-key') | |
| 14 | const { createPairingServer, createPairingClient } = require('./pairing-protocol.cjs') | |
| 15 | const { loadWalletMnemonic, initializeWDK } = require('./wdk-integration.cjs') | |
| 16 | const { fetchTransactions, fetchTokenTransactions } = require('./blockchain-providers.cjs') | |
| 17 | const { getConfig } = require('./config.cjs') | |
| 18 | ||
| 19 | // Storage configuration | |
| 20 | const STORAGE_DIR = path.join(os.homedir(), '.hyper-wallet', 'storage') | |
| 21 | const DEVICES_FILE = path.join(os.homedir(), '.hyper-wallet', '.devices') | |
| 22 | const PENDING_FILE = path.join(os.homedir(), '.hyper-wallet', '.pending') | |
| 23 | ||
| 24 | // Singleton instances | |
| 25 | let store = null | |
| 26 | let autobase = null | |
| 27 | let txHistoryDb = null | |
| 28 | let swarm = null | |
| 29 | let identityKeyPair = null | |
| 30 | let connections = new Set() // Track active connections | |
| 31 | ||
| 32 | // Initialization lock to prevent concurrent initialization | |
| 33 | let initializationPromise = null | |
| 34 | ||
| 35 | // WDK singleton instances (Phase 6.1) | |
| 36 | let wdkInstance = null | |
| 37 | let walletManagers = {} // { bitcoin, ethereum } | |
| 38 | ||
| 39 | /** | |
| 40 | * Apply function for Autobase | |
| 41 | * Processes operations from all input cores and builds Hyperbee view | |
| 42 | * | |
| 43 | * Pattern based on holepunchto/autobase examples: | |
| 44 | * - nodes: array of node objects from all input cores | |
| 45 | * - view: already a Hyperbee instance (created by open()) | |
| 46 | * - base: the Autobase instance (for operations like addWriter) | |
| 47 | * - IMPORTANT: Must parse JSON manually (no valueEncoding in Autobase) | |
| 48 | */ | |
| 49 | async function apply(nodes, view, base) { | |
| 50 | // Use batch for efficiency when processing multiple operations | |
| 51 | const b = view.batch({ update: false }) | |
| 52 | ||
| 53 | for (const node of nodes) { | |
| 54 | // Parse JSON manually (autobase doesn't use valueEncoding) | |
| 55 | const value = JSON.parse(node.value) | |
| 56 | ||
| 57 | // Handle writer management operations | |
| 58 | if (value.addWriter) { | |
| 59 | // Add writer to Autobase inputs using the host.addWriter() API | |
| 60 | const writerKey = b4a.from(value.addWriter, 'hex') | |
| 61 | await base.addWriter(writerKey, { indexer: true }) | |
| 62 | continue | |
| 63 | } | |
| 64 | ||
| 65 | if (value.removeWriter) { | |
| 66 | // Writer removal marker | |
| 67 | // Note: Autobase doesn't actually support removing inputs | |
| 68 | // This is just a marker that the device has been revoked | |
| 69 | // The device list manages the actual removal | |
| 70 | continue | |
| 71 | } | |
| 72 | ||
| 73 | // Handle transaction operations | |
| 74 | if (value.type === 'put-tx') { | |
| 75 | // Store value as JSON string | |
| 76 | await b.put(value.key, JSON.stringify(value.value)) | |
| 77 | } else if (value.type === 'del-tx') { | |
| 78 | await b.del(value.key) | |
| 79 | } | |
| 80 | ||
| 81 | // Handle address operations (Phase 6.2) | |
| 82 | if (value.type === 'put-address') { | |
| 83 | await b.put(value.key, JSON.stringify(value.value)) | |
| 84 | } else if (value.type === 'del-address') { | |
| 85 | await b.del(value.key) | |
| 86 | } | |
| 87 | ||
| 88 | // Handle UTXO operations (Phase 6.3) | |
| 89 | if (value.type === 'put-utxo') { | |
| 90 | await b.put(value.key, JSON.stringify(value.value)) | |
| 91 | } else if (value.type === 'del-utxo') { | |
| 92 | await b.del(value.key) | |
| 93 | } | |
| 94 | ||
| 95 | // Handle transaction storage (Phase 6.6) | |
| 96 | if (value.type === 'store-transaction') { | |
| 97 | await b.put(value.key, JSON.stringify(value.value)) | |
| 98 | } else if (value.type === 'delete-transaction') { | |
| 99 | await b.del(value.key) | |
| 100 | } | |
| 101 | ||
| 102 | // Handle contact storage | |
| 103 | if (value.type === 'put-contact') { | |
| 104 | await b.put(value.key, JSON.stringify(value.value)) | |
| 105 | } else if (value.type === 'del-contact') { | |
| 106 | await b.del(value.key) | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | await b.flush() | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * Initialize storage with Autobase multi-writer support | |
| 115 | * @param {Object} identity - keet-identity-key instance | |
| 116 | */ | |
| 117 | async function initializeStorage(identity) { | |
| 118 | // Return existing instance if already initialized | |
| 119 | if (store && autobase && txHistoryDb) { | |
| 120 | return { store, autobase, txHistoryDb, swarm } | |
| 121 | } | |
| 122 | ||
| 123 | // If initialization is in progress, wait for it | |
| 124 | if (initializationPromise) { | |
| 125 | console.log('⏳ Storage initialization in progress, waiting...') | |
| 126 | return await initializationPromise | |
| 127 | } | |
| 128 | ||
| 129 | // Start new initialization | |
| 130 | console.log('🔧 Initializing storage (singleton)') | |
| 131 | initializationPromise = _initializeStorage(identity) | |
| 132 | ||
| 133 | try { | |
| 134 | const result = await initializationPromise | |
| 135 | return result | |
| 136 | } finally { | |
| 137 | // Clear the promise after initialization completes (success or failure) | |
| 138 | initializationPromise = null | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | /** | |
| 143 | * Internal storage initialization (wrapped by singleton lock) | |
| 144 | */ | |
| 145 | async function _initializeStorage(identity) { | |
| 146 | // Store identity keypair for attestation | |
| 147 | identityKeyPair = identity.identityKeyPair | |
| 148 | ||
| 149 | // Initialize Corestore | |
| 150 | store = new Corestore(STORAGE_DIR) | |
| 151 | await store.ready() | |
| 152 | ||
| 153 | // View class for Autobase | |
| 154 | // Pattern from autobase-example | |
| 155 | class WalletView { | |
| 156 | open(store) { | |
| 157 | const core = store.get('view') | |
| 158 | // Use binary encoding - we'll handle JSON manually | |
| 159 | return new Hyperbee(core, { | |
| 160 | keyEncoding: 'utf-8', | |
| 161 | valueEncoding: 'binary' | |
| 162 | }) | |
| 163 | } | |
| 164 | ||
| 165 | async apply(nodes, view, base) { | |
| 166 | return apply(nodes, view, base) | |
| 167 | } | |
| 168 | ||
| 169 | close(view) { | |
| 170 | return view.close() | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | // Get or create namespace for this autobase | |
| 175 | const namespace = await Autobase.getLocalKey(store) | |
| 176 | ||
| 177 | // Initialize Autobase with Corestore | |
| 178 | // First param: Namespaced corestore | |
| 179 | // Second param: bootstrap key (null for new, or existing autobase key) | |
| 180 | // Third param: View instance with open/apply/close methods | |
| 181 | // Note: No valueEncoding - handle JSON manually | |
| 182 | autobase = new Autobase(store.namespace(namespace), null, new WalletView()) | |
| 183 | ||
| 184 | await autobase.ready() | |
| 185 | ||
| 186 | // Add existing devices as writers by appending operations | |
| 187 | // Autobase will process these operations and add the writers automatically | |
| 188 | const devices = loadDevices() | |
| 189 | for (const device of devices) { | |
| 190 | // Skip if this is the local writer (already added automatically) | |
| 191 | if (!b4a.equals(device.key, autobase.local.key)) { | |
| 192 | // Append addWriter operation - Autobase processes this internally | |
| 193 | const op = { addWriter: b4a.toString(device.key, 'hex') } | |
| 194 | await autobase.append(JSON.stringify(op)) | |
| 195 | } | |
| 196 | } | |
| 197 | ||
| 198 | // Wait for operations to be processed | |
| 199 | if (devices.length > 1) { | |
| 200 | await autobase.update() | |
| 201 | } | |
| 202 | ||
| 203 | // Get view as Hyperbee (already created by open()) | |
| 204 | txHistoryDb = autobase.view | |
| 205 | ||
| 206 | // Initialize Hyperswarm for P2P | |
| 207 | swarm = new Hyperswarm() | |
| 208 | ||
| 209 | // Track connections (Phase 6.7: Auto-replication enabled) | |
| 210 | swarm.on('connection', (conn, info) => { | |
| 211 | connections.add(conn) | |
| 212 | console.log(`📡 Connected to peer (${connections.size} connection${connections.size !== 1 ? 's' : ''})`) | |
| 213 | ||
| 214 | // Auto-replicate on all connections (Phase 6.7) | |
| 215 | // This enables real-time multi-device sync | |
| 216 | store.replicate(conn) | |
| 217 | ||
| 218 | // Handle disconnection | |
| 219 | conn.on('close', () => { | |
| 220 | connections.delete(conn) | |
| 221 | console.log(`📡 Peer disconnected (${connections.size} connection${connections.size !== 1 ? 's' : ''})`) | |
| 222 | }) | |
| 223 | }) | |
| 224 | ||
| 225 | // Join discovery key for this wallet | |
| 226 | const discoveryKey = crypto.discoveryKey(identityKeyPair.publicKey) | |
| 227 | const discovery = swarm.join(discoveryKey, { server: true, client: true }) | |
| 228 | ||
| 229 | // Flush in background - don't block initialization on DHT announcement | |
| 230 | discovery.flushed().then(() => { | |
| 231 | console.log('📡 DHT discovery announced') | |
| 232 | }).catch(err => { | |
| 233 | console.warn('⚠️ DHT discovery flush failed:', err.message) | |
| 234 | }) | |
| 235 | ||
| 236 | console.log('✅ Storage initialized successfully') | |
| 237 | return { store, autobase, txHistoryDb, swarm } | |
| 238 | } | |
| 239 | ||
| 240 | /** | |
| 241 | * Store a transaction in Autobase | |
| 242 | * Operations are appended to local input core and merged by Autobase | |
| 243 | */ | |
| 244 | async function storeTx(tx) { | |
| 245 | if (!autobase) { | |
| 246 | throw new Error('Storage not initialized') | |
| 247 | } | |
| 248 | ||
| 249 | // Append operation to Autobase | |
| 250 | // Note: No valueEncoding in Autobase - must JSON.stringify manually | |
| 251 | const op = { | |
| 252 | type: 'put-tx', | |
| 253 | key: `tx:${tx.timestamp}:${tx.hash}`, | |
| 254 | value: { | |
| 255 | hash: tx.hash, | |
| 256 | chain: tx.chain, | |
| 257 | to: tx.to, | |
| 258 | amount: tx.amount, | |
| 259 | fee: tx.fee, | |
| 260 | timestamp: tx.timestamp, | |
| 261 | status: tx.status || 'pending' | |
| 262 | } | |
| 263 | } | |
| 264 | ||
| 265 | await autobase.append(JSON.stringify(op)) | |
| 266 | ||
| 267 | // Wait for view to update | |
| 268 | await new Promise(resolve => setTimeout(resolve, 100)) | |
| 269 | } | |
| 270 | ||
| 271 | /** | |
| 272 | * Get transaction history from Hyperbee view | |
| 273 | */ | |
| 274 | async function getTxHistory(chain = null) { | |
| 275 | if (!txHistoryDb) { | |
| 276 | throw new Error('Storage not initialized') | |
| 277 | } | |
| 278 | ||
| 279 | const transactions = [] | |
| 280 | ||
| 281 | for await (const entry of txHistoryDb.createReadStream()) { | |
| 282 | // Parse JSON manually (binary encoding) | |
| 283 | const tx = JSON.parse(entry.value) | |
| 284 | ||
| 285 | if (chain && tx.chain !== chain) { | |
| 286 | continue | |
| 287 | } | |
| 288 | ||
| 289 | transactions.push(tx) | |
| 290 | } | |
| 291 | ||
| 292 | // Sort by timestamp (newest first) | |
| 293 | transactions.sort((a, b) => b.timestamp - a.timestamp) | |
| 294 | ||
| 295 | return transactions | |
| 296 | } | |
| 297 | ||
| 298 | /** | |
| 299 | * Get specific transaction by hash | |
| 300 | */ | |
| 301 | async function getTx(hash) { | |
| 302 | if (!txHistoryDb) { | |
| 303 | throw new Error('Storage not initialized') | |
| 304 | } | |
| 305 | ||
| 306 | for await (const entry of txHistoryDb.createReadStream()) { | |
| 307 | // Parse JSON manually (binary encoding) | |
| 308 | const tx = JSON.parse(entry.value) | |
| 309 | if (tx.hash === hash) { | |
| 310 | return tx | |
| 311 | } | |
| 312 | } | |
| 313 | ||
| 314 | return null | |
| 315 | } | |
| 316 | ||
| 317 | /** | |
| 318 | * Update transaction status | |
| 319 | */ | |
| 320 | async function updateTxStatus(hash, status) { | |
| 321 | const tx = await getTx(hash) | |
| 322 | if (!tx) return | |
| 323 | ||
| 324 | tx.status = status | |
| 325 | await storeTx(tx) | |
| 326 | } | |
| 327 | ||
| 328 | // ============================================================================ | |
| 329 | // WDK WALLET MANAGEMENT (Phase 6.2) | |
| 330 | // ============================================================================ | |
| 331 | ||
| 332 | /** | |
| 333 | * Initialize WDK wallets singleton | |
| 334 | * @param {Object} config - Configuration { testnet: boolean } | |
| 335 | * @returns {Object} - { wdk } - WDK instance with registered wallets | |
| 336 | */ | |
| 337 | async function initializeWallets(config = { testnet: true }) { | |
| 338 | if (wdkInstance) { | |
| 339 | return { wdk: wdkInstance } | |
| 340 | } | |
| 341 | ||
| 342 | const walletMnemonic = loadWalletMnemonic() | |
| 343 | if (!walletMnemonic) { | |
| 344 | throw new Error('Wallet mnemonic not found. Run: bare hyper-wallet-autobase.cjs init') | |
| 345 | } | |
| 346 | ||
| 347 | const { wdk } = await initializeWDK(walletMnemonic, config) | |
| 348 | ||
| 349 | wdkInstance = wdk | |
| 350 | return { wdk: wdkInstance } | |
| 351 | } | |
| 352 | ||
| 353 | /** | |
| 354 | * Store address in Autobase | |
| 355 | * @param {Object} addressData - { chain, network, address, path, index } | |
| 356 | */ | |
| 357 | async function storeAddress(addressData) { | |
| 358 | if (!autobase) { | |
| 359 | throw new Error('Storage not initialized') | |
| 360 | } | |
| 361 | ||
| 362 | const op = { | |
| 363 | type: 'put-address', | |
| 364 | key: `address:${addressData.chain}:${addressData.index}`, | |
| 365 | value: { | |
| 366 | chain: addressData.chain, | |
| 367 | network: addressData.network, | |
| 368 | address: addressData.address, | |
| 369 | path: addressData.path, | |
| 370 | index: addressData.index, | |
| 371 | label: addressData.label || '', // Pet-name/label for address | |
| 372 | createdAt: addressData.createdAt || Date.now(), | |
| 373 | device: getCurrentDevice()?.name || 'unknown', | |
| 374 | isArchived: addressData.isArchived || false, // For hiding old addresses | |
| 375 | isCurrent: addressData.isCurrent || false // Mark current receiving address | |
| 376 | } | |
| 377 | } | |
| 378 | ||
| 379 | await autobase.append(JSON.stringify(op)) | |
| 380 | ||
| 381 | // Wait for view to update | |
| 382 | await new Promise(resolve => setTimeout(resolve, 100)) | |
| 383 | } | |
| 384 | ||
| 385 | /** | |
| 386 | * Get all addresses for a chain | |
| 387 | * @param {string} chain - 'bitcoin' or 'ethereum' or 'all' | |
| 388 | * @returns {Array} - Array of address objects sorted by index | |
| 389 | */ | |
| 390 | async function getAddresses(chain) { | |
| 391 | if (!txHistoryDb) { | |
| 392 | throw new Error('Storage not initialized') | |
| 393 | } | |
| 394 | ||
| 395 | const addresses = [] | |
| 396 | const prefix = chain === 'all' ? 'address:' : `address:${chain}:` | |
| 397 | ||
| 398 | for await (const entry of txHistoryDb.createReadStream()) { | |
| 399 | if (entry.key.startsWith(prefix)) { | |
| 400 | addresses.push(JSON.parse(entry.value)) | |
| 401 | } | |
| 402 | } | |
| 403 | ||
| 404 | // For Ethereum, also include ERC-20 token addresses (they share the same address space) | |
| 405 | // When querying 'testnet-ethereum', also return 'testnet-ethereum-usdt' addresses | |
| 406 | if (!chain.includes('all') && (chain.includes('ethereum') && !chain.includes('-usdt'))) { | |
| 407 | const tokenPrefix = `${prefix}usdt:` | |
| 408 | for await (const entry of txHistoryDb.createReadStream()) { | |
| 409 | if (entry.key.startsWith(tokenPrefix)) { | |
| 410 | const addr = JSON.parse(entry.value) | |
| 411 | // Check if we already have this index (don't duplicate) | |
| 412 | if (!addresses.find(a => a.index === addr.index)) { | |
| 413 | addresses.push(addr) | |
| 414 | } | |
| 415 | } | |
| 416 | } | |
| 417 | } | |
| 418 | ||
| 419 | return addresses.sort((a, b) => a.index - b.index) | |
| 420 | } | |
| 421 | ||
| 422 | /** | |
| 423 | * Get next address index for a chain | |
| 424 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 425 | * @returns {number} - Next index to use (0 if no addresses exist) | |
| 426 | */ | |
| 427 | async function getNextAddressIndex(chain) { | |
| 428 | const addresses = await getAddresses(chain) | |
| 429 | if (addresses.length === 0) return 0 | |
| 430 | return Math.max(...addresses.map(a => a.index)) + 1 | |
| 431 | } | |
| 432 | ||
| 433 | /** | |
| 434 | * Get current (latest) address for a chain | |
| 435 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 436 | * @returns {Object|null} - Latest address or null if none exist | |
| 437 | */ | |
| 438 | async function getCurrentAddress(chain) { | |
| 439 | // Strip network prefix (testnet-ethereum-usdt -> ethereum-usdt) | |
| 440 | // Addresses are stored without network prefix | |
| 441 | const chainWithoutNetwork = chain.replace(/^(testnet|mainnet)-/, '') | |
| 442 | ||
| 443 | let addresses = await getAddresses(chainWithoutNetwork) | |
| 444 | ||
| 445 | // For ERC-20 tokens (like USDT), fall back to base Ethereum addresses | |
| 446 | // since they share the same address space | |
| 447 | if (addresses.length === 0 && chainWithoutNetwork.includes('-usdt')) { | |
| 448 | const baseChain = chainWithoutNetwork.replace('-usdt', '') | |
| 449 | addresses = await getAddresses(baseChain) | |
| 450 | } | |
| 451 | ||
| 452 | if (addresses.length === 0) return null | |
| 453 | ||
| 454 | // Find address marked as current, or return latest | |
| 455 | const current = addresses.find(a => a.isCurrent) | |
| 456 | return current || addresses[addresses.length - 1] | |
| 457 | } | |
| 458 | ||
| 459 | /** | |
| 460 | * Update address label | |
| 461 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 462 | * @param {number} index - Address index | |
| 463 | * @param {string} label - New label/pet-name | |
| 464 | */ | |
| 465 | async function updateAddressLabel(chain, index, label) { | |
| 466 | if (!autobase) { | |
| 467 | throw new Error('Storage not initialized') | |
| 468 | } | |
| 469 | ||
| 470 | // Get existing address | |
| 471 | const addresses = await getAddresses(chain) | |
| 472 | const existing = addresses.find(a => a.index === index) | |
| 473 | if (!existing) { | |
| 474 | throw new Error(`Address not found: ${chain}:${index}`) | |
| 475 | } | |
| 476 | ||
| 477 | // Update with new label | |
| 478 | await storeAddress({ | |
| 479 | ...existing, | |
| 480 | label | |
| 481 | }) | |
| 482 | ||
| 483 | return { ...existing, label } | |
| 484 | } | |
| 485 | ||
| 486 | /** | |
| 487 | * Set address as current receiving address | |
| 488 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 489 | * @param {number} index - Address index to set as current | |
| 490 | */ | |
| 491 | async function setCurrentAddress(chain, index) { | |
| 492 | if (!autobase) { | |
| 493 | throw new Error('Storage not initialized') | |
| 494 | } | |
| 495 | ||
| 496 | let addresses = await getAddresses(chain) | |
| 497 | ||
| 498 | // For ERC-20 tokens (like USDT), fall back to base Ethereum addresses | |
| 499 | // since they share the same address space | |
| 500 | let actualChain = chain | |
| 501 | if (addresses.length === 0 && chain.includes('-usdt')) { | |
| 502 | const baseChain = chain.replace('-usdt', '') | |
| 503 | addresses = await getAddresses(baseChain) | |
| 504 | actualChain = baseChain | |
| 505 | } | |
| 506 | ||
| 507 | // Find the address to set as current | |
| 508 | const targetAddr = addresses.find(a => a.index === index) | |
| 509 | if (!targetAddr) { | |
| 510 | throw new Error(`Address not found: ${actualChain}:${index}`) | |
| 511 | } | |
| 512 | ||
| 513 | // Update all addresses: unset current on others, set on target | |
| 514 | for (const addr of addresses) { | |
| 515 | if (addr.index === index) { | |
| 516 | await storeAddress({ ...addr, chain: actualChain, isCurrent: true }) | |
| 517 | } else if (addr.isCurrent) { | |
| 518 | await storeAddress({ ...addr, chain: actualChain, isCurrent: false }) | |
| 519 | } | |
| 520 | } | |
| 521 | ||
| 522 | return { ...targetAddr, chain: actualChain, isCurrent: true } | |
| 523 | } | |
| 524 | ||
| 525 | /** | |
| 526 | * Archive address (hide from UI) | |
| 527 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 528 | * @param {number} index - Address index | |
| 529 | */ | |
| 530 | async function archiveAddress(chain, index) { | |
| 531 | if (!autobase) { | |
| 532 | throw new Error('Storage not initialized') | |
| 533 | } | |
| 534 | ||
| 535 | const addresses = await getAddresses(chain) | |
| 536 | const existing = addresses.find(a => a.index === index) | |
| 537 | if (!existing) { | |
| 538 | throw new Error(`Address not found: ${chain}:${index}`) | |
| 539 | } | |
| 540 | ||
| 541 | await storeAddress({ | |
| 542 | ...existing, | |
| 543 | isArchived: true | |
| 544 | }) | |
| 545 | ||
| 546 | return { ...existing, isArchived: true } | |
| 547 | } | |
| 548 | ||
| 549 | // ============================================================================ | |
| 550 | // UTXO MANAGEMENT (Phase 6.3) | |
| 551 | // ============================================================================ | |
| 552 | ||
| 553 | /** | |
| 554 | * Store UTXO in Autobase | |
| 555 | * @param {Object} utxoData - { chain, txid, vout, address, amount, ... } | |
| 556 | */ | |
| 557 | async function storeUTXO(utxoData) { | |
| 558 | if (!autobase) { | |
| 559 | throw new Error('Storage not initialized') | |
| 560 | } | |
| 561 | ||
| 562 | const op = { | |
| 563 | type: 'put-utxo', | |
| 564 | key: `utxo:${utxoData.chain}:${utxoData.txid}:${utxoData.vout}`, | |
| 565 | value: utxoData | |
| 566 | } | |
| 567 | ||
| 568 | await autobase.append(JSON.stringify(op)) | |
| 569 | ||
| 570 | // Wait for view to update | |
| 571 | await new Promise(resolve => setTimeout(resolve, 100)) | |
| 572 | } | |
| 573 | ||
| 574 | /** | |
| 575 | * Get all UTXOs for a chain | |
| 576 | * @param {string} chain - 'bitcoin' or 'ethereum' or 'all' | |
| 577 | * @returns {Array} - Array of UTXO objects | |
| 578 | */ | |
| 579 | async function getUTXOs(chain) { | |
| 580 | if (!txHistoryDb) { | |
| 581 | throw new Error('Storage not initialized') | |
| 582 | } | |
| 583 | ||
| 584 | const utxos = [] | |
| 585 | const prefix = chain === 'all' ? 'utxo:' : `utxo:${chain}:` | |
| 586 | ||
| 587 | for await (const entry of txHistoryDb.createReadStream()) { | |
| 588 | if (entry.key.startsWith(prefix)) { | |
| 589 | utxos.push(JSON.parse(entry.value)) | |
| 590 | } | |
| 591 | } | |
| 592 | ||
| 593 | return utxos | |
| 594 | } | |
| 595 | ||
| 596 | /** | |
| 597 | * Update UTXO (e.g., mark as spent) | |
| 598 | * @param {string} chain - Chain identifier | |
| 599 | * @param {string} txid - Transaction ID | |
| 600 | * @param {number} vout - Output index | |
| 601 | * @param {Object} updates - Fields to update | |
| 602 | */ | |
| 603 | async function updateUTXO(chain, txid, vout, updates) { | |
| 604 | if (!autobase) { | |
| 605 | throw new Error('Storage not initialized') | |
| 606 | } | |
| 607 | ||
| 608 | // Get existing UTXO | |
| 609 | const key = `utxo:${chain}:${txid}:${vout}` | |
| 610 | const existing = await txHistoryDb.get(key) | |
| 611 | ||
| 612 | if (!existing) { | |
| 613 | throw new Error(`UTXO not found: ${key}`) | |
| 614 | } | |
| 615 | ||
| 616 | const utxoData = JSON.parse(existing.value) | |
| 617 | ||
| 618 | // Merge updates | |
| 619 | const updated = { | |
| 620 | ...utxoData, | |
| 621 | ...updates | |
| 622 | } | |
| 623 | ||
| 624 | const op = { | |
| 625 | type: 'put-utxo', | |
| 626 | key, | |
| 627 | value: updated | |
| 628 | } | |
| 629 | ||
| 630 | await autobase.append(JSON.stringify(op)) | |
| 631 | ||
| 632 | // Wait for view to update | |
| 633 | await new Promise(resolve => setTimeout(resolve, 100)) | |
| 634 | } | |
| 635 | ||
| 636 | /** | |
| 637 | * Delete UTXO from storage | |
| 638 | * @param {string} chain - Chain identifier | |
| 639 | * @param {string} txid - Transaction ID | |
| 640 | * @param {number} vout - Output index | |
| 641 | */ | |
| 642 | async function deleteUTXO(chain, txid, vout) { | |
| 643 | if (!autobase) { | |
| 644 | throw new Error('Storage not initialized') | |
| 645 | } | |
| 646 | ||
| 647 | const op = { | |
| 648 | type: 'del-utxo', | |
| 649 | key: `utxo:${chain}:${txid}:${vout}` | |
| 650 | } | |
| 651 | ||
| 652 | await autobase.append(JSON.stringify(op)) | |
| 653 | ||
| 654 | // Wait for view to update | |
| 655 | await new Promise(resolve => setTimeout(resolve, 100)) | |
| 656 | } | |
| 657 | ||
| 658 | // ============================================================================ | |
| 659 | // TRANSACTION STORAGE (Phase 6.6) | |
| 660 | // ============================================================================ | |
| 661 | ||
| 662 | /** | |
| 663 | * Store a transaction in Autobase | |
| 664 | * @param {Object} tx - Transaction details | |
| 665 | * @param {string} tx.hash - Transaction ID | |
| 666 | * @param {string} tx.chain - Chain (bitcoin, ethereum, etc) | |
| 667 | * @param {string} tx.direction - Direction (sent, received) | |
| 668 | * @param {string} tx.from - From address | |
| 669 | * @param {string} tx.to - To address | |
| 670 | * @param {bigint|number} tx.amount - Amount in smallest unit | |
| 671 | * @param {bigint|number} tx.fee - Fee in smallest unit | |
| 672 | * @param {string} tx.status - Status (pending, confirmed, failed) | |
| 673 | * @param {number} tx.timestamp - Unix timestamp | |
| 674 | * @param {number} tx.blockHeight - Block height (if confirmed) | |
| 675 | */ | |
| 676 | async function storeTransaction(tx) { | |
| 677 | if (!autobase) { | |
| 678 | throw new Error('Storage not initialized') | |
| 679 | } | |
| 680 | ||
| 681 | const transaction = { | |
| 682 | hash: tx.hash, | |
| 683 | chain: tx.chain, | |
| 684 | network: tx.network || 'testnet', // Add network field (testnet/mainnet) | |
| 685 | direction: tx.direction, | |
| 686 | from: tx.from, | |
| 687 | to: tx.to, | |
| 688 | amount: typeof tx.amount === 'string' ? tx.amount : String(tx.amount), // Ensure string | |
| 689 | fee: typeof tx.fee === 'string' ? tx.fee : String(tx.fee || 0), | |
| 690 | status: tx.status || 'pending', | |
| 691 | timestamp: tx.timestamp || Date.now(), | |
| 692 | blockHeight: tx.blockHeight || null | |
| 693 | } | |
| 694 | ||
| 695 | const op = { | |
| 696 | type: 'store-transaction', | |
| 697 | key: `tx:${tx.chain}:${tx.hash}`, | |
| 698 | value: transaction | |
| 699 | } | |
| 700 | ||
| 701 | await autobase.append(JSON.stringify(op)) | |
| 702 | ||
| 703 | // Wait for view to update | |
| 704 | await new Promise(resolve => setTimeout(resolve, 100)) | |
| 705 | } | |
| 706 | ||
| 707 | /** | |
| 708 | * Get all transactions for a chain | |
| 709 | * @param {string} chain - Chain name (bitcoin, ethereum, all) | |
| 710 | * @returns {Promise<Array>} - Array of transactions sorted by timestamp (newest first) | |
| 711 | */ | |
| 712 | async function getTransactions(chain = 'all') { | |
| 713 | if (!txHistoryDb) { | |
| 714 | throw new Error('Storage not initialized') | |
| 715 | } | |
| 716 | ||
| 717 | const transactions = [] | |
| 718 | const prefix = chain === 'all' ? 'tx:' : `tx:${chain}:` | |
| 719 | ||
| 720 | for await (const data of txHistoryDb.createReadStream({ gte: prefix, lt: prefix + '\xff' })) { | |
| 721 | try { | |
| 722 | const tx = JSON.parse(data.value) | |
| 723 | // Convert string amounts back to numbers for display | |
| 724 | tx.amount = BigInt(tx.amount) | |
| 725 | tx.fee = tx.fee ? BigInt(tx.fee) : 0n | |
| 726 | transactions.push(tx) | |
| 727 | } catch (err) { | |
| 728 | console.error('Error parsing transaction:', err.message) | |
| 729 | } | |
| 730 | } | |
| 731 | ||
| 732 | // Sort by timestamp, newest first | |
| 733 | transactions.sort((a, b) => b.timestamp - a.timestamp) | |
| 734 | ||
| 735 | return transactions | |
| 736 | } | |
| 737 | ||
| 738 | /** | |
| 739 | * Get a specific transaction by hash | |
| 740 | * @param {string} chain - Chain name | |
| 741 | * @param {string} hash - Transaction hash | |
| 742 | * @returns {Promise<Object|null>} - Transaction object or null | |
| 743 | */ | |
| 744 | async function getTransaction(chain, hash) { | |
| 745 | if (!txHistoryDb) { | |
| 746 | throw new Error('Storage not initialized') | |
| 747 | } | |
| 748 | ||
| 749 | try { | |
| 750 | const data = await txHistoryDb.get(`tx:${chain}:${hash}`) | |
| 751 | if (!data) return null | |
| 752 | const tx = JSON.parse(data.value) | |
| 753 | ||
| 754 | // Handle case where tx or its fields might be null/undefined | |
| 755 | if (!tx) { | |
| 756 | return null | |
| 757 | } | |
| 758 | ||
| 759 | // Safely convert amount and fee to BigInt | |
| 760 | tx.amount = tx.amount ? BigInt(tx.amount) : 0n | |
| 761 | tx.fee = tx.fee ? BigInt(tx.fee) : 0n | |
| 762 | return tx | |
| 763 | } catch (err) { | |
| 764 | if (err.code === 'LEVEL_NOT_FOUND') { | |
| 765 | return null | |
| 766 | } | |
| 767 | // Handle corrupted/malformed transaction data - treat as not found | |
| 768 | // The corrupted entry will be overwritten by the new correct entry | |
| 769 | if (err instanceof SyntaxError) { | |
| 770 | return null | |
| 771 | } | |
| 772 | throw err | |
| 773 | } | |
| 774 | } | |
| 775 | ||
| 776 | /** | |
| 777 | * Delete transaction (for repair/cleanup) | |
| 778 | * @param {string} chain - Chain name | |
| 779 | * @param {string} hash - Transaction hash | |
| 780 | */ | |
| 781 | async function deleteTransaction(chain, hash) { | |
| 782 | if (!autobase) { | |
| 783 | throw new Error('Storage not initialized') | |
| 784 | } | |
| 785 | ||
| 786 | const op = { | |
| 787 | type: 'delete-transaction', | |
| 788 | key: `tx:${chain}:${hash}` | |
| 789 | } | |
| 790 | ||
| 791 | await autobase.append(JSON.stringify(op)) | |
| 792 | ||
| 793 | // Wait longer for view to update and propagate delete | |
| 794 | await new Promise(resolve => setTimeout(resolve, 500)) | |
| 795 | } | |
| 796 | ||
| 797 | /** | |
| 798 | * Update transaction status (Phase 6.7) | |
| 799 | * @param {string} chain - Chain name | |
| 800 | * @param {string} hash - Transaction hash | |
| 801 | * @param {Object} updates - Fields to update (status, blockHeight, confirmations) | |
| 802 | */ | |
| 803 | async function updateTransactionStatus(chain, hash, updates) { | |
| 804 | if (!autobase) { | |
| 805 | throw new Error('Storage not initialized') | |
| 806 | } | |
| 807 | ||
| 808 | // Get existing transaction | |
| 809 | const existing = await getTransaction(chain, hash) | |
| 810 | if (!existing) { | |
| 811 | throw new Error('Transaction not found') | |
| 812 | } | |
| 813 | ||
| 814 | // Merge updates, ensuring BigInt values are converted to strings | |
| 815 | const updated = { | |
| 816 | hash: existing.hash, | |
| 817 | chain: existing.chain, | |
| 818 | direction: existing.direction, | |
| 819 | from: existing.from, | |
| 820 | to: existing.to, | |
| 821 | amount: existing.amount.toString(), | |
| 822 | fee: existing.fee ? existing.fee.toString() : '0', | |
| 823 | status: updates.status !== undefined ? updates.status : existing.status, | |
| 824 | timestamp: existing.timestamp, | |
| 825 | blockHeight: updates.blockHeight !== undefined ? updates.blockHeight : existing.blockHeight | |
| 826 | } | |
| 827 | ||
| 828 | const op = { | |
| 829 | type: 'store-transaction', | |
| 830 | key: `tx:${chain}:${hash}`, | |
| 831 | value: updated | |
| 832 | } | |
| 833 | ||
| 834 | await autobase.append(JSON.stringify(op)) | |
| 835 | ||
| 836 | // Wait for view to update | |
| 837 | await new Promise(resolve => setTimeout(resolve, 100)) | |
| 838 | } | |
| 839 | ||
| 840 | /** | |
| 841 | * Sync transactions from WDK to local Hyperbee storage | |
| 842 | * Fetches transaction history using Electrum for Bitcoin and provider for Ethereum | |
| 843 | * @param {Object} wdk - WDK instance | |
| 844 | * @param {Object} config - Configuration with chains to sync | |
| 845 | * @returns {Promise<Object>} - Sync results { synced, errors } | |
| 846 | */ | |
| 847 | async function syncTransactionsFromWDK(wdk, config = {}) { | |
| 848 | if (!wdk || !txHistoryDb) { | |
| 849 | throw new Error('WDK or storage not initialized') | |
| 850 | } | |
| 851 | ||
| 852 | const chains = config.chains || ['bitcoin', 'ethereum'] | |
| 853 | const results = { | |
| 854 | synced: 0, | |
| 855 | errors: [], | |
| 856 | chains: {} | |
| 857 | } | |
| 858 | ||
| 859 | for (const chain of chains) { | |
| 860 | try { | |
| 861 | // Determine base chain for WDK (ethereum-usdt -> ethereum) | |
| 862 | const baseChain = chain.replace(/-usdt$/, '') | |
| 863 | ||
| 864 | // Use explicit account index if provided, otherwise default to 0 | |
| 865 | // WDK design: Application must explicitly specify which account to query | |
| 866 | const accountIndex = config.accountIndex !== undefined ? config.accountIndex : 0 | |
| 867 | ||
| 868 | // Get WDK account for this chain at the correct index | |
| 869 | const account = await wdk.getAccount(baseChain, accountIndex) | |
| 870 | if (!account) { | |
| 871 | results.errors.push(`No account found for chain: ${chain}`) | |
| 872 | continue | |
| 873 | } | |
| 874 | ||
| 875 | // Get current address | |
| 876 | const currentAddress = await account.getAddress() | |
| 877 | ||
| 878 | let chainSynced = 0 | |
| 879 | let chainSkipped = 0 | |
| 880 | let transactions = [] | |
| 881 | ||
| 882 | // Chain-specific transaction fetching | |
| 883 | if (chain === 'bitcoin') { | |
| 884 | // Bitcoin: Use public WDK getTransfers() API (documented in wdk-wallet-btc) | |
| 885 | try { | |
| 886 | const transfers = await account.getTransfers({ | |
| 887 | direction: 'all', | |
| 888 | limit: 100 | |
| 889 | }) | |
| 890 | ||
| 891 | for (const transfer of transfers) { | |
| 892 | const existing = await getTransaction(chain, transfer.txid) | |
| 893 | if (existing) { | |
| 894 | chainSkipped++ | |
| 895 | continue | |
| 896 | } | |
| 897 | ||
| 898 | const tx = { | |
| 899 | hash: transfer.txid, | |
| 900 | chain, | |
| 901 | network: config.network || 'testnet', | |
| 902 | direction: transfer.direction, // 'incoming' or 'outgoing' | |
| 903 | from: transfer.direction === 'incoming' ? '(external)' : currentAddress, | |
| 904 | to: transfer.direction === 'incoming' ? currentAddress : (transfer.recipient || '(unknown)'), | |
| 905 | amount: String(transfer.value), | |
| 906 | fee: transfer.fee ? String(transfer.fee) : '0', | |
| 907 | status: transfer.height > 0 ? 'confirmed' : 'pending', | |
| 908 | timestamp: Date.now(), | |
| 909 | blockHeight: transfer.height || null | |
| 910 | } | |
| 911 | ||
| 912 | await storeTransaction(tx) | |
| 913 | chainSynced++ | |
| 914 | results.synced++ | |
| 915 | } | |
| 916 | } catch (err) { | |
| 917 | console.error(`Bitcoin sync error: ${err.message}`) | |
| 918 | results.errors.push(`${chain}: ${err.message}`) | |
| 919 | } | |
| 920 | } else if (baseChain === 'ethereum') { | |
| 921 | // Ethereum (including USDT): Fetch transactions from blockchain provider | |
| 922 | try { | |
| 923 | const config = getConfig() | |
| 924 | const network = config.network || 'testnet' | |
| 925 | ||
| 926 | // Determine if this is a token (USDT or USDC) or native ETH | |
| 927 | const isUSDT = chain.includes('-usdt') | |
| 928 | const isUSDC = chain.includes('-usdc') | |
| 929 | const isToken = isUSDT || isUSDC | |
| 930 | let providerTxs = [] | |
| 931 | let tokenSymbol = null | |
| 932 | ||
| 933 | if (isToken) { | |
| 934 | // Fetch ERC-20 token transactions | |
| 935 | console.log(` 📡 Fetching token transactions for ${chain}...`) | |
| 936 | console.log(` 📍 Address: ${currentAddress} (index ${accountIndex})`) | |
| 937 | ||
| 938 | // Token contract addresses | |
| 939 | let tokenContract | |
| 940 | if (isUSDT) { | |
| 941 | tokenSymbol = 'USDT' | |
| 942 | tokenContract = network === 'testnet' | |
| 943 | ? '0x7169D38820dfd117C3FA1f22a697dBA58d90BA06' // Sepolia testnet USDT | |
| 944 | : '0xdAC17F958D2ee523a2206206994597C13D831ec7' // Ethereum mainnet USDT | |
| 945 | } else if (isUSDC) { | |
| 946 | tokenSymbol = 'USDC' | |
| 947 | tokenContract = network === 'testnet' | |
| 948 | ? '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' // Sepolia testnet USDC (Circle official) | |
| 949 | : '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' // Ethereum mainnet USDC | |
| 950 | } | |
| 951 | console.log(` 🏦 ${tokenSymbol} Contract: ${tokenContract}`) | |
| 952 | ||
| 953 | if (!tokenContract) { | |
| 954 | throw new Error(`Token contract address not configured for ${chain} on ${network}`) | |
| 955 | } | |
| 956 | ||
| 957 | providerTxs = await fetchTokenTransactions(currentAddress, network, config, tokenContract) | |
| 958 | console.log(` 📊 API returned ${providerTxs.length} transactions`) | |
| 959 | } else { | |
| 960 | // Fetch native ETH transactions | |
| 961 | console.log(` 📡 Fetching ETH transactions for ${chain}...`) | |
| 962 | providerTxs = await fetchTransactions(baseChain, currentAddress, network, config) | |
| 963 | } | |
| 964 | ||
| 965 | for (const tx of providerTxs) { | |
| 966 | console.log(` 🔍 Checking tx: ${tx.hash.substring(0, 16)}...`) | |
| 967 | console.log(` From: ${tx.from?.substring(0, 10)}...`) | |
| 968 | console.log(` To: ${tx.to?.substring(0, 10)}...`) | |
| 969 | console.log(` Value: ${tx.value}`) | |
| 970 | if (tx.tokenSymbol) { | |
| 971 | console.log(` Token: ${tx.tokenSymbol}`) | |
| 972 | } | |
| 973 | ||
| 974 | // Check if already stored | |
| 975 | const existing = await getTransaction(chain, tx.hash) | |
| 976 | if (existing) { | |
| 977 | // Check if transaction data is corrupted (amount = 0 for token transfers) | |
| 978 | const isCorrupted = existing.amount === 0n && tx.value !== '0' | |
| 979 | if (isCorrupted) { | |
| 980 | console.log(` 🔧 REPAIRING corrupted tx: ${tx.hash.substring(0, 16)}... (amount was 0, should be ${tx.value})`) | |
| 981 | // Don't skip - will overwrite with correct data | |
| 982 | } else { | |
| 983 | console.log(` ⏭️ SKIPPED (already exists): ${tx.hash.substring(0, 16)}...`) | |
| 984 | chainSkipped++ | |
| 985 | continue | |
| 986 | } | |
| 987 | } | |
| 988 | ||
| 989 | // Determine direction | |
| 990 | const direction = tx.to.toLowerCase() === currentAddress.toLowerCase() ? 'received' : 'sent' | |
| 991 | console.log(` 💾 STORING (${direction}): ${tx.hash.substring(0, 16)}...`) | |
| 992 | ||
| 993 | // Store transaction | |
| 994 | const storedTx = { | |
| 995 | hash: tx.hash, | |
| 996 | chain, | |
| 997 | network, | |
| 998 | direction, | |
| 999 | from: tx.from, | |
| 1000 | to: tx.to, | |
| 1001 | amount: tx.value, | |
| 1002 | fee: tx.fee, | |
| 1003 | status: tx.status, | |
| 1004 | timestamp: tx.timestamp * 1000, // Convert to milliseconds | |
| 1005 | blockHeight: tx.blockNumber, | |
| 1006 | // Add token info if this is a token transfer | |
| 1007 | token: tokenSymbol || tx.tokenSymbol || null, | |
| 1008 | decimals: isToken ? 6 : 18 // USDT/USDC have 6 decimals, ETH has 18 | |
| 1009 | } | |
| 1010 | ||
| 1011 | await storeTransaction(storedTx) | |
| 1012 | chainSynced++ | |
| 1013 | results.synced++ | |
| 1014 | console.log(` ✅ Stored successfully`) | |
| 1015 | } | |
| 1016 | } catch (err) { | |
| 1017 | console.error(`Error fetching ${chain} transactions:`, err.message) | |
| 1018 | results.errors.push(`${chain}: ${err.message}`) | |
| 1019 | } | |
| 1020 | } | |
| 1021 | ||
| 1022 | results.chains[chain] = { | |
| 1023 | synced: chainSynced, | |
| 1024 | skipped: chainSkipped | |
| 1025 | } | |
| 1026 | ||
| 1027 | } catch (err) { | |
| 1028 | console.error(`Error syncing ${chain} transactions:`, err.message) | |
| 1029 | results.errors.push(`${chain}: ${err.message}`) | |
| 1030 | } | |
| 1031 | } | |
| 1032 | ||
| 1033 | return results | |
| 1034 | } | |
| 1035 | ||
| 1036 | // ============================================================================ | |
| 1037 | // DEVICE MANAGEMENT | |
| 1038 | // ============================================================================ | |
| 1039 | ||
| 1040 | /** | |
| 1041 | * Load device list from file | |
| 1042 | */ | |
| 1043 | function loadDevices() { | |
| 1044 | if (!fs.existsSync(DEVICES_FILE)) { | |
| 1045 | return [] | |
| 1046 | } | |
| 1047 | ||
| 1048 | const data = fs.readFileSync(DEVICES_FILE, 'utf8') | |
| 1049 | const devices = JSON.parse(data) | |
| 1050 | ||
| 1051 | // Convert hex strings back to Buffers | |
| 1052 | return devices.map(d => ({ | |
| 1053 | ...d, | |
| 1054 | key: b4a.from(d.key, 'hex'), | |
| 1055 | proof: d.proof ? b4a.from(d.proof, 'hex') : null | |
| 1056 | })) | |
| 1057 | } | |
| 1058 | ||
| 1059 | /** | |
| 1060 | * Save device list to file | |
| 1061 | */ | |
| 1062 | function saveDevices(devices) { | |
| 1063 | const dir = path.dirname(DEVICES_FILE) | |
| 1064 | if (!fs.existsSync(dir)) { | |
| 1065 | fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) | |
| 1066 | } | |
| 1067 | ||
| 1068 | // Convert Buffers to hex strings for JSON | |
| 1069 | const serializable = devices.map(d => ({ | |
| 1070 | ...d, | |
| 1071 | key: b4a.toString(d.key, 'hex'), | |
| 1072 | proof: d.proof ? b4a.toString(d.proof, 'hex') : null | |
| 1073 | })) | |
| 1074 | ||
| 1075 | fs.writeFileSync(DEVICES_FILE, JSON.stringify(serializable, null, 2), { mode: 0o600 }) | |
| 1076 | } | |
| 1077 | ||
| 1078 | // ============================================================================ | |
| 1079 | // CONTACT MANAGEMENT | |
| 1080 | // ============================================================================ | |
| 1081 | ||
| 1082 | /** | |
| 1083 | * Add a contact to the address book | |
| 1084 | * @param {Object} contact - Contact details | |
| 1085 | * @param {string} contact.address - Blockchain address | |
| 1086 | * @param {string} contact.chain - Chain name (bitcoin, ethereum) | |
| 1087 | * @param {string} contact.name - Display name | |
| 1088 | * @param {string} [contact.notes] - Optional notes | |
| 1089 | * @returns {Promise<Object>} - Created contact with ID | |
| 1090 | */ | |
| 1091 | async function addContact({ address, chain, name, notes = '' }) { | |
| 1092 | if (!autobase) { | |
| 1093 | throw new Error('Storage not initialized') | |
| 1094 | } | |
| 1095 | ||
| 1096 | // Generate unique ID for contact | |
| 1097 | const id = crypto.randomBytes(16).toString('hex') | |
| 1098 | ||
| 1099 | const contact = { | |
| 1100 | id, | |
| 1101 | address, | |
| 1102 | chain, | |
| 1103 | name, | |
| 1104 | notes, | |
| 1105 | addedAt: Date.now(), | |
| 1106 | addedBy: getCurrentDevice()?.key ? b4a.toString(getCurrentDevice().key, 'hex').slice(0, 16) : 'unknown', | |
| 1107 | lastUsedAt: null, | |
| 1108 | metadata: { | |
| 1109 | favorite: false, | |
| 1110 | category: null | |
| 1111 | } | |
| 1112 | } | |
| 1113 | ||
| 1114 | const op = { | |
| 1115 | type: 'put-contact', | |
| 1116 | key: `contact:${id}`, | |
| 1117 | value: contact | |
| 1118 | } | |
| 1119 | ||
| 1120 | await autobase.append(JSON.stringify(op)) | |
| 1121 | ||
| 1122 | // Wait for view to update | |
| 1123 | await new Promise(resolve => setTimeout(resolve, 100)) | |
| 1124 | ||
| 1125 | return contact | |
| 1126 | } | |
| 1127 | ||
| 1128 | /** | |
| 1129 | * Get all contacts, optionally filtered by chain | |
| 1130 | * @param {string} [chain] - Chain name to filter by (optional) | |
| 1131 | * @returns {Promise<Array>} - Array of contacts sorted by name | |
| 1132 | */ | |
| 1133 | /** | |
| 1134 | * Migrate a contact to new multi-address format with IDs | |
| 1135 | * @param {Object} contact - Contact to migrate | |
| 1136 | * @returns {Object} - Migrated contact | |
| 1137 | */ | |
| 1138 | function migrateContactFormat(contact) { | |
| 1139 | // Already in new format with proper IDs - check if ALL addresses have IDs | |
| 1140 | if (contact.addresses && Array.isArray(contact.addresses)) { | |
| 1141 | // Check if any address is missing an ID | |
| 1142 | const needsMigration = contact.addresses.some(addr => !addr.id) | |
| 1143 | ||
| 1144 | if (!needsMigration) { | |
| 1145 | // All addresses have IDs, no migration needed | |
| 1146 | return contact | |
| 1147 | } | |
| 1148 | ||
| 1149 | // Some addresses missing IDs - add them | |
| 1150 | const migratedAddresses = contact.addresses.map(addr => { | |
| 1151 | if (!addr.id) { | |
| 1152 | return { | |
| 1153 | ...addr, | |
| 1154 | id: crypto.randomBytes(8).toString('hex'), | |
| 1155 | network: addr.network || getConfig().network || 'testnet', | |
| 1156 | label: addr.label || '', | |
| 1157 | addedAt: addr.addedAt || Date.now() | |
| 1158 | } | |
| 1159 | } | |
| 1160 | return addr | |
| 1161 | }) | |
| 1162 | ||
| 1163 | return { | |
| 1164 | ...contact, | |
| 1165 | addresses: migratedAddresses | |
| 1166 | } | |
| 1167 | } | |
| 1168 | ||
| 1169 | // Old single-address format - migrate to multi-address | |
| 1170 | if (contact.address && contact.chain) { | |
| 1171 | const config = getConfig() | |
| 1172 | const defaultNetwork = config.network || 'testnet' | |
| 1173 | ||
| 1174 | return { | |
| 1175 | ...contact, | |
| 1176 | addresses: [{ | |
| 1177 | id: crypto.randomBytes(8).toString('hex'), | |
| 1178 | chain: contact.chain, | |
| 1179 | address: contact.address, | |
| 1180 | network: contact.network || defaultNetwork, | |
| 1181 | label: '', | |
| 1182 | addedAt: contact.addedAt || Date.now() | |
| 1183 | }], | |
| 1184 | // Remove old fields | |
| 1185 | address: undefined, | |
| 1186 | chain: undefined | |
| 1187 | } | |
| 1188 | } | |
| 1189 | ||
| 1190 | // Contact with no addresses - return as is | |
| 1191 | return contact | |
| 1192 | } | |
| 1193 | ||
| 1194 | async function getContacts(chain = null) { | |
| 1195 | if (!txHistoryDb) { | |
| 1196 | throw new Error('Storage not initialized') | |
| 1197 | } | |
| 1198 | ||
| 1199 | const contacts = [] | |
| 1200 | const prefix = 'contact:' | |
| 1201 | ||
| 1202 | for await (const data of txHistoryDb.createReadStream({ gte: prefix, lt: prefix + '\xff' })) { | |
| 1203 | try { | |
| 1204 | let contact = JSON.parse(data.value) | |
| 1205 | ||
| 1206 | // Migrate contact format if needed | |
| 1207 | contact = migrateContactFormat(contact) | |
| 1208 | ||
| 1209 | // Filter by chain if specified (check both old and new format) | |
| 1210 | if (chain) { | |
| 1211 | // New format: check if any address matches chain | |
| 1212 | if (contact.addresses && Array.isArray(contact.addresses)) { | |
| 1213 | const hasChain = contact.addresses.some(addr => addr.chain === chain) | |
| 1214 | if (!hasChain) continue | |
| 1215 | } else if (contact.chain && contact.chain !== chain) { | |
| 1216 | // Old format fallback | |
| 1217 | continue | |
| 1218 | } | |
| 1219 | } | |
| 1220 | ||
| 1221 | contacts.push(contact) | |
| 1222 | } catch (err) { | |
| 1223 | console.error('Error parsing contact:', err.message) | |
| 1224 | } | |
| 1225 | } | |
| 1226 | ||
| 1227 | // Sort by name (case-insensitive) | |
| 1228 | contacts.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) | |
| 1229 | ||
| 1230 | return contacts | |
| 1231 | } | |
| 1232 | ||
| 1233 | /** | |
| 1234 | * Find a contact by name or address | |
| 1235 | * @param {string} nameOrAddress - Contact name or blockchain address | |
| 1236 | * @param {string} [chain] - Chain to search (optional, makes search faster) | |
| 1237 | * @returns {Promise<Object|null>} - Contact object or null | |
| 1238 | */ | |
| 1239 | async function findContact(nameOrAddress, chain = null) { | |
| 1240 | const contacts = await getContacts(chain) | |
| 1241 | ||
| 1242 | // Try exact name match first (case-insensitive) | |
| 1243 | const byName = contacts.find(c => | |
| 1244 | c.name.toLowerCase() === nameOrAddress.toLowerCase() | |
| 1245 | ) | |
| 1246 | if (byName) return byName | |
| 1247 | ||
| 1248 | // Try exact address match (check in addresses array) | |
| 1249 | const byAddress = contacts.find(c => { | |
| 1250 | if (c.addresses && Array.isArray(c.addresses)) { | |
| 1251 | return c.addresses.some(addr => | |
| 1252 | addr.address.toLowerCase() === nameOrAddress.toLowerCase() | |
| 1253 | ) | |
| 1254 | } | |
| 1255 | return false | |
| 1256 | }) | |
| 1257 | if (byAddress) return byAddress | |
| 1258 | ||
| 1259 | // Try partial name match | |
| 1260 | const byPartial = contacts.find(c => | |
| 1261 | c.name.toLowerCase().includes(nameOrAddress.toLowerCase()) | |
| 1262 | ) | |
| 1263 | ||
| 1264 | return byPartial || null | |
| 1265 | } | |
| 1266 | ||
| 1267 | /** | |
| 1268 | * Get a contact by ID | |
| 1269 | * @param {string} id - Contact ID | |
| 1270 | * @returns {Promise<Object|null>} - Contact object or null | |
| 1271 | */ | |
| 1272 | async function getContactById(id) { | |
| 1273 | if (!txHistoryDb) { | |
| 1274 | throw new Error('Storage not initialized') | |
| 1275 | } | |
| 1276 | ||
| 1277 | try { | |
| 1278 | const data = await txHistoryDb.get(`contact:${id}`) | |
| 1279 | if (!data) return null | |
| 1280 | ||
| 1281 | let contact = JSON.parse(data.value) | |
| 1282 | ||
| 1283 | // Migrate contact format if needed | |
| 1284 | contact = migrateContactFormat(contact) | |
| 1285 | ||
| 1286 | return contact | |
| 1287 | } catch (err) { | |
| 1288 | if (err.code === 'LEVEL_NOT_FOUND') { | |
| 1289 | return null | |
| 1290 | } | |
| 1291 | throw err | |
| 1292 | } | |
| 1293 | } | |
| 1294 | ||
| 1295 | /** | |
| 1296 | * Update a contact | |
| 1297 | * @param {string} id - Contact ID | |
| 1298 | * @param {Object} updates - Fields to update | |
| 1299 | * @returns {Promise<Object>} - Updated contact | |
| 1300 | */ | |
| 1301 | async function updateContact(id, updates) { | |
| 1302 | if (!autobase) { | |
| 1303 | throw new Error('Storage not initialized') | |
| 1304 | } | |
| 1305 | ||
| 1306 | const existing = await getContactById(id) | |
| 1307 | if (!existing) { | |
| 1308 | throw new Error('Contact not found') | |
| 1309 | } | |
| 1310 | ||
| 1311 | const updated = { | |
| 1312 | ...existing, | |
| 1313 | ...updates, | |
| 1314 | id: existing.id, // Preserve ID | |
| 1315 | addedAt: existing.addedAt, // Preserve creation time | |
| 1316 | modifiedAt: Date.now() | |
| 1317 | } | |
| 1318 | ||
| 1319 | const op = { | |
| 1320 | type: 'put-contact', | |
| 1321 | key: `contact:${id}`, | |
| 1322 | value: updated | |
| 1323 | } | |
| 1324 | ||
| 1325 | await autobase.append(JSON.stringify(op)) | |
| 1326 | ||
| 1327 | // Wait for view to update | |
| 1328 | await new Promise(resolve => setTimeout(resolve, 100)) | |
| 1329 | ||
| 1330 | return updated | |
| 1331 | } | |
| 1332 | ||
| 1333 | /** | |
| 1334 | * Delete a contact | |
| 1335 | * @param {string} id - Contact ID | |
| 1336 | */ | |
| 1337 | async function deleteContact(id) { | |
| 1338 | if (!autobase) { | |
| 1339 | throw new Error('Storage not initialized') | |
| 1340 | } | |
| 1341 | ||
| 1342 | const op = { | |
| 1343 | type: 'del-contact', | |
| 1344 | key: `contact:${id}` | |
| 1345 | } | |
| 1346 | ||
| 1347 | await autobase.append(JSON.stringify(op)) | |
| 1348 | ||
| 1349 | // Wait for view to update | |
| 1350 | await new Promise(resolve => setTimeout(resolve, 100)) | |
| 1351 | } | |
| 1352 | ||
| 1353 | /** | |
| 1354 | * Find contact by address (helper for transaction display) | |
| 1355 | * UPDATED: Supports both old (single address) and new (multiple addresses) format | |
| 1356 | * @param {string} address - Blockchain address | |
| 1357 | * @param {string} chain - Chain name | |
| 1358 | * @returns {Promise<Object|null>} - Contact or null | |
| 1359 | */ | |
| 1360 | async function findContactByAddress(address, chain) { | |
| 1361 | const contacts = await getContacts() | |
| 1362 | ||
| 1363 | return contacts.find(c => { | |
| 1364 | // Old format: contact.address | |
| 1365 | if (c.address && c.address.toLowerCase() === address.toLowerCase() && c.chain === chain) { | |
| 1366 | return true | |
| 1367 | } | |
| 1368 | ||
| 1369 | // New format: contact.addresses[] | |
| 1370 | if (c.addresses && Array.isArray(c.addresses)) { | |
| 1371 | return c.addresses.some(addr => | |
| 1372 | addr.chain === chain && addr.address.toLowerCase() === address.toLowerCase() | |
| 1373 | ) | |
| 1374 | } | |
| 1375 | ||
| 1376 | return false | |
| 1377 | }) || null | |
| 1378 | } | |
| 1379 | ||
| 1380 | /** | |
| 1381 | * Add an address to an existing contact (supports multiple addresses per chain) | |
| 1382 | * @param {string} contactId - Contact ID | |
| 1383 | * @param {Object} addressInfo - Address details | |
| 1384 | * @param {string} addressInfo.chain - Chain name (bitcoin, ethereum, polygon, etc.) | |
| 1385 | * @param {string} addressInfo.address - Blockchain address | |
| 1386 | * @param {string} addressInfo.network - Network (testnet, mainnet, sepolia, etc.) | |
| 1387 | * @param {string} [addressInfo.label] - Optional label (e.g., "Hardware wallet", "Hot wallet") | |
| 1388 | * @returns {Promise<Object>} - Updated contact | |
| 1389 | */ | |
| 1390 | async function addAddressToContact(contactId, { chain, address, network, label = '' }) { | |
| 1391 | if (!autobase) { | |
| 1392 | throw new Error('Storage not initialized') | |
| 1393 | } | |
| 1394 | ||
| 1395 | const existing = await getContactById(contactId) | |
| 1396 | if (!existing) { | |
| 1397 | throw new Error('Contact not found') | |
| 1398 | } | |
| 1399 | ||
| 1400 | // Migrate old format to new format if needed | |
| 1401 | let addresses = [] | |
| 1402 | if (existing.addresses && Array.isArray(existing.addresses)) { | |
| 1403 | addresses = existing.addresses | |
| 1404 | } else if (existing.address && existing.chain) { | |
| 1405 | // Convert old single-address format (add ID to migrated address) | |
| 1406 | addresses = [{ | |
| 1407 | id: crypto.randomBytes(8).toString('hex'), | |
| 1408 | chain: existing.chain, | |
| 1409 | address: existing.address, | |
| 1410 | network: existing.network || network, | |
| 1411 | label: '' | |
| 1412 | }] | |
| 1413 | } | |
| 1414 | ||
| 1415 | // Check if EXACT same address already exists (same address + same chain) | |
| 1416 | const exactMatch = addresses.find(a => | |
| 1417 | a.chain === chain && a.address.toLowerCase() === address.toLowerCase() | |
| 1418 | ) | |
| 1419 | if (exactMatch) { | |
| 1420 | throw new Error(`This exact address already exists for ${chain}`) | |
| 1421 | } | |
| 1422 | ||
| 1423 | // Generate unique ID for this address | |
| 1424 | const addressId = crypto.randomBytes(8).toString('hex') | |
| 1425 | ||
| 1426 | // Add new address with unique ID | |
| 1427 | addresses.push({ | |
| 1428 | id: addressId, | |
| 1429 | chain, | |
| 1430 | address, | |
| 1431 | network, | |
| 1432 | label: label || '', | |
| 1433 | addedAt: Date.now() | |
| 1434 | }) | |
| 1435 | ||
| 1436 | // Update contact with new addresses array | |
| 1437 | const updated = { | |
| 1438 | ...existing, | |
| 1439 | addresses, | |
| 1440 | // Remove old format fields | |
| 1441 | address: undefined, | |
| 1442 | chain: undefined, | |
| 1443 | modifiedAt: Date.now() | |
| 1444 | } | |
| 1445 | ||
| 1446 | const op = { | |
| 1447 | type: 'put-contact', | |
| 1448 | key: `contact:${contactId}`, | |
| 1449 | value: updated | |
| 1450 | } | |
| 1451 | ||
| 1452 | await autobase.append(JSON.stringify(op)) | |
| 1453 | ||
| 1454 | // Wait for view to update | |
| 1455 | await new Promise(resolve => setTimeout(resolve, 100)) | |
| 1456 | ||
| 1457 | return updated | |
| 1458 | } | |
| 1459 | ||
| 1460 | /** | |
| 1461 | * Remove an address from a contact by address ID | |
| 1462 | * @param {string} contactId - Contact ID | |
| 1463 | * @param {string} addressId - Address ID to remove | |
| 1464 | * @returns {Promise<Object>} - Updated contact | |
| 1465 | */ | |
| 1466 | async function removeAddressFromContact(contactId, addressId) { | |
| 1467 | if (!autobase) { | |
| 1468 | throw new Error('Storage not initialized') | |
| 1469 | } | |
| 1470 | ||
| 1471 | const existing = await getContactById(contactId) | |
| 1472 | if (!existing) { | |
| 1473 | throw new Error('Contact not found') | |
| 1474 | } | |
| 1475 | ||
| 1476 | // Migrate old format to new format if needed | |
| 1477 | let addresses = [] | |
| 1478 | if (!existing.addresses || !Array.isArray(existing.addresses)) { | |
| 1479 | // Old format: single address | |
| 1480 | if (existing.address && existing.chain) { | |
| 1481 | // Get default network from config for migration | |
| 1482 | const config = getConfig() | |
| 1483 | const defaultNetwork = config.network || 'testnet' | |
| 1484 | ||
| 1485 | addresses = [{ | |
| 1486 | id: crypto.randomBytes(8).toString('hex'), | |
| 1487 | chain: existing.chain, | |
| 1488 | address: existing.address, | |
| 1489 | network: existing.network || defaultNetwork, | |
| 1490 | label: '', | |
| 1491 | addedAt: existing.addedAt || Date.now() | |
| 1492 | }] | |
| 1493 | } else { | |
| 1494 | throw new Error('Contact has no addresses') | |
| 1495 | } | |
| 1496 | } else { | |
| 1497 | addresses = existing.addresses | |
| 1498 | } | |
| 1499 | ||
| 1500 | // Filter out the address with this ID | |
| 1501 | const newAddresses = addresses.filter(a => a.id !== addressId) | |
| 1502 | ||
| 1503 | if (newAddresses.length === addresses.length) { | |
| 1504 | throw new Error(`No address found with ID: ${addressId}`) | |
| 1505 | } | |
| 1506 | ||
| 1507 | if (newAddresses.length === 0) { | |
| 1508 | throw new Error('Cannot remove last address. Delete contact instead.') | |
| 1509 | } | |
| 1510 | ||
| 1511 | const updated = { | |
| 1512 | ...existing, | |
| 1513 | addresses: newAddresses, | |
| 1514 | // Remove old format fields | |
| 1515 | address: undefined, | |
| 1516 | chain: undefined, | |
| 1517 | modifiedAt: Date.now() | |
| 1518 | } | |
| 1519 | ||
| 1520 | const op = { | |
| 1521 | type: 'put-contact', | |
| 1522 | key: `contact:${contactId}`, | |
| 1523 | value: updated | |
| 1524 | } | |
| 1525 | ||
| 1526 | await autobase.append(JSON.stringify(op)) | |
| 1527 | ||
| 1528 | // Wait for view to update | |
| 1529 | await new Promise(resolve => setTimeout(resolve, 100)) | |
| 1530 | ||
| 1531 | return updated | |
| 1532 | } | |
| 1533 | ||
| 1534 | /** | |
| 1535 | * Bootstrap initial device (this device) | |
| 1536 | * Creates attestation proof for the first device | |
| 1537 | */ | |
| 1538 | async function bootstrapDevice(identity, deviceName) { | |
| 1539 | if (!autobase) { | |
| 1540 | throw new Error('Storage not initialized') | |
| 1541 | } | |
| 1542 | ||
| 1543 | // With Autobase, the local writer is autobase.local | |
| 1544 | const localWriter = autobase.local | |
| 1545 | await localWriter.ready() | |
| 1546 | ||
| 1547 | // Create bootstrap proof (self-attestation) | |
| 1548 | const proof = await identity.bootstrap(localWriter.key) | |
| 1549 | ||
| 1550 | const device = { | |
| 1551 | name: deviceName, | |
| 1552 | key: localWriter.key, | |
| 1553 | identityKey: identityKeyPair.publicKey, // Shared identity key | |
| 1554 | proof: proof, | |
| 1555 | addedAt: Date.now(), | |
| 1556 | isLocal: true | |
| 1557 | } | |
| 1558 | ||
| 1559 | const devices = loadDevices() | |
| 1560 | ||
| 1561 | // Check if already bootstrapped | |
| 1562 | const existing = devices.find(d => b4a.equals(d.key, localWriter.key)) | |
| 1563 | if (existing) { | |
| 1564 | return existing | |
| 1565 | } | |
| 1566 | ||
| 1567 | devices.push(device) | |
| 1568 | saveDevices(devices) | |
| 1569 | ||
| 1570 | return device | |
| 1571 | } | |
| 1572 | ||
| 1573 | /** | |
| 1574 | * Create invite for another device to join | |
| 1575 | * Returns invite code that includes discovery key and autobase bootstrap key | |
| 1576 | */ | |
| 1577 | async function createInvite(identity) { | |
| 1578 | if (!identityKeyPair) { | |
| 1579 | throw new Error('Identity not loaded') | |
| 1580 | } | |
| 1581 | ||
| 1582 | if (!autobase) { | |
| 1583 | throw new Error('Storage not initialized') | |
| 1584 | } | |
| 1585 | ||
| 1586 | const now = Date.now() | |
| 1587 | const INVITE_EXPIRATION = 15 * 60 * 1000 // 15 minutes | |
| 1588 | ||
| 1589 | // Create invite data (without signature first) | |
| 1590 | const inviteData = { | |
| 1591 | discoveryKey: b4a.toString(crypto.discoveryKey(identityKeyPair.publicKey), 'hex'), | |
| 1592 | identityKey: b4a.toString(identityKeyPair.publicKey, 'hex'), | |
| 1593 | autobaseKey: b4a.toString(autobase.key, 'hex'), | |
| 1594 | timestamp: now, | |
| 1595 | expiresAt: now + INVITE_EXPIRATION | |
| 1596 | } | |
| 1597 | ||
| 1598 | // Sign the invite data with identity secret key | |
| 1599 | const message = b4a.from(JSON.stringify(inviteData)) | |
| 1600 | const signature = crypto.sign(message, identityKeyPair.secretKey) | |
| 1601 | ||
| 1602 | // Add signature to invite | |
| 1603 | const signedInvite = { | |
| 1604 | ...inviteData, | |
| 1605 | signature: b4a.toString(signature, 'hex') | |
| 1606 | } | |
| 1607 | ||
| 1608 | // Encode as base64 | |
| 1609 | const inviteStr = b4a.toString(b4a.from(JSON.stringify(signedInvite)), 'base64') | |
| 1610 | ||
| 1611 | console.log(`✅ Created signed invite (expires in 15 minutes)`) | |
| 1612 | return inviteStr | |
| 1613 | } | |
| 1614 | ||
| 1615 | /** | |
| 1616 | * Join wallet using invite code | |
| 1617 | * Receives mnemonic from primary device over secure Protomux channel | |
| 1618 | * Initializes Autobase with bootstrap key from invite | |
| 1619 | * | |
| 1620 | * NOTE: This reinitializes storage! Close existing storage first. | |
| 1621 | * @param {string} inviteCode - Invite code from primary device | |
| 1622 | * @param {string} deviceName - Name for this device | |
| 1623 | * @returns {Object} { device, mnemonic } - Device info and received mnemonic | |
| 1624 | */ | |
| 1625 | async function joinWallet(inviteCode, deviceName) { | |
| 1626 | // Decode invite | |
| 1627 | let invite | |
| 1628 | try { | |
| 1629 | const inviteJson = b4a.toString(b4a.from(inviteCode, 'base64')) | |
| 1630 | invite = JSON.parse(inviteJson) | |
| 1631 | } catch (err) { | |
| 1632 | throw new Error('Invalid invite code format') | |
| 1633 | } | |
| 1634 | ||
| 1635 | // Verify invite has required fields | |
| 1636 | if (!invite.discoveryKey || !invite.identityKey || !invite.autobaseKey || | |
| 1637 | !invite.timestamp || !invite.expiresAt || !invite.signature) { | |
| 1638 | throw new Error('Invalid invite: missing required fields') | |
| 1639 | } | |
| 1640 | ||
| 1641 | // Check expiration (15 minutes) | |
| 1642 | const now = Date.now() | |
| 1643 | if (now > invite.expiresAt) { | |
| 1644 | const expirationAge = Math.floor((now - invite.expiresAt) / 1000 / 60) // minutes ago | |
| 1645 | throw new Error(`Invite code expired ${expirationAge} minutes ago. Request a new invite.`) | |
| 1646 | } | |
| 1647 | ||
| 1648 | // Verify signature | |
| 1649 | const { signature, ...inviteData } = invite | |
| 1650 | const message = b4a.from(JSON.stringify(inviteData)) | |
| 1651 | const sig = b4a.from(signature, 'hex') | |
| 1652 | const pubKey = b4a.from(invite.identityKey, 'hex') | |
| 1653 | ||
| 1654 | if (!crypto.verify(message, sig, pubKey)) { | |
| 1655 | throw new Error('Invalid invite signature - invite may be forged or corrupted') | |
| 1656 | } | |
| 1657 | ||
| 1658 | console.log('✅ Invite signature verified') | |
| 1659 | console.log(`⏰ Invite expires in ${Math.floor((invite.expiresAt - now) / 1000 / 60)} minutes`) | |
| 1660 | ||
| 1661 | // Initialize Corestore for temporary writer key | |
| 1662 | if (!store) { | |
| 1663 | store = new Corestore(STORAGE_DIR) | |
| 1664 | await store.ready() | |
| 1665 | } | |
| 1666 | ||
| 1667 | // Get namespace | |
| 1668 | const namespace = await Autobase.getLocalKey(store) | |
| 1669 | ||
| 1670 | // View class for Autobase | |
| 1671 | class WalletView { | |
| 1672 | open(store) { | |
| 1673 | const core = store.get('view') | |
| 1674 | return new Hyperbee(core, { | |
| 1675 | keyEncoding: 'utf-8', | |
| 1676 | valueEncoding: 'binary' | |
| 1677 | }) | |
| 1678 | } | |
| 1679 | ||
| 1680 | async apply(nodes, view, base) { | |
| 1681 | return apply(nodes, view, base) | |
| 1682 | } | |
| 1683 | ||
| 1684 | close(view) { | |
| 1685 | return view.close() | |
| 1686 | } | |
| 1687 | } | |
| 1688 | ||
| 1689 | // Initialize Autobase with BOOTSTRAP KEY from invite | |
| 1690 | const bootstrapKey = b4a.from(invite.autobaseKey, 'hex') | |
| 1691 | autobase = new Autobase(store.namespace(namespace), bootstrapKey, new WalletView()) | |
| 1692 | await autobase.ready() | |
| 1693 | ||
| 1694 | // Get our local writer key (to send to primary device) | |
| 1695 | const localWriterKey = autobase.local.key | |
| 1696 | ||
| 1697 | // Get view | |
| 1698 | txHistoryDb = autobase.view | |
| 1699 | ||
| 1700 | // Initialize Hyperswarm for pairing | |
| 1701 | if (!swarm) { | |
| 1702 | swarm = new Hyperswarm() | |
| 1703 | } | |
| 1704 | ||
| 1705 | console.log('📡 Connecting to primary device...') | |
| 1706 | ||
| 1707 | // Set up connection handler BEFORE joining swarm (avoid race condition) | |
| 1708 | const { conn, identityMnemonic, walletMnemonic } = await new Promise((resolve, reject) => { | |
| 1709 | let timeout = null | |
| 1710 | ||
| 1711 | const handler = async (conn) => { | |
| 1712 | try { | |
| 1713 | if (timeout) clearTimeout(timeout) | |
| 1714 | swarm.off('connection', handler) | |
| 1715 | ||
| 1716 | console.log('✅ Connected to primary device') | |
| 1717 | console.log('🔐 Starting pairing protocol...') | |
| 1718 | ||
| 1719 | // Receive mnemonics via pairing protocol | |
| 1720 | const { identityMnemonic, walletMnemonic } = await createPairingClient(conn, localWriterKey, deviceName) | |
| 1721 | ||
| 1722 | // Start replication AFTER pairing completes | |
| 1723 | store.replicate(conn) | |
| 1724 | ||
| 1725 | resolve({ conn, identityMnemonic, walletMnemonic }) | |
| 1726 | } catch (err) { | |
| 1727 | if (timeout) clearTimeout(timeout) | |
| 1728 | reject(err) | |
| 1729 | } | |
| 1730 | } | |
| 1731 | ||
| 1732 | swarm.on('connection', handler) | |
| 1733 | ||
| 1734 | // Timeout after 60 seconds | |
| 1735 | timeout = setTimeout(() => { | |
| 1736 | swarm.off('connection', handler) | |
| 1737 | reject(new Error('Connection timeout')) | |
| 1738 | }, 60000) | |
| 1739 | ||
| 1740 | // Join discovery key AFTER setting up listener (avoid race condition) | |
| 1741 | const discoveryKey = b4a.from(invite.discoveryKey, 'hex') | |
| 1742 | swarm.join(discoveryKey, { server: true, client: true }).flushed().catch(reject) | |
| 1743 | }) | |
| 1744 | ||
| 1745 | console.log('✅ Received wallet mnemonics') | |
| 1746 | console.log('') | |
| 1747 | ||
| 1748 | // Create identity from received identity mnemonic | |
| 1749 | const identity = await IdentityKey.from({ mnemonic: identityMnemonic }) | |
| 1750 | identityKeyPair = identity.identityKeyPair | |
| 1751 | ||
| 1752 | // Create bootstrap proof for this device | |
| 1753 | const proof = await identity.bootstrap(localWriterKey) | |
| 1754 | ||
| 1755 | // Save local device info | |
| 1756 | const device = { | |
| 1757 | name: deviceName, | |
| 1758 | key: localWriterKey, | |
| 1759 | publicKey: b4a.toString(identityKeyPair.publicKey, 'hex'), | |
| 1760 | proof: proof, | |
| 1761 | addedAt: Date.now(), | |
| 1762 | isLocal: true | |
| 1763 | } | |
| 1764 | ||
| 1765 | const devices = [device] | |
| 1766 | saveDevices(devices) | |
| 1767 | ||
| 1768 | // NOTE: Don't try to add ourselves as a writer here - we can't append until | |
| 1769 | // we're already a writer (chicken-egg problem). The primary device handles | |
| 1770 | // adding our writer via the addDevice function during pairing. | |
| 1771 | ||
| 1772 | return { device, identityMnemonic, walletMnemonic } | |
| 1773 | } | |
| 1774 | ||
| 1775 | /** | |
| 1776 | * Add another device's writer to Autobase | |
| 1777 | * Called after attestation is verified | |
| 1778 | */ | |
| 1779 | async function addDevice(deviceKey, deviceName, proof) { | |
| 1780 | const remoteWriter = store.get(deviceKey) | |
| 1781 | await remoteWriter.ready() | |
| 1782 | ||
| 1783 | // Add to Autobase by appending addWriter operation | |
| 1784 | const op = { addWriter: b4a.toString(deviceKey, 'hex') } | |
| 1785 | await autobase.append(JSON.stringify(op)) | |
| 1786 | ||
| 1787 | // Save to device list | |
| 1788 | const devices = loadDevices() | |
| 1789 | devices.push({ | |
| 1790 | name: deviceName, | |
| 1791 | key: deviceKey, | |
| 1792 | proof: proof, | |
| 1793 | addedAt: Date.now(), | |
| 1794 | isLocal: false | |
| 1795 | }) | |
| 1796 | saveDevices(devices) | |
| 1797 | } | |
| 1798 | ||
| 1799 | /** | |
| 1800 | * Get current device info | |
| 1801 | */ | |
| 1802 | function getCurrentDevice() { | |
| 1803 | if (!autobase) return null | |
| 1804 | ||
| 1805 | const devices = loadDevices() | |
| 1806 | return devices.find(d => d.isLocal && b4a.equals(d.key, autobase.local.key)) | |
| 1807 | } | |
| 1808 | ||
| 1809 | /** | |
| 1810 | * List all devices | |
| 1811 | */ | |
| 1812 | function listDevices() { | |
| 1813 | return loadDevices() | |
| 1814 | } | |
| 1815 | ||
| 1816 | /** | |
| 1817 | * Remove device (revoke access) | |
| 1818 | * Note: Autobase doesn't have removeInput, so we append a removeWriter operation | |
| 1819 | */ | |
| 1820 | async function removeDevice(deviceKey) { | |
| 1821 | if (!autobase) { | |
| 1822 | throw new Error('Storage not initialized') | |
| 1823 | } | |
| 1824 | ||
| 1825 | let devices = loadDevices() | |
| 1826 | ||
| 1827 | const device = devices.find(d => b4a.equals(d.key, deviceKey)) | |
| 1828 | if (!device) { | |
| 1829 | throw new Error('Device not found') | |
| 1830 | } | |
| 1831 | ||
| 1832 | // Prevent removing local device | |
| 1833 | if (device.isLocal) { | |
| 1834 | throw new Error('Cannot remove local device') | |
| 1835 | } | |
| 1836 | ||
| 1837 | // Append removeWriter operation - Autobase will process this | |
| 1838 | // Note: Autobase doesn't have built-in removeInput, so we use custom operation | |
| 1839 | const op = { | |
| 1840 | removeWriter: b4a.toString(deviceKey, 'hex'), | |
| 1841 | timestamp: Date.now() | |
| 1842 | } | |
| 1843 | await autobase.append(JSON.stringify(op)) | |
| 1844 | ||
| 1845 | // Wait for operation to be processed | |
| 1846 | await autobase.update() | |
| 1847 | await new Promise(resolve => setTimeout(resolve, 500)) | |
| 1848 | ||
| 1849 | // Remove from device list | |
| 1850 | devices = devices.filter(d => !b4a.equals(d.key, deviceKey)) | |
| 1851 | saveDevices(devices) | |
| 1852 | } | |
| 1853 | ||
| 1854 | /** | |
| 1855 | * Deduplicate devices in the device list | |
| 1856 | * Removes duplicate entries keeping only the most recent one per writer key | |
| 1857 | */ | |
| 1858 | function deduplicateDevices() { | |
| 1859 | let devices = loadDevices() | |
| 1860 | const seen = new Map() | |
| 1861 | const deduplicated = [] | |
| 1862 | ||
| 1863 | // Sort by addedAt (newest first) | |
| 1864 | devices.sort((a, b) => b.addedAt - a.addedAt) | |
| 1865 | ||
| 1866 | for (const device of devices) { | |
| 1867 | const writerKeyHex = b4a.toString(device.key, 'hex') | |
| 1868 | ||
| 1869 | if (!seen.has(writerKeyHex)) { | |
| 1870 | seen.set(writerKeyHex, true) | |
| 1871 | deduplicated.push(device) | |
| 1872 | } | |
| 1873 | } | |
| 1874 | ||
| 1875 | // Sort back by addedAt (oldest first) for consistent ordering | |
| 1876 | deduplicated.sort((a, b) => a.addedAt - b.addedAt) | |
| 1877 | ||
| 1878 | const removed = devices.length - deduplicated.length | |
| 1879 | ||
| 1880 | if (removed > 0) { | |
| 1881 | saveDevices(deduplicated) | |
| 1882 | } | |
| 1883 | ||
| 1884 | return { removed, total: deduplicated.length } | |
| 1885 | } | |
| 1886 | ||
| 1887 | /** | |
| 1888 | * Get connection status | |
| 1889 | */ | |
| 1890 | function getConnectionStatus() { | |
| 1891 | return { | |
| 1892 | connected: connections.size > 0, | |
| 1893 | count: connections.size, | |
| 1894 | connections: Array.from(connections).map(conn => ({ | |
| 1895 | remotePublicKey: conn.remotePublicKey ? b4a.toString(conn.remotePublicKey, 'hex').slice(0, 16) + '...' : 'unknown' | |
| 1896 | })) | |
| 1897 | } | |
| 1898 | } | |
| 1899 | ||
| 1900 | /** | |
| 1901 | * Wait for incoming connections (for accepting join requests) | |
| 1902 | * Returns when a new device connects | |
| 1903 | * @param {number} timeout - Timeout in ms (Infinity = wait forever) | |
| 1904 | */ | |
| 1905 | async function waitForConnection(timeout = 30000) { | |
| 1906 | return new Promise((resolve, reject) => { | |
| 1907 | let timer = null | |
| 1908 | ||
| 1909 | // Only set timeout if not infinite | |
| 1910 | if (timeout !== Infinity && timeout > 0) { | |
| 1911 | timer = setTimeout(() => { | |
| 1912 | swarm.off('connection', handler) | |
| 1913 | reject(new Error('Timeout waiting for connection')) | |
| 1914 | }, timeout) | |
| 1915 | } | |
| 1916 | ||
| 1917 | const handler = (conn) => { | |
| 1918 | if (timer) clearTimeout(timer) | |
| 1919 | swarm.off('connection', handler) | |
| 1920 | resolve(conn) | |
| 1921 | } | |
| 1922 | ||
| 1923 | swarm.on('connection', handler) | |
| 1924 | }) | |
| 1925 | } | |
| 1926 | ||
| 1927 | /** | |
| 1928 | * Wait for a new device to connect and auto-approve it | |
| 1929 | * Sends mnemonics to joining device over secure Protomux channel | |
| 1930 | * @param {number} timeout - Timeout in ms (0 = infinite) | |
| 1931 | * @param {string} identityMnemonic - Identity mnemonic to send to joining device | |
| 1932 | * @param {string} walletMnemonic - Wallet mnemonic to send to joining device | |
| 1933 | * @returns {Object} { writerKey, deviceName } | |
| 1934 | */ | |
| 1935 | async function waitForNewDevice(timeout = 120000, identityMnemonic = null, walletMnemonic = null) { | |
| 1936 | if (!identityMnemonic || !walletMnemonic) { | |
| 1937 | throw new Error('Both identity and wallet mnemonics required to pair new device') | |
| 1938 | } | |
| 1939 | ||
| 1940 | // Get current device info | |
| 1941 | const currentDevice = getCurrentDevice() | |
| 1942 | const myDeviceName = currentDevice ? currentDevice.name : 'Primary Device' | |
| 1943 | ||
| 1944 | // Wait for connection (infinite if timeout=0) | |
| 1945 | const actualTimeout = timeout === 0 ? Infinity : timeout | |
| 1946 | const conn = await waitForConnection(actualTimeout) | |
| 1947 | ||
| 1948 | console.log('✅ Device connected, exchanging pairing information...') | |
| 1949 | ||
| 1950 | // Start pairing server protocol - this will send BOTH mnemonics | |
| 1951 | const { remoteDeviceName, remoteWriterKey } = await createPairingServer( | |
| 1952 | conn, | |
| 1953 | identityMnemonic, | |
| 1954 | walletMnemonic, | |
| 1955 | myDeviceName | |
| 1956 | ) | |
| 1957 | ||
| 1958 | console.log(`🔍 Received device info: ${remoteDeviceName}`) | |
| 1959 | console.log(` Writer: ${b4a.toString(remoteWriterKey, 'hex').slice(0, 16)}...`) | |
| 1960 | console.log('') | |
| 1961 | ||
| 1962 | // Start replication AFTER pairing protocol completes | |
| 1963 | store.replicate(conn) | |
| 1964 | ||
| 1965 | // Auto-approve the writer | |
| 1966 | console.log('🔐 Auto-approving writer...') | |
| 1967 | ||
| 1968 | // Append addWriter operation | |
| 1969 | const op = { | |
| 1970 | addWriter: b4a.toString(remoteWriterKey, 'hex'), | |
| 1971 | timestamp: Date.now() | |
| 1972 | } | |
| 1973 | await autobase.append(JSON.stringify(op)) | |
| 1974 | await autobase.update() | |
| 1975 | ||
| 1976 | // Wait for operation to be processed | |
| 1977 | await new Promise(resolve => setTimeout(resolve, 1000)) | |
| 1978 | ||
| 1979 | // Check for duplicate writer key (prevent re-adding same device) | |
| 1980 | const devices = loadDevices() | |
| 1981 | const writerKeyHex = b4a.toString(remoteWriterKey, 'hex') | |
| 1982 | const existing = devices.find(d => b4a.toString(d.key, 'hex') === writerKeyHex) | |
| 1983 | ||
| 1984 | if (existing) { | |
| 1985 | console.log(`⚠️ Device already exists: ${existing.name} (writer: ${writerKeyHex.slice(0, 16)}...)`) | |
| 1986 | console.log(' Skipping duplicate entry') | |
| 1987 | return { writerKey: remoteWriterKey, deviceName: remoteDeviceName, isDuplicate: true } | |
| 1988 | } | |
| 1989 | ||
| 1990 | // Get identity key (shared across all devices using same identity mnemonic) | |
| 1991 | const identity = await IdentityKey.from({ mnemonic: identityMnemonic }) | |
| 1992 | const identityKey = identity.identityKeyPair.publicKey | |
| 1993 | ||
| 1994 | // Save to device list with both keys | |
| 1995 | devices.push({ | |
| 1996 | name: remoteDeviceName, | |
| 1997 | key: remoteWriterKey, | |
| 1998 | identityKey: identityKey, // Shared identity key | |
| 1999 | proof: null, | |
| 2000 | addedAt: Date.now(), | |
| 2001 | isLocal: false | |
| 2002 | }) | |
| 2003 | saveDevices(devices) | |
| 2004 | ||
| 2005 | return { writerKey: remoteWriterKey, deviceName: remoteDeviceName, isDuplicate: false } | |
| 2006 | } | |
| 2007 | ||
| 2008 | /** | |
| 2009 | * Save pending writer to file with attestation proof | |
| 2010 | * @param {Buffer} writerKey - The writer key | |
| 2011 | * @param {Buffer} proof - Optional attestation proof | |
| 2012 | */ | |
| 2013 | function savePendingWriter(writerKey, proof = null) { | |
| 2014 | const dir = path.dirname(PENDING_FILE) | |
| 2015 | if (!fs.existsSync(dir)) { | |
| 2016 | fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) | |
| 2017 | } | |
| 2018 | ||
| 2019 | const pending = loadPendingWriters() | |
| 2020 | const keyHex = b4a.toString(writerKey, 'hex') | |
| 2021 | ||
| 2022 | // Check if already exists | |
| 2023 | const existing = pending.find(p => p.key === keyHex) | |
| 2024 | if (!existing) { | |
| 2025 | pending.push({ | |
| 2026 | key: keyHex, | |
| 2027 | proof: proof ? b4a.toString(proof, 'hex') : null, | |
| 2028 | addedAt: Date.now() | |
| 2029 | }) | |
| 2030 | } | |
| 2031 | ||
| 2032 | fs.writeFileSync(PENDING_FILE, JSON.stringify(pending, null, 2), { mode: 0o600 }) | |
| 2033 | } | |
| 2034 | ||
| 2035 | /** | |
| 2036 | * Load pending writers from file | |
| 2037 | * Returns array of {key, proof, addedAt} objects | |
| 2038 | */ | |
| 2039 | function loadPendingWriters() { | |
| 2040 | if (!fs.existsSync(PENDING_FILE)) { | |
| 2041 | return [] | |
| 2042 | } | |
| 2043 | ||
| 2044 | const data = fs.readFileSync(PENDING_FILE, 'utf8') | |
| 2045 | return JSON.parse(data) | |
| 2046 | } | |
| 2047 | ||
| 2048 | /** | |
| 2049 | * Remove a pending writer from the file | |
| 2050 | */ | |
| 2051 | function removePendingWriter(writerKey) { | |
| 2052 | const pending = loadPendingWriters() | |
| 2053 | const keyHex = b4a.toString(writerKey, 'hex') | |
| 2054 | const filtered = pending.filter(p => p.key !== keyHex) | |
| 2055 | ||
| 2056 | if (filtered.length === 0) { | |
| 2057 | // Remove file if empty | |
| 2058 | if (fs.existsSync(PENDING_FILE)) { | |
| 2059 | fs.unlinkSync(PENDING_FILE) | |
| 2060 | } | |
| 2061 | } else { | |
| 2062 | fs.writeFileSync(PENDING_FILE, JSON.stringify(filtered, null, 2), { mode: 0o600 }) | |
| 2063 | } | |
| 2064 | } | |
| 2065 | ||
| 2066 | /** | |
| 2067 | * Get list of pending writers (connected but not approved) | |
| 2068 | * Returns array of {key, proof} objects with Buffers | |
| 2069 | */ | |
| 2070 | function getPendingWriters() { | |
| 2071 | const pendingData = loadPendingWriters() | |
| 2072 | ||
| 2073 | // Convert hex strings back to Buffers | |
| 2074 | return pendingData.map(p => ({ | |
| 2075 | key: b4a.from(p.key, 'hex'), | |
| 2076 | proof: p.proof ? b4a.from(p.proof, 'hex') : null, | |
| 2077 | addedAt: p.addedAt | |
| 2078 | })) | |
| 2079 | } | |
| 2080 | ||
| 2081 | /** | |
| 2082 | * Approve a pending writer (add to Autobase) | |
| 2083 | * @param {Buffer} writerKey - The writer key to approve | |
| 2084 | * @param {string} deviceName - Optional name for the device | |
| 2085 | * @param {boolean} skipVerification - Skip attestation verification (for testing) | |
| 2086 | */ | |
| 2087 | async function approveWriter(writerKey, deviceName = 'Unknown Device', skipVerification = false) { | |
| 2088 | if (!autobase) { | |
| 2089 | throw new Error('Storage not initialized') | |
| 2090 | } | |
| 2091 | ||
| 2092 | // Check if already added | |
| 2093 | const devices = loadDevices() | |
| 2094 | const existing = devices.find(d => b4a.equals(d.key, writerKey)) | |
| 2095 | if (existing) { | |
| 2096 | throw new Error('Writer already approved') | |
| 2097 | } | |
| 2098 | ||
| 2099 | console.log('🔍 Approving writer:', b4a.toString(writerKey, 'hex').slice(0, 16) + '...') | |
| 2100 | ||
| 2101 | // Get attestation proof from pending writers | |
| 2102 | const pending = getPendingWriters() | |
| 2103 | const pendingWriter = pending.find(p => b4a.equals(p.key, writerKey)) | |
| 2104 | ||
| 2105 | let proof = null | |
| 2106 | if (pendingWriter && pendingWriter.proof) { | |
| 2107 | proof = pendingWriter.proof | |
| 2108 | ||
| 2109 | // Verify attestation proof | |
| 2110 | if (!skipVerification) { | |
| 2111 | console.log('🔐 Verifying attestation proof...') | |
| 2112 | ||
| 2113 | try { | |
| 2114 | const verified = IdentityKey.verify(proof) | |
| 2115 | ||
| 2116 | if (!verified) { | |
| 2117 | throw new Error('Attestation verification failed') | |
| 2118 | } | |
| 2119 | ||
| 2120 | console.log('✅ Attestation verified') | |
| 2121 | } catch (err) { | |
| 2122 | console.log('❌ Attestation verification failed:', err.message) | |
| 2123 | console.log('') | |
| 2124 | console.log('⚠️ WARNING: Proof verification failed!') | |
| 2125 | console.log(' This device may not be authorized.') | |
| 2126 | console.log('') | |
| 2127 | console.log('To approve anyway (UNSAFE), set skipVerification=true') | |
| 2128 | console.log('') | |
| 2129 | throw new Error('Attestation verification failed') | |
| 2130 | } | |
| 2131 | } else { | |
| 2132 | console.log('⚠️ Skipping attestation verification (unsafe mode)') | |
| 2133 | } | |
| 2134 | } else { | |
| 2135 | console.log('⚠️ No attestation proof found') | |
| 2136 | console.log(' Continuing without verification (Phase 5.4 limitation)') | |
| 2137 | } | |
| 2138 | ||
| 2139 | // Append addWriter operation - Autobase processes this internally | |
| 2140 | const op = { | |
| 2141 | addWriter: b4a.toString(writerKey, 'hex'), | |
| 2142 | timestamp: Date.now() | |
| 2143 | } | |
| 2144 | await autobase.append(JSON.stringify(op)) | |
| 2145 | ||
| 2146 | // Wait for operation to be processed | |
| 2147 | await autobase.update() | |
| 2148 | ||
| 2149 | console.log('✅ Writer added to Autobase') | |
| 2150 | ||
| 2151 | // Wait for view to update | |
| 2152 | await new Promise(resolve => setTimeout(resolve, 1000)) | |
| 2153 | ||
| 2154 | // Save to device list | |
| 2155 | devices.push({ | |
| 2156 | name: deviceName, | |
| 2157 | key: writerKey, | |
| 2158 | proof: proof, | |
| 2159 | addedAt: Date.now(), | |
| 2160 | isLocal: false | |
| 2161 | }) | |
| 2162 | saveDevices(devices) | |
| 2163 | ||
| 2164 | // Remove from pending list | |
| 2165 | removePendingWriter(writerKey) | |
| 2166 | ||
| 2167 | console.log('✅ Device approved:', deviceName) | |
| 2168 | ||
| 2169 | return writerKey | |
| 2170 | } | |
| 2171 | ||
| 2172 | /** | |
| 2173 | * Enable replication for device sync mode | |
| 2174 | * Call this to start replicating on new connections | |
| 2175 | */ | |
| 2176 | function enableReplication() { | |
| 2177 | if (!swarm || !store) { | |
| 2178 | throw new Error('Storage not initialized') | |
| 2179 | } | |
| 2180 | ||
| 2181 | // Add replication handler for new connections | |
| 2182 | swarm.on('connection', (conn) => { | |
| 2183 | store.replicate(conn) | |
| 2184 | }) | |
| 2185 | ||
| 2186 | // Replicate on existing connections | |
| 2187 | for (const conn of connections) { | |
| 2188 | store.replicate(conn) | |
| 2189 | } | |
| 2190 | } | |
| 2191 | ||
| 2192 | /** | |
| 2193 | * Close storage and network | |
| 2194 | */ | |
| 2195 | async function closeStorage() { | |
| 2196 | if (swarm) { | |
| 2197 | await swarm.destroy() | |
| 2198 | swarm = null | |
| 2199 | } | |
| 2200 | if (txHistoryDb) { | |
| 2201 | await txHistoryDb.close() | |
| 2202 | txHistoryDb = null | |
| 2203 | } | |
| 2204 | if (autobase) { | |
| 2205 | await autobase.close() | |
| 2206 | autobase = null | |
| 2207 | } | |
| 2208 | if (store) { | |
| 2209 | await store.close() | |
| 2210 | store = null | |
| 2211 | } | |
| 2212 | connections.clear() | |
| 2213 | } | |
| 2214 | ||
| 2215 | module.exports = { | |
| 2216 | initializeStorage, | |
| 2217 | storeTx, | |
| 2218 | getTxHistory, | |
| 2219 | getTx, | |
| 2220 | updateTxStatus, | |
| 2221 | // WDK wallet functions (Phase 6.2) | |
| 2222 | initializeWallets, | |
| 2223 | storeAddress, | |
| 2224 | getAddresses, | |
| 2225 | getNextAddressIndex, | |
| 2226 | getCurrentAddress, | |
| 2227 | updateAddressLabel, | |
| 2228 | setCurrentAddress, | |
| 2229 | archiveAddress, | |
| 2230 | // UTXO functions (Phase 6.3) | |
| 2231 | storeUTXO, | |
| 2232 | getUTXOs, | |
| 2233 | updateUTXO, | |
| 2234 | deleteUTXO, | |
| 2235 | // Transaction storage (Phase 6.6) | |
| 2236 | storeTransaction, | |
| 2237 | getTransactions, | |
| 2238 | getTransaction, | |
| 2239 | deleteTransaction, | |
| 2240 | // Transaction status updates (Phase 6.7) | |
| 2241 | updateTransactionStatus, | |
| 2242 | // Transaction sync | |
| 2243 | syncTransactionsFromWDK, | |
| 2244 | // Device management | |
| 2245 | bootstrapDevice, | |
| 2246 | createInvite, | |
| 2247 | joinWallet, | |
| 2248 | addDevice, | |
| 2249 | getCurrentDevice, | |
| 2250 | listDevices, | |
| 2251 | removeDevice, | |
| 2252 | deduplicateDevices, | |
| 2253 | getConnectionStatus, | |
| 2254 | waitForNewDevice, | |
| 2255 | getPendingWriters, | |
| 2256 | approveWriter, | |
| 2257 | enableReplication, | |
| 2258 | // Contact management | |
| 2259 | addContact, | |
| 2260 | getContacts, | |
| 2261 | findContact, | |
| 2262 | getContactById, | |
| 2263 | updateContact, | |
| 2264 | deleteContact, | |
| 2265 | findContactByAddress, | |
| 2266 | // NEW: Multi-address contact support | |
| 2267 | addAddressToContact, | |
| 2268 | removeAddressFromContact, | |
| 2269 | closeStorage | |
| 2270 | } |
| … | … | @@ -0,0 +1,214 @@ |
| 1 | // lib/wdk-integration.cjs - WDK Integration Layer | |
| 2 | // CRITICAL: Load bare-node-runtime FIRST before any other requires | |
| 3 | require('bare-node-runtime/global') | |
| 4 | ||
| 5 | const fs = require('bare-fs') | |
| 6 | const path = require('bare-path') | |
| 7 | const os = require('bare-os') | |
| 8 | ||
| 9 | // Import WDK packages directly | |
| 10 | // bare-node-runtime/global provides Node.js compatibility for Bare runtime | |
| 11 | const WDK = require('@tetherto/wdk') | |
| 12 | const WalletManagerBtc = require('@tetherto/wdk-wallet-btc') | |
| 13 | const WalletManagerEvm = require('@tetherto/wdk-wallet-evm') | |
| 14 | ||
| 15 | // Import encryption module | |
| 16 | const { encrypt, decrypt, isValidFormat } = require('./encryption.cjs') | |
| 17 | ||
| 18 | // Storage paths | |
| 19 | const STORAGE_DIR = path.join(os.homedir(), '.hyper-wallet') | |
| 20 | const WALLET_MNEMONIC_FILE = path.join(STORAGE_DIR, '.wallet-mnemonic') | |
| 21 | ||
| 22 | /** | |
| 23 | * Generate new wallet mnemonic (12 words) | |
| 24 | * Uses WDK.getRandomSeedPhrase() - official WDK method | |
| 25 | * @returns {string} - 12-word BIP-39 mnemonic | |
| 26 | */ | |
| 27 | function generateWalletMnemonic() { | |
| 28 | return WDK.default.getRandomSeedPhrase() | |
| 29 | } | |
| 30 | ||
| 31 | /** | |
| 32 | * Save wallet mnemonic to file (with password-based encryption) | |
| 33 | * Stored separately from identity mnemonic for security separation | |
| 34 | * @param {string} mnemonic - 12-word mnemonic | |
| 35 | * @param {string} password - Encryption password (min 8 chars) | |
| 36 | */ | |
| 37 | function saveWalletMnemonic(mnemonic, password) { | |
| 38 | if (!fs.existsSync(STORAGE_DIR)) { | |
| 39 | fs.mkdirSync(STORAGE_DIR, { recursive: true, mode: 0o700 }) | |
| 40 | } | |
| 41 | ||
| 42 | // Require password for new mnemonics | |
| 43 | if (!password || password.length < 8) { | |
| 44 | throw new Error('Password must be at least 8 characters') | |
| 45 | } | |
| 46 | ||
| 47 | // Encrypt mnemonic before saving | |
| 48 | const encrypted = encrypt(mnemonic, password) | |
| 49 | fs.writeFileSync(WALLET_MNEMONIC_FILE, encrypted, { mode: 0o600 }) | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * Load wallet mnemonic from file (with password-based decryption) | |
| 54 | * @param {string} password - Decryption password | |
| 55 | * @returns {string|null} - 12-word mnemonic or null if not exists | |
| 56 | */ | |
| 57 | function loadWalletMnemonic(password) { | |
| 58 | if (!fs.existsSync(WALLET_MNEMONIC_FILE)) { | |
| 59 | return null | |
| 60 | } | |
| 61 | ||
| 62 | const encrypted = fs.readFileSync(WALLET_MNEMONIC_FILE, 'utf8').trim() | |
| 63 | ||
| 64 | // Check if this is plaintext (legacy format) - contains spaces and 12 words | |
| 65 | if (encrypted.includes(' ') && encrypted.split(' ').length === 12) { | |
| 66 | console.warn('⚠️ WARNING: Wallet mnemonic is stored in plaintext (legacy format)') | |
| 67 | console.warn('⚠️ Please set a password to encrypt your mnemonics') | |
| 68 | return encrypted | |
| 69 | } | |
| 70 | ||
| 71 | // Decrypt with password | |
| 72 | if (!password) { | |
| 73 | throw new Error('Password required to decrypt wallet mnemonic') | |
| 74 | } | |
| 75 | ||
| 76 | try { | |
| 77 | return decrypt(encrypted, password) | |
| 78 | } catch (err) { | |
| 79 | throw new Error('Invalid password or corrupted mnemonic data') | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * Initialize WDK with wallet mnemonic | |
| 85 | * Creates WDK instance and registers Bitcoin + Ethereum wallets | |
| 86 | * @param {string} walletMnemonic - 12-word BIP-39 mnemonic | |
| 87 | * @param {Object} config - Configuration { testnet: boolean } | |
| 88 | * @returns {Object} - { wdk } - WDK instance with registered wallets | |
| 89 | */ | |
| 90 | async function initializeWDK(walletMnemonic, config = { testnet: true, testnet4: false }) { | |
| 91 | try { | |
| 92 | // Initialize WDK with mnemonic and register wallets (chainable) | |
| 93 | const wdk = new WDK.default(walletMnemonic) | |
| 94 | .registerWallet('bitcoin', WalletManagerBtc.default, { | |
| 95 | network: config.testnet4 ? 'testnet4' : (config.testnet ? 'testnet' : 'bitcoin'), | |
| 96 | host: config.testnet4 ? 'blackie.c3-soft.com' : 'electrum.blockstream.info', | |
| 97 | port: config.testnet4 ? 50001 : (config.testnet ? 60001 : 50001) | |
| 98 | }) | |
| 99 | .registerWallet('ethereum', WalletManagerEvm.default, { | |
| 100 | provider: config.testnet | |
| 101 | ? 'https://ethereum-sepolia-rpc.publicnode.com' | |
| 102 | : 'https://eth.drpc.org' | |
| 103 | }) | |
| 104 | ||
| 105 | return { wdk } | |
| 106 | } catch (err) { | |
| 107 | // Graceful error handling with clear user feedback | |
| 108 | if (err.message.includes('network') || err.message.includes('provider')) { | |
| 109 | throw new Error('Cannot connect to blockchain network. Check internet connection.') | |
| 110 | } else if (err.message.includes('mnemonic') || err.message.includes('seed')) { | |
| 111 | throw new Error('Invalid wallet mnemonic format.') | |
| 112 | } else { | |
| 113 | throw new Error(`WDK initialization failed: ${err.message}`) | |
| 114 | } | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | /** | |
| 119 | * Get first address for testing/display | |
| 120 | * Uses account index 0, address index 0 | |
| 121 | * @param {Object} wdk - WDK instance | |
| 122 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 123 | * @returns {string} - First derived address | |
| 124 | */ | |
| 125 | async function getFirstAddress(wdk, chain = 'bitcoin') { | |
| 126 | try { | |
| 127 | const account = await wdk.getAccount(chain, 0) | |
| 128 | return await account.getAddress() | |
| 129 | } catch (err) { | |
| 130 | throw new Error(`Failed to derive ${chain} address: ${err.message}`) | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * Check if wallet mnemonic exists | |
| 136 | * @returns {boolean} | |
| 137 | */ | |
| 138 | function walletMnemonicExists() { | |
| 139 | return fs.existsSync(WALLET_MNEMONIC_FILE) | |
| 140 | } | |
| 141 | ||
| 142 | /** | |
| 143 | * Token contract addresses for ERC-20 tokens | |
| 144 | * Format: { 'chain-token': { mainnet: 'address', testnet: 'address' } } | |
| 145 | */ | |
| 146 | const TOKEN_CONTRACTS = { | |
| 147 | 'ethereum-usdt': { | |
| 148 | mainnet: '0xdAC17F958D2ee523a2206206994597C13D831ec7', // Checksummed address (important!) | |
| 149 | testnet: '0x7169D38820dfd117C3FA1f22a697dBA58d90BA06' // Sepolia USDT | |
| 150 | }, | |
| 151 | 'ethereum-usdc': { | |
| 152 | mainnet: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', | |
| 153 | testnet: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' // Sepolia USDC | |
| 154 | } | |
| 155 | } | |
| 156 | ||
| 157 | /** | |
| 158 | * Check if a chain parameter refers to a token (e.g., 'ethereum-usdt') | |
| 159 | * @param {string} chain - Chain identifier | |
| 160 | * @returns {boolean} | |
| 161 | */ | |
| 162 | function isTokenChain(chain) { | |
| 163 | return chain.includes('-') && chain !== 'testnet' && chain !== 'mainnet' | |
| 164 | } | |
| 165 | ||
| 166 | /** | |
| 167 | * Get base chain from token chain identifier | |
| 168 | * @param {string} chain - Chain identifier (e.g., 'ethereum-usdt' or 'ethereum') | |
| 169 | * @returns {string} - Base chain (e.g., 'ethereum') | |
| 170 | */ | |
| 171 | function getBaseChain(chain) { | |
| 172 | if (!isTokenChain(chain)) { | |
| 173 | return chain | |
| 174 | } | |
| 175 | // Extract base chain from 'ethereum-usdt' -> 'ethereum' | |
| 176 | return chain.split('-')[0] | |
| 177 | } | |
| 178 | ||
| 179 | /** | |
| 180 | * Get token contract information | |
| 181 | * @param {string} chain - Token chain identifier (e.g., 'ethereum-usdt') | |
| 182 | * @param {boolean} isTestnet - Whether to use testnet contracts | |
| 183 | * @returns {Object} - Token info { contractAddress, symbol, decimals } | |
| 184 | */ | |
| 185 | function getTokenInfo(chain, isTestnet = true) { | |
| 186 | const tokenContracts = TOKEN_CONTRACTS[chain] | |
| 187 | if (!tokenContracts) { | |
| 188 | throw new Error(`Unsupported token: ${chain}`) | |
| 189 | } | |
| 190 | ||
| 191 | const contractAddress = isTestnet ? tokenContracts.testnet : tokenContracts.mainnet | |
| 192 | ||
| 193 | // Extract token symbol from chain (ethereum-usdt -> USDT) | |
| 194 | const symbol = chain.split('-')[1]?.toUpperCase() || 'TOKEN' | |
| 195 | ||
| 196 | return { | |
| 197 | contractAddress, // Changed from 'contract' to match usage in wallet-server.cjs | |
| 198 | symbol, | |
| 199 | decimals: 6 // USDT/USDC use 6 decimals | |
| 200 | } | |
| 201 | } | |
| 202 | ||
| 203 | module.exports = { | |
| 204 | generateWalletMnemonic, | |
| 205 | saveWalletMnemonic, | |
| 206 | loadWalletMnemonic, | |
| 207 | initializeWDK, | |
| 208 | getFirstAddress, | |
| 209 | walletMnemonicExists, | |
| 210 | isTokenChain, | |
| 211 | getBaseChain, | |
| 212 | getTokenInfo, | |
| 213 | TOKEN_CONTRACTS | |
| 214 | } |