stacker.news/wallets/wrap.js

199 lines
7.6 KiB
JavaScript

import { createHodlInvoice, parsePaymentRequest } from 'ln-service'
import { estimateRouteFee, getBlockHeight } from '../api/lnd'
import { toBigInt, toPositiveBigInt, toPositiveNumber } from '@/lib/validate'
const MIN_OUTGOING_MSATS = BigInt(900) // the minimum msats we'll allow for the outgoing invoice
const MAX_OUTGOING_MSATS = BigInt(900_000_000) // the maximum msats we'll allow for the outgoing invoice
const MAX_EXPIRATION_INCOMING_MSECS = 900_000 // the maximum expiration time we'll allow for the incoming invoice
const INCOMING_EXPIRATION_BUFFER_MSECS = 300_000 // the buffer enforce for the incoming invoice expiration
const MAX_OUTGOING_CLTV_DELTA = 500 // the maximum cltv delta we'll allow for the outgoing invoice
export const MIN_SETTLEMENT_CLTV_DELTA = 80 // the minimum blocks we'll leave for settling the incoming invoice
const FEE_ESTIMATE_TIMEOUT_SECS = 5 // the timeout for the fee estimate request
const MAX_FEE_ESTIMATE_PERCENT = 3n // the maximum fee relative to outgoing we'll allow for the fee estimate
/*
The wrapInvoice function is used to wrap an outgoing invoice with the necessary parameters for an incoming hold invoice.
@param args {object} {
bolt11: {string} the bolt11 invoice to wrap
feePercent: {bigint} the fee percent to use for the incoming invoice
}
@param options {object} {
msats: {bigint} the amount in msats to use for the incoming invoice
description: {string} the description to use for the incoming invoice
descriptionHash: {string} the description hash to use for the incoming invoice
}
@returns {
invoice: the wrapped incoming invoice,
maxFee: number
}
*/
export default async function wrapInvoice ({ bolt11, feePercent }, { msats, description, descriptionHash }, { me, lnd }) {
try {
console.group('wrapInvoice', description)
// create a new object to hold the wrapped invoice values
const wrapped = {}
let outgoingMsat
// decode the invoice
const inv = await parsePaymentRequest({ request: bolt11 })
if (!inv) {
throw new Error('Unable to decode invoice')
}
console.log('invoice', inv.id, inv.mtokens, inv.expires_at, inv.cltv_delta, inv.destination)
// validate fee percent
if (feePercent) {
// assert the fee percent is in the range 0-100
feePercent = toBigInt(feePercent, 0n, 100n)
} else {
throw new Error('Fee percent is missing')
}
// validate outgoing amount
if (inv.mtokens) {
outgoingMsat = toPositiveBigInt(inv.mtokens)
if (outgoingMsat < MIN_OUTGOING_MSATS) {
throw new Error(`Invoice amount is too low: ${outgoingMsat}`)
}
if (outgoingMsat > MAX_OUTGOING_MSATS) {
throw new Error(`Invoice amount is too high: ${outgoingMsat}`)
}
} else {
throw new Error('Outgoing invoice is missing amount')
}
// validate incoming amount
if (msats) {
msats = toPositiveBigInt(msats)
// outgoing amount should be smaller than the incoming amount
// by a factor of exactly 100n / (100n - feePercent)
const incomingMsats = outgoingMsat * 100n / (100n - feePercent)
if (incomingMsats > msats) {
throw new Error('Sybil fee is too low')
}
} else {
throw new Error('Incoming invoice amount is missing')
}
// validate features
if (inv.features) {
for (const f of inv.features) {
switch (Number(f.bit)) {
// supported features
case 8: // variable length routing onion
case 9:
case 14: // payment secret
case 15:
case 16: // basic multi-part payment
case 17:
case 25: // blinded paths
case 48: // TLV payment data
case 49:
case 149: // trampoline routing
case 151: // electrum trampoline routing
case 262:
case 263: // blinded paths
break
default:
throw new Error(`Unsupported feature bit: ${f.bit}`)
}
}
} else {
throw new Error('Invoice features are missing')
}
// validate the payment hash
if (inv.id) {
wrapped.id = inv.id
} else {
throw new Error('Invoice hash is missing')
}
// validate the description
if (description && descriptionHash) {
throw new Error('Only one of description or descriptionHash is allowed')
} else if (description) {
// use our wrapped description
wrapped.description = description
} else if (descriptionHash) {
// use our wrapped description hash
wrapped.description_hash = descriptionHash
} else if (inv.description_hash) {
// use the invoice description hash
wrapped.description_hash = inv.description_hash
} else {
// use the invoice description
wrapped.description = inv.description
}
if (me?.hideInvoiceDesc) {
wrapped.description = undefined
wrapped.description_hash = undefined
}
// validate the expiration
if (new Date(inv.expires_at) < new Date(Date.now() + INCOMING_EXPIRATION_BUFFER_MSECS)) {
throw new Error('Invoice expiration is too soon')
} else if (new Date(inv.expires_at) > new Date(Date.now() + MAX_EXPIRATION_INCOMING_MSECS)) {
// trim the expiration to the maximum allowed with a buffer
wrapped.expires_at = new Date(Date.now() + MAX_EXPIRATION_INCOMING_MSECS - INCOMING_EXPIRATION_BUFFER_MSECS)
} else {
// give the existing expiration a buffer
wrapped.expires_at = new Date(new Date(inv.expires_at).getTime() - INCOMING_EXPIRATION_BUFFER_MSECS)
}
// get routing estimates
const { routingFeeMsat, timeLockDelay } =
await estimateRouteFee({
lnd,
destination: inv.destination,
mtokens: inv.mtokens,
request: bolt11,
timeout: FEE_ESTIMATE_TIMEOUT_SECS
})
const blockHeight = await getBlockHeight({ lnd })
/*
we want the incoming invoice to have MIN_SETTLEMENT_CLTV_DELTA higher final cltv delta than
the expected ctlv_delta of the outgoing invoice's entire route
timeLockDelay is the absolute height the outgoing route is estimated to expire in the worst case.
It excludes the final hop's cltv_delta, so we add it. We subtract the blockheight,
then add on how many blocks we want to reserve to settle the incoming payment,
assuming the outgoing payment settles at the worst case (ie largest) height.
*/
wrapped.cltv_delta = toPositiveNumber(
toPositiveNumber(timeLockDelay) + toPositiveNumber(inv.cltv_delta) -
toPositiveNumber(blockHeight) + MIN_SETTLEMENT_CLTV_DELTA)
console.log('routingFeeMsat', routingFeeMsat, 'timeLockDelay', timeLockDelay, 'blockHeight', blockHeight)
// validate the cltv delta
if (wrapped.cltv_delta > MAX_OUTGOING_CLTV_DELTA) {
throw new Error('Estimated outgoing cltv delta is too high: ' + wrapped.cltv_delta)
} else if (wrapped.cltv_delta < MIN_SETTLEMENT_CLTV_DELTA + toPositiveNumber(inv.cltv_delta)) {
throw new Error('Estimated outgoing cltv delta is too low: ' + wrapped.cltv_delta)
}
// validate the fee budget
const minEstFees = toPositiveNumber(routingFeeMsat)
const outgoingMaxFeeMsat = Math.ceil(toPositiveNumber(msats * MAX_FEE_ESTIMATE_PERCENT) / 100)
if (minEstFees > outgoingMaxFeeMsat) {
throw new Error('Estimated fees are too high')
}
// calculate the incoming invoice amount, without fees
wrapped.mtokens = String(msats)
console.log('outgoingMaxFeeMsat', outgoingMaxFeeMsat, 'wrapped', wrapped)
return {
invoice: await createHodlInvoice({ lnd, ...wrapped }),
maxFee: outgoingMaxFeeMsat
}
} finally {
console.groupEnd()
}
}