import BigNumber from 'bignumber.js'; import * as bitcoin from 'bitcoinjs-lib'; import ecc from '../zeus_modules/noble_ecc'; bitcoin.initEccLib(ecc); import { nodeInfoStore } from '../stores/Stores'; import Base64Utils from '../utils/Base64Utils'; import NostrUtils from '../utils/NostrUtils'; import { SATS_PER_BTC } from '../utils/UnitsUtils'; import { walletrpc } from '../proto/lightning'; const btcNonBech = /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/; const btcBech = /^(bc1|BC1|[13])[a-zA-HJ-NP-Z0-9]{25,87}$/; /* BOLT 11 https://github.com/lightning/bolts/blob/master/11-payment-encoding.md#human-readable-part lnbc - mainnet lntb - testnet lntbs - signet lnbcrt - regtest */ const lnInvoice = /^(lnbc|lntb|lntbs|lnbcrt|LNBC|LNTB|LNTBS|LNBCRT)([0-9]{1,}[a-zA-Z0-9]+){1}$/; const lnPubKey = /^[a-f0-9]{66}$/; /* BIP-21 */ const bip21Uri = /^(bitcoin|BITCOIN):([13a-zA-Z0-9]{25,42})?(\?((amount|AMOUNT)=([0-9]+(\.[0-9]+)?)|(label|LABEL|message|MESSAGE|lightning|LIGHTNING|lno|LNO|noffer|NOFFER)=([^&]*))((&((amount|AMOUNT)=([0-9]+(\.[0-9]+)?)|(label|LABEL|message|MESSAGE|lightning|LIGHTNING|lno|LNO|noffer|NOFFER)=([^&]*)))*))?/; /* BOLT 12 */ const lnOffer = /^(lno|LNO)([0-9]{1,}[a-zA-Z0-9]+){1}$/; /* CLINK noffer — Nostr Offer (https://github.com/shocknet/clink) */ const noffer = /^(?:noffer1[02-9ac-hj-np-z]{6,}|NOFFER1[02-9AC-HJ-NP-Z]{6,})$/; /* testnet */ const btcNonBechTestnet = /^[mn2][a-km-zA-HJ-NP-Z1-9]{25,34}$/; const btcBechTestnet = /^(bc1|bcrt1|BC1|BCRT1|[2])[a-zA-HJ-NP-Z0-9]{25,89}$/; const btcBechPubkeyScriptHashTestnet = /^(tb1|TB1|[2])[a-zA-HJ-NP-Z0-9]{25,89}$/; /* lndhub */ const lndHubAddress = /^(lndhub:\/\/)[\w-]+(:)\w+(@https?:\/\/[\w\-_.]+(:\d{1,5})?([\w/-]+)?)?$/; /* lnurl */ const lightningAddress = /^(?:[\p{L}\p{N}\p{S}\p{M}!#$%&'*+/=?^_`{|}~-]+(?:\.[\p{L}\p{N}\p{S}\p{M}!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$/u; const blueWalletAddress = /^bluewallet:setlndhuburl\?url=(\S+)/; /* ZEUS ecash gift URL */ const ZEUS_ECASH_GIFT_URL = 'https://zeusln.com/e/'; /* xpub,ypub,zpub,vpub */ const xpubFormat = /^(xpub|ypub|zpub|vpub|tpub)(.*)/; /* psbt */ const psbt = /^((cHN)|(psbt))[,:]?.*$/; /* string wallet export */ const stringWalletExport = /^\[[0-9a-fA-F]+\/\d+[',h]\/\d+[',h]\/\d+[',h]\](xpub|ypub|zpub|vpub|tpub)[a-zA-Z0-9]+/; /* descriptors */ const wpkhDescriptor = /^wpkh\(\[[a-zA-Z0-9]+\/[0-9]+h\/[0-9]+h\/[0-9]+h\](xpub|ypub|zpub|vpub|tpub)[a-zA-Z0-9]+\/<([0-9]+);([0-9]+)>\/[*]\)#([a-zA-Z0-9]+)$/; const nestedWpkhDescriptor = /^sh\(wpkh\(\[[a-zA-Z0-9]+\/[0-9]+h\/[0-9]+h\/[0-9]+h\](xpub|ypub|zpub|vpub|tpub)[a-zA-Z0-9]+\/<([0-9]+);([0-9]+)>\/[*]\)\)#([a-zA-Z0-9]+)$/; const snakeCase = /^[a-zA-Z]+(?:_[a-zA-Z]+)*$/; const withdrawalRequest = /^lnr1qqg[a-z0-9]+$/; export const CUSTODIAL_LNDHUBS = [ 'https://lndhub.io', 'https://lndhub.herokuapp.com', 'https://legend.lnbits.com/wallet', 'https://infinity.lnbits.com/wallet', 'https://ln.getalby.com' ]; const bitcoinQrParser = (input: string, prefix: string) => { let satAmount, lightning, offer, clinkNoffer; const btcAddressAndParams = input.split(prefix)[1]; const [btcAddress, params] = btcAddressAndParams.split('?'); const result: any = {}; params && params.split('&').forEach(function (part) { const item = part.split('='); result[item[0]] = decodeURIComponent(item[1]); }); const value = btcAddress; if (result.amount || result.AMOUNT) { satAmount = new BigNumber(result.amount || result.AMOUNT).multipliedBy( SATS_PER_BTC ); satAmount = satAmount.toString(); } if (result.lightning || result.LIGHTNING) { lightning = result.lightning || result.LIGHTNING; } if (result.lno || result.LNO) { offer = result.lno || result.LNO; } if (result.noffer || result.NOFFER) { clinkNoffer = result.noffer || result.NOFFER; } return [value, satAmount, lightning, offer, clinkNoffer]; }; class AddressUtils { processBIP21Uri = (input: string) => { let value, satAmount, lightning, offer, clinkNoffer; // handle addresses prefixed with 'bitcoin:' and // payment requests prefixed with 'lightning:' // handle BTCPay invoices with amounts embedded if (input.includes('bitcoin:') || input.includes('BITCOIN:')) { const [ parsedValue, parsedSatAmount, parsedLightning, parsedOffer, parsedClinkNoffer ] = bitcoinQrParser( input, input.includes('BITCOIN:') ? 'BITCOIN:' : 'bitcoin:' ); value = parsedValue; if (parsedSatAmount) { satAmount = parsedSatAmount; } if (parsedLightning) { lightning = parsedLightning; } if (parsedOffer) { offer = parsedOffer; } if (parsedClinkNoffer) { clinkNoffer = parsedClinkNoffer; } } else if (input.includes('lightning:')) { value = input.split('lightning:')[1]; } else if (input.includes('LIGHTNING:')) { value = input.split('LIGHTNING:')[1]; } else if (input.includes('zeusln:')) { value = input.split('zeusln:')[1]; } else { value = input; } return { value, satAmount, lightning, offer, clinkNoffer }; }; processLNDHubAddress = (input: string) => { if (!this.isValidLNDHubAddress(input)) { throw new Error('Could not process invalid LNDHub account address'); } if (input.includes('lndhub://')) { input = input.replace('lndhub://', ''); let value; let host; if (input.indexOf('@') !== -1) { const [namepass, serverURL] = input.split('@'); value = namepass; host = serverURL; } else { value = input; host = CUSTODIAL_LNDHUBS[0]; } const [username, password] = value.split(':'); return { username, password, host }; } else { input = input.replace('bluewallet:setlndhuburl?url=', ''); input = decodeURIComponent(input); return { host: input }; } }; isValidBitcoinAddress = (input: string, testnet: boolean) => { if (testnet) { return ( btcNonBechTestnet.test(input) || btcBechTestnet.test(input) || btcBechPubkeyScriptHashTestnet.test(input) ); } return btcNonBech.test(input) || btcBech.test(input); }; isValidBIP21Uri = (input: string) => bip21Uri.test(input); isValidLightningPaymentRequest = (input: string) => lnInvoice.test(input); isValidLightningOffer = (input: string) => lnOffer.test(input); isValidNoffer = (input: string) => noffer.test(input); isValidLightningPubKey = (input: string) => lnPubKey.test(input); isValidLNDHubAddress = (input: string) => lndHubAddress.test(input) || blueWalletAddress.test(input); isValidLightningAddress = (input: string) => lightningAddress.test(input); isValidWithdrawalRequest = (input: string) => withdrawalRequest.test(input); isValidNpub = (input: string) => NostrUtils.isValidNpub(input); isValidXpub = (input: string) => xpubFormat.test(input); isPsbt = (input: string) => psbt.test(input); isValidTxHex = (txHex: string) => { try { bitcoin.Transaction.fromHex(txHex); return true; // Parsing succeeded, so it's a valid transaction hex } catch (error) { return false; // Parsing failed, so it's not a valid transaction hex } }; isKeystoreWalletExport = (walletExport: string) => { try { const parsed = JSON.parse(walletExport); if (parsed.keystore?.xpub && parsed.keystore?.ckcc_xfp) return true; return false; } catch (error) { return false; } }; processKeystoreWalletExport = (input: string) => { try { const parsed = JSON.parse(input); const { keystore } = parsed; const { xpub, label, ckcc_xfp } = keystore; return { MasterFingerprint: Base64Utils.mfpIntToBytes(ckcc_xfp), ExtPubKey: xpub, Label: label }; } catch (e: any) { // console.error('Error processing wallet export'); return { MasterFingerprint: '', ExtPubKey: '', Label: '' }; } }; isJsonWalletExport = (walletExport: string) => { try { const parsed = JSON.parse(walletExport); if (parsed.MasterFingerprint && parsed.ExtPubKey) return true; return false; } catch (error) { return false; } }; isStringWalletExport = (input: string) => stringWalletExport.test(input); processStringWalletExport = (input: string) => { try { const MasterFingerprint = input.split('[')[1].split('/')[0]; const ExtPubKey = input.split(']')[1]; return { MasterFingerprint, ExtPubKey }; } catch (e: any) { // console.error('Error processing wallet export'); return { MasterFingerprint: '', ExtPubKey: '' }; } }; isWpkhDescriptor = (input: string) => wpkhDescriptor.test(input); isNestedWpkhDescriptor = (input: string) => nestedWpkhDescriptor.test(input); processWpkhDescriptor = (input: string) => { try { const MasterFingerprint = input.split('wpkh([')[1].split('/')[0]; const ExtPubKey = input.split(']')[1].split('/')[0]; const AddressType = walletrpc.AddressType.WITNESS_PUBKEY_HASH; return { MasterFingerprint, ExtPubKey, AddressType }; } catch (e: any) { return { MasterFingerprint: '', ExtPubKey: '' }; } }; processNestedWpkhDescriptor = (input: string) => { try { const MasterFingerprint = input.split('sh(wpkh([')[1].split('/')[0]; const ExtPubKey = input.split(']')[1].split('/')[0]; const AddressType = walletrpc.AddressType.NESTED_WITNESS_PUBKEY_HASH; return { MasterFingerprint, ExtPubKey, AddressType }; } catch (e: any) { return { MasterFingerprint: '', ExtPubKey: '' }; } }; snakeToHumanReadable = (input: string) => { let output = input; if (snakeCase.test(input)) { // remove capital demarcation with spaces, move all to lowercase output = output.split('_').join(' ').toLowerCase(); // capitalize first letter output = output.charAt(0).toUpperCase() + output.slice(1); } return output; }; scriptPubKeyToAddress = (scriptPubKeyHex: string) => { const nodeInfo = nodeInfoStore?.nodeInfo; const { isTestNet, isRegTest } = nodeInfo; let network = bitcoin.networks.bitcoin; if (isTestNet) network = bitcoin.networks.testnet; if (isRegTest) network = bitcoin.networks.regtest; const scriptBuffer = Buffer.from(scriptPubKeyHex, 'hex'); const decodedScript = bitcoin.script.decompile(scriptBuffer); // Handle P2WSH (Pay-to-Witness-Script-Hash) if ( decodedScript && // Ensure the script is decoded decodedScript.length === 2 && // P2WSH scripts have exactly 2 elements decodedScript[0] === bitcoin.opcodes.OP_0 && // First element is OP_0 Buffer.isBuffer(decodedScript[1]) && // Second element is push data (a Buffer) decodedScript[1].length === 32 // Push data length is exactly 32 bytes ) { try { const witnessProgram = decodedScript[1]; const { address } = bitcoin.payments.p2wsh({ hash: witnessProgram, network }); return address; } catch (e) { console.log( `error decoding P2WSH pkscript: ${scriptPubKeyHex}:`, e ); throw new Error('Invalid scriptPubKey format'); } } // Handle P2PKH (Pay-to-PubKey-Hash) // TODO add address validation + tests if ( decodedScript && decodedScript[0] === bitcoin.opcodes.OP_DUP && decodedScript[1] === bitcoin.opcodes.OP_HASH160 && Buffer.isBuffer(decodedScript[2]) && // Ensure it's a valid public key hash (20 bytes) decodedScript[2].length === 20 ) { console.log('attempting to decode P2PKH pkscript'); try { const pubKeyHash = decodedScript[2]; const { address } = bitcoin.payments.p2pkh({ hash: pubKeyHash, network }); console.log('P2PKH address', address); return address; } catch (e) { console.log( `error decoding P2PKH pkscript: ${scriptPubKeyHex}:`, e ); throw new Error('Invalid scriptPubKey format'); } } // Handle P2PK (Pay-to-PubKey) // TODO add address validation + tests if (decodedScript && decodedScript[1] === bitcoin.opcodes.OP_CHECKSIG) { console.log('attempting to decode P2PK pkscript'); try { const pubkey: any = decodedScript[0]; const { address } = bitcoin.payments.p2pk({ pubkey, network }); console.log('P2PK address', address); return address; } catch (e) { console.log( `error decoding P2PK pkscript: ${scriptPubKeyHex}`, e ); throw new Error('Invalid scriptPubKey format'); } } // Handle P2SH (Pay-to-Script-Hash) if (decodedScript && decodedScript[0] === bitcoin.opcodes.OP_HASH160) { try { const scriptHash: any = decodedScript[1]; const { address } = bitcoin.payments.p2sh({ hash: scriptHash, network }); return address; } catch (e) { console.log( `error decoding P2SH pkscript: ${scriptPubKeyHex}`, e ); throw new Error('Invalid scriptPubKey format'); } } // Handle P2WPKH (Pay-to-Witness-PubKey-Hash) - SegWit if ( decodedScript && decodedScript[0] === bitcoin.opcodes.OP_0 && // First element is OP_0 Buffer.isBuffer(decodedScript[1]) && // Second element is a buffer (push data) decodedScript[1].length === 20 // Push data is exactly 20 bytes ) { try { const pubKeyHash = decodedScript[1]; // Use the hash part, not the entire script const { address } = bitcoin.payments.p2wpkh({ hash: pubKeyHash, network }); return address; } catch (e) { console.log( `error decoding P2WPKH pkscript: ${scriptPubKeyHex}`, e ); throw new Error('Invalid scriptPubKey format'); } } // Handle P2TR (Pay-to-Taproot) - Taproot address if (decodedScript && decodedScript[0] === 0x51) { try { const taprootPubKey: any = decodedScript[1]; const { address } = bitcoin.payments.p2tr({ pubkey: taprootPubKey, network }); return address; } catch (e) { console.log( `error decoding P2TR pkscript: ${scriptPubKeyHex}`, e ); throw new Error('Invalid scriptPubKey format'); } } console.log('unknown address type for script pubkey', scriptPubKeyHex); throw new Error('Unknown scriptPubKey format'); }; getAddressType = (address: string): string => { // Address type detection based on LND's supported types if (address.startsWith('bc1q')) return 'P2WPKH (Segwit)'; if (address.startsWith('bc1p')) return 'P2TR (Taproot)'; if (address.startsWith('3')) return 'NP2WKH (Nested Segwit)'; if (address.startsWith('1')) return 'P2PKH (Legacy)'; if (address.startsWith('tb1q')) return 'P2WPKH (Testnet Segwit)'; if (address.startsWith('tb1p')) return 'P2TR (Testnet Taproot)'; if (address.startsWith('2')) return 'NP2WKH (Testnet Nested Segwit)'; if (address.startsWith('m') || address.startsWith('n')) return 'P2PKH (Testnet Legacy)'; if (address.startsWith('bcrt1q')) return 'P2WPKH (Regtest Segwit)'; if (address.startsWith('bcrt1p')) return 'P2TR (Regtest Taproot)'; return 'Unknown'; }; } const addressUtils = new AddressUtils(); export default addressUtils; export { ZEUS_ECASH_GIFT_URL };