UNPKG

light-bolt11-decoder

Version:

<a href="https://nbd.wtf"><img align="right" height="196" src="https://user-images.githubusercontent.com/1653275/194609043-0add674b-dd40-41ed-986c-ab4a2e053092.png" /></a>

423 lines (374 loc) 11.3 kB
const bech32 = require('bech32') const Buffer = require('safe-buffer').Buffer const BN = require('bn.js') // 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 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: new BN(1e3, 10), u: new BN(1e6, 10), n: new BN(1e9, 10), p: new BN(1e12, 10) } const MAX_MILLISATS = new BN('2100000000000000000', 10) const MILLISATS_PER_BTC = new BN(1e11, 10) 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 => wordsToBuffer(words, true).toString('hex'), // 256 bits 16: words => wordsToBuffer(words, true).toString('hex'), // 256 bits 13: words => wordsToBuffer(words, true).toString('utf8'), // string variable length 19: words => wordsToBuffer(words, true).toString('hex'), // 264 bits 23: words => wordsToBuffer(words, true).toString('hex'), // 256 bits 27: words => wordsToBuffer(words, true).toString('hex'), // 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) } function convert(data, inBits, outBits) { let value = 0 let bits = 0 const maxV = (1 << outBits) - 1 const result = [] for (let i = 0; i < data.length; ++i) { value = (value << inBits) | data[i] bits += inBits while (bits >= outBits) { bits -= outBits result.push((value >> bits) & maxV) } } if (bits > 0) { result.push((value << (outBits - bits)) & maxV) } return result } function wordsToBuffer(words, trim) { let buffer = Buffer.from(convert(words, 5, 8, true)) if (trim && (words.length * 5) % 8 !== 0) { buffer = buffer.slice(0, -1) } return buffer } // 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 = wordsToBuffer(words, true) while (routesBuffer.length > 0) { pubkey = routesBuffer.slice(0, 33).toString('hex') // 33 bytes shortChannelId = routesBuffer.slice(33, 41).toString('hex') // 8 bytes feeBaseMSats = parseInt(routesBuffer.slice(41, 45).toString('hex'), 16) // 4 bytes feeProportionalMillionths = parseInt( routesBuffer.slice(45, 49).toString('hex'), 16 ) // 4 bytes cltvExpiryDelta = parseInt(routesBuffer.slice(49, 51).toString('hex'), 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 = { word_length: words.length } FEATUREBIT_ORDER.forEach((featureName, index) => { featureBits[featureName] = { required: bools[index * 2], supported: bools[index * 2 + 1] } }) if (bools.length > FEATUREBIT_ORDER.length * 2) { 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 ) } } else { featureBits.extra_bits = { start_bit: FEATUREBIT_ORDER.length * 2, bits: [], has_required: 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 = new BN(value, 10) const millisatoshisBN = divisor ? valueBN.mul(MILLISATS_PER_BTC).div(DIVISORS[divisor]) : valueBN.mul(MILLISATS_PER_BTC) if ( (divisor === 'p' && !valueBN.mod(new BN(10, 10)).eq(new BN(0, 10))) || millisatoshisBN.gt(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 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: wordsToBuffer(sigWords, true).toString('hex') }) 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 }