From d68dff1707e20376b32433ae249de290491df9b2 Mon Sep 17 00:00:00 2001 From: Unheard0840 Date: Sat, 29 Aug 2026 11:21:19 +0000 Subject: [PATCH] Add server/bitcoin-address.js Add shared Bitcoin address validation --- server/bitcoin-address.js | 249 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 server/bitcoin-address.js diff --git a/server/bitcoin-address.js b/server/bitcoin-address.js new file mode 100644 index 0000000..6da6a28 --- /dev/null +++ b/server/bitcoin-address.js @@ -0,0 +1,249 @@ +const crypto = require('crypto') + +const BECH32_CHARSET = + 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' + +function bech32Polymod(values) { + const generators = [ + 0x3b6a57b2, + 0x26508e6d, + 0x1ea119fa, + 0x3d4233dd, + 0x2a1462b2, + ] + + 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 ^= generators[i] + } + } + } + + return chk >>> 0 +} + +function bech32HrpExpand(hrp) { + const values = [] + + for (const char of hrp) { + values.push(char.charCodeAt(0) >> 5) + } + + values.push(0) + + for (const char of hrp) { + values.push(char.charCodeAt(0) & 31) + } + + return values +} + +function bech32VerifyChecksum(hrp, data) { + const polymod = bech32Polymod([ + ...bech32HrpExpand(hrp), + ...data, + ]) + + return polymod === 1 || polymod === 0x2bc830a3 +} + +function bech32Decode(address) { + if (typeof address !== 'string') { + throw new Error('Bitcoin address must be a string') + } + + if (address.length < 8 || address.length > 90) { + throw new Error('Invalid Bitcoin address length') + } + + const hasLower = address !== address.toUpperCase() + const hasUpper = address !== address.toLowerCase() + + if (hasLower && hasUpper) { + throw new Error( + 'Bitcoin address must not mix uppercase and lowercase', + ) + } + + const normalized = address.toLowerCase() + const separator = normalized.lastIndexOf('1') + + if (separator < 1 || separator + 7 > normalized.length) { + throw new Error('Invalid Bech32 separator') + } + + const hrp = normalized.slice(0, separator) + const dataPart = normalized.slice(separator + 1) + const data = [] + + for (const char of dataPart) { + const value = BECH32_CHARSET.indexOf(char) + + if (value === -1) { + throw new Error('Invalid Bech32 character') + } + + data.push(value) + } + + if (!bech32VerifyChecksum(hrp, data)) { + throw new Error('Invalid Bitcoin address checksum') + } + + return { + hrp, + data: data.slice(0, -6), + spec: + bech32Polymod([ + ...bech32HrpExpand(hrp), + ...data, + ]) === 1 + ? 'bech32' + : 'bech32m', + } +} + +function convertBits(data, fromBits, toBits, pad) { + let accumulator = 0 + let bits = 0 + const result = [] + const maxValue = (1 << toBits) - 1 + + for (const value of data) { + if (value < 0 || value >> fromBits !== 0) { + throw new Error('Invalid bit conversion input') + } + + accumulator = + (accumulator << fromBits) | value + bits += fromBits + + while (bits >= toBits) { + bits -= toBits + result.push( + (accumulator >> bits) & maxValue, + ) + } + } + + if (pad) { + if (bits > 0) { + result.push( + (accumulator << (toBits - bits)) & maxValue, + ) + } + } else { + if (bits >= fromBits) { + throw new Error('Invalid padding') + } + + if ( + ((accumulator << (toBits - bits)) & + maxValue) !== + 0 + ) { + throw new Error('Non-zero padding') + } + } + + return result +} + +function addressToScriptPubKey(address) { + const decoded = bech32Decode(address) + + if (decoded.hrp !== 'bc') { + throw new Error( + 'Only mainnet Bitcoin addresses are supported', + ) + } + + if (decoded.data.length < 1) { + throw new Error('Invalid SegWit address') + } + + const witnessVersion = decoded.data[0] + + if (witnessVersion > 16) { + throw new Error('Invalid SegWit witness version') + } + + if ( + witnessVersion === 0 && + decoded.spec !== 'bech32' + ) { + throw new Error( + 'Witness version 0 must use Bech32', + ) + } + + if ( + witnessVersion !== 0 && + decoded.spec !== 'bech32m' + ) { + throw new Error( + 'Witness version 1+ must use Bech32m', + ) + } + + const program = convertBits( + decoded.data.slice(1), + 5, + 8, + false, + ) + + if (program.length < 2 || program.length > 40) { + throw new Error('Invalid witness program length') + } + + if ( + witnessVersion === 0 && + program.length !== 20 && + program.length !== 32 + ) { + throw new Error( + 'Invalid witness version 0 program length', + ) + } + + const versionOpcode = + witnessVersion === 0 + ? 0x00 + : 0x50 + witnessVersion + + return Buffer.from([ + versionOpcode, + program.length, + ...program, + ]) +} + +function scriptPubKeyToScripthash(scriptPubKey) { + const hash = crypto + .createHash('sha256') + .update(scriptPubKey) + .digest() + + return Buffer.from(hash) + .reverse() + .toString('hex') +} + +function addressToScripthash(address) { + return scriptPubKeyToScripthash( + addressToScriptPubKey(address), + ) +} + +module.exports = { + addressToScriptPubKey, + scriptPubKeyToScripthash, + addressToScripthash, +} \ No newline at end of file