UNPKG

light-bolt11-decoder

Version:

decode lightning invoices without overhead (doesn't check signatures).

398 lines (351 loc) 10.6 kB
const {bech32, hex, utf8} = require('@scure/base') // defaults for encode; default timestamp is current time at call const DEFAULTNETWORK = { // default network is bitcoin bech32: 'bc', pubKeyHash: 0x00, scriptHash: 0x05, validWitnessVersions: [0] } const TESTNETWORK = { bech32: 'tb', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0] } const SIGNETNETWORK = { bech32: 'tbs', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0] } const REGTESTNETWORK = { bech32: 'bcrt', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0] } const SIMNETWORK = { bech32: 'sb', pubKeyHash: 0x3f, scriptHash: 0x7b, validWitnessVersions: [0] } const FEATUREBIT_ORDER = [ 'option_data_loss_protect', 'initial_routing_sync', 'option_upfront_shutdown_script', 'gossip_queries', 'var_onion_optin', 'gossip_queries_ex', 'option_static_remotekey', 'payment_secret', 'basic_mpp', 'option_support_large_channel' ] const DIVISORS = { m: BigInt(1e3), u: BigInt(1e6), n: BigInt(1e9), p: BigInt(1e12) } const MAX_MILLISATS = BigInt('2100000000000000000') const MILLISATS_PER_BTC = BigInt(1e11) const TAGCODES = { payment_hash: 1, payment_secret: 16, description: 13, payee: 19, description_hash: 23, // commit to longer descriptions (used by lnurl-pay) expiry: 6, // default: 3600 (1 hour) min_final_cltv_expiry: 24, // default: 9 fallback_address: 9, route_hint: 3, // for extra routing info (private etc.) feature_bits: 5, metadata: 27 } // reverse the keys and values of TAGCODES and insert into TAGNAMES const TAGNAMES = {} for (let i = 0, keys = Object.keys(TAGCODES); i < keys.length; i++) { const currentName = keys[i] const currentCode = TAGCODES[keys[i]].toString() TAGNAMES[currentCode] = currentName } const TAGPARSERS = { 1: words => hex.encode(bech32.fromWordsUnsafe(words)), // 256 bits 16: words => hex.encode(bech32.fromWordsUnsafe(words)), // 256 bits 13: words => utf8.encode(bech32.fromWordsUnsafe(words)), // string variable length 19: words => hex.encode(bech32.fromWordsUnsafe(words)), // 264 bits 23: words => hex.encode(bech32.fromWordsUnsafe(words)), // 256 bits 27: words => hex.encode(bech32.fromWordsUnsafe(words)), // variable 6: wordsToIntBE, // default: 3600 (1 hour) 24: wordsToIntBE, // default: 9 3: routingInfoParser, // for extra routing info (private etc.) 5: featureBitsParser // keep feature bits as array of 5 bit words } function getUnknownParser(tagCode) { return words => ({ tagCode: parseInt(tagCode), words: bech32.encode('unknown', words, Number.MAX_SAFE_INTEGER) }) } function wordsToIntBE(words) { return words.reverse().reduce((total, item, index) => { return total + item * Math.pow(32, index) }, 0) } // first convert from words to buffer, trimming padding where necessary // parse in 51 byte chunks. See encoder for details. function routingInfoParser(words) { const routes = [] let pubkey, shortChannelId, feeBaseMSats, feeProportionalMillionths, cltvExpiryDelta let routesBuffer = bech32.fromWordsUnsafe(words) while (routesBuffer.length > 0) { pubkey = hex.encode(routesBuffer.slice(0, 33)) // 33 bytes shortChannelId = hex.encode(routesBuffer.slice(33, 41)) // 8 bytes feeBaseMSats = parseInt(hex.encode(routesBuffer.slice(41, 45)), 16) // 4 bytes feeProportionalMillionths = parseInt( hex.encode(routesBuffer.slice(45, 49)), 16 ) // 4 bytes cltvExpiryDelta = parseInt(hex.encode(routesBuffer.slice(49, 51)), 16) // 2 bytes routesBuffer = routesBuffer.slice(51) routes.push({ pubkey, short_channel_id: shortChannelId, fee_base_msat: feeBaseMSats, fee_proportional_millionths: feeProportionalMillionths, cltv_expiry_delta: cltvExpiryDelta }) } return routes } function featureBitsParser(words) { const bools = words .slice() .reverse() .map(word => [ !!(word & 0b1), !!(word & 0b10), !!(word & 0b100), !!(word & 0b1000), !!(word & 0b10000) ]) .reduce((finalArr, itemArr) => finalArr.concat(itemArr), []) while (bools.length < FEATUREBIT_ORDER.length * 2) { bools.push(false) } const featureBits = {} FEATUREBIT_ORDER.forEach((featureName, index) => { let status if (bools[index * 2]) { status = 'required' } else if (bools[index * 2 + 1]) { status = 'supported' } else { status = 'unsupported' } featureBits[featureName] = status }) const extraBits = bools.slice(FEATUREBIT_ORDER.length * 2) featureBits.extra_bits = { start_bit: FEATUREBIT_ORDER.length * 2, bits: extraBits, has_required: extraBits.reduce( (result, bit, index) => index % 2 !== 0 ? result || false : result || bit, false ) } return featureBits } function hrpToMillisat(hrpString, outputString) { let divisor, value if (hrpString.slice(-1).match(/^[munp]$/)) { divisor = hrpString.slice(-1) value = hrpString.slice(0, -1) } else if (hrpString.slice(-1).match(/^[^munp0-9]$/)) { throw new Error('Not a valid multiplier for the amount') } else { value = hrpString } if (!value.match(/^\d+$/)) throw new Error('Not a valid human readable amount') const valueBN = BigInt(value) const millisatoshisBN = divisor ? (valueBN * MILLISATS_PER_BTC) / DIVISORS[divisor] : valueBN * MILLISATS_PER_BTC if ( (divisor === 'p' && !(valueBN % BigInt(10) === BigInt(0))) || millisatoshisBN > MAX_MILLISATS ) { throw new Error('Amount is outside of valid range') } return outputString ? millisatoshisBN.toString() : millisatoshisBN } // decode will only have extra comments that aren't covered in encode comments. // also if anything is hard to read I'll comment. function decode(paymentRequest, network) { if (typeof paymentRequest !== 'string') throw new Error('Lightning Payment Request must be string') if (paymentRequest.slice(0, 2).toLowerCase() !== 'ln') throw new Error('Not a proper lightning payment request') const sections = [] const decoded = bech32.decode(paymentRequest, Number.MAX_SAFE_INTEGER) paymentRequest = paymentRequest.toLowerCase() const prefix = decoded.prefix let words = decoded.words let letters = paymentRequest.slice(prefix.length + 1) let sigWords = words.slice(-104) words = words.slice(0, -104) // Without reverse lookups, can't say that the multipier at the end must // have a number before it, so instead we parse, and if the second group // doesn't have anything, there's a good chance the last letter of the // coin type got captured by the third group, so just re-regex without // the number. let prefixMatches = prefix.match(/^ln(\S+?)(\d*)([a-zA-Z]?)$/) if (prefixMatches && !prefixMatches[2]) prefixMatches = prefix.match(/^ln(\S+)$/) if (!prefixMatches) { throw new Error('Not a proper lightning payment request') } // "ln" section sections.push({ name: 'lightning_network', letters: 'ln' }) // "bc" section const bech32Prefix = prefixMatches[1] let coinNetwork if (!network) { switch (bech32Prefix) { case DEFAULTNETWORK.bech32: coinNetwork = DEFAULTNETWORK break case TESTNETWORK.bech32: coinNetwork = TESTNETWORK break case SIGNETNETWORK.bech32: coinNetwork = SIGNETNETWORK break case REGTESTNETWORK.bech32: coinNetwork = REGTESTNETWORK break case SIMNETWORK.bech32: coinNetwork = SIMNETWORK break } } else { if ( network.bech32 === undefined || network.pubKeyHash === undefined || network.scriptHash === undefined || !Array.isArray(network.validWitnessVersions) ) throw new Error('Invalid network') coinNetwork = network } if (!coinNetwork || coinNetwork.bech32 !== bech32Prefix) { throw new Error('Unknown coin bech32 prefix') } sections.push({ name: 'coin_network', letters: bech32Prefix, value: coinNetwork }) // amount section const value = prefixMatches[2] let millisatoshis if (value) { const divisor = prefixMatches[3] millisatoshis = hrpToMillisat(value + divisor, true) sections.push({ name: 'amount', letters: prefixMatches[2] + prefixMatches[3], value: millisatoshis }) } else { millisatoshis = null } // "1" separator sections.push({ name: 'separator', letters: '1' }) // timestamp const timestamp = wordsToIntBE(words.slice(0, 7)) words = words.slice(7) // trim off the left 7 words sections.push({ name: 'timestamp', letters: letters.slice(0, 7), value: timestamp }) letters = letters.slice(7) let tagName, parser, tagLength, tagWords // we have no tag count to go on, so just keep hacking off words // until we have none. while (words.length > 0) { const tagCode = words[0].toString() tagName = TAGNAMES[tagCode] || 'unknown_tag' parser = TAGPARSERS[tagCode] || getUnknownParser(tagCode) words = words.slice(1) tagLength = wordsToIntBE(words.slice(0, 2)) words = words.slice(2) tagWords = words.slice(0, tagLength) words = words.slice(tagLength) sections.push({ name: tagName, tag: letters[0], letters: letters.slice(0, 1 + 2 + tagLength), value: parser(tagWords) // see: parsers for more comments }) letters = letters.slice(1 + 2 + tagLength) } // signature sections.push({ name: 'signature', letters: letters.slice(0, 104), value: hex.encode(bech32.fromWordsUnsafe(sigWords)) }) letters = letters.slice(104) // checksum sections.push({ name: 'checksum', letters: letters }) let result = { paymentRequest, sections, get expiry() { let exp = sections.find(s => s.name === 'expiry') if (exp) return getValue('timestamp') + exp.value }, get route_hints() { return sections.filter(s => s.name === 'route_hint').map(s => s.value) } } for (let name in TAGCODES) { if (name === 'route_hint') { // route hints can be multiple, so this won't work for them continue } Object.defineProperty(result, name, { get() { return getValue(name) } }) } return result function getValue(name) { let section = sections.find(s => s.name === name) return section ? section.value : undefined } } module.exports = { decode, hrpToMillisat }