mirror of
https://github.com/dergigi/boris.git
synced 2025-12-17 14:44:26 +01:00
- Add project structure with TypeScript, React, and Vite - Implement nostr authentication using browser extension (NIP-07) - Add NIP-51 compliant bookmark fetching and display - Create minimal UI with login and bookmark components - Integrate applesauce-core and applesauce-react libraries - Add responsive styling with dark/light mode support - Include comprehensive README with setup instructions This is a minimal MVP for a nostr bookmark client that allows users to view their bookmarks according to NIP-51 specification.
398 lines
10 KiB
JavaScript
398 lines
10 KiB
JavaScript
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
|
|
}
|