524 lines
18 KiB
TypeScript
524 lines
18 KiB
TypeScript
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 };
|