OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-wallet-lib commits / 629b2ae

Add all shared lib files
Move complete lib directory into shared module: - All .cjs files (validators, storage, encryption, etc.) - Updated exports in package.json Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
629b2aec61dfbef0f3b85ca8ca280d6937929160
by plankatron <fleeky@gmail.com> · 2026-01-30T22:57:18.000Z

Changed files (19) +5328 -2

address-manager.cjs +91 -0
……@@ -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
3require('bare-node-runtime/global')
4
5const {
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 */
19async 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 */
68async 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 */
78async 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
87module.exports = {
88 generateNewAddress,
89 listAddresses,
90 getCurrentAddress
91}
address-validator.cjs +269 -0
……@@ -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
26const crypto = require('bare-crypto')
27
28/**
29 * Bitcoin Base58 alphabet (no 0, O, I, l to avoid confusion)
30 */
31const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
32
33/**
34 * Decode Base58 string to buffer
35 * @param {string} str - Base58 encoded string
36 * @returns {Buffer} Decoded bytes
37 */
38function 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 */
72function 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 */
89const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
90
91/**
92 * Bech32 polymod for checksum
93 */
94function 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 */
115function 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 */
130function verifyBech32(hrp, data) {
131 return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 1
132}
133
134/**
135 * Verify Bech32m checksum (SegWit v1+)
136 */
137function verifyBech32m(hrp, data) {
138 return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 0x2bc830a3
139}
140
141/**
142 * Decode Bech32 address
143 */
144function 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 */
172function 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 */
232function 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 */
251function 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
265module.exports = {
266 validateAddress,
267 validateBitcoinAddress,
268 validateEthereumAddress
269}
amount-validator.cjs +158 -0
……@@ -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 */
28const MAX_BTC = 21_000_000n * 100_000_000n // 21 million BTC in satoshi
29const 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 */
37function 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 */
93function 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 */
112function 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 */
135function 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
153module.exports = {
154 validateAmount,
155 validateSufficientBalance,
156 formatAmount,
157 parseAmount
158}
auth.cjs +144 -0
……@@ -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
26const crypto = require('hypercore-crypto')
27const fs = require('bare-fs')
28const path = require('bare-path')
29const os = require('bare-os')
30
31const STORAGE_DIR = path.join(os.homedir(), '.hyper-wallet')
32const 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 */
40function 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 */
50function 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 */
65function 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 */
84function 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 */
122function 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 */
133function tokenExists() {
134 return fs.existsSync(TOKEN_FILE)
135}
136
137module.exports = {
138 generateToken,
139 saveToken,
140 loadToken,
141 verifyToken,
142 deleteToken,
143 tokenExists
144}
blockchain-api.cjs +166 -0
……@@ -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 */
14async 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 */
40async 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 */
56async 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 */
83async 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 */
109async 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 */
125async 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 */
148async 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
158module.exports = {
159 getUTXOsForAddress,
160 getUTXOsForAddresses,
161 getFeeRates,
162 getTransaction,
163 getAddressBalance,
164 broadcastTransaction,
165 checkConnection
166}
blockchain-providers.cjs +502 -0
……@@ -0,0 +1,502 @@
1// lib/blockchain-providers.cjs - Blockchain API providers
2require('bare-node-runtime/global')
3
4/**
5 * Ethereum providers configuration
6 * Supports multiple providers with automatic fallback
7 */
8const 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 */
241const 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 */
316async 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 */
392async 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 */
479function 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 */
490function getProviderInfo (chain, providerName) {
491 const providers = chain === 'ethereum' ? ETHEREUM_PROVIDERS : BITCOIN_PROVIDERS
492 return providers[providerName] || null
493}
494
495module.exports = {
496 ETHEREUM_PROVIDERS,
497 BITCOIN_PROVIDERS,
498 fetchTransactions,
499 fetchTokenTransactions,
500 getAvailableProviders,
501 getProviderInfo
502}
config-loader.cjs +96 -0
……@@ -0,0 +1,96 @@
1// lib/config-loader.cjs - Configuration Loader
2require('bare-node-runtime/global')
3
4const fs = require('bare-fs')
5const 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 */
13function 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 */
43function 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 */
66function 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
92module.exports = {
93 loadConfig,
94 normalizeConfig,
95 getDefaultConfig
96}
config.cjs +233 -0
……@@ -0,0 +1,233 @@
1// lib/config.cjs - Configuration management system
2// CRITICAL: Load bare-node-runtime FIRST before any other requires
3require('bare-node-runtime/global')
4
5const fs = require('bare-fs')
6const path = require('bare-path')
7const os = require('bare-os')
8
9const CONFIG_DIR = path.join(os.homedir(), '.hyper-wallet')
10const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
11
12// Default configuration
13const 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 */
51function 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 */
73function 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 */
101function 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 */
152function 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 */
163function 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 */
177function 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 */
199function 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
225module.exports = {
226 getConfig,
227 saveConfig,
228 updateConfig,
229 migrateLegacyConfig,
230 DEFAULT_CONFIG,
231 CONFIG_FILE,
232 CONFIG_DIR
233}
create-wallet-server.cjs +37 -0
……@@ -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
4const http = require('bare-http1')
5const 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
9const parentWalletServer = path.join(__dirname, '../../hyper-wallet/wallet-server.cjs')
10
11module.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}
encryption.cjs +167 -0
……@@ -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
28const crypto = require('bare-crypto')
29const b4a = require('b4a')
30
31// PBKDF2 parameters (bare-crypto compatible)
32const PBKDF2_ITERATIONS = 100000 // NIST recommended minimum
33const PBKDF2_KEYLEN = 32 // 256-bit key
34const PBKDF2_DIGEST = 'sha256' // Hash algorithm
35
36// AES-GCM parameters
37const IV_LENGTH = 12 // 96-bit IV (recommended for GCM)
38const SALT_LENGTH = 32 // 256-bit salt
39const 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 */
49function 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 */
72function 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 */
108function 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 */
145function 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
155module.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}
input-sanitizer.cjs +227 -0
……@@ -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 */
35function 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 */
61function 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 */
83function 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 */
105function 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 */
129function 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 */
149function 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 */
171function 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 */
198function 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
218module.exports = {
219 sanitizeString,
220 sanitizeContactName,
221 sanitizeDeviceName,
222 sanitizeNotes,
223 validateChain,
224 validateNetwork,
225 sanitizeHex,
226 validateInviteCode
227}
package.json +20 -2
……@@ -1,13 +1,31 @@
11{
22 "name": "hyper-wallet-lib",
33 "version": "1.0.0",
4 "description": "Shared library for hyper-wallet - identity and secure storage",
4 "description": "Shared library for hyper-wallet",
55 "type": "module",
66 "main": "index.js",
77 "exports": {
88 ".": "./index.js",
99 "./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"
1129 },
1230 "dependencies": {
1331 "keet-identity-key": "^3.1.0",
pairing-protocol.cjs +210 -0
……@@ -0,0 +1,210 @@
1// lib/pairing-protocol.cjs - Device pairing protocol using Protomux
2
3const Protomux = require('protomux')
4const b4a = require('b4a')
5const c = require('compact-encoding')
6
7// Protocol identifier
8const 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 */
18async 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 */
115async 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
207module.exports = {
208 createPairingServer,
209 createPairingClient
210}
qr-svg.cjs +32 -0
……@@ -0,0 +1,32 @@
1// QR Code generator using SVG (no fs dependency)
2
3// Polyfill TextEncoder for Bare runtime
4if (typeof global.TextEncoder === 'undefined') {
5 const { TextEncoder, TextDecoder } = require('bare-encoding')
6 global.TextEncoder = TextEncoder
7 global.TextDecoder = TextDecoder
8}
9
10const QRCode = require('qrcode/lib/core/qrcode')
11const SvgRenderer = require('qrcode/lib/renderer/svg')
12
13async 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
32module.exports = { generateQRCodeSVG }
rate-limiter.cjs +265 -0
……@@ -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)
27const 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 */
42class 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
186const rateLimiter = new RateLimiter()
187
188// Cleanup every 5 minutes
189setInterval(() => 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 */
196function 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 */
214function 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 */
241function 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 */
253function getRateLimitStatus(req) {
254 const ip = getClientIP(req)
255 const endpoint = normalizeEndpoint(req.url)
256
257 return rateLimiter.getStatus(ip, endpoint)
258}
259
260module.exports = {
261 checkRateLimit,
262 getRateLimitStatus,
263 RateLimiter,
264 RATE_LIMITS
265}
secure-storage.cjs +70 -0
……@@ -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
7const fs = require('fs')
8const path = require('path')
9const os = require('os')
10
11const STORAGE_DIR = path.join(os.homedir(), '.hyper-wallet')
12const MNEMONIC_FILE = path.join(STORAGE_DIR, '.mnemonic')
13
14/**
15 * Ensure storage directory exists
16 */
17function 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 */
27async 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 */
36async 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 */
50async 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 */
60async function isInitialized() {
61 const mnemonic = await loadMnemonic()
62 return mnemonic !== null
63}
64
65module.exports = {
66 storeMnemonic,
67 loadMnemonic,
68 deleteMnemonic,
69 isInitialized
70}
utxo-manager.cjs +157 -0
……@@ -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
8const b4a = require('b4a')
9
10/**
11 * Create UTXO key for Autobase storage
12 * Format: utxo:{chain}:{txid}:{vout}
13 */
14function 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 */
24async 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 */
52async 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 */
66function 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 */
94function 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 */
111function 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 */
122function 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
149module.exports = {
150 createUTXOKey,
151 storeUTXO,
152 markUTXOSpent,
153 calculateBalance,
154 getSpendableUTXOs,
155 formatBTC,
156 formatBalance
157}
wallet-storage.cjs +2270 -0
……@@ -0,0 +1,2270 @@
1// lib/wallet-context-autobase.cjs - Multi-device storage with Autobase
2
3require('bare-node-runtime/global')
4const path = require('bare-path')
5const os = require('bare-os')
6const fs = require('bare-fs')
7const Corestore = require('corestore')
8const Hyperbee = require('hyperbee')
9const Autobase = require('autobase')
10const Hyperswarm = require('hyperswarm')
11const crypto = require('hypercore-crypto')
12const b4a = require('b4a')
13const IdentityKey = require('keet-identity-key')
14const { createPairingServer, createPairingClient } = require('./pairing-protocol.cjs')
15const { loadWalletMnemonic, initializeWDK } = require('./wdk-integration.cjs')
16const { fetchTransactions, fetchTokenTransactions } = require('./blockchain-providers.cjs')
17const { getConfig } = require('./config.cjs')
18
19// Storage configuration
20const STORAGE_DIR = path.join(os.homedir(), '.hyper-wallet', 'storage')
21const DEVICES_FILE = path.join(os.homedir(), '.hyper-wallet', '.devices')
22const PENDING_FILE = path.join(os.homedir(), '.hyper-wallet', '.pending')
23
24// Singleton instances
25let store = null
26let autobase = null
27let txHistoryDb = null
28let swarm = null
29let identityKeyPair = null
30let connections = new Set() // Track active connections
31
32// Initialization lock to prevent concurrent initialization
33let initializationPromise = null
34
35// WDK singleton instances (Phase 6.1)
36let wdkInstance = null
37let 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 */
49async 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 */
117async 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 */
145async 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 */
244async 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 */
274async 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 */
301async 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 */
320async 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 */
337async 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 */
357async 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 */
390async 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 */
427async 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 */
438async 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 */
465async 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 */
491async 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 */
530async 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 */
557async 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 */
579async 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 */
603async 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 */
642async 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 */
676async 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 */
712async 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 */
744async 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 */
781async 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 */
803async 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 */
847async 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 */
1043function 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 */
1062function 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 */
1091async 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 */
1138function 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
1194async 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 */
1239async 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 */
1272async 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 */
1301async 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 */
1337async 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 */
1360async 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 */
1390async 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 */
1466async 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 */
1538async 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 */
1577async 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 */
1625async 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 */
1779async 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 */
1802function 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 */
1812function 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 */
1820async 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 */
1858function 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 */
1890function 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 */
1905async 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 */
1935async 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 */
2013function 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 */
2039function 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 */
2051function 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 */
2070function 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 */
2087async 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 */
2176function 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 */
2195async 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
2215module.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}
wdk-integration.cjs +214 -0
……@@ -0,0 +1,214 @@
1// lib/wdk-integration.cjs - WDK Integration Layer
2// CRITICAL: Load bare-node-runtime FIRST before any other requires
3require('bare-node-runtime/global')
4
5const fs = require('bare-fs')
6const path = require('bare-path')
7const os = require('bare-os')
8
9// Import WDK packages directly
10// bare-node-runtime/global provides Node.js compatibility for Bare runtime
11const WDK = require('@tetherto/wdk')
12const WalletManagerBtc = require('@tetherto/wdk-wallet-btc')
13const WalletManagerEvm = require('@tetherto/wdk-wallet-evm')
14
15// Import encryption module
16const { encrypt, decrypt, isValidFormat } = require('./encryption.cjs')
17
18// Storage paths
19const STORAGE_DIR = path.join(os.homedir(), '.hyper-wallet')
20const 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 */
27function 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 */
37function 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 */
57function 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 */
90async 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 */
125async 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 */
138function 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 */
146const 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 */
162function 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 */
171function 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 */
185function 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
203module.exports = {
204 generateWalletMnemonic,
205 saveWalletMnemonic,
206 loadWalletMnemonic,
207 initializeWDK,
208 getFirstAddress,
209 walletMnemonicExists,
210 isTokenChain,
211 getBaseChain,
212 getTokenInfo,
213 TOKEN_CONTRACTS
214}