OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-wallet-lib / address-validator.cjs

270 lines · javascript ref:
/**
 * FILE: lib/address-validator.cjs
 * STATUS: ACTIVE
 * RUNTIME: Bare
 * MODULE: CommonJS
 *
 * PURPOSE:
 * Address validation with basic format checking
 *
 * USED BY:
 * - wallet-server.cjs (transaction and contact validation)
 *
 * KEY EXPORTS:
 * - validateAddress(address, chain, network) - Generic validator
 * - validateBitcoinAddress(address, network) - Bitcoin with Base58Check/Bech32
 * - validateEthereumAddress(address) - Ethereum format check only
 *
 * VALIDATION:
 * - Bitcoin: Full checksum validation (Base58Check and Bech32/Bech32m)
 * - Ethereum: Format check only (EIP-55 checksum disabled - Pear module resolution issues)
 * - Returns: { valid: boolean, reason?: string, warning?: string }
 *
 * NOTE: EIP-55 checksum validation disabled due to Pear module resolution bug with @noble/hashes
 */

const crypto = require('bare-crypto')

/**
 * Bitcoin Base58 alphabet (no 0, O, I, l to avoid confusion)
 */
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'

/**
 * Decode Base58 string to buffer
 * @param {string} str - Base58 encoded string
 * @returns {Buffer} Decoded bytes
 */
function base58Decode(str) {
  const bytes = []
  for (let i = 0; i < str.length; i++) {
    const char = str[i]
    let value = BASE58_ALPHABET.indexOf(char)
    if (value === -1) {
      throw new Error('Invalid Base58 character')
    }

    for (let j = 0; j < bytes.length; j++) {
      value += bytes[j] * 58
      bytes[j] = value & 0xff
      value >>= 8
    }

    while (value > 0) {
      bytes.push(value & 0xff)
      value >>= 8
    }
  }

  // Add leading zeros
  for (let i = 0; i < str.length && str[i] === '1'; i++) {
    bytes.push(0)
  }

  return Buffer.from(bytes.reverse())
}

/**
 * Verify Base58Check checksum
 * @param {Buffer} payload - Decoded Base58 bytes
 * @returns {boolean} True if checksum valid
 */
function verifyBase58Check(payload) {
  if (payload.length < 5) return false

  const data = payload.slice(0, -4)
  const checksum = payload.slice(-4)

  // Double SHA-256 hash
  const hash1 = crypto.createHash('sha256').update(data).digest()
  const hash2 = crypto.createHash('sha256').update(hash1).digest()

  // Compare first 4 bytes
  return checksum.equals(hash2.slice(0, 4))
}

/**
 * Bech32 character set
 */
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'

/**
 * Bech32 polymod for checksum
 */
function bech32Polymod(values) {
  const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
  let chk = 1

  for (const value of values) {
    const top = chk >> 25
    chk = ((chk & 0x1ffffff) << 5) ^ value

    for (let i = 0; i < 5; i++) {
      if ((top >> i) & 1) {
        chk ^= GEN[i]
      }
    }
  }

  return chk
}

/**
 * Expand HRP (Human Readable Part) for Bech32
 */
function bech32HrpExpand(hrp) {
  const result = []
  for (let i = 0; i < hrp.length; i++) {
    result.push(hrp.charCodeAt(i) >> 5)
  }
  result.push(0)
  for (let i = 0; i < hrp.length; i++) {
    result.push(hrp.charCodeAt(i) & 31)
  }
  return result
}

/**
 * Verify Bech32 checksum (SegWit v0)
 */
function verifyBech32(hrp, data) {
  return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 1
}

/**
 * Verify Bech32m checksum (SegWit v1+)
 */
function verifyBech32m(hrp, data) {
  return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 0x2bc830a3
}

/**
 * Decode Bech32 address
 */
function bech32Decode(address) {
  address = address.toLowerCase()

  const pos = address.lastIndexOf('1')
  if (pos < 1 || pos + 7 > address.length || address.length > 90) {
    throw new Error('Invalid Bech32 address')
  }

  const hrp = address.slice(0, pos)
  const data = []

  for (let i = pos + 1; i < address.length; i++) {
    const d = BECH32_CHARSET.indexOf(address[i])
    if (d === -1) {
      throw new Error('Invalid Bech32 character')
    }
    data.push(d)
  }

  return { hrp, data }
}

/**
 * Validate Bitcoin address with checksum
 * @param {string} address - Bitcoin address
 * @param {string} network - 'testnet' or 'mainnet'
 * @returns {Object} { valid: boolean, reason?: string }
 */
function validateBitcoinAddress(address, network = 'testnet') {
  // P2PKH (1... or m...) and P2SH (3... or 2...)
  if (address.match(/^[123mn][a-zA-HJ-NP-Z0-9]{25,34}$/)) {
    try {
      const decoded = base58Decode(address)

      if (!verifyBase58Check(decoded)) {
        return { valid: false, reason: 'Invalid Base58Check checksum' }
      }

      const version = decoded[0]

      // Check version byte matches network
      const validVersions = network === 'testnet' ? [0x6f, 0xc4] : [0x00, 0x05]
      if (!validVersions.includes(version)) {
        return { valid: false, reason: `Invalid network for address format (expected ${network})` }
      }

      return { valid: true }
    } catch (err) {
      return { valid: false, reason: `Base58 decode error: ${err.message}` }
    }
  }

  // Bech32 (bc1... or tb1...)
  if (address.match(/^(bc1|tb1)[a-z0-9]{39,87}$/)) {
    try {
      const prefix = network === 'testnet' ? 'tb' : 'bc'
      if (!address.startsWith(prefix)) {
        return { valid: false, reason: `Invalid network prefix (expected ${prefix}1...)` }
      }

      const { hrp, data } = bech32Decode(address)

      if (hrp !== prefix) {
        return { valid: false, reason: 'Network mismatch in HRP' }
      }

      // Try Bech32 (v0) first, then Bech32m (v1+)
      const validBech32 = verifyBech32(hrp, data)
      const validBech32m = verifyBech32m(hrp, data)

      if (!validBech32 && !validBech32m) {
        return { valid: false, reason: 'Invalid Bech32/Bech32m checksum' }
      }

      return { valid: true }
    } catch (err) {
      return { valid: false, reason: `Bech32 decode error: ${err.message}` }
    }
  }

  return { valid: false, reason: 'Unrecognized Bitcoin address format' }
}

/**
 * Validate Ethereum address (format only, NO EIP-55 checksum)
 * @param {string} address - Ethereum address (0x...)
 * @returns {Object} { valid: boolean, reason?: string, warning?: string }
 */
function validateEthereumAddress(address) {
  // Must start with 0x and be 40 hex chars
  if (!address.match(/^0x[a-fA-F0-9]{40}$/)) {
    return { valid: false, reason: 'Invalid Ethereum address format (expected 0x + 40 hex chars)' }
  }

  // EIP-55 checksum validation DISABLED
  // bare-crypto doesn't support keccak256, and installing @noble/hashes breaks Pear module resolution
  // Accepting all properly formatted addresses regardless of checksum
  return { valid: true, warning: 'EIP-55 checksum validation disabled (Pear module resolution limitation)' }
}

/**
 * Generic address validator (dispatches to chain-specific validator)
 * @param {string} address - Blockchain address
 * @param {string} chain - 'bitcoin' or 'ethereum'
 * @param {string} network - 'testnet' or 'mainnet'
 * @returns {Object} { valid: boolean, reason?: string, warning?: string }
 */
function validateAddress(address, chain, network = 'testnet') {
  if (!address || typeof address !== 'string') {
    return { valid: false, reason: 'Address is required' }
  }

  if (chain === 'bitcoin') {
    return validateBitcoinAddress(address, network)
  } else if (chain === 'ethereum') {
    return validateEthereumAddress(address)
  } else {
    return { valid: false, reason: `Unsupported chain: ${chain}` }
  }
}

module.exports = {
  validateAddress,
  validateBitcoinAddress,
  validateEthereumAddress
}