199 lines
7.6 KiB
JavaScript
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()
|
|
}
|
|
}
|